Effects & Contracts
In Hale, functions and loci can declare effect contracts specifying which side effects, resource allocations, or bus publications they are permitted to perform. The compiler verifies these contracts transitively across the entire closed call and bus graph—through helper functions, locus handle calls, imported library seeds, and the standard library—failing the build with a full call-chain witness if any path violates the contract.
@no_syscall @deterministicfn price(book: OrderBook, qty: Int) -> Decimal { ... }Effect contracts are opt-in and have zero runtime cost. The only implicit check applies to loci placed on an async_io coroutine pool, where the compiler warns if a handler reaches a thread-blocking operation.
Reference Tables
Section titled “Reference Tables”Built-in Effect Classes
Section titled “Built-in Effect Classes”Every built-in effect class name is reserved by the compiler (redeclaring effect syscall; or effect secret_use; is a compile error). Run hale doc --stdlib to inspect the exact effect classes of every standard library function.
| Effect Class | What It Covers | Representative Operations |
|---|---|---|
syscall |
Kernel boundary crossings: filesystem, sockets, child processes, terminal, and stdio (including println). |
std::io::fs::read_file, println |
stdio |
Writing to standard output or standard error. stdio is a sub-class of syscall (every stdio operation also carries syscall). |
println, std::io::stdout::write_bytes |
block |
Operations that wait or block execution (see async_io pool parking rules for coroutine exceptions). |
std::http::get, std::time::sleep |
time |
Reading a wall or monotonic clock. Pure transformations on passed-in Time values do not carry time. |
std::time::monotonic_ns, std::time::current |
entropy |
Reading random numbers or entropy sources. | std::rand::next_int |
env |
Reading environment variables or command-line arguments (argv). |
std::env::var |
ffi |
Calling an external C function declared with @ffi("c"). |
Any @ffi("c") function |
publish |
Publishing a message to a bus topic or subject. | Orders <- o |
spawn |
Instantiating a locus. | Worker { } |
recursion |
A direct or mutual cycle in the call graph. | Any recursive function chain |
alloc |
Dynamic allocation in the locus arena (strings, non-elided records, collection growth). | "a" + "b", Buf { n: 1 } |
secret_use |
Compiler-owned class on privileged operations over confined secret material (Verification). | std::secret::Signer.sign |
client(S) |
Calling an external API surface S bound via api::client (client(*) matches all consumed surfaces). |
self.shelf.get_item(req) |
Effect & Contract Decorators
Section titled “Effect & Contract Decorators”| Decorator | Target | Meaning |
|---|---|---|
@effects(none: {c1, c2}) |
fn |
Forbids reaching any listed effect class directly or transitively. |
@effects(only: {c1, c2}) |
fn |
Restricts transitive effects to a subset of the listed classes; forbids all other built-in and user-declared classes. |
@effects(is: {u1, u2}) |
fn |
Classifies the function as a direct source of user-declared effect classes u1, u2. |
@effects(publish: {T1, T2}) |
fn |
Restricts bus publications reachable from the function to the exact listed topics. |
@effects(causes: {c1, c2}) |
fn |
Declares the complete set of effects transitively triggered across the bus by messages published from this function. |
@effects(depends: {T1, T2}) |
locus |
Declares the complete set of bus topics or wire subjects that can transitively reach any handler on the locus. |
@no_syscall |
fn |
Shorthand for @effects(none: {syscall}). |
@no_block |
fn |
Shorthand for @effects(none: {block}). |
@no_ffi |
fn |
Shorthand for @effects(none: {ffi}). |
@no_publish |
fn |
Shorthand for @effects(none: {publish}). |
@no_spawn |
fn |
Shorthand for @effects(none: {spawn}). |
@no_recursion |
fn |
Shorthand for @effects(none: {recursion}). |
@deterministic |
fn |
Shorthand for @effects(none: {time, entropy, env}). |
@no_panic |
fn |
Forbids unhandled failures (violate, or raise, or trapping array indices). |
@budget(dim = N, ...) |
fn |
Bounds per-call resource dimensions (alloc_per_call, stack_bytes, block_points, publish, fanout, or <user_class>). |
@phase_effects(phase: {...}) |
locus |
Restricts each named lifecycle hook (birth, run, etc.) or method to the exact listed effect classes. |
@hot |
fn |
Enforces zero arena allocations and hot-path speed rules on the function. |
@unbounded / @bounded_by(x) |
fn, hook, mode |
Acknowledges intentional arena accumulation and silences the unbounded-allocation advisory. |
Function Effect Contracts (@effects)
Section titled “Function Effect Contracts (@effects)”Forbidding Effects (none: and Shorthands)
Section titled “Forbidding Effects (none: and Shorthands)”Use @effects(none: {...}) or its shorthand decorators to forbid specific effect classes across everything reachable from a function:
@effects(none: {syscall, block}) fn decode(b: Bytes) -> Msg { ... }@effects(none: {time}) fn backoff(n: Int) -> Int { ... }@effects(publish: {OrderFill}) fn route(o: Order) { ... }Decorators can be stacked in any order to form a composite hot-path contract:
@no_block @no_syscall @deterministic @no_recursion @hot@budget(alloc_per_call = 0)fn on_tick(a: Int, b: Int) -> Int { ... }When an assertion fails, hale check prints the complete call chain from the annotated function to the offending leaf operation, along with the source location in your code or the standard library:
type error: effect assertion violated: `quiet` must not reach `syscall`,but reaches quiet -> Reader::slurp [std::io::fs::file_size — asyscall-class operation (filesystem, socket, process, terminal, stdio)].Move the effect behind a locus this fn doesn't reach (thereader/writer-locus shape), or pass the value in as a parameter. note: the `alloc` effect happens here (in the standard library, io_tcp.hl:98:18)Key verification rules:
printlnis asyscall(andstdio): Writing to stdout/stderr invokeswrite(2)and can block. Debugprintlncalls inside a@no_syscallfunction are rejected as contract violations. To forbid terminal output while still permitting file or socket I/O, use@effects(none: {stdio}).- Handle calls are followed: Calling
reader.slurp()resolvesreader’s locus type and walks intoReader::slurp. Moving an effect into a child locus that the annotated function still calls directly does not hide the effect; instead, decouple via bus publication or pass precomputed values as arguments. - Pure operations vs. effectful sources:
@deterministic(none: {time, entropy, env}) forbids reading nondeterministic sources, not computing over passed-in values:
Pure (Allowed in @deterministic) |
Effectful (Rejected in @deterministic) |
|---|---|
std::time::time_from_unix(n) |
std::time::monotonic_ns() (time) |
std::time::iso8601(t), parse_time(s) |
std::time::current() (time) |
std::str::parse_int(s) |
std::env::var(k) (env) |
std::http::parse_request(b) |
std::http::get(u) (syscall, block) |
- Fail-closed on unknowns: Any unclassified or unresolved call is treated as potentially performing all effects and fails the assertion.
Closed-World Allowlists (only:)
Section titled “Closed-World Allowlists (only:)”While none: forbids specific classes and permits everything else, @effects(only: {...}) requires the function’s transitive effects to be a subset of the listed classes:
@effects(only: { alloc })fn label(n: Int) -> String { return "a" + "b";}The compiler computes the forbidden complement against the entire closed universe of built-in and user-declared atomic effect classes at check time. If a new user effect class is added to the program later, existing only: contracts automatically forbid it without requiring annotation updates:
effect money;
@effects(is: { money })fn charge(cents: Int) -> Int { return cents; }
@effects(only: { alloc })fn quote(n: Int) -> Int { return charge(n); } // error: reaches `money`
fn main() { println(quote(1)); }Decorator Stacking and Contradiction Rules
Section titled “Decorator Stacking and Contradiction Rules”Function decorators are order-independent, including memory advisories like @unbounded and @bounded_by(<name>):
@unbounded @no_syscall fn absorb(log: Lines, line: String) { ... }@no_syscall @unbounded fn absorb(log: Lines, line: String) { ... }The compiler rejects contradictory or duplicate decorator combinations at the declaration site:
- Duplicate decorators: Writing the same decorator twice on one declaration is an error.
@unboundedvs.@bounded_by(<name>): Mutually exclusive; use@bounded_by(<param>)when growth is bounded by a named input, or@unboundedwhen growth has no static bound.- Zero-allocation contradictions: Neither
@unboundednor@bounded_bycan be paired with@hot,@effects(none: {alloc}), or@budget(alloc_per_call = 0). However, pairing@unboundedwith a non-zero per-call ceiling (such as@budget(alloc_per_call = 2)) is valid. - Lifecycle hooks and modes: Lifecycle hooks (
birth,run,dissolve, etc.) andmodeblocks (@unbounded mode bulk() { ... }) execute once per phase rather than per call, so they accept only@unboundedor@bounded_by(<name>). Place per-call@effectsand@budgetcontracts on helperfnmembers invoked by the hook.
Forbidding Unhandled Failures (@no_panic)
Section titled “Forbidding Unhandled Failures (@no_panic)”@no_panic verifies that no code path in the function can trap or propagate an unhandled failure:
@no_panic fn parse_frame(b: Bytes) -> Frame { return decode(b) or Frame { }; // handled fallback — valid}- Rejected inside
@no_panic: Explicitviolatestatements,or raiseerror propagation, and unguarded trapping array/slice indexing. - Allowed inside
@no_panic: Fallible calls handled viaor discard,or <fallback_value>, oror handler(err).
Quantitative Budgets (@budget)
Section titled “Quantitative Budgets (@budget)”@budget enforces compile-time upper bounds on per-call resource consumption across a function’s transitive call tree:
@budget(alloc_per_call = 0, stack_bytes = 4096, block_points = 0)fn on_frame(x: Int) -> Int { ... }
@budget(publish = 1) fn reply(o: Order) { ... } // at most 1 bus publish per call@budget(fanout = 8) fn notify(e: Ev) { ... } // at most 8 transitive bus deliveries| Budget Dimension | What It Counts | Unbounded Conditions (Always Violates) |
|---|---|---|
alloc_per_call = N |
Arena allocations along the heaviest execution path per call. | Allocation inside a loop or recursion. |
stack_bytes = N |
Deepest call-chain stack frame total (sibling calls take max, not sum). |
Any recursion in the reachable call graph (pair with @no_recursion). |
block_points = N |
Blocking operations along the call tree. | Blocking call inside a loop or recursion. |
publish = N |
Direct and transitive bus publish statements per call. | Publish inside a loop or recursion. |
fanout = N |
Transitive runtime bus subscriber deliveries triggered by a call. | Computed publish subject, subscriber born dynamically outside main, or topic bound to an external transport. |
<user_class> = N |
Calls to functions carrying @effects(is: {<user_class>}). |
Carrier inside a loop, recursion, or unresolved indirect call. |
How fanout Is Calculated
Section titled “How fanout Is Calculated”Unlike publish (which counts Topic <- msg statements), fanout measures downstream delivery amplification:
- Runtime registrations, not source lines: A single
subscribedeclaration on a locus placed withreplicas = 3counts as 3 deliveries. - Transitive across republishers: If a subscriber handler republishes to another topic with 3 subscribers, a publish to the first topic has a
fanoutof1 + 3 = 4. - Key-aware routing: A publish to a
keyed_bytopic only counts subscriptions whosewhere key == ...filter matches the message’s key. For example, publishing one keyed message towhere key == replicaacrossreplicas = 4costs1delivery, not4.
Lifecycle Phase Contracts (@phase_effects)
Section titled “Lifecycle Phase Contracts (@phase_effects)”Attach @phase_effects to a locus to restrict which effect classes each lifecycle hook (birth, accept, release, run, drain, dissolve) or named handler/method may perform:
@phase_effects(birth: {alloc}, run: {})locus Engine { ... }
@phase_effects(on_order: {publish, alloc})locus Router { ... }| Phase Specification | Meaning |
|---|---|
run: {} |
run may perform no effects at all (neither built-in nor user-declared). |
run: {alloc} |
run may only perform alloc; all other built-in and user-declared atomic effects are forbidden. |
| (phase omitted) | The omitted phase is unconstrained. |
Implicit async_io Placement Checks
Section titled “Implicit async_io Placement Checks”Loci placed on a shared cooperative(pool = <name>) where async_io pool share a single OS worker thread across coroutines. Without requiring any decorator, the compiler warns if a handler on an async_io pool reaches a call that blocks the underlying OS thread:
`Worker::on_e` is placed on the async_io pool `web`, whose single workerit shares — but it reaches Worker::on_e -> slurp [std::io::stdin::read_line].A blocking call here stalls every other locus on `web` until it returns.Adding @no_block to the handler upgrades this check from a warning to a build error. The placement check inspects the pools spawned by the active main locus (placement blocks in imported libraries or unspawned modules are inert).
Coroutine-Parking Calls vs. Thread-Blocking Calls
Section titled “Coroutine-Parking Calls vs. Thread-Blocking Calls”On an async_io pool, operations that suspend the current coroutine and yield the worker thread to other coroutines do not stall the pool and do not trigger the placement warning:
Coroutine-Parking (No async_io Warning) |
Thread-Blocking (Triggers async_io Warning) |
|---|---|
std::time::sleep |
std::io::tcp::connect, std::io::tls::{connect, upgrade} |
std::io::tcp::{accept_one, recv_into, recv_stamped_into}, __recv, __recv_bytes |
std::http::* client calls |
std::io::udp::{recv, recv_with_source, recv_into} |
std::io::fs::* file I/O, std::io::stdin::* |
std::io::tls::{recv_into, recv_stamped_into} |
std::process::{run, wait, read_stdout, read_stderr} |
Note that std::time::sleep still carries the block effect class in the call graph because it blocks an OS thread when called on a classic thread pool. Consequently, an explicit @no_block annotation on a function will still reject std::time::sleep.
Bus-Graph Effect Contracts (causes: and depends:)
Section titled “Bus-Graph Effect Contracts (causes: and depends:)”Because Hale’s bus topology is closed and statically declared, effect contracts can traverse message publications and subscriptions across the bus.
Downstream Causal Contracts (@effects(causes: {...}))
Section titled “Downstream Causal Contracts (@effects(causes: {...}))”While none: and only: inspect the direct call graph of a function, @effects(causes: {...}) declares the complete set of effects triggered transitively by any bus messages published from the function:
@effects(causes: {publish, syscall})fn handle(o: Order) { Orders <- o; }If a subscriber to Orders (or a further downstream republisher) performs an effect not listed in causes:, compilation fails with the bus path:
type error: declared causal set violated: `Api::handle` can transitivelycause syscall through the bus, which its `@effects(causes: …)` does notdeclare. Path: `Api::handle` -> subject `Orders` -> `Audit::on_order`.Add the class to the declaration, or route the publish to a subjectwhose subscribers don't perform it.Upstream Dependency Contracts (@effects(depends: {...}))
Section titled “Upstream Dependency Contracts (@effects(depends: {...}))”Placed on a locus, @effects(depends: {...}) declares the complete set of bus topics (or wire subject strings) that can transitively reach any of the locus’s subscription handlers:
@effects(depends: {SumLookup, Recalled})locus StatedCarry { bus { subscribe SumLookup as on_sum; }}If an upstream locus subscribes to Recalled and republishes onto SumLookup, StatedCarry transitively depends on Recalled. Omitting Recalled from depends: fails the build with the upstream laundering path:
type error: declared dependency set violated: `StatedCarry` cantransitively depend on `Recalled` through the bus, which its`@effects(depends: …)` does not declare. Path: subject `Recalled` ->`Launderer` -> subject `SumLookup` -> `StatedCarry`.You can list either topic identifiers (Recalled) or wire subject literals ("recalled"); the compiler resolves both to the same underlying bus identity.
User-Declared, Composed, and Domain-Indexed Effects
Section titled “User-Declared, Composed, and Domain-Indexed Effects”Declaring Custom Effect Classes (effect and is:)
Section titled “Declaring Custom Effect Classes (effect and is:)”Declare custom effect classes with effect <name>; and mark source functions with @effects(is: {<name>}):
effect money;
@effects(is: {money})fn charge(cents: Int) -> Bool { return cents > 0; }
@effects(none: {money})fn quote(cents: Int) -> Int { return cents * 2; }
fn main() { println(quote(100)); }Custom effect classes propagate through the call graph, across seed imports, and over the bus (causes:), and work inside @effects(none:/only:), @phase_effects, @budget(<class> = N), and architectural claims (forbid reaches(G, effects(money)), bound money <= N). Up to 54 user-defined atomic effect classes can exist in a compiled program (exceeding the 64-bit effect mask capacity is rejected at the effect declaration).
Composed Effect Classes (effect name = {...})
Section titled “Composed Effect Classes (effect name = {...})”Define an effect class as the union of existing built-in or user-declared classes:
effect io = { syscall, block };effect wallclock = { time };A composed class owns no separate bit in the effect mask; its mask is the bitwise union of its members (syscall | block). This has two consequences:
- Forbidding
ioforbids bothsyscallandblock, and any function performingsyscallorblockcarriesio. - Defining
effect wallclock = { time };includes the built-intimebit inwallclock, meaning@deterministic(none: {time, entropy, env}) automatically rejects functions tagged@effects(is: {wallclock}).
Cyclic definitions (effect a = { b }; effect b = { a };) are rejected at compile time.
Domain-Indexed Effect Families (domain and effect name(domain))
Section titled “Domain-Indexed Effect Families (domain and effect name(domain))”To track effects parameterized over a finite set of partitions or tenants, declare a domain followed by an indexed effect family in the same file:
domain wing = { delta, gamma };
effect knowledge(wing);
@effects(is: {knowledge(delta)})fn read_delta(key: String) -> Idea { ... }This expands eagerly into atomic classes knowledge(delta) and knowledge(gamma), plus the wildcard union class knowledge(*). Misspelled indices (knowledge(delt)) are rejected with a did-you-mean diagnostic. See Claims & the Law for using domain-indexed effects in isolation claims.
Tracking Effect Drift in CI (.hale.effects)
Section titled “Tracking Effect Drift in CI (.hale.effects)”Even on unannotated code, the compiler infers the transitive effect set (does={...}) of every free function, locus method, and lifecycle hook across the program and its imported seeds.
Export the inferred effects manifest and commit it to version control:
hale check app.hl --dump-effects-manifest > .hale.effects# .hale.effects v1 — declared effect contractsApp::run does={syscall,block,time}Pusher::run does={syscall,block,publish,time,alloc}In CI, gate against unintended effect drift with --check-effects-manifest:
hale check app.hl --check-effects-manifest .hale.effectseffect manifest changed — .hale.effects no longer matches the program's effects. - Api::emit none={block} does={publish,alloc} + Api::emit none={block} does={syscall,publish,alloc}If a helper function several calls deep introduces a syscall, --check-effects-manifest flags the exact callers whose transitive does={...} set widened, even if those callers had no @effects annotation of their own.