hanki

Hanki - Quickstart for AI agents

A compact bootstrap reference. Read this once and you can write Hanki. Deep dives + full stdlib: https://hanki-lang.org/HANKI.md

Hanki is a statically-typed language with Ruby/Crystal-flavored syntax, Erlang-style actors, and a typed effect-marker system. Pure functions and actions share the keyword def; the ! suffix on the name distinguishes them.

The toolchain has two tiers, built from one front end: a bytecode interpreter with a REPL (hanki run), and an ahead-of-time LLVM compiler (hanki build). Both are equally supported production modes.

use io

def greet(name: string) -> string
  "Hello, #{name}"
end

def main!(args: List<string>) -> () [io]
  io.print!(greet("world"))
end

1. The ! suffix rule (read first, internalise)

Identifiers may end in ! or ?. The suffix is part of the name.

Calling an action requires the caller itself be an action. Pure code cannot reach side effects, even transitively. This is enforced by the type checker - not by convention.

use io

def pure_add(a: i32, b: i32) -> i32
  a + b              # OK
end

def add_and_log!(a: i32, b: i32) -> i32 [io]
  io.print!("adding")   # OK — caller is an action
  pure_add(a, b)        # OK — calling pure from impure is fine
end

def bad_pure(a: i32) -> i32
  io.print!("hi")    # ERROR: pure fn cannot call an action
  a
end

The three layers. One effect system (the effect row […], §7), three layers on top of it: pure functions (def foo, no effects) → actions (def foo!, synchronous effects - the vocabulary of every side effect) → actors (actor Foo, §15 - concurrent share-nothing entities built from actions: every on handler is an action, and only an action can spawn/send). main! is the root actor, so every effect runs within some actor. Actions and actors stay distinct: performing an effect (a cheap synchronous call) and being a concurrent entity (spawned - mailbox, thread, deep-copied messages) are different operations.

2. Lexical basics

Keywords (reserved): def actor on state match if else elif end then var type struct opaque impl trait throw try catch spawn async await move loop do break continue return use open meta true false self and or not where effect provide test with_budget.

3. Numeric types - two tiers

LOWERCASE TIER (exact, default):
  int        arbitrary-precision integer
  decimal    arbitrary-precision decimal
  rational   exact p/q

  Chain:  int ⊂ decimal ⊂ rational
  Widens implicitly; arithmetic NEVER rounds.

FIXED-WIDTH TIER (hardware, opt-in):
  i8 i16 i32 i64    signed   |  u8 u16 u32 u64    unsigned
  f32 f64           floats

  Same-width only. Cross-width / cross-tier needs an explicit method.

Defaults: unsuffixed integer literal → int; unsuffixed float → decimal. Annotation or suffix overrides; a pinned generic slot pins the literal too (o: Option<int> = Some(0), r.unwrap_or(0) on a Result<int, _>).

x = 5            # int
y = 1.5          # decimal
n = 42i32        # i32 via suffix
m: i64 = 7       # i64 via annotation

Division:

Overflow: the fixed-width tier wraps (modular) - + - * unary- are total, never trap (200u8 + 100u8 == 44u8), so pure functions stay total. To detect overflow use checked_add / checked_sub / checked_mul / checked_negOption (there are no wrapping_* methods - bare ops already wrap). The lowercase int/decimal/rational tier is arbitrary-precision: no overflow.

Bitwise (fixed-width tier only; methods, not operators): bit_and / bit_or / bit_xor / bit_not, bit_shl / bit_shr (shift amount u32). All total - the shift is taken mod the bit width; bit_shr is arithmetic (sign-extending) for signed widths, logical for unsigned. The lowercase tier has none. u32 also has count_ones (popcount).

abs / min / max: min/max are default methods on Ord (defined once over cmp), so every Ord type has them — int and every fixed width (3i32.min(7i32)), decimal/rational, bool, string, bytes, and any user Ord type ("apple".min("banana")); an inherent min/max overrides the default. abs is inherent on the signed int/i8/i16/i32/i64; fixed-width abs is modular like the bare operators (the most negative value wraps to itself), int.abs has no wrap case. f64 and f32 each have their own min/max/abs intrinsics (no Ord; IEEE: min/max return the non-NaN operand when one is NaN, abs clears the sign bit). f64 also carries the libm surface: floor/ceil/round/trunc, sqrt/cbrt/pow, exp/ln/log10/log2, sin/cos/tan/atan2/hypot — total in the fixed-width sense (a domain error is NaN, not a trap), identical on both tiers.

