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).

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
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 sides1. 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 int32 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. Two ways to traverse it, kept distinct by type so bytes and characters never blur:

fn int32 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.

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: …; };

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, intrinsic { const fn void format(ref Formatter f); }

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. "${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 int32 main() {
    FixedArray<int32> a = FixedArray.make(size: 4);   // fixed buffer, zero-initialized
    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

    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;
}

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 outremove(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.

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 int32 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 h.mint() as v { … }#

A view borrows storage it does not own, so kama answers how long is this view valid? with lexical scope — not a lifetime annotation, and not a programmer's promise. A borrow block is that extent, and it is purely lexical: one C block plus one initializer per binding, no runtime cost.

borrow d.view() as v { … }        // `Viewable<ConstView<T>>` grants `view()`; `ViewableMut<View<T>>` grants `viewMut()`
borrow m.values() as vals { … }   // `ValuesIterable<I>` grants `values()` — `Map` has no `view()`
borrow buf.span() as s { … }      // a user contract's own grant

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

  1. The host is a MINT CALL, and the window is over the call's receiver. The mint is any nullary member of a @viewable contract the receiver's type implements — the grant names its own member, so nothing is hardcoded. A container alone would not say which view to open (DynamicArray grants three), and a mint takes no arguments: narrow by deriving off the alias with .slice(…) inside the window, which is free.
  2. The host place is FROZEN for the extent of the block — no assignment, no non-const ref/out argument, no give out of it, no non-const fn receiver overlapping it, and the alias itself may not be reseated. 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 an element write (a[0] = 7 cannot realloc, and a View<T> is a mutate-through window in the first place).
  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 free function's result when every view handed to it was bounded. 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.

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. 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.

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 intrinsic 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 primitivethis 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, 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 intrinsic 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 int32 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. 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.

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 ({"slots":[1,2,1],"values":[10,30]}) — 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. len and the free list are derived on read and never go on the wire. 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 emits an ascending pair array). 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.

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>, 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:) 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 two-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 void deallocate(UnsafePtr pointer, usize bytes, usize align);
}

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/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 int32 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 errornew/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. It does not promise that the object never names malloc, since an unreached stdlib body may. 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_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, and a contract member or virtual method is dispatched through a slot. Marking the contract member @noheap is what lets the proof cross it — every implementation is then checked against that promise, exactly as const fn already works on a contract (tests/noheap_contract.kama). A virtual call the compiler devirtualizes (a final class, a final fn, or a method nobody overrides) is a direct call and needs no annotation.

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, and which one that is is a runtime fact, so the drop is rejected in a no-heap region like any other slot call, even when the handle's own storage is an arena. Two things prove it, and both are declarations rather than inference: @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.

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 or releases goes through one funnel, the runtime's kama_alloc/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 void deallocate(UnsafePtr pointer, usize bytes, usize align) { … }
}

GlobalHeap has the same two 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 int32 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.

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 nominallyimplements 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 int32 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 int32 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)

A marker on a fresh rvalue is an error. Move tracking is compile-time: reading a moved value, moving out of a field/element, moving inside a loop a value declared outside it, and a conditional move that is still live at scope exit are all rejected — there is no runtime drop flag.

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 or type in scope — declared in the module or imported, and with nothing implicit in scope (KR-87) that needs no exception. 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 namesOptional, 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
};

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; 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 contractOwned/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 elementDynamicArray<Shared<Shape>> stores and drops each handle in RAII order and dispatches polymorphically through it. See Generics below.

Functions ✅#

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

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 int32 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. That temp dies at the end of the statement, 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.

"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 int32 main() { return 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.

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 languagep + n on a raw pointer is an error, in both the bare and the typed form. 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.

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 intrinsic 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).

