Documentation

kama language specification (overview)

This is a semantics overview. The grammar is authoritative — see grammar.bnf (generated from kama.y). Executable examples live in ../tests/ (*.kama with a .expect exit code). The design philosophy — one way to do a thing, explicit over implicit, no GC / RAII — lives in GOALS.md; this document is the semantics/feature reference. Status flags below: ✅ implemented, 🚧 reserved (not yet implemented).

Model#

kama compiles to portable C (native + WASM). No garbage collector — object lifetimes are deterministic (RAII). Calls use named arguments (no positional). Every type declaration is type value (owns nothing, copies), type resource (owns/has identity, moves, RAII-dropped), type view (a non-owning stack-only borrow — a slice/span), or type contract (an interface).

Source files are UTF-8, and so are kama.json, kama.local.json and kama_workspace.json. A leading UTF-8 byte-order mark (EF BB BF, which Windows PowerShell 5.1 and "UTF-8 with BOM" editors write unasked) is ignored, so line and column positions are the same as without one. A UTF-16 file is refused with a message that says so, not lexed as bytes: re-save it as UTF-8. The rest of the file is UTF-8 text throughout, comments included, where no program observes the bytes; well-formed multi-byte text is welcome in every string form, char literal and comment. A byte that is not UTF-8 text is refused when the file is read, naming its line, byte column and offset: a stray 0xFF, an overlong form, an encoded surrogate, a sequence cut short (// caf<E9> in Latin-1 is one), and a NUL, which is valid UTF-8 but never text: a raw NUL in a string is rejected, and \0 writes one. A column counts bytes, a tab as one, which is what the language server reports.

Types ✅#

kama C
int8 int16 int32 int64 int8_t … int64_t
uint8 uint16 uint32 uint64 uint8_t … uint64_t
isize / usize ptrdiff_t / size_t — platform-varying (8 bytes on x86_64/arm64, 4 on wasm32/thumbv6m)
clong / culong long / unsigned long — platform-varying the other way (4 bytes on Windows, 8 elsewhere); for an extern that mirrors a C long
cchar char — C's byte, as a raw-pointer element only (UnsafeConstPtr<cchar> is const char*; string.cstr() returns one); never a value
float32 / float64 float / double
bool bool
string kama_string (borrowed view or heap-owned RAII string)
void void
user type value/type resource struct (value semantics)

No raw arrays and no raw pointers — by design (raw memory access is confined to unsafe fn at the FFI boundary). Collections are generic library types.

Numeric literals#

form example notes
decimal / hex / octal integer 42, 0xFF, 0o17, 1_000_000, 0xFFFF_0000 a digit separator is an underscore between two digits of a run (1_000, 0xFF_FF, and 1_000.5 in a float); 1__0, 10_ and 0x_F are not literals. A leading 0 on a multi-digit integer is an error, not decimal: C, Go and Node read 0644 as octal 420, and kama's octal is 0o644
based integer 0b1010_2 0b<digits>_<base>, base 2–32; the _<base> is required
integer suffix 42i32, 42ui32 u?i(8|16|32|64) — unsigned is ui; there is no bare 42u32
float 12.5, 12.5e10, 1e10, 1.5e-3 digits.digits with an optional exponent, or digits with a required one
float suffix 1.5f32, 1e10f64 f32 / f64

A float literal's dot needs digits on both sides — 1. and .5 are rejected (tests/xfail/float_bare_dot.kama); those are the error-prone spellings, .5 reading as a stray member access and 1. as an unfinished expression. A dotless exponent is fine (1e10), because the exponent marker already makes it unmistakably a float. This is exactly Swift's and Zig's rule. There is no normalization requirement: 12.5e10 and 0.5e10 are both literals, not just 1.25e11.

The string type#

There is one string type: lowercase string, a builtin like int32/bool/float64. It lowers to a fat value that is one of two things, chosen automatically:

fn uint8 main() {
    string s = "ab";                    // borrowed literal — no alloc
    string t = s.concat(other: "cd");   // heap-owned, RAII-freed at scope exit
    bool eq = s.equals(other: t);   isize len = s.length();
    return 0;
}

You never spell the borrowed-vs-owned distinction; the type carries it, and RAII frees exactly the owned ones. There is no separate capital-String collection type.

UTF-8 everywhere. A string is UTF-8 bytes; length() is the byte length (O(1)), and literals encode \u{…} escapes to UTF-8 — \u{0} is one NUL byte, the same as \0, and a literal's bytes reach the program exactly as written (C's trigraphs never touch "??="). An escape names one Unicode scalar value: a surrogate (\u{D800}–\u{DFFF}) is rejected, and so is a value above \u{10FFFF}, in a string or a char. Two ways to traverse it, kept distinct by type so bytes and characters never blur:

fn uint8 main() {
    string s = "A\u{E9}\u{20AC}";           // "Aé€" — 6 UTF-8 bytes, 3 codepoints
    int32 n = 0;
    foreach (char c in s.chars()) { n = n + 1; }   // n == 3 (codepoints, not bytes)
    uint8 first = s[0];                     // 65 ('A'), a byte
    return 0;
}

char is a distinct primitive — a Unicode scalar value backed by uint32 (not a numeric type, so it can't silently mix with ints). Literals: 'a', '\n', '\u{1F600}'. Equality + ordering compare codepoints; cast<int32>(c) / cast<char>(i) convert (arithmetic on codepoints is explicit, the Rust model). A multibyte source char literal is decoded to its scalar value: 'é' == '\u{E9}' == 233, '😀' == '\u{1F600}' == 128512. A source byte that is not UTF-8 — '<FF>', once read as 255 — is rejected with the file, like any other (Model).

Operators & methods. string carries the small, always-available ergonomic surface every language ships — all compiler intrinsics on the primitive (no import), byte-oriented like s[i]:

string path = "/usr/local/bin";
foreach (string part in path.split(separator: "/")) { … }   // "", "usr", "local", "bin"
string greet = "Hello, " + name + "!";
if (greet.toLower().contains(substring: "hello")) { … }
match (greet.find(substring: ",")) { case Some(value: i): …; case None: …; };
match (greet.findFrom(substring: "l", start: 3)) { case Some(value: i): …; case None: …; };   // i == 3
if (field.equalsIgnoringAsciiCase(other: "content-length")) { … }

Formatting & string interpolation ✅#

Rendering a value as text goes through one contract, Formattable (prelude, so it works without an import and survives --no-std) — the display twin of Serializable:

type contract Formattable for value, resource, enum, view, intrinsic { const fn void format(ref Formatter f); }

Every std collection renders when its elements do (Formattable when [T: Formattable]; a map when both its keys and values do): a sequence or a view as [1, 2, 3], a set as {a, b}, a map as {k: v, k2: v2}, each in iteration order (a hash map's is its slot order, a PriorityQueue's its heap order), an InlineArray included. A SlotMap writes its values as a sequence, since a Handle has no text. A hash set's elements must also be Copyable, as its serialize requires: its keys are read through its map's copying key iterator. A string element is written raw, as the derive writes a string field, so ["a, b"] and ["a", "b"] print alike; the quoted rendering that tells them apart is KR-222.

A type writes its pieces into a caller-owned Formatter sink (a growable UTF-8 buffer), so a whole nested value materializes in one allocation — no O(n²) concat. Formatter has writeStr / writeI64 / writeU64 / writeF64 / writeF32 / writeBool / writeChar and a finish() -> string. Every primitive (int8..uint64, float32/64, bool, string) conforms. A float renders as the shortest decimal that reads back as the same value, the digits Rust, Go, Python, JavaScript and PostgreSQL print: 0.1, 0.30000000000000004, and 0.3 for the float32 0.3f32. The style is %g's, scientific when the exponent is below -4 or at least 15 (6 for a float32), as PostgreSQL writes float8 and float4. "${x}" is the one way to render a value — it lowers to exactly this build, so there is no free wrapper beside it. Formattable is infallible (void, no Result) — an in-memory write can't fail, unlike serialize over an I/O sink.

type value Point implements Formattable {
    int32 x; int32 y;
    public const fn void format(ref Formatter f) {
        f.writeStr(s: "("); f.writeI64(v: cast<int64>(this.x));
        f.writeStr(s: ", "); f.writeI64(v: cast<int64>(this.y)); f.writeStr(s: ")");
    }
}

Interpolation. A ${expr} hole in a plain string literal splices a value in — lowered at compile time to a Formatter build (a writeStr per literal chunk, expr.format(ref f) per hole), statically type-checked, no runtime reflection:

string s = "point ${p} at n=${n}, first=${who[0]}";   // p.format, n.format, who[0].format into one buffer

Collections & strings ✅#

Built-in generics, monomorphized per element type and backed by the C runtime (unsafe internals, safe API — the Rust-Vec model); indexing is bounds-checked (a clean trap, not UB). An indexed element a[i] is a place (an lvalue): you can write a field through it (a[i].x = v), index it again (m[i][j] = v), compound-assign it (a[i] += x), or borrow it (ref a[i]) — every form stays bounds-checked. (Reading a[i] still yields a copy.)

import { std::collections::FixedArray, std::collections::DynamicArray };
@generate(of) type value Point { public int32 x; public int32 y; }

fn uint8 main() {
    FixedArray<int32> a = FixedArray.make(size: 4);   // fixed buffer, each element int32's default (0)
    a[0] = 10;  a[1] = 20;                          // bounds-checked []
    int32 first = a[0];
    foreach (int32 x in a) { /* ... */ }            // iterate (x is a copy)
    foreach (ref int32 x in a) { x = x * 2; }       // `ref`: mutate each element in place
    foreach (const ref int32 x in a) { /* ... */ }  // `const ref`: read each in place, write none

    DynamicArray<Point> ps = DynamicArray.empty();             // growable
    ps.add(item: Point.of(x: 1, y: 2));   isize n = ps.length();   Point q = ps[0];

    string s = "ab";                                // borrowed literal (no alloc)
    string t = s.concat(other: "cd");               // heap-owned, RAII-freed
    return 0;
}

A FixedArray's every element is live from construction, so construction builds each one: FixedArray.make(size:) gives each element T's default, and exists only where when [T: default] holds (see Collections — the four-ctor matrix); FixedArray.filled(size:, value:) gives each a copy of one value, and exists only for a copyable element. A move-only element with no default is built in a DynamicArray. Until 0.9.481 make zeroed the buffer for every T: a FixedArray<File> of 3 closed descriptor 0 three times when it dropped, a declared field default read 0, and a type with no constructor at all was built.

A bounded list in place — InlineDynamicArray<T>#(N). Capacity N in line, length 0..N, no heap (C++26 inplace_vector, Rust's heapless::Vec): the growable list a type value can hold, written in kama over InlineStorage. Its kind follows its element's by a kind clause — over a value it is a value, copied as its bytes, sent as one and held by a type value; over an element that owns something it is a resource that moves, drops exactly its live elements, and deep-copies with copy when the element is Copyable.

import { std::collections::InlineDynamicArray, std::collections::CapacityFull };
type value Packet { public InlineDynamicArray<uint8>#(64) body; public uint16 port; }

fn void fill(ref Packet p, uint8 b) {
    Result<Unit, CapacityFull<uint8>> r = p.body.add(item: b);
    match (r) { case Ok(value: _): { } case Err(error: _): { /* full: the byte is handed back */ } };
}

Its surface is DynamicArray's, bounded. Being full is an expected state, not a trap: add and insertAt return Result<Unit, CapacityFull<T>> and hand the item back (match (give e) { case Item(item: x): … }), and addAll adds a whole run or none of it (CapacityFull<Unit>, as arrayvec's CapacityError<()>). A discarded growth is refused like any Result. capacity() is N, with nothing to reserve; N is at least 1. Indexing, remove, pop, swap, clear, the views, the three foreach forms, contains/indexOf/equals, serde and Sendable are DynamicArray's, over the live prefix; an index past the length traps though the place exists. Reading more than N elements is an Err at the first with no place — never a truncation. Every place past the live prefix is kept zero: a pop or remove clears what it vacates, so a copy, a send or a byte view never carries a popped element's bytes, and two arrays holding the same elements are the same bytes. A value instance whose layout has no padding is BytesViewable (InlineDynamicArray<uint8>#(64) is; three int32 places beside an isize length are not), and never Blittable: written as bytes, its length could claim places that hold nothing. It has no modification counter, since its places never move: an iterator reads the live length at every step. Over an owning element it may not sit in a type value, as no resource may.

All collections own their storage and free it via RAII (with element-destructor chaining). Only the (collection, element-type) pairs the program actually uses are emitted (pay-for-what-you-use). A method call on an element works directly — list[i].method() borrows the element in place, so a mutating method mutates the stored element; a const collection allows only const methods on its elements. Elements enter a collection by the ownership rules below (give to move, copy to duplicate, a value copies).

DynamicArray / FixedArray also offer reserve(n:) (DynamicArray — preallocate to skip incremental growth), clear() (DynamicArray), and contains(item:) / indexOf(item:) (both — present only when the element is Equatable, i.e. string or a user type with equals). Removal on DynamicArray returns the removed element moved out — remove(index:) -> T (shifts the tail; bounds-checked, so always a valid T) and pop() -> Optional<T> (the last element, O(1), None when empty). Reclaim the element by binding the return, or discard it to drop (xs.remove(index: i); drops cleanly). This is the one consistent removal shape across every container — a single method that hands ownership back (matching Deque.popFront/popBack and Rust's Vec::remove/pop), never a silent drop.

Bulk copies take a view. DynamicArray.addAll(items: ConstView<T>) appends a copy of every element in one growth, and View.copyFrom(source: ConstView<T>) replaces every element of a window with a copy of the same-position one — the source must be exactly as long, and a mismatch traps (copy_from_slice's rule). Both exist only for a Copyable element, and both are ONE memcpy when the element's copy is its bytes, in any build — they lower to the floor's copyElements, as every collection's copy, clear and drop lower to the range form of drop. Inside one buffer, View.copyWithin(from:, to:, count:) copies the count elements at from over the count at to, and the two runs may overlap — Rust's copy_within, JS's copyWithin, C's memmove — with copyFrom's bound and its drop-then-copy for an owning element, in pieces no longer than the gap between the runs and in memmove's direction, so no source is overwritten before it is read; runs that do not overlap are one copyElements. A run past either end traps. Two windows of one buffer, one written (buf.sliceMut(…).copyFrom(source: buf.slice(…))), are refused, naming the overlap and this method. capacity() answers how many elements fit before the next append moves the buffer: reserve(n:) raises it to exactly n, and add/addAll raise it geometrically (double, or straight to what a large addAll needs).

Slices / spans — View<T> and ConstView<T> ✅#

A View<T> is a non-owning window over a contiguous run of T — a slice / span (zero copy, no ownership transfer) — and ConstView<T> is its read-only half (Rust &mut [T] / &[T], C# Span<T> / ReadOnlySpan<T>). Both are a type view (the stack-only-borrow kind; see Type declarations): the escape check keeps them from being stored, and the window rule below keeps them from outliving their buffer, so neither can dangle — without a borrow checker, and without lifetimes.

import { std::collections::DynamicArray, std::collections::View, std::collections::ConstView };
fn void uploadToGpu(ConstView<float32> window) { }

fn uint8 main() {
    DynamicArray<float32> verts = DynamicArray.empty();  // … fill …
    borrow verts.viewMut() as all {                         // a WINDOW — `verts` is frozen inside it
        View<float32> mid = all.slice(from: 2, count: 4);   // a sub-range [2, 6) — a derive, no new window
        foreach (ref float32 x in mid) { x = x * 2.0; }     // mutate-through: writes back to `verts`
        isize n = mid.length();   float32 first = mid[0];   // bounds-checked index (a place)
    }
    uploadToGpu(window: verts.slice(from: 0, count: 3));    // a read-only subrange down — no copy, no window needed
    verts.add(item: 1.0);                                   // mutable again: the window has closed
    return 0;
}

The window — borrow <place> as v { … } ✅#

borrow names something for a block. A place is second-class in kama — passed (ref/const ref parameters) and returned (fn ref T), never stored — so there is no ref local; borrow is how a place gets a name, and how a view gets the extent it is valid for. The extent is lexical: a borrow block is it, one C block plus one initializer per binding, no runtime cost — no lifetime annotation, and no programmer's promise.

borrow xs[i] as e { … }                    // an element — `e` IS `xs[i]`
borrow this.rig.joints[k] as j { … }       // a field path, and elements inside it
borrow m.getRefMut(key: name) as v { … }   // a call returning `ref`/`const ref`, with any arguments
borrow d.view() as v { … }                 // a view mint: `Viewable<ConstView<T>>` grants `view()`
borrow m.values() as vals { … }            // `ValuesIterable<I>` grants `values()` — `Map` has no `view()`

A host is a place or a mint, and nothing else:

Refused, each with its own sentence and no cascade after it: a bare local (borrow a as v — a already names its place), and a value: a call that returns one, a field of one (mk().x), or a mint on one (mk().view()), which nothing holds beyond the statement — bind it to a local first. A mint answers to access as any call does: a private one opens no window outside its type.

Four rules, and together they are what lets the paragraph above say "cannot dangle":

  1. The host's roots are FROZEN for the extent of the block — no assignment, no non-const ref/out argument, no give out of them, no non-const fn receiver overlapping them. The roots: a field path itself, an element's container, a mint's or a place-returning call's receiver, and every ref/const ref argument of that call — the signature lets the result point at any of them, so freezing the receiver alone would be unsound for a free fn ref. A mint on such a call (borrow h.items().viewMut() as v) freezes the call's roots the same way, so h.items().add(…) cannot grow the buffer v views. An element is a place in its container: a window over storage arr[0] owns (borrow arr[0].view() as w, foreach (x in arr[0])) freezes arr and every index of it, since arr[0] and arr[1] are disjoint only when the indices are — so arr[0].add(…), ref arr[1] and arr[1] = give z are refused there, and so is writing another index of xs while borrow xs[i] as e names one. Conflict is a prefix test over places, so a disjoint sibling field stays fully mutable: borrow this.buf.view() as b { this.hits = this.hits + 1; … } is legal, because two distinct fields cannot overlap in storage. Reads are untouched — a const fn call, an index, a foreach over the frozen host, and (under a view) an element written in place, which moves no buffer — arr[0][1] = 5 inside a window over arr[0], or arr[0].add(…) under borrow arr.view(), whose buffer stays where it is. One storage takes one name: two bindings over one container, or a window inside a window over the same storage, are refused — xs[0] and xs[1] are disjoint only when the indices are, which nothing proves in general. A window reached THROUGH an enclosing window's alias is not a second name: borrow tx.conn().transaction() as inner freezes tx for its block, so one name is usable at a time (a reborrow), and the windows close inside out.
  2. Nothing leaves the block. A view alias may not be reseated; a place alias is no value to store, give cannot move out of the host it points into (replace(place:, with:) swaps a value in), and a fn ref result is judged by what the alias points into — a place inside a local is refused, as returning the local's own place would be. Reading through the alias copies the value out, which is always fine.
  3. A view LOCAL's root must already be lifetime-bounded — a borrow alias, a by-value view parameter, a derive off either, or a call's result when every view handed to it was bounded and it takes nothing by ref: its own return rule let the result root at any of them, so h(w: w, d: arr) returning d.view() is a view of arr, which this frame can still grow. A view minted from a container you can still name is not bounded, because the next line may grow it, and is an error with the window as its remedy.
  4. A store may not outlive what it stores. A view binding's depth is the innermost window open where it was declared — the caller's, outside every window — and x = e needs e's window to enclose x's. Only a binding at the caller's depth is returnable. The rule reads no statement order, so a loop that reads x before a later store cannot get past it, and it keeps the shapes a parser is written in: the cursor loop rest = rest.slice(from: n, count: …), and an accumulator found in a loop and returned after it. What it refuses is outer = vb from a window nested inside outer's, which closes first, and ref T with T a view, which is a view by reference however it is spelled (std::memory::swap over two views). Rust proves the same thing with lifetimes and C# fixes a ref struct local's scope from its initializer; kama reads it off the block.

borrow works the same in every statement position — a function or method body, a loop body left by break/continue/return, a match arm, a generic body, a spawned child's body, a parallel_for body — and in a comptime fn, which borrows its own locals' elements and field paths and is checked by the same rules (a call that returns a place has no compile-time form). A place a spawned child holds by ref cannot be named until its scope joins it.

A view that closes its window. A type view may declare ~dtor. It frees nothing — a view owns nothing — and runs when the window that minted the view closes, on every path out of the block (its end, return, break, continue), after the block's own locals, and once; a window's several bindings close in reverse. So a transaction is a window that rolls back unless the code committed, with no guard object to store and no closure:

borrow conn.begin() as tx { tx.execute(sql: "…"); tx.commit(); }   // ~Tx() rolls back unless commit() ran

Such a view is pinned to its window, because a second one is a transaction no window ends — committing a copy commits nothing the window sees. A helper takes it ref/const ref; refused are a local of it, a copy of the alias, a by-value or out parameter, a return from anything but its own constructor or a granted mint, a type argument, a mint anywhere but a window (conn.begin().commit()), and a foreach over one a window already holds, which would copy it out of that window. A trap aborts without running it, as for every destructor; an @onPanic region may not reach one, since recovery skips it — the same rule as for any owner with a destructor. (A database server rolls back an uncommitted transaction when its connection drops, so an abort loses nothing the server kept.)

A foreach is the other window such a view lives in (KR-263), when it is an iterator: the loop makes it — its operand is the mint (foreach (Entry<K, V> e in live.drain())), or the container's own iterator()/iterMut()/ iterConst() returns one — holds it for the whole loop, and runs its destructor when the loop is left: run dry, break (from a match arm too), return (from a nested loop too), after the body's locals and the pass's binding, once; continue stays in the loop. So Map.drain() empties the map however the loop ends, Rust's HashMap::drain guarantee, with no guard to name. The operand is a borrow host's kind of mint — granted, nullary, on a receiver something names, reaching through no other closing view — and it is a call like any other: a non-const mint is refused on a read-only receiver and inside another loop over the same host. Refused too: a loop over a view a window already holds, in all three loop forms, and returning a ref binding of such a loop, or a field of one, whose place the view's destructor ends.

Why a window and not a ref local. ref Joint j = this.rig.joint(name: n); is safe only if its host stays frozen from the declaration to j's last use — a region nobody wrote. Rust infers that region (lifetimes and a borrow checker); C++ does not check it (auto& j = … dangles when the vector grows); C#'s ref locals lean on a GC and cannot point into a List<T>; Swift has none. kama has no lifetimes, so the frozen region is the visible block, the rule a view window already used — one construct for both. A ref local is a parse error that points here.

The rule covers every type view, not just View<T>: an iterator that borrows is a view. That is only expressible because the mint is read from the grant — Map has no view(), so before that, MapValueIter<int32> mi = m.values(); had no window it could open at all.

foreach and parallel_for are the same window under a different spelling. Each holds a borrowing iterator over its operand for the extent of the body, so the operand is frozen there by rule 2 — foreach (int32 x in d) { d.add(item: 9); } is a compile error, not the runtime panic the growable containers' mods counter used to raise. The operand roots through its receiver, so foreach (v in m.values()) freezes m, and an element operand is iterated where it lives and freezes its container: foreach (x in arr[0]) { arr.add(…) } is refused. The loop calls its container's mint itself, and a non-const one (iterMut(), an iterator() that writes) is a write to the container: refused over a read-only place, and inside another loop over the same container, as d.iterMut() written out would be. Reads stay free: a const fn call on the operand, an element write through a ref binding (that is what foreach (ref …) is for), and any disjoint container or sibling field. The mods counter remains as defense in depth for the unsafe/FFI paths that no static rule sees.

An iterator that owns its source — a resource, as std::io::Lines is — is advanced where it is named: foreach (string ln in ls) moves ls itself on, so what it recorded is still there after the loop (ls.error()), a second loop resumes where the first stopped, and ls is dropped once, at its own scope's end. It is frozen for the body and may not be read-only, since the loop writes it. One the operand MAKES (foreach (… in Lines::<File>.make(src: give f))) belongs to the loop, which drops it when it is left. A value or view iterator is copied into the loop, as every value is.

So is a match arm that binds a view of a lookup. A container may return an Optional view (public const fn Optional<ConstView<int32>> find(int32 k)), and match (s.find(k: 1)) takes it apart: an arm that binds the view holds the subject's roots — here s — still for that arm, its binding takes the arm's depth (rule 4), so it is stored nowhere outside the arm, and it is as returnable as the subject (a lookup on a ref parameter, yes; on a local, no). An arm that binds no view holds nothing, since the subject is reachable only through an arm's bindings, so lookup-or-insert is one match:

match (s.find(k: key)) {
    case Some(value: v): { use(v: v); }         // `s` is held still here
    case None:           { s.put(v: fresh); }   // and may grow here
};

Rust's NLL accepts the same shape (match map.get_mut(&k) { Some(v) => …, None => { map.insert(…); } }). A subject behind a Shared gets the arm the runtime access a window gets.

Views compose — a type that holds a view is a view ✅#

A zero-copy parser keeps views into its input. A type view may hold views, and an enum with a view payload is a view — Optional<V>, Result<V, E>, a user type enum with a view case — under every rule above: stack-only, never a field of anything but a view, never a collection element, returnable only where it borrows what the caller holds, and a local of it bounded by a window. So absence is in the type, not a sentinel:

import { std::collections::ConstView };

type view Tokens {
    ConstView<uint8> rest;
    public ctor over(ConstView<uint8> input) { this.rest = input; }   // no raw pointer: a public constructor
    public fn Optional<ConstView<uint8>> next() {                      // an Optional of a view is a view
        isize n = 0;
        while (n < this.rest.length() && this.rest[n] != 32) { n = n + 1; }
        if (n == 0 && this.rest.length() == 0) { return Optional::None; }
        ConstView<uint8> word = this.rest.slice(from: 0, count: n);   // a derive off `this`
        isize skip = n < this.rest.length() ? n + 1 : n;
        this.rest = this.rest.slice(from: skip, count: this.rest.length() - skip);
        return Optional::Some(value: word);
    }
}

fn uint8 main() {
    string text = "zero copy";
    int32 words = 0;
    borrow text.bytes() as b {
        Tokens t = Tokens.over(input: b);                               // bounded by the window `b` came from
        while (true) { match (t.next()) { case Some(value: w): { words = words + 1; } case None: { break; } }; }
    }
    return cast<uint8>(words);   // 2
}

Tokens is the view's pointer and length — 16 bytes on a 64-bit target, on the stack — and Optional<ConstView<uint8>> is that plus its tag. Nothing is allocated and no byte is copied.

A structure that must outlive the parse — an AST kept in a collection — keeps offsets into an owned buffer instead. That is what rustc itself does, and it is the honest limit of a language without lifetimes. Rust stores a borrow anywhere and proves it with lifetimes; C# lets a ref struct hold Span<T> and ref fields, and Swift a ~Escapable struct hold Span, with Optional of one accepted since SE-0465 — kama takes the second road, with no annotation.

The bytes of a plain value — @generate(BytesViewable) / @generate(Blittable) ✅#

A plain value that a program sends, hashes or uploads as-is wants its own in-memory bytes, not a serialization pass. The type hands them out itself, as string hands out s.bytes():

import { std::collections::ConstView, std::collections::View };
type enum Phase : uint8 { Lobby, Play }

@generate(BytesViewable)            // read: numbers, `bool`, `char`, an enum with a declared integer type
type value Round { public uint32 tick; public Phase phase; public bool live; public uint8 spare0; public uint8 spare1; }

@generate(Blittable)                // read and write: numbers only
type value Snapshot { public uint32 tick; public InlineArray<float32>#(64) pos; }

fn uint32 sum(ConstView<uint8> b) { uint32 s = 0; for (isize i = 0; i < b.length(); i++) { s = s + cast<uint32>(b[i]); } return s; }
fn void receive(View<uint8> into) { }
fn void step(ref Round r, ref Snapshot snap) {
    uint32 h = sum(b: r.bytes());                        // an argument needs no window
    borrow snap.bytesMut() as w { receive(into: w); };   // received in place
}

bytes() is a ConstView<uint8> over the value's storage and bytesMut() a View<uint8>; both are minted by the type, so the window rule applies as to any view. Two facts make them sound, and the compiler proves both:

A field qualifies when it is a number, a bool/char/declared-width enum (read only), an InlineArray or Simd of those, or a value that itself declares the view. An enum with payloads, an undeclared-width enum, a pointer and anything that owns or borrows memory are refused, naming the field; so are an enum, a resource and a type that already has a bytes(). On a generic type the view is conditional, as every derive is: each instance gets the strongest level its fields allow, and a use that wants more says why.

A run of plain elements is plain bytes too: an InlineArray, a ConstView<T> and a View<T> over such a T give bytes() (and over numbers bytesMut()), len * sizeof(T) bytes of the same buffer — borrow list.view() as v { send(bytes: v.bytes()); }. BytesViewable and Blittable<R, W> are the prelude contracts a bound names; Blittable refines BytesViewable.

The bytes are in host layout — a mirror, not a portable format; kbin is the portable one — and byte equality is equality of representation (-0.0 and 0.0 differ). A lone number has no byte view (a non-goal): a method on a number receives a copy, so there is no storage to view; std::num::byteswap* and kbin put a number on the wire.

Hash maps & sets (std::collections) ✅#

Map<K, V, H: Hasher = DefaultHasher, A: Allocator = GlobalAllocator> (open-addressing, linear-probing, tombstoned, grows at 0.75 load) and Set<K, H: Hasher = DefaultHasher, A: Allocator = GlobalAllocator> (a thin wrapper over Map<K, Unit, H, A>), over a key K: Hashable + Equatable (the trailing A is the custom allocator — see "Custom allocators" below). These two key contracts are in the prelude:

type contract Hashable  for value, resource, enum, intrinsic { const fn uint64 hash(); }
type contract Equatable<T is This> for value, resource, enum, intrinsic { const fn bool equals(const ref T other); }

Keys are hashed and compared by content, so a lookup key built any way (a concat, a fresh construction) finds the stored entry. string and every integer width satisfy both out of the box, via pure-kama type adapter blocks that ship in the prelude (universal — no std::collections import; no compiler blessing). hash() returns a cheap CONTENT hash — identity (cast<uint64>(this)) for an integer, FNV-1a over the UTF-8 bytes for a string — and the avalanche/mixer is a separate, pluggable step the Map/Set apply via their H: Hasher type parameter: slot = H::finish(k.hash()) & (cap-1). H defaults to DefaultHasher (splitmix64, strong avalanche, DoS-agnostic — see below) so Map<K, V> is unchanged; Map<K, V, FastHasher> swaps in a cheaper single-multiply mixer for trusted, well-distributed keys (the Hasher contract + both hashers live in std::collections). string declares Equatable with an empty body, satisfied by its built-in equals; each integer's is scalar (a conformance on a primitive — this is the scalar itself). Floats get Equatable only (exact ==) — intentionally not hash-keyable. A third prelude contract, type contract Comparable<T is This> for value, resource, enum, intrinsic { const fn Ordering compareTo(const ref T other); } (returning the prelude enum Ordering { Less, Equal, Greater }), gives every int/float/string a total order through the same pure-kama type adapter blocks — the bound for PriorityQueue and the sorted containers. A user key declares implements Hashable, Equatable<This> and provides the two methods. Bounds are nominal: the implements is required (a coincidental equals is not enough), the same rule as foreach.

import { std::collections::Map, std::collections::Set };

fn uint8 main() {
    Map<string, int32> counts = Map.empty();
    counts.put(key: "a", value: 1);
    counts.put(key: "a", value: 2);                        // overwrite (drops the old value)
    int32 v = match (counts.get(key: "a")) { case Some(value: x): x; case None: 0; };   // 2
    counts.remove(key: "a");   bool has = counts.contains(key: "b");   isize n = counts.length();

    Set<string> seen = Set.empty();
    seen.add(key: "x");   bool member = seen.contains(key: "x");
    return 0;
}

foreach (K k in map) / foreach (K k in set) iterates the keys (a by-value key iterator, present for a Copyable key; it guards against a mid-iteration put/remove like the DynamicArray iterator). foreach (V v in m.values()) iterates the values by copy (present for a Copyable value), and foreach (ref V v in m.valuesMut()) borrows every value in place to mutate it — the value analogue of iterMut(), and the way to walk a map whose key isn't Copyable — and foreach (const ref V v in m.valuesConst()) reads every value in place, from a const fn, so a function that only borrows a map (const ref Map) walks values that are not Copyable. copy m deep-copies a whole map (independent clone) — present only when both key and value are Copyable, gated by a multi-condition when [K: Copyable, V: Copyable]. foreach (Entry<K,V> e in m.entries()) iterates the key-value pairs by copy (e.key() / e.value()), present only when both key and value are Copyable — the pair analogue of iterator() (keys) and values(). Entry<K,V> is a named pair (the language has no tuple, by decision: a named pair is greppable and self-describing — explicit over implicit, GOALS 5 — and a one-line type value is the spelling); a key is never mutated in place (that would corrupt the table), so there is no entriesMut() — mutate values via valuesMut() / getRefMut.

drain() moves every pair out (KR-263; Rust's HashMap::drain), with no Copyable asked of either half:

foreach (Entry<string, Session> e in live.drain()) {           // live is empty after the loop, its table kept
    match (give e.intoParts()) { case Parts(key: k, value: s): { archived.put(key: give k, value: give s); } };
}

Each pair is moved once, as the owned Entry the loop binds, and taken apart by name with give e.intoParts() → EntryParts::Parts(key:, value:) (kama has no tuple). The map is empty after the loop however it ends — run dry, break, return — because the drain is a view that closes its window: the foreach holds it and its destructor drops what the loop did not take (A foreach is the other window). The table is kept for reuse. The loop freezes the map, so a put/remove in the body is refused, and a read is legal and sees exactly the pairs not yet taken. Map drains in table order, OrderedMap oldest first, SortedMap smallest key first; Set, OrderedSet and SortedSet drain their members the same way (foreach (string k in seen.drain())), and ConcurrentMap.drain() moves every pair into one Map, shard by shard under each shard's lock. Consuming a map is drain() and then dropping it, so there is no second, consuming spelling. The grant is the @viewable contract Drainable<I> (std::collections), as the other iterations are granted.

Map is move-only: it owns its keys and values (dropping the key + handing the value back on remove, dropping both on overwrite/clear/end of life — ASan/UBSan-clean for owning keys and values, e.g. Map<string, DynamicArray<string>>). Lookups borrow the key (ref K), so they don't consume a key you're holding. Three value accessors form a consistent trio: get(key:) -> Optional<V> hands back a deep copy (present only when V is Copyable); getRef(key:) -> const ref V borrows the stored value in place, read-only, and getRefMut(key:) -> ref V is its writable twin (any V — the accessor that makes a Map of move-only values like Owned/Shared/a collection first-class rather than write-only; panics on an absent key, so guard with contains first, as a map lookup is partial); remove(key:) -> Optional<V> moves the value out (None when absent — reclaim it or discard to drop). A key that is an inline rvalue — a string/number literal or a user-type ctor — is materialized into a temp automatically, so m.get(key: 5) / m.get(key: Point.make(x: 1, y: 2)) work without binding a local first.

std::collections also carries Deque<T> (a growable ring buffer — O(1) push/pop at both ends) and PriorityQueue<T: Comparable> (a binary heap). The queue is a min-heap by default (bare ctor or PriorityQueue.minHeap() — smallest out first, the fit for A* / event scheduling); PriorityQueue.maxHeap() inverts it. push(item:) and pop() -> Optional<T> are O(log n), peek() -> Optional<T> (copy, Copyable element) / peekRef() -> const ref T (read-only borrow, panics when empty; peekRefMut() to mutate in place) read the root O(1). It orders via the element's Comparable.compareTo, is move-only (deep-copies only for a Copyable element), and — since heap order isn't meaningful — isn't iterable; drain it with pop. (It's backed by a DynamicArray, which gained an O(1) swap(i:, j:) in-place element exchange.)

SlotMap<V> is a generational slot map (Rust's slotmap; the ECS entity / asset registry): insert(value:) -> Handle hands back a stable, Copyable Handle, and get(handle:) -> Optional<V> (copy, Copyable) / getRef(handle:) -> const ref V (read-only borrow, panics on a stale handle; getRefMut(handle:) writes) / remove(handle:) -> Optional<V> (moves out) all reject a stale handle — one whose slot was removed, or removed and reused for a different value — returning None (or panicking on getRef) instead of aliasing the new occupant. That's a per-slot generation counter (odd while occupied, bumped on every insert/remove), so a handle can safely outlive the value it names and a dangling handle is caught, not a use-after-free. contains(handle:), length/isEmpty, clear, and values()/valuesMut() (iterate the live values, copy or borrow) round it out. A Handle survives a serde round trip — save a slot map, load it, and every handle a caller stored elsewhere still resolves to the same value, which is the only reason to hold handles rather than indices. That is what the wire format is for: it preserves the slot LAYOUT — the per-slot generation array including vacant slots, then the live values in ascending slot order, then the free list as a stack whose last entry is the next slot reused ({"slots":[1,2,1],"values":[10,30],"free":[1]}) — so a hole costs a generation and no payload, and the size is proportional to the high-water slot count rather than to the live count. The free list is written because its order is state: it picks the slot the next insert reuses, so a map loaded and stepped is the map saved and stepped, which replay and rollback rely on. A free list that does not name every vacant slot exactly once is refused as Malformed. Only len is derived on read. The compact alternative (write only the live values, rebuild dense) was rejected for failing silently: a stale handle could then coincidentally match a rebuilt slot and alias the wrong value, which is the exact accident the generation check exists to prevent. Handle is itself Serializable, so the handles a caller stored travel beside the map; its index is an isize, which has no wire format, so the wire carries a fixed-width int64 and a handle written on one target reads back on another.

SortedMap<K: Comparable<K>, V> / SortedSet<K: Comparable<K>> are the ordered map/set — a B-tree (min-degree 6; peer: Rust BTreeMap, C++ std::map) keyed by Comparable.compareTo, not a hash. They mirror Map/Set (put/get(copy)/getRef(in-place borrow, panics absent)/remove -> Optional<V>/contains/ length/isEmpty/clear/deep copy/JSON serde), but keys stay sorted, so they add the ordered queries a hash map can't answer: first()/last() (min/max key), floor(key:)/ceil(key:) (nearest ≤ / ≥), range (from:, to:) (a half-open key window), and keys()/values() (resp. SortedSet.elements()) yielding a fresh DynamicArray of copies in ascending order (serde likewise writes its entries in ascending order). Each node backs its keys, values, and child boxes with DynamicArray (reusing its move-out / shift / RAII) and holds children as Owned<BTreeNode> heap boxes, so a subtree moves as one owned pointer and the splits (insert) / borrows + merges + predecessor-swaps (remove) relocate move-only keys and values with ownership intact. getRef forwards an in-place borrow up the tree via the escape checker's chained-ref-return rule (a fn ref T may return a place-returning method call whose receiver roots at this). SortedSet<K> wraps SortedMap<K, Unit>, as Set wraps Map.

OrderedMap<K: Hashable + Equatable<K>, V> / OrderedSet<K> keep the third order a map can have: insertion (Python's dict, Java's LinkedHashMap, .NET 9's OrderedDictionary). Lookup is O(1) as in Map, and every walk — foreach, values()/valuesMut()/valuesConst(), entries(), serde — goes in the order keys were first put; a put on a present key replaces the value and keeps the place. They mirror Map/Set member for member (H and A included) and add what order answers: first()/last() (the oldest and newest key, O(1)) and moveToEnd(key:) (an entry becomes the newest, its key and value relocated, not copied) — together an LRU: moveToEnd on a hit, evict first() at capacity. The layout is Python's compact dict, entries in order plus a hashed index of their positions. remove is O(1) and never moves an entry: it leaves a hole iteration skips. Holes are reclaimed in exactly two places, so a program chooses when their O(n) cost is paid: an insert that finds the entries full rebuilds them (in place when at least half are dead, growing otherwise — inserts stay amortized O(1)), and compact() does it on demand. compact() is reserve()'s mirror — one moves a growth, the other a compaction, out of the put that would pay it — so a frame or an audio callback pays neither. There is no positional access (at(index:)): with holes it would be O(n). Serde writes insertion order too: a text-keyed map is a keyed object in the order its keys were put (where Map sorts them), a repeated key in a document keeps its first position and takes its last value, other keys write Map's pair array, and a text-keyed OrderedMap merges per key, keeping its order.

Custom allocators ✅#

A collection's memory source is a trailing type parameter A: Allocator = GlobalAllocator. Because it defaults (default type parameters), DynamicArray<T> / Map<K,V> / bare BitSet are unchanged; the allocator is opt-in. Every container that manages its own heap buffer carries it: DynamicArray, Map, Set, Deque<T, A>, FixedArray<T, A>, BitSet<A> (its first type parameter, so a plain BitSet is now the all-defaulted instance), SlotMap<V, A>, OrderedMap<K, V, H, A>/OrderedSet<K, H, A>, and PriorityQueue<T, A> (which owns no buffer itself — it threads A to its embedded DynamicArray<T, A>). A stateful allocator arrives via a named ctor: withAllocator(allocator:) for the growable containers, withAllocator(allocator:, size:) and filledWithAllocator(allocator:, size:, value:) for the eager FixedArray, and withAllocator(allocator:, maxOrder:) for PriorityQueue. The ordered containers thread it too: SortedMap<K, V, A> / SortedSet<K, A> push A through the B-tree — the node contents (inner arrays) and every node box, the root included, draw from A (via the placement new(allocator:) BTreeNode below), so arena.reset() reclaims the whole tree. An Allocator is a copyable value handle (the C++ std::pmr::polymorphic_allocator / Rust &Bump / Zig std.mem.Allocator model), a three-method contract. It is foundational, so the Allocator contract and the default GlobalAllocator live in the global prelude (beside Comparable/Hashable) — both the collections and the smart pointers name them, and they survive --no-std. The concrete strategy types Arena/BumpAllocator stay in std::collections.

type contract Allocator for value {
    fn Optional<UnsafePtr> allocate(usize bytes, usize align);  // None on OOM/exhaustion — fallible seam (never panics)
    fn Optional<UnsafePtr> reallocate(UnsafePtr pointer, usize bytes, usize newBytes, usize align);
    fn void deallocate(UnsafePtr pointer, usize bytes, usize align);
}

reallocate resizes a block the allocator handed out and keeps its first min(bytes, newBytes) bytes. It may move the block, and the allocator does the whole job: in place when it can, otherwise a new block, the bytes, and its old block back. So None means what it means for allocate, out of memory, and the original block is then untouched and still the caller's. That is C's realloc contract, and Zig's remap. GlobalAllocator is realloc. An arena's BumpAllocator grows the block it handed out last where it stands, by moving its offset, and otherwise bumps a fresh block and copies. An allocator with nothing smarter to do allocates, copies and deallocates. A growing DynamicArray calls it instead of doing those three steps itself, because a resize can extend a block or remap its pages where a copy cannot. On Linux the difference was 5× on a megabyte-scale append (bench bulk): freed blocks went back to the kernel, and every round faulted them in again.

Both halves carry the block's layout — its size and its alignment (a power of two) — the model Rust's Layout and Zig's aligned allocation use. allocate must return storage aligned to align, since a type holding a Simd field needs 16 and an @align(64) type needs 64; deallocate is handed back exactly the layout the block was allocated with, so an allocator may trust it (a size-class pool keeps no per-block header). The compiler and the stdlib always pass sizeof/alignof of what they place, and the object's own layout (sizeof(ptr:)/alignof(ptr:)) when a base handle may hold a derived one. The container stores A alloc by value and routes every buffer through this.alloc.allocate/reallocate/deallocate; dispatch is a direct monomorphized call (no vtable), so a GlobalAllocator (a zero-size handle onto the runtime's allocation funnel, the system heap) costs nothing. A stateful allocator is a small handle pointing into a caller-owned Arena (one heap buffer, bump-allocated, reset() bulk-frees in O(1)); the arena must outlive the container — a documented contract, not a borrow-checked one (a raw UnsafePtr isn't escape-checked and there is no lifetime tracking). Since Kama has no constructor overloading, a stateful allocator arrives via a named ctor (DynamicArray.withAllocator(allocator:)), which assigns alloc on the value it builds. Allocator/GlobalAllocator are prelude (global, no import); Arena and BumpAllocator ship in std::collections:

import { std::collections::DynamicArray, std::collections::Map, std::collections::Arena, std::collections::BumpAllocator };

fn uint8 main() {
    Arena arena = Arena.make(capacity: 1 << 16);                             // caller-owned; drops last
    DynamicArray<int32, BumpAllocator> xs = DynamicArray.withAllocator(allocator: arena.handle());
    Map<int32, int32, A: BumpAllocator> m = Map.withAllocator(allocator: arena.handle());  // named arg skips H
    // ... fill/use; xs and m draw from the one arena; their deallocate is a no-op; the Arena frees the buffer.
    return 0;
}

Fallible seam (✅ shipped, MCU step 5). allocate returns Optional<UnsafePtr> — None on OOM/exhaustion, never panics. Infallible new and the direct-malloc containers (DynamicArray, Map, Set, Deque, FixedArray, BitSet, SlotMap, PriorityQueue) plus the boxed SortedMap/SortedSet B-tree keep the pre-step-5 panic-on-OOM behavior (they unwrap the Optional via the prelude unwrapPtr, panicking on None; try new takes the same pointer through ptrOrNull, the sibling that answers with a null pointer so the failure can become a None). The ONE non-panic construction entry is try new (below); user code that calls allocate directly can match on None (e.g. a bump/arena that stops at exhaustion instead of trapping — the game-engine frame-allocator / real-time pattern, not only MCU).

try new — non-panic construction ✅ (MCU step 5)#

try new T.make(...) yields Optional<Owned<T>> — None when the allocation fails, instead of panicking. It is the single fallible construction entry (new stays the infallible sugar that unwraps-or-panics); there is no parallel tryAllocate.

try accepts exactly what new accepts — they share one grammar rule, so the two spellings cannot drift: the placement form (try new(allocator: a) T.make(…)), the on-type turbofish (try new T::<A>.make(…)), and an interface-element handle (Optional<Owned<Contract>>, Optional<Shared<Contract>>) all work. Like try cast, it needs a declared destination to read Some's payload type from — a local, an argument, or the function's return type — which is what makes a fallible factory the ordinary shape.

Optional<Owned<Box>> b = try new Box.make(v: 7);
match (b) { case Some(value: x): use(box: ref x); case None: { /* OOM — recover, don't trap */ } };

fn Optional<Owned<Mesh>> load(string path) { return try new Mesh.parse(path: give path); }   // return position

// Placement: the block comes from the arena, and `None` is how an EXHAUSTED arena answers — no trap.
Optional<Owned<Shape, BumpAllocator>> s = try new(allocator: arena.handle()) Circle.make(r: 5);

Every allocation on this path answers None, including a Shared handle's control block. A bare try new into a box whose allocator is anything but GlobalAllocator is refused, because that block would be released through the wrong allocator. An arena-backed box is built with the placement form (tests/try_new_arena.kama, tests/try_new_iface.kama, tests/xfail/try_new_stateful_bare.kama).

No-heap subset ✅ (MCU step 5)#

A per-region @noheap function attribute and a whole-program --no-heap build flag make every emitter-visible heap allocation a compile error — new/try new, parallel_for/spawn argument boxing, error-boxing into Owned<Error>, and string interpolation's Formatter buffer all funnel through one gate. Target-independent (composes with --target embedded).

The two answer different questions. @noheap proves a BODY, so it refuses an allocation where it is written. --no-heap proves the PROGRAM, and a program is what its entry points reach: main, every expose fn, and every @foreignEntry body. A body nothing reaches is not part of it. An allocating helper nobody calls builds, and so does a stdlib module whose error paths allocate, merely imported. What a no-heap object promises follows: the reached program never allocates. A build compiles only that reached program, so an unreached body is not in the object at all (docs/packages.md); a kama transpile output, which keeps everything, may still name malloc in one. Every no-heap and bare-metal compile gets one section per function, so the board's link drops unreached code with --gc-sections (docs/targets.md).

@noheap is transitive, which is what makes it a proof rather than a lint: a @noheap body may not call anything that allocates, however many calls away it is, and the diagnostic names the chain (tick -> mix -> grow). Nothing has to be annotated for this — where the compiler can see the callee it infers, so an ordinary un-annotated helper is fine exactly when it is allocation-free (tests/noheap_chain.kama). Reachability includes destructors: owning a local whose ~T() allocates allocates, even though the body contains no call. That holds for the drops the compiler writes itself as well: owning a type whose FIELD frees, or an Optional whose payload does, reaches that free. The call graph is read from the C the build emits, so every call the compiler writes is an edge, whether or not any source line spells it.

The chain ends at libc, and GlobalAllocator is the leaf, unless the program declares a global allocator (below): a container or box drawing from it is rejected inside a no-heap region, including merely owning one (dropping it frees; free can block on the allocator's lock exactly as malloc can). The whole-program --no-heap flag applies the same leaf, so a no-heap build cannot reach malloc through a container either. A reached allocation is reported where it is written when that is in a body you wrote. Otherwise the diagnostic anchors on the innermost function you wrote, the frame holding the call you can change, and never on a prelude or std:: body, which would name code the author did not write and cannot edit. The same container over a bump allocator is fine and needs no annotation: A is a type parameter, so DynamicArray<T, BumpAllocator> is a different monomorph reaching a different allocate (tests/noheap_arena.kama) — which is the idiom a real-time region is expected to use. A new is judged the same way, by the allocator it draws from: a placement new(allocator: a) takes its block from a, so it is legal exactly when a is, while a bare new is refused as the libc allocation it is (tests/noheap_new_bump.kama). Under the whole-program flag the region itself must not come from the heap either: Arena.make draws its buffer from GlobalAllocator, so a --no-heap program backs a BumpAllocator with storage it owns.

The runtime's own C is READ, not declared. kama ships the runtime headers, so the compiler scans them the way it scans the C it emits: every static inline body is a node in the same call graph, and what a runtime extern does with the heap is DERIVED. # lines are skipped, so both arms of every #if are read — which is what makes a no-heap verdict the same on every target, mechanically rather than by anyone remembering to say so. The scan bottoms out in two leaves. The allocation funnel (kama_alloc/kama_realloc/kama_free) is an edge into the declared pool's entries when a program declares a global allocator, and a heap fact when it does not — so a pool-backed program may build a string, format a number and read its arguments, all from storage it owns. A foreign allocator — one that hands back a block the program must release through a route the funnel never served, such as getaddrinfo — is a fact always, pool or not.

Because that verdict is derived rather than declared, it reads the seam as written — so std::fs's PATH calls allocate on no target and a no-heap program may use them: exists, stat, rename, remove, createDir, removeDir and File.open in every OpenMode, with no pool declared. Windows converts UTF-8 to UTF-16 into a caller-owned stack buffer to earn that (the heap conversion it replaced was a fact on Linux and wasm too, since both arms are read). The directory and whole-file calls are not, for reasons of their own and on every target: readDir owns a DIR*/cursor, readFile and removeDirAll own a growing buffer, and createDirAll takes a substring for each parent.

C the compiler cannot read still declares itself. An extern fn into C kama does not ship is marked @heap, and a call to it is then an allocation fact like any the compiler writes: @heap extern fn UnsafePtr vendor_block_alloc(usize n);. The mark is on the SYMBOL, so a redeclaration without it does not unmark the call. An unmarked user extern is unchecked C, as it always was — unless its C names an allocator the compiler can see: a reached extern fn calloc is refused whether or not anyone marked it, because the call the compiler writes spells the name. @heap is refused on anything but an extern fn: a body's allocations are seen without it, and a fnptr has no C of its own. It is also refused on a symbol kama's own headers define — there the answer is derived, and a mark would not agree with it but override it: a mark is unconditional, so one placed on kama_fmt_i64 would refuse a formatted string whose bytes came from the program's own pool.

string is the one intrinsic that mints heap, and it is immutable, so every method that "changes" one returns a NEW owned string: a + b, concat, substring, trim*, replace, toLower/toUpper, truncate, split and a deep copy are all rejected in a no-heap region. So is foreach (string s in xs), which yields each element by value and deep-copies to do it — foreach (ref string s in xs) borrows and is legal. Reading a string is always legal (length, indexing, the predicates), and a string literal is a borrowed view that allocates nothing, so string tag = "voice"; belongs in a real-time body.

tests/noheap_realtime_ok.kama is the standing control for all of this: a literal, an InlineArray<T>#(N) with a borrow window and an in-place sortUnstable, and a borrowed heap object — the whole of it legal, with no annotation anywhere but the one attribute.

Where the compiler cannot see the callee, the target must declare the promise, and the call is otherwise rejected rather than assumed harmless: a fnptr or bound function pointer has no knowable target at all. A contract member or virtual method is different. It is dispatched through a slot, but a slot holds only a function some vtable the program builds installed there, and a build sees every one of them, so a call through it is an edge to every implementer the program defines — the class-hierarchy analysis that whole-program C++ compilers and Java JITs use. The call is legal exactly when each of those is, and the refusal names the slot it went through and the implementer that allocates: `total` -> `Area.area [Blob]` -> `Blob::area` -> …. The proof is over the program as built, so adding an implementer that allocates refuses every @noheap call through that slot, naming it. Marking the contract member @noheap states the promise once instead: every implementation is then checked against it, exactly as const fn already works on a contract (tests/noheap_contract.kama). A generic body bound by the contract (fn measure<T: Area>(T x)) calls each instance directly, and its chain names the instance as written, measure<Blob>. A virtual call the compiler devirtualizes (a final class, a final fn, or a method nobody overrides) is a direct call. One limit is outside any kama build: a host calling an expose fn can hand it a contract value whose vtable it built in C, and that vtable's functions are unchecked C, like an unmarked extern fn.

A destructor is a slot like any other. Dropping an Owned/Shared/Weak handle over a contract or a virtual base runs the concrete object's ~T() through the vtable, so the drop is an edge to every implementer's or subclass's destructor, and it is refused in a no-heap region when any of them frees to the system heap — even when the handle's own storage is an arena: `Shape.dtor [Circle]` -> `Circle::dtor`. Two declarations state the promise instead of leaving it to the implementers of the day: @noheap ~Base(), which every subclass destructor is then held to — a subclass that owns a field which frees must say so, and is refused when it cannot — and a contract declared for value, whose implementations own nothing and therefore have no destructor to dispatch (tests/noheap_drop_box_ok.kama). A handle over a concrete type is unaffected: its destructor is a direct call the analysis already reads. With a declared pool (@globalAllocator) every implementer's destructor frees into it, so a boxed error builds under --no-heap (tests/noheap_pool_serde.d).

The fnptr seam has the same escape, spelled on the signature: @noheap fnptr int32 Op(int32 x); makes the promise part of the named type, so every function bound to it must itself be @noheap — in every bind position, a local initializer, an assignment, a call argument, a field and a module static alike — and a call through such a slot is then legal inside a no-heap region (tests/noheap_fnptr.kama). One direction, as on a contract: a @noheap function may be bound to a plain signature, which constrains only itself. BindableFunctionPtr<Sig> inherits it, because its Sig is an fnptr type. Without this a callback — the shape a real-time system is built from — had no legal spelling in a @noheap region at all.

@noheap marks a body, so it goes on any declaration that has one: a free fn, and equally a method, ctor, destructor or operator. The member forms matter because a real-time entry point is naturally a method — synth.fill(…) — and a destructor is what runs at the end of a real-time scope. It is rejected where there is no body to gate: on a module static, and on the bodyless extern forms — an extern "<h>"; and an extern fn, whose bodies are C and cannot be checked at all. A fnptr is the exception, and by the same property rather than in spite of it: it declares a TYPE for someone else's body to satisfy, so the promise has somewhere to land (above). @interrupt and @section remain rejected on all three, because they attach to emitted code and a fnptr emits only a typedef. @interrupt stays free-function-only (a vector table reaches its handler by symbol, and a member's C name is mangled and takes a receiver); @section(".name") is legal on a free fn and a member. The other attribute a bodyless form accepts is @linkName("…") on an extern fn — it names a symbol, not a body, and an extern fn is the one bodyless form that has one (FFI — calling C); on a fnptr it is rejected, as it is on an ordinary fn, whose C name is kama's to choose.

@noheap fn int32 tick(int32 n) { /* new / "${x}" / spawn here is a compile error */ ... }

type value Mixer {                                     // ...and the same gate on a member
    @noheap public fn int32 fill(int32 n) { ... }      // the natural spelling for an audio callback
}

Global allocator — @globalAllocator ✅#

Every heap block a kama program's C obtains, resizes or releases goes through one funnel, the runtime's kama_alloc/kama_realloc/kama_free, and that includes each box, container buffer, string, error box and spawn bundle. It defaults to the platform allocator. A program replaces that default with a declaration:

import { std::concurrent::Atomic };

@globalAllocator type resource Pool implements GlobalHeap {
    InlineArray<uint64>#(4096) storage;   // storage the program owns
    Atomic<int32> lock;                   // every field starts at zero
    Atomic<usize> bumped;
    Atomic<usize> head;
    public unsafe fn Optional<UnsafePtr> allocate(usize bytes, usize align) { … }
    public unsafe fn Optional<UnsafePtr> reallocate(UnsafePtr pointer, usize bytes, usize newBytes, usize align) { … }
    public unsafe fn void deallocate(UnsafePtr pointer, usize bytes, usize align) { … }
}

GlobalHeap has the same three members as Allocator, and it is a separate contract because the two are different things. An Allocator is a handle: there are many of them, stored in containers and copied. GlobalHeap is the heap: there is exactly one, and only the funnel calls it. The default A, GlobalAllocator, is a handle onto the funnel, so it follows the declaration with no change. A bare DynamicArray<T> draws from the pool.

One instance, process-wide. The compiler emits Pool kama_global_allocator = {0}; once, in the entry translation unit, and every translation unit and every isolate calls into it. A block allocated in one isolate may be freed in another (tests/global_allocator_isolates.kama). The declaration is held to rules that follow from that:

Reaching the instance — globalHeap::<Pool>(). The pool is the only object that knows what the program has allocated, so a program can ask it: globalHeap::<Pool>() is a place (ref Pool) naming the one instance.

@globalAllocator type resource Pool implements GlobalHeap {
    Atomic<isize> live;                                       // blocks out and not yet returned
    public fn isize liveCount() { return this.live.load(); }   // …published by the pool, like any method
    …
}

isize n = globalHeap::<Pool>().liveCount();

The type argument names the declared allocator and is checked against it, so the dependency is written at the call site rather than resolved silently. Naming a type that is not the declared one is refused, and so is the call in a program that declares none. The result is the pool's ordinary surface: a private field stays private through it. Pool itself obeys the file rung like any other name, so a pool read from another file is exported and imported there. A fn ref Pool of the program's own may return the place — it roots in static storage (see Writing a collection in kama, place-returning methods).

No-heap. @noheap and --no-heap keep one meaning: never reaches the system heap. With a declaration, the funnel is the pool rather than the system heap. Every allocation the no-heap gate would refuse becomes a call into GlobalHeap::allocate, and the pool's own body decides. A pool over storage it owns therefore makes boxes, containers, strings and error boxes legal under both — and, because the runtime's own C is read rather than marked, that reaches the runtime too: a pool-backed program may format a number and read its arguments. A pool that calls a @heap extern is refused, and the chain runs through the pool (tick -> GlobalHeap::allocate -> Pool::allocate). --no-heap still refuses whatever the declaration does not cover: a @heap extern reached from anywhere, a slot call it cannot see through, and a foreign allocator, which no declaration can excuse — the pool never handed that block out, so it cannot take it back.

The sanitizer leg's layout check (KAMA_ALLOC_CHECK) wraps whichever implementation is active, so a declared pool is held to the same promise as the default: every deallocate receives exactly the layout its block was allocated with.

Foreign entry points — @foreignEntry / @callerThread ✅#

A function handed to C as a callback may run on a thread kama did not create: an audio render callback, a completion port, an RTOS ISR. Every module static is per isolate (_Thread_local, see Module statics), so on such a thread a static holds only its declared initialiser — never a value some isolate assigned — and a mixer that keeps its buffer in a static reads silence. The program compiles and runs. That is the failure this pair exists to make a compile error, and it is a declaration, not an inference, because the compiler genuinely cannot know: kama_run_loop is while (tick(state)) { } and calls back on the calling thread, CoreAudio's render callback does not, and nothing in either signature says which. The threading contract of a C API is knowledge only the seam author has.

Recoverable regions — @onPanic(recover: <literal>) ✅#

A panic — a bounds miss, a narrowing or arithmetic fault, division by zero, a bad shift, a float cast, panic, assert, out of memory — terminates the process, and in a real-time callback it does so from a thread the player cannot see: the mixer dies and the game goes silent. @onPanic names a region that recovers instead: the fault leaf still writes its message (a glitch stays visible on stderr), then control returns to the region's prologue, which returns the value the region declared. The mixer glitches and degrades.

@foreignEntry @noheap @onPanic(recover: 1)      // a TickFn returns 1 to keep the loop running
fn int32 tick(UnsafePtr state) { … }             // any panic inside, at any depth, returns 1

Allocator-aware new / Owned<T, A> / Shared<T, A> / Weak<T, A> ✅#

Heap-boxed objects draw from an allocator too: Owned<T, A: Allocator = GlobalAllocator>, Shared<T, A>, and Weak<T, A>. A bare new T.make(args) is unchanged (A defaults to GlobalAllocator → the system heap); a placement form new(allocator: a) T.make(args) draws the block from a and stores the handle in the box, so its dtor releases through the same allocator — letting a boxed object live in a caller-owned arena and be bulk-reclaimed on reset():

import { std::collections::Arena, std::collections::BumpAllocator };
type resource Node { int32 v; public ctor make(int32 v) { this.v = v; } }

fn uint8 main() {
    Arena arena = Arena.make(capacity: 1 << 12);                  // drops last (outlives the box)
    Owned<Node, BumpAllocator>  n = new(allocator: arena.handle()) Node.make(v: 42);
    Shared<Node, BumpAllocator> s = new(allocator: arena.handle()) Node.make(v: 7);   // pointee AND ctrl from the arena
    // n/s dtor deallocate() is a no-op; the objects live in the arena; the Arena frees the region.
    return 0;
}

The allocator must be spelled on the box type (Owned<T, A> / Shared<T, A>, explicit over implicit — a new(allocator: BumpAllocator) into a box spelled Shared<T> is a compile error). Any A but GlobalAllocator requires the placement form. A bare new draws from the global allocator while the box releases through A, so it is a compile error even for a stateless A, since the two families would meet only by accident. For Shared/Weak, both the pointee and the shared control block are drawn from A, and every handle carries its own copyable A value (copied through copy()/downgrade()/tryUpgrade()), so whichever handle observes strong == 0 && weak == 0 — even a Weak that outlived its Shared — frees the ctrl through the right allocator; arena.reset() reclaims a whole ref-counted graph. With allocator-aware new, SortedMap/SortedSet (whose B-tree nodes box through new/Owned) thread A through their full SortedMap<K, V, A> / SortedSet<K, A> form (every node box, the root included, placement-new from A). Interface-element boxes (Owned/Shared/Weak<Contract, A>, e.g. Shared<Shape, BumpAllocator>) draw from the allocator the same way: the type-erased fat handle ({obj, vtbl[, ctrl]}) grows a by-value A alloc + pointee objsize, so the pointee and control block are drawn from A and freed through it — completing allocator coverage for every box (concrete and contract-erased). Default-GlobalAllocator interface boxes are byte-identical to before (they keep the plain intrinsic macros). One design limit: object-graph serialization (@generate Shared/Weak/Owned edges) is GlobalAllocator-only — a graph edge spelling a stateful A is rejected at compile time (deserialize has no allocator on the wire).

Smart pointers ✅ (triad → prelude/built-in ✅ — embedded, always in scope, no import)#

The smart-pointer triad Owned/Shared/Weak is prelude / built-in — always in scope, no import. RAII-over-GC is the language (every new T.make(args) already targets a HeapOwner, and the compiler special-cases the triad throughout: HeapOwner/Deref, never-null checks, drop insertion, ctrl-block layout), so the ownership triad is as fundamental as int or UnsafePtr and shouldn't require an import. "Built-in" means always-available, not rewritten in C: they stay kama-defined (RAII resources over Deref/HeapOwner, refcounting in kama), loaded as part of the prelude like the primitive Hashable/Equatable conformances.

The compiler adds only what a library can't express: the type-erasure (fat pointer + vtable) that makes Owned<Shape>/Shared<Shape> over a contract work, new T.make(args) heap placement into any HeapOwner<T>, and — because it is their emitter — direct manipulation of their internals (ctrl blocks, shell-adopt, ownership transfer) in the emitted C of the serialization graph lowering, so that machinery leaks no public __-methods onto the triad.

Owned<T> — unique heap ownership (= Rust Box / C++ unique_ptr), zero overhead, move-only, auto-deref, RAII-freed. The kama surface stays pointer-free; the raw pointer is confined to the library. Use it for heap objects, recursive data structures, and polymorphic ownership.

Owned<Counter> c = new Counter.make(start: 40);     // `new` heap-boxes the ELEMENT type
c.bump();  int32 n = c.get();                        // auto-deref: . reaches the pointee
Owned<Counter> d = c;                                // MOVE: c is now empty (moved-from)
fn Owned<Node> make(int32 v) { return new Node.make(id: v); }   // inline `new` in return/arg position — factory, moves out

Move-only: copying/initializing/returning an Owned transfers ownership and invalidates the source, so the pointee is freed exactly once (RAII, with the pointee's destructor).

The foreign boundary — adopt in, release() out. HeapOwner<T>'s ctor adopt(UnsafePtr<T> raw) is how ownership enters kama from a host API (Rust's Box::from_raw); unsafe fn UnsafePtr<T> release() is how it leaves (Box::into_raw): it hands back the raw block and empties the handle, so the destructor frees nothing. That is the one sanctioned way for a value to outlive the frame that made it — hand it to the host's own userdata slot in one unsafe fn, borrow through it in the callback via a ref T parameter, and adopt it back to destroy. It is not a way to own through a raw pointer inside kama: a static will not hold a resource and a raw element will not be dropped or called through (see The raw seam). An Owned<T, A> over a custom allocator releases the same block; the host must give it back to adoptIn with that allocator, or free it as the allocator would. Shared<T> has no release(): its control block makes the ownership a count, not a pointer, so there is nothing a host could hold.

Where an adopted block comes from, and where it goes back. A bare new T.make(…) into any HeapOwner<T> — the stdlib's or one you write — draws the block from GlobalAllocator with T's layout, so the owner's destructor returns it there, with the object's own layout read before the drop: usize n = sizeof(ptr: this.p); usize a = alignof(ptr: this.p); drop(ptr: this.p); GlobalAllocator.make().deallocate(pointer: cast<UnsafePtr>(this.p), bytes: n, align: a);. Not libc free: kama's heap goes through one funnel that a program may replace, and the sanitizer leg checks every release against the layout its block was allocated with.

type resource World { int32 x; public ctor make() { this.x = 0; } }
unsafe fn UnsafePtr<World> install(Owned<World> w) { return w.release(); }   // the host's userdata now owns it
unsafe fn void teardown(UnsafePtr<World> raw) { Owned<World> back = Owned.adopt(raw: raw); }   // dropped here

Ownership hand-off — give / copy. When a named owned value is handed off — in an initializer, assignment, argument, or return — an explicit marker states the intent, uniformly in all four positions: give moves (invalidates the source), copy retains (Shared/Weak) or duplicates. Every owning kind is movable; what varies is whether it is also Copyable and what a bare hand-off defaults to. Owned is move-only — a bare hand-off moves, and copy Owned is an error (it's unique). Shared/Weak are Copyable and declare bare: copy, so a bare hand-off retains (refcount++); copy is the explicit retain, and give still moves the handle — the ref transfers and the source is consumed (this is how a Shared returns from a factory without a spurious retain/drop). A value/primitive just copies. A fresh new/constructor/call result needs no marker. Smart pointers also pass by value: the callee owns the argument and drops it at function end — fn int32 use(Owned<T> p) consumes it (use(p: give x)), fn int32 peek(Shared<T> s) retains it (peek(s: x), x stays valid).

Owned<Counter> b = give a;   // explicit move (a consumed)
Shared<Counter> t = s;       // copy/retain (default) — both valid
Shared<Counter> u = copy s;  // explicit retain (same as bare); `give s` moves the handle (s consumed)

(copy of a collection is a deep copy — a fresh buffer, element-wise: a bitwise-copyable element is copied memberwise, a Copyable-resource element is deep-copied via its own copy ctor. A resource element that is not Copyable is rejected. give of a collection moves the buffer.)

Reading a value after give moved it is a compile error — including out of a declaration's initializer, and including an element-slot store v[i] = give s, and a variant payload E::V(f: give s). Those three are spelled out because "uniformly" above is a claim about the hand-off SHAPE as much as about the type, and it was true of string in only some of the shapes until 0.9.436 — with nothing here able to tell, since the fixtures tested the site and tested the type but never the intersection.

this is a named value. In a method it names the receiver the CALLER owns, so the method may hand off copy this (into a local, an argument, a return, a payload), and a bare hand-off takes the type's bare default, but it never moves the receiver: give this, or a bare hand-off that would move, is refused. In a named ctor this is the value under construction, the ctor's own, and give this hands it back.

A ternary hands off one arm, so each arm is the hand-off. c ? a : b over a type that owns something chooses between values; it never bit-copies one. Where it is handed off, a named arm carries the marker the same value would carry alone: string label = useName ? copy name : copy fallback;. A fresh arm, such as a literal, a call or a constructor, needs no marker: useName ? copy name : "anonymous". A bare named arm takes its type's bare default when that default leaves the source alive: a Shared retains, and a Copyable(bare: copy) resource copies. Otherwise the arm is refused, and so is a give in any arm. A ternary runs one arm, so a move there would leave the value moved on one path and live on the other. That is the "moved on some paths" error an if gets. A move nested deeper in an arm, such as f ? wrap(s: give a) : … or the right side of &&/||, is judged the same way. Where the ternary is borrowed instead of handed off, it is a place: a receiver, a ref/const ref argument, a match subject, an operand. (useName ? name : fallback).length() reads the chosen string where it lives and copies nothing. A fresh arm there is held in a temporary, which is dropped only if that arm ran, and a marker there is refused as on any other sub-expression.

A value-producing match hands off one arm the same way. A named owning value in an arm carries its marker, whether it is a local from outside the match or the arm's own binding: case A: copy t;, case Some(value: x): give x;. A bare named arm takes its type's bare default only when that default leaves the source alive, as a ternary arm does; otherwise it is refused. Unlike a ternary arm, an arm may give: every arm's moves meet at the end of the match, as an if's branches meet, so a value given in one arm and not another is the "moved on some paths" error an if gets. A value-producing match may also be assigned to an existing binding or field of any type, string included.

Move-only resource values + the Copyable contract. A type resource value (it owns something, or has identity) is move-only: a bare named hand-off moves (the source is consumed, its destructor suppressed), so its heap is freed exactly once — a silent copy is never emitted (that would double-free). give is optional emphasis; copy is an error unless the type opts in. A resource opts into copy nominally — implements Copyable<This>(bare: …) (the prelude contract Copyable<T is This> { ctor copy(ref T source); }) plus a public copy constructor (a lone copy ctor without the implements does not make a type copyable). It is a ctor because a copy is a new object — the same reason a self-returning static fn is rejected as a disguised constructor; the source is borrowed (ref This), since copying never consumes it — and borrowed mutably, not const ref, because a retaining copy bumps a refcount reached through the source and const is deep (the write is refused through a const ref, raw pointer or not). The copy ctor must spell that ref itself: a parameter's ref-constness is part of the signature (below, const ref), so ctor copy(const ref This source) does not conform even when it never writes through the source. That costs nothing at the call: a Copyable resource element still copies out of a const ref container, since the one __copy funnel casts the const place. Copying never consumes it. Opting in requires declaring the bare-hand-off default: Copyable<This>(bare: give) (a bare hand-off moves) or Copyable<This>(bare: copy) (a bare hand-off deep-copies). A marker (give x / copy x) always overrides the default; there is no "ambiguous — must annotate" error. Because copy/give are markers only in expression position, they're contextual keywords — usable as member names, so the opt-in ctor is literally named copy.

import { std::collections::DynamicArray };

type resource Res implements Copyable<This>(bare: copy) {   // a bare hand-off deep-copies
    DynamicArray<int32> items;
    ~Res() { }
    public ctor make() { this.items = DynamicArray.empty(); }
    public ctor copy(ref Res source) { this.items = copy source.items; }     // the Copyable ctor
}
fn uint8 main() {
    Res a = Res.make();
    Res b = copy a;   // deep copy — a stays valid, b has its own buffer
    Res c = give b;   // move — b consumed
    Res d = a;        // bare — follows the declared default (here: deep-copy)
    return 0;
}

Auto-deref — the Deref<T> / DerefMut<T> contracts. The standard smart pointers forward member access to their pointee (ptr.method()/ptr.field reach the held T). Any type can opt into the same auto-deref by implementing the prelude contracts — the implements is the explicit gate:

type contract Deref<T>    for value, resource { const fn const ref T deref(); }   // the read-only place
type contract DerefMut<T> for value, resource { fn ref T derefMut(); }           // the writable place

When a member isn't found on the wrapper itself, it resolves on the pointee T and is called through a place into the pointee: derefMut() when the type implements DerefMut<T> and the receiver is mutable, else deref(), which is a const fn returning a const ref T — so a const ref Owned<T> reaches its pointee's const fns, and a type implementing only Deref<T> forwards reads and refuses a write or a non-const call through it. Resolution is recursive, so deref chains. Both places root at this, so they are bound by the same second-class-borrow rules as ref T operator[] (no lifetimes needed). This is how a smart pointer is written as an ordinary resource (RAII + move-only come free) rather than a compiler intrinsic; Owned/Shared implement both halves.

type value Point { public int32 x; public int32 y; public const fn int32 sum() { return this.x + this.y; }
                   public ctor make(int32 x, int32 y) { this.x = x; this.y = y; } }
type value BoxP implements Deref<Point>, DerefMut<Point> {
    Point inner;
    public ctor make(Point p) { this.inner = p; }
    public const fn const ref Point deref() { return this.inner; }
    public fn ref Point derefMut() { return this.inner; }
}
fn uint8 main() {
    BoxP b = BoxP.make(p: Point.make(x: 30, y: 12));
    int32 s = b.sum();   // auto-deref -> Point__sum(BoxP__derefMut(&b))  (42)
    int32 x = b.x;       // auto-deref -> BoxP__derefMut(&b)->x           (30)
    b.x = 1;             // a write goes through `derefMut()`; with only `Deref<Point>` it is refused
    return 0;
}

The give/copy behavior matrix. Every owning kind is movable; a bare hand-off follows the kind's default (a Copyable type must declare it with bare:), and an explicit marker overrides. The rule is uniform across all four hand-off positions — initializer, assignment, argument, return — and a fresh rvalue (new/constructor/call result) never takes a marker.

kind bare hand-off give copy
primitive / value copy (cheap) copy (a value's move is a copy) copy (redundant, allowed)
Owned<T> (unique) move move (emphasis) ⛔ "is unique"
Shared<T> (ref-counted) retain (strong++) move (transfer the handle) retain (explicit)
Weak<T> (weak ref) retain (weak++) move (transfer the handle) retain (explicit)
collection (FixedArray/DynamicArray/string) ⛔ marker required move (buffer) deep copy (fresh buffer)
plain resource (move-only value) move move (emphasis) ⛔ "opt into Copyable"
Copyable resource (has a copy ctor) its declared bare: default move deep copy via copy
collection of Copyable elements ⛔ marker required move deep copy (element-wise copy)
enum whose payload owns move move (emphasis) deep copy when every payload copies (variant-wise), else ⛔ naming the payload

A marker on a fresh rvalue is an error. Move tracking is compile-time: reading a moved value, moving out of a field/element, and a conditional move that is still live at scope exit are all rejected — there is no runtime drop flag. Assigning a moved local gives it a value again, and every later use is sound; the old value is dropped only if it is still live, so a give before the assignment, or one on its right side (r = wrap(r: give r)), leaves nothing to drop:

one.add(item: give t);              // t is moved…
t = Tree::Node(kids: give one);     // …and holds a value again

A local moved on only some paths cannot be assigned: whether to drop its old value would be a run-time question. Moving, inside a loop, a value declared outside it is allowed only when every path back to the loop's start — the end of the body, and each continue — assigns it again; otherwise the next pass would move it a second time. A break carries its state out of the loop, so a local moved before a break and live at the loop's own exit is moved on some paths only. A while or for condition runs before every pass, so a value it moves is held to the same rule, and since the loop ends on a failed test it is moved after the loop (while (eat(b: give w)) { w = …; }); a do … while condition is a pass's last step, so nothing can give a value it moved a new one before the next.

Shadowing is a compile error — kama has none. A binding may not take the name of a parameter, an enclosing-scope local, or an in-scope field of the enclosing type (C#-aligned; one name = one binding within any live scope — keeps both name resolution and move tracking unambiguous). Every binding is covered, not just a declaration — a for counter, a foreach variable and a match payload binding are each refused the same three ways. Nor may a binding take the name of a function, type, module static or module constant in scope — declared in its file or imported into it — and with nothing implicit in scope (KR-87) that needs no exception. "In scope" is what a use is held to, so a sibling file's private name, a sibling's export this file does not import, and a dependency's file-private extern are free to take: a call to any of them from here is refused as not visible. A field is a binding too: it is reachable bare in its type's methods, so it is held to the same rule, and may not share a name with a method of its own type. The language's names — Optional, Result, Owned, Ordering, the core contracts, every name the prelude declares — are in every scope like string, so no binding and no declaration may take one. A pattern's label names the variant's field and is unrestricted (case V4(a: o1)); it is the binding beside it that must be fresh. Sibling scopes may reuse a name freely (they never coexist), whatever binds it — two sequential for loops over i are ordinary, because the counter dies with its loop. A parameter sharing a field's name — the this.x = x constructor idiom — is allowed; a static method has no this, so a local there can never shadow a field, and neither exemption is narrowed by the rule above.

Shared<T> — ref-counted shared ownership (= C++ shared_ptr / Rust Rc). Copyable: each copy retains (refcount++), each drop releases, and the pointee is destroyed when the last handle goes away.

Shared<Tex> a = new Tex.make(id: 7);
Shared<Tex> b = a;     // retain — a and b share one Tex (both valid)
b.use();  int32 n = a.id;
// a, b drop in RAII order; the Tex is freed exactly once, with the last handle

Weak<T> — a non-owning weak reference to a Shared<T>'s pointee. It does not keep the pointee alive, so it breaks reference cycles that Shared alone would leak. You can't dereference a Weak (it may be dead) — upgrade it with the checked tryUpgrade(), which returns an Optional<Shared<T>> you must match on, so the dead case is impossible to ignore:

Weak<Tex> w = s.downgrade();                  // make a weak ref from a Shared (does not keep Tex alive)
int32 id = match (w.tryUpgrade()) {           // -> Optional<Shared<Tex>>
    case Some(value: up): up.id;                     // alive: use the upgraded Shared
    case None: -1;                            // dead: the cycle-safe path
};

A value being dropped is already dead to its Weaks. While the last Shared's destructor runs, tryUpgrade() answers None — so a destructor that upgrades a weak back-edge to its own value (an observer, a parent pointer) can never hand out a Shared to memory about to be freed. Rust's Weak::upgrade answers the same way.

Two handles to one value cannot pull its storage out from under each other. Two Shared handles to one value are two names the place rules cannot tell apart, so kama checks at run time what they check statically, with Swift's model. A conflict traps, in every build, naming both sites:

Shared<Core> b = a;
borrow a.first() as x {   // a window: an access to the value for its extent
    b.grow();             // TRAP at grow's `this.items.add` — "made at …:2 … (core.kama:9)"
}

What the model counts:

Rust's Rc forbids mutation without a RefCell, whose borrow flag panics on a conflicting borrow_mut for the whole value. Swift traps an overlapping access to a class property at run time, as kama does here.

.downgrade() is the only way to get a Weak, for a handle over a contract and over a concrete type alike. A Shared given where a Weak is wanted (Weak<Tex> w = s;, an argument, a return, a field) is refused, naming s.downgrade(). Boxing and upcasts change what a handle owns; this would change whether it owns, which is written down. An Owned has no weak handle.

No null (safe surface) — see GOALS §3b. A value, Owned/Shared, ref/out borrow, or contract value is always valid: there is nothing to null-check. null is only for UnsafePtr<T> at the FFI boundary, and that holds in both directions and for every other type — a safe type can neither be compared to null (== null / != null is a compile error; the C habit checks the wrong thing here) nor set to it. int32 x = null;, Thing t = null;, a string field defaulted to null, x = null and x == null on any of them are all rejected, and so is null == null, which asks nothing; model absence with Optional<T>, or use a zero value. The rule reads the declared type, so it covers primitives — and it does not care whether you are inside an unsafe fn, which changes what may be dereferenced, not what may be null. An unresolved or FFI type name is left alone, since a C typedef for a pointer is a legitimate null target. A Weak<T>'s liveness is obtained through tryUpgrade() -> Optional<Shared<T>>, whose result forces you to handle the dead case.

Passing a smart pointer: borrow it by passing ref T — the borrow names the object (ref T, storage-agnostic; a ref may not name the smart pointer itself), which auto-derefs to the held object; or transfer by value, where the callee owns the argument and drops it at function end (Owned moves in, Shared retains). The pointee is a value/resource or a contract — Owned/Shared/Weak<Shape> own a concrete implementer behind a fat handle and dispatch polymorphically (see Contracts below). A smart pointer works as a field, return, and a collection element — DynamicArray<Shared<Shape>> stores and drops each handle in RAII order and dispatches polymorphically through it. See Generics below.

Handing out what a place holds — replace and swap ✅#

A field or an element cannot be given: that would leave its owner holding a moved-from value. std::memory has the two moves that keep the place full instead. replace(place:, with:) hands back what place held and leaves with there, and swap(a:, b:) exchanges what two places hold. Nothing is ever moved-from, so the move state has nothing new to track. Unlike the triad, they are an ordinary import, and take is replace(place: x, with: T.default()).

import { std::memory::replace, std::collections::DynamicArray };

type resource Batch {
    DynamicArray<int64> items;
    public ctor make() { this.items = DynamicArray.empty(); }
}
type resource Builder {
    Batch batch;
    public ctor make() { this.batch = Batch.make(); }
    public fn Batch finish() { return replace(place: this.batch, with: Batch.make()); }   // a fresh batch stays
}
fn uint8 main() { Builder b = Builder.make(); Batch done = b.finish(); return 0; }

Functions ✅#

fn int32 add(int32 a, int32 b) { return a + b; }
fn uint8 main() { return cast<uint8>(add(b: 20, a: 10)); }   // named args; reordered to declared order

Arguments are evaluated in the order they are written, left to right, whatever order the parameters are declared in: add(b: f(), a: g()) calls f first. An enum variant's payload is evaluated the same way. So both(a: copy s, b: give s) copies s before it moves, and a give s written BEFORE a copy s leaves nothing for the copy to read, which is refused. C leaves a call's argument order unspecified; kama does not.

ref and out parameters both pass by pointer, but they are different promises:

fn void divmod(int32 a, int32 b, out int32 q, out int32 r) { q = a / b; r = a % b; }
fn uint8 main() {
    slot int32 quotient; slot int32 rem;
    divmod(a: 17, b: 5, q: out quotient, r: out rem);   // 3, 2
    return 0;
}

const ref T x is a read-only borrow, and it is what a literal or a temporary may bind to. Neither has an address, so the compiler materialises one into a temp — which is what lets m.get(key: 5), readFile(path: "some/path") and useShape(a: Square(3)) be written without binding a local first. Any other value works the same way: take(v: a + 1), take(v: count()), take(v: -a), take(v: mk().y), each held in storage of the parameter's type. That temp dies at the end of the statement — before the next one runs, as a call receiver's does — so a non-const ref/out cannot take one: the callee's write would land in storage nothing can read back. The repair is whichever the callee meant — bind a local if it really writes, or say const ref if it never did. Prefer const ref for any parameter you only read; it is what makes the borrow usable at a call site.

No borrow in a call overlaps a mutable one. Two ref/out arguments naming overlapping places hand the callee two mutable borrows of one object, and so does a ref/out argument overlapping the receiver of a non-const fn. A const ref argument is a borrow too: overlapping a mutable argument or such a receiver, it lets the callee change or free what it reads (two(a: o, b: ref o), b.absorb(other: b)). Each is refused at the call. The test is the window's prefix test over places, so disjoint fields stay legal: f(a: ref p.lo, b: p.hi). A receiver that is an element is compared by what holds it, so an argument that CONTAINS the receiver is refused too: this.items[0].touch(owner: ref this) would let the method grow this.items and move its own this. A receiver behind a contract — an Owned<Handler> element — is judged by the contract member's signature, the only one in view.

"Every path" is a real flow merge, not "assigned somewhere": an if/else in which both arms assign counts, a lone if does not, and an arm that ends in return/break/continue never reaches the join and so owes nothing to it.

A non-void function must return on every path. Reaching the closing brace without a value is a compile error in kama itself — not a C-compiler diagnostic against generated code, which the language server could not see. A path satisfies it by RETURNING or by DIVERGING, so all of these are accepted: an if/else where both arms return; a match where every arm does (a match is exhaustive by construction); a tail call to panic; and a loop that cannot exit (while (true) / for (;;) with no break). void functions may fall off the end. The analysis is deliberately one-sided — it reports only what it can prove, so a construct it does not model costs a diagnostic, never a false rejection. (Fixtures: tests/return_paths.kama for what must be accepted, tests/xfail/missing_return for what must not.)

FFI — calling C ✅#

extern fn Ret name(params); declares a C function's call signature (name + named params for lowering); the C prototype comes from the header you extern "<header.h>"; — kama never emits a prototype for an extern function (so there's no redeclaration conflict, and a missing include is a plain C error). Link libraries with --link. The FFI boundary is the language's only "unsafe" seam (explicitly extern):

extern "<stdlib.h>";             // every C function comes from an explicit header
extern "<math.h>";
extern fn UnsafePtr  malloc(usize n);     // UnsafePtr = void* (opaque pointer/handle); usize = size_t
extern fn void free(UnsafePtr p);
extern fn float64 sqrt(float64 x);  // libm auto-links when a program `extern "<math.h>";`s (pay-for-use)

unsafe fn int32 demo() {          // calling an `extern fn` is the unsafe seam
    UnsafePtr p = malloc(n: 64);
    if (p == null) { return 1; }  // hold / null-check / compare — but no deref yet
    free(p: p);
    return cast<int32>(sqrt(x: 1764.0));   // 42
}
fn uint8 main() { return cast<uint8>(demo()); }   // safe code may call an `unsafe fn`

The FFI rule (one sentence): declare C types/functions by extern-including their header. An extern declaration is purely kama's call-signature (name + named params, so it can lower the call) — the actual C prototype comes from the header you include with extern "<header.h>";. kama never emits a C prototype for an extern function, so there are no redeclaration conflicts; and the runtime hides its own libc dependencies (block-scope declarations), so no C function (not even malloc) is available without its header — a missing include is a plain C error, never a silent guess.

An extern is a file-private declaration, like any other. A file names an extern it declares, or one it imports from a file that exports it — a type extern value and an extern fn alike, keeping its literal C spelling either way. A file that names one it neither declares nor imports is refused. Repeating the declaration in another file is still legal — every declaration of one C symbol must agree (tests/xfail/extern_disagree), and a file in a loose build root, with no module to import from, has no other way. Exporting an extern widens nothing: CALLING one requires an unsafe fn wherever it is imported; wrapping it in an ordinary fn is how a module offers a SAFE surface instead. tools/check-extern-rung.sh holds all of it.

@linkName("symbol") binds an extern fn to a C symbol spelled differently from its kama name — Rust's #[link_name]. The kama name is what the file calls; the string is what the call emits. It is how a C function whose name is a kama keyword gets a binding at all, and how a binding takes a name the API's own is not:

extern "adder.h";
@linkName("match") extern fn int32 cMatch(int32 a, int32 b);          // C's `match` — a kama keyword
@linkName("kama_test_add") extern fn int32 plus(int32 a, int32 b);

The string is a C symbol — letters, digits and _, not starting with a digit — and not a C keyword; it takes exactly one string literal, like @section. One C symbol has one kama binding in a program: extern fn abs in one file and @linkName("abs") extern fn magnitude in another is an error, because the agreement rule below compares declarations by kama name and two names for one symbol would let them disagree in silence. (tests/linkname_extern.d/.) Repeating extern fn UnsafePtr malloc(usize n); in each file that calls malloc is the idiom, not a smell — it is what a C header does, and a declaration is not a definition. A file that would rather not repeat it wraps the extern in an ordinary fn and exports that; the wrapper costs nothing, because --release folds the program into one translation unit and a pass-through compiles to the same instructions as the direct call.

On a type extern value itself, @linkName names the C struct — a header's vec2_t bound as kama's Vec2. kama code writes the kama name, imports and exports it by that name, and sees it in diagnostics; the emitted C spells the struct's own name. (core::PanicInfo is the runtime's kama_panic_info this way.)

On a field of a type extern value, @linkName names the C member — the same attribute, for a struct field a header spelled with a kama keyword or a name the binding would rather not take:

extern "settings.h";                     // struct Settings { BaseSettings base; int match; float mass; }
type extern value BaseSettings { public int32 up; public int32 slope; }
type extern value Settings {
    @linkName("base")  public BaseSettings baseSettings;
    @linkName("match") public int32 matchCount;
    public float32 mass;
}

kama code writes the kama name (s.baseSettings.up = 1, Settings.of(baseSettings: …)); the C reads the member. It marks only an extern value's fields, since every other type's fields are kama's to spell, and one member of the header's struct has one kama field.

Every declaration of one C symbol in a program must agree — same return type, same parameter names, same parameter types. They are one entry: kama emits no prototype, so nothing downstream could catch a mismatch, and calls are lowered by named argument, so two declarations differing only in parameter order would silently reorder one file's arguments (memcpy(dst:, src:) emitting memcpy(src, dst, n)). The same holds for a type extern value — matching field names and types. A disagreement is an error naming both files.

UnsafePtr is void*; UnsafePtr<T> is T* — an opaque carrier (hold, pass to/from C, null-check, compare; no dereference in kama outside an unsafe fn). UnsafeConstPtr<T> is the read-only twin, T const* (bare, const void*): same carrier, no store through it, no conversion back to UnsafePtr<T> (see unsafe fn). usize/isize map to size_t/ptrdiff_t. Names beginning kama_ are reserved (runtime-provided).

Pointer arithmetic is not in the language — p + n on a raw pointer is an error, in both the bare and the typed form, and so are -p, ~p and null as any arithmetic operand. An offset is addr(of: p[i]), which scales by the element type (a bare UnsafePtr takes a cast<UnsafePtr<uint8>>(…) first), and an address that is genuinely being computed becomes a number with cast<usize>(p). Carrying + as well would be a second spelling of the same step, and on a bare UnsafePtr a byte-stepping one that disagrees with the typed form. Comparisons stay: a carrier is null-checked and compared, and the check is written out — a raw pointer is not a condition, so C's if (p) is if (p != null).

A cast converts between scalars and pointers, on both sides — an aggregate is neither, so cast<UnsafePtr>(someInlineArray) is refused exactly as a cast TO an aggregate always was. An aggregate's storage address is addr(of: arr[0]), and an element's address is an ordinary usize (cast<usize>(addr(of: arr[0]))) — the shape an allocator's own bookkeeping is written in.

Math (std::math) ✅#

Engine Tier-0 linear algebra — concrete float32 value types: Vec2/3/4, Mat2/3/4, Quat, plus a full scalar surface over libm. import { std::math::Vec3, std::math::Mat4, std::math::sqrt, std::math::sin, … };.

One name per scalar operation, at BOTH float widths — the width is inferred from the argument, so sqrt(x: 1.0) is a float64 call and sqrt(x: 1.0f32) a float32 one. That matters because a bare 1.0 literal in kama is a float64, so a float32-only module made sin(x: 1.0) a type error for the most obvious thing a reader would write. kama has no overloading, so the usual answers were unavailable (C suffixes every float32 entry point, Go and Java ship one width and make you convert, C# adds a second class MathF); the mechanism used instead is kama's own — a contract with a type adapter impl per width, exactly how Comparable reaches every primitive. Each operation is one generic free function over Real, and the per-width libm call lives in the impls: sqrt cbrt sin cos tan asin acos atan exp log log2 log10 floor ceil round trunc abs (one argument) and pow fmod atan2 hypot (two).

Real is exported, so a user type can join in — type value MyFixed implements Real<This> { … } and every function above works on it. Its methods carry the same names as the free functions (as Rust's Float::sqrt and Swift's squareRoot() do), so an implementer writes public fn MyFixed sqrt(). atan2 keeps C's (y, x) meaning, but the arguments are named, so the classic mix-up cannot happen silently. The engine helpers that have no libm counterpart stay float32 under their kama names: pi/tau/halfPi/epsilon/radians/degrees/lerp/signf — constants are zero-arg functions because a zero-argument generic has nothing to infer from. The seam is kama_math.h: a kama function cannot share a name with the extern it calls, so the binding is renamed rather than the API (the same rename is now spelled in kama as @linkName("sqrt") extern fn … cSqrt(…); the header predates it).

A generic call infers from any argument whose type is known, including another generic call's result: abs(x: sin(x: t)) is abs<float64>, because the inner call is solved first. A field, an element and a ?: infer the same way, and so do this and its fields in a member body (cos(x: this.yaw)). One limit worth knowing: a ref parameter may not name a smart pointer, so a contract instantiated at Owned<T> — e.g. Order<Owned<T>> — is not expressible; sort or compare the resources themselves. Methods + operators (one operator* per type: matrices/quaternions compose, vector transform / rotate are named methods — no overloading). Matrices are column-major with the column-vector convention (result = M * v, GPU/WebGPU-native); perspective/orthographic/lookAt target WebGPU 0..1 depth, right-handed. Quat is a unit quaternion (fromAxisAngle/fromEuler, Hamilton *, rotate, slerp/ nlerp, toMat3/toMat4). All literals are f32-suffixed (a bare 1.0 is float64). SIMD needs no explicit vector types or intrinsics: the value types have a SIMD-ready contiguous layout (Vec4 = 16 B, Mat4 = 4×Vec4), and in a --release build the C backend auto-vectorizes the elementwise ops (Vec4 +/-/scale, Mat4.transform, Mat4*Mat4) to NEON on aarch64, SSE on the x86-64 baseline, and v128 on wasm (the wasm build passes -msimd128; see targets.md Platform notes) — landing hot math at C parity, and often better than a hand-written vector type would: clang de-interleaves the array to SoA registers and computes four dots or four transforms at once. This relies on the ops inlining into the caller, which release builds guarantee (see Building & debugging — release compiles as one translation unit). Results are bit-identical to the scalar path (the exact-value semantics are unchanged; SIMD is a pure throughput property). Quat's Hamilton product is intentionally left scalar — its shuffled ± pattern makes a hand-vectorized version slower than the 16 pipelined scalar FMAs on measured hardware (ARM64), which is the same effect that makes the auto-vectorized path win generally.

⚠️ One limit, measured. An arbitrary shuffle or a lane mask as a value has no spelling in kama at all, at any target: those are what an explicit SIMD surface would add, and auto-vectorization cannot produce them from scalar source — see Explicit SIMD below. (The other limit this paragraph used to carry — that a wasm build got no vector instructions at all — was real and is fixed: the driver now passes -msimd128 on every wasm build. Both halves of the claim above are held down by guards that read real machine code, tools/check-simd-native.sh and tools/check-simd-wasm.sh; it went wrong twice for want of them.)

Numbers (std::num) ✅#

Numeric type limits as zero-arg functions — int8Min/Max … int64Min/Max, uint8Max … uint64Max, float32Max/float32MinNormal/float32Epsilon (signed min is -max - 1) — and per-width integer operations — min/max/clamp, ONE generic each over Comparable<T> (every integer width, float32/float64 under their total order, string, a user type implementing Comparable<This>; min/max answer a on Equal, clamp panics on lo > hi), plus per-width absI32/signI32 (+ I64: no contract names an integer that can be negated) — and explicit wrapping arithmetic wrappingAddI32/wrappingSubI32/wrappingMulI32/ wrappingNegI32 (+ I64) for intentional overflow. import { std::num::int32Max, std::num::min, std::num::wrappingAddI32, … };. The minI32/minI64/minf ladder that spelled the width in the name is gone: a literal beside a typed sibling now binds T (generic inference defers an unsuffixed literal), so min(a: n, b: 9) with isize n is the whole spelling.

The wide product — mulWideU64(a:, b:) -> Wide64 { hi, lo }, mulHighU64 (the top 64 bits, Lemire's multiply-shift in one call) and the signed mulWideI64 -> WideI64 — is the answer to "kama has no int128": a native 128-bit width is a non-goal (__int128 exists in clang and gcc on 64-bit targets only, so it would be a numeric type that exists on some targets, which the fixed-width position forbids), while the two things that reach for one — a Fixed<int64> backing's intermediate product, an unbiased random range — need exactly this product, which is four 32-bit limb multiplies in plain unsigned arithmetic on every target.

A number at a byte offset, in a stated order — loadU16/loadU32/loadU64(from: ConstView<uint8>, at:, order:) and storeU16/storeU32/storeU64(into: View<uint8>, at:, value:, order:), with ByteOrder { Little, Big } the format's order (KR-270): loadU32(from: hdr.view(), at: 8, order: ByteOrder::Little) reads a glTF header's length. The order is one typed argument, as Go's binary.LittleEndian and Zig's .little are, not a suffix on twelve names; signed widths go through bitcast; bytes past the view's end trap, naming the caller's line, as an index does. byteswap* stays the value transform; host-order helpers stay out, since a stated order needs no knowledge of the host.

Sorting & searching (std::collections) ✅#

import { std::collections::sort, std::collections::sortUnstable, std::collections::binarySearch, std::collections::lowerBound, std::collections::isSorted, std::collections::Order, … };.

Free functions over a View<T>, not methods on each container. One implementation therefore serves DynamicArray, FixedArray and any sub-range — sort(items: xs.sliceMut(from: 1, count: 4)) orders a window and leaves everything outside it untouched, which a per-container xs.sort() could not express. View<T> gained swap/reverse to support this: a view is second-class in escape, not in mutability (it already writes through its place-returning operator[]), and putting the raw move there keeps every algorithm above it safe. Descending order needs no API — sort, then reverse.

Two guarantees, deliberately both. sort is stable and sortUnstable is an in-place introsort (median-of-3 quicksort, insertion-sort cutoff, heapsort depth fallback, so the worst case stays O(n log n)). Stability is what makes sorting by a minor key and then a major key produce the intended answer; an in-place sort is what an MCU or an audio callback can afford. The stable form sorts an index permutation and applies it with swaps, which keeps it O(n log n) and free of a Copyable bound, so move-only elements sort stably too — the cost is the int32 buffers, and therefore the heap. sort/sortWith are @compileFor(!NOHEAP), so a --no-heap build does not silently reach the allocator: they simply do not exist there, and the diagnostic says so.

Ordering comes from a contract, never a function pointer. Comparable gives the natural order; Order<T> supplies any other, through sortWith/sortUnstableWith/binarySearchWith/lowerBoundWith. A comparator is an object, so it may carry state (a key index, a direction, a collation table) — which is what stands in for a capturing closure, since kama has none. It is also the faster choice: a C: Order<T> bound monomorphizes to a direct, inlinable call, where an fnptr is an indirect call the C compiler cannot inline (the reason qsort trails std::sort). fnptr could not express it in any case — a function-pointer type takes no type parameters (ROADMAP_DETAIL §2).

The same object is kama's closure — the generic functor. Anything a capturing closure would hold is a field: declare the callback signature as a contract (type contract Handler<E> for resource { fn void call(ref E e); }), write one type resource per handler with its captured state as fields, and either store it — an event table is a DynamicArray<Owned<Handler<Click>>>, dispatched through the contract — or lend it for one call, the non-escaping case where Rust would borrow locals: the functor is passed ref, never moved, and the caller reads its state back afterwards. The lent form is spelled as a generic bound, fn void each<T, V: Visitor<T>>(ref DynamicArray<T> xs, ref V v), because a concrete value cannot be passed to a ref parameter of contract type (mutable references are invariant — see Contracts), and the bound monomorphizes to a direct call. There is no prelude Callable family: without variadic generics it would be an arity ladder, and a callback signature is the library's own contract, as Order<T> is. What a closure would add is syntax only — the handler type, its ctor and the conformance line written for you; not borrowed captures, which no lifetime tracking means kama would not have in either spelling. It is sized and not scheduled (ROADMAP_DETAIL §2).

Because a ref parameter may not name a smart pointer, Order<Owned<T>> is not instantiable: sort a container of the resources themselves. Searching splits what Rust folds into Result<usize, usize> — kama's Result<T, E> constrains E to Error, so binarySearch returns Optional<int32> (the first index of an equal run) and lowerBound returns the total insertion point.

Rust's sync::{Mutex, RwLock} are std::concurrent's Locked and ReadWriteLocked, whose value is reachable only inside a lock window that cannot wait (see Concurrency). Once has no counterpart, and that is a stance, not a gap: a module static is per-isolate and takes a compile-time initializer, so there is nothing to initialize once.

Parsing (std::fmt) ✅#

import { std::fmt::parse, std::fmt::parseRadix, std::fmt::ParseError }; — the exact inverse of this module's intStr/f64Str side (std.fmt.parseInt is Zig's placement too).

import { std::fmt::parse, std::fmt::ParseError };
fn Result<int32, ParseError> readCount(string text) {
    Result<int32, ParseError> r = parse::<int32>(s: text);
    return r;
}

A parse fails, it does not come up absent, so the result is Result, not Optional — GOALS #3d draws exactly that line — and ParseError separates Empty / InvalidDigit / OutOfRange, because "not a number" and "too big for this type" want different messages. Rust, Zig and Go all keep that distinction; only the boolean and optional shapes discard it.

One generic spelling, no parseI32/parseI64 ladder. The mechanism is the serde one — a marker contract (Parseable) plus a per-type type adapter impl supplying a fallible ctor, reached as T.fromStr(...). The turbofish is required because nothing in the arguments mentions T. Covers int8…int64, uint8…uint64, float32/float64 and bool (exactly "true"/"false"). parseRadix adds bases 2..36 for the integer widths, case-insensitive, with no 0x/0b prefix — the base is already an argument. Parsing is strict, as in Rust: no whitespace is trimmed and a trailing byte is an error, so " 7" and "7x" both fail. A float is read in Rust's spelling — a sign, then digits with an optional fraction and exponent, or inf/infinity/nan in any case — so a hex float and a NaN payload fail too, and it is converted once, by strtod or strtof. Only overflow is OutOfRange; a value too small for the type rounds to zero or a subnormal, as a literal does. A JSON number into a float field follows the same rules, and one out of range or malformed fails the document.

ASCII (std::ascii) ✅#

import { std::ascii::isDigit, std::ascii::isAlpha, std::ascii::isSpace, std::ascii::toLower, … }; — isDigit, isHexDigit, isAlpha, isAlnum, isSpace, isUpper, isLower, isPunct, isControl, isAscii, toLower, toUpper, digitValue, all over char.

Named ascii rather than char for two reasons: char is a keyword, so std::char cannot be a module path; and the name states the limit in every import line instead of a footnote. This is the same boundary Zig draws with std.ascii; text beyond ASCII is std::unicode (below). Every predicate is false for a non-ASCII codepoint rather than guessing, and toLower/toUpper return one unchanged — so they can never corrupt one. Free functions rather than methods because char and uint32 share a C type and the conformance registry cannot hold both.

Unicode normalization (std::unicode) ✅#

import { std::unicode::normalize, std::unicode::NormalizationForm }; — normalize(s:, form:) returns s in one of UAX #15's four forms, and isNormalized(s:, form:) asks whether it already is. One text can be spelled several ways in code points (é precomposed or as e + a combining accent; the ligature fi; a full-width A), and normalizing picks one spelling, so two strings that mean the same compare equal byte for byte. Nfd/Nfc are the canonical forms, which keep the text exactly, decomposed or recomposed. Nfkd/Nfkc also fold compatibility variants into plain letters — normalize(s: "Afi", form: NormalizationForm::Nfkc) is "Afi" — which is what SASLprep applies to a password before SCRAM hashes it, and what an identifier comparison or a search index usually wants. A string in and a string out: UTF-8 both ways. The tables are the Unicode Character Database at one pinned version, generated (tools/gen-unicode) and held to the UCD's own NormalizationTest.txt, every line of it, by tools/check-unicode.sh. They are read-only data in a program that imports this module and in no other. Unicode casing, whitespace and grapheme segmentation are the module's to add (ROADMAP_DETAIL §2).

Fixed-point — Fixed<B> comptime(int32 F). A signed binary fixed-point type value in the same module, for FPU-less targets and for exact fractional arithmetic: + - * / through operator overloading (multiply and divide widen through int64 and re-scale), fromInt/toInt/fromFloat/toFloat, and saturating satAdd/satSub/satMul. The base operators trap on overflow like every other integer op above; the sat* forms clamp. Pure library, no compiler support.

Both halves of the format are parameters. B is the backing integer, bounded by the FixedBacking<B> contract (int8/int16/int32; int64 is not one today, because wide() widens into an int64 — a native int128 is a non-goal, and std::num::mulWideI64 is the 128-bit product an int64 backing would widen through, an additive change if a consumer wants it), and F is the fraction count as a comptime parameter — so Fixed<int32>#(16) is the classic Q16.16 and Fixed<int16>#(8) is Q8.8. The backing is passed, not computed from a bit count: kama has no type-level computation — a type is passed, never computed, which is the simplicity line GOALS 4 draws — and Rust's fixed and C++'s fixed_point<Rep, Exponent> pass storage explicitly for the same reason. Pairing a fraction with a backing too narrow to hold it (Fixed<int8>#(16)) is a compile error, from one comptime assert in the type's own body reading sizeof(B) — not a rule the compiler knows about this type. See MCU_READINESS.md for the no-FPU story it belongs to.

Arithmetic on two values of one type yields that type. uint8 + uint8 is a uint8, at every width — kama's own rule, the one Rust, Swift and Go have, and not C's integer promotion, which would make the result an int and every sub-int expression a narrowing on the way back out:

fn uint8 hexDigit(uint8 v) {
    if (v < 10ui8) { return 48ui8 + v; }   // `uint8 + uint8` : uint8 — no cast, nothing to convert
    return 97ui8 + (v - 10ui8);
}

Two consequences worth stating, because C answers both differently:

C's promotion is not part of kama's surface, which is the point: a reader should not have to know it to predict which lines need a cast. The emitted C carries an explicit narrowing so the two agree.

There is no implicit numeric conversion. If two numeric types differ, the conversion is written down — the Rust/Swift/Go rule. It applies in two places:

It is every crossing, not just narrowing — widening, a signedness flip and int/float in either direction are all conversions. What is not a conversion, and needs no cast:

a literal, typed by its destination — or by the other operand int8 a = 100; · float32 f = 3; · v < 10 on a uint8
arithmetic over literals, which is still the literal — including a shift of an unsuffixed literal whose value fits an int32 int8 a = 2 + 3; · isize n = 1 << 16;
arithmetic on one type, which yields that type a + b on two uint8s
a shift, whose count is a count and not a co-operand x << someInt32 on an int64
a literal handed to a generic T, which takes the width its typed siblings bind pick(a: 0, b: n) on an isize n
a literal OPERAND of a generic argument, typed by the other operand sqrt(x: 1.0 + a) on a float32 a is sqrt<float32>
a match whose every arm is a literal, typed by its destination return match (k) { case A: 0; case B: 1; }; from an isize function
a bitcast literal, typed by the target's width bitcast<uint64>(1.0) reads 1.0 as a float64

A named constant is not a literal: comptime int32 N = 5; states a type, so int8 x = N; wants a cast. A constant that does not fit its destination is rejected for that instead (int8 a = 300;).

A float literal is typed the same way. Beside a float32 — an operand, a ternary's other arm, a destination, a cast<float32> — an unsuffixed 0.319 is a float32, so x == 0.319 compares two float32s and x * 0.1 is float32 arithmetic, exactly as x == 0.319f32 and x * 0.1f32 are. Its value is the decimal rounded once, to float32, and compile-time code computes it the same way (tests/comptime_float32_literal.kama). One past the range it is typed to is rejected: float32 f = 1e39;, like 1e39f32 and 1e999, has no value to give.

A ref parameter is a destination like any other. A literal has no address, so one bound to a const ref is materialised into a temp — and that temp is the parameter's storage, typed by the parameter: a.contains(item: 2) on a DynamicArray<int64> stores an int64, and the same call on a DynamicArray<isize> stores an isize. It reads as a detail of the lowering and is not one. Typing the temp from the literal instead handed the callee a four-byte slot to read eight bytes out of, and the container answered false for a value it held (tests/constref_literal_width.kama, fixed in 0.9.138).

isize is the size type; usize is the C ABI#

A length, a count and an index are an isize — every collection's length()/count(), every operator[], every index parameter, string/Fixed/View included. usize is reserved for quantities crossing into C: sizeof, an allocation size, an extern fn mirroring a size_t.

isize/usize are platform-varying (ptrdiff_t/size_t — 8 bytes on x86_64/arm64, 4 on wasm32/thumbv6m), which is why they keep size in their names: the name is what says a crossing to a fixed width needs a cast, and isize → int32 is a genuine narrowing on a 64-bit host. The other two platform-varying types are clong/culong, C's long/unsigned long — 4 bytes on Windows, 8 elsewhere, so an extern mirroring a C long must say clong, never isize. All four take the same refusals: bitcast is refused (a reinterpret that holds on one target breaks on the next), and there is no wire format. A compile-time sizeof of one is this build's number, as a run-time one is: 8 on x86_64 Linux, 4 on Windows or wasm32.

cchar is C's char, and never a value. kama's char is a 32-bit codepoint; C's is a byte that is a third type beside int8 and uint8, and a libc prototype spelled const char* warns on either. So cchar exists only as what a raw pointer points at — UnsafeConstPtr<cchar> is const char* (what string.cstr() returns), UnsafePtr<cchar> is char*, nested and in a type extern value field alike — and every bare use is a compile error: a local, a field, a parameter or return outside a pointer, a generic argument, a cast target, sizeof, and reading p[i] through one; the bytes are a cast<UnsafePtr<uint8>> away. A raw pointer's char meaning C char was the alternative, and is a non-goal: DynamicArray<char>.dataPtr() would declare a 1-byte stride over a 4-byte buffer.

The size type is signed, which is the part that is easy to get wrong. The intuition says a length cannot be negative, so make it unsigned — but unsigned does not prevent the invalid state, it makes it unrepresentable, so an erroneous negative becomes an enormous positive instead of an obvious -1:

usize len = 0;   usize last = len - 1;    // 18446744073709551615 — silently
isize len = 0;   isize last = len - 1;    // -1, which fails `< length` and trips a bounds check

kama traps signed overflow in every build and lets unsigned wrap (it is defined), so usize would put the most common length expression, len - 1, in the one arithmetic domain with no protection. The collections already relied on signedness: operator[] bounds-checks i < 0 || i >= len, a test that cannot be written against an unsigned index. Go's len() -> int, Swift's Int, Python's Py_ssize_t (PEP 353) and C++20's std::ssize() all landed in the same place; the unsigned camp (C, C++, Rust, Zig) predates the lesson.

They carry the four value contracts, and deliberately not the two wire ones. isize/usize implement Formattable, Hashable, Equatable and Comparable, so a length can be interpolated, be a Map/Set key, be contains-searched and be sorted — the four a size type needs, given that every length(), count() and index is one. They implement neither Serializable nor Deserializable, and they are the only primitives that do not: a stream is read by a program that is not the one that wrote it, so a field whose width is ptrdiff_t would be 8 bytes written natively and 4 read on wasm32. A platform-varying width has no wire format. Give a serialized field a fixed width (int64/uint64) and cast at the boundary; @generate(Serializable) over an isize field is an error naming the field.

The same line divides everything else about them: bitcast is refused (an equal-width reinterpret that compiles on one target is a different program on the next), while Formattable's widening cast<int64> and a compile-time sizeof — this build's number, like the run-time one — are not. What decides is whether the operation needs ONE width on every target.

Bare int is not a kama type. It was an alias for int32 carrying no information of its own, and a reader coming from C or Go would expect a platform width from the name — the opposite of what it meant. Write int32 for a fixed 32-bit integer, or isize for a size. double is gone the same way — it aliased float64, and every kama float states its width. Neither uint nor float ever existed, but both get the same diagnostic, because a C or Go reader will try them and "unknown type" would send them hunting for a missing import instead of a different spelling.

The rule holds through a binding. A foreach element and a match-arm payload are typed values like any other, so both of these are errors wanting a cast — they are not a hole the rule quietly skips:

foreach (int64 x in xs) { int8 n = x; }                              // error, not 44
int8 n = match (big()) { case Some(value: c): c; case None: 0i8; };  // error, not 44

Where kama cannot be certain of a type it still says nothing rather than guessing — a type parameter, a comptime parameter, an extern fn result, an intrinsic with no declared return type, and a value-producing match seen before its arms are bound. Silence there is deliberate: a rule built on a classifier that confuses "this is a primitive" with "I have no idea" is either silent on every primitive or fires on every unresolved name.

Two types are two types, even when they share a representation#

The rule above is about width. This one is about identity: a value must already have its destination's type, and sharing a machine representation does not make two types one. It reaches the three kinds of type that a C backend would otherwise let blur together.

A plain enum is not an integer, and not another enum. Its values are named variants, so:

type enum A : uint8  { A0, A1, A2 }
type enum B : uint8  { B0, B1, B2 }

A a = A::A2;
B b = a;              // error — two distinct enums, whatever tag width they share
uint8 n = a;          // error — an enum is not its underlying integer
A back = n;           // error — an integer names no variant until it has been checked
A z = 0;              // error — an enum value is written by name: `A::A0`
A c = a + A::A1;      // error — the result would be an `A` that is no declared variant
bool q = (a < A::A1); // error — ordering is `Comparable`: `@generate(Comparable)` on `A` gives it one

(tests/xfail/identity_enum_cross_enum.kama, identity_enum_to_int, identity_int_to_enum, identity_enum_int_literal, identity_enum_arith, identity_enum_order, identity_enum_cmp_cross.)

The two directions have different doors, because they are not symmetric. Enum → integer is total, so it is an ordinary cast: cast<int32>(Code::Bad). Integer → enum is fallible by construction — an arbitrary integer names no variant — so it is try cast<Color>(x), yielding Optional<Color>, and the None arm is where a byte off a wire gets handled. ==/!= between two values of the same enum is the one operator that stays; everything else goes through match.

char is one Unicode codepoint, not a number. s[i] is a uint8 (a byte); .chars() yields codepoints. The two never cross implicitly, in either direction:

char c = 'a';   uint32 u = 65ui32;
uint32 n = c;   // error — cast<uint32>(c)
char d = u;     // error — cast<char>(u)
char e = 65;    // error — write the character: 'A'

That last line is where contextual literal typing stops. float32 f = 3; is accepted because 3 is a float32; 65 is not a codepoint, it is an integer standing in for one. (tests/xfail/identity_char_to_int.kama, tests/xfail/identity_char_literal.kama.)

A fnptr signature type is nominal. Two signatures with the same shape are still two types, and assigning one signature-typed value into another is an error — including, and especially, when the shapes differ, since calling through a mismatched function pointer is undefined behavior that neither the C compiler nor the sanitizers will report here. Assigning a function to a signature is checked structurally and is unaffected. (tests/xfail/identity_sig_cross_sig.kama, tests/xfail/identity_sig_arity.kama.)

A user class is not another user class. Two type value/type resource declarations are two types however alike their fields, and a value of one is never accepted where the other is declared — in a local initializer, an assignment, an argument, a return, or an operator's operand:

type value Mat4 { … }   type value Vec4 { … }
Vec4 v = …;
Mat4 m = v;             // error — unrelated types; convert explicitly, or take a contract both implement
Mat4 p = m * v;         // error — `Mat4 * Vec4` needs an operator declared for that pair

The rule holds inside a generic body too: fn f<T>(T x) { Mat4 m = x; } is judged per instantiation, and is an error exactly when T is bound to something other than Mat4. (tests/xfail/class_identity_value.kama, class_identity_operator, class_identity_generic_fn, class_identity_generic_type.)

What this rule does not touch: a contract destination (which admits every kind by design, so Hashable h = someInt32; keeps working), an inheritance upcast, Owned/Shared/Optional promotion, and any hand-off whose type kama cannot resolve — the same silence the width rule keeps. Every exemption is pinned, running, by tests/identity_exemptions.kama. Binding a value to a contract it does not implement is a separate error (tests/xfail/contract_arg_nonconforming.kama and its two siblings).

No undefined behavior in arithmetic (Rust's model). Every integer operation is defined — never C's UB:

Enforced by the compiler's own checks in the emitted C (KAMA_DIV/KAMA_MOD/KAMA_SHL/KAMA_SHR, kama_f2i_chk, the narrowing checks) + -fwrapv — so a kama program can't hit arithmetic UB whether built debug or release, and every fault it can raise takes ONE path: a message, the panic hook, recovery inside an @onPanic region. Until 0.9.160 division by zero, a bad shift and a float cast were -fsanitize-trap — the same compare-and-branch, lowered to a bare __builtin_trap that printed nothing, ran no hook and could not be recovered from; and any -fsanitize at all is what made emscripten refuse -sWASM_WORKERS, so there was no audio thread in the browser. ⚠️ The two tiers differ in exactly one macro definition, and that is load-bearing rather than incidental. -fsanitize=signed-integer-overflow used to supply the debug trap, and it was not reliably suppressed by -fwrapv (Apple clang does not suppress it; Ubuntu clang and gcc do), so passing it in release made overflow trap on one platform and wrap on another and cost a compare-and-branch on every signed add and multiply — against an invariant that calls release arithmetic C-parity. Since 0.9.161 no tier passes it: the debug check is KAMA_ADD & co. under !NDEBUG, and under NDEBUG the same macros are the plain operator, so the release tier relies on -fwrapv alone with the one case it does not define (TYPE_MIN / -1) checked explicitly. tools/check-release-arith.sh asserts both the semantics and the zero cost, on a tier the fixture suite cannot build.

import { std::math::Vec3, std::math::Mat4 };
fn uint8 main() {
    Mat4 vp = Mat4.perspective(fovyRad: 1.0472f32, aspect: 1.777f32, near: 0.1f32, far: 100.0f32)
            * Mat4.lookAt(eye: Vec3.of(x: 0.0f32, y: 2.0f32, z: 5.0f32),
                           center: Vec3.zero(), up: Vec3.unitY());   // method chaining
    Vec3 p = vp.transformPoint(p: Vec3.of(x: 1.0f32, y: 0.0f32, z: 0.0f32));
    return cast<uint8>(p.length());
}

Standard I/O (std::io / std::fs / std::net / std::process) ✅#

A native, single-binary I/O foundation — library over FFI, no new language surface beyond the prelude's enum Unit (the empty Result<Unit, E> payload — one error convention for void-fallible ops). std::io gives IoError + error classification; std::fs gives a RAII File (fd closed by its destructor; opened Read, Write — create/truncate — Append, ReadWrite — create, never truncate — or CreateNew, which is AlreadyExists if anything is at the path, in the same step as the create), with random access: seek (a SeekFrom — Start, Current or End), readAt/writeAt at an offset, which leave the cursor where it was on every OS (Windows moves it for a positioned call, so kama puts it back, as Go does), and setLength; a pipe has no position and answers InvalidInput; a lock on the whole file — lock/lockShared wait, tryLock/tryLockShared answer WouldBlock when it is held elsewhere, unlock and closing release it, and so does the holder's death, so a lock file needs no pid check (Rust's File::lock; flock on Linux and macOS, advisory; LockFileEx on Windows, mandatory — another handle's read or write fails while it is held; none on wasm, Other); plus free readFile/writeFile/readText/writeText/stat/readDir/remove, createDir/createDirAll/removeDir/removeDirAll/rename/exists, symlink and hardLink (original:, link:; on Windows a symbolic link wants Developer Mode or a privilege, past which it is PermissionDenied, and wasm's filesystem has no hard links), and a Metadata of size, kind, modified (a std::time::Timestamp — nanoseconds where the filesystem records them, whole seconds on Windows), changed (the change time — st_ctim, Windows' ChangeTime — which no user tool can set back, as cp -p and touch -r set modified), id (a FileId, equal for two reads of one file through any path, kept by a rename: a device and inode on Linux and macOS, a volume serial and a 128-bit file id on Windows; Equatable and Hashable, so a cache keys on it — git's index keys on the same) and permissions (the nine bits the file records, not an access check — below). kind is a FileKind — File, Dir, Symlink, Fifo, CharDevice, BlockDevice, Socket, Other — with isFile(), isDir() and isSymlink() for the common questions; stat(path:) follows a symbolic link and symlinkStat(path:) describes the link itself, and File.metadata() asks an OPEN file, so a check and the read after it are about one file. An open File does not hold its path: while it is open, the path can be renamed, removed, created again or replaced by a rename, and the file reads on through all of it — POSIX's rule, kept on Windows by opening every file with delete sharing, as Rust's std does. owner and group come from the same stat, so they describe the same file as permissions (libpq accepts a group-readable key only when root owns it). On Linux and macOS they are a std::process::UserId and GroupId, the stat's st_uid/st_gid. On Windows they are an Optional of each: the SIDs the file's security descriptor names, which the stat reads for permissions anyway, and None where this account may not read that descriptor — another account's private file. Rust and Go expose no owner on Windows, and Python and Node report 0. std::net gives RAII TcpListener/TcpStream (blocking TCP), UnixListener/UnixStream (Unix-domain) and UdpSocket. All fallible calls return Result<…, IoError>, consumed by match.

Sockets on a fiber. A socket call that would block — a read or a write, an accept, a connect, a datagram's send or receive, a sendFile, descriptors passed over a Unix socket — parks only its fiber on an isolate that runs fibers, and the core runs the others until the socket is ready (KR-3, docs/design/fibers.md decision 7). The same call blocks the thread on an isolate that runs none, so a function is written once for both. Underneath, kama makes such a socket non-blocking and registers it with the isolate's scheduler at its first wait; neither is the program's to see:

Pipes and children on a fiber. A File over a pipe, a FIFO or a terminal parks only its fiber while it waits, as a socket does, and so do StdIn, StdOut and StdErr, Process.wait() until the child exits, and Command.run() while it captures; off a fiber each blocks its thread as before. Go and tokio put a child's pipes on their poller the same way, and Rust's std blocks. Which descriptors kama switches is the rule that keeps the parking invisible:

Deadlines. setReadDeadline(at:) and setWriteDeadline(at:) on a stream or a datagram socket, and setAcceptDeadline(at:) on a listener, take an absolute Instant and keep it until it is reset; Optional::None clears one. A call made once it has passed fails with Err(TimedOut) at once, even with bytes waiting, and so does a call still waiting when it passes (Go's model, KR-3 decision 11). One deadline therefore bounds an exchange of several calls — a request read in pieces, a writeAll — where a per-call timeout (Rust's set_read_timeout, Java's setSoTimeout) restarts on every call, so a peer that sends a byte at a time holds it open for ever; set it before each call for a per-call limit. A connect's deadline is ConnectOptions.deadline, through connectWith, as ListenOptions goes through bindWith. On a fiber the call parks until the deadline; on a thread a socket with one waits in a poll for the time left, and a socket with none costs the one system call it always did.

An IoError is a kind and, when the OS reported it, the OS's code — the two facts every mainstream library keeps apart. e.kind() is an IoErrorKind (NotFound, AlreadyExists, PermissionDenied, WouldBlock, ConnectionRefused, …, Interrupted, Other), which a caller branches on with match (e.kind()). e.rawOsError() is the POSIX errno or, on Windows, the Winsock or Win32 code, and e.message() quotes the system's own words for it: "not found (os error 2: No such file or directory)"; e.osMessage() is those words alone, as strerror gives them, or None when no OS code backs the error. So two failures of one kind stay distinguishable to whoever reads the log — EPERM and EACCES are both PermissionDenied, and say "Operation not permitted" and "Permission denied" — and an unclassified one is Other with its code and text rather than a bare "i/o error". An error kama raises itself, IoError.of(kind: IoErrorKind::InvalidInput), has a kind and no code. Interrupted is a signal arriving during a blocking call; Poller.wait(timeoutMs:) absorbs it and waits out the time that is left. TooManyOpenFiles is Rust's name with its scope: the process (EMFILE, Winsock's WSAEMFILE, Win32's ERROR_TOO_MANY_OPEN_FILES) or the whole system (ENFILE) at its limit on open files and sockets. That is an accept that cannot take the connection, an open or a socket. NotConnected is Rust's name too: a socket with no connection (ENOTCONN, Winsock's WSAENOTCONN), such as the peer of a UDP socket that never connected. InvalidData is Rust's name for someone else's bytes that the operation cannot accept: text that is not UTF-8, a compressed stream that is corrupt or fails its checksum, an archive header that does not parse. InvalidInput is the caller's own argument. LimitExceeded is a bound the caller set being reached, such as a decoder's limit on its output.

A write to a peer that has gone is an error, not the end of the program. Writing to a socket whose peer closed, or to the stdin of a child that exited, returns an error of kind BrokenPipe (or ConnectionReset or NotConnected, as the kernel reports it: macOS answers ENOTCONN to every send after the first, once that one was buffered before the reset landed) — POSIX raises SIGPIPE there instead, whose default ends the process. A program ignores that signal from the start of main, as Go, Rust, Python and Node do, and every socket is also made not to raise it (MSG_NOSIGNAL, and SO_NOSIGPIPE on Apple) — TCP and Unix-domain alike — so a --shared kama library is covered without touching its host's signals. Two things keep the Unix convention: stdout and stderr still end the program by SIGPIPE when the reader goes away — prog | head -n 1 stops prog with exit 141 — and a child starts with SIGPIPE at its default (tools/check-sigpipe.sh holds both). Both hold whatever the program inherited: a parent that leaves SIGPIPE ignored — the GitHub Actions runner does — used to make prog | head write its whole loop into a closed pipe, and pass the ignore on to every child, until 0.9.478. Until 0.9.471 the first write after a peer hung up killed the process.

Permissions are the nine Unix bits — read, write, execute for the owner, the group and everyone else — on every platform. Hand-written code names them, Permissions::OwnerRead | Permissions::OwnerWrite (the constants are comptime values of the type itself); has(p:) tests, mode() gives the number, and ${p} renders rw-r--r-- as ls -l does. A mode number from outside the program goes through Permissions.fromMode(mode:), which returns Err(InvalidInput) for any bit outside 0o777 — setuid, setgid, the sticky bit and a raw st_mode's file-type bits are not permissions kama models (they mean nothing on Windows), and nothing drops them silently. Metadata.permissions replaced the lone readOnly bit in 0.9.447: !m.permissions.has(p: Permissions::OwnerWrite) asks the same question. On Windows the bits are read back from the file's access list — the owner's entries, the group's, and Everyone's (with Authenticated Users and Users, the every-user groups) — and the DOS read-only attribute clears the write bits; a list this process may not read falls back to what the C runtime reports. An EPERM ("not permitted", what changing another user's file answers) is of kind PermissionDenied, as EACCES is, and its code and message still say which.

Setting permissions. File.openWith(path:, mode:, permissions:) opens for writing with EXACTLY the permissions given — Write creates or truncates, Append creates or appends, and Read, which creates nothing, is Err(InvalidInput). createDirWith(path:, permissions:) is createDir's form, and setPermissions(path:, permissions:) changes a path's (following a link, as chmod does). The permissions are applied at creation, never by a create and then a change — the race in which a secret sits readable. EXACT means what it says, on every platform: the umask does not narrow an explicit request (a plain create is what it narrows, below), and a file that already exists is given the permissions before it is truncated or written, so a secret never lands in a file someone else could already read — the gap Go documents and leaves open, since O_TRUNC keeps the old mode. A file that already holds exactly the permissions asked for is left as it is, so a group member may write a shared file it does not own; changing a file this process does not own is PermissionDenied, as chmod is. Each call reads back what the filesystem kept and fails when it differs, so a volume that cannot hold permissions (FAT, some network mounts) never yields a file left open, and a file or directory the call made is removed again. What no permission can revoke is a descriptor another process opened earlier; for a secret, write a fresh name and rename it into place. On Windows the bits become an access list — Cygwin's mapping: an entry for the owner (which always keeps the right to change and delete its own file), for the file's group and for Everyone only when they are granted something, SYSTEM always, deny entries first where an owner has less than the others, and protected from inheriting anything wider. A private file is therefore owner + SYSTEM, the set Win32-OpenSSH accepts for a private key; Administrators get no entry and can take ownership of any file, as root reads any file. An owner's list is written whole there, even over bits that already match — an entry no bit expresses, an Administrators grant say, does not survive the owner's openWith or setPermissions — so "left as it is" is the non-owner's case. The group maps weakly there (a file's group is usually "None", which every local user belongs to). The wasm target's virtual filesystem stores and reports the bits.

A new file's permissions. A plain create — File.open with Write or Append, writeFile, writeText — asks for rw-rw-rw- and lets the process umask narrow it (rw-r--r-- under the usual 022, rw-rw-r-- under a group-shared 002), and a directory asks for rwxrwxrwx the same way: the default of C fopen, Go, Rust, Python, Node, Zig, Java and .NET. Until 0.9.446 a file was a hardcoded rw-r--r--, which under 002 stripped the group-write bit the user's own umask granted. On Windows a plain create inherits its directory's access list, as in every language; the wasm target's virtual filesystem applies the umask as POSIX does.

removeDirAll does not follow a symlink — a link inside the tree is unlinked, never descended into (the CVE-2022-21658 shape) — and says in its doc comment what it still cannot promise (atomicity against a racing writer, which needs openat). exists returns a bool, and is a snapshot: to use a file, open it and handle the error.

Text files (readText / writeText). The one-call form for a file that holds text, beside the byte-level readFile/writeFile that keep working unchanged: readText(path:) -> Result<string, IoError> and writeText(path:, text:) -> Result<isize, IoError> (the byte count, and it create/truncates like writeFile). writeText copies nothing on the way in — text.bytes() is a borrow of the string's own storage. Both whole-file writes are all or an error: Ok means every byte was written, and a write the OS cut short (a full disk, a file-size limit) is the Err that stopped it, never Ok with fewer bytes — File.write is the partial-write primitive, and std::io::writeAll is the loop over it that both use.

⚠️ readText validates, and so does every other path that builds a string out of foreign bytes — std::io::readAll and BufReader.readLine, which are where the reading actually happens. Invalid UTF-8 is Err(InvalidData), not an ill-formed string: string's UTF-8 invariant is what .chars() assumes and what substring traps to preserve, and until 0.9.433 readAll was a way around it (a binary file read as text yielded bogus codepoints from entirely safe code). Bad input is an error, the same way parse returns one; bad output written into a StringWriter is a caller bug, so finish() carries a release-stripped debugAssert naming takeBytes() instead. readFile still accepts any bytes — "these bytes are not text" is a fact about the file, not a reason to refuse it.

The checked conversion itself is std::encoding::utf8: decode(bytes:) -> Result<string, Utf8Error> and validate(bytes:) -> bool over a ConstView<uint8>, with Utf8Error::Invalid(at:) carrying the byte offset where the text stopped being well-formed (Rust's valid_up_to). Rejection is full RFC 3629 — a non-lead byte, a truncated tail, an overlong encoding, a UTF-16 surrogate, and anything above U+10FFFF. There is deliberately no encode: a string is UTF-8 bytes, so that direction is s.bytes(). For text shown to a person — a log line, a file name — decodeLossy(bytes:) -> string replaces each ill-formed sequence with U+FFFD, one per maximal subpart (Unicode §3.9, WHATWG's decoder, Rust's from_utf8_lossy): ED A0 80 is three, a four-byte sequence cut short is one. It is named apart on purpose, because substituting corrupts data; decode stays strict.

Names (std::net). resolve(host:, port:) -> Result<DynamicArray<SocketAddr>, IoError> asks the system resolver for every address a name has, IPv4 and IPv6, in the resolver's own (RFC 6724) order and unfiltered (on macOS localhost is ::1 first); resolveOne is the first of them. TcpStream.connectHost(host:, port:) resolves a name and tries each address in that order, keeping the first that connects and reporting the last failure otherwise, so a server bound to 127.0.0.1 alone is reached by name although ::1 is tried first. (One after another, not raced: RFC 8305 happy eyeballs is not offered.) connect(host:, port:) keeps taking numeric text and performs no lookup — a call that reads like a syscall must not make a network round trip behind the caller's back, so a name is resolved by a function that says so, and a name handed to connect is Err(InvalidInput). parseIp is the strict numeric parser under every text-taking call: exactly four decimal octets, no leading zero on a multi-digit part (C's inet_addr reads 010.0.0.1 as octal and 127.1 as a packed number — the CVE-2021-29922 shape). inet_addr is gone from the socket seam, so connect(host: "010.0.0.1"), which reached 8.0.0.1 before 0.9.375, is now Err(InvalidInput).

Sockets and families (std::net). A socket's family is its address's, for its whole life. The text forms (TcpStream.connect/connectNonBlocking, TcpListener.bind, UdpSocket.bind/connect) each have an address form (connectTo, connectToNonBlocking, bindTo, connectTo) taking a SocketAddr, which is the only spelling that reaches a link-local address, whose interface is its scopeId. "" keeps its meaning: every IPv4 interface to bind, IPv4 loopback to connect. "::" is dual-stack: IPV6_V6ONLY is turned off on every OS (Windows defaults it on), so one socket serves both families and an IPv4 peer reads as ::ffff:a.b.c.d. setTtl sets the unicast hop limit of either family. A send to an address of the other family is an error from the socket, not a silent drop.

TCP options and the two ends (std::net). A TcpStream takes the options a long-lived client needs, one setter per option, each returning Result<Unit, IoError>: setNoDelay(on:); keepalive — setKeepAlive(on:) and its three knobs setKeepAliveIdle(idle:), setKeepAliveInterval(interval:), setKeepAliveCount(count:) — so a peer that vanished without a FIN (a NAT that forgot the flow, a failed-over server) is noticed; and setUserTimeout(timeout:), how long written data may go unacknowledged before the connection is dropped. A duration is positive and rounds up to what the stack takes — whole seconds for the keepalive knobs, milliseconds for the user timeout on Linux (TCP_USER_TIMEOUT, which also bounds the keepalive probes there) and whole seconds on macOS (TCP_RXT_CONNDROPTIME) and Windows (TCP_MAXRT) — so a timer never fires earlier than asked; zero, a negative duration or a zero count is Err(InvalidInput), and an option to leave at the OS's default is simply not set. shutdown(how:) closes one half: Shutdown::Write sends a FIN, so the peer reads end-of-file while this side still reads its reply, and a later write here is BrokenPipe. localAddr() on a TcpListener is how a program learns the port the OS picked for bind(port: 0) — every socket fixture binds that way, so no two can collide (tools/check-fixture-ports.sh) — and a TcpStream has localAddr() and peerAddr(), the peer being how a server learns who an accepted stream belongs to. A UdpSocket has both too, its peer being the one connect fixed. Asking a socket where it is changes nothing, so every socket's localAddr()/peerAddr() is a const fn: a const ref to the socket is enough to ask.

Sending a file (std::net). TcpStream.sendFile(file:, offset:, count:) sends up to count bytes of a File from offset, and answers the count sent. It is a partial transfer, like write, so a caller loops and advances offset. On a non-blocking stream nothing sent is WouldBlock, and 0 means offset is at or past the end. The file's own cursor never moves, as for readAt. On Linux and macOS the bytes go from the page cache to the socket with no copy through the program (sendfile; on macOS, about 40% fewer cycles than a read and write loop). Where the OS cannot do that, it copies through a small buffer with the same result: on Windows, wasm, or a file the kernel has no such path for. Windows' TransmitFile would block a non-blocking socket for the whole count. A peer that has gone is an error, never a signal, even in a --shared library. Python's socket.sendfile, .NET's Socket.SendFile and Java's transferTo spell it the same way; Go and Rust hide it inside their generic copy.

Listener options (std::net). TcpListener.bindWith(address:, options:) takes a ListenOptions, one type on every OS and each its own OS's. Every one carries backlog, the accept queue's length (128 by default, what bind uses; the OS caps a larger one at its own maximum, and a negative one is InvalidInput). Where a kernel lets several listeners share a port, the type names what that kernel then does, because the OSes differ:

Every listener on a shared port must ask for it. A port another listener holds is otherwise AddrInUse on every OS. Linux and macOS set SO_REUSEADDR so a restarted server rebinds a port whose old connections linger in TIME_WAIT. Windows rebinds one by default, and there the option would let a second listener take a live port, so kama does not set it (Go and Rust set it on Unix only). @compileFor gates a declaration, never a statement, so code that sets balance is a function gated to Linux.

Sharing a UDP port (std::net). A plain UdpSocket.bind/bindTo shares nothing: a port another socket holds is AddrInUse on every OS, as Rust's and Go's are. (Until 0.9.785 kama set SO_REUSEADDR on every UDP socket, and Linux then let a second program's plain bind take a live port's datagrams.) A socket that shares its port asks for it through UdpSocket.bindWith(address:, options:), whose UdpBindOptions.share is a UdpPortShare, one type on every OS and each its own OS's. Every socket on a shared port must ask, and an Alone socket's port stays its own (on Windows, whose SO_REUSEADDR lets a later socket take a held port, an Alone socket is bound SO_EXCLUSIVEADDRUSE):

Readiness (std::net::Poller). A server waits once for whichever of its sockets is ready, and the wait costs what is ready, not what is watched. Each OS's own mechanism stands behind the one interface:

It is native only: in a browser the event loop is the selector.

One event per descriptor per wait, on every OS. kqueue reports reading and writing as two events, and its backend merges them, so code tested on Linux never meets a second event for a connection it has just closed. More ready descriptors than the list holds are reported by the next wait. It is level-triggered: a socket with unread data is reported again by every wait, so a reader may stop part way.

Waking a loop from another isolate. p.waker(token:) answers a Waker (std::concurrent). Its wake(), from any isolate, ends the Poller's wait, which reports an Event carrying that token, readable. It is the one place a loop handles events, so the loop learns what woke it there.

A channel the loop waits on. rx.wakeOnSend(waker:) gives a Receiver's channel a waker. A send that takes the queue from empty to non-empty wakes the loop, and so does the last sender's close. Items already queued wake it at once, so a late registration misses nothing.

The ready list is its own value, not the Poller's, because foreach freezes what it walks. A loop over Events can therefore add the connection it just accepted. Raising the descriptor limit first (raiseOpenFileLimit) lets a loop hold thousands, and Windows, which bounds sockets by memory, past the 1,024 its select() held. Measured on 0.9.717: one ready descriptor among N idle Unix-domain pairs, the best of seven interleaved rounds of 2,000 waits. Linux ran in the arm64 container.

N poll(2), Linux epoll poll(2), macOS kqueue
100 4.0 µs 0.16 µs 14.4 µs 0.18 µs
1,000 36.7 µs 0.14 µs 177 µs 0.18 µs
10,000 1,288 µs 0.14 µs 7,267 µs 0.18 µs

Unix-domain sockets (std::net). A byte stream between two processes on one machine, named by a path: UnixStream.connect(path:) and UnixListener.bind(path:) + accept(), on Linux, macOS and Windows 10 1803+. UnixStream is a ReliableStream like TcpStream, with shutdown(how:), localAddr()/peerAddr() (a UnixAddr: a path, or unnamed, which a connecting client's own end is) and peerCredentials() — who is on the other end: its processId(), user(), group() and groups() (every group, the primary first and each once — Linux itself lists only supplementary ones), the same members on every OS (Linux reads what the kernel recorded at connect, macOS the peer's credentials, Windows the peer process's access token, which a more privileged peer may refuse to show). A path is checked before the OS is asked: empty, holding a NUL, or longer than sun_path less its terminator (103 bytes on macOS, 107 on Linux and Windows) is InvalidInput. bind over an existing path is AddrInUse — a live listener's or a stale file, which is never removed behind the caller — and a listener removes the socket file it made when it drops, so a clean restart just works (a crash still leaves the file). SIGPIPE is handled as for TCP (above). connectNonBlocking(path:) / connectToNonBlocking(address:) never wait, for a caller holding a deadline: a local connect completes at once, and a listener whose queue is full answers at once too — WouldBlock on Linux, which started nothing (connect again), ConnectionRefused on macOS. Where an OS reports a connect in flight, the stream is returned and checkConnected() resolves it once it polls writable, as for TCP. Windows reports every connect in flight, and its queue does not fill (all 400 connects to a listener nobody accepts from are made). A path with nothing at it is NotFound on every OS: Windows answers it as it answers a path with no listener, ConnectionRefused, so kama looks.

A connected pair with no path. UnixStreamPair.open() makes two connected UnixStreams and no file — the shape a parent uses to talk to a helper both ways: keep one end, hand the other to a child as its stdin and stdout (Stdio::Handle(descriptor: give b.intoDescriptor())) or to an isolate. Each end is lent in place, first() and second(), as Process.stdin() lends its pipe, and the pair comes apart into two owned ends with give pair.split() (match … { case Ends(first: a, second: b): … }, KR-261); an end still in the pair closes when the pair drops. Both ends are close-on-exec, so a child holds only the end it is handed, and each end's peer is this process. POSIX makes it with socketpair; Windows has none, so there the pair is a private listener in the temp directory, connected to once and accepted, its path removed as soon as the connection is queued, and refused unless both ends name this process as their peer — a stranger who connected first is not paired. A socket is no child's stream on Windows (Stdio says why), so there a pair serves isolates. On macOS the SO_NOSIGPIPE every kama socket carries travels into a child with its end, so a child writing to a parent that has gone gets EPIPE there where Linux raises SIGPIPE. UnixDatagramPair is the same over datagrams (Linux and macOS): each send arrives whole at the other end, and neither needs an address.

A connected pair in memory. MemoryStreamPair.open(capacity:) makes two connected MemoryStreams that hold no descriptor and run on every target, wasm included: the test pipe for protocol code written over ReliableStream. Each direction holds at most capacity bytes (at least 1), so a partial write happens at the size the test chose on every OS, where the kernel sizes a UnixStreamPair's buffers. A write takes what fits and is WouldBlock when nothing does, and a read of an empty direction is WouldBlock. After the far end's shutdown(how: Write) or drop, a read is 0 once drained, and a write after this end's own shutdown(how: Write) is BrokenPipe, as is a write the far end shut its Read against. Always non-blocking, as a stream on the web is: setNonBlocking changes nothing. The ends are lent in place, first() and second(), as UnixStreamPair's are, or taken apart with give pair.split(), and are not Sendable: they share their buffers through a Shared, so the pair is one isolate's. tokio's io::duplex(max_buf_size) is the model; Go's net.Pipe is unbuffered.

Passing open descriptors. UnixStream.sendDescriptors(bytes:, descriptors:) sends open files, sockets and streams to the process on the other end, with at least one byte to carry them, and recvDescriptors(into:, descriptors:) receives them (UnixDatagram has the pair too). What travels is a std::io::Descriptor, an OWNED descriptor that closes when it drops, so none can leak — one received and never adopted is closed like any value, and a message carrying more than 253, or more than the kernel kept, is MessageTooLong with every one that did arrive closed. Every handle type converts both ways: X.adopt(descriptor:) (the receiver says what a descriptor IS by the type it adopts it into), give x.intoDescriptor(), which consumes x (a use after it is a compile error, KR-261), and x.duplicateDescriptor(). A pipe end a Process lends leaves it with takeStdout() (and takeStdin(), takeStderr()), which leaves an empty File in its place. Sending CONSUMES the list — each is closed once the peer holds its own — so a sender keeps a file by sending a duplicate. A raw number from outside kama becomes a Descriptor through ONE named door, Descriptor.adopt(raw:) / adoptSocket(raw:) — adopt, as every ownership transfer in std is spelled — and the per-type public make(fd:) doors are private since 0.9.485, so grep finds every place a program claims a number it did not get from kama. It is not checked: adopting a number something else owns (0, stdin) still closes it twice, and kama's unsafe marks a dangerous body, never a caller, so it is no marker here. Received descriptors are close-on-exec. POSIX passes them as SCM_RIGHTS, which reaches any program; Windows has no such message, so each handle is put into the peer process (DuplicateHandle, WSADuplicateSocketW) and a frame ahead of the bytes tells the peer where — a convention between two kama programs there, where POSIX's is the kernel's. A Windows file descriptor and socket close differently, so adopting one as the other is InvalidInput there.

Each OS keeps what is its own. UnixDatagram (Linux, macOS) sends whole messages between local processes — systemd's notify socket, syslog's /dev/log: unbound(), bind(path:)/bindTo(address:), sendTo(bytes:, to:), recvFrom(into:) → RecvFrom<UnixAddr> { count, from }, connect/send/recv, and it removes a file it bound when it drops. It is UdpSocket's shape over a UnixAddr, and both implement DatagramSocket<A>, the contract over the address type A a datagram's peer is named by: UdpSocket, WtConnection and RtcConnection are DatagramSocket<SocketAddr>, UnixDatagram is DatagramSocket<UnixAddr>, and recvFrom answers RecvFrom<A>. So a syslog client that sends to /dev/log or to UDP 514 by configuration is one generic path — fn emit<S: DatagramSocket<A>, A>(ref S s, ConstView<uint8> line, const ref A to) — and A is inferred from the socket where no argument names it. The address is always spelled, never defaulted: it is what kind of socket this is, not a policy knob. An empty into is InvalidInput from every recvFrom: it cannot hold a datagram, and answering would invent a sender. Windows' AF_UNIX is stream-only, so the type is not declared there. On Linux UnixAddr.fromAbstract(name:) names the abstract namespace — no file behind it, nothing a crash leaves, gone when the last socket holding it closes, and, with no file, no file permissions either: any process in the network namespace may connect — read back by abstractName() and rendered @name. An API one OS lacks is a compile error on that OS, never a run-time one (tools/check-target.sh builds both for every target); a portable program keeps its use of one in a file gated to where it exists, with a twin answering the same call elsewhere (tests/net_unix_datagram.d), because an import of a name its build excludes is refused even when every use is gated.

Multicast (std::net). A UdpSocket joins a group per interface, and the families name an interface differently, so each has its own call, as in the C API: joinMulticastV4(group:, interface:) takes an address the interface holds (0.0.0.0 = the OS's choice), joinMulticastV6(group:, interfaceIndex:) its index (0 = the OS's choice), with leaveMulticastV4/leaveMulticastV6 beside them. interfaceIndexOf(name:) turns lo0 or eth0 into an index, and a name that is no interface is Err(NotFound). On Windows the name is the NDIS name (loopback_0, ethernet_32769), and the friendly name (Loopback Pseudo-Interface 1) is Err(NotFound). setMulticastInterfaceV4/V6 choose the interface sends leave by (without it the routing table picks, and a loopback-bound V4 socket's send fails on a host with a default route); setMulticastLoop and setMulticastHops apply to either family. A group or interface of the other family is Err(InvalidInput) before the OS is asked.

Addresses (std::net). IpAddr is V4(uint8 a, uint8 b, uint8 c, uint8 d) or V6(InlineArray<uint8>#(16) octets), both in network order, and match on one names both arms. parseIp reads IPv6 text as RFC 4291 writes it — :: once, and an optional dotted IPv4 tail under the same strict octet rules — and an IpAddr renders RFC 5952's canonical form: lowercase, no leading zeros, the longest run of two or more zero groups as ::, and an IPv4-mapped address dotted (::ffff:1.2.3.4). A mapped address stays V6. A zone (fe80::1%lo0) is refused by parseIp: the scope names an interface of this machine rather than part of the address, so it is SocketAddr.scopeId, the C sin6_scope_id (0 when there is none; SocketAddr.of(ip:, port:) leaves it 0 and SocketAddr.scoped(ip:, port:, scopeId:) sets it). A SocketAddr renders 127.0.0.1:80, [::1]:8080, or [fe80::1%4]:22.

Lines and the standard streams (std::io). BufReader<R>.readLine() -> Result<Optional<string>, IoError> is the text-framing primitive over the byte substrate: Ok(None) is end of input, the terminator is stripped, CRLF is one terminator and a lone \r is not, and a final unterminated line is still a line. Lines<R: Reader> is the foreach form — Lines::<File>.make(src: give f) — and since an Optional-yielding iterator cannot tell a read failure from a clean end, it records the failure for error() after the loop; readLine's Result is the spelling that cannot be ignored. stdin(), stdout() and stderr() are three non-owning Reader/Writer values (no-op destructors — the descriptors belong to the process) that compose with pump, BufWriter and serde; they do not replace core's print family (import { core::println };), which FLOOR.md explains.

Subprocesses (std::process). A Command builder — argv vector (never a shell string, so injection-safe by construction; Command.shell(line:) is the explicit sh -c opt-in) with cwd/env/ envClear and one Stdio per stream — Inherit, Piped, Null, Handle(descriptor:) (any owned handle: a file, a socket, a pipe end, another child's stdout, so a pipeline is two Commands) or, for stderr alone, Stdout (2>&1) — spawns an owned, move-only type resource Process via start(), or captures via the one-shot run() -> Output { status, stdout, stderr }, which drains stdout and stderr concurrently (so a child that fills both pipes can't deadlock the parent). A Handle goes to the child: the parent's copy is closed once the child holds it, so a reader of the other end sees end-of-file, and it serves one start() (a second is InvalidInput). run() captures only what was left unset — stdout and stderr; an unset stdin is the null device — honours every stream that was set, and leaves the Command as it found it. Process gives wait() (blocking reap → an ExitStatus, below), tryWait() -> Optional<ExitStatus> (non-blocking), kill/terminate/signal, and — with processGroup(group:) — a child in a group of its own, signalled whole by killGroup/terminateGroup/signalGroup so a supervisor stops it with everything it started. ProcessGroup keeps each OS's own: POSIX has Inherit, New (setpgid), Join(group:) (a pipeline's children share one) and NewSession (setsid — no controlling terminal); Windows has Inherit and New, the child's own console group plus a Job Object that ends with its last handle, so the group never outlives the Process that owns it. A child in the parent's group has none to signal (InvalidInput), and a group with nobody left in it is IoErrorKind::ProcessNotFound; and the piped streams as std::fs::Files (stdout()/stderr() read, stdin()+closeStdin() feed-then-EOF). Dropping a Process never blocks: it reaps a already-exited child (no zombie) or detaches it (the OS reparents to init) — explicit wait() is how you get the status. On a fiber wait() and run() park only their fiber (Pipes and children on a fiber). A child inherits its three streams and nothing else of its parent's: every descriptor kama makes is close-on-exec on POSIX (atomically where the OS can, and on macOS under a gate a fork waits on) and non-inheritable on Windows, where the spawn also names exactly the three handles the child may take — so no child keeps a parent's listening port, file or pipe end open (Rust, Go and Python default the same). POSIX and Windows both ship: process.kama is byte-identical across platforms, with the whole difference behind kama_os.h (fork/execvp/pipe/waitpid vs CreateProcess), and run()'s two-pipe drain sits behind one kama_capture2_parked seam (poll on POSIX, a reader thread per pipe on Windows, a park on both pipes on a fiber) so every platform takes the same code path. wasm has no process model.

An ExitStatus is each platform's own type, as main's status is. On POSIX a child either exits, with the one byte the platform keeps, or is killed by a signal: @nonExhaustive type enum ExitStatus { Exited(uint8 code), Signaled(int32 signal) }. On Windows it only exits, with all 32 bits: Exited(uint32 code). Portable code reads a code with case Exited(code: c): … beside a case _:, and success() asks the same question on both. Neither needs a sentinel: a signalled child used to report code = -1.

Where a process works (std::process, std::fs). currentDirectory() reads what a relative path is relative to and setCurrentDirectory(path:) changes it — process-wide, so it races every isolate resolving a relative path at the time (set it at startup, or pass absolute paths), and MAX_PATH-bound on Windows. std::fs::canonicalPath(path:) resolves ., .. and every link into the one absolute spelling of a file that exists (NotFound otherwise). On Windows neither answer carries the verbatim \\?\ prefix the OS hands back, except where the plain path would name another file (a trailing . or space, a device name such as CON); kama re-adds the prefix itself past 248 characters, so nothing is lost.

Who a process runs as (std::process). currentUser(), currentGroup() and currentProcessId() answer for this process, and UnixStream.peerCredentials() (below) for the one on the other end of a socket. The interface is the same on every OS and each OS keeps what is its own — the rule for every platform difference in std: one primary interface, and the deltas in the types it returns, never a platform cut down to the common subset. UserId and GroupId compare with ==, name their account with name() (NotFound when no account has the id) and render with ${…}; on Linux and macOS they are the uid and gid (raw(), of(raw:)), on Windows security identifiers (sid() renders S-1-5-21-…, fromSid(bytes:) checks what it is given, and domain() names the account's domain). Windows adds AccessToken, its own process identity: current(), ofProcess(id:), and its user(), primaryGroup() and every one of its groups(). Each twin is a whole type gated by @compileFor, since a member cannot be (Conditional compilation). UserId.homeDirectory() is the home the account database records — the passwd entry on Linux and macOS, the profile Windows records for the SID — and homeDirectory() is the current user's: $HOME (%USERPROFILE% on Windows) when it is set and not empty, else the account's, as Rust's home_dir and libpq read it.

Another process: alive, and the same one? (std::process). A pid alone is not a process — the OS reuses it — so processStamp(id:) answers a ProcessStamp: an Equatable, Hashable value made only of what the kernel recorded once — on Linux and macOS this boot's UUID (boot_id, kern.bootsessionuuid) and the process's start (the ticks since boot in /proc/<pid>/stat; p_starttime), on Windows its creation time — with a text form a lock file or pid file writes ("${stamp}") and ProcessStamp.parse(text:) reads back. A wall-clock start is not an identity: Linux's btime and macOS's kern.boottime are now less the time since boot, recomputed when the clock is set, so after an NTP step a live holder's would no longer match (psutil's (pid, create_time) moves that way). processExists(id:) is the cheap check (kill(pid, 0); on Windows WaitForSingleObject), processStarted(id:) and bootTime() answer the wall clock for display, and processGroupExists(group:) is POSIX's, beside ProcessGroup. An exited child not yet reaped still exists on Linux and macOS; on Windows an exited process is gone. A pid stays an int32, as everywhere in std::process. A lock file itself is File.lock, which dies with its holder; the stamp says who holds it.

The limit on open descriptors (std::process, POSIX). openFileLimit() answers an OpenFileLimit, the RLIMIT_NOFILE pair:

Both are what can actually be opened. macOS enforces kern.maxfilesperproc whatever setrlimit accepted: measured on Darwin 25, it takes any soft value up to RLIM_INFINITY and reads it back. A shell's 1,048,576 there is a number no program reaches (61,440 on the measuring machine), so both values are bounded by it, which is Go's cap too. No limit at all reads as the largest uint64, which compares as one.

kama never raises the limit implicitly; Go does at startup, as the JVM and Node do. The low default protects C code that uses select(), which breaks above descriptor 1023, and a csources library may be such code, so raising it is the program's decision. Windows bounds sockets by memory and has no such limit, so the type is absent there rather than faked. A portable program reaches it from a file gated to POSIX, with a twin.

The streaming byte substrate. std::io also defines two contracts that unify every byte source/sink: type contract Writer (the partial-write primitive write(ConstView<uint8>) -> Result<isize, IoError> + flush) and type contract Reader (read(View<uint8>) -> Result<isize, IoError>, Ok(0) = EOF). Buffers are always views (a non-owning span — zero-copy sub-slicing, no charset assumptions: binary-native, text backends layer UTF-8 on top): the read-only ConstView<uint8> where the callee only reads (write, send), the writable View<uint8> where it fills (read, recv). Write-all looping, pump (Go io.Copy), and readAll are free helpers over the primitive (contracts carry no default methods: a contract is purely an interface, so conformance is total and every body lives in the implementing type — helpers compose over the primitive); StringWriter/SliceReader are the in-memory impls and BufWriter<W>/BufReader<R> the buffering layer (each owns its inner sink/source by value — kama forbids stored borrows). A drop abandons, a consuming finish completes: a BufWriter dropped with bytes buffered writes nothing and reports nothing, since a drop has no way to report an error, and give bw.intoInner() flushes and hands the sink back, or hands the writer itself back in Unfinished beside the error, so a WouldBlock can be finished again; the writer is gone after the call (KR-261). give r.intoParts() hands a BufReader's, or a Lines', source back with the bytes it read ahead, and give sw.finish() and give sw.takeBytes() move a StringWriter's buffer out without a copy. std::fs::File implements both, and a reliable network stream is type contract ReliableStream for resource implements Reader, Writer (refinement) + setNonBlocking — so TcpStream and the web WsConnection are drop-in Reader/Writers. The upshot: the serde backends and fmt stream over a file or a socket with no transport-specific code (deserializeJsonStream<T>(from: someReader)), and unbounded data moves in bounded memory. (Datagram endpoints — UdpSocket, WebTransport — are message-oriented, not byte streams, so they take the same view buffers but do not implement Reader/Writer.)

import { std::fs::writeFile, std::collections::DynamicArray };
fn uint8 main() {
    DynamicArray<uint8> data = DynamicArray.empty();
    data.add(item: 104ui8);                              // "h" — a file is bytes
    match (writeFile(path: "out.txt", bytes: data)) {
        case Ok(value: n): {}
        case Err(error: e): { return 1; }
    };
    return 0;
}

Compressed streams (std::compress). DeflateReader<R> (RFC 1951), ZlibReader<R> (RFC 1950) and GzipReader<R> (RFC 1952) are Readers of the decoded bytes. Each takes its source as a BufReader<R> and takes exactly the stream's bytes from it, handing back what it read ahead, so streams back to back (a gzip file's members, a packfile's objects) decode one after another. limit, a bound on the decoded bytes, is required, since a few kilobytes can decode to gigabytes: up to limit bytes are delivered, and the read that would deliver one more answers LimitExceeded. A stream that breaks its format or fails a checksum is InvalidData and one cut short is UnexpectedEof, and either is final; an error from the source passes through and changes nothing, so the read after a WouldBlock resumes where it stopped. A read of nothing is not the end.

A checksum is verified by the read that answers Ok(0). give d.finish() reads nothing, so it cannot block or time out: it hands the BufReader back at the first byte after the stream, and an Ok from it says the stream was read to its end, where Go's Close and Rust's into_inner hand a source back whether it was or not. Before the end it answers InvalidInput, and after a failure that failure, each with the reader back whole in Unfinished, and give d.abandon() takes the source back as it stands, nothing verified.

DeflateWriter<W>, ZlibWriter<W> and GzipWriter<W> are Writers of the bytes to compress, each taking level: DeflateLevel, which is Store, Level1 to Level9 or Frozen, and is @nonExhaustive. flush is a sync flush, so a peer can decode everything written so far. finish writes the end of the stream and hands the sink back, flushing nothing of its own; over a stalled sink the writer comes back whole in Unfinished, and finishing it again writes no second end. Dropping a writer abandons its stream, which then never verifies. The bytes depend only on the input and the level, never on how the writes were split, on every target, for one kama version; DeflateLevel::Frozen's never change across versions, and are what kama publish writes, so a package's sha256 can be rebuilt by std.

GzipReader.make takes members: GzipMembers, with no default: All reads a file's members as one stream, and limit counts across them, while One reads the first and leaves the rest in the BufReader. What follows a member under All is another member or nothing: zeros and junk are InvalidData, as in Go, where gzip(1) warns and exits 2, and Python skips zeros. header() reads as far as the header's end and gives the member most recently begun as a GzipHeader: mtime(), os(), and name(), comment() and extra() as raw bytes, empty when absent. RFC 1952 says a name is Latin-1 and gzip(1) copies a file name's bytes as they are, so nothing in a file says which it holds, and the caller decides. A name or a comment past 65,535 bytes is InvalidData, since neither has a length field. GzipWriter writes one header for every file, with no flags, no name and no time. A zlib stream that names a preset dictionary is InvalidData.

Archives (std::archive::tar). TarReader<R> reads ustar, pax x and g records, GNU long names and v7, and hands back every entry as written, since listing a hostile archive is how one audits it: next() gives each TarHeader or None at the end, skipping what was left of the last body, and read gives its body. TarHeader.path() is raw bytes, because a tar promises nothing about its names; kind() is a TarKind carrying what only that kind has (File(size), Directory, Symlink(target), HardLink(target), CharDevice(major, minor), BlockDevice(major, minor), Fifo, Other(flag, size)), and is @nonExhaustive; mode() is the archive's own bits; mtime() is an Optional<Timestamp>, None for a time a Timestamp cannot hold; owner() is TarOwner::None or Named(uid, gid, uname, gname). make takes endMarker: TarEndMarker, with no default: NotRequired reads what GNU tar, bsdtar and Python read, where the source ending between entries or the first zero block ends the archive, and Required asks for what none of them offers, two zero blocks and nothing less. A header whose checksum fails, a number that does not parse or a malformed pax record is InvalidData, and a source that ends inside a header or a body is UnexpectedEof. A path, a link target or an owner name past 1 MiB is InvalidData, since each is held; a pax record of any other key is skipped without being held, however long. finish and abandon are the decoders', and finish hands R back exactly after the end, since a reader never reads ahead; over a GzipReader that leaves the gzip trailer to the caller, whose own finish then insists on it.

TarWriter<W> writes ustar: begin(header:), exactly the size declared through write, and finish. What no ustar field holds goes in a pax record. Too long a body is InvalidInput with nothing taken, and too short is InvalidInput from the next begin or finish. Over GzipWriter at Frozen, an archive of regular files with kama publish's fields is publish's bytes.

extractInto(archive: ref t, dir: "out") writes an archive under one directory and never outside it, and never traverses a link. The destination must be new or empty. Each entry's parents are asked of the operating system one at a time, so a parent that is a link is refused however it is spelled, and files are opened CreateNew, so nothing is written through a final link, and the same name twice is refused, where GNU tar, bsdtar and Python leave the last. It refuses, as a TarExtractError naming the entry and the TarRefusal: an absolute path, a .. component, a NUL, a name that is not UTF-8, a device, a FIFO or a type it does not know, a link whose target is absolute or has a .. anywhere but at its start or past the destination, a hard link to anything but an earlier regular file of the archive, and on Windows alone a drive, UNC path, stream, reserved name or trailing dot. The setuid, setgid and sticky bits and group and other write are dropped, nothing is chowned, and a directory keeps its owner's access. A hard link on a filesystem with none is a copy. What was written stays after a refusal, under a directory that was new or empty.

Every platform difference lives in one bundled C bindings header, kama_os.h (pulled in only when a module extern "kama_os.h";s it — pay-for-what-you-use), which keeps OS aggregates (struct stat, sockaddr_in, dirent) opaque behind static inline accessors — the standard FFI boundary (Rust libc / Zig @cImport), forced by "an extern struct emits the literal C name." POSIX (Linux/macOS/iOS/Android) and Windows (Winsock + CRT) both ship; under wasm the virtual FS works, sockets need a host proxy. Sockets link -lws2_32 on Windows (pay-for-use, like -lm for <math.h>). examples/httpd/ is a ~200-line static-file HTTP server built on these three modules.

FFI data — all controlled; the only unsafe fn is the one every C call needs:

extern "<stdlib.h>";                       // a C #include
type extern value div_t { int32 quot; int32 rem; }   // bind an external C struct (not re-emitted)
extern fn div_t div(int32 numer, int32 denom);

extern fn float64 frexp(float64 value, UnsafePtr<int32> exp);

unsafe fn int32 demo() {                   // calling an `extern fn` is the unsafe seam
    div_t r = div(numer: 17, denom: 5);    // r.quot=3, r.rem=2  (field access on a C struct)
    int32 e = 0;
    frexp(value: 1764.0, exp: addr(of: e));// addr(of: x) = &x  — controlled out-param
    return r.quot + r.rem + e;             // 5 + 11 = 16
}
fn uint8 main() { return cast<uint8>(demo()); }

type extern value Foo { ... } is an external struct provided by an included header / linked code — kama uses its fields (all public, the C layout) but never re-emits it (so no redefinition), and its name is the literal C name. It may declare ctors, methods, operators and static fns — the struct is the header's, but a member is an ordinary kama function, emitted like any other — and it is built by a constructor like every kama type: bind a struct-returning C fn (div(...) above), call one of its ctors, or opt into @generate(of) (memberwise) / @generate(zero) (a C struct's fields are public, so it is a data bag). The nameless div_t(quot: 3, rem: 2) is refused. A field a ctor does not assign is zero, not an error: the header owns the layout and kama may declare only the fields it uses — so partial init of a large descriptor is WGPUBufferDescriptor bd = WGPUBufferDescriptor.zero(); bd.size = 64;. The fields may be repeated in another file that declares the same struct; the members have one body each, so they are declared once and the type is exported from there. (tests/extern_value_init.kama.) The field list is a claim about the header, and the build checks it: each field must match the header's in size and in kind (integer, floating, bool), so int32 level over a C short level is a build error naming the field rather than a silent truncation. Order does not matter (every read is by name) and a binding may name a subset of the header's fields. Signedness is not compared — a C enum field is int or unsigned int by implementation, and int32 is how a binding spells it. The check is a C11 _Static_assert, because the C compiler, not kama, reads the header's layout — so it fails at kama build, not at kama check. addr(of: x) takes the address of a real local (out-params, descriptor pointers) — a controlled op, though its result is a raw pointer and so lives in an unsafe fn. s.cstr() yields an UnsafeConstPtr<cchar> — C's const char*, read-only (cchar is C's char, a pointee only — see Numbers).

A struct crossing by value. A struct passed or returned BY VALUE through an extern fn or an expose fn needs a layout both sides agree on, and the marker on the type says who owns that layout — exactly as extern fn and expose fn say who owns a body:

by value extern fn (kama calls C) expose fn (C calls kama)
plain type value, a resource, an enum with payloads, a generic instance refused refused
type extern value ✅ ✅
type expose value refused ✅
a plain enum refused refused
type extern enum ✅ ✅
type expose enum refused ✅

A plain type value is refused because nothing states its layout, and marking one is what makes a layout a promise: an unmarked value stays free to change, which is what keeps every other value type's layout kama's own. What passes unmarked is what is not a kama composite — a primitive, a pointer (UnsafePtr<T>, ref), an fnptr, a name the header itself declares — and kama's own intrinsics, whose layout is its ABI: string (the runtime header's kama_string, on an extern fn), InlineArray/Simd, and View/ConstView. An InlineArray lays its elements out inline, so its element answers the same question: an array of a plain value is refused. Fixture: tests/extern_value_crossing.d/, both directions against a real C file.

An enum crossing to C answers the same question for its VALUES, with the same pair. A plain enum numbers its variants itself, so inserting one silently renumbers the rest — it is refused at the boundary: in a signature, as a field of a type extern value or type expose value, or as an InlineArray element.

Either marker requires the width (: int32) and a payload-less enum: a tagged union has no C spelling. Neither takes both markers — one side owns the values. Either may declare methods, a contract and @generate like any enum: a payload-less enum is its integer whatever it declares, so what crosses is unchanged. A C enum declared in several files takes its members on one of those declarations. A type expose enum crosses an expose fn only, as a type expose value does.

A named C constant — extern const T NAME; binds a constant a header defines, by name, the way type extern enum binds an enum's: kama writes no value, a use emits the C name, so the number is the header's by construction. One spelling covers both shapes a C API uses — a typed static const (webgpu.h's bit flags) and a #define (GLFW's keys) — and bit flags compose with the ordinary integer operators:

extern "webgpu.h";
extern const uint64 WGPUBufferUsage_Uniform;      // static const WGPUBufferUsage WGPUBufferUsage_Uniform = 0x40;
extern const uint64 WGPUBufferUsage_CopyDst;
bd.usage = WGPUBufferUsage_Uniform | WGPUBufferUsage_CopyDst;   // C: `bd.usage = (WGPUBufferUsage_Uniform) | (…);`

Time (std::time) ✅#

import { std::time::Duration, std::time::Instant, std::time::Timestamp, std::time::Date, std::time::Weekday, std::time::TimeError, std::time::monotonicNow, std::time::unixNow, std::time::sleep }; — two clocks, and choosing between them is the only thing the module asks of a caller, plus the calendar the wall clock reads in.

type read with for
monotonic Instant monotonicNow() measuring a span: a timeout, a frame time, a benchmark. Never runs backward; has no relation to any date.
wall Timestamp unixNow() stamping an event: a file's mtime, a log line, a protocol field. Signed nanoseconds from the UNIX epoch (1677-09-21..2262-04-11). Can jump either way (NTP, a user setting the clock), so durationSince may be negative — that is the clock being honest, not an error.

Duration is the signed span both produce (fromMillis/asSecsF/… and + - <); all three implement Equatable/Comparable/Hashable/Sendable, so a stamp is a SortedMap key without a comparator and crosses an isolate.

The calendar is the Postgres split: an instant and a day.

kama Postgres is
Timestamp TIMESTAMPTZ an instant. It holds no zone and no offset; it reads its fields in UTC.
Date DATE a day with no time: a birthday, a due date. Years 0000–9999, the range RFC 3339 text can write.
import { std::time::Date, std::time::Timestamp, std::time::unixNow };
fn uint8 main() {
    Date due = match (Date.make(year: 2026, month: 9ui8, day: 16ui8)) { case Ok(value: d): d; case Err(error: e): { return 1; } };
    Timestamp t = unixNow();
    Date today = t.date();                       // plus hour() minute() second() nanosecond() weekday(), in UTC
    int64 left = due.daysSince(earlier: today);
    string text = "${t}";                        // 2026-09-16T14:03:07.123Z
    return 0;
}

Text is RFC 3339, the strict profile of ISO 8601, plus exactly what PostgreSQL writes. "${t}" prints YYYY-MM-DDTHH:MM:SS, a fraction only when there is one (3, 6 or 9 digits, the fewest that are exact, so every stamp parses back to itself), then Z; a Date prints YYYY-MM-DD. Timestamp.parse(text:) and Date.parse(text:) return Result<_, TimeError>, the Uuid.parse shape (Parseable is a primitive-only contract):

Postgres keeps microseconds, so a stamp written with finer digits comes back rounded and no longer compares equal. For a binary format, Date.fromUnixDays(days:) and date.unixDays() are the day number Arrow, Parquet, Avro and BigQuery store; Postgres's binary DATE is that minus 10957, and its TIMESTAMPTZ is microseconds from 2000-01-01 — Timestamp.fromUnixMicros plus a constant.

Both types serialize as that text on every backend, as a Uuid does, and text that does not parse is DeErrorKind::Malformed.

sleep(d: Duration) waits at least d — the calling thread, or on an isolate that runs fibers only the calling fiber, while the core runs the others (KR-3; Sockets on a fiber). POSIX resumes nanosleep across a signal (without which any program that also uses std::process wakes early when a child exits), and a zero or negative span returns at once — so sleep(d: deadline - now) past its deadline is a no-op. ⚠️ On the web target it busy-waits: without ASYNCIFY (a whole-program cost kama will not pay for one function) a wasm function cannot yield to the host, so it spins on the monotonic clock, burns a core, and freezes a page on the browser's main thread. On an event loop, schedule instead of sleeping.

It sleeps for the span you asked for, not the platform's scheduler tick. Windows Sleep() rounds to the next tick — ~15.625 ms by default — so a 1 ms and an 8 ms nap cost the same and a frame loop paced with sleep ran at half rate there while running correctly everywhere else. kama uses a high-resolution waitable timer instead, per call, falling back to Sleep where the OS declines it (the flag wants Windows 10 1803+). Deliberately not timeBeginPeriod, which raises the tick for the whole process — a library may not spend a caller's power to fix its own resolution. Measured before and after on one machine: 1 ms → 13.4 ms then 1.6 ms, 8 ms → 15.6 ms then 8.7 ms. The at least guarantee is what this preserves, not what it changes.

Application loop (std::app) and web transports (std::net::web) ✅#

std::app is the frame loop a game or an interactive tool runs, written once for both hosts: run(tick: t, state: s) calls t(state) until it returns 0 — a plain while natively, and on wasm emscripten's main loop, which yields to the browser between ticks so asynchronous I/O makes progress (a busy while on the web never sees an event). quit(code:) ends the program correctly on both; a plain exit is ignored on the web while the loop keeps the runtime alive. TickFn is @callerThread — the tick runs on the thread that called run, so a module static it touches is the caller's. On the web run does not return.

std::net::web is the browser half of std::net, for a wasm program talking to a server:

Paths (std::path) ✅#

import { std::path::join, std::path::parent, std::path::fileName, std::path::stem, std::path::extension, std::path::isAbsolute, std::path::separator }; — six pure functions over string, no I/O, and no Path type: two string-ish types is what GOALS #4 warns against, and Rust's Path earns its keep through OsString encoding concerns kama does not have. The names are the ones every modern successor API converged on (Rust, pathlib, C#), not the shell tools' dirname/basename.

import { std::path::join, std::path::parent, std::path::fileName, std::path::extension };
fn uint8 main() {
    Optional<string> dir  = parent(path: "/a/b/c.txt");     // Some("/a/b")
    Optional<string> name = fileName(path: "/a/b/c.txt");   // Some("c.txt")
    Optional<string> ext  = extension(path: "/a/b.tar.gz"); // Some("gz") — the LAST dot, WITHOUT the dot
    string full = join(path: "/srv", with: "www");          // "/srv/www"
    return 0;
}

Five deliberate answers, each a place a mainstream implementation surprises someone:

  1. join never discards the base. Rust's and Python's join replace the whole path when the right side is absolute — the classic traversal footgun. join(path: "/srv/root", with: "/etc/passwd") is /srv/root/etc/passwd here. This is the one place kama disagrees with its comparator on purpose.
  2. A dotfile has no extension: extension(path: ".bashrc") is None.
  3. The extension excludes the dot (Rust/Go/Zig, not Node's extname).
  4. Missing is None, never "": parent(path: "file.txt") is None (Rust says Some("")), and so is fileName of a root or of ./...
  5. There is no normalize: resolving .. lexically is wrong across a symlink; the honest version needs the filesystem and belongs in std::fs.

Separators are / everywhere and \ as well on Windows (both accepted on input; join writes \), and isAbsolute knows the drive (C:\) and UNC forms. The variance is two @compileFor pairs, not a runtime branch.

Random (std::random) ✅#

import { std::random::Rng, std::random::range, std::random::shuffle, std::random::entropy }; — a seeded generator first, OS entropy as the convenience, and neither one is a secret.

import { std::random::Rng, std::random::range, std::random::shuffle, std::collections::DynamicArray };
fn void demo(ref DynamicArray<int32> xs) {
    Rng g = Rng.seeded(seed: 42ui64);                       // the same stream on every platform, forever
    uint64 raw = g.next();                                  // xoshiro256**
    int32 die  = range(rng: g, lo: 1, hi: 7);               // a value in [1, 7), unbiased — T from the bounds
    isize pick = g.below(n: xs.length());                   // an index in [0, n)
    borrow xs.viewMut() as v { shuffle(items: v, rng: g); } // Fisher–Yates over any View, move-only safe
    Rng h = Rng.fromEntropy();                              // seeded by the OS — a different stream each run
}

Seeded is primary, because a deterministic stream is what most callers want: a game replays a match from its seed, a spectator catches up by re-running it, a test pins its inputs. The generator is xoshiro256** (Blackman & Vigna) seeded through splitmix64 — the mix DefaultHasher already finalizes with — and the first outputs of two seeds are pinned against the reference C implementation, so a change to the algorithm is a test failure rather than a silent replay break.

Not cryptographic. An observer who sees a few outputs can recover the state and predict the rest. entropy(into: View<uint8>) fills a buffer from the operating system's own generator (getentropy, BCryptGenRandom, crypto.getRandomValues under wasm) and is the one draw here fit for a key, a nonce or a token; everything above that is a libsodium package's job, not std's. An entropy source that refuses is an environment fault, so it panics rather than returning a Result (Go 1.24 drew the same line).

Rng is a resource — move-only, handed around as ref Rng. A copy would silently fork the stream into two identical ones, the bug Rust's &mut Rng, Go's *rand.Rand and C#'s class all rule out the same way. It is Sendable, so an isolate can own one.

Two integer draws, two jobs. range(rng:, lo:, hi:) is one generic spelling over every numeric width — int8…int64, uint8…uint64, isize/usize, float32/float64 — through the same marker-contract mechanism as parse::<T> (Ranged). It is half-open, unbiased (rejection sampling, kept because it is already unbiased; std::num::mulHighU64 is Lemire's multiply-shift if the branch ever matters), and the signed span is computed in wrapping unsigned arithmetic so range(rng: g, lo: int64Min(), hi: int64Max()) draws without trapping. T comes from the bounds: a typed one binds it and an unsuffixed literal follows (range(rng: g, lo: 0, hi: xs.length()) is an isize draw — generic inference defers an unsuffixed literal until its typed siblings have bound T, and a method call on a typed local answers with its declared return type); two bare literals are int32, 0.0/1.0 are float64. g.below(n:) stays beside it because an index and a value are different jobs. An empty range (hi <= lo) panics rather than answering lo. nextFloat() is a float64 in [0, 1) from the top 53 bits, chance(p:) is true with probability p, and a float range interpolates convexly so two finite bounds cannot overflow.

shuffle permutes through View.swap, so it works for a move-only element type, allocates nothing, and covers a DynamicArray, a FixedArray or a sub-range alike — the sort shape. The platform seam is kama_random.h; on a Windows target the driver links -lbcrypt beside -lws2_32.

Digest (std::digest) ✅#

import { std::digest::sha256::Sha256, std::digest::sha256::sha256, std::digest::sha1::sha1 }; — three submodules, sha1, sha256 and sha512, each exporting a streaming hasher (make(), update(ConstView<uint8>), finish()), a one-shot function of the same name as the module, its HMAC one-shot (hmacSha1, hmacSha256, hmacSha512), and DIGESTBYTES/BLOCKBYTES. The digest is an InlineArray<uint8>#(N) — 20, 32 or 64 bytes on the stack, no allocation anywhere, so all three are present in a --no-heap build.

std::digest itself holds what is written once over any of them. Each hasher implements the BlockDigest contract (update, finishInto(digest:), digestBytes(), blockBytes()), and over it: Hmac<H>, the streaming HMAC (RFC 2104) — Hmac.make(hash: Sha256.make(), key: k), update, finishInto(tag:) — keyed once, so a copy MACs another message under the same key; and pbkdf2(hash:, password:, salt:, iterations:, into:) (RFC 8018), which fills into with the derived key. Together they are SCRAM-SHA-256, PostgreSQL's default authentication — tests/digest_scram_rfc7677.kama reproduces RFC 7677's client proof and server signature from them. constantTimeEquals(a:, b:) checks a received tag (or a bearer token, a signed cookie) against the expected one in a time that does not depend on where they first differ. == stops at the first byte that differs, so its time says how many leading bytes matched. Unequal lengths are false at once, since a length is not the secret, and the comparison reads both inputs through volatile in C, so the compiler cannot turn it back into an early exit (Go's subtle.ConstantTimeCompare, Python's hmac.compare_digest). MD5 is not here: it serves only deprecated protocols, and a package that needs one carries it.

import { std::digest::sha256::sha256, std::digest::sha1::Sha1, std::encoding::base64::encode as b64Encode,
         std::collections::DynamicArray, std::collections::ConstView };
fn string demo(ConstView<uint8> v, ref DynamicArray<uint8> key, ref DynamicArray<uint8> guid) {
    InlineArray<uint8>#(32) d = sha256(bytes: v);          // one shot
    Sha1 h = Sha1.make();                                    // streaming: any number of updates, one finish
    h.update(bytes: key.view());
    h.update(bytes: guid.view());
    string accept = b64Encode(bytes: h.finish().view());     // RFC 6455's Sec-WebSocket-Accept
    return accept;
}

Why a digest is in std when ciphers are not. Ciphers, key exchange and signatures are a package, @kama/sodium, because they carry the constant-time burden and that is libsodium's job. TLS is a package too, and a different one — libsodium has no TLS: a C TLS stack behind a ReliableStream wrapper (the planned @kama/tls, over Mbed TLS), or TLS terminated at a reverse proxy in front of a plain TcpListener. A digest is what NON-cryptographic protocols need — the WebSocket handshake, git object ids, content addressing, ETags, the registry's own sha256-… integrity strings — and every peer with a batteries stdlib (Go, Zig, Python, .NET, Java, Node) ships one; only Rust leaves it to a crate, and the sha1/sha2 crates being among its most-downloaded is the argument against copying that.

SHA-1 is legacy — not collision-resistant — and its header says so; it exists for the protocols that still require it. Anything new is sha256. Both are pure kama, spell their loads and stores big-endian by hand, and give the same bytes on every target; the fixtures pin the FIPS 180-4 vectors (including the million-a message, streamed in odd-sized pieces), RFC 2202 and RFC 4231 for HMAC, RFC 6070 and RFC 7914 for PBKDF2, and the RFC 6455 example. ⚠️ Comparing a received MAC with == leaks through timing how many leading bytes matched; a verifier compares every byte before deciding.

UUID (std::uuid) ✅#

import { std::uuid::Uuid, std::uuid::UuidError }; — RFC 9562 identifiers as a 16-byte value, made two ways: v7, time-ordered and the default for a key, and v4, for an id that must not say when it was made.

import { std::uuid::Uuid, std::uuid::UuidError, std::collections::Map };
type value Account { public int64 balance; }

fn uint8 main() {
    Uuid id  = Uuid.v7();                               // later ids compare greater
    Uuid tok = Uuid.v4();                               // 122 random bits, no timestamp
    string s = "${id}";                                 // "01932c07-a4b2-7c3e-8f1a-5b6c7d8e9f00"
    Result<Uuid, UuidError> r = Uuid.parse(text: s);    // either case in, strict 8-4-4-4-12
    InlineArray<uint8>#(16) raw = id.bytes();           // network order: a binary column, a wire field
    Optional<int64> ms = id.unixMillis();               // Some for a v7, None for anything else
    Map<Uuid, Account> byId = Map.empty();              // Hashable, Comparable, Sendable, serde-ready
    return 0;
}

Why v7 is the default. A v7 is a 48-bit Unix-millisecond prefix, 12 bits of sub-millisecond time and 62 random bits, so ids made later sort later and a B-tree or LSM index appends instead of splitting pages at random. A v4 indexes like noise, and that is also its use: RFC 9562 §6.12 names the id whose creation time must not leak. .NET 9, Python 3.14, Ruby 3.3 and PostgreSQL 18 all added v7 for the same reason; Go, Rust and Zig leave UUIDs to a package, and the ubiquity of that package is the argument for std.

Six deliberate answers:

  1. A type, not a string convention. Two big-endian words, so < is exactly the RFC's byte order (not Java's signed-long order), bytes() is network order (not .NET's mixed-endian ToByteArray), and nothing allocates.
  2. Text is canonical and parsing is strict. "${id}" writes lowercase 8-4-4-4-12; Uuid.parse reads either case and nothing else — braces, urn:uuid:, 32 bare digits and whitespace are InvalidLength or InvalidCharacter(at:), the hex stance: the caller strips a wrapper. It is a Result because Parseable is a primitive-only contract.
  3. Strictly increasing within an isolate (RFC 9562 §6.2 Method 3). The 12 rand_a bits carry the clock's sub-millisecond fraction, and a tick that does not exceed the previous id's becomes previous + 1 — under a coarse clock, inside one tick, and when the wall clock steps backward, where the embedded time runs ahead until the clock catches up (PostgreSQL 18's uuidv7() does the same).
  4. Per isolate, not process-wide. The last tick is a module static. Ids from two isolates interleave to within a tick and never collide, since each carries 62 bits from the OS generator; a cross-thread order is not something a reader of the ids could observe.
  5. Every id draws from the OS generator (std::random::entropy), because the random bits are only worth having unguessable (§6.9), and no pool keeps key-grade bytes in memory to save the syscall. An id is still a name, not a credential: a v7 dates itself, and anything that grants access by knowing an id wants a secret beside it.
  6. On the wire a Uuid is its canonical string, on every backend. It is serialized to meet a database or an HTTP API, and text that is not a UUID fails the read as DeErrorKind::Malformed rather than decoding to some id.

Encoding (std::encoding) ✅#

import { std::encoding::base64::encode, std::encoding::base64::decode, std::encoding::base64::encodeUrl, std::encoding::base64::decodeUrl, std::encoding::hex::encode as hexEncode, std::encoding::hex::decode as hexDecode }; — two submodules, base64 and hex, each exporting encode(ConstView<uint8>) -> string and decode(string) -> Result<DynamicArray<uint8>, DecodeError>. That is the shape Go (encoding/base64 + encoding/hex), Rust (base64 + hex), Zig and Python all converged on; only C# prefixes (Convert.ToBase64String). Both export the same two names, so a file that wants both renames at the import — the same as any colliding pair uses.

import { std::encoding::base64::encode, std::encoding::base64::decode, std::encoding::base64::encodeUrl,
         std::encoding::base64::DecodeError, std::encoding::hex::encode as hexEncode,
         std::collections::DynamicArray, std::collections::ConstView };
fn void demo(ConstView<uint8> v) {
    string t = encode(bytes: v);                                    // "Zm9vYmFy" — RFC 4648 § 4, padded
    Result<DynamicArray<uint8>, DecodeError> b = decode(text: t);
    string u = encodeUrl(bytes: v);                                 // § 5 alphabet, UNPADDED — what a JWT carries
    string h = hexEncode(bytes: v);                                 // "666f6f626172", lowercase
}

Five deliberate answers:

  1. Two named pairs, not flags. encode/decode is the standard alphabet with = padding; encodeUrl/decodeUrl is the URL-safe alphabet without it. A call site reads which wire format it speaks. (The RFC's padded URL-safe form is urlSafe plus a stripped = tail; a third named pair would be a second spelling of two that exist.)
  2. Decoding is strict, as in Rust and Go and unlike Python: a byte outside the alphabet is InvalidCharacter(at:) with its position; = is accepted only where padding belongs and is not a character of the URL alphabet at all (InvalidPadding / InvalidCharacter); a length the encoding cannot produce is InvalidLength; and the unused bits of the last character must be zero (InvalidTrailingBits), so exactly one text decodes to a given byte string.
  3. Whitespace is not skipped. Line-wrapped MIME input is the caller's replace; a decoder that drops some bytes silently would have to decide which, and that decision is not its.
  4. Hex writes lowercase and reads either case. An odd digit count is InvalidLength. This is byte encoding, not integer formatting — ${x} and parseRadix cover the number.
  5. A decode fails, it does not come up absent — Result, never Optional, the parse line.

Both directions allocate their result, so they are @compileFor(!NOHEAP) and absent from a --no-heap build, as sort is. The byte substrate is ConstView<uint8> in and DynamicArray<uint8> out; a string's bytes reach encode through a DynamicArray<uint8> built by foreach (uint8 b in s).

Command-line arguments + environment ✅#

A program reads its own command-line arguments and environment through module core, imported by name like any other module's symbols (import { core::args, core::envOr };). core is embedded in the compiler, so it survives --no-std. It is not a std:: module because it cannot be one: arguments enter through the compiler-synthesized main wrapper (which stashes argc/argv into a runtime global before kama_main runs), so a --no-std user could not reimplement them. The user's fn uint8 main() signature is unchanged.

import { core::args, core::Args, core::programInvocation, core::programName, core::programPath,
         core::env, core::envOr };

// arguments (argv[0] is excluded — see programPath())
foreach (string a in args()) { /* each user arg, in order */ }
int32 n     = args().count();              // number of user args
string first = match (args().get(at: 0)) { case Some(value: v): copy v; case None: ""; };

// program identity — three separate accessors, not part of args()
Optional<string> inv  = programInvocation();  // argv[0] verbatim, e.g. "./myapp" (exact launch string)
Optional<string> name = programName();        // basename of argv[0], e.g. "myapp" (usage text / dispatch)
Optional<string> path = programPath();        // OS-resolved absolute exe path (find files / re-exec)

// environment — a keyed lookup, not a list
Optional<string> home = env(name: "HOME");                 // None when unset
string term = envOr(name: "TERM", dflt: "dumb");           // value, or the fallback

Logging (std::log) ✅#

Leveled, tagged diagnostics — the configurable logger, a library over a small runtime seam, no new language surface. Module core gives print/eprint (raw console output) and the language gives assert/panic (fatal checks); std::log is the tier above: filterable, taggable, redirectable output that keeps running (a warn is a log level, never an abort). Import it — the module is the discovery unit; it is not scattered as floor globals.

import { std::log::logInfo, std::log::logWarn, std::log::logError, std::log::logDebug, std::log::logTrace, std::log::logEnabled, std::log::setLogSink, std::log::LogLevel };

@generate(Formattable, of) type value Mix { public int32 voices; public float32 gain; }
fn string dumpState() { return "voices=8 gain=0.5"; }

fn uint8 main() {
    string version = "1.2.0";
    Mix state = Mix.of(voices: 8, gain: 0.5f32);
    logInfo(tag: "boot", msg: "starting ${version}");   // tag may be "" (untagged)
    logWarn(tag: "net", msg: "returning");
    logDebug(tag: "audio", msg: "mix ${state}");        // state.format() runs ONLY if the record passes (v2)
    if (logEnabled(level: LogLevel::Debug, tag: "audio")) {   // logEnabled remains — for guarding a whole block
        string dump = dumpState();                      // a hole takes no call, so bind it first
        logDebug(tag: "audio", msg: "mix ${dump}");
    }
    return 0;
}

Two axes, either suppresses a call. enum LogLevel { Error, Warn, Info, Debug, Trace } (ordered — Error most severe, Trace most verbose) is the level; a free-text string is the tag. A call at level L prints when L <= threshold(tag); the default threshold is Info (so Error/Warn/Info print, Debug/Trace are suppressed until raised). Both are reconfigurable at runtime on a shipped binary — the QA/live-debug win most compile-time loggers discard.

Configuration (runtime). One grammar, warn,audio=debug,net=trace — a leading bareword is the global threshold, each tag=level overrides one tag (levels error/warn/info/debug/trace, plus off). Two sources, --log primary, KAMA_LOG env secondary:

KAMA_LOG=debug ./app                 # env: global debug
./app --log warn,audio=debug         # flag: global warn, but the "audio" tag at debug
KAMA_LOG=info ./app --log=off         # the flag wins (overrides the env) → silence

The config source is the process-global env: every translation unit / isolate reads KAMA_LOG into its own module-scoped static and gets a consistent answer (argv is a module-scoped static, so the --log flag is bridged into KAMA_LOG once in main — see kama_log_init_args; only programs that import std::log emit that call).

Baked project default (kama.json). A shipped binary has no kama.json beside it, so a project's default filter is compiled in. The manifest gains a log section — a JSON object mirroring the same level vocabulary:

{ "log": { "level": "warn", "tags": { "audio": "debug", "net": "trace" } } }

The compiler translates it to the canonical spec (warn,audio=debug,net=trace) and seeds it into KAMA_LOG in main only if the env is unset — so the full precedence is --log > KAMA_LOG env > baked kama.json default > the built-in info floor. Invalid level names are a manifest error at build time.

Swappable sink. The default sink writes [LEVEL] tag: msg to stderr (kept off stdout so a CLI's real output stays clean), colored on a tty. Install your own — the filter runs upstream, so a sink only ever sees enabled records:

import { std::log::setLogSink };
fn void mySink(int32 level, string tag, string msg) { /* route to a file / engine console / telemetry */ }
fn uint8 main() {
    setLogSink(s: mySink);   // set once at startup, before spawning isolates — like setPanicHandler
    return 0;
}

Modeled on setPanicHandler (a runtime-held slot), not a stored Logger object: a kama resource can't be a module-static and an UnsafePtr to an interface isn't dispatchable, so the facade calls the extern kama_log_dispatch, which invokes the C-held slot (or the built-in console default). On --target embedded output routes through the same weak kama_log_sink as print (freestanding, no libc); config falls back to the Info default (no argv/env on bare metal).

Zero-cost when filtered (v2 lowering). The compiler recognizes the five facade calls and lowers each to a guard with the message built inside it — so a filtered-out record never assembles its (possibly expensive) message:

logDebug(tag: "audio", msg: "mix ${state}");   // state.format() runs ONLY if the record passes the filter

Two axes reach zero cost. Level, compile-time: under --release a Debug/Trace call is stripped physically (like debugAssert — gone at any -O; Error/Warn/Info stay). Level(above the floor) + tag, runtime: an inlined kama_log_enabled(level, tag) guard (the same filter, reading the process-global config) wraps the message build. So logEnabled is no longer a manual necessity — it stays available, but the guard is now automatic at every recognized call site. To make a whole subsystem physically absent regardless of runtime config, gate its declarations with @compileFor(FLAG). This is an AST lowering, not a preprocessor — typed, hygienic, one grammar (the "${x}"/assert/print shape), exactly like Rust log/tracing.

unsafe fn — raw pointer memory access#

The only place kama can touch arbitrary memory through a raw pointer. Raw UnsafePtr<T> / UnsafeConstPtr<T> index/store is a compile error outside an unsafe fn — so the entire dangerous surface is explicit and greppable, and greppable at the declaration (grep -rn 'unsafe fn') rather than buried in a body. Everything else (collections, smart pointers, FFI structs/handles/out-params, addr) stays safe.

unsafe marks the BODY, not the caller. This is C#'s meaning of the word, not Rust's: it says this function does dangerous things inside, so calling an unsafe fn is unrestricted and its visibility is ordinary. public unsafe fn is the common shape, not a contradiction — the function's signature is the safe boundary, so a caller needs no permission. There is no propagation and no caller obligation. (Rust's unsafe fn means the opposite — calling this is dangerous, the caller must uphold an invariant — which is what forces containment there. kama does not take that meaning.)

It is markable wherever a body exists: a method, a ctor, a destructor, an operator, and a free function. It is rejected where no body exists — on a type, on a field, on an abstract method, and on a contract member — because there is nothing there to be unsafe. A contract member is a conduit: the implementation whose signature names UnsafePtr must itself be an unsafe fn, and a caller cannot invoke the member without holding an UnsafePtr. That is what keeps A: Allocator a perfectly safe bound while allocate/reallocate/deallocate stay uninvocable outside an unsafe fn.

import { std::collections::FixedArray };

unsafe fn int32 sum(UnsafePtr<int32> p) {
    p[0] = 10;  p[1] = 32;       // raw store  (p[0] is *p)
    return p[0] + p[1];          // raw read
}
unsafe fn int32 answer() {       // no raw pointer in its signature, so a safe caller may call it
    InlineArray<int32>#(2) buf = [0; 2];
    return sum(p: addr(of: buf[0]));
}
fn int32 caller() {
    return answer();             // calling an unsafe fn needs no ceremony — the signature is the boundary
    // UnsafePtr<int32> p;       // ERROR here: "local `p` is a raw pointer, so it requires an `unsafe fn`"
}

unsafe fn void upload(const ref FixedArray<float32> verts) {
    UnsafeConstPtr<float32> data = verts.dataPtr();   // a `const fn` — Rust's as_ptr; `dataPtrMut()` is as_mut_ptr
    usize n = cast<usize>(verts.length()) * sizeof(float32);   // byte count: there is no `byteLen()` — one way, `length() * sizeof(T)`
    // ... pass (data, n) to a C upload fn
}

A pointer to a struct is read the same way, through its element: p[0].x. The pointer itself has no fields, so p.x is refused with that spelling — there is no ->, since [0] already is the one way to reach a pointee. A method is reached the same way: p[0].get(), and p.get() is refused with that spelling. (*p) and p->x, the C spellings, are refused with a note naming p[0].

The raw seam is a seam, not a second ownership system. An element p[i] of a raw UnsafePtr<T> is untyped to ownership: p[i] = v is a plain store (the old bytes are overwritten, no destructor runs), and nd[i] = od[i] is a bitwise relocate — which is exactly what a collection's own buffer needs, and why the emitter does not resolve a class for a raw element in a store. A method call on a raw element (p[0].m()) is not a store: it borrows the element in place, so it resolves, through a local pointer as through a field one or a module static one. Nor is a member of the element (p[0].x, p[0].inner.x): it names the pointee's field, spelled the way the pointee's type spells it — a type extern value's fields are the header's — and private to that type as it is anywhere else. What stays refused is a drop of the element: it takes the POINTER, not the element — drop(ptr: p) destroys *p and drop(ptr: addr(of: p[i])) destroys one element — so the thing being destroyed is named rather than inferred from an untyped slot. To take the value out or release it instead: borrow it through a ref T parameter (fn f(ref T x), called as f(x: ref p[0])), or own it — keep it in an Owned<T>, and release() it to a foreign API's userdata slot when it must outlive a frame (see Smart pointers). A raw pointer is the foreign boundary, never general-purpose escape (GOALS §3a/§3e). And addr(of:) takes the address of a place, never of a temporary: an element or field whose root is a by-value call result (addr(of: f.view()[0]), a view minted by the call and dropped at the end of the statement) is refused — bind the value to a local first — while a place-returning call at the root (addr(of: b.at(i: 0))) is storage the callee still owns and is fine.

What requires an unsafe fn — the decision table#

One rule, and it keys on the TYPE, not the spelled token: an expression, declaration, or binding whose type IS or CONTAINS UnsafePtr<T> or UnsafeConstPtr<T> may only occur inside an unsafe fn. Plus one more: calling an extern fn requires one too.

construct example verdict
declare an UnsafePtr field UnsafePtr<T> data; legal — every container and every type extern value depends on it
module static of UnsafePtr type static hardware UnsafePtr<uint32> gpio; legal — the MCU path depends on it
read or write such a field or static this.data unsafe fn only
a local of raw-pointer type UnsafePtr<T> p = …; unsafe fn only
a signature naming UnsafePtr fn UnsafePtr<T> dataPtr() legal iff the function is unsafe
a contract MEMBER naming it ctor adopt(UnsafePtr<T> raw) legal, no marker — bodiless; implementer and caller are each forced by their own types
bind one without spelling it match (a.allocate(…)) { case Some(value: p): … } unsafe fn only — the rule reads the type
a call whose result is raw kfree(p: make()) unsafe fn only
addr(of: x) gpio = addr(of: led); unsafe fn only — it produces a raw pointer
cast<UnsafePtr<T>>(…) cast<UnsafePtr>(0x40021000) unsafe fn only; stays possible for MMIO
compare against null if (this.handle != null) unsafe fn only — it reads a raw-typed place
declare an extern fn extern fn int32 abs(int32) no marker — bodiless
call an extern fn, scalar-only included kama_close_socket(fd: fd) unsafe fn only
inline asm(…) unsafe fn only

The read-only raw pointer — UnsafeConstPtr<T>. C's const T* (bare, const void*), lowered east-const as T const* so that UnsafeConstPtr<UnsafePtr<uint8>> is uint8_t* const* — a pointer to a const pointer, which is what it says. It obeys the containment rule above exactly as UnsafePtr<T> does, reading through it is the same unsafe read, and it differs in three ways: a store through it — q[i] = x, q[i].f = x, a ref/out borrow of q[i] — is rejected; a non-const fn call on q[i] is rejected as a call on a const receiver; and it does not convert to UnsafePtr<T> — at a local's initializer, an assignment, an argument, a return — while UnsafePtr<T> → UnsafeConstPtr<T> is implicit, as in C, and cast<UnsafePtr<T>>(q) inside an unsafe fn is the explicit launder. It is what a const fn may honestly hand out: addr(of: …) on a const root yields one, and so do dataPtr() on every contiguous container and string.cstr() (the writable halves are dataPtrMut() and addr(of:) on a mutable root), so a buffer held const reaches a const T* C API with no cast and no mutable borrow — Rust's *const T, Swift's UnsafePointer<T>, the stdlib's own get/getRef split applied at the FFI seam. A const UnsafePtr<T> parameter is unchanged: a const binding of a mutable pointer, still lowered const T*, which is why it accepts either pointer. | call an unsafe fn | | unrestricted | | unsafe on main | unsafe fn uint8 main() | rejected — see below |

Two of these are worth their own sentence, because the obvious weaker version of each is wrong.

The rule reads the type because a token rule leaks. match (a.allocate(bytes: n)) { case Some(value: p): … } binds an UnsafePtr and never spells the word — the payload's declared type is the template's T. Before the type-keyed rule existed, that shape compiled into a double free with zero unsafe tokens in the function.

A generic is judged as written. That match is the program's own code, so the type it binds is read with the instance's arguments in. Inside the generic itself they are not: in DynamicArray<T>, T is never a raw pointer, so DynamicArray<UnsafePtr<int32>> instantiates and its safe methods stay safe. The raw pointer is the instantiator's: the local, the field read and the match binding that hold one need an unsafe fn, as anywhere. What a generic spells for itself is checked all the same, by an instance over other arguments or, when there is none, by the walk every uninstantiated generic gets.

extern has no scalar exemption. extern fn int32 kama_close_socket(isize fd) names no pointer and is a double-close primitive by effect; roughly half the stdlib's extern declarations are pointer-free. Danger at this boundary is a property of the callee's effect, which kama cannot see, not of its signature, which it can — so a type-based carve-out would look like a rule and behave like a hole.

main returns the process's exit status, in the platform's own type. fn uint8 main() is the one portable signature — the one byte Linux and macOS keep — and on Windows fn uint32 main() is accepted too, for the 32 bits it keeps; a program that wants them writes it as a @compileFor(OS_WINDOWS) twin. Any other return type is refused, and fn int32 main() names the fix: an int32 status was truncated by the OS at exit with no word, so return 256; exited 0. fn uint32 main() is refused off Windows, where the status keeps one byte. A literal status that does not fit is refused where it is written. A computed one narrows explicitly: cast<uint8>(n) traps in every build when n does not fit, and truncate<uint8>(n) keeps its low bits on purpose. Rust (ExitCode from a u8) and Zig (fn main() u8) put the range in the type the same way; every other language inherits C's int and the truncation. std::process's ExitStatus is the parent's side of the same rule, and std::app::quit takes the same types.

main may not be unsafe. It encloses the whole program, so the marker would put every line in the trusted region and stop marking anything — the same shape as an extern fn call that needed no marker at all. Since calling an unsafe fn from safe code is unrestricted (above), the fix is always available and always better: move the raw work into a helper unsafe fn and call it from main, so the marker names the region that actually needs it.

Definite assignment inside an unsafe fn: locals relax, out parameters do not. The split is load-bearing. Relaxation exists because a raw store is invisible to the definite-assignment walker, so an unsafe body's own initialization dance would otherwise read as a use-before-assign. Filling an out, by contrast, is a contract with the caller — and since safe code may call an unsafe fn freely, relaxing it would hand every caller a hole full of uninitialized stack. A raw fill still counts: addr(of: dst) marks its target assigned, which is how a type-erased C call satisfies the rule.

a.dataPtr() bridges a collection's buffer to C (safe to call; the returned UnsafePtr is valid only while the collection is alive + unmodified, and dereferencing it requires an unsafe fn). An unlowered construct (including a safety-gate violation) is a hard build error — kama never emits incomplete C and claims success.

Moving an owned value into a raw slot uses give: buf[i] = give w; (inside an unsafe fn) stores the bytes and consumes w (its scope-drop is skipped — a use-after-move is a compile error), the one marker that carries ownership across into unsafe manual storage. An unmarked slot[i] = x is a plain bitwise store (the untracked raw-relocate a container uses internally, e.g. moving elements between buffers). Getting a value back out is manual (bitwise-copy into a local, take responsibility) — there is no give-out of a raw slot; a safe Slot<T>/MaybeUninit wrapper for both directions is a tracked design spike.

That move-out direction has one name: std::ptr::relocate(from:, at:, into:), which bitwise-moves from[at] into an out parameter. std::ptr is the raw-pointer module, kept apart from std::memory (the owning handles Owned/Shared/Weak) and imported explicitly. The source is left stale — the bytes are still there — so the caller must vacate it (tombstone the entry, decrement the length) or the value is dropped twice. Every container's move-out goes through it, which is what keeps addr(of: …) on a slot out of the call site.

The module's other member is the honest reading of a C pointer that may be null: std::ptr::nonNull(p:) returns Optional<UnsafePtr> — Some(p) or None, decided once at the FFI seam, so the fact reaches a match instead of a == null test someone can forget. An extern fn cannot return an Optional (its prototype comes from the C header), so this is how the stdlib reads every extern that genuinely returns null (kama_diropen, kama_poller_create, the process argv/envp builders); readDir on a missing directory is the Err that path produces. It is the inverse of the prelude's unwrapPtr/ptrOrNull.

Inline assembly — asm("...") ✅#

Some operations have no C-level equivalent: wfi/wfe (idle-sleep), cpsid i/cpsie i (interrupt-masked critical sections), dsb/dmb/isb (memory barriers), cycle-exact delays. asm(...) emits them directly.

unsafe fn void idle() {
    asm("wfi");                        // -> __asm__ __volatile__("wfi" : : : "memory");
    asm("cpsid i\n\tdsb");             // multiple instructions in one \n-separated string
}

Conditional compilation — @compileFor(FLAG) ✅#

Tag a whole declaration; the compiler keeps or drops it for the active build. There is no in-body branching — no static if, no #ifdef/comptime-if soup. Build-mode (DEBUG/RELEASE) and platform (OS_WINDOWS/ARCH_WASM32/…) are the same primitive: a decl-level keep/drop gate.

@compileFor(DEBUG)   fn void traceState(int32 s) { ... }   // gone entirely in a release build
@compileFor(!RELEASE) static int32 assertsRun;             // present in any non-release build
@compileFor(OS_WINDOWS, TELEMETRY) fn void ping() { ... }  // comma = AND (both flags active)

⚠️ A platform flag is DERIVED FROM THE TRIPLE, and a built-in target NAME is not a flag. The derived set is ARCH_<arch>, OS_<os>, ABI_<abi>, the synthesized HOSTED and SIMD128, plus the name of a target the project declared. So NATIVE, WASM, EMBEDDED and WINDOWS are not gates — they are undeclared flags, and this is deliberate: EMBEDDED is a shortcut for a triple family, so gating on it would gate on how the build was spelled and would silently stop applying the moment a real board triple (xtensa-none-elf) was used instead. OS_NONE is the fact, and it holds for both. ⚠️ Getting this wrong fails silently in a loose build, which reads no manifest and treats an undeclared flag as simply inactive — so the declaration is dropped and the only symptom is a missing symbol somewhere else, or nothing at all if both sides were gated. A manifest build rejects the name outright. These four spellings were in this document's own examples until 2026-09-01, and a program built on kama nearly shipped them off these pages; tools/check-compilefor.sh now greps the docs for them, because the half of that guard which proves the compiler rejects such a name is exactly what made the docs drifting invisible.

⚠️ A triple component must be one some target kama KNOWS can produce — the built-in catalog, a target the manifest declares under select.TARGET, or this build's own --target triple. Anything else is an error naming the nearest known spelling: @compileFor(OS_WINODWS) is refused with "did you mean OS_WINDOWS?". It holds in every mode — a loose file as much as a manifest build, because OS_/ARCH_/ABI_ are reserved namespaces and therefore kama's names to validate rather than a project's to declare — and wherever the name appears, negated included: !OS_WINODWS is satisfied by every target there is and is the same typo, silently keeping the declaration everywhere instead of dropping it. Until 2026-09-18 the prefix was a free pass: any spelling validated, and the gate quietly deleted the declaration from every build, while @compileFor(WINODWS) one level down was refused — one rule holding in one position and not its sibling.

type contract Clock for value { fn int32 tick(); }
@compileFor(!ARCH_WASM32) type value NativeClock implements Clock { ... }   // native build keeps this
@compileFor(ARCH_WASM32)  type value WasmClock   implements Clock { ... }   // wasm build keeps this

A flag and its negation, rather than two positive names, because that is what makes the pair both exhaustive and mutually exclusive — exactly one impl survives on every target, including ones nobody has built yet. (HOSTED would not serve here: emscripten has a libc, so it is hosted too.) The working fixture is tests/compilefor_platform.kama.

The file gate — file @compileFor(FLAG); ✅#

The same primitive applied to a whole compilation unit, written as its first line:

file @compileFor(!ARCH_WASM32);     // this file is not part of a wasm build at all

import { std::fs::exists };
fn int32 platformValue() { return 1; }

It exists because a package build compiles every .kama under its source root, whatever the import graph — which is what lets the manifest alone prove there is no unreachable module, and what left a native-only file with no way to sit in a project that also builds for wasm. Gating declarations was not enough: the file still had to analyze on every target, so an extern over a platform header or a type built on one sank a build that was never going to run the code.

file is a contextual keyword: one only in this position, an ordinary name everywhere else. tests/file_gate.d/ is the working example — two implementations of one function, one gated per platform, returning the same value on the native and wasm legs.

kama check covers every gate ✅#

A gate is a keep/drop, so the code it drops is never analyzed — which means code for a target, a build type or a flag you do not build every day rots in silence, and the first report comes from whoever does build it. So kama check owes every gate in your own files one analysis in which that gate is active:

$ kama check kama.json
src/impl_wasm.kama:11:0: error: unknown type `Zork` in a parameter — …  [--target WASM]
kama: kama.json FAILED (2 configurations, 1 error)

It analyzes the configuration it was given, then adds configurations until each @compileFor and each file gate has been active in one, and tags a diagnostic from any other configuration with the flags that reproduce it. No cross toolchain is involved: this is kama's own analysis, and a --target WASM check on a Mac needs no emscripten. The obligation is over the gates the program contains, not over the configurations it admits — the latter is a product (TARGET × BUILD_TYPE × OUTPUT × every group × the powerset of flags) and cannot be enumerated, while a gate is a conjunction of possibly-negated names with no ||, so a covering set is computable and is what "this code is checked somewhere" means.

Your editor does the same for one file: a source the editor's configuration gates out is analyzed under a configuration that admits it, with its diagnostics tagged the same way — see editors.md.

The guard for the cover itself is tools/check-compilefor-cover.sh: every axis, both tags, and the corpus swept; the editor half is section J of tools/check-lsp.sh.

Flags are reproducible — from the explicit build invocation, never ambient environment. They come from two places: single-select groups (pick one value; its name becomes a flag) and the multi-select flags bag (any number on at once).

Gate on the derived flag rather than a target name: @compileFor(OS_NONE) holds for EMBEDDED and for a real board triple like xtensa-none-elf, whereas a built-in name describes only how the build was spelled — which is why built-in target names are not flags at all.

kama.json (a user-project file, named as the build's operand — kama build kama.json) declares the valid user-flag universe and any extra groups, and turns on strict validation — an undeclared @compileFor/--define name is then rejected (typo protection). A build that names .kama files instead is a loose build: it reads no manifest at all, so it is permissive (an undeclared flag is simply inactive) and bare single-file builds need no config. There is no auto-discovery and no --config flag — the operand says which of the two you meant, which is the rule everywhere in the CLI.

{ "name": "myapp", "version": "0.1.0",
  "select": {
    "TARGET":     { "RPI":  { "triple": "aarch64-linux-gnu", "cc": "aarch64-linux-gnu-gcc" } },
    "BUILD_TYPE": { "FAST": { "inherits": "RELEASE" } },
    "CONSOLE":    { "XBOX": { "default": true }, "PS5": {} }
  },
  "flags": { "TELEMETRY": { "default": true }, "PROFILING": {} } }

A single-select group takes exactly one value — --select CONSOLE=PS5 --select CONSOLE=XBOX is an error — and inherits pulls the base in with it, so FAST activates RELEASE and gets its optimization/stripping behavior without redeclaring it. A value may also carry build flags — cflags, ldflags, cxxflags, objcflags, the names a target entry takes — added after the project's and the target's, base first along inherits, to every translation unit (a dependency's included). That is where a sanitizer build lives: "ASAN": { "inherits": "DEBUG", "cflags": ["-fsanitize=address"], "ldflags": ["-fsanitize=address"] } is a build type of any target, --select BUILD_TYPE=ASAN, beside --target. kama.local.json overrides defaults per machine. Precedence: CLI > kama.local.json > kama.json > the built-in default. The practical toolchain setup is in targets.md.

kama.json is the project manifest — modules, dependencies, targets and build settings (packages.md, targets.md). It is parsed by the compiler driver (C++), not by the language's own JSON library — the compiler is not self-hosted, so its build-time config can't run kama-level code.

Writing a collection in kama — sizeof, panic/assert, place-returning methods ✅#

The above pieces (a place-returning operator[], UnsafePtr<T> + unsafe, generics, RAII) let a Vec/matrix be written in the language rather than baked into the compiler. Three builtins complete the kit:

foreach over a user type — the iterator protocol. A user container is foreach-able (not just the built-in InlineArray) via a small iterator protocol — not indexing, so it works for any shape (list, tree, map, range). It's zero-cost: monomorphized to direct calls (no vtable), and the container is borrowed, not consumed. The container hands out an iterator via a nullary factory method, and that iterator must implements the matching prelude contract — foreach is nominal: a type with the right method shape but no implements is rejected (explicit over implicit).

The prelude contracts (type contract Iterator<T> for value, view, resource { fn Optional<T> next(); } and IteratorMut<T>) are ordinary monomorphized generic contracts, so they double as a static bound — fn sum<I: Iterator<int32>>(I it) (zero-cost, direct Concrete__next) or a dynamic fat-pointer value Iterator<int32> it (vtable). foreach uses the same implements, checked nominally.

Iterator safety: growing a collection (add) while iterating it would be a use-after-free when the buffer reallocates. The library DynamicArray guards against this at runtime (C#-style): a modification counter is bumped on every structural change (add), each iterator snapshots it, and next()/ hasNext() panics if it changed — before the stale cursor is dereferenced. In-place element writes (foreach (ref x in list) { x = … }) don't touch the counter and are fine — that's the point of ref. The guard lives in DynamicArray's own kama source (not the compiler), so it's a stdlib policy: a hand-rolled container chooses whether to pay for it. FixedArray/InlineArray are fixed-size and can't reallocate, so they need no guard. (The iterator's back-pointer to the counter uses the addr(of: place) builtin — the address of a place as an UnsafePtr<T>; safe to take, unsafe to deref.)

An InlineArray's kind follows its element's. Over a value — a number, a type value, a plain enum — it is a value: copied bitwise, with nothing to drop, and it may sit in a type value or a module static. Over an element that owns something — a string, a resource, an owning enum, an owning array — it owns its elements, as a DynamicArray does, and drops each one at the end of its scope. A hand-off of one says give (move) or copy (deep, element by element); a bare b = a is refused. foreach (T x in a) hands each pass a copy the pass drops, and is refused when the element does not copy (borrow with const ref). [v; N] copies v into every place, and is refused when the element does not copy. Each element of a literal is handed off as xs.add(item: …) would take it, so a bare named string there is refused. Such an array owns memory, so it is refused in a type value and as a module static. A contract element is refused: it has no layout to store in place. Copies are independent: writing one never reaches the other.

InlineStorage<T>#(N) is N places for a T in line that the compiler never constructs, copies as elements or drops. It is the in-place half of "unsafe core, safe API": what a container written in kama keeps a live prefix in, as an allocator's block is the heap half. It is laid out and aligned as T[N], so its sizeof/alignof are compile-time facts; it is zero-filled at construction, InlineStorage<T>#(N) s = InlineStorage.empty(); or as a field; and its kind follows T's — a value over a value, copied bitwise, and move-only over an element that owns something, though it never drops anything either way: which places hold a T is the holder's knowledge. Its only way in is dataPtr()/dataPtrMut(), whose pointer only an unsafe fn may hold. It has no element operations: no index, no foreach, no operators. Over an owning element it is refused in a type value, and copy does not apply to it. It has at least one place, and a contract, with no layout, is refused as its element. InlineStorage.empty() is its one constructor, and takes its instance from where the value goes. An InlineArray is Equatable exactly when its element is, and compares element by element: ==, != and a.equals(other: b), a T: Equatable<T> bound, a field of a @generate(Equatable) type, a collection's contains. Each element goes through its own equality — == for a number, so -0.0 == 0.0 and a NaN is unequal to itself; the element's equals for anything else — never a memcmp, which would compare padding. Rust's [T; N], C++'s std::array and Go's arrays compare the same way; a digest compared to a secret wants std::digest::constantTimeEquals. An element with no equality is refused, naming it, and an InlineArray has no other operator. FixedArray has equals too, as DynamicArray always had.

Every contiguous container hands out the same two bridges, InlineArray included. dataPtr() (a const fn) returns an UnsafeConstPtr<T> for C and dataPtrMut() the writable UnsafePtr<T> — both safe to obtain, unsafe to dereference; string.cstr() is the same read-only bridge — and view() returns the read-only ConstView<T> and viewMut() the writable View<T> for kama — which is what reaches borrow, parallel_for (viewMut()) and every view-taking algorithm in the stdlib (sort, sortWith, binarySearch). This matters most for InlineArray<T>#(N), the one container that is stack-allocated, fixed-size and allocation-free — so the one a @noheap region is obliged to use, and until it carried these two it was the one locked out of all of the above. view() is also what lets a single signature serve every size: N is part of an InlineArray<T>#(N)'s type (and of any contract that mentions one), while a View<T> carries its length as a value, so fn void fill(View<float32> block) works for a 480-frame call and a 512-frame call alike.

⚠️ A view over an InlineArray is bounded by the same rules as any other, and needs no extra care despite the storage being on the stack: the escape checks key on the view, not on where the buffer lives. Returning one over a local is rejected, and a view local still needs a borrow window (tests/inline_array_view.kama shows both accepted spellings). View<T> is a stdlib type, so a program that never imports it has no view to mint — the diagnostic says exactly that rather than claiming the method does not exist. dataPtr() is unconditional.

Explicit SIMD — Simd<T> comptime(int32 N) ✅#

A lane batch: N numbers the CPU operates on as one value. It is an intrinsic value type, monomorphized per (T, N), lowering to a C vector_size typedef — so a + b is one machine instruction, not a loop.

fn uint8 main() {
    Simd<float32>#(4) a = [1.0f32, 2.0f32, 3.0f32, 4.0f32];   // an array literal — lanes written out
    Simd<float32>#(4) k = [10.0f32; 4];                        // the fill form IS a splat
    Simd<float32>#(4) c = a * k + a;                           // elementwise; C's own operators
    float32          x = c.lane(index: 2);                    // a bounds-checked lane read
    Simd<float32>#(4) p = c.abs();
    Simd<float32>#(4) lo = c.min(rhs: k);                      // also `max(rhs:)`
    Simd<float32>#(4) rt = c.sqrt();                           // also `floor()` / `ceil()` — FLOAT lanes only
    InlineArray<float32>#(4) back = c.toArray();               // back to addressable memory
    return 0;
}
fn float32 lanes(Simd<float32>#(4) a, Simd<float32>#(4) b) {
    Simd<float32>#(4) rev = a.shuffle(pattern: [3, 2, 1, 0]);        // an arbitrary permutation
    Simd<float32>#(4) mix = a.blend(rhs: b, pattern: [0, 5, 2, 7]);  // two vectors: 0..3 from a, 4..7 from b
    Mask<float32>#(4) gt  = a.greaterThan(rhs: b);                   // a lane mask, as a VALUE
    Simd<float32>#(4) pick = gt.select(ifTrue: a, ifFalse: b);
    float32 total = a.reduceAdd();     // also reduceMul / reduceMin / reduceMax
    return total;
}

SIMD128 — are the lanes real? A derived @compileFor flag, read off the resolved target like ARCH_AARCH64 / OS_LINUX / HOSTED. It is not "this target can compile a Simd" — every target can, by degrading to scalars. It says the target has 128-bit vectors in its baseline (mandatory SSE2 on x86-64, mandatory NEON on AArch64, wasm with -msimd128), so a library can pick a different algorithm rather than hoping the fallback is fast enough (tests/simd128_flag):

@compileFor(SIMD128)   fn int32 sum4(InlineArray<int32>#(4) a) { … Simd<int32>#(4) … }
@compileFor(!SIMD128)  fn int32 sum4(InlineArray<int32>#(4) a) { … a scalar loop … }

Prefer it to a target name: SIMD128 states the capability, and a name only implies it. (Gating on a target name is legal — names are ordinary flags through the TARGET group, and that is how platform implementations are selected — so this is guidance about which question you are asking, not a rule the compiler enforces.)

The codegen — that this really becomes a machine vector rather than four scalars — is asserted by tools/check-simd-type.sh, because a value fixture passes just as happily against a scalar fallback.

Function pointers — fnptr ✅#

kama has no naked function pointers. fnptr declares an explicit, named function-pointer type (independent of any user type) — zero-cost (a bare C function pointer, no wrapper). It is non-null (must be bound; no null, no null-check at the call, and cb == null is refused), and binding a free function is signature-checked. (A bodiless fn is not a function pointer — a forgotten body is a clear error, never a silent type.)

fnptr int32 Comparator(int32 a, int32 b);       // an explicit function-pointer TYPE
fn int32 cmp(int32 a, int32 b) { return a - b; }

fn uint8 main() {
    Comparator c = cmp;                             // bind by name (positional, type-checked) — used directly
    int32 r = c(a: 9, b: 2);                        // named invoke through the pointer
    return 0;
}

A bare function name used as a value is its function pointer (Rust-like), so binding and passing need no operator — c = cmp and f(cb: cmp) just work. That is the only place a function is a value: where an fnptr (or a BindableFunctionPtr promoted from one) is wanted. Anywhere else — an operand (a * pi), a float32 local or argument — the name is refused, naming the call that was meant (pi()). An fnptr can also be a parameter (fn run(Op op, …) { op(…) } — the core callback shape), and it can be stored and invoked later — in a field or a module static — which is the callback-registry shape: install a handler now, dispatch through it on a later call (tests/fnptr_stored.kama). Binding is checked in every one of those positions, not only in a local's initializer: a shape-mismatched bind elsewhere used to compile, and the call through it then passed the wrong number of arguments — an indirect call through a mismatched function-pointer type, which is undefined behavior.

Any value of an fnptr type binds another of that type — a field, an element, a module static, a call's result — and a call through any of them has the signature's return type, so the numeric, kind and identity rules judge its result like any call's. The callee of such a call is the value, never a function that happens to share its name: a parameter decode is called through, whatever decode the program compiles elsewhere (tests/fnptr_call_named_like_fn.kama).

Any expression of an fnptr type is callable: an element (handlers[i](x: 3)), a call's result (pick()(x: 1)), a parenthesized operator ((ready ? onOk : onErr)(x: 1)). The callee is evaluated first, then the arguments left to right, so handlers[0](x: reinstall()) runs the handler that was there before reinstall did (Rust's, Go's and Java's order; C leaves it unspecified). Two function names chosen by an operator have no type until a destination names one, since fnptr types are nominal (below), so bind the choice first: Op h = ready ? twice : thrice;.

An fnptr type is a type argument like any other, which is what makes a registry a collection: DynamicArray<Op>, Map<string, Op>, a user Box<Op>, a generic function over one, and Optional<Op>, the one maybe-a-callback (Rust's Option<fn>, Swift's (…)?, Zig's ?*const fn). It has no default, because there is no null function to fill with, so a fixed array of them is FixedArray.filled(size:, value:), never make. A bare function name gives a generic T nothing to infer from: write the type argument, id::<Op>(v: twice).

An fnptr is Equatable and Hashable, so == and the bound agree and a handler can be a Map key, be found with contains, or be removed from a registry by identity. Two values are equal when they call the same code: a function has one definition, so it always equals itself, and two different functions compare equal only when a linker folded identical machine code into one, in which case calling either does the same thing. Equality is not "same name". The hash is the address. Both methods are reached through a bound, as a primitive's are; == compares two directly. (C, Zig, Rust and C# compare function pointers the same way; Go and Swift refuse it, because their function values are closures carrying captured state, which a kama fnptr never is.) Constraint: a generic function's instance is emitted per file, so one could have two addresses; if an instance ever becomes an fnptr value, it must first get one.

A signature may carry @noheap, which makes non-allocating part of the type: @noheap fnptr int32 Op(int32 x); requires every function bound to it to be @noheap too, and in exchange a call through the slot is legal inside a no-heap region — the one slot whose target no build can see. See No-heap subset.

Unbound method references — Type::method (zero-cost). A method lowers to Class__method(Class* self, …), so it's a function pointer whose first parameter is the receiver; the object is passed explicitly:

@generate(of) type value Vec2 { public int32 x; public int32 y; fn int32 dot(ref Vec2 o) { return this.x*o.x + this.y*o.y; } }
fnptr int32 DotFn(ref Vec2 self, ref Vec2 o);   // receiver is an explicit first param

fn uint8 main() {
    Vec2 u = Vec2.of(x: 1, y: 2);   Vec2 v = Vec2.of(x: 3, y: 4);
    DotFn d = Vec2::dot;            // unbound (`::` = no instance, no binding) — zero-cost
    int32 n = d(self: ref u, o: ref v);
    return 0;
}

BindableFunctionPtr<Sig> — a callable that captures a receiver so you don't pass it each call. Unlike the zero-cost fnptr, it carries an object (opt-in cost) and is RAII-managed. It's constructed like any other object — by its constructor, BindableFunctionPtr.bind, whose signature comes from the local it initializes (not new: a bindable is a value, and its object is already on the heap behind the pointer you hand in) — and the ownership model follows the pointer type you hand in — no separate keyword:

fnptr int32 Compare(int32 a, int32 b);   // NB: receiver is HIDDEN here (the inverse of an unbound fnptr)
type value Scaler { int32 k; public ctor make(int32 k){ this.k = k; }
               public fn int32 apply(int32 a, int32 b){ return (a - b) * this.k; } }
fn int32 sub(int32 a, int32 b) { return a - b; }

fn uint8 main() {
    Owned<Scaler>  s  = new Scaler.make(k: 3);     // (constructed as Owned)
    BindableFunctionPtr<Compare> c  = BindableFunctionPtr.bind(obj: s,  method: Scaler::apply);  // MOVE-in (sole owner)
    Shared<Scaler> s2 = new Scaler.make(k: 2);
    BindableFunctionPtr<Compare> c2 = BindableFunctionPtr.bind(obj: s2, method: Scaler::apply);  // RETAIN (shared owner)
    BindableFunctionPtr<Compare> c3 = sub;   // free-function PROMOTION (no object) — so this type "accepts either"

    int32 r = c(a: 9, b: 2);                 // -> Scaler::apply(boundObj, 9, 2) = (9-2)*3 = 21
    return 0;
}

An Owned obj: moves in (the bindable becomes the sole owner); a Shared obj: is retained (refcount; the object lives while any owner holds it). Either way the object is released by the box it came from: the bindable rebuilds that Owned/Shared and runs its destructor, so the object's own drop, its allocator and its size are decided in one place. That rebuild uses the allocator's default value, so the box must draw from a stateless allocator (GlobalAllocator, or any Allocator with a default ctor) — a box in an arena (BumpAllocator) is refused, since a default one would point at no arena. The obj: is a box over a concrete type, because the method is named T::method. A bare fnptr / free function promotes in with a null object — so a BindableFunctionPtr<Sig> parameter accepts both free and bound callables, while fnptr stays the zero-cost free-only form. It is move-only (it may uniquely own its object): returning one from a factory transfers ownership; the captured object's destructor runs exactly once when the bindable finally drops.

Because a bound one owns its receiver, handing one bindable to another is an ownership transfer and takes the same marker every other owning type takes: give, and only give. A bare b = a is refused — it would leave two handles releasing one object — and copy is refused outright: the handle is type-erased, so whether its receiver is unique or refcounted is only known at run time, and the compiler cannot decide which of the two a duplicate would need (Owned refuses copy for the same reason). Promoting a free function is not a hand-off — it transfers nothing — so it takes no marker, and a give/copy there is refused rather than ignored. Reading a bindable after it was given is a use-after-move like any other, including calling it.

FFI: an extern fn may take an fnptr type as a param; passing it hands C the raw pointer. C requires an exact function-pointer-type match (incompatible fn-pointer types are a hard error), so when the C callback signature is one kama's fnptr doesn't spell identically — most commonly const-qualified parameters — name the callback via a header typedef and cast to it at the edge:

extern "<stdlib.h>";
extern "cb.h";   // typedef int (*CompareFn)(const void*, const void*);
@callerThread fnptr int32 Comparator(UnsafePtr<int32> a, UnsafePtr<int32> b);   // qsort calls back on this thread
extern fn void qsort(UnsafePtr buf, usize nmemb, usize size, CompareFn compar);
...
Comparator c = cmp;
qsort(buf: a.dataPtrMut(), nmemb: 4, size: 4, compar: cast<CompareFn>(c));   // qsort WRITES: the mutable half; cast to the header's fn-ptr type

Exposing to a host — expose ✅#

extern is the host→kama direction (kama calls C); expose is the reverse — it gives a free function a stable, host-callable entry point, decorated with KAMA_EXPORT for external linkage that survives dead-code elimination. Its C symbol is its module path joined to its name with _: expose fn tick in module game::sim exports game_sim_tick. C has one flat namespace and kama's modules exist to keep names apart, so the module travels into the symbol — two modules may each expose tick, and neither collides with the other or with a libc open/time in the host's translation unit. A loose file is in no module, so its file name qualifies it (gameplay.kama → gameplay_update). Inside kama an exposed function is an ordinary function of its module: it is exported, imported and called by its kama name. _ is the join rather than kama's internal __, which C++ reserves in any identifier. @linkName("symbol") is the one way to fix a symbol verbatim — Rust's #[export_name], the same attribute an extern fn uses to bind one (FFI — calling C) — for a name someone else chose: a plugin API's entry point, an interrupt vector's handler, a host that already knows the module by host_tick:

// gameplay.kama — a hot-reload module (note: no `main`)
type value World { public float32 time; }
expose unsafe fn void update(UnsafePtr<World> w, float32 dt) { /* … */ }   // dlsym("gameplay_update")
expose fn int32 version() { return 3; }                                    // dlsym("gameplay_version")
@linkName("host_tick") expose fn int32 tick() { return 41; }   // dlsym("host_tick"); there is no `gameplay_tick`

(tests/expose_module_scope.d/ proves two modules' symbols through the linker, tests/linkname_expose.d/ the @linkName; tests/support/expose_shared_check.sh both through dlsym.)

Rules (checked at compile time — a clear error, never a silent no-op):

The published header keeps the kama spelling of every parameter name, while the implementation C prefixes them (§ C names). A C prototype's parameter names are not part of its ABI and may differ from the definition, and the header is documentation for the host author, compiled in the HOST's macro environment — which kama cannot see. So a parameter named near reads as near in the header a Windows host includes, and a clash there is the host's to resolve, exactly like a clash on an exposed field name.

expose is distinct from export (module public-surface visibility) and public/private (member access): three boundaries, three keywords. (The full 2.0 expose — richer wasm module exports and the scripting-host interface — is future work; the keyword is live today for the C-ABI boundary above.)

C names ✅#

kama lowers to C, and the C preprocessor is not scoped: a macro from any included header rewrites a matching identifier anywhere it appears. One generated <name>.gen.h carries every extern header, so importing std::fs anywhere puts the whole OS seam in front of every module's names. Measured: 4,708 macros over the shipped header set on macOS, 21,748 over one Windows TU.

So every name kama owns reaches C in a namespace no header can rewrite. The registers:

register holds
KAMA_… kama's macros
kama_… what the compiler owns — runtime types and their members, emitted members and temps, the prelude's own scope
k_… what the user owns — fields, variant payloads and union members, parameters, locals, bindings, contract/vtable slots
kF_… a file's private declarations — kF_<file>__…, or <module>__kF_<file>__… in a module
kS_… a scoped package's declarations — @south/net's who is kS_south__net__who, so two scopes' net never meet
<project>__… an unscoped package's declarations — geo__area, std__collections__DynamicArray…, core__println

They are disjoint by construction, not by convention: k_ has _ at index 1 and kama_ has a, so no user name can ever produce a compiler-owned spelling. k, an uppercase tag and _ is a prefix kama builds (kF_, kS_), set apart by its second character in turn. A user name that already starts with k_ becomes k_k_…, and the mangling stays reversible — strip exactly one k_. Two rules close the rest. A project's name keeps out of k, kama, anything starting k_ or kama_, and k plus an uppercase letter alone or before _ (packages.md), since its <project>__ prefix would open one of the other registers (a project k's x was k__x, which a local _x also emits; a project kF_app's h would be app.kama's private h). And a name may start with __ but not contain one — __ is how scopes join in C, so a type a__b in geo would be geo__a__b, module geo::a's b. A project's name, a scope and a module segment are held to the same, since they are joined the same way. Nor may a joined name start or end with _: beside an edge _ the join could move, so project geo_'s module x and project geo's module _x would both be geo___x, and type T_'s method m and type T's method _m both T___m. The rule holds for every name kama joins: a project, a scope, a module segment, a file's name (its private names are kF_<file>__<name>), and a declaration's own name — a top-level declaration, a type's method, constructor or constant. A local, a parameter, a field and a binding are k_<name> and joined to nothing, so __ok and a field a_ stay legal. With both rules, every run of underscores longer than one in a C name is exactly one join, and kama demangle reads it one way. The one place that rule meets C is the declared surface: an extern fn whose C name has __ inside it is bound with @linkName.

The declared C surface keeps its spelling, because code on the other side depends on it: extern fn names, type extern value fields, type expose value / expose enum fields and values, and the parameter names in a published host header (§ Exposing to a host).

None of this reaches a human: a diagnostic, a hover, a kama query answer and an editor completion all show the name as written.

Where one does reach you, kama demangle turns it back. Three things read names straight out of the binary and cannot be taught otherwise — a debugger, a C compiler's own output under --keep-c, and a crash log or objdump:

kama demangle app.kama -- kF_app__Pair_int32__make     # -> Pair<int32>::make
kama demangle app.kama -- kS_south__net__who           # -> @south/net::who

It is a subcommand rather than a name map written beside the build, because a map is a record and a record can diverge from the binary being debugged — a stale one renames a frame to something plausible and wrong, silently. It runs the front end, because the reverse of DynamicArray_int32_kama__GlobalAllocator is DynamicArray<int32> and the argument spellings (and the dropped default) live in the resolved program, not in the text of the name. Names are rewritten in place, so a whole line of C survives its surroundings, and stdin is read as a batch so one process serves a whole debug session.

The reversibility rule above is what makes it possible: strip exactly one k_. Only a token no table claimed is stripped, because a declaration's own leaf was never prefixed — a type the author called k_Box in kk.kama is emitted kF_kk__k_Box and demangles to k_Box, while a local called k_near is emitted k_k_near and demangles to k_near.

Values are a separate matter from names: kama demangle --lldb-init prints the command that loads the LLDB formatters shipped beside the runtime headers, after which a string inspects as its text and an Optional as Some(…)/None rather than as the lowering.

The residual, stated. kama compiles headers it has never seen, so a user's own extern "<vendor.h>" could in principle define a macro spelled k_…, kF_… or kama_…. tools/check-c-names.sh holds the line for the surface kama ships and the system headers its seam pulls in, re-measured wherever the suite runs — because a macro surface moves with every SDK release. A vendor header that squats on kama's registers is the one case this rule cannot close, and it is a bug to report rather than a hazard to live with.

Control flow ✅#

if/else, while, do/while, for, foreach, break, continue, return; the full operator set (+ - * / %, bitwise, shifts, comparisons, && || !, ternary ?:), assignment ops (= += …), ++/--, casts. Branching on an enum is done with match (see Enums & match below); arbitrary-integer branching is done with if / else if. There is no switch statement: match on an enum, if/else if on an integer — one construct per concept (GOALS 4). A value pattern (case 1:) and a match over an integer, a string or a bool are refused, naming that rule. &&, || and ?: evaluate an operand only when the result needs it, so n > 0 && a[0].isEmpty() never indexes an empty a, and the arm ?: does not choose never runs — its calls, its temporaries, its drops.

break and continue act on the innermost while, do, for or foreach. A match is not a loop, so a break in one of its arms leaves the loop around the match, and outside any loop either word is an error:

while (true) {
    match (queue.next()) {
        case Some(value: job): { run(job: job); }
        case None: { break; }               // leaves the `while`, not just the `match`
    };
}

In a parallel_for body, continue ends that element's pass. break and return are errors there: the elements run on several workers at once, so there is no rest of the loop to skip, and the body does not run in the enclosing function. A loop nested in the body is an ordinary loop.

Every branch and loop body must be braced. if, else, while, do, for and foreach each take a { … } block — never a bare statement, and never an empty ;:

if (n > 0) { return 1; }        // ok — and a one-line body is fine, the rule is about the braces
if (n > 0) return 1;            // ERROR: the body of `if` must be braced
if (n > 0);                     // ERROR: binds the branch to nothing

A bare body is where goto fail;-shaped bugs live: a later edit adds a second statement, it indents as though it belongs to the branch, and it does not. kama has no whitespace rule to fall back on, so the brace is the only thing that can carry that meaning — requiring it makes the bug unrepresentable. The rule is about braces, not line breaks: if (x) { return; } on one line stays legal, because a braced body cannot silently acquire a second statement.

The one exemption is else if. The else arm accepts a block or another if, so a chain stays flat:

fn int32 rank(int32 n) {
    if (n > 100) { return 4; } else if (n > 10) { return 3; } else { return 0; }
}

That if is the branch — it cannot grow a sibling statement the way a bare body can — and requiring else { if (…) { … } } would nest every chain for no safety gain. scope, parallel_for and match are unaffected: they already require a block (a match arm's case P: expr; is an expression, not a statement body).

Unreachable code is an error. A statement after one that never completes is refused, naming what leaves: a return, break or continue; a panic; a while (true) or for (;;) with no break out of it; or an if/else or match whose every branch does one of those. Reachability is read off the structure, never off a folded constant, so if (true) { return 0; } followed by code stays a debug early-out, as in Java; a break from a match arm inside an expression leaves its loop as any break does; and a value-producing match arm may end in any of them, since it yields nothing.

while (true) { if (done()) { return give xs; } step(); }
return give xs;     // ERROR: the `while (true)` above has no `break`, so nothing after it runs

Dead code is bug-shaped — Apple's goto fail (CVE-2014-1266) was a duplicated jump that left a certificate check unreachable — and a checker that judged it would report mistakes in code that cannot run. Java and Zig refuse it too; Go accepts it and leaves it to vet; Rust, Swift, C# and Kotlin warn.

kama has errors only, never warnings. A diagnostic refuses the program, or it is not printed. A warning is the compiler saying it does not believe its own output: the code is wrong, and should not build, or it is fine, and saying otherwise is noise. Treating warnings as errors does not repair that; it ties a build to one compiler's lint set, so a newer compiler's new warning breaks code that was correct yesterday — which is why Rust discourages #![deny(warnings)] in a library and caps the lints of dependencies, and why distributions patch -Werror out of C packages. Every language with warnings also grows a second language to silence them (#[allow], #pragma, @SuppressWarnings). An error in kama is a rule of the language, stated here with a fixture, so it changes only when the language does. Advice that is not a rule — an import the package does not declare — is an editor hint from the language server, never a line on stderr.

How far an expression may go#

kama accepts or refuses a source the same way on every machine: the answer does not depend on the host's stack, on ulimit, or on which C compiler is installed, and kama check and kama build give the same one. Three limits say so, each with a message that names it:

Past any of them, split the expression across statements or move the inner part into a function.

Type declarations — value / resource / view / contract / enum / intrinsic ✅#

Every type declaration is introduced by the type marker followed by a kind — parallel to fn on every function, so declarations are greppable and self-describing:

type adapter <targets> implements C { … } is not a kind: it declares no type. It writes contract C's conformance for types declared elsewhere — a built-in, a std type, another package's type, one generic instance, or a generic's every instance — in the module that declares C, one block for a whole set of targets. See type adapter below.

The full model + rationale is in TYPE_MODEL.md. The kind words value / resource / view / contract, and adapter, are contextual, not reserved — because they appear only right after type (and intrinsic only in a contract's for list), they remain ordinary identifiers everywhere else (int32 value = 5;). enum is the one kind word that IS a reserved keyword, for the historical reason that it predates the type marker; that costs nothing, since nothing else could be spelled there. The qualifiers virtual / abstract / final are reserved words; type itself is contextual (below).

type may still NAME a binding — a field, a local, a module static, a parameter or a method — and be read, written and passed like any other name. It is a contextual keyword: it leads a declaration only where a declaration can begin, and is an ordinary identifier everywhere else. That puts it beside copy/give/truncate/default/base, and beside the kind words above.

This exists for the FFI and could not be worked around there: webgpu.h calls a field type in four structs, and an extern struct emits the literal C name, so unlike a function — whose kama binding is simply renamed — the field is assigned BY NAME and renaming it emits the wrong C. Omitting it is not a lesser evil: the field then holds 0, and 0 is WGPUBufferBindingType_BindingNotUsed, so an explicitly built bind-group entry for a uniform buffer or a sampler is inert rather than under-specified.

type extern value WGPUBufferBindingLayout {
    public UnsafePtr nextInChain;
    public uint32    type;              // the literal C name — no escape, no rename
    public uint32    hasDynamicOffset;
    public uint64    minBindingSize;
}
b.type = WGPUBufferBindingType_Uniform;

fn void configure(uint32 type) { … }    // and it reads as an ordinary name everywhere else
configure(type: WGPUBufferBindingType_Uniform);

The contract for clause — which kinds may implement it ✅#

A type contract must declare its implementers: type contract C for <kinds> { … }. The clause takes any combination of the five implementable kinds, comma-separated, meaning any of these:

type contract Rankable for value { … }                             // one kind
type contract Iterator<T> for value, view, resource { … }          // a borrowing iterator is a view, a
                                                                   //   generating one is a value, and one
                                                                   //   that OWNS its source is a resource
type contract Hashable for value, resource, enum, intrinsic { … }  // anything that can be a Map key

contract is not among them: a contract implementing a contract is refinement, a different axis, already spelled by implements on the contract itself. The separator is a comma and only a comma — + was rejected because it means conjunction in a generic bound (<K: Hashable + Equatable> = satisfy all) and would mean disjunction here, one symbol with opposite senses.

Every kind is enforced, and each is judged as what it was declared, not as what it lowers to: a type view codegens like a value (same layout, same copy) but implements as a view, so a contract that does not name view rejects it. @generate-synthesized conformances go through the same gate — a @generate(Serializable) type value needs Serializable to name value.

Widening a clause is a non-breaking change and narrowing one is not, so state the kinds a contract is for, not merely the ones implementing it today: the clause is a design statement, and a set narrowed to the current corpus is easily narrower than the contract's audience. Real names value alongside intrinsic for exactly that reason — nothing but float32/float64 implements it yet, and a user-defined soft-float value type is what it exists for.

(for both is gone. It named an arbitrary pair the moment there were more than two kinds.)

What an implements clause is checked against ✅#

An implements clause is a promise about signatures, and every part of it is verified at the declaration — not at the call sites, and not by the C compiler:

Matching is on the resolved type, so an alias, an import spelling, or a generic contract's substituted parameter (Iterator<T> implemented at T = int32) compares equal — what differs is what the two would lower to.

This applies to every kind that can conform, including an enum's conformance and a type adapter block's, to a contract-declared operator (int32 operator+(int32 rhs) — the generic-math bound shape), and to a conformance that dispatches only statically. A @viewable contract is the one exception: it emits no vtable and no fat-pointer type, so its members are nominal markers rather than slots — Iterable<T> declares fn Iterator<T> iterator() and every container correctly returns its own concrete iterator type.

override keeps the same promise, and is checked the same way. A derived method stands in for the base's through the base's slot, so its signature must match in every position above. It is not covariance-aware: a derived return type would be a language feature with its own rules and its own lowering, and accepting "any subtype" here would let a hierarchy promise what the vtable cannot keep. It has to be checked here: a contract's vtable slot is filled with a cast, so a mismatch is invisible to the C compiler at the declaration and surfaces — if at all — at a use site, naming mangled types the author never wrote. A mismatched member reached through the contract does not fail, it silently does the wrong thing (returning int64 through an int32 slot yields the low 32 bits).

type value Counter {
    int32 value;                                     // fields are private by default
    public ctor make(int32 start) { this.value = start; }   // named ctor (`public` to call from outside)
    public fn void add(int32 n) { value = value + n; } // method (implicit self)
    public fn int32 get() { return value; }
}
fn uint8 main() {
    Counter c = Counter.make(start: 40);   // stack value — dot-on-type construction, not `new`
    c.add(n: 2);                            // a `value` copies on hand-off
    return 0;
}

Fields, methods (take an implicit self), named constructors, field initializers (run in the ctor), this.field, obj.method(args). Lowers to a struct + Counter__method(Counter* self, …) functions. Members are private by default; new is reserved for the heap (Owned/Shared element construction), so a stack value uses Counter.make(start: 40), not new Counter.make(...). A constructor may also be called inline in a call argument — f(x: Counter.make(start: 5)) — it materializes a temporary passed by value (a value copies, a resource moves); use a local for a ref/out parameter. On a generic type the argument position pins the instance just as a declaration does: the parameter's type is the destination (take(b: Box.empty()) at a Box<int32>), and with no destination the constructor's own arguments bind its type parameters (generic(b: Box.of(v: x)), Box.of(v: 8).get()). With neither, the constructor is refused as the mistake, the receiver of a method call included ("cannot tell which Tag to construct").

new builds an enum on the heap with the variant's own spelling — Shared<Geo> g = new Geo::Circle(r: 4); (and new Geo::Point() for a payload-less one). A value is never converted into a heap handle over its own type: Shared<Pt> p = Pt.make(x: 1) and Shared<Geo> g = Geo::Circle(r: 4) are compile errors that name new, in every position a handle is bound. (Boxing a value into a handle over a contract — Owned<Hashable> h = 20, Owned<Error> e = ParseError.of(at: 3) — is a different operation, and legal: the value moves onto the heap, or is copied there, as any by-value hand-off moves or copies it, and the handle drops it. It happens wherever a handle is bound — a local, an assignment, a return, a match or ?: arm, a field initializer, an argument, a variant payload. A ?: arm may not move a named value into the box, as no arm may move one. A handle with its own allocator is filled with new(allocator: …) instead, since a value boxed in place comes from the global heap.)

A type that owns a heap resource (a collection, an Owned/Shared/Weak, or another resource) is declared type resource and is move-only:

import { std::collections::DynamicArray };
type resource Buffer {
    DynamicArray<uint8> data;                                // owns heap → resource; fields stay private
    public ctor make(int32 n) { this.data = DynamicArray.empty(); }
    public fn isize size() { return this.data.length(); }
}

A value that transitively owns a resource is a compile error ("declare type resource"), and a ~dtor is allowed only on a resource (~dtor ⟺ resource — a value owns nothing to free).

A kind that follows the type's parameters — the kind clause. A generic type that owns nothing of its own holds whatever its argument holds, so its right kind depends on the argument: Pair<int32> should copy as its bytes and sit inside a type value, and Pair<string> should move and drop its strings. A kind clause after the name says so:

type resource Pair<T> value when [T: value] {
    T a;
    T b;
    public ctor make(T a, T b) { this.a = give a; this.b = give b; }
    public const fn const ref T first() { return this.a; }
}

The declaration is a resource, and it is checked as one for every instance (its fields stay private). Each instance whose condition holds is a value instead: copied as its bytes, with no destructor and no copy constructor, and not Copyable by declaration (that contract is a resource's; a value satisfies the bound by its kind). So a ~Pair() and a ctor copy belong to the resource instances alone, and a conditional implements Copyable<This>(bare: give) when [T: Copyable<T>] beside the clause serves them. The clause narrows a type resource and nothing else; its word is value; the type is generic; and each condition is P: value over one of its own type parameters. A value is sealed, so the clause takes no extends, virtual, abstract or final. A value instance must own nothing: a field the clause's parameters do not decide (a string name) is refused at the first instance the clause makes a value. A resource instance (Pair<string>) is a resource everywhere, so a type value may not hold it. An enum needs no clause: its kind follows its payloads by nature (Optional<int32> is a value, Optional<string> a resource), and an InlineArray and an InlineStorage follow their element the same way. C++ makes the same property conditional (inplace_vector<T> is trivially copyable exactly when T is), as do Rust (#[derive(Copy)] emits impl<T: Copy> Copy) and Swift (extension Box: Copyable where T: Copyable); each makes the cheap property the conditional one over an owning base, which is the clause's shape.

value is a structural bound. Beside default, value asks of an argument what a kind clause asks: does it own nothing? It holds for a primitive, a raw pointer, a type value, a value instance of a kind clause, an enum whose every payload is a value, and an InlineArray/InlineStorage of values; not for a string, a heap collection, a smart pointer, a resource, a view or a contract (both borrow). It gates a member or a conformance (fn int32 width() when [T: value]) and bounds a type parameter (fn void f<T: value>(T x)), where the body may then copy a T as its bytes.

A kind word names no type. value, resource, view, contract, adapter and intrinsic say what a type is, so none of them names a type or a type parameter: type value value { … } and type contract resource for value { } built through 0.9.687, and would make [T: value] mean two things. As a field, binding or parameter name each stays legal — Result::Ok(value: x).

RAII / destructors ✅#

A ~Type() destructor runs deterministically at scope exit, in reverse construction order, on every path (block end, early return, break/continue). Destructible fields are destroyed in reverse declaration order, after the destructor's own body — and a return in that body ends the body, not the destruction: the fields, and a base's destructor after them, still run. No GC; allocation/deallocation is predictable.

Construction ✅#

A constructor is a named factory that returns a fully-initialized object, or an error. There is exactly one kind, spelled ctor (no fn, no static — it is implicitly type-associated), and everything is one, including deserialization and copying.

type resource Buffer {
    UnsafePtr<uint8> data = null;                                  // a field default states the empty value
    int32 size;
    public ctor make(int32 size) { this.size = size; }        // the value under construction is `this`
    public ctor withCapacity(int32 n) { return Buffer.make(size: n); }   // reuse = an ordinary call
}
fn uint8 main() {
    Buffer b = Buffer.make(size: 8);          // dot-on-type: construction
    Owned<Buffer> h = new Buffer.make(size: 8);   // `new` composes — heap, an owning handle
    return 0;
}

Complete initialization — enforced ✅#

A constructor must assign every field, checked at compile time. The returned value is complete by delegation when the ctor's terminating move is return Other.make(…), so chaining stays clean. This is kama's answer to "a returned object is always fully initialized" — it is proven, not conventional. The proof is definite assignment, path by path: a field assigned in both arms of an if/else, or in every arm of a match, is assigned after it; one assigned in only some arms is not; and a return inside a branch is judged by what that branch assigned. A ctor whose every path returns — a trailing if/match whose arms all return — never falls off its end, so it owes this nothing: a fallible ctor that returns only an Err or a delegated Ok is complete as written.

Two escape hatches, both explicit and at the declaration rather than hidden in codegen:

What is exempt is not a carve-out but a guarantee the compiler supplies: a field whose type has a default — string and the other language-owned types whose zero representation is their empty value (see Collections — the four-ctor matrix for the one rule), and a type with a default ctor, which the compiler calls at the fill site (element by element for an InlineArray of them). A number and a raw pointer field are still assigned: their zero is a value, but a forgotten one is a bug, not a choice.

type resource Ring {
    UnsafePtr<uint8> data = null; int32 len = 0;   // stated defaults — every ctor inherits them
    int32 cap;                                // no default -> every ctor must assign it
    public ctor withCapacity(int32 cap) { this.cap = cap; }
}

The idiom for a raw handle follows: give the field's empty value a niche rather than letting zero double as "unset". std::fs::File declares int32 fd = -1, so its destructor is if (fd >= 0) and descriptor 0 (stdin) is an ordinary ownable handle — Rust's OwnedFd.

The value under construction is this, and it needs no declaration. A constructor's whole job is to produce the type before it returns, so the storage is implied by the function itself — and since definite assignment already proves every field is set, a declaration would add ceremony, not proof. Declaring uninitialized storage of the type being built inside its own ctor is therefore an error: this is the only name it has. (An initialized local of the same type is untouched — it is a finished value like any other.) Falling off the end returns that value, exactly as a void function need spell no return; return give this; is the early-return form, and a bare return; is refused for naming nothing to return.

this is never assigned whole — not in a constructor and not in a method. A constructor's this is the value being built, so building it with another constructor is return Foo.a(…);, and "delegate, then change one field" is a private constructor that takes every field. A method borrows its receiver, so it assigns the receiver's fields, or returns the new value for the caller to assign (l = l.next();). Replacing a receiver whole (C++'s *this = …, Rust's *self = …) is a non-goal: an enum's receiver is a copy, and the assignment would change nothing the caller can see. A ref this argument on such a receiver is refused for the same reason. A const ref this one reads it.

self is reserved inside a type body. It is the emitted C name of the receiver pointer, so a local or parameter called self anywhere in a type — method, constructor or static fn — is a compile error pointing at this. Outside a type body it is an ordinary identifier: a free fn or fnptr may name a parameter self to spell an explicit receiver.

Collections — the four-ctor matrix ✅#

Every growable collection (DynamicArray, Deque, Map, Set, SlotMap, BitSet) offers empty() / withCapacity(n) using the default GlobalAllocator, and withAllocator(a) / withCapacityAndAllocator(a, n) for a caller-owned allocator. PriorityQueue carries capacity on its backing array; the B-tree SortedMap/SortedSet keep their own shapes, and the always-sized FixedArray has its two builds (make(size:) from T's default, filled(size:, value:) from a copy) each with an allocator twin. The canonical zero-arg build is marked default, which is what makes such a field default-fillable elsewhere.

The default-allocator conveniences are gated when [A: default] — a structural bound (the argument bound to A must itself have a default ctor; there is no nominal Default contract; value is the other structural bound, under A kind that follows the type's parameters above). So DynamicArray<T, BumpAllocator>.empty() does not exist: you get a clean "not available for this instantiation" error rather than a collection with a zero allocator. Use withAllocator for a custom A.

One rule for a default. when [T: default] holds, and T.default() exists, for exactly the same types. A language-owned type whose all-zero bytes are a real value has its default by nature: the numbers, bool, char, UnsafePtr/UnsafeConstPtr (null), string (""), Simd/Mask (zero lanes), and an InlineArray whose element has one (element by element). Every type a program declares — a value, a resource and an enum — has one only by electing it: variant order must not pick a value, and type enum Code : uint8 { Ok = 3, Bad = 200 } has no zero member at all. An enum elects the way a value does, type enum Level { Debug, Info; public default ctor info() { return Level::Info; } }. A never-null handle (Owned, Shared, Weak), an fnptr and a BindableFunctionPtr have none: each one's zero is a null pointer. Until 0.9.480 the bound held for every enum and every intrinsic, so FixedArray<Owned<Shape>>.make(size: 1) built a null handle in safe code, and T.default() still refused int32 though the bound admitted it.

Calling the election — T.default(). The mark names which ctor is canonical; T.default() calls it without the caller knowing the name the author chose (empty, zero, closed, …). It works on any type that has a default by the rule above — for one that has it by nature there is no ctor, and the value is its zero — and it is what makes the when [A: default] bound usable from kama rather than only by the compiler's field fill:

ctor fresh() when [A: default] { this.item = A.default(); }

Electing a default stays the type's choice: a type that never marked one has no default(), and the call site is a compile error naming that choice rather than a silently synthesized zero (tests/default_ctor_call.kama, tests/xfail/default_ctor_missing.kama).

Derives — @generate(...) ✅#

One opt-in surface. Every name is opt-in by design; a hand-written member always wins over the synthesized body, and the nominal conformance is registered either way.

Name Synthesizes
Serializable / Deserializable the reflective wire methods — see Serialization
Formattable a field-dump format(ref Formatter) — Type { f: v, … }
Equatable a memberwise equals(ref This); also what gives the type == / !=
Hashable a field-walked hash(), FNV-combined in declaration order
Comparable a lexicographic compareTo(const ref This) over the fields in declaration order; also what gives the type < > <= >= and makes it a sorted-container key
BytesViewable / Blittable bytes() / bytesMut(), a view of the value's own bytes — plain value only; see The bytes of a plain value
of a memberwise ctor T.of(f1:, …) — bag only (a transparent value)
zero a zero-init ctor T.zero() — bag only

Equatable/Hashable/Comparable walk each field through its own equals/hash/compareTo — never a bitwise compare, which would read padding and be wrong for any type whose equality is not its representation — so every field must itself conform, and @skip is honored by all three (which is what keeps "equal values hash equal" true, and compareTo answering Equal exactly when equals answers true). A float field is ordered by its Comparable total order (NaN after every number), as it is in a SortedSet, not by IEEE <.

What may carry it. Any value, resource or enum, generic or not:

Subject Derives Shape
plain value / resource all eight Type { f: v, … }
generic Box<T> all eight, per instantiation as above, for each instance
tagged enum the six contract names (of/zero are a non-goal) per tag: Circle { r: 2 }, or the bare name for a payload-less variant
payload-less enum the six contract names the bare variant name; on the wire a plain string "Green", not an object
generic enum (Optional<T>, Msg<T>) the six, per instantiation as the tagged/payload-less rows

A derive on a generic type is a conditional conformance: the instance carries it exactly when every substituted field (or payload) conforms. @generate(Equatable) on Box<T> cannot hold for every T, and the author does not write the condition because it is not a choice — it is a consequence of the fields, the same rule Rust spells impl<T: PartialEq> and Haskell instance Eq a => Eq (Box a). An instance that does not qualify is refused at the use site, naming the field that disqualified it. For an enum it names the variant too — "Put's field v (Plain)" — because a sum type gives the reader two places to look.

A conditional conformance is decided by the instance's own type arguments, never by what else the program imports. DynamicArray<T> is Serializable exactly when T is, in a program that serializes nothing as in one that does, and a generic function may bind its T to the contract. A container is Deserializable only when its allocator can be default-made, because deserializing builds one from nothing. An instance that misses a gate is refused naming the condition it failed.

A payload-less enum derives over its members: it serializes as the bare variant name, formats as it, and tests equality and hashes as its integer — which it still is, with a derive or without: ==, cast<IntType>, try cast, its explicit member values and match are unchanged.

A derived Comparable orders an enum by DECLARATION, then by the live variant's payload, field by field — Rust's derived Ord, and Swift's synthesized Comparable. Declaration order and not the integer, for both kinds of enum: a tagged enum has no integer to offer, and one rule beats one that changes when a member writes = 200. An enum whose integers run against its declaration and that wants them as its order writes compareTo itself (implements Comparable<This>), which is the one place that choice is spelled. Two names for one value (B = K::A) take the first one's place, so they compare Equal as they are ==.

@generate(Comparable, Equatable)
type enum Level { Low, Mid, High }

@generate(Comparable)
type enum Shape { Dot, Circle(float64 r), Rect(int32 w, int32 h) }

fn bool demo() {
    return Level::Low < Level::High                              // by declaration
        && Shape::Dot < Shape::Circle(r: 9.0)                    // the variant first...
        && Shape::Circle(r: 1.0) < Shape::Circle(r: 2.0);        // ...then its payload
}

of/zero on an enum is a non-goal, not a gap: a variant is already its own memberwise constructor (Shape::Circle(r: 2)), and zero names no variant. So is Serializable on the prelude's Optional, whose wire form is the inline null/value case an Optional field already has.

The prelude's Optional<T> and Result<T, E> carry @generate(Equatable, Hashable, Formattable), which is why opt == opt, an Optional Map key and ${opt} work for a payload that supports them — and, conditionally, why they do not for one that does not.

Deliberately not in the model#

Recorded so they are not re-proposed: a nameless Type(…) call shape or a primary keyword blessing one ctor as nameless-callable; a compiler-synthesized memberwise as the general designated ctor (of is a bag-only convenience — a memberwise seam breaks on complex types); a separate init/onConstruction hook (input-blind, auto-run — it does not stop logic scattering, and chaining already reaches every path); and a mandatory "designated"/"final" ctor every path funnels through (completeness comes from definite assignment, not from a funnel). Constructor overloading is a standing non-goal — named parameters cover it.

Immutability — const ✅#

const is runtime immutability, and it is deep: neither the binding nor anything reached through it may be mutated. (The other two axes are orthogonal — static is runtime associated storage, comptime is compile-time evaluation; see Compile-time constants.) It appears in exactly six positions:

form what it binds
const T x = init; a local — no reassign, no write through it, no ++/--
const T f; in a type body a field — write-once, assignable only in a constructor
const T x / const ref T x parameter a read-only argument; const ref is a read-only borrow
const UnsafePtr<T> p parameter lowers to C const T*, for const-correct FFI — a const binding of a mutable pointer; the read-only pointer type is UnsafeConstPtr<T> (see unsafe fn)
const fn on a method the method does not mutate its receiver
comptime(int32 N) parameter list a comptime parameter — a compile-time value, an unrelated feature

A free function has no receiver, so const fn does not apply to one; nor to a ctor, a destructor, or an operator member. The qualifier follows the modifiers: public unsafe const fn parses, const public fn does not.

const fn — a non-mutating method#

Inside a const fn the receiver is immutable, deeply. Writing this.f, writing a bare field name, writing through this.a.b[i], ++/-- on any of those, and passing any of them to a non-const ref/out parameter are all rejected. So is moving out of it — give leaves its source holding a moved-from value, which is a mutation. addr(of: …) into it hands back an UnsafeConstPtr<T>, never a writable pointer, and returning that as an UnsafePtr<T> is rejected at the return.

Symmetrically, a const receiver — a const local, a const/const ref parameter, this inside a const fn, or a const ref T place (below) — may call only const fn methods. That gate is the point of the marker: it is what lets a caller hold a value immutably and still use it.

type value Counter {
    int32 n;
    public ctor make(int32 n) { this.n = n; }
    public const fn int32 value() { return this.n; }              // read-only
    public const fn int32 doubled() { return this.value() * 2; }  // const calling const: fine
    public fn void bump() { this.n = this.n + 1; }                // mutating
}
const Counter c = Counter.make(n: 9);
int32 v = c.value();      // fine
c.bump();                 // error: cannot call non-const method `bump` on a const receiver

const fn is ABI-neutral. It is a front-end rule only — the emitted C signature is identical either way, so marking a method costs nothing and changes no generated code.

A const fn may not return ref T; it returns const ref T. A ref T place returned out of a const method is a writable alias into the receiver, so c.place() = 99 would mutate a const binding with no unsafe anywhere. What a const method may hand out is a read-only place — const ref T, C#'s ref readonly, Rust's &T — the fourth const-rooted place beside a const local, a const ref parameter and an element reached through an UnsafeConstPtr<T>. It reads like any place (index, field, a const fn call, copy, addr(of:) — which yields an UnsafeConstPtr<T>) and refuses every write: assignment and compound assignment, give out of it, passing it as a ref/out argument, and a non-const fn call on it. The root binding may be perfectly mutable — it is the call that narrowed the place, which is what a root-const rule alone cannot see. The same form is available on a free function and on operator[] (below), and a const ref T result must borrow this or a ref/const ref parameter, exactly as a ref T must.

type resource Bag {
    DynamicArray<Counter> items;
    public const fn const ref Counter at(isize i) { return this.items[i]; }   // read-only place
    public fn ref Counter atMut(isize i) { return this.items[i]; }           // the writable twin
}
int32 v = b.at(i: 0).value();   // fine: reading through the place
b.atMut(i: 0).bump();           // fine: the writable twin
b.at(i: 0).bump();              // error: cannot call non-const method `bump` on a const receiver

The mirror rule: a ref T body may not return a read-only place. fn ref int32 firstMut(const ref DynamicArray<int32> d) { return d[0]; } would hand the caller a writable alias into storage it lent read-only, and return this.at(i: 0) from a ref method would do the same through a const ref place. Both want const ref T as their return type. Ref-constness is part of the signature in both directions: a contract member declaring ref T may not be implemented as const ref T (every caller writing through the slot would be handed a read-only place), nor the reverse, and an override may not change it. The same holds for a parameter passed ref/out: a contract member declaring const ref T other may not be implemented as ref T other (a caller holding a const place hands it through the slot, and the body would write through it), nor the reverse, and an override may not change it either. One spelling per member, so a reader knows where const is without opening the implementation. A by-value const T x is the callee's own copy, invisible to every caller, and is not compared.

Unlike const fn, const ref T is not ABI-neutral: it lowers to T const* where ref T lowers to T*, so the C compiler's own pointer-qualifier check agrees with the front end, and a body that returns this.data[i] out of an UnsafeConstPtr<T> field type-checks in C without a cast.

The standard library's two-name split — get/getRef, iterator/iterMut, peek/peekRef — is the convention for the WRITABLE twin (Rust's get/get_mut, not C++'s const-overloading, which would need every accessor written twice); it is no longer the only way to reach an element through a const receiver.

Operators cannot be const fn, and need not be: a write through operator[] on a const receiver is already rejected at the assignment, since its root is const. An operator's PLACE can be const, though — const ref T operator[] is how a read-only view indexes.

Constness in a contract#

A contract member may be declared const fn, and an implementation must honor it — the promise is to every caller bound by the contract, and dispatch goes through a slot, so the implementation is the only place it can break. The same holds one level down for a const ref parameter. Only that direction is checked: an implementation may be more const than its member asks, which merely widens where it can be called.

This is the opposite call from unsafe, which is rejected on a contract member — and the reason is the difference between the two markers. unsafe describes a body, which a member does not have. const constrains what a caller may pass as receiver, so it is signature-level and belongs on the declaration.

An override may not drop const either: the caller sees only the base declaration, so a const receiver that is legal there has to stay legal for whatever subclass sits behind the slot.

What the standard library marks#

The query surface: length/isEmpty/capacity/count, contains/indexOf/test/isSubsetOf, get/peek/first/last/floor/ceil, iterator (but not iterMut), all of Vec/Mat/Quat/ Duration/Instant/Fixed, and the protocols — Hashable.hash, Equatable.equals, Comparable.compareTo, Error.message, Formattable.format, Serializable.serialize, Real's twenty-one members. Equatable and Comparable borrow their operand const ref.

What it deliberately does not mark is as informative:

Consuming methods — give fn ✅#

A method marked give fn takes its receiver by move: the call hands the value over, as eat(b: give x) hands an argument over, and the method may take it apart. It is how a wrapper gives back what it wraps — a buffered writer its stream, an encoder its sink — and it is Rust's self method and Swift's consuming func (KR-261).

type resource Wrap {
    Box inner; Box spare;
    public ctor make(Box inner) { this.inner = give inner; this.spare = Box.make(v: 0); }
    public give fn Box unwrap() { return give this.inner; }      // `spare` drops here
}
Wrap w = Wrap.make(inner: Box.make(v: 7));
Box b = give w.unwrap();                                         // `w` is moved; using it after is an error

The declaration is always give fn and a use never is, so grep -rn 'give fn' lists the consuming methods.

Uninitialized storage — slot ✅#

A slot names the storage an out parameter is about to fill — declared externally so the reader can see the scope the value will live in. slot means only this. A contract's requirements are its members, never its "slots"; the one other place the word is load-bearing is the emitted vtable slot, which is always spelled with vtable/vtbl. That is the whole of it, and it is the only kind of local a kama program may leave without a value:

slot File f;                          // a HOLE: an `out` argument will fill it
openInto(path: p, dst: out f);        // now it is live, and drops normally from here

Three rules, and they are what make a hole worth declaring:

  1. Only an out argument fills a slot. Not an assignment (a nested one included), not a field write, not a method call, not addr(of: x). A value that arrives one line late is an ordinary local — T x = …; says so with the value in hand, and a branch has a stronger spelling still, since match and the ternary are value-producing and can build a resource (Conn c = match (k) { case A: Conn.tcp(fd: 3); … };).
  2. A slot with no out fill anywhere is an error. A hole nothing fills is a dead declaration, not an opportunity to elide a drop.
  3. The fill sits on the same unconditional path as the declaration — a statement of the declaring block, or of a nested block that always runs (a plain block, borrow, scope). Not inside an if, a match arm or a loop the declaration is outside of. Measured relative to the declaration, so a slot declared and filled inside one branch is fine. The reason is that a conditionally-filled slot cannot be tested before use: slot validity is a compile-time fact, never a runtime check.

A slot is illegal to read until it is filled, and — the point — no destructor is emitted where it is provably still empty. "Drop only if live" is therefore proven, not defended against at runtime. Move state is tracked in emission order, so this is decided per exit point: a return that precedes the fill drops nothing, while one after it drops normally. A slot filled on only some paths is dropped where the paths join — on the others its storage is the type's field defaults, whose drop does nothing — but a slot given away on only some paths is the ordinary "moved on some paths but not others" error, exactly as for any local: there its storage still holds the moved bytes, and the join would free them twice (until 0.9.474 it did, in safe code). A local with no initializer and no slot is a compile error, and slot with an initializer is one too: each thing is said exactly one way. slot does not run the type's default constructor; spell T x = T.empty(); if that is what you want.

Rule 3 is about the slot's own declaration, not about the callee: an out parameter is still proven filled on every path, so the callee may fill it through an if/else, a match, or an early return — that join analysis is where conditional filling legitimately lives.

Every read waits for the fill, whatever the slot's type. A class-typed slot's storage holds the declaration's fill — field defaults, each field's default ctor — so that the drop at an exit the fill never reached does nothing, and for no other reason: no read sees it. The whole value, a field at any depth, an element, a borrow of a field (ref p.y), an interpolation hole, a const initializer, a bitcast operand and a method call on it are each refused until the out fill has run, and so is handing it to a callee as anything but out. A primitive slot, which has no fill at all, and an Owned/Shared one, whose zero is a null pointer, are refused the same way. Until 0.9.479 a class slot's non-owning field read freely, and slot Pt p; int32 y = p.y; returned the field's default — a value no constructor made.

This is not Optional<T>: a slot has no runtime tag and no drop, and it disappears entirely at compile time. Use Optional<T> when emptiness is a value you carry, slot when it is a fact the compiler should prove away.

Fallible construction (no exceptions) ✅#

kama has no exceptions, so a fallible constructor returns Result<T, E> (where E: Error) — an infallible ctor returns the bare T. The fallible work lives in the ctor, and on failure it returns Err before the object exists, so no half-constructed object can escape and match forces the caller to handle the error. A fallible new Type.ctor(...) composes to Result<Owned<T>, E> — the box is allocated only on Ok. An Err returned after some fields were stored drops what was stored, as the type's destructor would, so failing late leaks nothing; so does any ctor return that hands back a value other than this. Where this is complete — every field holds a value — it is a value like any other and is dropped through its destructor, body and all, which is what releases what a collection keeps behind a raw pointer. Where a field is still missing, only the stored fields are dropped: the destructor body would read the missing one's zero as a value.

type enum SizeError implements Error { TooSmall; public const fn string message() { return "size must be positive"; } }
type resource Buffer {
    int32 size;
    private ctor make(int32 size) { this.size = size; }                                // trivial, infallible
    public ctor Result<Buffer, SizeError> create(int32 size) {
        if (size <= 0) { return Result::Err(error: SizeError::TooSmall); }            // fail before it exists
        return Result::Ok(value: Buffer.make(size: size));                           // delegate to the base ctor
    }
    ~Buffer() { /* … */ }
}
fn uint8 main() {
    Result<Owned<Buffer>, SizeError> b = new Buffer.create(size: 8);   // fallible `new` -> Result<Owned<T>, E>
    return 0;
}

A type with a meaningful inert state may instead start valid-but-inert and expose a bring_up(): Result<…> method. (This reuses named ctors + Result + Owned + RAII — no dedicated feature. See tests/fallible_factory, tests/ctor_named_fallible, tests/dot_on_type_new_fallible.)

Inheritance & virtual dispatch ✅#

Extensible hierarchies are a resource concern (an embedded vtable breaks a value's free copy). The extensible base opts in with a qualifier after type:

type virtual(maxDepth: 1) resource Shape {             // opts in to extension, and says how deep
    int32 sides;
    public ctor make(int32 sides) { this.sides = sides; }
    public fn int32 describe() { return this.area(); }   // public surface
    protected virtual fn int32 area() { return 0; }      // overridable hooks are written `protected`
}
type final resource Circle extends Shape {             // `type final resource` = sealed leaf
    public ctor make() { this.base = Base.make(sides: 1); }   // installs its base FIRST
    protected override fn int32 area() { return 42; }
}

Single inheritance (extends) — one base embedded by value at offset 0 is what keeps an upcast a no-op and the depth budget meaningful; there is no multiple inheritance — base.m() for non-virtual upcalls — subject to the same visibility rules as this., so a derived type cannot reach a private base member by choosing the other spelling. virtual/override methods dispatch through a vtable. Inheritance is opt-in and one-way: only a type virtual resource/type abstract resource may be extends-ed (a value, a plain resource, and a type final resource are sealed); an overridable method is written protected (never public/private — public polymorphism is a contract's job); type final resource/final method seal a leaf/slot. virtual/abstract/final and protected are meaningless outside an extensible resource — they are errors on a value, a plain resource, or a contract. See docs/KEYWORDS.md for the full kind table.

A derived constructor installs its base ✅#

A derived type's constructor must install its base, as its first statement:

public ctor make(int32 x, int32 y)
{
    this.base = Base.make(x: x);     // FIRST — the base's own ctor runs
    this.y = y;
}

This is not delegation. A named ctor is a factory with no self to chain into, so the base part is built by the base's own constructor and then embedded whole — which is why the base's invariants hold for every subclass, and why a base's field initializers reach a derived instance.

Consequently a virtual/abstract class must declare a ctor — without one it can be neither instantiated nor installed, so it and every type below it would be unconstructible. No generator can stand in: @generate(zero)/of require a transparent value, and a value is sealed.

The depth budget ✅#

An extensible type states how many levels may still be added below it, and a deriving type states at most one less — or is final, which is a budget of 0 and the only spelling for it:

type virtual(maxDepth: 2) resource Root { … }
type virtual(maxDepth: 1) resource Mid extends Root { … }
type final                resource Leaf extends Mid { … }

The chain's length is therefore bounded by the root's budget by construction. The point is that the limit is met where a design opts in to extensibility, rather than arriving as a refusal on the third type — by which time the design has been built around an assumption the language was never going to honour. Inheritance is deliberately restricted here (it is a footgun more often than a tool), and a budget you must write down is how that restriction announces itself.

maxDepth: 0 is an error — extensible yet unextendable is a contradiction; write final. So is a budget above the compiler's ceiling, KAMA_INHERIT_DEPTH (default 2: a root, a middle layer and a leaf, which is what mainstream hierarchies use). Neither bound is clamped: a clamp would hide the very surprise the annotation exists to prevent.

Shadowing is an error ✅#

A derived type may not redeclare a method it inherits. The only way to redefine one is override on a protected virtual (may override) or protected abstract (must override) — the type designer decides what is overridable, which is what protected + virtual/abstract is for.

type virtual(maxDepth: 1) resource B { public fn int32 h() { return 1; } }   // no seam offered
type final resource D extends B {
    public fn int32 h() { return 2; }        // ✗ shadows B.h() — which body runs would depend
}                                            //   on the STATIC type of the receiver

This holds at every visibility, public included. It is a separate rule from no widening above, and they divide the work rather than overlapping: widening is about a name the base does not have, shadowing about one it does.

kama already rejects public virtual because a public override is a footgun; silent shadowing is the same footgun with no keyword marking it at all (C# at least demands new).

Reusing a name that is private in the base stays legal, and is not shadowing: the base's member is invisible to the derived type, so the two names are unrelated and each type sees its own. The rule asks the same question access control does — would the derived type even see this? — so a friend grant opens no back door either.

A derived type may not widen the public interface ✅#

The hierarchy's public surface is fixed at its root. A derived type may add fields, add private helpers, and override the protected seams the base sanctioned (virtual = may, abstract = must) — it may not add a public method, and it may not declare implements.

type final resource Exposer extends Base {
    public ctor make() { … }                                // ✓ ctors are exempt
    protected override fn int32 secretHook() { return 2; }  // ✓ a seam the base sanctioned
    public fn int32 hook() { return this.secretHook(); }    // ✗ republishes a protected seam
}
type final resource Icon extends Widget implements Clickable { … }   // ✗ contracts belong on the root

Substitutability is then total rather than aspirational: what a base handle can do is what any subclass can do, and no more. The rule exists for the second line above — a subclass republishing an internal seam under a new public name, handing the world a hook the base deliberately kept private.

Constructors are exempt. A derived type needs its own public ctor (RawChannel.open(…)), and construction is not part of the substitutable surface — you build a concrete type, then hand it out as its base.

implements is barred on a deriving type because a contract's methods are public and need not exist on the base, so allowing it would widen the surface through a door the rule never looked at. If a hierarchy conforms to a contract, its root declares it and every leaf inherits the conformance.

Depth — a declared budget ✅#

See The depth budget above for the rule. Widget -> Control -> Button -> … — a chain that keeps adding middle layers — runs out of budget and is refused at the type that asks for more than its base left:

'Button' extends 'Control', which allows 1 more level(s) — so 'Button' may allow at most 0,
i.e. it must be `final`

The ceiling is KAMA_INHERIT_DEPTH in kama.cemit.h, a compile-time constant of the compiler, not a per-project setting — it is a property of the language, not of a build. A hierarchy may ask for less than the ceiling but never more, so a project can restrict itself further without rebuilding anything: a design pattern that is only ever two layers says maxDepth: 1 and the compiler holds it to that.

Building kama without inheritance — KAMA_INHERITANCE=0 ✅#

make                      # inheritance in
make KAMA_INHERITANCE=0   # a compiler built without it

This is a build-time switch on the compiler itself, and it is not exposed to programs — there is no flag or manifest key that turns inheritance off for a project. It exists for kama's own development, for two reasons: to isolate what the feature costs the compiler (answerable only by building both ways and subtracting), and to be the extraction point if inheritance is dropped — the #if KAMA_INHERITANCE blocks are then the deletion list, already proven to compile without their contents.

Such a compiler rejects extends, a virtual/abstract class, and a virtual/override/abstract method — all four, since a virtual class with no subclass still carries a vtable. final stays legal (it seals a type; it does not extend one), and contracts are untouched: they are the intended way to express polymorphism and keep their own vtables. The grammar still parses extends, so you get a real diagnostic rather than a syntax error. tools/check-no-inheritance.sh builds the variant and exercises it.

Owning a derived through a base handle (upcast). A Shared/Owned over a derived class widens to one over a base class (or a contract it satisfies) — the IS-A relationship, Liskov-style:

Shared<Circle> c = new Circle.make();
Shared<Shape>  s = c;          // upcast — retain (both handles share one Circle)
Owned<Circle>  u = new Circle.make();
Owned<Shape>   o = give u;     // upcast — move (u consumed)
int32 a = s.describe();          // polymorphic: describe() calls the protected virtual area() -> Circle's

The widening is not a declaration's alone: a handle widens wherever one is bound — a return, an argument, an assignment, a field, a payload — and a fresh handle (shapeOf(c: makeCircle())) widens as a named one does.

Polymorphism flows through the base's public surface, which invokes the protected virtual hooks (Template Method) — you never call an overridable method through the handle directly. Destruction is virtual: a virtual/abstract resource (and every owning contract handle) carries a vtable __dtor slot, so dropping through a base/contract handle runs the most-derived destructor's full chain — the derived's owned resources are freed, never sliced, exactly once. (An upcast only ever yields an owning handle or a scope-local borrow; an un-owned contract value can't be stored — see Contracts.)

Contracts ✅#

A contract is a public-only guarantee — "some type satisfying this contract." It carries signatures only: no bodies, no fields, no dtor. Besides methods it may require a ctor or a static fn, which is how a bound gets to construct rather than only to call — ctor T fromWide(int64 v) on std::num::FixedBacking is what lets generic fixed-point arithmetic narrow back to its backing type (tests/contract_requires_ctor.kama).

type contract Shape for value, resource { fn int64 area(); }   // a public guarantee (a "type placeholder")
type value Circle implements Shape {                   // a value satisfies a contract, too
    int64 r;
    public ctor make(int64 r) { this.r = r; }
    public fn int64 area() { return r * r; }           // a method satisfying Shape MUST be `public`
}
fn int64 measure(Shape sh) { return sh.area(); }       // accept "any shape" — by value = zero-copy dispatch

A contract is represented as a fat pointer {obj, vtbl} (an implementation detail of type erasure — never something you spell). Both a value and a resource may implements any number of contracts; a method that satisfies a contract method must be declared public (the contract is public — a hidden implementer would be reachable through the contract but not by name). The contract's own members take no visibility word: each is public by definition, so public is redundant and private/protected could not hold. A member fewer callers may reach is a non-goal: a call through the contract reaches the member on every implementer, so what restricts the caller is a parameter only that caller can build — a key type whose ctor it alone is granted (friend driver[mint]) — and, inside a hierarchy, a protected method of the extensible resource. A contract may refine another (type contract Animated for value, resource implements Drawable { … }) for capability layering, without inheritance, and an implementer of the refining contract implements its parents too, as Swift's protocol inheritance and Rust's supertraits do. A Sprite implements Animated satisfies a Drawable bound and binds to a Drawable value with no implements Drawable of its own. An Animated value, Owned<Animated> or Shared<Animated> passes where the Drawable one is wanted: the same object, with its vtable for Drawable read out of its Animated one. A contract value whose contract does not refine the wanted one is refused, whatever object is behind it.

Passing a contract — by value vs. ref/out (mirrors C#'s ref rule exactly):

Borrow vs. storage — a contract value is second-class. The fat pointer borrows its object, so a bare contract value is fine as a parameter or local (the zero-copy polymorphic view above) but cannot be stored beyond the call that made it — a bare Shape field, return type, collection element or enum payload is a compile error, because the borrowed object could die and leave it dangling. To keep polymorphism around, own the object with a smart pointer over the contract (below). Ownership is always written explicitly — never an implicit box. This is the language-wide rule "a borrow lives in a parameter or a borrow window, never in storage; storage requires ownership" — the same reason a ref parameter can't be returned and a returnable "reference" is always an owned smart-pointer handle.

A contract local borrows its object for as long as the local lives, so the object must stay where it is: a local, a parameter, this, a field of one, or an element a borrow window holds still. A temporary is none of these: Counts c = W.make(); (a constructor, a call's value, an operator's, a ?:, a copy) is refused in a declaration and in a rebind alike, since nothing holds the value beyond the statement — bind it to a local first. A contract PARAMETER takes one, since its borrow ends with the call. A widened scalar (Formattable f = 5) is a copy, not a borrow. Rust and C++ would keep the temporary alive to the end of the block, a hidden owner; kama writes the owner down. Mutating a local through the contract is fine — the local never moves — but an element of a container that grows, or the value behind a handle that can be reseated, is refused (Formattable fo = arr[0], Formattable fo = sh over a Shared<Box>), and so is a static of a contract type. The value behind a handle is a contract through a contract PARAMETER, width(f: sh), whose call holds sh still — the one way: Rust's &*sh freezes sh for the borrow's life, and kama keeps every freeze a visible block. So does a handle over the contract itself, or over one that refines it (draw(d: o) with Owned<Drawable> o); a ref contract parameter takes no handle, since it reseats a contract variable and a handle is none. A contract local may be rebound (fo = y) to any object that outlives it; one declared in a block nested inside the local's is refused, as a view store from a nested window is (rule 4 of The window).

Owned contracts — Owned/Shared/Weak<Shape>. A smart pointer over a contract owns the concrete object behind a fat handle {obj, vtbl} (Shared/Weak add a ctrl block). new Circle.make(...) boxes a concrete implementer into it; p.draw() dispatches polymorphically through the vtable; dropping the handle runs the concrete destructor through a virtual-destructor slot in the contract vtable, then frees the object. Owned<Shape> is move-only; Shared<Shape> retains/releases (Weak<Shape>.tryUpgrade() -> Optional<Shared<Shape>>). Because the handle is an ordinary value type, it stores — as a field or a function return:

type resource Holder { Shared<Shape> shape;  public fn int64 area() { return this.shape.area(); } }
fn Owned<Shape> make(int64 s) { Owned<Shape> o = new Square.make(s: s); return give o; }

A DynamicArray<Shared<Shape>> (the engine's scene) works — polymorphic elements stored and dropped in RAII order.

type adapter — a contract for a type you don't declare ✅#

A type you declare lists its contracts in its own declaration (implements C). A type you don't declare — a built-in, a std type, another package's type — gains one through an adapter, written in the module that declares the contract: type adapter <targets> implements C { … }.

// the module that declares DbParam
type contract DbParam for value, resource, enum, intrinsic { const fn int32 code(); }

type adapter <User> implements DbParam { public const fn int32 code() { return this.id * 10; } }   // a declared type
type adapter <int8, int16, int32> implements DbParam { public const fn int32 code() { return this; } }  // built-ins
type adapter <DynamicArray<uint8>> implements DbParam { … }                                  // one instance
type adapter <Optional> implements DbParam when [T: DbParam] {                               // every instance
    public const fn int32 code() { return match (this) { case Some(value: v): v.code(); case None: -1; }; }
}

Targets. A target is a built-in (a number, bool, char, string), a declared type of any kind the contract's for list admits, one instance of a generic type, or a generic type written BARE, which in a target list means every instance (the one place a bare generic is not its all-defaulted instance). A when [T: C] narrows that to the instances whose arguments meet it, and names the generic type's OWN parameters. It is refused beside a concrete target, which has no parameter for it to condition. A name that is not one of the type's parameters is refused rather than read as a fresh one, and so is a target that names no type. The contract's for list judges each target by the target's own kind.

Where. An adapter lives in the module that declares its contract, and never over a type that module declares: there the declaration's own implements list is the one way. So a type's conformance to a contract is written in one of two places, the type's declaration or the contract's module. Across the modules of one package this is how a dependency points the right way: app::db adapts app::model::User to its own contract, and the model never imports the database layer. It also means at most one adapter for a (type, contract) pair can exist in any program. Two packages cannot each supply one and leave an application that owns neither unable to build, and a library that adds an adapter later can never collide with one a consumer wrote. A program that wants a contract it doesn't own on a type it doesn't own wraps the type in one of its own.

Members. Methods only, for exactly one contract, covering all of it. A field, a destructor or an extends is refused: an adapter adds a contract's methods to types that already exist, never storage. Inside the block This is the target being adapted, resolved per member of the set, and over a generic target the type's own parameters (T) are bound per instance.

The <…> is a set because one body usually serves many targets — the prelude's per-primitive impls collapse from 64 blocks to roughly 8 — and a <…> { … } SECTION inside the body overrides it for the targets it names:

type contract Hashable for value, resource, enum, intrinsic { const fn uint64 hash(); }

type adapter <string> implements Hashable {        // a built-in gains a contract, in pure kama
    public const fn uint64 hash() {
        uint64 h = 2166136261ui64;                   // FNV-1a
        isize i = 0;
        while (i < this.length()) { h = (h ^ cast<uint64>(this[i])) * 16777619ui64; i = i + 1; }
        return h;
    }
}

type adapter <int8, int16, int32, int64, uint8, uint16, uint32, uint64>
    implements Comparable<This> { … }                // ONE body for eight widths

fn uint64 hashOf<K: Hashable>(K k) { return k.hash(); }   // `string` now satisfies the bound

The set form works because the bodies are genuinely identical across it — they use raw < / ==, which stay raw C operators for all-primitive operands. It is not a substitute for per-type dispatch: a type list cannot serve sqrt, which needs a different C function per width (sqrtf vs sqrt), and kama has no in-body type branching by design: @compileFor is a declaration-level gate (SPEC § Conditional compilation), and a body that branched on a type would be a second, hidden one.

A built-in gets no _classes entry — every "is this a user type?" test keys on that — so its conformance hangs on a separate registry, and a scalar target's this is the value itself: the method takes T self by value and the call is a plain int32__hash(k). That is how Map<int32, V> / Set<int32> get their keys. A declared target's methods hang on its own ClassInfo, and it gets the ordinary T__as_C vtable beside its other contracts, so it binds to a contract value like any implementer.

A contract is a SCOPE. An adapter decorates a type within the scope of that contract, so a contract-supplied method is not part of the type's own API — it is reached through the contract, never off the bare value. Without this, an adapter giving int32 the contract Weighable would put .weight() on every int32 in the program, including code that never heard of it.

int32 l = 3; int32 r = 7;
l.compareTo(other: r);                       // ERROR — `compareTo` is Comparable's, not int32's

fn Ordering cmp<T: Comparable<T>>(const ref T a, const ref T b) { return a.compareTo(other: b); }
cmp(a: l, b: r);                             // a BOUND — monomorphizes to a direct call, zero cost

Comparable<int32> c = l;
c.compareTo(other: r);                       // a CONTRACT VALUE — one indirect call

Those two are the only spellings, and both are real. The rule covers every type an adapter decorates; a type that declares implements C in its own body is untouched — its methods are its own. String interpolation is exempt: "${x}" is the compiler's own lowering to Formattable, not something an author wrote.

Widening — a primitive as a contract value. A primitive or a string can be bound to a contract, as a borrow or as an owning box, from any expression — a literal, a local, a field, a call or method result, a cast, arithmetic — and in any position that takes a contract value:

Hashable h = 3;                  // a BORROW — a fat pointer over block-scoped storage. Cannot escape:
fn void f(Hashable h) { … }      // the same escape check that governs every contract value applies.
Owned<Hashable> o = 42;          // an OWNING box — the form that can be a field, an element, a return.
Owned<Formattable> t = give s;   // a `string` box owns the string: a named one is handed off (`give`/`copy`)

The machinery is pay-for-what-you-use: the vtable and its deref thunks (a built-in's adapter method takes self by value; a vtbl slot passes void*) are emitted only for the pairs a program actually widens. A bare named string into an owning box is a compile error, as any owning hand-off without a marker is.

History. Before primitives had a spelling, the only way to give one a contract was implements C for T, a block that reached into a type from anywhere in the program. M6 deleted it once primitives and enums got spellings of their own. The adapter is its successor with a home: written only where the contract is declared, and only for a type declared elsewhere. Its first form, type intrinsic, covered built-ins only, and was renamed type adapter at 0.9.510.

Coherence. Two declarations of the same (contract, type) pair are a compile error: a declaration's own implements and an adapter, two adapters, or an adapter over every instance and another over one of them. There is no specialization, and with the home rule above no such pair can span two packages.

Static methods & operator overloading ✅#

Static methods — a static fn has no implicit self and is called at the type level with named args:

@generate(of)
type value Vec2 {
    public float64 x;  public float64 y;   // all-public transparent value → `@generate(of)` gives `Vec2.of(x:, y:)`
    public static fn float64 dot(Vec2 left, Vec2 right) { return left.x*right.x + left.y*right.y; }
}
fn uint8 main() {
    Vec2 a = Vec2.of(x: 1.0, y: 2.0);   Vec2 b = Vec2.of(x: 3.0, y: 4.0);
    float64 d = Vec2::dot(left: a, right: b);
    return 0;
}

A static method has no vtable slot (so it can't be virtual/override/abstract) and may not touch this or a bare field.

static marks a method, and nothing else in a type. A type holds no static storage, so static on a field is a compile error that points at what was meant: a constant that belongs to the type is a comptime member (public comptime int32 K = 4;, read V::K — see Compile-time constants), and state every instance shares is a module static (below). On a ctor (called on the type already), a comptime member or comptime fn (type-associated already) and an operator (whose arity picks its form) the word adds nothing and is refused. It used to be dropped without a word — public static const int32 K = 4; built a per-instance field.

Module statics — static T name = const; at module scope declares a module-level mutable variable (MCU step 1). It is firmware's home for state that outlives any one call: ISR↔main flags, peripheral handles, ring/DMA buffers, flash tables.

type value Uart { public uint32 data; }  // a peripheral's register block
static uint32 tick = 0;                 // deterministic const init at reset
static bool     data_ready;             // no initializer → zero-init
static InlineArray<uint8>#(256) rx_buf;  // a zero-initialized buffer
static UnsafePtr<Uart> uart;                  // a peripheral handle (null until assigned)

fn void on_timer() { tick = tick + 1; } // shared with `main` in the same isolate

Compile-time constants — comptime ✅#

A comptime declaration is a named compile-time constant (const-eval 6b-2). Three keywords name three orthogonal axes: const = runtime immutability, static = runtime associated storage, comptime = computed at compile time (and therefore also immutable and associated — those fall out). Unlike Rust's const (which fuses immutable + compile-time), Kama keeps them separate: const may bind a runtime value (const Box b = Box.make(...)), while comptime must fold before the program runs.

One keyword, three scopes:

comptime int32 CAP = 64;                 // module scope — a shared named constant
comptime int32 CAP2 = CAP + 1;           // may reference an earlier comptime (folds to 65)

type value Palette {
    public comptime int32 SIZE = 4;      // type-associated — read `Palette::SIZE`
    comptime int32 SEED = 100;           // private (default for a `value`) — internal use only
}

fn void demo() {
    comptime int32 N = 8;                            // local (function or block scope)
    InlineArray<int32>#(N) a = [0; (N)];            // drives a comptime size and fill
    InlineArray<int32>#(Palette::SIZE) b = [0; (Palette::SIZE)];
}

Compile-time functions — comptime fn ✅#

A comptime fn is a function the compiler RUNS at compile time to bake its result into a static const scalar or table — a CRC / gamma / trig lookup table computed once, sitting in .rodata/flash with zero runtime cost (const-eval 6b-3). It extends the comptime axis to computation: comptime constants name a compile-time value; a comptime fn produces one. A comptime function is necessarily static (it has no runtime this to read), so the bare comptime fn form is the whole story — no extra marker.

comptime fn InlineArray<uint8>#(256) crcTable() {           // top-level compile-time function
    InlineArray<uint8>#(256) t = [0; 256];
    for (int32 i = 0; i < 256; i = i + 1) {
        uint8 c = cast<uint8>(i);
        for (int32 k = 0; k < 8; k = k + 1) {
            c = ((c & 1) != 0) ? cast<uint8>((c >> 1) ^ 0x8C) : cast<uint8>(c >> 1);
        }
        t[i] = c;
    }
    return t;
}
comptime InlineArray<uint8>#(256) CRC = crcTable();   // baked → static const InlineArray_uint8_256 CRC = {.v={…}};

type value Palette {
    comptime fn int32 sq(int32 x) { return x * x; }             // private (default) — internal helper
    public comptime fn InlineArray<int32>#(8) squares() { … }    // read `Palette::squares()`
}

Compile-time assertions — comptime assert ✅#

A comptime assert(cond:, msg:) states a premise the build must satisfy. It takes the same arguments as the runtime assert — msg: mandatory, the condition's source text auto-appended to the diagnostic — and only the comptime marker differs, because only when it is checked differs. It emits no runtime code: an assertion that holds costs nothing, and one that fails is a build error rather than a trap.

comptime assert(cond: sizeof(int32) * 8 == 32, msg: "int32 must be 32 bits");   // module scope

type value Fixed comptime(int32 F) {
    comptime assert(cond: F > 0 && F < 32, msg: "fractional bits must fit the backing");
}

type value Vertex { public float32 x; public float32 y; public float32 z; public float32 u; public float32 v; }
comptime assert(cond: sizeof(Vertex) == 20, msg: "vertex buffer stride");       // a layout claim

fn void render() {
    comptime assert(cond: sizeof(float64) == 8, msg: "float64 is 8 bytes");     // and inside a body
}

MCU codegen attributes (step 4) — @interrupt and @section(".x") are declaration attributes (the existing @name(args) mechanism, extended from serialization to functions + statics). Each emits a C __attribute__((...)) only on the declaration it annotates; un-annotated code is byte-identical.

@section(".isr_vector") static hardware UnsafePtr<uint32> vtor;   // -> __attribute__((section(".isr_vector")))

@linkName("SysTick_Handler") @interrupt expose fn void onSysTick() { … }   // -> __attribute__((interrupt, used))

Layout control — @align(N) / @packed. The same passthrough mechanism, applied to a TYPE rather than a declaration: @align(N) emits __attribute__((aligned(N))) and @packed emits ((packed)) on the struct kama generates. They are the companion to layout verification — sizeof/alignof fold and comptime assert checks them — and verification shipped first deliberately, because asserting a layout is what makes stating one safe.

@packed  type value Reg  { public uint8 ctrl; public uint32 data; }   // sizeof 5, not 8 — an MMIO block
@align(16) type value Vec4 { public float32 x; public float32 y; public float32 z; public float32 w; }
comptime assert(cond: sizeof(Reg) == 5, msg: "the wire format is 5 bytes");

Operator overloading — the sanctioned exception to named-args-only (a binary operator has exactly two operands, positional by nature). The full overloadable set is supported: arithmetic + - * / %, comparison == != < > <= >=, bitwise & | ^ << >>, unary - ! ~, and ++/--. Arity picks the form:

params form example a op b lowers to
0 unary on this Vec2 operator-() Vec2__op_neg(&a)
1 binary method (this is the left operand) Vec2 operator+(Vec2 rhs) Vec2__op_add(&a, b)
2 binary free (both explicit) Vec2 operator*(float64 s, Vec2 v) Vec2__op_mul(a, b)

The free form handles the mixed-type case a method can't — a primitive on the left (s * v). Dispatch prefers the method form on the left operand's type, else a free form on either operand's type. A binary or unary expression whose operands are all primitives keeps the built-in C operator (zero overhead).

Type-based dispatch. A type may carry several operator* distinguished by operand type — mat * vec and mat * mat, v * s and s * v — exactly as C++/C#/Rust allow. Resolution matches by the operand types; a same-type / This / scalar right operand uses the bare name, a different user-type right operand its own. Only two operators with the same symbol and operand type are a duplicate (a clean error).

Chaining & compound assignment. Operators chain freely (a + b + c, -(a + b), (a + b) * s), in any position including a raw if/while condition: a nested rvalue is wrapped in a C99 compound-literal array so the method form's by-pointer this is legal without a statement slot (and it re-evaluates correctly each loop pass). Compound assignment lowers to the operator — pos += vel ≡ pos = pos + vel. An inline constructor is a valid operand — v + Vec3.of(x: 1, y: 0, z: 0) needs no separate local. It works in an if/while/for condition too (the condition is wrapped / uses a loop-and-a-half so the temp materializes and re-evaluates each pass); a do/while condition is the one place it must still be bound to a local.

Comparison is a contract, not an operator. The six comparison operators are the one place where the operator is not declared on the type: ==/!= lower to Equatable.equals, and </>/<=/>= lower to Comparable.compareTo. Declaring operator== (or any of the other five) is a compile error that hands back the implements form. This is what keeps a == b and a <K: Equatable> bound from ever disagreeing — the split C# has, where operator==, Equals, IEquatable<T> and EqualityComparer<T> can all give different answers. Rust is the same shape as kama here (a == b is PartialEq::eq).

The rule that falls out: operators a generic bound has to name are contracts; operators that are pure concrete-type ergonomics (+, *, []) stay operator members. So Equatable is the sibling of Comparable it always should have been, and conforming to either also buys container eligibility — a Comparable type is a SortedMap/SortedSet key and a PriorityQueue element; add Hashable and it is a Map/Set key.

type value Cents implements Equatable<This>, Comparable<This> {
    public int32 v;
    public const fn bool equals(const ref Cents other) { return this.v == other.v; }          // `==` / `!=`
    public const fn Ordering compareTo(const ref Cents other) {                               // `<` `>` `<=` `>=`
        if (this.v < other.v) { return Ordering::Less; }
        if (this.v > other.v) { return Ordering::Greater; }
        return Ordering::Equal;
    }
}

Both contracts borrow their operand (const ref This) — a comparison never consumes or copies it. != is !equals; <=/>= are "not Greater"/"not Less", so there is nothing separate to define. Equality stays explicit: a value that implements neither contract cannot be compared, and there is no auto-generated structural equality — but @generate(Equatable, Hashable) will synthesize the memberwise walk on request, and @generate(Comparable) the lexicographic order (see Derives). The true/false conversion operators are out of scope: there is no implicit truthiness — if takes a bool, and so does every condition: while, do … while, for's, ?:'s, and the operands of !, && and ||. A raw pointer, null and a fnptr are not conditions either.

Primitives are untouched. An all-primitive comparison keeps the built-in C operator, so float < keeps exact IEEE semantics at zero cost and never routes through Comparable. (A float is deliberately not Hashable — NaN and ±0.0 make it a bad key — while its Comparable impl is a total order with NaN sorting last, which is what the sorted containers need. Same split as Rust's total_cmp vs PartialEq.)

A user type may define a place-returning index operator — public ref T operator[](usize i) — whose body returns a place (return this.cells[i]). It lowers to T* C__op_index(C* self, size_t i), and the caller derefs the place, so g[i] = v, g[i] += 1, m[i][j] = v, m[i].field = v, and ref g[i] all work — the same place semantics as a built-in collection, now expressible in the language (so a Vec/matrix can be written in kama). The place is a second-class borrow of self: it is used transiently and cannot be stored (there is no ref-local/ref-field to hold it — GOALS 3e, no stored borrows: a place is used where it is produced), and a const receiver makes it read-only. A type whose elements are read-only by construction — a read-only view over an UnsafeConstPtr<T> — declares that in the operator itself: const ref T operator[](isize i) returns a read-only place (§ const fn), so cv[i].method() reaches only const fn methods and cv[i] = v is refused whatever the receiver's own constness. Bounds safety is the operator's responsibility — a InlineArray/collection-backed body is auto-checked; a raw UnsafePtr<T> body is unsafe. The same place-return works for a named method — public fn ref T at(usize i) { … } — so v.at(i) = x too. It also works on a free function and a static method — fn ref int32 at(ref Buf b, usize i) { return b.d[i]; }, called as at(b: ref b, i: 0) = 5. Because a free/static function has no this, the returned place must borrow a ref/out parameter (the caller-held borrow that outlives the call); a place into a local or a by-value param is rejected ("would dangle"), the same escape rule as a method borrowing this. Generic free functions work too (monomorphized per T). A ref of a contract is not returnable — a contract value already borrows its object, so own it (Shared<Contract>) to hand polymorphism back.

Used in a contract, an operator becomes a bound for generic math (see below).

Generics ✅#

User-defined generics, monomorphized (one specialized copy per concrete type — elements inline, no boxing; identical layout and cost to the built-in collections).

import { std::collections::DynamicArray };
type contract Shape for value, resource { fn int64 area(); }

type value Pair<A, B> { public A a; public B b; public ctor make(A a, B b){ this.a = a; this.b = b; } }
fn T max<T: Comparable<T>>(T a, T b) { if (a > b) { return give a; } return give b; }   // generic fn — args INFERRED from the call

fn uint8 main() {
    Pair<int32, bool> p = Pair.make(a: 1, b: true);         // generic type (args inferred from the LHS)
    int32 m = max(a: 3, b: 4);                              // -> max<int32>, a static specialized C fn
    DynamicArray<Shared<Shape>> scene = DynamicArray.empty();   // nested generics, no space (the `>>` split)
    return 0;
}

Access control ✅#

Encapsulation is compile-time only (the emitted C is unchanged) and stricter than C#:

See TYPE_MODEL.md § Access control for the full kind × visibility table.

Enums & match ✅#

A type enum declares either a plain (payload-less) set of variants or a tagged union (variants carry payloads, and the enum may be generic):

type enum Color { Red, Green = 5, Blue }     // plain: Red=0, Green=5, Blue=6
type enum Shape { Circle(float64 r), Rect(float64 w, float64 h) }   // tagged union (payloads)

fn uint8 main() {
    Color c = Color::Blue;                    // variants are scope-resolved with ::
    return 0;
}

A member's value is a compile-time integer expression. It may combine integer literals, the enum's own members, another enum's members, comptime constants and cast<…> with + - * / % << >> & | ^ ~, and it is folded by the compiler — what reaches C is a number. A member without a value is one more than the member before it (the first is 0), so a derived member may be followed by implicit ones, and a member may refer to a sibling declared after it:

type enum Flag : uint8 { None = 0, Read = 1, Write = Flag::Read << 1, All = Flag::Read | Flag::Write }
comptime int32 BASE = 100;
type enum Code { Ok = BASE, Warn, Bad = Code::Ok + 50 }        // 100, 101, 150

A sibling is spelled Flag::Read, as a variant is everywhere else — the bare Read C allows is an error. The value must fit the tag — the pinned IntType's range, or int32 when unpinned — so : uint8 { A = 300 } is an error, not a silent 44. A member defined in terms of itself is an error, and so is an initializer that does not fold or is not an integer. Two members may share a value (B = K::A is an alias). This is the one place enum arithmetic is written: a value of the enum is still not a number, and Flag f = Flag::Read + 1; stays refused (see Type identity). (tests/enum_derived_member.kama.)

An enum is a type kind like any other, so it declares its contracts inline and carries the methods that satisfy them — the variants come first, then a ;, then ordinary members:

type enum FetchError : uint8 implements Error {
    NotFound, Denied(int32 code);

    public const fn string message() {
        return match (this) { case NotFound: "not found"; case Denied(code: c): "denied"; };
    }
}

A method is called on any value of the enum, a member written in place included (Light::On.lit()). A generic enum's payload-less member names no instance (Slot::Empty is a Slot<what>?), so it is a receiver only once something gives it a type, as everywhere else it is read.

The ; separating variants from members is mandatory, and it is what makes the body unambiguous: a bare Foo variant and a Foo bar; field are indistinguishable until it appears. An enum may declare methods with or without a contract — private unless written public, like any member, and public when they satisfy a contract — and named ctors and friend grants, as a type does (one of those ctors may be marked default, the enum's only way to have a default — see Collections — the four-ctor matrix) — and its own comptime constants and comptime fns, read E::NAME and E::name(), and comptime asserts, which are checked like a type's (all three parsed and did nothing before 0.9.443) — but not a field (a const one included), a destructor or an operator — its layout is its tag plus its variant payloads, and it owns nothing beyond them; an operation on an enum is a named method. Operators on an enum are a non-goal, declared or built in. An enum's values are named variants, so +, | or << over them names no variant; a user operator| would reopen exactly the hole the built-in refusal closes (Flag::Read | Flag::Write is no Flag). The two needs that look like operators each have a home already. Order is a contract, as it is for every type: @generate(Comparable) or implements Comparable<This> gives an enum <, and == is the one operator it keeps without asking. A set of flags is a type value holding the bits, with comptime constants for the members and its own operator| and has(), as std::fs::Permissions is — the result of a | b is then a value of the type, which is what an enum's could never be. C#'s [Flags] enum is the shape this rejects: there, Read | Write is an enum value no member names, and switch cannot be exhaustive over it. Members never change what an enum IS in C: a payload-less enum stays its integer (typedef int32_t Color), and its methods take it by value (bool Color__isWarm(Color self)), the way a type adapter method takes a primitive; a contract reaches them through the ordinary vtable. An enum with payloads is a tag plus a union either way.

A generic enum declares members and contracts the same way, and — like a generic value — each instance takes them per instance: a when [...] gate on a method or on an implements entry is judged against that instance's own arguments.

type contract Tag for value, resource, enum { fn int32 tag(); }
type enum Maybe<T> implements Tag when [T: Tag] {
    Has(T v), Nope;
    public const fn bool has() { return match (this) { case Has(v: v): true; case Nope: false; }; }
    public fn int32 tag() when [T: Tag] { return match (this) { case Has(v: v): v.tag(); case Nope: 0; }; }
}

Maybe<Tagged> implements Tag and has tag(); Maybe<Plain> has neither, and binding it to a Tag is refused. A missing contract method is reported once, at the template. (tests/generic_enum_members.kama, tests/generic_enum_members_cross_module.d/.)

type enum E : IntType pins the tag to a fixed-width integer — uint8 for a wire format or a packed MMIO field, where the default (a compiler-chosen enum width) is not something you can serialize against. It is the only part of an enum's layout kama lets you state, and it changes the lowering: ISO C cannot set an enum's underlying type, so a pinned enum emits typedef <IntType> E; plus an anonymous enum carrying the constants, where an unpinned one emits a real C enum. That is a genuine difference in what the C compiler can prove — with a plain integer tag it cannot see a switch over every variant as total — and it is why the default: arm of a value-producing match over a pinned enum diverges (a kama_panic) rather than falling through. Every other match emits default: break; unchanged. Fixture: tests/enum_match_intty.kama.

An unpinned plain enum lowers to a C enum; a tagged union lowers to a tag + payload union. Enum variants are scope-resolved with :: and constructed with named args (Shape::Rect(w: 3.0, h: 4.0)). A variant is not a type — Rect r does not name anything, and an enum cannot nest type declarations — so Rect is a member of Shape's scope, reached with :: like any other scope member; supplying its payload yields a Shape. That is why construction's dot-on-type rule does not apply here: there is no type to dot.

match is the one construct for branching on an enum — payload-less enums, tagged unions, and the Optional/Result prelude types alike — and an enum behind an Owned/Shared handle, which it matches through the handle, borrowing the payloads in place (not give, and not a Weak, which must be upgraded first). It is value-producing (usable in statement or expression position), enforces compile-time exhaustiveness, and accepts a _ wildcard for the catch-all case. An enum that means to grow says so: @nonExhaustive. Outside the PACKAGE that declares it, a match on one needs case _: even when it lists every case today — the package may add a case in a later release, and a match with no _ would then stop compiling. Inside the package the match stays exhaustive, so the package handles each case it adds. std's error kinds and growable options carry it — IoErrorKind, DeErrorKind, SerError, ParseError, the DecodeErrors, Utf8Error, UuidError, TimeError, FileKind, OpenMode, Stdio, ProcessGroup (whose cases already differ by OS), SqlValue, Interest — while closed sets (Optional, Result, Ordering, Weekday, SeekFrom, …) stay exhaustive everywhere. Any enum may carry it, a registry package's included. It takes no arguments, and it is an error rather than a warning: kama has none. In a pattern, _ in place of a binding discards that payload field — as often as the pattern needs it, case Rect(w: _, h: _) — and a discarded owning field of a consumed subject is dropped at the arm's end. It is also the only construct that reads an enum, which is why the only way to build one from an integer — try cast<E>(x), above — hands back an Optional<E>: a value that names no variant arrives as a None arm of the same construct, never as a trap.

A subject that names storage is borrowed where it lives: a local, a field, an element, or a call that returns a place (fn ref T, const fn const ref T), so its payload bindings read storage the owner keeps. Only a fresh value, such as a constructor or a by-value call result, is held in a temporary, and the match OWNS it, as match (give x) owns a local: nothing else does, so an arm may give an owning payload out, and the payloads it leaves drop at the arm's end: match (resolve(host: h, port: 443)) { case Ok(value: list): addrs = give list; … } needs no local to hold the result first. A fresh subject takes no give (match (give f()) is refused, as T x = give f(); is). Through a storage-naming subject, a binding of an OWNING payload (a collection, a string, a resource, a handle) is an alias of the subject's slot, as a foreach (ref …) binding is of an element: a change through it changes the subject's payload, and it cannot be given away (match (give x) hands the arm the payload to own); a place-returning call is storage-naming, so its payload stays where its owner keeps it. A const subject's alias is const. A binding of a plain value is a copy the arm may change, as a by-value foreach binding is — and costs no copy when nothing could tell: a payload with no destructor, out of the match's own temporary or a local (a by-value parameter is one) that the arm never writes, in an arm that never writes, refs or gives the binding, is read where it lies. A 16 KB case Ok(value: b): b.last() then holds one Big in its frame, not two (tools/check-construct-frame.sh). The binding still means a copy: an arm that writes it gets one, and so does a const ref parameter's payload, which code the arm calls could change.

Writing the subject ends its aliases. The arm may write the subject — state = State::Ready(…) is the commonest arm there is — but the write replaces the payload an alias points into, so from that write on the alias is gone, and a use of it is refused. A write is an assignment, a ref/out argument, or a non-const fn on the subject or on what contains it (this.reset() writes this.state). It counts on every path it may have taken, and inside a loop a use anywhere in the loop is refused when the loop writes the subject, because the next pass runs it after the write. Read what you need first, or match again. Writing THROUGH the alias, and writing a disjoint sibling field, stay legal.

A pattern NAMES the fields it binds — field: local — exactly as a call names its arguments; there is no positional form, and kama no more exempts a one-field variant here than it exempts a one-argument call from a label. The label is the variant's field; the identifier after it is the local it introduces, and it may be called anything. Because the label decides, order does not: case Rect(h: y, w: x) and case Rect(w: x, h: y) are the same pattern. Positional binding is how case Rect(height, width) compiled clean and silently returned the wrong values — the bug class named arguments exist to remove. Three mistakes are compile errors, each naming the fields the variant actually has: binding an unknown field (tests/xfail/match_label_unknown.kama), binding one twice (…/match_label_duplicate.kama), and leaving one unbound (…/match_label_missing.kama) — a pattern names every field of its variant, just as construction supplies every one. The label is also a reference to the field, so hover, go-to-definition and rename reach it. Pinned by tests/match_named_bindings.kama.

int32 area = match (sh) {                     // expression position — yields a value
    case Circle(r: r):              cast<int32>(r * r * 3);
    case Rect(w: width, h: height): cast<int32>(width * height);
};

match (color) {                               // statement position — a plain enum works too
    case Red: fire();
    case _: hold();                           // wildcard catch-all
};

An arm is either a single expression (case X: <expr>;) or a block (case X: { … }). A block arm names the value it produces with a := <expr>; statement, which must be the block's final statement (single-exit) — it accepts any expression, and reads as "bind this value out" (a match in a typed position is an assignment from the outside, x = match … { … := v; }). := is distinct from return, which leaves the enclosing function. An arm of a value-producing match must therefore either end in := or diverge (return, a break / continue of the loop around the match, a panic, or anything else that never completes — see Unreachable code); in particular a block arm cannot be empty, since it would leave the match's value unset:

fn string describe(Optional<int32> reading) {
    string label = match (reading) {
        case Some(value: c): {
            string name = "mild";
            if (c < 0)  { name = "freezing"; }
            if (c > 30) { name = "hot"; }
            := give name;                         // the arm's value (must be last) — an arm is a hand-off
        }
        case None: "unknown";
    };
    return label;
}

The match subject can be a variable, a method call, a static-method call, a free-function call (match (File.open(path: p, mode: OpenMode::Read)) { … }), or a value-producing variant constructor (match (Optional::Some(x)) { … } — the concrete instance is inferred from the payload). Arbitrary-integer branching (not on an enum) is done with if / else if — there is no switch.

Error model — Optional / Result ✅#

The prelude provides two tagged-union types, so error handling needs no exceptions and no null:

Both are ordinary tagged unions consumed by match, so the caller is forced to handle the empty/error case (exhaustiveness):

fn Optional<int32> find(const ref DynamicArray<int32> xs, int32 target) { … }

int32 idx = match (find(xs: list, target: 7)) {
    case Some(value: i): i;
    case None: -1;
};

A yes/no question needs no match: isSome()/isNone() on an Optional, isOk()/isErr() on a Result, each a const fn (if (cache.lookup(key: k).isNone()) { … }). They take nothing apart — the value, or a default in its place, is still match's, the one way; there is no unwrap, unwrapOr or map.

A Result is never discarded silently. A call whose Result a statement would throw away is refused — f(); names the fix: handle it with match, or write _ = f();. The same holds for the arm of a statement match and for a for clause, which discard their values too. _ = expr; is the one explicit discard: it evaluates expr and drops what it produced, owning or not, at the end of the statement. It takes what an expression produces: a named local is dropped by the end of its scope, and a { … } block ends one sooner, so _ = x; is refused. _ is the discard in a pattern and as that target, so nothing may be named _. An Optional stays discardable — absence is not failure — and so does any other value: a statement drops what it discards, so xs.remove(index: i); drops what it removed. Zig refuses every ignored value, and Rust and Swift warn on an unused Result; kama has no warning channel, so the choice was an error or nothing, and a Result nobody looks at is a failure nobody handles.

Weak<T>.tryUpgrade() returns Optional<Shared<T>>; a fallible static fn factory returns Result<T, E> (see Fallible construction above).

A value is wrapped by naming its variant — Optional::Some(value: x), Result::Ok(value: x), Result::Err(error: e). Nothing converts a T into an Optional<T> on its own, so handing one where the wrapper is expected is refused at every crossing, with the variant to write: an initializer, an assignment, an argument, a return, a field. The same holds for either arm of a Result.

A boxed error — Owned<Error> — and .as<T>(). Where a failure can come from more than one error type, the error is boxed: reading data returns Result<T, Owned<Error>>, because the format reader fails with its own error and a type's hand-written deserialize may fail with another. e.message() reports any of them. To branch on one, e.as<T>() asks whether the box holds a T and answers Optional<T>: Some with a copy when it does, None when it holds something else. It is a checked test, never a reinterpretation, and the box keeps what it holds. T is any concrete type implementing Error — an enum, a value such as IoError, a copyable resource such as DeError (the last two have a kind(), which is what code branches on). A type that owns something and cannot be copied is refused, since a bitwise copy would alias the box's.

Modules ✅#

A module is a FOLDER, and a file's identity is where it sits — never anything it declares. A project's kama.json lists its modules in a nested modules map mirroring the source tree, and a module's name is the chain of keys read down to it, rooted at the project's name (the Modules section above). import names a module by that full name; the compiler resolves it through the manifest, compiles the module's files, and scopes their public symbols. There is one keyword for depending on another module — import (it replaced using).

// geometry/kama.json
{ "name": "geometry", "version": "0.1.0", "kind": "library",
  "modules": {
    ".":         { "visibility": "public" },   // src/*.kama          — module `geometry`
    "graphics":  { "visibility": "public" },   // src/graphics/*.kama — module `geometry::graphics`
    "internals": { "visibility": ["graphics"] }
  } }
// geometry/src/graphics/texture.kama   — in module `geometry::graphics`, because of WHERE IT IS
export { Texture, scale };             // the public surface, at a glance — mirrors `import`

type resource Texture { ... }          // declarations carry NO visibility modifier
fn int32 scale(int32 x) { ... }
type resource GpuHandle { ... }        // unlisted → module-private

// main.kama
import {
    geometry::graphics::Texture,      // -> bare `Texture`
    geometry::graphics::scale,
    physics::Body as PhysBody,        // `as` renames
};
fn uint8 main() {
    Texture t = ...;                    // imported, bare
    PhysBody b = ...;                   // renamed with `as`
    int32 n = scale(x: 3);
    return n;
}

There is no namespace declaration. A file used to name its own scope; the declaration and the path could then disagree, so a file could be compiled into one scope and imported as another. Deleted outright — namespace is not a keyword and writing one is a syntax error.

One import { … }; block per file, and every entry names a SYMBOL. a::b::X is symbol X of module a::b, always — there is no whole-module import, so the entry is never ambiguous. as renames (a::b::X as Y), and an entry with no scope (X) names a symbol of this file's own module, because there the scope is the only candidate. There is no glob — unqualified-everything is not offered: explicit over implicit, and per-symbol imports are what the LSP's auto-import and closure pruning ride on. A module path is written only in an import (KR-87) — every name a file uses is in its import list, and every use of it is bare. geo::area() is refused in every position, naming the import that fixes it or, for a symbol already imported, the spelling to write. Importing one symbol used to open every export of its module to a qualified spelling, which was a qualified glob and a second way to name each thing. :: stays for a TYPE's scope (Color::Blue, Ordering::Less), which names no module; the one other place a module path is written is a friend grant, a module relation like an import entry rather than a use (§ Access control). Two imports binding the same bare name is a compile error — disambiguate with as — and so is one entry written twice. An as alias may not claim a name that already roots a project this file can reach, which would leave the original unspellable (tests/xfail/import_alias_shadows_project.kama).

A file's header comes first, in one order: file @compileFor(…); if the file is gated, then its one import { … };, then its one export { … };, then its declarations. An extern "h.h"; is a declaration, so it follows both blocks.

visibility decides what a module reaches beyond itself, in four widening forms — a list of modules in this project, "children" (every module nested under it, at any depth), "internal" (every module in this project) and "public" (plus dependent projects, and the only form that crosses a project boundary). ⚠️ Nesting determines NAME, never ACCESS: a narrow parent does not confine a public child, which follows Go rather than Rust's implicit downward grant. A module's own files always see each other, so a list never names itself.

Visibility is per FILE. A file may name only what it DECLARES or IMPORTS — export { … }; is the outbound half and import the inbound one, and the symmetry is the rule. A top-level type/fn leaves its file only by being named in that file's one export block; a listed name must be a top-level declaration of that same file, so a directory-module's files each state their own surface.

Every name in a type is held to this, at any depth — not only the name that IS the type. A type argument (Result<Uuid, HidErr>, Optional<Result<int32, X>>), a #(K) constant, a bound and an implements entry with its own type arguments, a module static's type, and every type a body writes — a cast<…>, sizeof(…), an .as<…>() target, a turbofish argument, a variant expression's enum — is refused the same way, and inside a generic body whether or not anything instantiates it. A name that is not in reach is reported by kama, never left for the C compiler, and a name another module EXPORTS is reported with the import that fixes it (add import { std::uuid::UuidError };); a private one says which file keeps it, because no import can reach it. A bound or an implements entry must name a contract: a type there is refused at the declaration, even on a generic nobody calls.

The FFI seam keeps its C spellings, and only those. An extern fn signature names types its header owns, so a BARE name there is the header's literal C spelling and is not resolved; a module path is never a C spelling and is refused there as everywhere. A body may name the C spellings its own file's extern fn signatures introduced — cast<CompareFn>(c) beside extern fn void qsort(…, CompareFn compar) — and no other unresolved name: another file's extern does not introduce it here (tests/callback_qsort.d).

A sibling in the same module is imported like anything else, and needs no path to do it, because there is exactly one candidate: import { DynamicArray };, then the bare name at every use. A module's files share their exported names but not a scope, so export offers a name and import accepts it — which is what lets a reader name the source of every symbol in a file without leaving it. A name a file does not export is the file's alone: two files of one module may each declare a private helper, a private static n, a private type T, and neither shadows the other's — the compiler keys them by file, and only an exported name may be declared once per module. This is the rung Go does not have (any file of a package reaches any unexported identifier in it) and the reason Java needed sealed JARs and then JPMS. What visibility in kama.json governs is reach beyond the module (the Modules rules above) — it says nothing about files.

An exported symbol may not name an unexported type of its own file. A project's API is derived, never written down — the public modules of kama.json crossed with its files' export blocks — and that enumeration is only usable if every name in it can be spelled by whoever reads it. Rust calls the family it rules out private_interfaces. Publicly reachable positions only: a private field's type is not part of what the export offers.

Declarations carry no visibility keyword, keeping type/fn syntax uniform, and the manifest reads as the mirror of import. Member access (public/protected/private) is a separate axis; the kama→host/WASM boundary (expose) is a third. The prelude floor and the built-in std::memory triad are intrinsics — always in scope, never imported, outside the rung entirely. A file in no module — a loose file the build was handed directly, sitting in the operand set's own root — keeps its symbols file-private, so single-file scripts need no boilerplate and cannot be imported.

Resolution. import { a::b::c::X } names symbol X of project a's module b::c, and the answer comes from a manifest, never from a search: the project being built, then its declared dependencies, then the stdlib bundled with the compiler (located relative to the binary like the runtime header, so std::* resolves on any install regardless of cwd). std, core and global are reserved roots. A module already in the compilation satisfies an import without a disk lookup. A build with no kama.json does no searching at all — you pass every source file, and each one's module is its directory below the operand set's deepest common ancestor, which makes kama build a.kama b.kama a genuine subset of a project build rather than a second dialect. Loading is transitive and deduped by path, so import cycles load once. The stdlib is optional on disk: no import std::… means the resolver never touches it, and nothing is auto-linked — a no_std-like floor (only kama_runtime.h is mandatory; the prelude Optional/Result/Deref/HeapOwner is baked into the compiler).

A module import compiles only what it needs. import { a::b::X, a::b::Y } resolves to the files of that module which declare X and Y, plus their transitive closure within it — not to every file of the module. The closure follows references, not import edges. That was once forced: a sibling was reachable with no import at all, so an import-edge closure would have under-computed. It no longer is — every sibling reference now carries an import { … }; — but reference-following is kept because it is a superset of the import graph and cannot under-compute even when the two disagree. A name a file declares itself is satisfied there and pulls in no sibling, which is what keeps a repeated extern fn memset from tying three files together — and is why an extern name, which keeps its literal C spelling and so is never scope-prefixed, sits outside the file rung.

Anything the resolver does not fully understand loads the whole module, so the diagnostics are unchanged: a bare import a::b; (nothing pins a file — and a type reached only through inference is never spelled, so the importing file's own text cannot be used to seed one), a symbol the directory does not declare, and a file whose declarations are nameless but program-wide — a type adapter conformance on a primitive, or the extern seam that spawn/parallel_for require.

Two consequences, both deliberate and both pre-1.0: a defect the compiler would report in a sibling file nothing imports no longer fails the build, and a conformance that was arriving only because the whole directory loaded must now be reachable. KAMA_NO_PRUNE=1 restores whole-directory loading; KAMA_PRUNE_TRACE=1 reports each import's decision and =2 names the reference that retained each file.

Passing several files to one build still works (kama build a.kama b.kama -o app); the compiler emits a shared header (<out>.gen.h) + one .c per unit — imports just add the resolved module files to that set.

Scope resolution uses :: (modules, qualified types, enum variants: Color::Blue); . is instance/value access only (obj.field, obj.method()). The two are syntactically distinct, so there's no module-vs-object precedence rule — a :: head is always a type/module, a . head always a value. This is enforced, not merely conventional: a :: whose head is a local, a parameter or a field is rejected with a message naming the . spelling, so field access has exactly one spelling (tests/xfail/scope_op_on_value.kama). The one deliberate crossover is dot-on-type for constructors — Vec2.make(...) constructs, Vec2::dot(...) calls a static fn — and the split is enforced in both directions, so it is a real greppability guarantee rather than a convention: a static fn called with a dot is rejected (tests/xfail/dot_on_type_not_ctor.kama) and a ctor called with :: is rejected (tests/xfail/scope_op_on_ctor.kama), each naming the other spelling. The same holds for a read: a variant, a field, a comptime constant or a static reached through a type with . (Color.Blue, V.N) is rejected naming the :: spelling (tests/xfail/enum_variant_dot.kama). And a name that resolves to nothing — a bare Nope, a Foo::Bar with no such member, a K::ZZZ with no such variant, a type where a value is expected, and likewise a member that does not exist (v.zz on a type with no such field, a.foo on an int32) — is kama's own error: it is rejected by kama check, in kama's vocabulary, before any C compiler runs (tests/xfail/unresolved_name.kama). A ctor is static (it takes no self), so it would otherwise answer to both and grep '\.make(' would miss half the construction sites. The rule holds through a generic type parameter too — T.deserialize(...) for T: Deserializable — and for a ctor added to a primitive by a type adapter block. Every spelling is pinned by tests/ctor_spelling_edges.kama.

On a generic type both forms take a turbofish, and the same .-vs-:: split applies:

Box::<int32>.make(v: 5)     // ctor   — dot
Box::<int32>::tag()         // static — colon-colon

Here the turbofish is mandatory, unlike for a ctor: a ctor can infer its instance from its arguments, but a static has no receiver and its parameters need not mention T, so there is nothing to infer from. (Box<int32>::tag() cannot be the spelling — in expression position Box < int32 > is two comparisons, which is why kama has a turbofish at all.) Pinned by tests/generic_static.kama. Relatedly, a self-returning static fn is rejected as a disguised constructor (tests/xfail/self_returning_static_fn.kama): if it returns the enclosing type or Result<This, E>, declare it a ctor.

main is the entry point, not a symbol. It is reached below the visibility system — every main emits as the same C symbol, and the generated C main calls it directly — so calling main is an error, main may not be exported, and it must be unique per PROJECT rather than per module: two collide where a::helper and b::helper do not. Its location is unconstrained; location simply does not scope it, which is exactly why it is not callable.

There is no global:: (retired with KR-87). It named the floor, and nothing is left for it to do: the intrinsics are keywords (global::assert is refused, naming assert), the runtime capabilities are module core, imported by name, and nothing may shadow a name in scope, so there is no local to reach past. global stays a reserved project name, like std and core.

Concurrency ✅#

Kama earns data-race freedom the way it earns null-safety: by making the hazard unrepresentable rather than checked. Where Rust proves exclusivity over shared memory (borrow checker, lifetimes, Send/Sync, Pin, async colouring), kama removes the shared mutable state, so there is nothing to prove — and where several isolates must change one value, a Locked<T> owns it, reachable only inside a window that holds the lock and can neither wait nor take another. There is no async, no await, and therefore no function colouring: an ordinary function is the only kind of function.

The model has three levels, and they all reuse the ownership rules already in this document.

Isolates — spawn ✅#

An isolate is a unit of shared-nothing execution: a real OS thread natively, a Web Worker on wasm. It has its own stack, heap and module statics, and communicates only through channels, the Atomic<T> seam and a Locked<T>. Isolates are meant to be coarse — roughly one per core, or a handful of long-lived service isolates — which is what makes it honest for one to block.

There are two spawn forms, and what separates them is who owns the join.

import { std::concurrent::Isolate };

scope { spawn worker(p: give payload); }     // the SCOPE owns the join — at its closing brace
Isolate h = spawn worker(p: give payload);   // the HANDLE owns it; starts now, joins later
give h.join();                               // explicit join, consuming `h`; `~Isolate()` also joins (RAII)

spawn and parallel_for are the language's, and they run on the isolate seam std::concurrent brings in (its header and, natively, -lpthread), so a program that uses either imports from it — import { std::concurrent::Isolate }; when it names nothing else there.

A bare spawn must appear inside a scope { }. The handle form need not, and that is its reason to exist: Isolate is a resource, so its join travels with the handle, which may be moved into a field. That is how a long-lived service isolate is written — its join is owned by an object rather than by a block, and shutdown falls out of RAII in field order (dropping the Sender closes the channel, which ends the worker's recv loop, which the Isolate drop then joins). Either way a forgotten join is impossible: there is no detach-by-forgetting.

The stack ✅#

An isolate's stack is stated, and it is the main thread's. On a 64-bit native target that is 8 MiB — what Linux and macOS give a main thread, and what kama links Windows' main thread up to from its 1 MiB default. A 32-bit native target gives 2 MiB, since its address space would not hold many 8 MiB reservations. Natively the size is reserved, not committed, so an isolate that never recurses deeply touches a few pages of it. On wasm nothing is merely reserved: every stack is carved out of the module's one fixed linear memory. So kama states 256 KiB there for the main thread and, by inheritance, every isolate (emscripten's own default is 64 KiB). A project that sets STACK_SIZE in emSettings moves both together, so the rule holds there as well. In a debug build a wasm frame lives in that stack, so it bounds call depth just as it does natively. Before 0.9.529 an isolate took the OS's thread default instead: 512 KiB on macOS, 8 MiB with glibc, 128 KiB with musl. A recursion that worked on the main thread then crashed in an isolate on two of the three.

Where the stated size is not enough, the spawn site says so — the one place that knows the depth it needs:

scope { spawn(stack: 64 * 1024 * 1024) walk(tree: ref deep); }   // 64 MiB for this isolate
Isolate h = spawn(stack: 256 * 1024) worker(p: give job);         // or less
parallel_for (ref Node n in nodes, workers: cpuCount(), stack: 16 * 1024 * 1024) { … }

stack: is a usize byte count, rounded up to a multiple of 64 KiB, the largest page any target uses, and never below the platform's thread minimum (64 KiB, or 128 KiB on Linux arm64). A constant stack: 0 asks for no stack and is a compile error. parallel_for and parallel_spawn take it as a clause beside workers:, applying to each worker, and each clause is said at most once.

An isolate that outgrows its stack says so and names the clause that raises it, where it was a bare segfault: kama: isolate stack overflow (256 KiB; `spawn(stack: …)` to raise it), then the process aborts. A guard-page hit runs on a signal stack of the thread's own on POSIX, and in a vectored exception handler with a stack guarantee on Windows (Rust's std reports a thread's overflow the same way there); a fiber's stack is covered by the same handler. The main thread is not: covering it would install a handler in every program, isolates or not, and its overflow is the platform's own crash. On wasm a stack is emscripten's, and its overflow is emscripten's to report.

The bundle ✅#

An isolate entry is an ordinary top-level fn that returns void and takes exactly one parameter — the bundle. It is void because there is no channel to hand a result back over, and one parameter because the bundle is the boundary: one object, one owner, one transfer. Everything an isolate receives, it receives here.

The entry may be a generic function, with its type arguments written or inferred as in a call: spawn work::<int32>(j: give j), and spawn fiber the same. A spawn, a parallel_for and a BindableFunctionPtr.bind may each be written in a generic's body.

The bundle has two forms, and the choice is not a style preference — they restrict on opposite axes:

restricts is free in
give — a moved resource the type — it must be a resource (a value, a primitive and a string are all rejected) and it must declare implements Sendable lifetime — the child may outlive the parent's frame, which is what the handle form needs
ref T — a borrow the lifetime — only from a bare spawn inside a scope, over a place that scope outlives the type — any place, of any size, ref int32 included

So a moved bundle transfers ownership (the parent provably cannot touch it afterwards), while a borrowed one leaves the parent owning the storage — which makes ref the only way an isolate can mutate something the parent goes on using, since join() returns void and a moved bundle never comes back.

How state reaches an isolate — the whole surface, in four rows.

who may touch it lifetime why it is sound
a moved resource the child, exclusively unbounded ownership transferred
ref T, disjoint places one child per place the scope no two children see the same bytes; the join orders the parent's read
ref Atomic<T>, the same place every child the scope atomic operations do not race
Shared<immutable T> every isolate unbounded nothing can change
a copy of a Locked<T> handle every isolate, one window at a time unbounded the value is reachable only inside a window that holds the lock

Rows 2 and 3 are what a scope licenses, and they are the reason it exists: its closing brace joins every child before any local declared in it drops, so a borrow provably cannot dangle, the child's writes are ordered against the parent's next read with no atomic at all, and the scope is the domain over which "no two children overlap" is decided. Rows 1 and 4 need no scope because they are self-sufficient — one transfers exclusivity, the other removes writing from the picture.

The scope body is one more party. It runs beside its children until the closing brace, so a place a child holds is out of its reach in the statements after the spawn: held by ref, it may not be used at all, read or write; held by const ref, it may be read but not written. The same holds for the collection whose elements a parallel_spawn hands its workers. A disjoint sibling field stays usable, and so does an Atomic<T>. Before the spawn and after the scope, the place is the parent's as always.

Sendability applies to the bundle, exactly as it does to a channel element: the bundle's type must declare implements Sendable (see Channels below for the whole rule), and a declaration over a Shared/Weak with a mutable payload is rejected, naming the offending field, because both isolates would release one non-atomic control block. The same shape over a deeply-immutable payload is legal — that is the case whose control block switches to an atomic refcount, and it is what lets many isolates share one large read-only asset with no copy.

Channels ✅#

A Channel<T> is a typed pipe. Channel.bounded(capacity:) sets the buffer depth; capacity 0 is a rendezvous channel. sender() and receiver() hand out owned endpoints you move to whoever needs them.

import { std::concurrent::Channel, std::concurrent::Sender, std::concurrent::Receiver, std::concurrent::Isolate };
fn void producer(Sender<int32> tx) { _ = tx.send(item: 1); }

fn uint8 main() {
    Channel<int32> ch = Channel.bounded(capacity: 4);
    Sender<int32>   tx = ch.sender();
    Receiver<int32> rx = ch.receiver();

    Isolate h = spawn producer(tx: give tx);     // the sender is moved into the isolate

    Optional<int32> v = rx.recv();               // blocks; None once closed AND drained
    return 0;
}

send(item:) returns Result<Unit, Undelivered<T>> — a channel whose receivers are all gone hands the item back in the Err (Undelivered<T> { Item(T item) }, taken apart with match (give e) { case Item(item: x): … }) rather than dropping it on the floor, so nothing is lost, and since a discarded Result is refused, nothing is lost silently: the sender decides what to do, or says _ = tx.send(…);. recv() returns Optional<T>: None means the channel is closed and empty, which is why dropping the last Sender is how a producer signals end-of-stream.

A receive can decline to wait. tryRecv() takes a value only if one is buffered, and recvTimeout(timeout:) waits at most a Duration for one; both answer Result<T, RecvError>, with RecvError { Empty, Closed } — Empty when none was there, or none arrived before the deadline, while a sender is live; Closed is recv's None. "Not yet" and "never" are two answers because they want two responses: a connection pool shared by worker isolates retries acquire on one and gives up on the other, which a blocking recv could not express (an exhausted pool waited forever). The deadline runs on the monotonic clock, so moving the wall clock neither stretches nor cuts it, and a zero timeout is a tryRecv. A lock (Mutex/Condvar) is still not in std — the shared-nothing model makes a channel the seam, and this is that seam's missing half.

Endpoints are counted, so a channel is many-to-many. sender() and receiver() may each be called any number of times; every call registers another endpoint, and a side closes only when its last endpoint drops. Several Receivers over one channel is a worker pool — each item goes to exactly one of them — and several Senders is a fan-in, rendezvous included. The count moves exactly where ownership does, the same way a Shared<T> control block works, so the last holder to drop — endpoint or the Channel itself — is the one that frees the queue. There is no clone() on an endpoint and none is needed: a Channel<T> is itself a move-only resource, so it can be moved into an isolate whose worker then mints its own endpoint there.

On a fiber, a channel's wait parks the fiber. A recv with nothing queued, a send into a full channel — or a rendezvous send, until its value is taken — and a recvTimeout park only the fiber that makes them, on an isolate that runs fibers, and the core runs its others meanwhile (KR-3, docs/design/fibers.md decision 8). So two fibers on one isolate can talk over a channel, which deadlocked the core until 0.9.751. A send from another isolate wakes a receiver parked there, a timed receive's deadline is the scheduler's timer, and a side's close wakes every fiber parked on the other. The same calls block the thread on an isolate that runs no fibers, so a function is written once for both. A wait that parks is held to the park rule: on an isolate that runs fibers, no channel wait inside a lasting access to Shared storage or inside an @onPanic region (Smart pointers, the access model's Fibers item).

A side is also open before it is ever claimed, which is what lets that last pattern work: a receiver may call recv() before the isolate holding the Channel has minted its Sender, and it blocks rather than reading "no senders yet" as end-of-stream. That lasts exactly as long as the Channel: dropping it closes every side nobody claimed, so a receiver whose sender can no longer be minted sees None, a send nobody can receive comes back as an Undelivered error, and a channel dropped before minting both sides frees its queue (until 0.9.477 it blocked, and leaked, forever).

Sendability is declared, and verified — the immutable model at the isolate seam. A type says implements Sendable, and the compiler checks the claim over every field, base and variant payload; a crossing — a spawn bundle, a Channel<T: Sendable> element — requires the declaration. Both mistakes are errors: the lie (Bad implements Sendable over a Shared<Leaf> whose Leaf is mutable — a non-atomic refcount two isolates would release) is refused where it is made, naming the member and the reason; the omission (a plain Quiet { int32 n; } handed to spawn or put in a Channel) is refused at the crossing. So the whole set of types that may leave an isolate is one grep away, and no type is sendable by accident: a resource wrapping a thread-affine C handle whose author wrote nothing is not Sendable, and nothing holding it may claim to be.

What may cross without a declaration is exactly what cannot carry one: a primitive and string (declared for them in the prelude, like Hashable), a payload-less enum, a bare fnptr value, and the C seam — UnsafePtr and an extern struct. A generic enum is not on that list: it declares, per instance, like a generic value — the prelude's Optional<T> implements Sendable when [T: Sendable] (and Result's, over both parameters) — so Msg<int32> with no declaration is refused as a channel element, and an unconditional Msg<T> implements Sendable is still verified over each instance's payloads. The stdlib writes its own rules in the same words — Sendable when [T: Sendable, A: Sendable] on every container, so an arena-backed DynamicArray<T, BumpAllocator> cannot cross (its allocator points into the parent's arena), and Sendable when [T: Immutable] on Shared/Weak, which is the rule "a shared handle may cross exactly when its payload cannot change" written down rather than built in.

Two shapes are never Sendable, because their types hide what they hold. A BindableFunctionPtr bound from a Shared<T> retains that receiver — a non-atomic refcount — and its type names only the signature. A boxed contract Owned<C> erases the concrete type behind it, so it is Sendable only when the contract requires it of every implementor — type contract Job for resource implements Sendable — after which each implements Job must declare Sendable too and is verified like any other; that is what lets a job queue of Owned<Job> cross. A view and a bare contract value need no rule here: neither can be a field at all, so neither ever reaches a bundle. The stdlib's I/O vocabulary is Sendable, because a descriptor, a socket and a process handle are process-wide rather than bound to a thread, and each owning type is move-only: File, TcpStream, TcpListener, UnixStream, UnixStreamPair, UnixListener, UnixDatagram, UnixDatagramPair, UdpSocket, Poller, Process, Command, Output, StringWriter, SliceReader, PeerCredentials, Descriptor, and the values SocketAddr, UnixAddr, IpAddr, RecvFrom<A> (for a Sendable address), Event, Events, Waker, Metadata, Permissions, ExitStatus, UserId, GroupId. So a connection accepted on one isolate is served on another, and a connection pool is shared by workers. The ReliableStream contract does not require it of an implementor: the browser's WebSocket is a JavaScript object bound to one thread, and a MemoryStream shares its buffers with its peer through a Shared. A channel element is dropped exactly once — received, or buffered when the queue's last holder goes — and never as a zeroed copy: a type's empty value is the one it states (fd = -1), not zero, and until 0.9.474 every send closed descriptor 0.

A raw UnsafePtr does cross, and that is the unsafe seam working as designed rather than a hole: the bundle is built in an unsafe ctor and read through an unsafe fn, and joining the child — scope's closing brace, or an explicit give h.join() — is what orders the write against the parent's read. It is the primary isolate idiom, not an edge case.

Structured concurrency — scope ✅#

A scope { } block joins every child spawned inside it at its closing brace. It is RAII applied to tasks: deterministic lifetimes, no orphans, and — because a child is guaranteed to be joined before the scope exits — a child may safely borrow from the enclosing scope.

scope {
    spawn writer(s: give sa);     // a bare `spawn` inside a scope is a deferred-join child
    spawn writer(s: give sb);
}                                 // BARRIER: both joined here, before anything below runs

A bare isolate spawn must be a direct statement of its scope; one inside a nested block is rejected. So a scope's isolates are exactly the spawns written in it, and can be counted by reading the block. That matches the coarse-isolate model above: roughly one isolate per core, not one per work item. A conditional child puts the if outside the scope, and per-item work over a container is parallel_for. A fiber child, spawn fiber work(job: give j);, is the one exception: it may sit anywhere in its scope's body, loops included, because the scope keeps a runtime list of its live fibers and its closing brace waits for the list to empty (KR-3, being built: docs/design/fibers.md). That wait is a park, so a scope with fibers may not close inside a window over Shared storage (Smart pointers, the access model's Fibers item).

On a fiber, a join parks the fiber. A scope's close over its isolates, an Isolate's join() or drop, and a parallel_for's barrier, made on an isolate that runs fibers, park only the joining fiber until the isolate has ended, and the core runs its other fibers meanwhile (KR-3, docs/design/fibers.md decision 8); on an isolate that runs none they block the thread as before. Each is then a park, held to the same rule as a fiber's own join.

What a child may borrow#

A child borrows a place — a local, or a chain of field names on one: spawn step(a: ref w.bodies) beside spawn step(a: ref w.springs) gives two children two disjoint fields of one world. Two children may not borrow overlapping places, and two places overlap exactly when one is a prefix of the other, so ref w beside ref w.bodies is refused and so is the same local twice. An Atomic<T> is exempt — it is the sanctioned shared-mutable cell, so several children may share one.

This needs no lifetime analysis, and that is the point. A field's storage is inside its root's storage, so it is created and destroyed exactly with the root; bounding the root bounds the field. The prefix test is the same placesConflict the borrow window uses, so one rule serves both.

Two shapes are refused because they are not places. An element (ref xs[0]) has a runtime index no static rule can pin — splitting a buffer across workers is parallel_for's job, which supplies the proof this rule cannot. A call result designates no place at all.

A place may project through an indirection only when the handle is unique. An Owned<T> is — nothing can copy one — so distinct roots really are distinct objects and its fields are borrowable. A Shared/Weak is not: another handle may name the same object, so a.bodies and b.springs could be one cell under two non-prefix names, and it is refused. So is ref s on the handle itself, which would borrow that object.

A ref bundle is lowered as the entry's own call would pass that argument, and every rule of a call's ref holds. ref this borrows the object and ref o on an Owned<T> the object it holds, for both kinds of child. The label names the parameter, the argument is what the parameter takes, and a read-only place cannot go into a parameter that may write it.

Data parallelism — parallel_for ✅#

parallel_for (ref T e in coll) { … } splits coll into K non-overlapping sub-Views, one per worker isolate, runs the body over each in place, and joins them all at its own closing brace. It is safe by disjointness — two workers never touch the same element — so it needs no lock and no borrow checker. Workers that only READ say so — parallel_for (const ref T e in coll) — and then the operand may be read-only (a const ref parameter): the slices are ConstViews through view(), and each binding is a read-only place. A ref worker over a read-only operand is refused, naming that form.

import { std::collections::DynamicArray, std::concurrent::cpuCount };
fn void doubleAll(ref DynamicArray<int32> xs) {
    parallel_for (ref int32 e in xs, workers: cpuCount()) { e = e * 2; }   // closing brace is the barrier
}

workers: is mandatory — there is no default. kama has no optional parameters (a user fn cannot declare one), so a built-in construct does not get one either. Write workers: cpuCount() for one slice per core, workers: 4 for a fixed width, or any expression: workers: cpuCount() - 2 to leave headroom. It is exactly what you asked for, capped only by length (more workers than elements would leave some with no slice) — not silently reduced to the core count, since oversubscription is the caller's call. A literal workers: 0 or negative is a compile error. A second clause, stack:, sizes each worker's stack (see The stack above).

Stating it is the point: how many isolates a loop splits into used to be invisible, which is what made the chunking below surprising. Now grep 'workers:' finds every parallelism-width decision in a codebase.

ref is mandatory: disjoint mutable access is the entire point. The input is a View<T> or any contiguous container that exposes .viewMut() (DynamicArray, FixedArray are auto-viewed); a non-contiguous container such as a Map has no .viewMut() and is rejected, and so is a read-only ConstView<T> (nothing to bind ref to). The element and every captured local reach the workers by ref, which is the borrow form of a spawn bundle, so each faces the same sendability gate: a user element type must declare implements Sendable (a primitive needs nothing — see Channels below for the whole rule), and parallel_spawn shares the gate.

There are usually FEWER workers than elements — workers: 8 over 30 elements is 8 isolates running 4 elements each, one after another. That is the point of the construct: the work is finite and independent, so four elements run back to back in the same total time, and 40,000 elements need not become 40,000 OS threads.

The one thing it asks of the body: an element may not wait on another element's progress, since only workers: of them are ever in flight. A body that blocks until all elements have reached some point waits forever — the count stalls at workers:, and the elements queued behind those never start. Ordinary independent work is unaffected. When you genuinely need all N running at once — a pool whose members rendezvous — that is parallel_spawn below, which is also why parallel_for cannot build one.

Worker pools — parallel_spawn ✅#

parallel_spawn (ref T w in coll) { … } starts one long-lived isolate per element and hands the join to the enclosing scope { }. That second half is the whole point: the children run alongside the statements after it.

scope {
    parallel_spawn (ref Worker w in workers) { w.run(); }   // K = workers.length()
    {
        Sender<int32> tx = ch.sender();                     // …and this runs WHILE they run
        int32 n = 0;
        while (n < 100) { _ = tx.send(item: n); n = n + 1; }
    }                                                       // sender drops: every worker wakes and finishes
}                                                           // BARRIER: all K joined here

This is what makes a pool sized to the machine writable — workers is built by an ordinary loop, so cpuCount() elements means cpuCount() isolates. A bare spawn cannot: it must be a direct statement of its scope, so K spawns means K typed statements. parallel_for cannot either, for a different reason — it spawns and joins in one statement, so the producer above would never run and the program would hang.

Two differences from parallel_for, and both are load-bearing:

⚠️ The coarse-isolate rule still binds, and this construct makes it easy to break. An isolate is an OS thread (a Web Worker on wasm), so parallel_spawn over a container of work items is the anti-pattern named above — one isolate per item — now spelled in one line. The container should hold workers: a cpuCount()-sized pool, one per GPU device, one per shard. Oversubscription itself is fine and sometimes right (blocked workers cost nothing but stack), which is why there is no cap; the cost that bites is stack reservation per thread and, on wasm, Worker exhaustion. The compiler cannot tell a worker from a job, so this is a rule you keep, not one it checks.

The four sharing seams ✅#

Cross-isolate state is confined to four greppable seams, the same way raw memory is confined to unsafe fn:

Seam Meaning Native wasm Bare metal
module static per-isolate state — each isolate gets its own copy _Thread_local _Thread_local (emscripten pthreads share one linear memory, so TLS is what makes it per-isolate) a plain C static, zero cost (one core = one isolate)
hardware volatile MMIO and the single-core ISR↔loop flag volatile T* n/a the register/ISR seam — not cross-isolate
Atomic<T> cross-isolate mutable sharing of a scalar _Atomic / <stdatomic.h> Atomics over a SharedArrayBuffer when the program creates a thread; otherwise the build is single-threaded and needs neither atomics, if multicore
Locked<T> cross-isolate mutable sharing of any Sendable value, inside a lock window os_unfair_lock, a futex mutex, SRWLOCK a pthread mutex over Atomics.wait when the program creates a thread; a flag otherwise a flag (one core = one isolate)

The load-bearing rule: a module static is per-isolate by construction, so it cannot be observed by another isolate and therefore cannot race. To share mutable state you must reach for Atomic<T> or Locked<T>, which are visible in a grep.

That is the rule about storage. For the rule about how state reaches an isolate in the first place — the bundle, and the four ways anything crosses — see The bundle above; the two answer different halves of the same question and neither is complete alone.

Atomic<T> ✅#

import { std::concurrent::Atomic, std::concurrent::MemoryOrder };

fn uint8 main() {
    Atomic<int32> counter = Atomic.make(value: 0);
    counter.fetchAdd(delta: 1);
    int32 now = counter.load();
    return 0;
}

load / store / swap / compareExchange / fetchAdd / fetchSub, all sequentially consistent by default. Each has an …Explicit form taking a MemoryOrder for the expert case. Atomics and Locked (below) are the whole shared-mutable surface; general shared mutable memory stays outside the safe language.

Shared mutable state — Locked<T> ✅#

A cache several isolates write, a connection table, a metrics registry: state many isolates change, with no owner isolate worth a channel round trip per access. std::concurrent::Locked<T> is a lock that OWNS its value, and the value is reachable only inside a borrow window over the guard its mint returns:

fn void hit(Locked<Map<string, int64>> cache, string path) {   // each isolate is handed its own copy of the handle
    borrow cache.lock().value() as m {                         // acquires
        m.put(key: give path, value: 1);
    }                                                          // the window's close releases, on every path out
}

Deadlock-free by construction. A lock window may reach no wait and take no other lock, so a critical section never waits on anything, and taking a lock blocks only for as long as some other section's CPU work:

What the walk cannot see, stated. It reads what kama compiles. A foreign call that blocks with no @parks wrapper is invisible to it — it sits in unsafe, the trusted set — and so is a spin on an Atomic waiting for a thread that needs the lock. The runtime backs the first: a lock taken while the thread holds one, which only C calling back into kama inside a window can do, traps instead of deadlocking.

The bytes. A handle is two pointers, 16 B: the cell, and the value's place in it. The cell is a 32 B header (the count, the kind, the value's offset, its size and alignment), the lock and the value. The lock is os_unfair_lock (4 B) on macOS, glibc's futex-backed pthread_mutex_t on Linux (40 B on x86-64, 48 B on arm64) and SRWLOCK (8 B) on Windows; ReadWriteLocked's is pthread_rwlock_t (200 B on macOS, 56 B on glibc, set to prefer writers, since glibc's default starves a writer under a steady read load) or SRWLOCK. Uncontended, a window is one atomic read-modify-write to acquire and one to release, where the channel round trip to an owner isolate it replaces is about 3 µs. On a target with no threads — unthreaded wasm, --target embedded — the lock is a flag.

A concurrent map — ConcurrentMap<K, V> ✅#

One Locked<Map<K, V>> makes every access from every isolate take the same lock. std::concurrent::ConcurrentMap holds N of them — shards — and a key's hash picks one, so isolates working on different keys rarely meet. Java's ConcurrentHashMap, C#'s ConcurrentDictionary and Rust's dashmap answer the same need:

import { std::concurrent::ConcurrentMap, std::concurrent::Locked };

type value Stats implements Sendable { public int64 hits; public ctor zero() { this.hits = 0; } }

fn void hit(ref ConcurrentMap<string, int64> counts, ref ConcurrentMap<string, Locked<Stats>> stats, const ref string path) {
    bool done = false;
    while (!done) {                                                   // a counter: swap until it lands
        int64 now = counts.getOrPut(key: path, value: 0);             // inserts if absent; returns what is there
        done = counts.compareAndSwap(key: path, expected: now, desired: now + 1);
    }
    Locked<Stats> s = stats.getOrPut(key: path, value: Locked.make(value: Stats.zero()));
    borrow s.lock().value() as st { st.hits = st.hits + 1; }          // its own lock, after the shard's
}

fn uint8 main() {
    ConcurrentMap<string, int64> counts = ConcurrentMap.empty();     // cpuCount() × 4 shards, a power of two
    ConcurrentMap<string, Locked<Stats>> stats = ConcurrentMap.empty();
    hit(counts: ref counts, stats: ref stats, path: "/a");
    Optional<int64> n = counts.get(key: "/a");                         // a copy, taken under that shard's lock
    return match (n) { case Some(value: v): v == 1 ? 0 : 1; case None: 2; };
}

The bytes. A handle is an array of shard handles, 16 B each. A shard is one Locked cell: a 32 B header, the lock and a 56 B Map — 96 B on macOS and Windows, 128 B on Linux x86-64 — and an empty Map allocates nothing, so an empty map on a 10-core machine is 64 shards and about 7 KiB. Copying the handle costs one array allocation and an atomic increment per shard, once per isolate it is handed to. An uncontended get hashes once, takes one lock, probes one Map and releases. Contended, the shards are the point: eight isolates making 400,000 calls each on 1,024 string keys, one in ten a put, take 61 ms through a ConcurrentMap and 360 ms through one Locked<Map> on a 10-core M4 (the minimum of interleaved runs, bench/concurrent_map/run.py).

Immutable sharing — type immutable ✅#

The other way to share safely is to share something that cannot change. type immutable value T (or type immutable resource T) marks a type deeply immutable, which the compiler verifies: every field, base and variant payload must itself be a primitive, a string, an enum, or another deeply immutable type. A mutable member is a compile error naming that member. A primitive means every primitive, the platform-varying isize/usize included — they are scalars with no interior mutability, so they are as shareable as int64. That half went unenforced and was wrongly rejected until 0.9.144: the compiler's own list said "a primitive" while its check read an ordered span of builtin type values that isize/usize are deliberately appended after.

It is a type qualifier and nothing smaller. immutable on a field or a method is a hard error, not a silent no-op: the guarantee is deep and whole-type — one mutable field anywhere breaks it — so a per-member spelling could promise nothing. const is the per-field promise (write-once, constructor only) and const fn the per-method one.

A Shared<T> over a deeply-immutable T is Sendable — std::memory writes it as Sendable when [T: Immutable] on the type itself — so any number of isolates can hold and read the same asset with no copy. Its control block switches to an atomic refcount only in that case, so an ordinary single-isolate Shared pays nothing. This is distinct from a const binding, which only promises this alias will not mutate and therefore cannot license cross-isolate sharing.

Why not green threads or async/await#

Both exist to serve "proceed until ready". Stackful green threads need a userspace stack-switching scheduler, which on wasm means Asyncify — precisely the colouring cost being rejected. async/await colours every function and drags in pinning. Kama takes neither into the language: isolates are real threads, and blocking is honest when they are few; massive parallelism comes from the never-blocking data-parallel layer. The "multiplex thousands of connections over a few threads" ergonomic is a library concern above the language — a native scheduler can back the very same blocking-shaped surface with fibers, with no language change and no effect on wasm.

Serialization — @-attributes + @generate ✅ (by-value + object graphs + polymorphic Shared<Contract> on every backend; see ROADMAP_DETAIL.md §4)#

Opt-in, compile-time serialization. The user-facing surface is just contracts + attributes; the wire format is library; the object-graph algorithm is library; what the compiler contributes is reflection, still opt-in — the per-type field walk, synthesized only for a @generate type, as a lowering to C. Nothing is a runtime type registry: a type that did not opt in gets nothing.

Three layers.

Two modes, gated by whether T reaches a Shared/Weak field — a precomputed per-type flag (the tighter sibling of the destructible transitive-ownership walk): true iff T transitively reaches a Shared/Weak field, recursing through by-value fields, a generic instance's type arguments — collection elements and Owned/Optional payloads alike — and enum variant payloads (strings/scalars/enums add nothing). You get back exactly what you name:

You name reaches Shared/Weak Result
int32 / MyEnum / MyValueType — / false by value (stack)
tree resource (User { string name }, DynamicArray<int32>, Tree { Optional<Owned<Tree>> left }) false by value (stack)
graph type (Node reaches a pointer) true heap graph — written from that root, read back as Shared<Node>
Shared<T> where T reaches nothing false by value: the handle is walked through, byte-identical to a bare T
deserializeJsonBuffer::<Shared<T>> where T reaches nothing false compile error → "reaches no Shared/Weak field, so it is not a graph"
a collection of edges (DynamicArray<Shared<Node>>), as a field or on its own true heap graph; the collection itself has no identity, so it is written inline and read back by value
a hand-written Serializable/Deserializable holding edges true the same — no @generate required

Common rules (both modes).

Graph specifics. Shared/Weak fields serialize as u64 ids into the table (an absent Optional edge is null; an expired Weak is 0), 1-based in discovery order, the root first. Shared/Weak dedup by pointee identity; a Weak is interned only while a strong handle exists. Cycles ride Weak back-edges; a dangling id → DeErrorKind::UnresolvedReference; a table id seen twice → DeErrorKind::DuplicateId. A polymorphic edge — Shared/Weak<Contract> — reconstructs the concrete type from each entry's type tag and re-forms the fat handle with that concrete's vtable; a tag naming a type that doesn't implement the contract → DeErrorKind::TypeMismatch. A node's nested parts are walked inline: a by-value field whose type itself reaches a Shared, or an Owned pointee that does, contributes its edges to the OWNER's entry — one table, one id per pointee. An enum takes part on the same terms as a type: a Shared/Weak in a variant's payload is an edge (written as an id inside the ordinary {"tag":…,"value":{…}} frame), and a @generate enum may be a node — an edge's pointee, or the root — built on the heap with new Geo::Circle(r: 4). Every nominal implementor of a contract used as a graph edge, and every pointee of a concrete one, must be @generate(Serializable, Deserializable) — this is compile-enforced: a non-@generate implementor (which would have no adapters and be silently dropped from the wire) is a compile error at the edge field. DeErrorKind = {Malformed, UnexpectedEnd, TypeMismatch, MissingField, UnresolvedReference, DuplicateId, UnknownField, TooDeep}, the kind a DeError carries beside its path. Every reader stops at 128 nested objects and arrays with TooDeep: untrusted input must not decide how deep a read recurses, through a typed schema that holds itself or the skip of an unknown field, and 128 fits the smallest stack kama runs on. A JSON number is read by RFC 8259's grammar, and an integer field takes only an integer that fits it — 1.5, 1e3 and a value past the range are TypeMismatch, never a truncation, a wrap or a trap. The write is two passes (discover every pointee, so the table's length is exact before the first entry — a positional array carries its length up front; then write), the read two passes with no rewind (allocate each shell and stash its edge ids; then wire). The type index an index backend writes is the node type's position in the program's closed set of node types, so adding a node type renumbers a positional/numbered graph wire — the named backends are the schema-evolution-safe ones, as for fields before @field(id:).

The Owned/Shared/Weak triad is prelude / built-in (always in scope, no import) — RAII-over-GC is the core model; see TYPE_MODEL.md.

import { std::serialization::text::json::serializeJsonBuffer, std::serialization::text::json::deserializeJsonBuffer,   // wire backend (library)
         std::memory::Shared };                                                                             // a graph root comes back as a handle

// by-value (tree): a resource reaching no Shared/Weak round-trips on the stack — an Owned child included
@generate(Serializable, Deserializable)
type resource User { @field(name: "user_name") string name; @field int32 age;
    public ctor make(string name, int32 age) { this.name = give name; this.age = age; } }

// graph (heap): reaches a Shared -> what you pass IS the root; cycles rebuilt through the Weak back-edge
@generate(Serializable, Deserializable)
type resource Node { @field int32 id; @field Optional<Shared<Node>> next; @field Optional<Weak<Node>> back;
    public ctor make(int32 id) { this.id = id; this.next = Optional::None; this.back = Optional::None; } }

fn uint8 main() {
    Result<string, Owned<Error>> r = serializeJsonBuffer(v: User.make(name: "ada", age: 36));   // Ok: {"user_name":"ada","age":36}
    string j = match (give r) { case Ok(value: x): give x; case Err(error: e): ""; };
    Result<User, Owned<Error>> u = deserializeJsonBuffer::<User>(src: give j);                   // by value

    Shared<Node> a = new Node.make(id: 1);
    Result<string, Owned<Error>> w = serializeJsonBuffer(v: a);   // Ok: {"root":1,"objects":[{"id":1,"type":"Node","value":{…}},…]}
    string wire = match (give w) { case Ok(value: x): give x; case Err(error: e): ""; };
    Result<Shared<Node>, Owned<Error>> g = deserializeJsonBuffer::<Shared<Node>>(src: give wire);   // read back as a handle
    // the whole graph is read before anything is usable; a dangling id is DeErrorKind::UnresolvedReference
    return 0;
}

Building & debugging ✅#

kama build app.kama                          # this host, debug (-g, breakpoints in .kama via #line)
kama build app.kama --release                # optimized, stripped, NDEBUG
kama build app.kama --target wasm            # browser: .html + .js + .wasm
kama build app.kama --target EMBEDDED        # bare-metal: a -ffreestanding -nostdlib object (.o)
kama build app.kama --target aarch64-linux-gnu --cc "zig cc"   # cross-compile to any triple
kama build lib.kama --select OUTPUT=STATIC   # a static library (libapp.a)

Toolchain setup per platform lives in targets.md.

--target takes a built-in name (HOST, MACOS, WINDOWS, LINUX, WASM, EMBEDDED), a target your kama.json declares, or a bare <arch>-<os>-<abi> triple. Every compile and link flag follows the selected target rather than the machine you are building on, so cross-compiling is a matter of having a C compiler that can reach the target: zig cc does out of the box (it ships musl/mingw-w64/wasi-libc), or declare a cc for the target in kama.json. Without one, kama transpile --target … always works — emit the C and build it with someone else's toolchain.

A bare-metal target (any triple with os=none, of which EMBEDDED is the shortcut for this host's arch) compiles to a -ffreestanding -nostdlib object rather than a linked executable. The synthesized entry becomes int main(void) { kama_main(); for(;;){} } — no argc/argv (there is none), and main never returns (a startup/crt0 calls it and it spins). Fatal conditions (bounds/panic/OOM) route through an overridable weak kama_panic_handler (default for(;;) __builtin_trap()) — provide a strong symbol to blink/reset/breakpoint. Name the board's triple directly (--target thumbv7em-none-eabihf, with a cc that can reach it), and link the object with your chip's startup object + linker script (memory map) as a separate step — turnkey triples, linker scripts, and vendor HALs are a later milestone. A module static hardware UnsafePtr<T> lowers to a volatile T* MMIO register, and module statics become plain zero-cost statics (one core = one isolate).

Debug builds are breakpoint-debuggable in an IDE (locals + call stack map back to .kama), and emit one .c per module (faithful stepping, readable generated code). A --release build, native or wasm, instead folds every module into one unity translation unit so the C compiler can inline across module boundaries — a std::math operator or a collection accessor inlines into the caller's hot loop and then auto-vectorizes, which is what lands numeric code at C parity (kama has no incremental object cache, so a build already compiles all modules in a single invocation — the unity fold costs nothing and only unlocks inlining). The numeric-safety checks (division by zero, TYPE_MIN / -1, a bad shift, an out-of-range float cast, a narrowing cast, signed overflow in debug) are the compiler's own, in the emitted C — no -fsanitize flag in either tier; signed overflow wraps (-fwrapv, passed in both tiers) in release.

One UBSan sub-check is permanently exempt: function. A kama program built under -fsanitize=undefined should add -fno-sanitize=function, as the test suite does. This is a deliberate, permanent exemption, not a workaround for an unfixed defect. Contract, vtable and fnptr dispatch store every slot as Ret (*)(void* self, …) and call the concrete Ret C__m(C* self, …) through it. That type-erased self is ABI-identical — it is how essentially all C object dispatch works, GObject and COM included — but the function sub-check enforces exact function-pointer type identity, so it would flag every contract call in a correct program. Every other UBSan check (integer overflow, null, bounds, alignment, …) and all of ASan stay on. The exemption costs no real coverage: the emitter generates both sides of a slot from one declaration, so a genuine signature mismatch fails to compile rather than reaching a sanitizer. Making the pointer types exact would mean emitting a cast-and-call thunk per slot, which buys nothing and adds an indirection to every dynamic call — expressly the wrong trade for the embedded and hot-path targets.

Reserved keywords not yet implemented 🚧#

One keyword has reserved surface not yet implemented — using it is a hard error (never a silent no-op):

volatile is not a kama keyword: C's volatile is spelled hardware (emits C volatile for MMIO registers and single-core ISR↔loop flags — see Module-level statics and ROADMAP_DETAIL §5). It is, however, reserved — see below.

kama's keywords#

The complete list — 90 words, six of them contextual — and the reason it is printed here: every one that is not published is found by walking into it. One port found three that way — base, type, slot — each costing a build cycle to a parse error that names the token (unexpected SLOT) without saying that the word is reserved. tools/check-keyword-list.sh holds this list identical to the lexer's table, so it cannot drift.

abstract addr alignof as asm assert base bitcast bool borrow break case cast cchar char clong
comptime const continue copy copyElements ctor culong debugAssert default do drop else enum export expose
extends extern false file final float32 float64 fn fnptr for foreach friend give hardware if immutable
implements import in int16 int32 int64 int8 isize match new null operator out override panic
parallel_for parallel_spawn private protected public ref return scope sizeof slot spawn static
string this true truncate try type uint16 uint32 uint64 uint8 unsafe usize virtual void when while

Six of them are CONTEXTUAL — copy, give, truncate, type, slot and file may name any binding — a local, a parameter, a field, a member, an argument label, a match label or binding, a foreach binding, a name in an interpolation hole — and no type, free function or enum member. Each is a keyword only at its anchor in the table below: type, slot and file where a declaration can begin, give/copy directly before a name (the hand-off, give s), truncate before <T>( (the conversion). Anywhere else the word is a name — give + 1, copy(x: 1) through a fnptr, truncate < n — so give(s) is a call of something named give, and its diagnostic says so. Until 0.9.470 the parser took copy, give and truncate only as a method's name, and none of the six as a pattern, foreach or hole binding. Each was made contextual for the same reason: the word is one a program genuinely wants as a name, and admitting it cost no bison conflicts at any name position. type is what lets an FFI binding emit a C field literally called type without inventing a name (tests/extern_field_type_keyword.d/); slot is the natural name for an index into a table (tests/contextual_slot.kama); file leads the file gate below and is otherwise an ordinary name (tests/contextual_file.kama) — File file = … is the spelling a user reaches for first, and measured, the word is not an identifier anywhere in kama's own sources, so this arm exists purely for their code. The kind words value / resource / view / contract / intrinsic, and adapter, are not keywords at all — they lex as identifiers. fiber is a keyword only directly after spawn (spawn fiber work(…), KR-3), and a name of any kind anywhere else — a fiber's entry included — except an isolate's entry: spawn fiber( always starts a fiber, so spawn fiber is a grep that finds fibers and only fibers.

Which words are reserved — the greppability rule (maintainer, 2026-09-22). A word is reserved everywhere unless every KEYWORD use of it has a fixed neighbouring token that a one-line grep anchors on, so the keyword use can always be found without also finding the word used as a name. How common the word is as a name is not a reason by itself. The six contextual words pass:

word keyword use grep anchor
type type resource Foo { type + a kind word
file file @compileFor(X); (line 1) file @
truncate truncate<int8>(x) truncate<T>( — <, a name, >; a binding so named reads truncate < n
give / copy / slot give x, copy x, slot T x; the word + a name other than in — a binding so named is followed by an operator or punctuation, never a name, except in in a foreach header. For give/copy this anchor IS the lexer's rule, so the grep and the compiler cannot disagree

By the same rule out stays reserved (no anchor: out T x and q: out quotient), and so do the call-site intrinsics. this and the primitive type names are keywords like any other: they cannot be redeclared. The language's type and contract names (Optional, Result, Owned, Formattable, …) are not keywords — they lex as identifiers — but they are reserved NAMES in every scope (§ Shadowing).

C's reserved words are reserved in kama#

Every C11 and C23 keyword is a reserved word in kama and cannot be used as a name — not for a type, a function, a field, a parameter, a local, an enum case, a variant payload, a generic parameter, a foreach or match binding, or an exposed/extern symbol. Writing one is a lexical error that names the spelling.

kama compiles to C, so a name that is a C keyword emits C that does not compile: int32 switch; becomes int32_t switch;. Since every name kama owns now reaches C prefixed (§ C names), a renamed switch would in fact be safe — k_switch collides with nothing, not even a user's own k_switch, which becomes k_k_switch. The reservation is kept anyway, and deliberately: reserving a spelling now and relaxing it later is source-compatible, while the reverse is not, one lexer rule covers every position a name can appear in, and nobody needs a local called switch. It is the declared-C surface — extern fn names, extern/expose fields and values — that still requires the reservation, because those names are not prefixed and a C keyword there would reach the C compiler verbatim.

Twenty-one of the 59 are already kama keywords, so they were never spellable. The 38 that would otherwise lex as identifiers are:

alignas auto constexpr double float goto inline int long nullptr register restrict short signed
static_assert struct switch thread_local typedef typeof typeof_unqual union unsigned volatile
_Alignas _Alignof _Atomic _BitInt _Bool _Complex _Decimal128 _Decimal32 _Decimal64 _Generic
_Imaginary _Noreturn _Static_assert _Thread_local

Four of these are spellings a C, Go or Java reader reaches for, and their diagnostics name the replacement rather than merely reporting that the word is reserved: int → int32/isize, double and float → float64/float32. uint is not a C keyword and so is not reserved — it is rejected in type position only, with the same guidance (uint32/usize). Both halves are guarded by tools/check-c-keywords.sh, which also holds the reserved table equal to the two C standards' sets.

Known limitations (tracked → ROADMAP_DETAIL.md §1)#

Everything below hard-errors (never miscompiles) and has a clean workaround. Two kinds:

By-design rules — an rvalue can't be borrowed/reseated soundly, so these stay errors, not "unbuilt":

Open (deferred inference) — a rare residual; bind the subject to a typed local:

(Target-typed inline construction works in initializers, return, operator[] place-stores, value-producing match arms, class-typed lvalue stores, call arguments, variant payloads, and string-rvalue indexing. Inline new heap-boxes into an owning pointer — Owned/Shared, concrete OR contract element — in every by-value position. An owned rvalue receiver is RAII-dropped through method chains (b.make().use()) and for .chars()/.split() over an owned rvalue.)

Reserved/runtime#

Generated C names are prefixed and module-qualified — kF_<file>__Type__member for a file-private declaration (§ C names has the whole scheme, and kama demangle reverses it) — and the compiler's own emitted names — the members it synthesizes (kama_vptr, kama_base, kama_tag, kama_u), its vtable slots (kama_dtor, kama_size, kama_align, kama_type) and its temporaries (kama_ret_0, kama_strtmp0) — live in the kama_ register (§ C names). The runtime (../include/kama_runtime.h) provides kama_string and a kama_trace/kama_trace_get hook used by tests.

A kama name is never in danger of colliding with any of these, because a name the user owns reaches C as k_<name> — so __-prefixed spellings and the emitter's own identifiers are not reserved against you. A local called __ret_0 is legal kama and compiles: it emits as k___ret_0.

Edit this page on GitHub