hanki

17. The standard library at a glance

Hanki's libraries form four tiers along two distribution mechanisms. The mechanism settles a tier's other properties, because two questions that could be answered separately are deliberately made to coincide - is it part of the language? = is it baked into the compiler? - splitting the four tiers cleanly in two:

A third, stricter line sits inside the baked half: the native @intrinsic seam is confined to core alone - neither extra nor any package may cross it. (The package manager has landed across all three tiers — transitive universe-dependency resolution, contrib-tier resolution from the toolchain's seeded source, and local path deps — with the resolve/add/publish CLI verbs and the effect-growth gate on top (§21); only the hosted registry remains unimplemented; this section fixes their model.)

TierMechanismVersioningCurationNative codeIdentity
corebaked= toolchain version; break → majorlocked; promote/demote by decision ticketyes - the sole native seamshort flat name
extrabaked= toolchain version; break → minorcurated, foundationalno - pure-Hanki faces over coreshort flat name
contribbundled packageindependent SemVercurated, low barnoshort flat name (curation authorizes it)
universegit-URL packageindependent SemVernone - discovery index onlynoits git URL (host/user/repo); projects may bind a local alias

The baked tiers (core, extra) are the language's own curated surface (core locked, extra evolving); their module names stay flat (no path prefix at import sites), and the directory their source lives in carries the tier. Because they compile into one artifact, core and extra share a single version - the toolchain/language version; the tier difference is only the bump policy on it (a breaking change is a major bump for core, a minor bump for extra - one notch looser - while additions are minor and fixes patch). The package tiers (contrib, universe) are not part of the language: they never enter the compiler binary or the type-index, carry their own independent SemVer (they are distributed separately, so versioning granularity tracks the mechanism), and the native seam stays closed to them by design (statically enforced - see The boundary, locked). universe is a provisional name for the decentralized tier; the final name is deferred.

Tier 1: core

Locked surface - a breaking change in any of these cascades into every program, so promotion/demotion takes a deliberate decision ticket. Opened by convention.

The index surface is int. Every collection length, byte/element offset, and count - length, get, slice/slice_from, find, split pieces, BytesReader.take!/position!/remaining!, the serializer count headers, each_with_index!'s index - returns and accepts the lowercase scripting-default int, not a fixed width. Unsuffixed literals and int loop counters therefore compose with no conversions (xs.get(i + 1), 0 seeds), and there is no platform cap on sizes. An index that is out of range, beyond i64, or an in-band sentinel behaves like any other out-of-range value: slices clamp, reads return None. Crossing to a wire width goes through the conversion bridge (length.to_i64().to_u64(), u32_value.to_int() - §3). The int representation has a small-int fast path: a magnitude within ±2^62 is held inline as a machine word (no allocation), and only a larger value boxes as a bignum, so hot index loops stay allocation-free. The split is never observable - a value's arithmetic, equality, ordering, hash, and rendering are identical whichever form it takes - and both execution tiers share the same boundary.

ModuleWhat it gives you
optionOption<T> - absence channel; every API leans on it
resultResult<T, E> - `Ok(T) \Err(E); failure as a value for pure code (§7); unwrap_or/map/map_err`
listList<T> - immutable persistent list: empty, append, prepend, concat, slice (sub-list [lo, hi), clamped), take/drop (first / all-but-first n, clamped), take_last/drop_last (last / all-but-last n, clamped), take_while/drop_while (the longest prefix satisfying a predicate / the rest after it), update (replace one index), length, is_empty, get, first/last (the two ends as Option<T>), find, find_map (the first Some a (T) -> Option<U> yields — find and map fused, short-circuiting), position (the index of the first element matching a predicate as Option<int>find's index complement), any/all (short-circuiting predicate tests), count (how many elements satisfy a predicate, as int — a fold, no filtered list built), map, fold, fold_right (the right-associative fold — f takes the element then the accumulator, elements visited right-to-left), scan (the inclusive running fold — each prefix's accumulator, as a List<U> of self's length), filter, partition (split into the elements satisfying a predicate and those that do not, as Pair<List<T>, List<T>>filter and its complement in one O(n) pass, input order preserved within each), reverse, enumerate (each element paired with its index, as List<Pair<int, T>> — the pure indexed view, since each_with_index! is an action and so cannot carry an index into a pure chain), flat_map, zip (pair up two lists element-wise as List<Pair<T, U>>, stopping at the shorter), zip_with (combine two lists element-wise with f, stopping at the shorter — zip without the Pair allocation), intersperse (place sep between adjacent elements; a 0/1-element list is unchanged), chunk/windows (consecutive n-sized pieces with a shorter final one, vs. every contiguous n-length sublist sliding by one — both List<List<T>>; a non-positive n, or a windows n past the length, yields empty), each!, each_with_index!, min_by/max_by (extreme by a (T, T) -> bool strict-before comparator, Option<T> — bound-free), sort (stable merge sort, T: Ord), sort_by (stable, ordered by a (T, T) -> bool strict-before comparator — bound-free), min/max (Option<T> extreme, T: Ord), contains/index_of (T: Eq — membership test, and the index of the first element == x as Option<int>), dedup (T: Eq — collapse each run of consecutive equal elements to one; non-adjacent duplicates are kept), unique (T: Eq — drop all later duplicates, keeping each element's first occurrence; O(n²), the non-adjacent complement to dedup), sum/product (T: Numeric — a fold from T.zero()/T.one() through add/mul, so the empty list gives the identity)
mapMap<K, V> - immutable Hash-keyed map backed by a hash array mapped trie (HAMT): O(log n) get/insert/remove, persistent (structural sharing). empty/insert/get/remove/contains_key/length/is_empty/keys/values/each!/fold/map_values/to_list/from_list/merge, plus Display and order-independent Eq; m1 == m2 routes through that Eq impl, so the operator agrees with m1.eq(m2). Keys need Hash (Hash: Eq, so == still works). The keying hash is an unkeyed FNV-1a - byte-identical on both tiers and under --deterministic replay, hence attacker-predictable - so a program that builds a Map/Set from untrusted keys is hash-floodable (keys crafted to collide on the full 64-bit hash pile into one HAMT collision bucket and drive inserts quadratic) and must bound its own untrusted-key count, as the stdlib's own untrusted-key surfaces do (json.parse! caps object members, http caps headers); keyed hashing was rejected to preserve cross-tier determinism and byte-identical replay, making this residual an accepted, documented tradeoff. Iteration (keys/values/to_list/fold/each!/Display) is in hash order - unspecified and unstable, not insertion order. Encode stays canonical (entries sorted by key bytes), so ==-equal maps encode identically regardless of build order
setSet<T> - immutable Hash-element set over the Map HAMT (persistent, structural sharing): empty/from_list/insert/contains/remove/length/is_empty/to_list/union/intersection/difference, plus Display, order-independent Eq, and Hash (so a Set nests as a key or element). Elements need Hash; iteration (to_list/Display) is in hash order - unspecified, not insertion order
rangeRange - half-open [lo, hi) integer range (Range.new(lo, hi)), the iteration primitive carrying List-like combinators: each! (effectful; a loop under the hood, so a large range adds no stack depth), map (→ List<U>), fold, length, to_list. Pair with int.upto for the inclusive form and int.times! for count-only iteration (§3)
pairPair<A, B> - the generic two-field product (first/second): what other languages spell as a tuple, as a named struct (Hanki deliberately has no tuple type - structs are the product type). List.zip's element type. Constructed call-style (Pair(first=1, second="a")) - annotate the binding (p: Pair<int, string> = …; a generic constructor call does not infer its type parameters from the arguments). Eq and Display synthesize on demand like any struct's (== is field-wise, rendering is the structural Pair(first=1, second=a)), left to synthesis rather than hand-written since nothing here needs to differ from the structural derivation (map.Entry likewise). A program may define its own type named Pair: impls key on the module-qualified head (§10), so the two are distinct types and each derives its own
strstring ops - length, concat, starts_with, ends_with, strip_prefix/strip_suffix (drop a matching prefix/suffix as Option<string>, else None; the empty affix strips nothing), contains, is_empty, slice(start, stop), slice_from, to_bytes, find (→ Option<int> byte offset), split (→ List<string>, keeps empty pieces), lines (→ List<string>, \r\n-aware, no trailing empty line), join (", ".join(parts) - the separator is the receiver), repeat(n), pad_start/pad_end (to a byte width with a repeated fill), trim/trim_start/trim_end (ASCII whitespace), replace, to_ascii_uppercase/to_ascii_lowercase (ASCII-only fold), eq_ignore_ascii_case (ASCII-case-insensitive compare), hash_u64
bytesbytes ops - length, slice(start, stop), concat, get(i) -> Option<u8>, to_string() -> Result<string, Utf8Error>, is_empty, hash_u64; byte-wise Eq/Hash. Utf8Error (the to_string error) is Eq, so a Result<string, Utf8Error> can be ==-compared
bytes_builderBytesBuilder resource - new!(), push!(b: u8), extend!(b: bytes), finish!() -> bytes (non-consuming); the O(n) way to assemble a bytes (§4). Empty-effect-row actions
bytes_readerBytesReader resource - new!(input: bytes), take!(n: int) -> bytes (clamped, advancing), peek!() -> Option<u8> (next byte without advancing, None at end), remaining!() -> int, position!() -> int; the read cursor mirroring bytes_builder (§4). Empty-effect-row actions
fileFile resource methods - read_all!() -> Result<bytes, FsError>, close!(); open a handle with fs.open_file! (§4)
socketTcpStream / TcpListener resource methods - read!(max), write!(data), accept!(), close!(); open one with net.connect! / net.listen! (§4)
displayDisplay<T> trait - #{…} interpolation and .to_string() desugar through it. Builtin impls for every integer type (int and i8/i16/i32/i64, u8/u16/u32/u64), decimal, rational, f64, bool, and string; a struct/sum is synthesised on demand (no @derive needed). The container types carry hand-written impls: List<T> renders as [e1, e2, e3] (the empty list as []) and Map<K, V> as {k: v, …}, each element shown through its own Display; both are opaque intrinsic types, so they need the explicit impl rather than the variantless auto-derived one
eqEq<T> trait - ==/!= always dispatch through a type's Eq impl - hand-written (so a custom eq, e.g. Map's order-independent equality, drives the operator) or synthesised on demand (the same auto-derivation that needs no @derive, structural field/variant-wise) for any struct/sum whose parts are all Eq. There is no structural runtime fallback, so the two tiers cannot disagree. A type with a part that has no Eq (a resource, actor reference, function value, or notably f64 or an aggregate containing one) makes ==/!= on it a compile error naming the part - which also makes the nested-float bit-compare divergence (§22) structurally unreachable. An opaque ActorRef<T> handle (§15) is the one builtin exception: it compares by identity (same actor), by id on both tiers. On a generic param, ==/!= instead require a T: Eq bound and dispatch through it (§10); without the bound they are a compile error. Builtin Eq covers every integer type (int and the fixed-width ints), bool, string, and bytes; f64 is deliberately excluded - IEEE NaN != NaN would break reflexivity (and with no structural fallback, a struct holding an f64 simply has no Eq, so it cannot reach a float compare at all).
opsThe operator traits Add<Rhs = Self> and Sub<Rhs = Self>, each with an associated type Output and one pure method (add/sub). Non-numeric +/- dispatch through them (§3, §10): the operand pair selects the impl (impl Add<Period> homogeneous via the default, impl Add<Instant, Duration> heterogeneous) and the expression's type is the impl's Output. Numerics never route here, and a builtin-primitive Self is rejected (H0573). The datetime module carries the flagship impls (now!() + 2.days, instant - instant, date + 2.months, 1.years + 2.months); user types join by impl. Future Mul/Div/Neg land here too.
numericNumeric trait - the static identities zero()/one() plus add/mul (each impl's body is the type's native +/*), implemented for all 13 numeric types (int, i8i64, u8u64, f32, f64, decimal, rational). The bound for generic numeric code: List.sum/product bound <T: Numeric> and reach the identities as T.zero()/T.one() (static-through-bound, §10). A plain trait by necessity - the operator traits exclude builtin-primitive Self (H0573) and bounds on parameterized traits are staged (H0569). Fixed-width add/mul wrap exactly as the operators do; floats stay IEEE; the lowercase tier's in-band inf/undefined sentinels propagate unchanged
ordOrd<T> trait - def cmp(self, other: Self) -> Ordering (Less/Equal/Greater); the single total-order primitive List.sort and the canonical Map encoding build on. Ord also provides default min/max (defined once over cmp), so every Ord type gets them without a per-type copy. </<=/>/>= dispatch through it the way ==/!= dispatch through Eq: a numeric operand uses the type's direct relational opcode (so every numeric tier stays comparable with the operators), while bool, string, bytes, and any user type with an Ord impl lower a < b to a.cmp(b) and test the Ordering (< is Less, <= is "not Greater", and so on). As with Eq, there is no structural fallback - a non-numeric type with no Ord impl makes < a compile error (write impl Ord<T>, or compare via .cmp(…)). On a generic param, </<=/>/>= require a T: Ord bound and dispatch through it (§10); without the bound they are a compile error. Builtin Ord covers every integer type (int and the fixed-width ints), the lowercase decimal and rational (total over their in-band infinities - -inf < finite < +inf < undefined, so they stay sortable), bool, string, and bytes; f64 is excluded (IEEE NaN breaks a total order), though bare f64 < still works through the IEEE float opcode.
hashHash<T> trait - prop hash(self) -> u64 (a bare-dot read, §10); supertrait Hash: Eq (§10), so an impl must satisfy a.eq(b) => a.hash == b.hash. Builtin impls for every integer type (int and i8/i16/i32/i64, u8/u16/u32/u64), bool, string, and bytes; f64 is excluded (it has no Eq). The trait surface is frozen ahead of 1.0, and the HAMT-backed Map (§17) now consumes it - a Map key must be Hash.
serializerSerializer trait - the format-agnostic write events an Encode walk emits (put_u8!put_bytes!, put_none!/put_some!, begin_seq!/begin_map!/begin_struct!/begin_variant!); a concrete impl translates them to one wire format so one @derive(Encode) serves every format. BinarySerializer (over a BytesBuilder) is the built-in fixed-width format.
deserializerDeserializer trait - the read inverse (take_u8!…, take_is_some!, take_seq!/take_map!/take_struct!/take_variant!, each → Result<_, DecodeError>); DecodeError = Truncated(int) / BadTag(u8, int) / BadUtf8(int) / TooDeep(int). BinaryDeserializer reads BinarySerializer's format over a BytesReader cursor; a short read is Err, never a crash. A decoded List/Map bounds its element count against the unread input (remaining!), so a count exceeding the bytes left is rejected rather than looped — a crafted huge count can't amplify a few bytes into a giant collection; and the whole Decode path bounds recursion depth (past 128 → TooDeep), so deeply-nested untrusted bytes can't overflow the decode stack (json.parse!-parity, and the fuel-less AOT tier's only guard).
encodeEncode trait - def encode!<S: Serializer>(self, s: S); the structural serializer (§16), @derive(Encode) for any struct/sum whose fields are Encode. Writes format-agnostic events to a Serializer; the built-in BinarySerializer gives a compact fixed-width layout (Map canonical - entries sorted by key bytes, so ==-equal maps encode identically). Builtin impls for the fixed-width integers, f64, bool, string, bytes, List<T>, Option<T>, Map<K, V>.
decodeDecode trait - def decode!<D: Deserializer>(d: D, depth: int) -> Result<Self, DecodeError> (static, no self); the inverse of Encode, @derive(Decode). Reads from a Deserializer cursor (BinaryDeserializer over a BytesReader); DecodeError = Truncated(int) / BadTag(u8, int) / BadUtf8(int) / TooDeep(int); malformed input yields Err, never a crash. depth bounds recursion (containers/derived bodies recurse at depth + 1, rejecting past 128 with TooDeep), so hostile nesting can't overflow the decode stack; from_bytes seeds it at 0.
codecto_bytes<T: Encode>(v) -> bytes / from_bytes<T: Decode>(input) -> Result<T, DecodeError> - the @encapsulated, pure-callable serialization entry points over Encode/Decode (the built-in BinarySerializer / BinaryDeserializer format, §16), plus encoded_len. Callable from ordinary and pure code; not yet foldable at comptime (§22)
actorSendFailed typed throw (variants MailboxFull, Died(ActorId, DeathCause), Timeout); every send picks it up. Also the actor.await_timeout!(f, ms) bounded-await form (§15)
moduleDynamic source-module loading - module.load!<T>(path) / unload! / reload!<T> (§14). Carries its own @intrinsic native seam (like actor), which is why it is core, not a pure extra face; runtime-coupled and niche
configComptime config loading. config.load<T>(path) reads a .config.hk file at build time, type-checks and evaluates it (against the stdlib plus the host's entry module, so the config may open <entry-module> and construct the host's types), and bakes its trailing value into a const - the binding's annotation pins T. Pure and comptime-only (no effect row; a failure is a compile error); the path resolves relative to the entry file and must stay within the project - an absolute path, one that climbs out with .., or one that resolves through a symlink planted inside the project, is rejected (the same confinement applies to the manifest's own entry), so a build can't be steered into reading an arbitrary file on the build machine. A config value of any shape - a primitive, string, or a struct/list/variant aggregate - bakes into a usable runtime const, materialised where it is read (in an action, an interpolation, or a pure def body) by re-emitting the construction ops the value's literal would lower to. The only values that can't bake are the ones with no constant form: a closure, a bytes buffer, a comptime resource, or an in-band ±inf/undefined.
compilerExperimental compiler reflection (§17, Compiler reflection): run the front end over a source string and read its outputs back as Hanki values - parse/type_decls/imports/diagnostics/effect_surface/doc_items, plus stdlib_modules (the embedded stdlib sources), bundled_card/bundled_reference (the embedded HANKI-CARD.md/HANKI.md), and bundled_skills (the embedded agent-agnostic skills hanki new ships). The seam the off-path dev tools (fmt/doc/lint/effects/api-diff/cddl/new) reflect through. Shape unstable until the tool ports validate it; works on both tiers (an AOT binary that calls one links the embedded front end, like load!)
manifestExperimental effectful artifact reflection: manifest.read!(path) -> Result<ManifestInfo, ManifestError> [fs_read] reads a built shared module's .hanki_manifest ELF section and decodes it into reflected values (ManifestInfo / Export / TypeSig / MType / ExportExtras, mirroring the wire format) - the data behind hanki inspect. Kept out of the pure compiler.* source-reflection namespace because it reads a file; works on both tiers (an AOT binary that calls it links the embedded front end)
pkgPackage-manifest data types: struct Dep { source, version } and struct Locked { source, version, hash, effects } - the constructible records a hanki.config.hk deps list and the generated hanki.lock.config.hk lock build with (§21). Pure data (like path); a manifest/lockfile open pkgs them
validationValidationError - the error value a where-invariant or smart constructor (Email.new, §9) returns on a failed predicate (Err(ValidationError)), with a Display rendering. Pure data
Numeric towerthe 13 numeric types along the Num / Integer / Real / Float hierarchy (§3): int, the fixed-width i8i64 / u8u64, the IEEE floats f32 / f64, and the lowercase arbitrary-precision decimal / rational (each a core module; the trait rows above enumerate the same 13)

Planned for core (not yet implemented): float. (The numeric-tower trait hub landed as numeric, above.)

Tier 2: extra

Curated, foundational capability/utility modules - evolving and not locked like core. Import-gated (§14): in scope only after a file use/opens them, so a file declares the capability vocabulary it pulls in. Qualified by default - write use net rather than open net; io is conventionally opened (for bare print!) but still requires the import like every other extra module.

ModuleNotes
ioTerminal I/O face over the sys stdio seam - print! (stdout), eprint! (stderr), read_line! (a Line(string) / Eof sum; interruptible by actor.shutdown! (§15), so an actor parked on a half-typed line can still be reclaimed); no file IO. Each write reaches the terminal as it is made, on both tiers, so a prompt with no trailing newline is visible before a following read_line! blocks - there is no flush! to call, and none is needed. Pure-Hanki (no @intrinsic, delegates to sys), conventionally opened for bare print!
processSubprocess spawn (process.run!) - spawns a child, feeds it stdin, and waits for it to exit. process.run_with!(cmd, args, stdin_input, env: Map<string, string>) is the same spawn with a per-spawn environment overlay: the child inherits the parent's environment with env laid on top (overlay wins on collision). It charges [process, env] - handing a value TO a subprocess is the same capability as reading one out, so --deny env reaches it - and the overlay is per-spawn by design: there is deliberately no process-wide env.set! (ambient mutable state would race across actors). process.run_in!(cmd, args, stdin_input, cwd, env: Map<string, string>) adds a per-spawn working directory on top of that overlay: the child runs in cwd (an empty cwd inherits the parent's, matching run! / run_with!), so a spawned tool's own relative paths resolve against it rather than the caller's cwd - the same per-spawn design (no ambient chdir), charging the same [process, env]. The wait is interruptible: actor.shutdown! / program exit (§15) wakes it and kills the child (SIGTERM, a short grace, then SIGKILL), so a never-exiting child can't pin the actor past shutdown - program exit terminating its own subprocesses matches actor-termination semantics
fsFilesystem read/write (fs.read!, fs.write!), path resolution (fs.canonicalize!(path) -> Result<string, FsError> - the OS realpath), plus handle open (fs.open_file! -> Result<File, FsError>, §4)
pathPure-Hanki lexical slash-path helpers - no effects, no sys, both tiers, never touch the filesystem. path.join(base, child) (one separator at the seam), path.dirname(p), path.basename(p), path.normalize(p) (collapse //, drop ., resolve .. without escaping an absolute root - Go path.Clean semantics), and path.split_ext(p) -> PathSplit { root, ext } (root is the path without its extension, ext the extension without its dot; a dotfile or extensionless name yields an empty ext)
netBlocking TCP - net.connect! -> Result<TcpStream, NetError>, net.listen! -> Result<TcpListener, NetError>, net.write_all!; one-actor-per-connection (§4). connect!'s resolve-and-connect is interruptible by actor.shutdown! (§15), so a connect to a blackholed peer can't pin the actor
httpHTTP/1.1 over net - Request / Response structs, a Method sum, HttpError. Client: http.get!(url) / http.request!(method, url, headers, body) -> Result<Response, HttpError> (parses an http:// URL; https:// is unsupported, TLS out of scope). Server: framing (read_request! / write_response! on a TcpStream, plus pure parse_request! and render_request / render_responseResult<bytes, HttpError>) and a blocking serve!(listener, handle) that runs one connection at a time - concurrency is a per-connection worker actor with move (§15). Headers are lower-cased maps (case-insensitive); parse_url rejects userinfo/IPv6 authorities and render_* reject a bare CR/LF in a header or the request-line (method + target) (request/response-splitting guard). v0 wire: Content-Length bodies, Connection: close, no chunked/keep-alive, size-bounded
cborCBOR (RFC 8949) codec - CborSerializer / CborDeserializer are pure-Hanki impls of Serializer / Deserializer (§16), so @derive(Encode, Decode) yields a real-CBOR codec for any type. Both tiers. v0: definite-length items; f64 read as the 64-bit form (foreign half/single floats are a follow-up). The matching CDDL (RFC 8610) schema for your types is emitted by hanki cddl (below)
jsonJSON (RFC 8259) as a value tree - a JsonValue sum (Null/Bool/Num(f64)/Str/Arr/Obj), parse! / parse_bytes! (→ Result<JsonValue, JsonError>) and Display rendering (compact JSON). Both tiers. parse! bounds array/object nesting depth (a TooDeep error past the limit) so deeply nested untrusted input cannot exhaust the stack, and caps the members decoded into a single object (a TooManyKeys error past the limit) so untrusted keys cannot hash-flood the backing Map (its FNV-1a hash is unkeyed for cross-tier determinism, hence attacker-predictable). The compat shim (§16), deliberately not wired to @derive: JSON is a standalone tree you (a) pattern-match, (b) walk with accessors (get/at/as_num/as_str/as_bool/as_array/as_object/is_null) chained through Option.and_then, or (c) decode into typed values via the FromJson trait - a hand-written from_json per struct, with builtin scalar + List/Option/Map impls that compose (a JSON array of objects → List<YourStruct> for free) and located errors (JsonShapeError, e.g. [1].y: expected number). The inverse ToJson (to_json, total - no error) encodes your types back, so v.to_json().to_string() round-trips with parse! + from_json. Non-finite f64 renders as null; object members render with keys in sorted (canonical) order - deterministic regardless of build order, since Obj is backed by a hash-order Map (RFC 8259 §4: object members are unordered). To instead reproduce a foreign envelope that fixes its field order byte-for-byte (a serde struct's declaration order, say), the field-order emitters object / array / quote / boolean build object/array JSON text with members in the given order (object over a List<JsonMember>; quote renders a string with serde-matching escapes) - a separate emission path kept off the value tree, since field order is not part of the parsed model (parse! never yields an ordered object)
xmlXML 1.0 (5th ed) pull parser, secure by construction: the parser defines no custom entities and performs no I/O of any kind, so XXE and entity-expansion attacks are structurally impossible rather than mitigated - only the five predefined references (&amp; &lt; &gt; &apos; &quot;) and numeric character references decode (any other named reference is a typed UndefinedEntity error), and a DOCTYPE is tolerated but skipped inert (bracket-matched, never interpreted). Pure Hanki - no native seam, no effect atom; "parsers don't do I/O" is structural. Face types today: XmlEvent (Declaration/ElementStart/ElementEnd/Text/CData/Comment/ProcessingInstruction, names as raw unresolved qnames), XmlAttribute, and XmlError (a kind sum - Syntax/UnexpectedEof/MismatchedTag/DuplicateAttribute/UndefinedEntity/UnsupportedEncoding/InvalidChar/TooDeep/MultipleRoots - with byte offset + line/column). Well-formedness enforced (single root, tag balance, attribute uniqueness, XML name validity, char validity incl. after reference decoding, no raw < in attribute values, no ]]> in character data); UTF-8 only (BOM stripped; any other declared encoding is UnsupportedEncoding); nesting depth capped (default 1024) - the one resource guard the entity ban does not cover. Text is verbatim (no trimming) with §2.11 line-end normalization; attribute values normalize literal whitespace to spaces (§3.3.3). The public reader is namespace-aware (Namespaces in XML 1.0, always-on - there is no raw-prefix public mode): reader(input) / reader_bytes(input) build an XmlReader value, next! pulls one event through the XmlStep sum (Next(successor, event) / Done / Fail), and element/attribute names arrive resolved as XmlName { uri, local, prefix }. xmlns/xmlns:p declarations are scope, not data (scoped + shadowing; xmlns="" un-binds the default; they never surface as attributes); the reserved xml prefix is always bound and unrebindable, xmlns is never a usable prefix, an unbound prefix is a typed UnboundPrefix error, and reserved-namespace misuse is ReservedNamespace. An unprefixed attribute has no namespace even under a default declaration (the spec element/attribute asymmetry); two attributes may not share one expanded (uri, local) name. The tree layer is the convenience surface: parse!(s) / parse_bytes!(b) (→ Result<XmlDocument, XmlError>) drain the reader into a plain immutable value tree - XmlDocument { declaration, before_root, root, after_root }, XmlNode (Element/Text/Comment/ProcessingInstruction), XmlElement { name, attributes, namespace_declarations, children } - sendable across actors like any data, hand-constructible for writing, built iteratively (document depth never grows the call stack). CDATA merges into Text and adjacent character data coalesces at this level (the event layer keeps the distinction); each element records the namespace declarations written on its tag for faithful re-emission. Navigation is children-only (no parent pointers - plain values cannot cycle): elements / elements_named(local) / elements_ns(uri, local) / first / first_ns / attribute(name) (the NO-namespace attribute) / attribute_ns(uri, local) / text (all descendant character data, document order - text_content semantics). Writing: a streaming XmlWriter value (writer(), then chained start/attribute/namespace/text/cdata/comment/processing_instruction/declaration/close, and finishResult<string, XmlError>) - errors are STICKY (an invalid operation poisons the writer; finish reports the first), so generation needs no per-step match, and output is well-formed by construction: context-correct escaping (text escapes &<> so ]]> cannot appear; attributes always double-quoted escaping &<"; CDATA ]]> split across sections; comment -- and PI ?> are unescapable and typed errors), validated names, prefix-must-be-declared enforcement (reserved rules as in the reader), tag balance, single root. The tree renderer rides on it: render(doc) compact, render_pretty(doc, indent) indenting element-only content and NEVER reformatting mixed content (whitespace is data); re-rendering a parsed document preserves original prefixes, and render → parse → render is a fixed point (structural round-trip modulo the documented normalizations: CDATA arrives back as text, quoting is normalized). Typed decode: the FromXml trait (T.from_xml(element) -> Result<T, XmlShapeError>, the FromJson shape) with scalar impls (string/bool/int/i32/i64/f64 - decoding an element text content, trimmed) and the decode accessors require_attribute / require_child / child_text (with attribute / first / elements_named as the optional/repeated forms). XmlShapeError (MissingElement/MissingAttribute/UnexpectedShape/BadScalar) carries a cheap feed/entry/title path (@name for attributes) extended upward through nested decodes with within(parent, result); Display renders path: what went wrong. The v1 mapping convention - a record field decodes from the CHILD ELEMENT of the same local name; attributes are reached explicitly - is exactly what @derive(FromXml) (staged) will mechanize. Scope excludes DTD processing, validation, XPath, XML 1.1, and non-UTF-8 encodings
terminalLow-level terminal control over the sys.term_*! seam - PURE escape builders (move_to(column, row) 0-based with the 1-based ANSI conversion inside, cursor_hide/cursor_show, clear_screen/clear_line/clear_to_line_end, enter_alternate_screen/exit_alternate_screen (CSI ?1049h/l), reset), a face-owned Color sum (the sixteen ANSI names + Indexed(u8) + Rgb(u8, u8, u8)) with a chainable Style (Style.new().fg(Red).bold() - sgr(style) always starts from 0 so a sequence stands alone; styled(s, style) wraps with reset), and thin [io] delegations (stdin_is_tty!/stdout_is_tty!/size!/enable_raw_mode!/disable_raw_mode!/write!). The full-screen pair every TUI calls: enter_full_screen! registers its undo bytes (leave alt screen, show cursor) through sys.term_restore_write! BEFORE switching, so a crash anywhere never strands the terminal; leave_full_screen! undoes and clears the registration. Model: one actor owns the terminal; build frames as a joined List<string>, never quadratic concat. Key input: the PURE incremental decoder decode(input: bytes) -> Decoded { events: List<TermEvent>, rest: bytes } (complete sequences consumed, the incomplete tail returned for the next chunk; malformed/unknown sequences consumed and dropped - it never desyncs and never fails) covering UTF-8 chars, C0 controls (Ctrl+letter, Enter/Tab/Backspace), lone-vs-prefixing ESC (alt), CSI (arrows/Home/End/tilde navigation and function keys, ;2/;3/;5 modifier params, back-tab), and SS3 (F1-F4, app-mode arrows); Key is the face-owned sum (Char(string) one scalar, the navigation keys, Function(u8)), KeyEvent { key, ctrl, alt, shift } with best-effort modifiers (documented). A decoded event is a TermEvent sum: Key(KeyEvent), Paste(string) (one bracketed-paste burst ESC[200~ESC[201~ delivered as a single event, so a pasted control character is literal text), or Mouse(MouseEvent { kind, column, row, ctrl, alt, shift }) (one SGR-1006 report at zero-indexed cells; MouseKind = Press/Release/Drag of a MouseButton plus Moved and Scroll{Up,Down,Left,Right}) - both intercepted in the byte stream before the CSI path, an incomplete burst/report parking in rest; enter_full_screen! turns paste + mouse tracking on for the session (and registers the OFF bytes as crash-restore). read_key!(timeout_ms) -> KeyRead (Pressed(KeyEvent) / Pasted(string) / Moused(MouseEvent) / TimedOut / Eof / Failed) reads byte-at-a-time so it never over-reads, disambiguating a lone ESC from a sequence start with a ~25ms follow-up window (silence after ESC is the Escape key). display_width(s) -> int / first_width(s) give terminal CELL widths (UAX #11 East-Asian Wide/Fullwidth + emoji-presentation = 2; combining marks, format chars, default-ignorables, and controls = 0 - a Wide-but-ignorable scalar like the Hangul fillers counts 0; everything else 1), from committed range tables pinned to Unicode 16.0.0 with the extraction provenance in-file; widths sum SCALARS, not grapheme clusters (documented v1 limit)
envEnvironment-variable reads - env.get!(name) -> Option<string> (effect [env]): Some(value) when set, None when unset (a non-UTF-8 value also reads as None in v0). Effect-gated on purpose - environment reads are the classic exfiltration target (cloud credentials, CI tokens), so a program that can touch the environment says so in its signatures and is refused wholesale by --deny env. Writes are deliberately absent (a different authority, rarely needed)
randomRandom draws - random.u64!() -> u64 and random.bytes!(n: i32) -> bytes (effect [random]). Effect-gated on purpose: minting randomness is an authority a signature must declare and a run can refuse with --deny random, so pure code can never silently mint a nonce. Under --deterministic the stream is seeded from the run seed (reproducible - property tests replay; not cryptographic in that mode) and drawn from a PRNG distinct from the scheduler's, so consuming randomness never reorders the actor schedule; otherwise it draws from the OS CSPRNG (/dev/urandom). Range/float helpers are a later pure-Hanki surface
timeActor-shaped time - time.sleep!(ms: i32) (effect [time]) blocks only the calling actor; negative ms is 0. Under --deterministic the sleep is virtual (§6): the actor parks on the deterministic gate's clock, which advances to the earliest deadline only when no actor can run, so a deterministic run sleeps in zero real time, stays byte-replayable per seed, and a program that is merely sleeping is never misreported as deadlocked. Timer patterns (send-after, backoff, periodic) build on sleep! in plain actor code (§15). Clock reads: time.now_ms!() -> i64 (wall-clock ms since the Unix epoch) and time.monotonic_ms!() -> i64 (ms since an unspecified fixed origin, never decreasing - for durations), both [time]. Under --deterministic both read the gate's virtual clock, so even a program that prints timestamps replays the same bytes per seed
datetimeOffset-based calendar, clock, and duration library (chrono / JS-Temporal / arrow class), pure-Hanki over the arbitrary-precision int tier - Howard Hinnant's civil-calendar algorithms are total and exact (no overflow, no year bounds) because int's // floors and % carries the divisor's sign. Eight opaque value types: Instant (absolute UTC-timeline point, epoch nanoseconds), Date (proleptic-Gregorian y/m/d), Time (nanosecond wall-clock time of day), DateTime (date + time, no offset), Offset (fixed UTC offset, `\seconds\< 86400), OffsetDateTime (an Instant viewed through an Offset - the RFC 3339 timestamp; compares **by instant**, offset a display attribute), Duration (signed elapsed nanoseconds - *not* calendar months/years, whose length is not fixed), and Period (calendar-variable whole months, rendered ISO P1Y2M/P0D/-P1Y2M; **normalized by total months** and compared by duration, so Period.of_years(1) == Period.of_months(12) and 1.years > 11.months). Plus Weekday / Month enums (ISO numbering, names) and an IsoWeek struct. Validating constructors return Result<T, RangeError> (Date.new(y,m,d), Time.new(h,mi,s,ns), Offset.of_seconds/ofhoursminutes, DateTime.of); parsers return Result<T, ParseError>. Calendar ops: weekday, dayofyear, iso_week, add_days/weeks/months/years (month/year shifts clamp to month end), days_until, leap-year and month-length queries. **Fluent units** — properties on int after open datetime: 2.days / 5.minutes / 3.weeks build a Duration, 2.months / 1.years a Period, read bare per §10 (2.days, never 2.days()); Date / DateTime / OffsetDateTime gain add_period(p) (years then months, clamping to month end), and a Duration gains the Rails clock helpers ago!() / from_now!() (3.hours.ago!(), 2.days.from_now!()). ISO 8601 / RFC 3339 throughout: to_iso/to_rfc3339 and Date.parse / Time.parse / Offset.parse / DateTime.parse / Instant.parse / OffsetDateTime.parse / Duration.parse (ISO 8601 durations PnDTnHnMnS, PnW). Internal precision is **nanoseconds** though the host clock resolves to milliseconds. Every function is pure except the clock seam - now!() -> Instant, now_at!(offset), today!(offset) (effect [time], delegating to time.now_ms!, virtual and replayable under --deterministic). **Scope: fixed UTC offsets only** - named IANA zones (America/New_York) and DST transitions need an embedded, yearly-updated tz database and are left to a community library (the chrono-core / chrono-tz split); this surface is complete for offset-based work **Operators** ride on the core Add/Sub traits: instant ± duration, instant - instant -> Duration (positive when the left is later), datetime ± duration, datetime - datetime -> Duration, odt ± duration, odt - odt -> Duration, date ± period, period ± period, duration ± duration — each delegating to the named method beside it. Deliberate omissions: no Time operators (its add wraps mod 24h; the named method's doc says so) and no Date + Duration (Date is calendar-only — shift it with a Period or add_days`).
sqliteSecure-modern-strict SQLite (use sqlite) - a pure-Hanki face over the sys.sqlite_*! native seam (§4), effect [db], both tiers. open_path!(path) / open_memory!() open with a hardened default profile most wrappers leave off: WAL, foreign_keys ON, DQS off (strict_sql), defensive + trusted_schema hardening, a 5 s busy_timeout, synchronous=NORMAL, and a 64 MiB journal cap; open_with!(path, opts) overrides via a functional OpenOptions (defaults() + with_* updaters). For executing untrusted SQL, two composable bounds pair up — the engine limits bound how much hostile SQL can make the engine do, and the table policy bounds what it may touch; neither alone is the full story. OpenOptions.hardened() layers the SQLite security doc's engine-limit profile on the defaults (sqlite3_limit reductions — statement length, expression depth, VDBE program size, column count, ATTACHED 0, … — via the sys.sqlite_limit! seam) plus PRAGMA cell_size_check; with_secure_delete(true) is a further opt-in. The threat model splits in two: a hostile database file is substantially covered by the on-by-default profile (defensive, trusted_schema OFF — overridable, unlike the native lockdown, which is not an option) with cell_size_check as the paid extra, while hardened() bounds hostile SQL — and the engine limits cap only what SQLite itself will accept, a different axis from --max-steps/--max-bytes, which bound the Hanki side; neither implies the other. The table policywith_allowed_tables([...]) at open (file databases) or the one-way conn.confine_tables!([...]) after schema setup (the :memory: workflow) — statically confines all later statements to the named tables: reads and writes elsewhere, schema reads, all DDL, and pragmas (including this face's own pragma-backed conveniences) are denied at prepare time, surfacing as SQLite's authorization error. It is a static allow-list evaluated natively (no callback into Hanki), installs at most once per connection (a second install, which could widen the set, is Misuse), and composes with — never weakens — the always-on lockdown. The boundary it draws is confinement within a database the caller legitimately opened; file reach is the lockdown's separate, non-optional job. (Named open_path!, not open!, because open is a keyword.) The always-on security lockdown (no ATTACH, no URI filenames, no extension loading) lives in the native seam and is not an option, so [db] grants a database, never arbitrary file access. SqliteConn is an actor-confined resource (§4, move into spawn); a long statement is shutdown-interruptible — the connection's progress handler (installed at open, in-thread, no cross-thread call) aborts the statement once actor.shutdown! fires, and the actor dies with ExplicitShutdown (§15). Two latency gaps the handler does not cover, each bounded by its own wait: a mid-statement lock wait (up to the busy_timeout, since the busy wait runs no VDBE ops) and time between statements; its methods are batch! (multi-statement scripts), execute! (one statement → rows changed), query! (→ a materialized DbRows), execute_named! / query_named! (named parameters as a Map<string, DbValue>: each :x / @x / $x in the SQL binds the map's bare-key x entry — keys carry no prefix — with coverage checked both ways, so a statement name missing from the map, a map key the statement never uses, or a positional ? slot in a named call is Err(BindName(name)) rather than a silent NULL), last_insert_rowid! / changes!, the transaction verbs begin! / begin_immediate! / commit! / rollback!, the savepoint verbs savepoint!(name) / release!(name) / rollback_to!(name) (nestable partial-rollback points; the name must be a bare ASCII identifier with the hanki_ prefix reserved case-insensitively, SQLite matching savepoint names without case — anything else is rejected before reaching SQL, since a savepoint name is the one caller string in this face that cannot be a bound parameter), plus with_tx!<T>(f) (runs f inside a BEGIN IMMEDIATE, committing on Ok and best-effort rolling back on Err; composes with itself: called inside an open transaction it scopes an auto-savepoint instead of the BEGIN SQLite would reject, so an inner Err rolls back only the inner work and the outer transaction stays open), in_transaction!, busy_timeout!, integrity_check!, optimize!, backup_to!(dest_path) (online-snapshot a live database to a new file, returning pages copied; SQLite's backup API in shutdown-interruptible steps. Its effect row is [db, fs_write] — the deliberate answer to "backup writes a caller-chosen path": rather than quietly stretching what [db] grants, the one file-reaching sqlite call charges the file-write capability, so --allow db --deny fs_write reaches the database but cannot export it and "[db] grants a database, never arbitrary file access" stays literally true), and close! (optimize-then-close). Query results are plain data (DbRows / Row / DbValue), so a result is sendable across actors; results larger than memory stream instead through conn.stream!(sql, params) → a Cursor (next! / columns! / close!, plus fold!(init, f) to drain through a closure) reading row-at-a-time with bounded heap — the stream state lives inside the connection's own native cell (one stream per connection; a second stream! while one is open is Misuse; exhaustion or any error closes the stream; ordinary calls interleave freely), so no separate resource and no finalization-order hazard exists; read them with Row.col(name) / Row.get(i) and the strict, coercion-free DbValue accessors (as_int / as_real / as_text / as_blob / as_bool / is_null). Map a row to a typed value with the FromRow trait (T.from_row(row) -> Result<T, MapError>, FromJson's shape): a hand-written impl reads each field by name with the req_* / opt_* Row accessors (an absent column is MissingColumn, a wrong kind WrongType(column, expected, got); opt_* maps SQL NULL to None), and @derive(FromRow) generates exactly that field-name = column-name impl. Bind parameters through the ToValue trait and the v(x) sugar (int/i64Int, f64Real, stringText, bytesBlob, boolInt 0/1, Option<T>NULL). Failures are sys.DbError (Constraint(kind, msg), BindCount, BindName, IntOutOfRange, MultiStatement, Misuse, …). User-defined SQL functions: conn.create_function!(name, f) registers the PURE Hanki function f: (List<DbValue>) -> Result<DbValue, string> as a scalar SQL function callable from any SQL this connection runs — the parameter type's empty effect row IS the purity gate (an effectful closure is rejected at the call site, H0524), so every UDF is deterministic by construction and registers SQLITE_DETERMINISTIC (usable in indexes and generated columns) plus DIRECTONLY (not callable from untrusted-schema triggers/views). The function receives each call's arguments as one List<DbValue> (any arity); returning Err(message) fails the calling statement with that message as the SQL error, surfacing to the statement's caller as a DbError — and every fault inside the function (a step-budget exhaustion, a malformed result, an over-i64 int return) fails the statement the same way; no fault ever unwinds through the SQLite C frames, either tier. The connection keeps each registered function alive for its own lifetime (the conn is the closure's counted parent); re-registering a name replaces the SQL binding. Under --deterministic replay the enclosing statement remains ONE os-seam crossing — the UDF is pure compute inside it, so the replay log never sees it. Aggregates, window functions, and effectful UDFs are out of scope for v1.
supervisorRestart-policy bookkeeping for actor supervision - RestartPolicy(max_restarts, base_ms, max_ms) with `next(restarts) -> Restart(delay_ms) \GiveUp (exponential backoff, capped). Pure: no actor or clock dependency, so it is unit-tested in isolation. The supervision *wiring* (catch a child's SendFailed::Died or its on actor_died, sleep the backoff, respawn) stays per-actor worked code - see the timer and restart patterns in §15 and examples/v01/supervisedrestart`. Consecutive-restart counting only; restart-per-window policies await clock-read windows
genProperty-testing generator core (§20) - Gen<T>, a pure choice-sequence sampler (seeded ChoiceSource, no [random] effect) with map / bind / filter / list combinators; the integrated shrinker and arbitrary build on it
arbitraryArbitrary<T> trait + builtin instances (bool, the fixed-width ints, the arbitrary-precision int, bytes, string, decimal, rational, Option / List / Map / Entry) and @derive(Arbitrary); draws a typed test parameter as a Gen<T> (§20)
vcsVersion-control facts for the build-time manifest host (§21) - sha() -> string (commit sha) and describe() -> string (git describe --tags --always --dirty), effect [Vcs] (the block is effect Vcs), open-only. Injected by hanki check/build/run/test when evaluating a hanki.config.hk; a git-less source tarball reads cached values from a hanki.vcs.lock.config.hk (written by hanki build --refresh-manifest) rather than failing

Planned: regex, log, test. Each new module's tier is decided at module-creation time and recorded by its directory placement.

Serialization. Structured-data interchange is CBOR (RFC 8949), with CDDL (RFC 8610) for schemas, as the interoperability format; JSON (RFC 8259, the json module) is supported for compatibility with existing ecosystems, not as the primary format. The two are deliberately different shapes: CBOR is type-driven (@derive(Encode, Decode) gives any type a codec through the Serializer/Deserializer framework), whereas JSON is a standalone JsonValue tree you parse! and then pattern-match, walk with accessors, or decode into / encode from your own types through hand-written FromJson / ToJson impls - its arbitrary key order, single number type, and text escapes don't fit the positional framework, so it is intentionally not auto-derived. Types stay the single schema source: a type's @derive(Encode, Decode) fixes its CBOR encoding, and hanki cddl <file> emits the RFC 8610 CDDL describing that encoding - a publishable contract for non-Hanki consumers (a struct → a positional array, a sum → a choice of [tag, …] arrays, List/Option/Map[* T] / (T) / null / {* K => V}). Using Hanki's own value syntax as a serialization format ("Hanki-source-as-data") was considered and declined for wire use: it serves only Hanki-to-Hanki exchange and would force map and tuple literals into the language - the Hanki-native human-readable need is met by the configuration DSL (§21) instead. Binary payloads and codecs traffic in the bytes core type (§4).

Tier 3: contrib

Globally useful but not foundational and not part of the language - a TUI library, a money-handling library, and the like. Curated at a lower bar than extra and distributed with the toolchain, but realized as a bundled package, not baked code: a contrib library is an ordinary package the manager resolves from a pre-seeded local source shipped in the toolchain directory, so it never bloats the compiler binary or stdlib_baked.bin. Versioned with independent SemVer per library. Because the set is curated, contrib libraries earn short flat names (use money) - the curation is what authorizes the name. Pure Hanki only: like every package, a contrib library carries no @intrinsic and builds only on the effect faces core / extra expose.

Contrib resolution has landed, with no bespoke loader: it is the package manager's local-source path. A deps entry with no URL binding is contrib (§21), and the resolver maps it to the pre-seeded contrib/<name>/ source shipped beside the hanki executable (in a dev tree, the repo-root contrib/; the root is threaded, so tests point it at a scratch dir). The toolchain bundles exactly one version per contrib package, so resolution checks the declared constraint against that shipped version and errors naming it when unsatisfied; the lockfile pins the reserved contrib:<name>@<version> identity with a deterministic fnv1a: tree hash over the seeded source (it has no git), and --frozen hash-verifies that tree — never fetches — since the tier ships with the toolchain. Two packages EXIST in-tree and are now resolvable - contrib/sql/, an sqlx-flavored SQL toolkit over extra/sqlite, and contrib/tui/, a ratatui-flavored immediate-mode terminal UI toolkit over extra/terminal. The curation/inclusion policy stays open.

Tier 4: universe

The decentralized long tail: libraries spread across git repositories worldwide, over which the Hanki project has no governance say. A universe package's identity is its git URL (host/user/repo), so namespacing is inherent and name-squatting is structurally impossible - there is no flat global namespace to grab. The project offers at most a discovery index (a searchable directory of opt-in-listed repositories), never an identity or gatekeeping authority. For ergonomics a project may bind a project-local alias to a package's git URL in its own manifest (http = "git.sr.ht/~user/http"), so its source reads use http without minting a global short name; the alias is scoped to that project, keeping the global namespace alias-free and squatting-free. That one binding does double duty: it is both the use http import alias and the dependency's URL source — a deps entry names it by short name (Dep(source = "http", …)), and the presence of the binding is what marks that dep as universe rather than contrib (§21). Independent SemVer; pure Hanki only, exactly as contrib. universe is a provisional working name (final name deferred); unlike the curated tiers it is not a governed set, only "everything else, discoverable."

The boundary, locked

The decision locks these commitments - the parts a future change must not quietly break (the descriptive table above is not itself the contract):

  1. The native seam stays core-only. No package - curated contrib or open universe - may add native code, so the entire trusted native surface remains exactly the @intrinsic defs in core even with a full third-party ecosystem. *(Enforced at check time: an @intrinsic declared outside the core stdlib - whether in extra or in user/package code - is a compile error at the def site, not a deferred runtime failure.)*
  2. Resolution and trust descend by tier: baked core / extra (never re-fetched) → bundled contrib → git-fetched universe. The manager will resolve in that order, with trust diminishing along it.
  3. Universe identity is its git URL, so name-squatting is structurally impossible and the index stays discovery-only; short flat names are reserved for the curated tiers (core, extra, contrib), and universe is aliased only project-locally.

Mechanism - directory hierarchy

A module's tier is derived from the directory its source lives in; there is no per-file annotation.

Compiler-enforced invariant: a core stdlib module cannot use or open an extra stdlib module. A violation surfaces as a check-phase diagnostic naming both modules and their tiers. The rule is internal to the stdlib - user modules are untiered and can freely use or open either tier.

Native-seam rule (enforced). All native code is confined to core: an @intrinsic def is allowed only in a core module - never in extra, and never in user or package code (a check-phase diagnostic at the def site in each case). The OS-capability primitives live in one core module, sys; the extra capabilities (io, fs, process) are pure-Hanki faces that declare their effect and delegate to a sys primitive - io.print! calls sys.stdout_write!, fs.read! calls sys.file_read!. So the entire trusted native surface is exactly the @intrinsic defs in core, the same closure The boundary, locked (invariant 1) holds against user and package code. (module keeps its own intrinsics as core substrate; it is core, not a face.)

Promoting an extra module to core (or demoting a core module to extra) takes a fresh, explicitly recorded decision that quotes the existing carve-up.

Combinators and method-style surface

Pure generic combinators have short names (map, filter, fold); effectful variants get ! suffix (map!, filter!, fold!). The effectful variant accepts a callback with any effects; those effects bubble up to the call site.

Generic containers (Option<T>, List<T>) and the built-in string expose their stdlib helpers as inherent methods, not free functions. Call sites use receiver-first method-chain form:

open list
open option

def surface(xs: List<i32>, x: i32) -> ()
  Some(7i32).unwrap_or(0i32)
  xs.append(x).length
  "foo".concat("bar")
  ()
end

The associated function for empty containers also lives on the type: List<i32>.empty().

The sys terminal seam. Low-level terminal control lives on the same native seam: sys.stdin_is_tty!() / sys.stdout_is_tty!() (-> bool [io], false when piped or redirected), sys.term_size!() -> Result<TermSize, TermError> [io] (TermSize { columns, rows }, int fields per the index-surface convention; NotATty when stdout is not a terminal), sys.term_set_raw!(enabled) -> Result<(), TermError> [io] (character-at-a-time, no echo; idempotent in both directions; the FIRST enable saves the original terminal state), and sys.term_restore_write!(s) [io] (register bytes written to stdout at process exit - single slot, overwrite semantics, empty clears; a full-screen face registers alt-screen-leave + cursor-show here). Its input sibling is sys.stdin_read!(max: int, timeout_ms: i32) -> StdinRead [io] - the timed raw read behind a terminal event loop ("give me input bytes or time out"): Bytes(bytes) / TimedOut / Eof / Failed(message), one OS read of up to max bytes (clamped >= 1, no accumulation), timeout_ms < 0 blocking indefinitely and 0 polling immediately; it reads the raw descriptor directly (no buffering layer, so interleaving with stdin_read_line! cannot swallow bytes), a parked read is cancellable by actor.shutdown! (S15), and the timeout is real wall time outside the --deterministic virtual clock (terminal input is inherently non-replayable, like the read itself). The runtime guarantees the exit restore: the first raw-mode save (or restore-bytes registration) installs a C-runtime exit hook that puts the saved terminal state back and writes the registered bytes on every exit path of both tiers - a normal main! return and the fatal 254/253/252 exits alike - so a crashed program never strands the user's terminal. TermError is NotATty / Other(message). The ergonomic escape-sequence/key-input face over this seam is a staged extra library.

How stdlib functions are implemented. Most stdlib items are written in Hanki itself; their sources are embedded into the compiler at build time. Items that must bottom out in host code - sys.stdout_write!, string.concat, Display<i32>::to_string, and the like - are declared (in a core module only; see the native-seam rule above) with the @intrinsic annotation:

@intrinsic def stdout_write!(s: string) -> () [io]
end

@intrinsic is a top-of-item attribute (sibling of @derive). The compiler ignores the def's body and emits a single host-call thunk that dispatches by name into a runtime-registered Rust closure. @intrinsic is reserved for the stdlib - and within the stdlib, for core only - user code cannot declare its own intrinsics; the call site looks like any other Hanki call. The signature is the entire contract, so the body may be empty.

io.read_line! returns a sum, not a bare string. Empty input on a still-open stream is a legitimate value (the user pressed Enter) and must be distinguishable from end-of-stream (Ctrl+D, closed pipe). To keep both cases first-class, read_line! returns io.Line:

type Line
  Line(string)
  Eof
end

@intrinsic def read_line!() -> Line [io]

Read loops pattern-match and break on Eof. With use io the call site and variants stay qualified; with open io they read bare:

use io

def handle!(line: string) -> () [io]
  io.print!(line)
end

def repl!() -> () [io]
  loop
    io.print!("> ")
    match io.read_line!()
      io.Eof        -> break
      io.Line(line) -> handle!(line)
    end
  end
end
open io

def handle!(line: string) -> () [io]
  print!("got: #{line}\n")
end

def repl!() -> () [io]
  loop  print!("> ")
    match read_line!()
      Eof        -> break
      Line(line) -> handle!(line)
    end
  end
end

The dedicated sum is favoured over Option<string> for self-documenting variant names (Eof is clearer than None for the EOF case) and because it leaves room for future variants (Interrupted, Closed) without breaking the API. Reading past Eof is the caller's responsibility - the scheduler keeps returning Eof, but a sensible loop breaks on the first one.

Compiler reflection (experimental)

The compiler core module exposes the front end's outputs to a running Hanki program, so the off-path developer tools (fmt, doc, lint, effects, …) can be written in Hanki over a faithful parse/check model instead of in Rust. It runs the in-process compiler over a source string and marshals the result into ordinary Hanki records:

def api_version() -> int                                 # reflection schema revision (13)
@intrinsic def parse(src: string) -> Result<Ast, ParseError>              # shallow item headers
@intrinsic def type_decls(src: string) -> Result<List<Decl>, ParseError>  # deep structured decls
@intrinsic def imports(src: string) -> Result<List<Import>, ParseError>   # use/open directives
@intrinsic def diagnostics(src: string) -> List<Diagnostic>
@intrinsic def effect_surface(src: string) -> EffectSurface
@intrinsic def doc_items(src: string) -> List<DocItem>
@intrinsic def stdlib_modules() -> List<StdModule>       # the embedded stdlib (name, source) set
@intrinsic def bundled_card() -> string                  # embedded HANKI-CARD.md
@intrinsic def bundled_reference() -> string             # embedded HANKI.md
@intrinsic def bundled_skills() -> List<BundledSkill>    # embedded agent skills (name, summary, body)

A reflected Decl reports is_meta for a top-level meta def (the field is is_meta, not meta, which is a keyword), since the modifier shows in the rendered signature. A meta constant's reflected body carries its value twice: value_source is the verbatim slice, and value_rendered is the same expression printed back from the parse. A comparison wants the rendered one, since two spellings differing only in layout render identically and a reformat is not a value change; a tool showing the user what they wrote wants the slice. Experimental. The surface is destined for the 1.0 contract but stays unstable until the tool ports validate its shape - treat the records as provisional, and check api_version() for a revision change. The intrinsics run on both tiers: on the bytecode tier (hanki run / test / REPL) directly, and in an AOT-compiled binary that calls one by linking the embedded front end - the same statically-linked compile-pipeline + VM a load!-using build links (§14) - so a reflected value is byte-identical across tiers. The cost is paid only where used: a hanki build of a program that calls no reflection intrinsic keeps the lean, LLVM-free AOT baseline and pays nothing, while one that does pulls the front end into the binary's size/trust surface (the same tradeoff load! makes). Comptime reflection is a follow-up.