Two limits worth knowing. A nested generic call cannot inferlog(x: exp(x: 1.0)) fails because the inner call's return type is the very T being resolved; bind it to a local (kama's usual "bind it to a local" rule). And 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/Maxint64Min/Max, uint8Maxuint64Max, float32Max/float32MinNormal/float32Epsilon (signed min is -max - 1) — and per-width integer operationsmin/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 productmulWideU64(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.

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-rangesort(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.

sync::{Mutex, RwLock, Once} has no counterpart here and that is a stance, not a gap — the shared-nothing isolate model means Atomic<T> is the one shared-mutable seam (see Concurrency).

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 intrinsic impl supplying a fallible ctor, reached as T.fromStr(...). The turbofish is required because nothing in the arguments mentions T. Covers int8int64, uint8uint64, 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. Floats go through strtod behind kama_fmt.h, whose checked entry point reports ERANGE as OutOfRange rather than folding it to an infinity.

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, and full Unicode character properties belong in a package (see the Unicode stance 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.

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 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 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: sizeof does not fold, bitcast is refused (no width until the target is known), and there is no wire format.

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: they refuse sizeof folding at compile time and refuse bitcast (an equal-width reinterpret needs a width), while Formattable's widening cast<int64> needs none. What decides is whether the operation needs to know the width.

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`, which an enum may implement

(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 int32 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<int32>(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 — or Append) plus free readFile/writeFile/readText/writeText/stat/readDir/remove, createDir/createDirAll/removeDir/removeDirAll/rename/exists, and a Metadata of size, isDir, modified (a std::time::Timestamp, one-second resolution) and readOnly (the recorded permission bit, not an access check); std::net gives RAII TcpListener/TcpStream (blocking TCP) and UdpSocket. All fallible calls return Result<…, IoError>, consumed by match.

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.

⚠️ readText validates, and so does every other path that builds a string out of foreign bytesstd::io::readAll and BufReader.readLine, which are where the reading actually happens. Invalid UTF-8 is Err(InvalidInput), 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().

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.

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 per-stream Stdio { Inherit | Piped | Null } — spawns an owned, move-only type resource Process via start(), or captures via the one-shot run() -> Output { status, stdout, stderr } (which drains stdout+stderr concurrently through std::net::Poller, so a child that fills both pipes can't deadlock the parent). Process gives wait() (blocking reap → ExitStatus { code, signal, success() }), tryWait() -> Optional<ExitStatus> (non-blocking), kill/terminate/signal, 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. 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 seam (poll on POSIX, a reader thread per pipe on Windows) so both platforms take the same code path. wasm has no process model.

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). 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 int32 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;
}

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 int32 main() { return 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 int32 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 DeError::Malformed.

sleep(d: Duration) blocks the calling thread for at least d: 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 int32 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 — int8int64, uint8uint64, 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 }; — two submodules, sha1 and sha256, each exporting a streaming hasher (make(), update(ConstView<uint8>), finish()), a one-shot function of the same name as the module, and DIGESTBYTES/BLOCKBYTES. The digest is an InlineArray<uint8>#(N) — 20 or 32 bytes on the stack, no allocation anywhere, so both are present in a --no-heap build.

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. TLS, ciphers, key exchange and signatures are a package (@kama/sodium), because they are libsodium's job and carry the constant-time burden. 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) and the RFC 6455 example. HMAC and SHA-512 are the recorded next cut.

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 int32 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 DeError::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 absentResult, 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 int32 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 int32 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 int32 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/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.

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 (until 0.9.232 the local form was refused, the field form never was). What stays resolves is a drop: 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 int32 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.

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 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 the first external project 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: src/app.kama 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 operandkama 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. 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 contractforeach 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.)

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 int32 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 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 int32 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. 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.

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 escape hatch for the blind seam. See No-heap subset.

Unbound method referencesType::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 int32 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 int32 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.kamagameplay_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. Three 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, and file-private declarations (k_F<file>__…)
<project>__… a module'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. 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 and anything starting k_ or kama_ (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). And a name may start with __ but not contain one__ is how scopes join in C, so a local Fmain__helper spelled the file-private helper's C name (k_Fmain__helper). 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 -- k_Fapp__Pair_int32__make     # -> Pair<int32>::make

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 k_Fkk__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_… 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).

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).

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:

The full model + rationale is in TYPE_MODEL.md. The kind words value / resource / view / contract / intrinsic are contextual, not reserved — because they appear only right after type, 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 intrinsic 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 int32 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 argumentf(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()).

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 contractOwned<Hashable> h = 20 — is a different operation, and legal.)

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 (~dtorresource — a value owns nothing to free).

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. 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 int32 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.

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: an intrinsic collection, whose zero representation is its valid empty value, and a type with a default ctor, which the compiler calls at the fill site. (A generic field could not spell the latter anyway — there is no expression for "the default A".)

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.

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 and the always-sized FixedArray keep their own shapes. 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). 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.

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 elected one, value or resource, 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
of a memberwise ctor T.of(f1:, …)bag only (a transparent value)
zero a zero-init ctor T.zero() — bag only

Equatable/Hashable walk each field through its own equals/hash — 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 both (which is what keeps "equal values hash equal" true).

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

Subject Derives Shape
plain value / resource all seven Type { f: v, … }
generic Box<T> all seven, per instantiation as above, for each instance
tagged enum the five 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 five contract names the bare variant name; on the wire a plain string "Green", not an object
generic enum (Optional<T>, Msg<T>) the five, 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 payload-less enum derives over its members: it serializes as the bare variant name, formats as it, and compares 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.

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 placeconst 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:

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, 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. 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 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.

Two consequences worth stating plainly. A class-typed slot is valid-but-empty from the declaration on, so reading a non-owning field of one or handing it to a callee is fine; an Owned/Shared slot is not — its zero value is a null pointer, so reading through it is rejected, as is reading a primitive slot, which has no field-default fill behind it.

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.

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 int32 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

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). A contract may refine another (type contract Animated for value, resource implements Drawable { … }) for capability layering, without inheritance.

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, or collection element 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 "borrow is parameter-only; storage requires ownership" — the same reason a ref parameter can't be returned and a returnable "reference" is always an owned smart-pointer handle.

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 intrinsic — a primitive declares its conformances ✅#

A primitive is a type kind like any other, and it declares conformance the same way: type intrinsic <targets> implements C { … }. The <…> is a set, because one body usually serves many widths — the prelude's per-primitive impls collapse from 64 blocks to roughly 8. Inside the block This is the target being decorated, resolved per member of the set.

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

type intrinsic <string> implements Hashable {        // a primitive 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 intrinsic <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 primitive gets no _classes entry — every "is this a user type?" test keys on that — so the 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 contract is a SCOPE. A conformance decorates a primitive within the scope of that contract, so a contract-supplied method is not part of the primitive's own API — it is reached through the contract, never off the bare value. Without this, any package declaring type intrinsic <int32> implements 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 impl block 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 (an intrinsic's 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.

Why a kind rather than a mechanism. Before this, a primitive had no kama spelling at all, so the only way to give it a contract was implements C for T — a retroactive block reaching into a type from outside. Giving primitives (and enums) a spelling removed that mechanism's whole job rather than fencing it, and the block itself is now gone from the language. See The contract model for the full argument.

Coherence. Two declarations of the same (contract, type) pair are a compile error, whichever kind declares them — a class's or enum's own implements list, or a type intrinsic block. When the two claims come from different packages the message names both — kama's whole-program view makes the conflict directly visible, so no orphan rule is needed to forbid legal-but-unusual cases in order to prevent one the compiler can simply see.

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 int32 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.

Module staticsstatic 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 assertmsg: 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");
}

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 verificationsizeof/alignof fold and comptime assert hands the rest to the C compiler — 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/static (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 typemat * 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 += velpos = 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 (see Derives). The true/false conversion operators are out of scope: there is no implicit truthiness — if takes a bool.

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 operatorpublic 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 methodpublic fn ref T at(usize i) { … } — so v.at(i) = x too. It also works on a free function and a static methodfn 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) { return a > b ? a : b; }   // generic fn — args INFERRED from the call

fn int32 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 int32 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 IoError : uint8 implements Error {
    NotFound, Denied(int32 code);

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

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 — but not a field or a destructor — its layout is its tag plus its variant payloads, and it owns nothing beyond them. 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 intrinsic 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 typeRect 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. 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 pattern NAMES the fields it bindsfield: 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 / break / continue); 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"; }
            := name;                              // the arm's value (must be last)
        }
        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;
};

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

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 int32 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. 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).

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 IMPORTSexport { … }; 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 intrinsic 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 constructorsVec2.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 intrinsic 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. 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 and the Atomic<T> seam. 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
h.join();                                    // explicit join; `~Isolate()` also joins (RAII)

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 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 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

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.

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 int32 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 SendResult<T> { Sent, Undelivered(T item) } — a channel whose receivers are all gone hands the item back rather than dropping it on the floor, so nothing is silently lost and the sender decides what to do. 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.

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 endpoint to drop 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.

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.

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. 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 childscope's closing brace, or an explicit 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 spawn must be a direct statement of its scope; one inside a nested block is rejected. So a scope's children 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.

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.

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.

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.

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 elementsworkers: 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 three sharing seams ✅#

Cross-isolate state is confined to three 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> the only cross-isolate mutable sharing _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

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>, which is 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 int32 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 are the whole shared-mutable surface; general shared mutable memory stays outside the safe language.

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 → DeError::UnresolvedReference; a table id seen twice → DeError::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 → DeError::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. DeError = {Malformed, UnexpectedEnd, TypeMismatch, MissingField, UnresolvedReference, DuplicateId}. 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 int32 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 DeError::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 native build 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 — 89 words, six of them contextual — and the reason it is printed here: every one that is not published is found by walking into it. The first external project 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 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 CONTEXTUALcopy, give, truncate, type, slot and file may name any binding (a field, a local, a parameter, an argument label, a member) and lead a declaration only where a declaration can begin. 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 are not keywords at all — they lex as identifiers.

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< (a method is .truncate()
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

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: intint32/isize, double and floatfloat64/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 — k_F<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