6. Effects
Effects are typed markers, checked statically. They are not algebraic: there is no handle or with, and io.print! cannot be intercepted at runtime.
Built-in effects:
| Effect | Meaning |
|---|---|
[io] | standard input/output |
[net] | network access |
[fs_read] | reading the filesystem |
[fs_write] | writing the filesystem |
[db] | embedded database access (see stdlib/extra/sqlite.hk); covers :memory: databases too |
[process] | subprocess spawn (see stdlib/extra/process.hk) |
[time] | reading the clock / sleeping |
[env] | environment-variable reads |
[random] | random-number generation |
[throws E] | may throw an error of type E |
[state] | inside an actor handler; reads/writes actor state |
[local] | mutation of state the computation owns; implicit in every action's row, writable only on a function type (see Non-substitutability in the row) |
[runtime_state] | observing process-global state the program does not own; on log.min_level! / log.set_min_level! / log.enabled! and viral through their callers |
Multiple effects: [io, throws ParseError].
A row is a set. [io, db] and [db, io] are one type, the order you write means nothing, and hanki fmt canonicalizes a row the way it canonicalizes an import block. The bare tags come first, sorted alphabetically and case-insensitively; user-defined effects are capitalized, and a byte ordering would cluster every one of them ahead of the built-ins. Then Crash, then each throws E, then the effect variable (below). Those last three are what a reader scans a row for, can this diverge, must I handle a throw, is the row still open, and they take the tail in place of sorting in among the tags. [io, fs_read, Crash] prints as [fs_read, io, Crash], and [e, net] as [net, e].
[fs] is an alias for [fs_read, fs_write], sugar you may write in an effect row or an --allow/--deny atom, expanded to the two base atoms wherever effect names are interned. --allow fs_read grants a read-only filesystem and refuses a writer, and [fs] on an action that both reads and writes declares both.
User-defined effect groups:
struct Rows
affected: i32
end
effect Database
def query(sql: string) -> Rows
def exec(sql: string) -> ()
end
The built-in names above are reserved. An effect block may not take one of them, the nine capability atoms, the [fs] alias, state, local, runtime_state or Crash. Effect names are one flat vocabulary: a same-named user effect would merge with the builtin in place of shadowing it, and --allow io would then grant both with no way to say which was meant (H0632).
The ops inside an effect block are declared as def, with no ! and no body; they are signatures. At a call site they implicitly charge the [Name] effect, and the caller must declare it:
def run!() -> () [Database]
exec("DELETE FROM things") # legal: caller has [Database]
end
An op is bare-callable only within its declaring module, or where that module is opened. Like every other top-level name it sits at its module-qualified name (§14) and is never a bare global that could collide with or shadow a same-named identifier in a file that did not open it, which matters where the stdlib is merged into every program. An op is never reachable as module.op: that spelling would make an op call read like an ordinary action call while hiding the atom it charges. Its qualified spelling goes through the effect's name:
Database.exec("DELETE FROM things") # effect name in scope (declaring module, or `open db`)
db.Database.exec("DELETE FROM things") # under `use db`
The call form therefore spells the atom it charges, in call and value position alike, and f = db.Database.exec stores the op as readily as f = exec does inside its own module. The two spellings are the same reference as the bare one, with identical row charging, purity wall, provider binding and behaviour on both tiers. Only the spelling differs, and db.exec(…) remains an error naming both forms that work. Within its own module an op call is bare (exec(…) above), as is the matching provide op name. A use is kept live by the qualified spelling like any other qualified reference. An op whose name starts with _ remains private to its module through it (§12). Where a module declares an effect Foo beside a type Foo, Foo.member searches both namespaces, and a member both supply is an error and no silent pick (H0633, the §14 composition rule).
An effect block gives its ops signatures and no behavior. A provide block binds the implementations:
provide Database [net]
def query(sql: string) -> Rows
Rows(affected=0i32) # ordinary action body; its budget is [net]
end
def exec(sql: string) -> ()
()
end
end
- The head row (
[net]above) is the shared effect budget of every op body in the block; ops have no rows of their own. It must be concrete.throws Eis rejected, since a call site charges only[Database]and a thrown error could never be caught with a type; declare fallible ops as returningResult. An effect variable is rejected too. - The block must implement every op the effect declares, each one once, with a signature equal to the declaration's.
- One provider per used effect. A program that references an effect's ops, calling one or reading one as a value, must contain one
provideblock for that effect. A missing (H0610) or duplicate (H0611) provider is a compile error, like a missingmain. An effect that is declared and never referenced needs no provider. A library may declareeffect Databaseand call its ops and leave the application to supplyprovide db.Database [net] … end; the head name takes a module qualifier like any reference. That promise is what an exports-only root gives you. A root the manifest lists underexportsand not underprogramsis checked with its unprovided user effects treated as its declared capability interface and no completeness hole, reachability being per root (§21) and the library-alone workspace having no application in it to hold theprovide. A path in both lists is run, and it goes on answering for its own effects as every program root does; an application that forgets theprovidestill getsH0610at its own root, which is where the question is answerable. A library's own tests get real coverage the same way its consumers do: a test module that imports a fake provider is a root of its own. - Dispatch. On the bytecode tier an op call is an indexed call through a per-module handler table, the seam an embedding host overrides before evaluation (§16). The AOT tier devirtualizes the same call to a direct call, the table being workspace-constant. This is an optimisation and no semantic fork. An op stored as a value (
f = exec) binds its provider at lowering time on both tiers, the per-call indirection belonging to direct call sites. - Capability accounting. The provide head's row is the transitive truth behind the marker:
[Database]⊨[net]for this program.--allowand--deny, and the package effect manifest, count both the effect name and its provider's row, and--deny netrefuses a program whoseDatabaseprovider does[net]work even where no caller spellsnet. - A
.config.hkfile may not contain aprovideblock. In embedded evaluation the host injects capabilities.
Rifts: answering an effect with Rust
A provide block answers an effect with Hanki. An application may answer one with native code: a Rust crate in its own repository, bound by its manifest, reached through the effect's ops the way a Hanki provider is. That opening is a rift, and a program opens one. It is how a program reaches a library the stdlib does not wrap, such as Steamworks, a physics engine, an audio backend or a codec, with no feature request per library.
Only an application root may open one, never a package at any depth (§17, invariant 1). A dependency that tries is refused at resolution (H0641). A rift is therefore first-party: it sits in the repository of the program it serves, written by the same author, and no dependency can introduce one, ask for one, or tell whether one is present.
The effect declaration is the whole FFI signature: the same block a provide would satisfy, with no extern and no pointer type added to the language:
# audio.hk - the interface, ordinary Hanki
struct Clip
id: i64
gain: f64
end
effect Audio
def play(clip: Clip) -> Result<(), AudioError>
def stop() -> ()
end
hanki rift new audio.Audio native/audio scaffolds the crate and writes the api crate it compiles against. The author implements one generated trait with ordinary Rust and exports pub fn open() -> impl Audio. The manifest binds it (§21), and hanki run and hanki build regenerate and build it: loaded beside the program on the bytecode tier, linked into the binary by hanki build, which also links whatever the crate's own build.rs asks for. The Rust author writes no unsafe, sees no Hanki heap, and cannot call back into Hanki.
- A bound effect counts as provided. The program needs no
provideblock for it, andH0610does not fire. - Precedence. A
provideblock for a bound effect in a file of the root project wins for the root whose workspace contains it, which lets a test root import a fake (provide audio.Audio [] … end) while the program root reaches the rift. A test root is an entry in the manifest'sexports, which is what makes a file a root of its own. The layout is three files:fake.hkwith theprovide,fake_test.hkimporting it with the tests beside them, andexports = ["fake_test.hk"]naming the second. That root needs neither the crate built nor adeterministic = trueclaim on the binding,--deterministicfiltering to the bindings live for the root being run.examples/v0_1/native_riftis the worked layout. Two Hankiprovides for one effect are stillH0611, and aprovidefor a bound effect in a dependency is refused (H0642): a package must not be able to shadow the application's native code with a Hanki body of its own. - Values cross as bytes, with message-send semantics. The runtime encodes an op's arguments out of its heap, the generated shim decodes them into owned Rust values, and the result travels back the same way. Neither side ever takes a pointer into the other's memory, and refcounts, ownership and the per-actor model apply as they do across a mailbox. A call copies its arguments and its result, which suits a chunky call and does not suit per-entity per-frame work.
- The signatures are restricted, transitively, to what has a plain Rust twin:
| Hanki | Rust | Notes |
|---|---|---|
() bool | () bool | |
i8…i64 / u8…u64 | the same width | |
f32 / f64 | f32 / f64 | IEEE bits; an f32 signalling NaN comes back quiet, since the runtime's float slot is an f64 |
string | String | UTF-8 validated on the way back |
bytes | Vec<u8> | |
Option<T> / Result<T, E> | the same | a fallible op is declared Result; ops have no throws |
List<T> | Vec<T> | |
Pair<A, B> | (A, B) | |
non-generic struct / sum type | pub struct / pub enum | fields and variants in declaration order |
Everything else is a compile error at the binding (H0639) naming the op, the parameter or return, and the type: int, decimal and rational (unbounded, and with in-band sentinels; declare a fixed width, or convert in an adaptor provide), Map (its trie is a private stdlib representation the runtime cannot enumerate; cross a List<Pair<K, V>>), resources, Future<T>, function types, ActorRef<T>, and generic user types. Every op, parameter, field, variant and type name must also have a Rust spelling of its own (H0643). A predicate's ? is dropped, a Rust keyword is raw-escaped, and self, Self, crate and super, or two names that collide once the ? goes, are refused, as is a user type whose name is one the generated crate already spells: Vec, Result, String, its own Wire or DecodeError, or the trait the effect's own name becomes.
- State is per actor. An actor opens its own instance on its first call into a rift, and it is dropped when that actor dies. A rift cannot become a shared back channel between actors the type system believes are isolated. A device that is per-program by nature, a window or an audio output, gets one owning actor that serves the rest by message, which is the language's own answer to a shared resource.
- A panic is an actor death, never an abort and never an unwind through Hanki frames. The shim catches it and the runtime raises
DeathCause::HostPanicnaming the rift, the op and the message, a supervisor hears it like any other death (§15), and the program continues. Arguments or a result that will not decode die the same way. A declared failure is no panic: an op that can fail returnsResult, which crosses as an ordinary value. - A stale artifact is refused and never mis-linked. Every built rift records the canonical text of the interface it was generated against. The runtime compares it with the declaration the program was checked against before any op runs, and refuses with the differing line and the command to rebuild.
- The capability gate treats it as opaque (§23). Wherever any
--allowor--denyis given, a program that can reach a rift op is refused unless--allownames that effect's atom. Aprovidehead row is a checkable claim about what a provider does, and native code has none.--deterministicrefuses a program that opens a rift unless the binding claimsdeterministic = true, an unverified claim by the author.--max-stepsand--max-bytescannot see inside native code: a rift call charges its decoded result's allocation and no more, like a core intrinsic. - v1 limits. Linux x86-64 only. No callbacks from Rust into Hanki; an op that must push polls instead, or the owning actor polls it. No record or replay of rift calls under
--deterministic, the refusal above being the answer, and none is planned: a rift-owning actor is tested by aprovidein a test root (§6), which answers the effect in Hanki and builds no crate.hanki check,fmt,doc,effectsand the LSP build and load nothing: onlyrun,test,replandbuildinvoke cargo, and only for the root's own manifest.
The design record is docs/design/native-rifts.md; the manifest surface is §21.
A function that performs whatever its callback performs is polymorphic in that callback's effects. Write the row as a single lowercase letter, an effect variable, in the callback's type, and again in the function's own row:
def apply_twice!(x: T, f: (T) -> () [e]) -> () [e]
f(x)
f(x)
end
e stands for whatever effects the callback performs. A single lowercase letter is an effect variable as a single uppercase letter is a type parameter (§11); built-in effects like io are multi-letter, and the two never collide. Invoking f in the body charges [e], which apply_twice! declares. At each call site e is solved to the concrete effects of the callback passed for f and folded into the caller's required set. Passing a closure that performs [io] charges the caller [io], and a pure callback charges nothing. A row may combine concrete effects with a variable, -> () [fs, e], meaning "I touch the filesystem, plus whatever the callback does". A variable may be forwarded: a higher-order action can pass its [e] callback on to another [e] action, and the row remains open until the outermost call site.
A bound may name a row too. A callback is not the only thing whose effects a signature can be polymorphic in. A trait method may declare its row as a variable, each impl answers it with a concrete row of its own, and a bounded generic names that row through its bound:
trait Stream
def read!(self, max: u32) -> bytes [e]
end
impl Stream<TcpStream>
def read!(self, max: u32) -> bytes [net]
# ...
end
impl Stream<MemStream>
def read!(self, max: u32) -> bytes
# ...
end
def read_head!<S: Stream[e]>(s: S, max_head: int) -> Result<Head, HttpError> [e]
# `s.read!(...)` charges `[e]`, which this signature declares
end
The variable is bound by the generic-param list and rigid inside the body, as a callback's is. A multi-bound parameter brackets only the bounds that name one (<S: Stream[e] + Display>), and two bounds get two variables (<S: Stream[e], T: Sink[r]> … [e, r]). At each call site the variable solves to the union of the impl's rows for every method the trait declares with a variable row, and never to the subset the body happens to call. A caller reading <S: Stream[e]> can therefore say what e is from the signature alone. read_head!(tcp) charges [net], and read_head!(mem) charges nothing beyond local. A generic that forwards its own still-generic S to another leaves the variable unsolved, and the outermost concrete instantiation closes it.
Bound effect rows resolve after argument and result type inference, including numeric literal defaults. When a row variable is shared by bounds and callbacks, each call requires the union of all selected implementation rows and callback rows. This also applies to saved function values and higher-order arguments; their callback rows are solved separately at each invocation. Row names are local to each signature, and forwarding preserves independent caller rows even when a callee uses the same letters. A parameterized bound selects its implementation with the complete type argument tuple after expanding trailing defaults.
Four rules fence it, each with its own diagnostic. A trait method's row is either concrete or one variable; [net, e] is refused (H0647), a mixed row stating a floor under the ceiling every other row states. An impl answers a variable row with a concrete row, a variable there having nothing to solve it (H0648). A bound may name a row only where the trait, or a trait above it, leaves one open, and the bracket takes one variable and no more (H0649). Calling a variable-row method through a bound that did not name the row is refused (H0650) in place of charging an atom the signature cannot name. Forwarding a generic argument to a callable whose bound names a row also requires that row on the enclosing bound (H0650), even when an unrelated callback happens to use the same letter. Under a concrete trait row nothing changes: an impl may perform fewer effects than the trait declares and never more (§10, H0617). Design: docs/design/trait-effect-rows.md.
A function type may take an effect row after its return type, as a def does: (T) -> () [io], (string) -> i32 [throws ParseError]. The row is the effects a value of that type performs when called. It matters wherever a function is stored or returned in place of invoked on the spot: a struct field, an annotated let, a function's return type.
struct Event
name: string
end
struct Handlers
on_event: (Event) -> () [io] # calling on_event performs [io]
end
- An unannotated function type means pure, an empty row, in every position: parameter, field,
let, return. A higher-order action that accepts an effectful callback must say so, with a variable row[e]to stay polymorphic or a concrete row[io]to fix the effect. Passing an effectful closure to a pure(T) -> ()parameter is a compile error, and so is storing or returning a closure that performs more than a concrete slot permits. A closure that performs less is accepted. - Invoking a stored function value charges its declared effects to the caller, as any other call does. Reading
h.on_eventintogand callingg(e)requires the caller to permit[io]. This closes the effect-erasure hole where a stored effectful closure could be invoked from a pure or under-declared context. - A trailing
[…]binds to the innermost->on its left. Parenthesise to detach:((T) -> ()) [io]against(T) -> () [io].
A variable row is no concrete contract. A value whose type has an effect variable may be invoked in place or forwarded to another polymorphic parameter. It cannot be stored in a struct field, returned through a concrete return type, or bound to an annotated let; those slots demand a fixed row. Storing one is a compile error: a polymorphic callback can perform anything, and a later invocation from a pure or under-declared context would launder its effects. Use a concrete row ([io]) to store a real callback, and the variable to call or forward it.
The package-manifest wire format encodes these rows on function-typed components of an exported signature, and the load-time compatibility check (module.load!) compares them. A callback slot's effects are therefore enforced across a shared-module boundary. Source-level cross-package import reconstructs an imported signature by re-parsing and re-checking the dependency's cached source. hanki build resolves each declared universe dep into the content-addressed cache and binds use <alias> to that package's cached <alias>.hk module, and callers are checked against the dependency's real definitions; a fetched universe dep is inert source with no .hanki_manifest. Resolution is transitive: a resolved dependency's own manifest resolves too, and its own use binds its declared deps. Each package sees only its own manifest's aliases and never the consuming project's, and a dependency's non-sibling use of an undeclared name remains an unresolved-import error in place of a silent reach into the project's deps. One version per source applies graph-wide (§21). Known limitation (tracked): as elsewhere in the manifest, a throws E payload is compared by name only (throws) and not by error type.
Encapsulated purity: @encapsulated
! has two distinct meanings. A world effect, a non-empty row over io/fs/net/db/process/time/env/random/throws E/Crash, is a real observable interaction. An action with an empty effect row takes its ! for the other reason: it is not substitutable. BytesBuilder.push! mutates a builder, and a BytesReader read advances a cursor (§4). Neither is a world effect, two calls still differ, and the operation takes the !. A pure def cannot call any !, and this second class infects pure contexts (meta, where-invariants, content-hashing) even for code that is externally pure: a local builder created, written and finalized inside a function never escapes, and the function is a pure function of its inputs.
@encapsulated is a def-level attribute marking a def, with no ! and externally pure, that may call empty-effect-row actions internally. The checker proves external purity from three rules and trusts nothing:
- Empty-effect-row actions only. The body may call
!-actions whose row is empty. Any call to an action with a non-empty row, or anythrow, is a compile error (H0607). The encapsulated def's own row is therefore empty: it performs no side effect, throw or crash. World-effect safety is untouched, and no route launders one. - No mutable inputs (
H0609). Every parameter,selfincluded, must be deeply immutable, on the same lattice as rule 3. Adefcannot capture an enclosing binding and there is no ambient global mutable state, and immutable parameters therefore guarantee that every mutable value the body touches was allocated inside the function. The body can only mutate local state. A foreign mutable is rejected outright and never origin-tracked, since even reading one, advancing a cursor, is non-substitutable. A helper that mutates a passed-in builder is therefore an ordinary empty-effect-row action, and the@encapsulatedboundary sits at the entry point that owns the builder. A generic parameter is checked where it is instantiated and not where it is declared (H0629, reported at the call):v: Tcannot say whatTwill become, a caller handing a bounded generic a deeply-mutable type is what the rule has to catch, and blaming the declaration would name a def that is correct for every other instantiation. An unbounded generic parameter is out of scope, and soundly so: with no bound the body has no method, no field read and no==on that value, and no part of the caller's state is observable through it. - Deeply-immutable result. The return type must be transitively free of anything mutable, with no resource, no live
BytesBuilderorBytesReaderand noFuture, and no mutable alias to internal state escapes (H0608). Finalizedbytes,Arc-shared and immutable, qualifies; a builder does not.
With all three, the function's only observable behavior is return = f(inputs). It is referentially transparent, a pure context may call it, and the optimizer and comptime evaluator may treat it as any pure def, the usual non-termination caveat aside. It is the lightweight checked subset of uniqueness and region types, Hanki's runST without the rank-2 machinery.
@encapsulated
def join_bytes(a: bytes, b: bytes) -> bytes
buf = BytesBuilder.new!() # rule 2: the builder is allocated *here*
buf.extend!(a) # rule 1: extend! is an empty-effect-row action
buf.extend!(b)
buf.finish!() # rule 3: only the immutable bytes escapes
end
join_bytes has no ! and is callable from meta, where-invariants and other pure code. The full design, the immutability lattice, the optimizer-soundness argument and the reason serialization needs no Encode or @derive change, is in docs/design/encapsulated.md.
Non-substitutability in the row: local and runtime_state
A function type states only its effect row, and an action with an empty row would leave a closure's inferred row empty. Such a closure is type-identical to a pure closure: storable in a (u8) -> () field, and invocable from a pure def, a pure combinator like fold, a bare-dot prop read, or the comptime evaluator. Two row atoms close that, and both are enforced. Pure-def substitutability is a checked property at every boundary:
localis non-substitutability over state the computation owns. Every action's row implicitly containslocal, injected at the declaration (def name!,onhandlers, trait action signatures,@intrinsicactions), uniformly and not only where the row is otherwise empty. An action's row is therefore never empty, and a row-polymorphic[e]cannot instantiate back to empty and shed its action-ness. A non-empty row absorbslocal: an action declaring[io]lists nothing extra, and a(T) -> () [io]slot accepts a closure that also mutates captured state. The atom is written only where it stands alone,(u8) -> () [local], the type of a slot holding an action closure. It never appears on declarations, the!being that spelling, and writing it on an action, handler orproviderow is rejected as redundant, like namingstate. It never surfaces inhanki doc,effectsorscoreboard, which report written rows, nor in a signature hint, which renders the declaration, and it reaches closures only by inference from their bodies. Anonhandler needs no injection of its own, its implicitstatealready making the row non-empty, and an impl narrowing a trait's[io]to nothing still conforms, absorption making[local]a subset of every non-empty row. The injection reaches source declarations only. The few actions the compiler synthesises for itself have empty rows:dbg!, the enumerated exemption, which is what makes it callable from pure code, and the three the@propertydesugar generates, which is what leaves generation pure.assert!is not among them. A failed assert is an abort, and it takes[Crash]ascrash!does, which is what makes that abort visible where the name is not, in a closure over it and in an@encapsulated def. A declared empty row then means substitutable: a puredef/propcannot performlocal, which closes the launder at the store through the row-pinning check and at the invocation through the pure-context arm; an action context permits it without listing it; an@encapsulated defpermitslocaland no more, rule 1 above restated at closure granularity. A.config.hkvalue cannot perform it at all: every action call there is refused by name, the toolchain evaluating the manifest, and an ambient effect at build time is a compile error and no runtime surprise (§21).runtime_stateis non-substitutability over state the program does not own. Two intrinsics take it,sys.log_min_level!andsys.log_set_min_level!, which closes the ambient-global audit surface. It is an ordinary declared viral atom: a caller oflog.min_level!declares it, and H0601 charges an action that does not. It is no capability and is not deniable. It is absent from the restricted-execution capability surface,--allow runtime_stateand--deny runtime_stateare refused as a category error, and a writtenruntime_staterow never enters the capability walk.@encapsulatedrejects it, H0607 naming the atom, which un-certifies an@encapsulated defcallinglog_set_min_level!that would otherwise check as referentially transparent.log.emit!does not read the threshold: every record crosses the seam andsys.log_emit!drops the ones below it before rendering, the logging entry points keep[io], and nothing that merely logs widens its row. Handing the raw message across and rendering runtime-side makes a dropped record about 3x cheaper than a Hanki-side compare, where rendering Hanki-side first would make it about 6.5x dearer. The record line, its tag and its newline-escaping, is therefore produced by the runtime, held byte-identical to the purelog.formatby cross-tier tests.- No silent exemptions. A third shape, an atom every action permits but no signature ever shows, was considered and rejected: it would be a reusable mechanism for stepping around the row, and the exemptions here are enumerated and never general.
dbg!is the only one a user can reach, and it alone is callable from pure code. The others are the compiler's own desugars, which no source can name.local's implicitness is no breach of this: it gates the pure-and-action boundary the!spelling already draws, and it is implicit where it is tautological. - The bang has to be earned (
H0630). An action whose row is[local]alone, and whose parameters (selfincluded) and result are all deeply immutable by the same lattice@encapsulateduses, did not need its bang. It is a pure function of its inputs, and the!locks every caller out ofmeta,whereand ordinary pure code for nothing. It is a hard error with one named opt-out,@reserved_effect, for an author reserving row room for a world effect the signature will gain later. That is H0574's shape, and like it the marker is itself an error where the question could not arise (H0631), and stale markers cannot accumulate. The predicates are decidable only where the boundary types are concrete. An abstract one, a trait'sSelfor a generic parameter, gives the lattice nothing to walk, and@encapsulatedmay read that as immutable only becauseH0629re-asks at every instantiation, which a claim about one signature has no equivalent of.Serializer.put_u8!, whose implementors all drive aBytesBuilder, is therefore never flagged. Entry points retain their bang by convention, and a trait member is flagged at the declaration and never at an impl (H0568mandates the kind there).
effect local and effect runtime_state declarations are rejected as reserved names, like state. The three atoms differ in where they may then be written: state nowhere, local only on a function type, runtime_state in any effect row, which is how a caller of the log knob declares it.
Debugging & observability
The stance is observe, do not interrupt, and every diagnostic tool writes to stderr, the developer channel outside the effect-tracked contract with the world. Three tools cover it: inline printing with dbg! (the one effect exemption, below), and live actor observation with sys.actors! and sys.get_state!, following the BEAM model of observing without stopping.
For a bug you capture and study afterwards, the complement is deterministic replay. hanki run --deterministic, and hanki test --deterministic, serializes the actor threads through a seeded scheduling gate, and the interleaving, and therefore the program's output bytes, is a pure function of (program, inputs, seed). --seed N picks a different interleaving and implies --deterministic; the bare flag runs the canonical seed-0 schedule. The gate yields at every actor park point (mailbox wait, blocking send, await, actor.await_timeout!, spawn, time.sleep!) and at every sys.* OS-seam crossing, and seeds therefore steer the order of observable effects and not only of message dispatches.
Time is virtual under the gate. time.sleep! parks the actor on a deterministic clock that advances to the earliest sleeper's deadline only when no actor can run: a deterministic run sleeps in zero real time, and actors that are merely sleeping never count toward a deadlock. actor.await_timeout! deadlines sit on the same clock, and a timed-out await also costs no real time and can never be misreported as deadlocked. Both apply while the program only ever waits on itself. A deadline that comes due against an actor parked in a real OS call is spent in real time, for the reason the scoping notes below give. Clock reads, time.now_ms! and time.monotonic_ms!, draw from the same virtual clock, and even a program that prints timestamps replays byte for byte under a fixed seed.
Two things follow. A flaky interleaving bug reproduces by replaying its seed. And a schedule on which every live actor is blocked is detected as a deterministic deadlock, reported to stderr in place of hanging: hanki run exits 251, and a deadlocking test records one failure while the suite continues.
Three scoping notes. External nondeterminism (subprocess output, network peers, stdin) is an input and no interleaving, and "same bytes" applies only where inputs are fixed. Second, an actor blocked inside a slow OS call (a stdin read, a socket, a subprocess) retains its turn and pauses every other actor until the call returns, and the earliest deadline another actor is waiting on bounds that call in real time, a virtual clock being unable to reach a deadline while a real call has the turn. The gate spends the deadline on the pending call in place of jumping over it: a peer that answers within the bound wins, and one that never answers hands the turn back for the deadline to fire. With no deadline pending the call blocks and the whole run waits with it, the turn still being held. No other actor can arm a deadline meanwhile, and a call that starts unbounded remains unbounded. An actor parked in one never counts toward the all-blocked determination, and a reported deadlock still means what it says. Third, two actors that rendezvous through the OS, one actor's net server read blocking on another actor's write, can self-deadlock under the serialized schedule; the gate understands mailbox-level waits alone.
Two operations are refused outright and are never treated as an input: process.run_attached! and process.run_attached_opts! (§17), which hand the terminal to a child. A captured subprocess's output is an input like any other, a child that owns the tty is an interactive session, and no seed replays what a user types into one. The gate faults the call, exit 1 with the same reason text on both tiers, in place of producing a run that claims a reproducibility it cannot have. They are the only sys.* seams that decline under the gate: laying an environment on an attached child changes what it inherits and not who owns the terminal, and the options form declines for the reason the bare one does.
Both tiers run it. hanki run and hanki test take --deterministic and --seed, and an AOT-compiled binary reads the same through HANKI_DETERMINISTIC=1 and HANKI_SEED=N in the environment, leaving a compiled program's main(args) argv untouched. The gate, the park points (mailbox wait, send, await, spawn, time.sleep!, and every sys.* OS-seam crossing), the virtual clock and the seeded random stream are the tier-agnostic rtcore core, and a given seed replays byte-identically on both tiers: an AOT binary and hanki run produce the same interleaving for one seed.
There is no stop-the-world step debugger. Hanki's bug classes are logic, caught up front by types, effects and dbg!, and concurrency and lifecycle, which need observation and no stepping. Stepping distorts the actor timing the bug depends on, and stop-the-world fits a share-nothing actor language poorly. Live-attach tooling beyond those two readouts, tracing, a remote shell and state-replace, is staged by cost and extends the observe model.
Debug-trace exemption: dbg!
dbg!(expr) is the one compiler-blessed exemption from effect propagation. It evaluates expr, prints [file:line] <source> = <value> to stderr, and returns the value unchanged. It is drop-in around any subexpression (let y = dbg!(f(x)) + 1) and may be called from a pure function without forcing [io] onto its callers:
struct Point
x: i32
y: i32
end
def slope(p: Point) -> Option<i32> # pure — no effect row
dbg!(p) # stderr: [main.hk:7] p = Point(x=3, y=4)
p.y.checked_div(p.x) # a runtime divisor goes through checked_div (§3)
end
Effects track a program's semantic contract with the world: stdout, net, fs, throws. dbg! writes to a developer diagnostic channel outside that contract. Purity tracking and debug observation are orthogonal, and every effect-disciplined language arrives at the same exemption (Haskell Debug.Trace, Koka trace, Rust dbg!). The ! spelling marks it as an action, no one mistakes it for pure logic, and it flags itself for removal.
It renders any value structurally, structs with their field names, sum variants, lists and primitives, with no Display impl required. The names come from the argument's static type, which the runtime value representation erases.
dbg! works on both tiers, and the rendering is identical, the AOT runtime sharing the bytecode tier's structural renderer. hanki check, the REPL, and ordinary unconstrained hanki run, test and fuzz accept it. A restricted execution, any of those three commands with --allow or --deny, instead fails before execution when a selected entry or test can reach dbg!; pass the explicit --allow-dbg escape hatch to retain diagnostic output. hanki build is the shipped-code boundary and always applies the same fail-closed check before it creates out/ or invokes the native backend, with hanki build --allow-dbg as its explicit opt-in. The audit follows the lowered call graph: a pure helper counts, a spawned actor contributes all of its handlers, and dead code does not; function values conservatively over-approximate. hanki effects PATH --dbg exposes the exact audit as text, or as a hanki-effects-dbg-v1 JSON envelope {schema, entry, sites: [{symbol, path, line, column}], loads_modules} under --format=json. Runtime-loaded source cannot be enumerated statically. The loads_modules field flags the caveat, and each module.load! / reload! checks the loaded exports at that boundary. A restricted bytecode host passes its --allow-dbg choice through; an AOT host bakes the build choice into its load baseline. A denied load is a catchable ModuleLoadError. Loading therefore cannot launder a diagnostic write.
When enabled, an un-monomorphised generic parameter still renders structurally: the compiler threads each rendered type parameter's concrete layout from the call site as a hidden shape witness, and dbg!(x: T), or a nested dbg!(xs: List<T>), prints field and variant names on both tiers even though the bytecode tier lowers one generic body. Two positional fallbacks are narrow: a type parameter that never appears as a direct value parameter (m: Map<K, V> with no k: K or v: V), and a generic function used as a value (f = show!, called indirectly).
Actor introspection: sys.actors!
sys.actors!() prints a one-line-per-actor table of the live actor system to stderr, giving each registered actor's id, class where known, liveness, mailbox depth and capacity, and supervisor, then returns ():
def main!() -> () [io]
sys.actors!()
end
Output on stderr:
actors (1):
#0.0 <none> alive mailbox 0/1024 supervisor <root>
An id prints as slot and generation, #1.0, both halves of the ActorRef (§15, Actor identity and slot reuse): a slot is recycled with a bumped generation, and the slot number alone names its current occupant and not the actor a row refers to. A supervisor whose id is not among the rows is marked <gone>.
Unlike dbg!, it is not effect-exempt. It is an ordinary [io] action: it observes runtime structure and writes a diagnostic, it takes [io] like any other output, and it cannot be called from pure code. It works on both tiers, bytecode and AOT, each labelling a spawned actor with its class name; the bare program actor, the root, has no class and shows <none>. It is structural only and never reads an actor's state. Live state inspection is sys.get_state!, below.
Live actor state: sys.get_state!
sys.get_state!(handle) prints the current state of one running actor, addressed by its ActorRef<T> handle, to stderr, with its state fields rendered as a struct, the field names recovered from the actor's static type and the values rendered as dbg! would render them, then returns (). It is the live counterpart to the crash-time state dump below: the same renderer, triggered on demand against a running actor.
actor Counter
state n: i32 = 0
on increment() -> ()
n += 1
end
end
def main!() -> () [io, throws actor.SendFailed]
c = spawn Counter
c.increment()
sys.get_state!(c) # stderr: actor #1.0 (Counter): state: Counter(n=1)
end
The snapshot is taken at the actor's next inter-handler safe point, a priority request the actor services ahead of its pending mailbox messages, in the gap between handler runs. State is never reached for from another thread. Hanki actor state is share-nothing and this preserves that, following the BEAM model of observing without stopping. Outcomes other than a clean snapshot are reported on stderr and never by blocking:
- A wedged actor, inside a long-running handler and not reaching a safe point within about a second, reports
actor #N.G: unresponsive, state unavailable. This is the documented Erlangsys:get_statelimit. - A
selfsnapshot,sys.get_state!(self)from inside a handler, can never reach its own safe point while that handler runs, and always reports unavailable. Usedbg!to inspect your own state inline. - A dead or shutting-down actor reports
actor #N.G: not live, no state. The generation is printed for the recycled slot: a stale handle names a slot a later spawn took over, and#Nalone would read as its current occupant.
Like sys.actors!, it is an ordinary [io] action and no dbg!-style exemption, and it works on both tiers. It is spelled get_state! and never state!, after Erlang's sys:get_state. state is an ordinary identifier (§2), and the verb is what marks this as an observation in place of a field read.
Divergence: crash!
crash!(msg: string) -> Never is the divergence primitive: it never returns. Use it on a branch you have proven unreachable, the "this cannot happen" assertion the type system cannot express:
type Color
Red
Green
Blue
end
def render!(c: Color) -> string [Crash] # an action — a pure `def` cannot crash
match c
Red -> "red"
Green -> "green"
_ -> crash!("Color had an impossible variant")
end
end
Its result type is Never (§4), which coerces to any expected type, and a crash!(...) fits any arm without a placeholder. It takes the Crash effect, its own uncatchable effect atom, written in the effect row as in [io, Crash], and a pure def with an empty effect row cannot call it: pure functions are total and never crash. Crash takes no type parameter, unlike throws E, since it cannot be caught. catch e: Crash is a compile error.
Crash is not a capability, and the restricted-execution gate treats it as it treats runtime_state: a policy has nothing to grant or withhold about a divergence marker. --allow Crash and --deny Crash are refused as an unknown capability, and a Crash in a program's row never denies it under an allow-list: --allow io runs a program that can crash!. hanki effects still lists it, the report being the wider view, and "can this program diverge" is a question worth asking.
When reached, crash! terminates like any other runtime fault (§15) and not as a throw. Inside an actor it kills that actor and fires its supervisor's on actor_died; at the program root it exits 252 printing crash: <msg>; under hanki test it fails the current test. It is a production construct, valid on both tiers, bytecode and AOT.
Typed holes: ???
??? is a typed hole, a placeholder for code you have not written yet. It type-checks anywhere an expression is expected. Its type is Never (§4), and like crash! it coerces into any position. Unlike crash! it is effect-free: a hole charges no effect, Crash included, and it is legal in a pure def as readily as in an action. Sketch a program's outline and leave the gaps as holes:
def parse_user!(line: string) -> Result<User, FormError> [io]
fields = ??? # what goes here?
???
end
hanki check type-checks the surrounding program and reports, for every hole, the type the context expects there and the effect budget, the effects permitted at that point:
parse_user.hk:2:12: hole[H0250]: this hole expects `List<string>`; effect budget here: [io]
parse_user.hk:3:3: hole[H0250]: this hole expects `Result<User, FormError>`; effect budget here: [io]
This drives spec-first development: write the types, let the compiler dictate each gap, fill it to fit. hanki check, the LSP and the REPL leave holes non-blocking. check exits 0 and reports every hole as advice, severity hole on the --format=json envelope; the REPL does the same for each submitted block. A hole has no runtime value. A permitted execution that reaches one diverges like crash!: it prints crash: hole reached and exits 252 on both tiers.
Executable boundaries fail closed by presence in the selected root's checked module closure, imports and dependencies included, without a call-graph guess. hanki run, hanki test and hanki fuzz reject every such hole before a rift is prepared or any program effect runs. --allow-holes is their explicit development escape: every site remains reported, and reaching one exits 252. Executable doctests follow test; an illustrative hole belongs in a hanki skip fence. A runtime module.load! or module.reload! applies the same rule before binding, spawning or swapping code, and a failed reload leaves the old module live. A host started by run, test or fuzz with --allow-holes passes that development allowance into its loads; an ordinary host rejects the load with all sites in its ModuleLoadError.
hanki build also rejects holes by default before creating out/, building rifts, lowering or linking. AOT development remains possible through hanki build --allow-holes; its artifacts are quarantined under out/holey/ and never overwrite the canonical executable, object or shared-library path. Each contains a durable .hanki_holes section with the sites, and LLVM-IR output contains equivalent !hanki.holes named metadata. Copying or renaming the artifact cannot erase its identity. Such an AOT host inherits the permissive load rule above. Ordinary AOT artifacts are always closed. This uses the same AOT tier and hole lowering. The quarantine excludes an explicit sketching build from ordinary packaging and deployment paths.