Generic numeric code — the Numeric trait (core numeric): zero()/one() static identities plus add/mul mirroring the native +/*, implemented for all 13 numeric types. List.sum()/List.product() are its stdlib consumers (a fold from the identity, so the empty list gives zero/one); they work on any concrete numeric list with no import. To write your own generic, bound <T: Numeric> (open numeric to name it) and reach the identities as T.zero()/T.one(). The operator traits (Add/Sub) deliberately exclude the builtin numerics, so this plain trait is the numeric bound; fixed-width add/mul wrap like the operators, and the lowercase sentinels propagate.

Iteration (stdlib, not syntax - no for/while/..): Range.new(lo, hi) is the half-open [lo, hi) int range with List-style combinators - each!(|i| …), map(|i| …) -> List, fold(init, |acc, i| …), length/to_list. Inclusive via int.upto (1.upto(3) → 1, 2, 3); n.times!(|i| …) runs an action n times (index 0…n-1; zero/negative → no-op). Bounds are int, so counters compose with no conversions.

Cross-tier or cross-width = explicit method:

a: i32 = 5
b: i64 = 10
a.to_i64() + b       # OK
a + b                # ERROR: width mismatch

n: int = 100
n.to_i64() + 5i64    # OK

x: f64 = 1.0
x + 1.0f64           # OK (both f64; a bare 1.0 is decimal)
x + 1.0              # ERROR: tier mismatch (1.0 is decimal by default)

Narrowing (i32.to_i8) truncates (modular, total - the low bits at the target width); pair with try_to_i8 (returns Option<i8>, None when it doesn't fit) when you need to detect out-of-range instead of wrapping through it. The cross-tier bridge: i32/i64/u32/u64 have .to_int() (exact widening into the lowercase tier) and int has .to_i64() (modular truncation, sentinels saturate); the same four also carry .to_f64() (float-tier widening, exact for the 32-bit widths, lossy beyond 2^53 for i64/u64), as does int (its inf/-inf/undefined sentinels map to IEEE inf/NaN), and f32.to_f64() widens losslessly (the bridge for f32 + f64). Off the exact tier: decimal and rational carry .to_int()/.to_i64() (truncate toward zero; to_i64 then modular; sentinels pass through / saturate) and .to_f64() (rounded) - the crossings that make the x.to_i64() // 10i64 recipe reachable. The reverse crossing is f64.to_int() - truncates toward zero into the unbounded int (total; non-finite floats map back to inf/-inf/undefined). Text→value: int, f64, and i32/u32/i64/u64 carry .parse(s: string) -> Option<T> (base-10 integers, decimal f64; None on non-numeric or out-of-range, e.g. u32.parse("4294967296") is None).

4. Other built-in types

TypeNotes
booltrue / false
stringUTF-8, immutable
bytesimmutable byte buffer (binary I/O, codecs)
()unit - both the type and the value
List<T>immutable, persistent
Map<K, V>immutable HAMT; Hash keys, hash order
Set<T>immutable, over the Map HAMT; Hash elements, hash order
Option<T>Some(T) or None
Result<T, E>Ok(T) or Err(E) - a computed failure carried as a value (§8), not thrown
Fileopen OS file - a runtime-managed native resource (moved, never copied, across a send)
TcpStream / TcpListeneropen TCP connection / bound listening socket - runtime-managed native resources (moved, never copied)
BytesBuilderpure-memory growable byte buffer - a runtime-managed resource for assembling bytes in O(n)
BytesReaderpure-memory forward cursor over bytes - the read counterpart to BytesBuilder

bytes: get one by encoding a string (s.to_bytes()) or from a codec - no bytes literals. Byte-offset ops length / slice(start, stop) / concat / get(i) -> Option<u8>; decode with b.to_string() -> Result<string, Utf8Error>. (stop, not end - a keyword.) Assemble in O(n) with a BytesBuilder resource (new!(), then push!(b: u8) / extend!(b: bytes) / finish!() -> bytes); read back with a BytesReader cursor (new!(input), then take!(n) -> bytes / peek!() -> Option<u8> (next byte, no advance; None at end) / remaining!() / position!()). These ops are actions with empty effect rows - they mutate runtime-managed state but no OS capability, so ! marks impurity, not a tracked effect. There is still no value-level mutability: bytes, structs, and lists never mutate in place.

Pair<A, B> (stdlib pair, not prelude - open pair to name it): the two-field product (first/second) standing in for tuples (Hanki has no tuple type - structs are the product type). List.zip's element: xs.zip(ys) -> List<Pair<T, U>>, stopping at the shorter list (zip_with(other, f) is the Pair-free form). Constructed call-style - Pair(first=1, second="a"), and annotate the binding (p: Pair<int, string> = …) - a generic constructor call does not infer its type params from the arguments (same as List.empty()). Eq/Display synthesize on demand like any struct's (== field-wise; renders Pair(first=1, second=a)). Naming your own type Pair is fine - impls key on the module-qualified head, so yours and pair.Pair are distinct types that each derive their own.

Resources (File, TcpStream, TcpListener, BytesBuilder, BytesReader, Module<T>): a handle to live native state released the moment its last handle drops (memory is per-actor reference counting - values are acyclic, so no cycle collector and no collection pause), or eagerly via close! (a Module<T> via module.unload!). A resource is single-owner and never copied - it can't be deep-copied across a send, but can be moved into a receiving actor: a resource-typed handler parameter is a move-in and the sender writes move x (async w.serve!(move sock)), consuming its binding. Rejected: a resource as a return / throws type or nested in an aggregate param, passing one without move, or move off a send arg. Keep one long-term in actor state. Open a file with fs.open_file! -> Result<File, FsError>, then f.read_all!() / f.close!(); a socket with net.connect! / net.listen!, then read! / write! / accept! / close!.

Convention: lowercase types (bool, string, int, i32) are ubiquitous primitives. PascalCase is abstract / generic / user-defined. A bare single uppercase letter (T, K, V) is always an implicit type parameter - so a struct/type/actor may not be named with one (use Point, not P).

5. Bindings and mutability

x = 5            # immutable; type inferred
n: i32 = 7       # immutable, annotated
var y = 0        # mutable
y = y + 1        # OK

Shadowing is allowed only if the type does not change. Names starting with _ are private to the module.

Structs are values; a field write rebinds. q = p copies the binding (no aliasing - unlike Python/JS); p.x = v (or nested p.a.b = v) rebinds p to an updated copy, so it needs var p (writing through a = binding or a parameter is H0207), and q is untouched. The compiler elides the per-write copy to an in-place write where it proves p unshared, and where it can't prove it, single- and two-level writes check the actual handle count at run time (unique mutates in place, shared copies) - an accumulator loop stays allocation-free either way, value semantics unchanged; hanki check --explain-copies reports the writes the proof missed (H0564, non-blocking, with an O(n) note when the write is in a loop). Actor state fields rebind the same way. Values are acyclic by construction: no sequence of writes can make a value reference itself (a knot attempt just nests a copy - the RHS is evaluated before the write). Model cyclic structures with keys in a Map<Id, Node> or with ActorRefs (identifiers, so reference cycles between actors are fine).

6. Functions and actions

use io

def add(a: i32, b: i32) -> i32
  a + b
end

def main!(args: List<string>) -> () [io]
  io.print!("hi")
end

7. Effects

Built-in effects:

EffectMeaning
[io]stdin / stdout / stderr
[net]network
[fs_read]reading the filesystem
[fs_write]writing the filesystem
[db]embedded database access (sqlite.hk; incl. :memory:)
[process]subprocess spawn (process.run!; run_with! adds a per-spawn env overlay, run_in! also a working directory — both charge [process, env])
[time]clock / sleep
[env]environment-variable reads
[random]random-number generation
[throws E]may throw E
[state]inside an actor handler (implicit)

Multiple effects: [io, throws ParseError]. Effects are typed markers - checked statically, NOT algebraic (no handle/with). [fs] is an alias for [fs_read, fs_write] (expanded wherever effect names are interned, including --allow/--deny), so --allow fs_read grants a read-only filesystem.

User-defined effect groups:

struct Rows
  affected: i32
end

effect Database
  def query(sql: string) -> Rows
  def exec(sql: string) -> ()
end

def run!() -> () [Database]
  exec("DELETE FROM x")   # legal: caller has [Database]
end

Ops are bare-callable only within their module or via open db - like every top-level name they live at module.op (never bare-global, so a merged module's op can't collide with your identifiers), and there is no module.op qualified call: open the module to use its ops.

Providing user effects - provide. The effect block declares signatures; a provide block binds the implementations, its head row the shared effect budget of every op body (ops carry no rows of their own; a module-qualified head lets an app provide a library's effect, provide db.Database [net] … end). A program that uses an effect's ops needs exactly one provider (missing/duplicate = compile error, like a missing main); a declared-but-unused effect needs none. --deny net counts the provider's row:

provide Database [net]
  def query(sql: string) -> Rows
    Rows(affected=0i32)    # ordinary action body; budget is [net]
  end
  def exec(sql: string) -> ()
    ()
  end
end

Effect variables. A higher-order action that performs whatever its callback performs writes the row as a single lowercase letter - an effect variable - in the callback's type and its own row: def each!(f: (T) -> () [e]) -> () [e]. At each call site e is solved to the callback's concrete effects, so passing an [io] closure charges the caller [io]; a pure callback charges nothing. (Single lowercase letter = effect variable, just as single UPPERCASE = type parameter; io/fs are multi-letter, so no collision.)

Function types carry effects. A function type may annotate its effect row after the return type, like a def: (Event) -> () [io]. An unannotated function type is pure, uniformly - in every position (parameter, field, let, return). This matters when a function is stored or returned - a struct field, an annotated let, a return type:

struct Handlers
  on_event: (Event) -> () [io]   # calling on_event performs [io]
end

Storing/returning/passing a closure that does more than the slot permits is an error (annotate the slot); doing less is fine. Invoking a stored function value charges its declared effects to the caller - so reading h.on_event and calling it requires [io]. A value whose row is a variable ([e]) may be invoked or forwarded but not stored into a concrete slot - that would launder its effects; use a concrete row ([io]) to store a real callback.

@encapsulated - checked local mutation. ! marks either a world effect (non-empty row) or mere non-substitutability (empty row, e.g. BytesBuilder.push!); a pure def can call neither. Mark a def @encapsulated (no !) and it may call empty-row actions on locally-allocated state yet stay callable from meta / where / pure code. Checked: empty-row actions only (no world effect / throw / crash! / spawn), no deeply-mutable parameters (it allocates its own state), deeply-immutable result (no live builder/cursor/resource/Future/closure escapes; finish!()'s bytes is fine).

Debugging - observe, don't interrupt. All diagnostics write to stderr, outside the effect contract: dbg! (inline print), sys.actors! / sys.get_state! (live actor observation, BEAM observe, don't stop). No stop-the-world step debugger by design - capturable bugs get deterministic replay instead (hanki run/test --deterministic / --seed, below).

dbg!(expr) - the one effect exemption. Evaluates expr, prints [file:line] <source> = <value> to stderr, returns the value unchanged. Drop it around any subexpression (let y = dbg!(f(x)) + 1) and call it from a pure function - it adds no effects. Renders any value structurally (struct/sum/list/primitive) with no Display needed, identically on both tiers - including an un-monomorphised generic param (dbg!(x: T), nested List<T> too), whose concrete shape the compiler threads from the call site as a hidden witness. Keeping it out of shipped code is author discipline.

struct Point
  x: i32
  y: i32
end

def slope(p: Point) -> Option<i32>   # pure — yet dbg! is allowed
  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

sys.actors!() - actor introspection. Prints a one-line-per-actor table to stderr (id, class, liveness, mailbox depth/capacity, supervisor) and returns (). Unlike dbg! it's not exempt - an ordinary [io] action (not callable from pure code) - on both tiers. Structural only (never reads actor state).

sys.get_state!(handle) - live actor state. Prints one running actor's state (by its ActorRef<T>) to stderr via the dbg! renderer - the live counterpart to the crash-time dump - taken at its next inter-handler safe point. A wedged, self, or dead actor is reported unavailable rather than blocking (the Erlang sys:get_state limit). [io], both tiers. (Spelled get_state! - state is a keyword.)

crash!(msg) -> Never - divergence primitive. Never returns; Never (bottom) coerces to any type, filling any unreachable match arm. Carries the Crash effect - an uncatchable atom ([io, Crash]), so a pure def cannot call it (pure functions are total). Not a throw (catch e: Crash is a compile error): it kills the actor (→ supervisor on actor_died) / exits non-zero at the root / fails the test. Both tiers. Write -> Never [Crash] for a never-returning action.

??? - typed hole (spec-first). A placeholder for unwritten code. Like crash! its type is Never, but it is effect-free (legal in a pure def). hanki check reports, for every hole, the expected type and effect budget (hole[H0250]). Non-blocking: a holey program type-checks, runs, and builds; a hole reached at runtime diverges like crash! (crash: hole reached, exit 252).

8. Errors

Failure splits two ways - the Functional Core / Imperative Shell split, compiler-enforced:

Throw a struct/sum value with throw expr; catch with try/catch.

use io

open option   # bare Some / None below come from option (§14)

@derive(Display)
struct ParseError
  raw: string
end

def parse_int!(s: string) -> i32 [throws ParseError]
  match i32.parse(s)
    Some(n) -> n
    None    -> throw ParseError(s)
  end
end

def main!(args: List<string>) -> () [io]
  try
    n = parse_int!("42")
    io.print!("#{n}")
  catch e: ParseError
    io.print!("bad: #{e}")
  end
end

9. Pattern matching

open option
open result

struct Point
  x: i32
  y: i32
end

type LookupError
  NotFound(i32)
  Unknown
end
def classify!(r: Result<Point, LookupError>) -> string [Crash]
  match r
    Ok(Point(x, y)) if x > 0 -> "#{x},#{y}"
    Ok(p@_)                  -> "point #{p.x}"
    Err(NotFound(id))        -> crash!("missing ##{id}")
    Err(_)                   -> crash!("unknown")
  end
end
use io

open option

def classify!(input: string, target: i32) -> string [io]
  match i32.parse(input)
    Some(g) -> do          # do … end groups the two statements
      io.print!("checking\n")
      if g < target then "low" elif g > target then "high" else "hit" end
    end
    None    -> "not a number"
  end
end

10. Structs and sum types

struct Todo
  id: i32
  text: string
  done: bool
end

type Command
  Add(string)
  Done(i32)
  List
  Quit
end

Construct by positional or keyword:

def build() -> ()
  t = Todo(id=1i32, text="buy milk", done=false)
  cmd = Add("buy milk")          # Command::Add
end

Variants list one per line, no commas - same shape as struct fields and trait items.

Generics on declarations: type Option<T> ... end. Variant payloads use positional payload lists: Some(T) is a payload, NOT type application.

opaque - smart-constructor pattern

opaque is a standalone declaration keyword for a product type (not a modifier - opaque struct/opaque type/opaque actor are parse errors).

opaque Email
  value: string
end

def parse_email!(raw: string) -> Email
  # validation elided
  Email(value=raw)
end

Outside the defining file: construction and field access are rejected. A prop/def member read is not — a prop is mechanically a method, so e.domain crosses the boundary like any accessor (opacity gates construction and field access, not behavior). The type name remains usable in signatures. Trait impls may live elsewhere but cannot reach inside fields.

where-block invariants (opaque type)

opaque Email
  value: string
where
  not value.is_empty
  value.contains("@") else "email must contain @"
end

Each line is a bool predicate over the fields (bare names; no self, no actions, no effects). The compiler generates a pure Email.new(value) -> Result<Email, validation.ValidationError> (validates; Ok on success, Err on the first failed predicate - failure is a value, not a throw; the error carries the failing predicate, a field snapshot, and location - the construction call site as file:line:col, threaded in by the compiler) and a file-local Email.unchecked(value) -> Email (bypasses validation). A where-having opaque type has no bare constructor: Email(value=…) is a compile error even in the defining module - pick .new (returns Result) or .unchecked (file-local bypass). Match for Option-style use: match Email.new(s) ... Ok(e) -> Some(e) ... Err(_) -> None ... end. A Type.new(literal) whose predicates provably fail folds to a compile error (comptime-known args only; non-literal args check at runtime). (Non-generic opaque types only.)

11. Traits and impls

trait Display
  def to_string(self) -> string
end

impl Display<Point>
  def to_string(self) -> string
    "(#{self.x}, #{self.y})"
  end
end

impl Display<Point> reads "Display applied to Point". Generic impls: impl Display<Container<T>> (T is introduced from the parameter position). Constrained: impl<T: Show> Display<Container<T>>. An impl's method signatures must conform to the trait's: param/return types equal the declaration (Self = the implementing type), and an action's effect row must be a subset of the trait method's row (fewer effects OK, never more) - a divergent type or a widened row is rejected (H0617), since bounded/static dispatch charges the caller the trait's signature and effects.

Default methods. A trait method may carry a default body (def greeting(self) -> string "hi, #{self.name()}" end); an impl that doesn't override it uses the default, and a default body may call the trait's other methods on self. An inherent/impl override outranks the default.

A trait bound lets you call that trait's methods on the bounded param - def same<T: Eq>(a: T, b: T) -> bool can write a.eq(b), dispatching to the concrete impl (Eq<i32>, Eq<string>, …) - and under a Display bound #{a} interpolation is equivalent to a.to_string(); without the bound a bare T has no methods. Bounds work (both tiers) on free defs, impl heads (impl<K: Hash, V> Map<K, V>), generic trait-impl heads, and a method's own generic (def both_eq<U: Eq>(self, x: U, y: U)), including static trait methods (def decode<D: Deserializer>(d) -> Self). Multiple bounds join with +: def f<T: Display + Eq>(…) brings both traits' methods into scope on T (use + for an unrelated second trait; a supertrait comes free, below).

Supertraits. trait Hash: Eq declares Eq as a supertrait: impl Hash<T> requires impl Eq<T>, and a T: Hash bound brings Eq's methods (and ==/!=) into scope alongside Hash's. One supertrait per trait; transitive along the chain.

Trait type params + associated types. trait Combine<B> declares a per-impl type parameter; type Out in the body declares an associated type. An impl supplies them positionally after Self (impl Combine<Thing, string>) and binds every associated type (type Out = bool); a trailing default (trait Add<Rhs = Self>) lets a homogeneous impl write just impl Add<Period> — the same impl as the spelt-out form (a default is a bare type name; composites like = List<Self> are H0569). Inside the trait and its impls the names resolve to that impl's bindings (def mk(self, b: B) -> Out) — callers never name an associated type, they observe it as the call's result type; a generic impl's binding projects through instantiation (impl<T> Un<Box<T>>, type Out = T). Coherence is per-slot: Add<Instant, Duration> and Add<Instant, Instant> coexist; duplicates/wildcard overlaps are H0516; shape errors (arity, unbound/unknown bindings) are H0570. When several impls share the receiver's type, the argument types select the impl (x.add(y) picks by y's type; the winner's Output types the call) — arguments must be concrete (suffix your literals) and must fit some impl, else H0571; a bare-dot prop read has no argument to select by, so multi-impl resolution of one is H0619 (it names the competing impls — rename yours; note one may be a stdlib impl you never imported, e.g. datetime's days/hours/… on int). Operators ride on this: non-numeric +/- dispatch through the core Add/Sub (module ops, prelude) selecting by the operand pair, result = the impl's Output; numerics never route here (cross-width + keeps its conversion error), and t += d inherits the dispatch (§12 pins Output to t's type; on a field target spell it out: p.f = p.f + d). *Still staged (H0569):* bounds on parameterized traits (so a + b on a generic T is rejected), static dispatch through them, module.load!<T> on them, operator compounds on field targets, default bodies on such traits.

Inherent impls

impl <Type> ... end (no trait, no <...> args) attaches methods directly:

impl int
  def parse(s: string) -> Option<int>
    # ...
  end
end

x = int.parse("42")         # Some(42) — associated fn dispatch
e = Email.parse("a@b")

A method (first param is self) dispatches via value.method(). An associated fn (no self) dispatches via <Type>.fn(args) - for an inherent impl or a trait (a no-self trait method). The same works through a bounded type param: in a <T: Trait> body, T.fn(args) dispatches to the concrete impl via the trait dictionary (the no-self counterpart of value.method() - what lets a generic codec write T.decode(bytes, pos)). The type is always named (no inline type args, §11), so Self is pinned by the receiver.

Properties (prop). A prop is a pure, self-only value read with bare-dot syntax and no parens: 2.days, point.magnitude. Declared prop name(self) -> Ret in a trait or impl body (contextual keyword; a trait prop may carry a default body). One spelling per member: bare dot x.name = struct field or prop (a pure read, nothing happens); x.name() = def method (an invocation); x.name!(…) = action (world effects). Reading a prop with parens (2.days()) is H0567; the stdlib's own attribute reads are props too - length and is_empty on List/Map/Set/Range/string/bytes are bare (xs.length, s.is_empty), while the computing members (sort, trim, keys) stay def; a prop must be self-only, may not declare method-own generics (bare-dot has no syntax for type arguments), and must not collide with a field name (H0566; also a hidden opaque field — rename the backing field to re-expose it; a compiler-derived prop like Hash.hash is exempt — the field wins the bare-dot read); an impl must match the trait's kind — prop vs def — or H0568. Not on actors or at top level; a bounded-generic receiver works (<T: Hash> x.hash dispatches through the bound's dictionary). A pure, self-only, zero-arg def in an inherent impl or a trait declaration is a should-be-prop error (H0574) with a machine-applicable defprop fix (downgraded to a suggestion when the name shadows a field); at a trait declaration the declared kind cascades to every impl (H0568) — Hash.hash is a prop (x.hash, never x.hash()). The exemptions: a deliberate transformation — the to_/into_/from_ prefixes by convention, any other marked @transform def (sort, trim), in trait declarations too; a misplaced @transform is H0620. Trait impl members (kind mandated by the declaration), meta, @encapsulated, @intrinsic (that surface is the primitive conversions and math - transformations by name), and method-own-generic members are out of scope.

Cross-tier conversion methods (i32.to_f64, int.to_i64) all live as inherent methods on the source type. A float's raw IEEE 754 bits are reachable with f64.to_bits() -> u64 / f64.from_bits(u64) -> f64 (exact inverses). Same-width signed↔unsigned reinterprets (i32.to_u32/u32.to_i32, i64.to_u64/u64.to_i64) keep the bit pattern, total and exact inverses - the seam a binary codec uses to reach a number's wire bytes (bitwise ops are methods, not operators - below).

Generic inherent impls (impl<T> Option<T>) work the same way - the impl's type params unify with the receiver's concrete args at the call site:

impl<T> Option<T>
  def unwrap_or(self, default: T) -> T
    match self
      Some(v) -> v
      None    -> default
    end
  end
end

Some(7i32).unwrap_or(0i32)   # T = i32

One impl per base target type (mixing generic and concrete on the same type is rejected); likewise one trait impl per (trait, head type name) - overlapping impls are an error, specialization unsupported (fold the concrete case into the generic impl). The head name is module-qualified, so a stdlib impl never reserves its head's bare name for your own types.

12. Generics

open list
open map
open option

struct User
  name: string
end

def first<T>(xs: List<T>) -> Option<T>
  xs.get(0)          # index is `int`, not a fixed-width literal
end

def demo() -> ()
  list: List<i32> = [1i32, 2i32, 3i32]
  m: Map<string, User> = Map.empty()    # type args inferred from the binding
  ()
end

Type arguments are INFERRED, never supplied inline:

# ERROR — Hanki has no <T> at call sites; type args are inferred:
n = parse<i32>("42")
n = parse::<i32>("42")

# RIGHT — call the associated fn; the result is an Option:
n: Option<i32> = i32.parse("42")

When inference can't pin a parameter, the compiler tells you exactly where to add an annotation.

13. Closures

use io

def demo!() -> () [io]
  [1i32, 2i32, 3i32].map(|x| x * 2i32)   # inline: `)` bounds the body, no `end`

  [1i32, 2i32, 3i32].each! |x|           # trailing block: `end` closes it
    io.print!("#{x}\n")
  end
end

14. Modules and imports

One file = one module. Module name = file basename.

use io          # qualified: io.print!, io.read_line!

open option     # unqualified: Some, None drop into scope bare

Stdlib modules are auto-injected (so use io always resolves without a sibling file) but not auto-opened: a program using bare Some / None / Line / Eof opens the relevant module explicitly. A local file may not shadow a stdlib module name (list.hk / time.hk next to your code): use list always binds the stdlib, so the workspace rejects the colliding file rather than silently ignoring it. A local binding (param / let / var) named after a module does shadow it in value position, though — module.concat(name) is the local value's concat method, not the module module (lexical scoping); the module is then unreachable by that name in scope.

15. Actors

The third of Hanki's three layers (§1) - the unit of concurrency, isolation, and hot reload, built from actions (every on handler is an action). Each actor runs on its own OS thread with its own reference-counted heap (freed at last handle drop; no tracing collector).

use io

actor Counter
  state n: i32 = 0

  on increment() -> ()
    n += 1
  end

  on get() -> i32
    n
  end
end

def main!(args: List<string>) -> () [io, throws actor.SendFailed]
  c = spawn Counter           # ActorRef<Counter>
  c.increment()               # blocking send (parens required)
  c.increment()
  io.print!("n = #{c.get()}") # blocks, gets handler return
end

Mailbox values are deep-copied between heaps. Primitives + strings + actor handles pass cheaply; tuples, lists, sum payloads are recursively walked (internal aliasing preserved; values are acyclic, §5, so the walk terminates). A resource is moved, not copied (§4): move x consumes the sender's binding; returns / throws / aggregate-nested resources are rejected, so keep a long-lived one in state. A Future is likewise non-Sendable - it can't cross a message boundary (parameter, return, or throws), since sharing one reply slot across actors would deadlock both; await it in its origin actor and send the resulting value.

Throws do not cross sends (§15). A handler's [throws E] is the receiver's and never reaches the sender - an uncaught handler throw takes the actor down, surfacing only as actor.SendFailed::Died. To return an error to a caller, a handler returns it as a value (-> Result<T, E>). The sole throw a send carries is actor.SendFailed.

Bounded mailboxes. Default capacity is 1024 envelopes; override with spawn Counter(mailbox=N) (compile-time positive int literal). Every send picks up [throws actor.SendFailed] - a sum of MailboxFull (receiver at capacity), Died(ActorId, DeathCause) (receiver terminated), and Timeout (an actor.await_timeout! deadline passed). Blocking sends and statement-position FAF throw at the call site; let-bound async folds the throw into the Future and surfaces it at await. Discriminate with an inner match (open actor for bare MailboxFull / Died / Timeout). The actor keyword doubles as the module name, usable as a qualifier head.

Bounded await. actor.await_timeout!(f, ms) (a compiler special form on the actor module head) is await f with a deadline: it yields the future's T, counts as the future's await for the discard rule, and throws SendFailed::Timeout if no reply lands within ms milliseconds (i32; negative is 0). A timeout cancels nothing - the handler still runs; its late reply is abandoned. Under --deterministic the deadline is virtual (same gate clock as time.sleep!), so timing out costs no real time and replays per seed.

Supervision. Every actor has a supervisor link - by default the actor that spawned it; override with spawn Worker(supervisor=ref), where ref : ActorRef<T> and T declares on actor_died. Inside a handler self is the actor's own ref (a valid supervisor= target). On death the runtime fire-and-forget-casts on actor_died(who: actor.ActorId, cause: actor.DeathCause) -> () to the supervisor (signature fixed, names free). This death cast is capacity-exempt (Erlang exit-signal semantics): a supervisor at its mailbox= cap still receives every child's actor_died - never dropped, never a whole-run failure from backpressure - serviced ahead of its queued messages. DeathCause is a five-case sum: UncaughtThrow, InvariantViolation(predicate, label, fields), HostPanic, ExplicitShutdown (an actor.shutdown! of the actor, below; also a call to a shutdown-closed mailbox fails Died(_, ExplicitShutdown) rather than hanging, casts to it absorbed), and Gone (a stale ActorRef whose actor died and whose slot was recycled by a later spawn - the original cause is no longer retained; a send to a still-dead, not-yet-reused actor keeps returning its real cause). An actor that spawns a child without supervisor= MUST declare on actor_died (it becomes that child's supervisor). main! is the root: a fault reaching it exits the process non-zero, with a crash-time state dump of the dying actor on both tiers via the dbg! renderer (an ExplicitShutdown is orderly, so it dies quietly). The death sources are crash!, uncaught throws, a violated state invariant, and actor.shutdown!. (Arithmetic never dies - §3.)

Stopping an actor. actor.shutdown!(target) (a compiler special form on the actor head, like await_timeout!) winds an actor down - including one parked in a cancellable OS call (accept!/read!/write!/connect!, stdin read_line!, the timed sys.stdin_read!, or a process.run! awaiting a child, which it kills) or grinding inside a long SQLite statement (aborted between VDBE ops by the connection's progress handler) - and returns a total Future<actor.Shutdown>: read the outcome with await (or actor.await_timeout!), or drop it for fire-and-forget - it carries no throw, so unlike a send Future it is freely discardable (statement-position actor.shutdown!(t); still a one-shot - awaitable at most once). Outcomes: Terminated (was live, now fully down), AlreadyDead (idempotent), SelfScheduled (actor.shutdown!(self) can't wait on its own death, so it exits at the end of the current handler - the clean self-stop, distinct from crash!). A supervised target's on actor_died receives ExplicitShutdown; an unsupervised one dies quietly. Both tiers.

Program exit terminates spawned actors - it does not wait. When main! returns the program is over: the runtime tears down every live spawned actor (interrupting a parked cancellable OS call - accept!/read!/write!/connect!, read_line!, sys.stdin_read!, process.run! - and any in-flight SQLite statement, like actor.shutdown!, dropping unprocessed messages), so a fire-and-forget handler that hasn't finished may not run to completion. To keep work alive, block in main! (e.g. await actor.shutdown!(s), or await a result). Both tiers identical: AOT exits the process; bytecode tears its actors down to the same effect.

State invariants. An actor may end with a where block of bool predicates over its state fields (after the handlers, before end), mirroring the opaque-struct surface (§9):

actor Counter
  state n: i32 = 0
  on dec() -> ()
    n -= 1
  end
where
  n >= 0 else "count must never go negative"
end

Predicates run after each successful handler return and once after init; a violation terminates the actor with DeathCause::InvariantViolation (it does not throw to the sender). Same predicate scope as opaque types (state fields by bare name; no self, actions, effects, or methods on the actor type). A comptime-known static default that already violates a predicate is a compile error (§16). Both tiers.

Hot reload. module.reload!<T>(m, path) swaps a live loaded actor to recompiled code at its next safe point - the in-flight handler finishes under the old code, later messages run the new; the actor and its mailbox survive. Same state layout ⇒ state carries across; a changed layout needs a migrate(old: T) hook (implicit [state], returns (); T mirrors the previous state, built from defaults then overwritten from old) or the reload throws module.ModuleLoadError::StateMigrationRequired and keeps the old code (atomic). Reloading a function-mode handle just re-points its vtable at the recompiled code (no actor, no migration) - both tiers. Failures no migrate hook can fix are their own variants: a mode switch (actor ↔ function exports) throws ModeMismatch, a dead/unloaded handle throws InvalidHandle.

Use after unload!. A method dispatched through a handle unload! already released throws a catchable module.ModuleHandleInvalid (sole variant Released) - distinct from ModuleLoadError (a use-after-release is not a load failure). So every Module<T> method call carries [throws module.ModuleHandleInvalid] - even a pure def method: the throw is the handle dispatch's, not the method's, the same universally-injected shape as SendFailed on sends. A trait prop is read bare-dot through the handle (m.answer, never m.answer() - that's H0567) and that read charges the same throw. Declare it or try … catch e: module.ModuleHandleInvalid. Identical value on both tiers, so use-after-release fails safely as a typed throw, never a null-pointer deref.

actor Counter
  state total: i64 = 0

  on add!(n: i64) -> ()
    total += n
  end

  migrate(old: CounterV0)   # old.* are the previous state's fields
    total = old.count
  end
end

struct CounterV0
  count: i64
end

Actor-mode reload is bytecode tier only in v0 (an AOT host can't load actors yet, so there it reloads function-mode handles only); one previous layout per migrate; the hook is total (no throws).

16. Comptime (meta)

Compile-time evaluation of regular functions - Zig comptime style.

meta def fib(n: i32) -> i32
  if n < 2i32 then n
  else fib(n - 1i32) + fib(n - 2i32)
  end
end

MAX_TODOS: i32 = fib(20i32)       # 6765, evaluated at compile time

17. Tests

def double(x: i32) -> i32
  x + x
end

test "double doubles"
  assert!(double(2i32) == 4i32)
end

Doctests. A fenced block inside a doc comment (a # run directly above an item) runs as a synthesized test, keeping examples honest:

# Doubles its argument.
#
# ```
# double(2i32) => 4i32
# ```
def double(x: i32) -> i32
  x + x
end

18. Project layout

A Hanki project has a manifest (hanki.config.hk) plus one or more .hk source modules.

# hanki.config.hk
name        = "todo"
version     = "0.1.0"
entry       = "src/main.hk"
description = "A simple todo CLI"

19. Stdlib essentials

Auto-injected modules; no on-disk file needed.

Two baked tiers. core is the locked, opened-by-convention surface — option, list, str, display, eq, hash, actor, the numeric tower, plus sys (the native seam) and module. extra is evolving capability/utility modules, import-gated — in scope only after a file use/opens them. Core may not depend on extra; user code uses either. Native-seam rule (enforced): every @intrinsic lives in core (the sys module owns the OS primitives); the extra capabilities are pure-Hanki faces that declare an effect and delegate to sys — a non-core @intrinsic is a check error. (Two never-baked package tiers: curated contrib — bundled with the toolchain as seeded source, resolved by the package manager — and decentralized universe.)

Compiler reflection (experimental, core compiler). compiler.parse(src) -> Result<Ast, ParseError> (a shallow list of item headersItem{kind, name, doc, exported, action, span}, not the deep AST), type_decls(src) -> Result<List<Decl>, ParseError> (the deep structured declarations — each Decl{name, exported, action, is_meta, generics, doc, body, span} whose body is a sum Fn/Struct/Sum/Trait/Impl/Actor/Effect/Const carrying fields/variants/signatures — a Const body reports its value twice, value_source verbatim and value_rendered printed back from the parse, so a comparison can ignore a pure reformat — with every type a reflected Type tree Name/App/Unit/Fn/Infer, every effect row a List<Effect> of Simple(name)/Throws(Type), and every member a Method{name, action, prop, generics, sig, span} whose generics are its own type parameters), diagnostics(src) -> List<Diagnostic> (what hanki check reports), effect_surface(src) (the capability surface), doc_items(src), imports(src) -> Result<List<Import>, ParseError> (the use/open directives — Import{path, is_open, alias, span} — the open/alias detail parse drops), stdlib_modules() -> List<StdModule> (the embedded stdlib {name, source} set), bundled_card()/bundled_reference() -> string (the embedded HANKI-CARD.md/HANKI.md that hanki new scaffolds), and bundled_skills() -> List<BundledSkill> (the embedded agent skills {name, summary, body} hanki new writes into skills/) run the front end over a source string and return Hanki values — the seam the off-path dev tools (fmt/doc/lint/effects/api-diff) reflect through. Shape unstable until the tool ports validate it; works on both tiers (an AOT hanki build that calls one links the embedded front end, like load!, so the reflected value is byte-identical across tiers). Full shapes in HANKI.md. The sibling core manifest module does effectful artifact reflection: manifest.read!(path) -> Result<ManifestInfo, ManifestError> [fs_read] decodes a built .so's .hanki_manifest section into reflected values (the data behind hanki inspect).

Extra modules: io (terminal: print! stdout, eprint! stderr, read_line!), fs (file r/w), process (subprocess; run_with! spawns with a per-spawn env overlay - child inherits + overlay wins; run_in! adds a per-spawn working directory - both charge [process, env]; no process-wide env.set!/chdir by design), net (blocking TCP), http (HTTP/1.1 over net), env (gated reads), time (sleep! + clock reads, virtual under --deterministic), datetime (pure offset-based calendar/clock/duration: Instant/Date/Time/DateTime/Offset/OffsetDateTime/Duration/Period + Weekday/Month, ISO 8601 & RFC 3339 parse/format, Hinnant civil algorithms over int; fluent units as props on int (2.days/5.minutesDuration, 2.months/1.yearsPeriod, read bare per §11) plus add_period and 2.days.from_now!()/3.hours.ago!(); operators via the core Add/Sub: now!() + 2.days, instant ± duration, instant - instantDuration (positive when the left is later), datetime ± duration, datetime - datetime, odt ± duration, odt - odt, date ± period (date + 2.months), period ± period (1.years + 2.months), duration ± duration — no Time operators (mod-24h wrap stays on the named add) and no Date + Duration (calendar vs fixed-length; use Period/add_days); fixed UTC offsets, no IANA zones; now!/now_at!/today! the only [time] seam), random (gated, seeded under --deterministic), cbor/json (codecs; @derive(Encode, Decode) is real CBOR), xml (XML 1.0 pull parser, secure by construction: pure Hanki, no I/O, no custom entities — only the five predefined + numeric character references decode, any other named reference is a typed error; DOCTYPE skipped inert; UTF-8 only; depth-capped; public namespace-aware pull reader: reader/reader_bytes -> XmlReader, next! -> XmlStep (Next/Done/Fail) with names resolved as XmlName{uri,local,prefix}; unprefixed attributes have NO namespace (the spec asymmetry); xmlns declarations are scope, not attributes; tree: parse!/parse_bytes! -> Result<XmlDocument, XmlError>, XmlElement navigation elements/elements_ns/first/first_ns/attribute/attribute_ns/text (children-only, CDATA merged into text); writing: sticky-error XmlWriter chain (writer().start(..).attribute(..).text(..).close().finish() -> Result<string, XmlError>, context-correct escaping, prefixes must be declared) + render/render_pretty (mixed content never reformatted); typed decode: FromXml (T.from_xml(el) -> Result<T, XmlShapeError>, fields decode from same-local-name CHILD ELEMENTS, attributes reached explicitly via require_attribute; require_child/child_text accessors; path-carrying errors extended with within)), supervisor (restart policy), path (pure lexical slash-path: join/dirname/basename/normalize/split_ext->PathSplit{root,ext}, no effects), terminal (pure escape builders move_to/clear_*/alt-screen + chainable Style/Color -> sgr/styled, raw-mode + full-screen actions with crash-safe restore via the sys exit slot; pure incremental decoder decode(bytes) -> List<TermEvent> (Key/Paste bracketed-paste burst/Mouse SGR-1006 report) + rest (never desyncs) + read_key!(timeout_ms) (Pressed/Pasted/Moused/…) with ~25ms lone-ESC disambiguation, paste + mouse tracking enabled by enter_full_screen!; display_width/first_width cell widths from Unicode 16.0.0 tables), sqlite (secure-modern-strict SQLite over the [db] seam: open_memory!/open_path!/open_with! (+ OpenOptions.hardened() — the untrusted-SQL engine-limit profile — and with_allowed_tables/confine_tables! — one-way static table confinement; limits bound how much, the policy bounds what; hostile files are substantially covered by the on-by-default profile, overridable unlike the native lockdown), batch!/execute!/query!, execute_named!/query_named! (named :x/@x/$x params from a Map<string, DbValue> of bare keys; any mismatch is a loud BindName), with_tx! (self-composing: nested calls scope an auto-savepoint), savepoint!/release!/rollback_to! (bare-identifier names only, hanki_ reserved), backup_to! (online snapshot; charges [db, fs_write] — the one sqlite call that writes a caller-chosen path), create_function!(name, f) (register a PURE (List<DbValue>) -> Result<DbValue, string> as a deterministic scalar SQL function — purity enforced by the parameter's empty effect row; Err(msg) fails the statement with that SQL error; the conn keeps f alive), params via v()/ToValue, typed rows via @derive(FromRow); an actor-confined SqliteConn, plain-data results, conn.stream! → row-at-a-time Cursor (next!/fold!/columns!/close!) for bounded-heap scans (one per conn), long statements aborted by actor.shutdown!; both tiers). The OS-capability seam (incl. net/process) runs on both tiers. Planned: regex, log.

The capability faces return specific error sums, never weak bool/Option — e.g. fs.read!(path) -> Result<string, sys.FsError> (NotFound/PermissionDenied/NotUtf8/Other), fs.canonicalize!(path) -> Result<string, sys.FsError> (OS realpath); process.run!sys.ProcessResult with Exited(code)/SpawnFailed(reason) (process.run_with! adds an env-overlay Map<string, string>, run_in! also a cwd string, both charging [process, env]); net/http carry NetError/HttpError. Each declares its effect ([fs_read]/[fs_write]/[net]/[process]/[env]/[time]/[random]/[io]), gated by --allow/--deny. Full signatures, methods, and error variants are in HANKI.md (shipped alongside this card).

Core surfaces (full signatures in the shipped HANKI.md):

20. Common idioms

Read-loop on stdin until EOF:

open io

def main!(args: List<string>) -> () [io]
  loop    print!("> ")
    match read_line!()
      Eof        -> break
      Line(line) -> print!("got: #{line}\n")
    end
  end
end

Parse-then-act with Option / sum match (open option at the top of the file so Some/None work bare; see §14):

use io

open option

def handle_positive!(n: i32) -> () [io]
  io.print!("positive: #{n}\n")
end

def handle_other!(n: i32) -> () [io]
  io.print!("other: #{n}\n")
end

def parse_then_act!(line: string) -> () [io]
  match i32.parse(line)
    Some(n) if n > 0i32 -> handle_positive!(n)
    Some(n)             -> handle_other!(n)
    None                -> io.print!("not an int\n")
  end
end

Struct + inherent method:

struct Point
  x: i32
  y: i32
end

impl Point
  def origin() -> Point
    Point(x=0i32, y=0i32)
  end

  def shifted(self, dx: i32, dy: i32) -> Point
    Point(x=self.x + dx, y=self.y + dy)
  end
end

def demo() -> ()
  p = Point.origin().shifted(3i32, 4i32)
  ()
end

Actor + blocking send:

use io

actor Counter
  state n: i32 = 0
  on bump() -> i32
    n += 1
    n
  end
end

def main!(args: List<string>) -> () [io, throws actor.SendFailed]
  c = spawn Counter
  v = c.bump()
  io.print!("first: #{v}\n")
end

21. Anti-patterns (preempt cross-language reflexes)

Common mistakes from carrying habits in from other languages:

Don't writeWhyDo
null, nil, undefinedHanki has no nullOption<T> with Some / None
x? / expr? (postfix try / propagate)No ?, by design - it hides the exit; foo? is only a predicate namematch, a return Err(..) guard, or chain and_then / or_else
def foo!() -> T, call as foo()! is part of the nameCall as foo!()
io.print(s) in pure defPure cannot reach effectsCaller must be def name! with [io]
parse<i32>("42") (turbofish)No inline type argsi32.parse("42") (assoc fn → Option)
xs.append(x) mutates xsLists are immutableys = xs.append(x) (returns new)
i32 + i64 directlyNo implicit cross-widtha.to_i64() + b
i32 + intNo implicit cross-tiera.to_int() + n or a + n.to_i32()
Throwing a stringOnly struct / sum values throwablethrow MyError(msg=...)
`{ \x\x*2 }` brace closureOnly the `\x\body` form`xs.map(\x\x * 2)`
c.method in a non-actor contextSends only valid on ActorRef<T>spawn Counter, then c.method()
c.get bare (parenless send)A send is an invocation, not a field readc.get() - parens even for zero args
2.days() on a propertyA prop is a bare-dot value read, not a call2.days (no parens; else H0567)
xs.length() / s.is_empty()Both are props on every collection and string/bytesxs.length, s.is_empty (bare; else H0567)
def for a derived pure valueA bare-dot read reads cleaner than x.f()prop (self-only, pure, no parens)
Heavy logic in a closure bodyClosures are smallExtract a named def
Mixing decimal and f64Different tiers, no implicit crossbridge explicitly: d.to_f64() + x, or annotate the literal (1.0f64)

22. Working in Hanki as an agent - the loop

The toolchain is one loop, not disconnected flags. The habits, in working order:

  1. Sketch with typed holes. Write the program's shape and leave every unwritten expression as ???; hanki check reports each hole's expected type and permitted effects (hole[H0250]), so the compiler dictates the gaps - spec-first, no placeholder values.
  2. Query semantics instead of reading source into context. hanki doc FILE --format=json is a module's API surface as data (signatures, effect rows, doctest presence); hanki query FILE --at LINE:COL answers "what is this?" at a position. Grep is the fallback, not the default.
  3. Check, explain, fix - don't re-derive edits. A failed hanki check prints error[H####]; hanki explain H#### gives the recipe; hanki check . --fix applies the machine-applicable fixes (it propagates a missing effect up the whole call chain). --format=json for the structured envelope. The loop is run → explain → fix → re-run.
  4. Test with structure; reach for properties. hanki test --format=json gives per-test results with failure operands. A claim that should hold for all inputs is a property test - test "name"(x: i32) samples via Arbitrary, and a failing case hands you the input. For actor code, --deterministic makes runs byte-identical and a failing --seed N is the exact repro.
  5. Run just-written code under least privilege. hanki effects . prints every effect the program can perform (the declaring rows included); then hanki run . --allow <that set> (or --deny …) refuses, statically and before execution, anything outside it. Sandbox by construction.
  6. Classify the contract before landing. hanki api-diff FILE diffs the module's public API against HEAD and exits 1 on any breaking finding (rule table: effect added → breaking; variant added → breaking; effect removed → compatible). Done means: tests pass, the surface change is intentional.

Every envelope is versioned (hanki-diag-v1, hanki-test-v1, hanki-query-v1, hanki-doc-v1, hanki-effects-v1, hanki-apidiff-v1) - one JSON object on stdout, stable keys, safe to parse.

23. Where to go next