hanki

17. The standard library at a glance

Client networking is deadline-bounded by default: net.connect!, tls.connect!, and the one-shot HTTP helpers use one absolute 60-second budget across resolution, connection, TLS negotiation, request write, and response read. The net and tls modules expose connect_with_deadline!; full TCP/TLS streams and http.Client expose set_deadline!. Expiry is the typed TimedOut case at each face, including the transport-independent HttpError.TimedOut. See §4 for reset and immediate-poll semantics.

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

A third, stricter line sits inside the baked half: the native @intrinsic seam is confined to core alone, and neither extra nor any package may cross it. The package manager covers all three tiers, with transitive universe-dependency resolution, contrib-tier resolution from the toolchain's seeded source, local path deps, the resolve, add and publish CLI verbs, and the effect-growth gate on top (§21). Only the hosted registry is 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; a discovery index onlynoits git URL (host/user/repo); projects may bind a local alias

The baked tiers, core and extra, are the language's own curated surface, core locked and extra evolving. Their module names are flat, with no path prefix at import sites, and the directory their source sits in names the tier. A flat name is a claim on the one namespace user code shares (§14): a baked log means no project may have a log.hk, and adding a baked module is a source-breaking change, announced with the name it claims. That is the cost of extra being the evolving tier, and the reason its name list is pinned in a test in place of growing with whoever adds a file. They compile into one artifact, and core and extra share a single version, the toolchain and language version. The tier difference is the bump policy on it: a breaking change is a major bump for core and a minor bump for extra, one notch looser, while additions are minor and fixes patch. The package tiers, contrib and universe, are not part of the language. They never enter the compiler binary or its baked archive, they have their own independent SemVer, being distributed separately so that versioning granularity tracks the mechanism, and the native seam is closed to them, statically enforced; see The boundary, locked. universe is a provisional name for the decentralized tier, and the final name is deferred.

stdlib_baked.bin is one fingerprinted, framed artifact with three independently encoded payload sections: the semantic type index, lowered bytecode seed and qualified stdlib AST. Its fixed-width header records the HANKIBAK magic, the compiler's wire-schema revision, the stdlib-source fingerprint and the section count; the ordered section table records each payload's kind, absolute offset and byte length. The whole outer frame is validated before any section is exposed. The index section has its own HANKIIDX frame: each semantic side table is a sorted array of fixed-width Span or NodeId keys and 32-bit offsets into separately postcard-encoded values. A lookup binary-searches those borrowed keys and decodes and caches only the value it reaches; misses decode nothing. The bytecode and qualified-AST sections are postcard-decoded and cached only on the first accessor that needs them. A stale or structurally invalid outer frame makes every accessor take the full-source fallback. A malformed inner index frame or postcard failure inside an outer frame already accepted as the compiler's committed include_bytes! artifact is corruption and a hard invariant failure, never permission to mix one baked payload with freshly qualified or lowered source.

Tier 1: core

A locked surface. A breaking change in any of these cascades into every program, and promotion or demotion takes a decision ticket. Opened by convention.

The general index surface is int. Every persistent-collection length, byte and element offset, and count returns and accepts the lowercase scripting-default int: List and bytes length/get, slices, find, split pieces, BytesReader.take!, position! and remaining!, serializer count headers, and each_with_index!'s index. 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, and reads return None. The exception is the fixed-width numeric Array: its length, get, get_or and set use i64; LLVM then sees a native induction variable and can vectorize the contiguous loop, while its allocation can never approach the i64 limit. Crossing either surface to a wire width goes through the conversion bridge (§3). The int representation has a small-int fast path, but retaining a possible boxed edge in native loop IR is the cost Array's fixed index avoids.

ModuleWhat it gives you
optionOption<T>, the absence channel every API rests on: unwrap_or/map/and_then/or_else, the effect-polymorphic twins map!/and_then!/or_else!, each taking a [e] callback and performed only on the branch the pure form would run, plus the partial or_crash!(msg)
resultResult<T, E>, spelled Ok(T) | Err(E): failure as a value for pure code (§7). unwrap_or/map/map_err/and_then/or_else, the effect-polymorphic twins map!/map_err!/and_then!/or_else!, each taking a [e] callback and performed only on the branch the pure form would run, plus the partial or_crash!(msg)
from_stringParseError { input, reason } and the plain FromString trait, def parse(s: string) -> Result<Self, ParseError>: the seam every text-to-value read shares, through which generic code can ask for a value it cannot name. A <T: FromString> body calls T.parse(s) and dispatches through the bound; parse_all(texts) is the shipped example. The trait needs no type parameter: Self is the parsed target, and a parameter would have to be supplied at every bound without carrying new information. ParseError reports the input beside the reason, a caller that threaded a parse through a pipeline no longer holding the text. Display renders cannot parse '<input>': <reason>, and it is Eq. All 13 numeric types implement it (§3), which is where parse sits for them; there is no inherent numeric parse beside it
intoThe explicit conversion trait Into<To>, with @transform def into(self) -> To. Self is the source and To the target; a bound such as S: Into<HttpError> fixes the target and permits s.into() in generic code. Impl coherence applies per target slot. One source may have several conversions, while a bare concrete call is ambiguous if several candidates have no value argument to select them. There is one direction, no blanket twin and no derive. http supplies the first stdlib impl, sys.NetError into HttpError::Net
listList<T>, an immutable persistent list. Construction and shape: empty, repeat (List.repeat(x, n), n copies of x, and the empty list for a count of zero or less, the string.repeat counterpart a blank grid of identical cells wants), append, prepend, concat, slice (the sub-list [lo, hi), clamped), take and drop (the first n and all but the first n, clamped), take_last and drop_last (the last n and all but the last n, clamped), take_while and drop_while (the longest prefix satisfying a predicate, and the rest after it), update (replace one index), length, empty?, get, first and last (the two ends as Option<T>), reverse, intersperse (place sep between adjacent elements; a list of 0 or 1 elements is unchanged), and chunk and windows (consecutive n-sized pieces with a shorter final one, against every contiguous n-length sublist sliding by one; both give List<List<T>>, and a non-positive n, or a windows n past the length, gives empty). Search and test: 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? and all? (short-circuiting predicate tests), and count (how many elements satisfy a predicate, as int, a fold that builds no filtered list). Traversal: map, fold, fold_right (the right-associative fold, where f takes the element then the accumulator and elements are visited right to left), scan (the inclusive running fold, each prefix's accumulator as a List<U> of self's length), filter, partition (the elements satisfying a predicate and those that do not, as Pair<List<T>, List<T>>, filter and its complement in a single indexed pass with n predicate calls and input order preserved within each), enumerate (each element paired with its index, as List<Pair<int, T>>, the pure indexed view, each_with_index! being an action that cannot take an index into a pure chain), flat_map, zip (two lists paired element-wise as List<Pair<T, U>>, stopping at the shorter) and zip_with (two lists combined element-wise with f, stopping at the shorter, zip without the Pair allocation). Effectful traversal: each!, each_with_index!, and the effect-polymorphic map!/filter!/fold!/find!/find_map!/any!/all!/flat_map!/partition!/take_while!/count!, each the ! twin of the pure form above and taking a [e] callback, which spares an effectful traversal a hand-rolled index loop. find!, find_map!, any!, all! and take_while! short-circuit, and the skipped elements' effects never happen. Fallible traversal: try_fold and try_each, and try_fold! and try_each!. The step returns Result, and the first Err ends the traversal as the result. The pure pair exists because the fold plus and_then composition answers the same value and cannot stop, and a long list that fails early still ran the step once per element; here later elements go unvisited and their effects never run. try_each! is the unit-accumulator majority case: perform each element's effect and stop at the first failure. Ordering and equality: min_by and max_by (the extreme by a (T, T) -> bool strict-before comparator, as Option<T>, bound-free), sort (a stable merge sort, T: Ord), sort_by (stable, ordered by a (T, T) -> bool strict-before comparator, bound-free), min and max (the Option<T> extreme, T: Ord), contains? and index_of (T: Eq, a membership test and the index of the first element == x as Option<int>), dedup (T: Eq, collapsing each run of consecutive equal elements to one, non-adjacent duplicates kept), unique (T: Eq, dropping all later duplicates and keeping each element's first occurrence, O(n²) equality comparisons plus indexed traversal, the non-adjacent complement to dedup), and sum and product (T: Numeric, a fold from T.zero() and T.one() through add and mul, and the empty list gives the identity)
arrayArray<T>, a fixed-size contiguous unboxed numeric value for f64, f32, i64 and i32: filled(n, x), from_list(xs), length -> i64, total get(i: i64) -> Option<T> and get_or, FBIP set (unique buffer reused, shared buffer copied, out of range unchanged), and explicit copy_resize(n, fill). There is no push. Arrays are sendable, comptime-foldable, rendered as Array[...], and budgeted by payload bytes (§4, §12)
mapMap<K, V>, an immutable Hash-keyed map backed by a hash array mapped trie (HAMT): O(log n) get, insert and remove, persistent through structural sharing. empty/insert/get/remove/contains_key?/length/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, and the operator agrees with m1.eq?(m2). Keys need Hash, and Hash has Eq as its supertrait, which leaves == working. The keying hash is an unkeyed FNV-1a, byte-identical on both tiers and under --deterministic replay, and therefore attacker-predictable. A program that builds a Map or Set from untrusted keys is hash-floodable, keys crafted to collide on the full 64-bit hash piling into one HAMT collision bucket and driving inserts quadratic, and it must bound its own untrusted-key count, as the stdlib's own untrusted-key surfaces do: json.parse caps object members, and http caps headers. Keyed hashing was rejected to preserve cross-tier determinism and byte-identical replay, and the residual is an accepted, documented trade. Iteration (keys, values, to_list, fold, each!, Display) is in hash order, unspecified and unstable, and never insertion order. Encode remains canonical, entries sorted by key bytes, and ==-equal maps encode identically whatever their build order
setSet<T>, an immutable Hash-element set over the Map HAMT, persistent through structural sharing: empty/from_list/insert/contains?/remove/length/empty?/to_list/union/intersection/difference, plus Display, order-independent Eq, and Hash, which lets a Set nest as a key or element. Elements need Hash, and iteration (to_list, Display) is in hash order, unspecified and never insertion order
rangeRange, the half-open [lo, hi) integer range built by Range.new(lo, hi): the iteration primitive, with List-like combinators each! (effectful, running as a loop and adding no stack depth for a large range), map giving a List<U>, fold, length and to_list. Pair it with int.upto for the inclusive form and int.times! for count-only iteration (§3)
pairPair<A, B>, the generic two-field product with fields first and second: what other languages spell as a tuple, written as a named struct. Hanki has no tuple type; structs are the product type. It is List.zip's element type. Construct it call-style, Pair(first=1, second="a"), and annotate the binding (p: Pair<int, string> = …), a generic constructor call not inferring its type parameters from the arguments. Eq and Display synthesize on demand like any struct's, == field-wise and rendering the structural Pair(first=1, second=a), left to synthesis 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), the two are distinct types, and each derives its own
secretSecret<T>, material the renderers, the derives and the mailbox refuse (§4): Secret.hide(v) wraps, s.reveal() reads it back, and no other route reaches it. It has no Display, Encode, Eq or Hash, and an aggregate holding one cannot derive them either. dbg! and the crash dump print <redacted> on both tiers, and a handler parameter, return or throws type holding one is H0635. A program may define its own type named Secret, as it may its own Pair: every refusal keys on the module-qualified head, the two are distinct types, and yours derives normally
strstring ops. There is no concat; text is assembled by interpolation, join, or a StringBuilder. length, starts_with?, ends_with?, strip_prefix and strip_suffix (drop a matching prefix or suffix as Option<string>, None otherwise, the empty affix stripping nothing), contains?, empty?, slice(start, stop), slice_from, to_bytes, find (an Option<int> byte offset), split (non-overlapping pieces as a List<string>, empty pieces kept; an empty separator returns [self]; linear scan and copied result bytes), split_whitespace (a List<string> of the words, splitting on runs of the same ASCII whitespace set trim strips, with empty pieces already dropped), lines (splits at LF, strips one CR immediately before LF, preserves bare CR, and omits one trailing empty fragment; empty input gives no lines), join (", ".join(parts), the separator being the receiver), repeat(n), pad_start and pad_end (to a byte width with a repeated fill), trim, trim_start and trim_end (space, tab, LF and CR only; linear boundary scans and copied results), replace (non-overlapping matches, empty pattern leaves the input unchanged; linear in input, pattern and output bytes), to_ascii_uppercase and to_ascii_lowercase (an ASCII-only fold), eq_ignore_ascii_case? (an ASCII-case-insensitive compare), and hash_u64
bytesbytes ops: length, copying slice(start, stop), zero-copy view(start, stop), join(parts) (the pieces fused in a single pass, self the separator; there is no concat, removed for the quadratic chain it invited), get(i) -> Option<u8>, to_string() -> Result<string, Utf8Error>, empty?, hash_u64, and the scan combinators any? / all? / position / find / fold / each! / try_fold! / try_each!, which take List's names and are Hanki over get and length; the try_ twins stop at the first Err. Views flatten onto one owner, an empty view retains none, equality and observers see only the visible range, and actor transport detaches a subview. There is no map or filter, BytesBuilder owning construction. Eq and Hash are byte-wise. Utf8Error, the to_string error, is Eq, and a Result<string, Utf8Error> can be ==-compared
bytes_builderThe BytesBuilder resource: new!(), push!, extend!, reserve!, spare_capacity!, length!, fill!, set!, big- and little-endian push_{be,le}_{u16,u32,u64,f32,f64}!, and non-consuming finish!() -> bytes. It is the O(n) way to assemble and patch a bytes (§4). Empty-effect-row actions
string_builderThe StringBuilder resource: new!(), push!(s: string), push_display!(v), and finish!() -> string, which is non-consuming and total. It is the O(n) way to accumulate a string piece by piece (§4). Empty-effect-row actions
bytes_readerThe BytesReader resource: new!(input: bytes), copying take!(n: int) -> bytes and zero-copy take_view!(n: int) -> bytes (both clamped and advancing); take_be_u16!/u32!/u64! with their take_le_* mates, plus the f32/f64 big- and little-endian reads (all Option<T>, None on a short buffer with no advance); peek!() -> Option<u8>, advancing take_u8!(), remaining!() -> int and position!() -> int. It is the read cursor mirroring bytes_builder (§4). Empty-effect-row actions
fileFile resource methods: read_all!() -> Result<bytes, FsFailure> and close!(). Open a handle with fs.open_file! (§4). A handle op's FsFailure reports an empty path, the descriptor no longer recording what it was opened from
socketTcpStream / TcpListener and UnixStream / UnixListener resource methods: read!(max), write!(data), split!(), accept!() and close!(); full TCP streams also provide set_deadline!(timeout_ms). split! invalidates a full stream and returns directional TcpReadHalf / TcpWriteHalf or UnixReadHalf / UnixWriteHalf resources; each half has only its direction and close!, with the half-close semantics in §4. Open TCP through net.connect! / net.listen!, and pathname Unix-domain sockets through net.connect_unix! / net.listen_unix! (§4)
streamFour independent capability contracts. ReadStream supplies read!(max) and WriteStream supplies write!(data), both fixed at sys.NetError and [net] for full sockets and their directional halves. The duplex Stream supplies both plus close!(), declares associated type Error, and gives each method a variable row [e]. DeadlineStream separately supplies set_deadline!(timeout_ms) for full TCP and TLS client streams, without making custom streams invent a clock. Full TcpStream, UnixStream and TlsStream values implement all three; split TCP, Unix and TLS read/write resources implement only their direction. TCP and Unix bind Stream.Error = sys.NetError; TLS binds sys.TlsFailure; another implementation may choose its own error and row. Directional framing takes the narrow bound it needs, while duplex code writes <S: Stream[e]>, names failures as S.Error, and pays the chosen impl's row. Stream is separately implemented and not a formal supertrait composition, which Hanki cannot express (§10). close! returns () and no Result, which is what the transports do
displayThe Display<T> trait: #{…} interpolation and .to_string() desugar through it. There are builtin impls for every integer type (int and i8/i16/i32/i64, u8/u16/u32/u64), decimal, rational, f64, bool and string, and a struct or sum is synthesised on demand, with no @derive needed. The container types have hand-written impls: List<T> renders as [e1, e2, e3], the empty list as []; Map<K, V> as {k: v, …}; and Option<T> as Some(inner) or None, each element shown through its own Display. List and Map are opaque intrinsic types and need the explicit impl in place of the variantless auto-derived one. Option needs it for the other reason: synthesis is demanded only at the outermost type, an Option reached as a payload is never itself demanded, and the impl has to exist already
eq?The Eq<T> trait. == and != always dispatch through a type's Eq impl, hand-written, which lets a custom eq? such as Map's order-independent equality drive the operator, or synthesised on demand, the same auto-derivation that needs no @derive, structural field-wise and variant-wise, for any struct or sum whose parts are all Eq. There is no structural runtime fallback, and the two tiers cannot disagree. A type with a part that has no Eq, a resource, an actor reference, a function value, or an f64 or an aggregate containing one, makes == and != on it a compile error naming the part, which also puts the nested-float bit-compare divergence (§22) out of reach. An opaque ActorRef<T> handle (§15) is the one builtin exception: it compares by identity, the same actor, by id on both tiers. On a generic param, == and != require a T: Eq bound and dispatch through it (§10), and without the bound they are a compile error. Builtin Eq covers every integer type (int and the fixed-width ints), the exact tier's decimal and rational, matching the total order §3 gives them, and a struct holding a price or an exact ratio therefore derives Eq and Hash and keys a Map, which it could not while only the operator worked; plus bool, string and bytes. f64 and f32 are excluded, IEEE NaN != NaN breaking reflexivity, and with no structural fallback a struct holding an f64 has no Eq and 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 or sub. Non-numeric + and - dispatch through them (§3, §10): the operand pair selects the impl, impl Add<Period> homogeneous through the default and 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 has the flagship impls, now!() + 2.days, instant - instant, date + 2.months, 1.years + 2.months, and user types join by impl. Future Mul, Div and Neg land here too
numericThe Numeric trait: the static identities zero() and one() plus add and mul, each impl's body being the type's native + or *, implemented for all 13 numeric types (int, i8 to i64, u8 to u64, f32, f64, decimal, rational). It is the bound for generic numeric code: List.sum and List.product bound <T: Numeric> and reach the identities as T.zero() and T.one(), static-through-bound (§10). It is a plain trait because the operator traits exclude a builtin-primitive Self (H0573), and the numeric fold needs no target-dependent output slot. Fixed-width add and mul wrap as the operators do, floats remain IEEE, and the lowercase tier's in-band inf and undefined sentinels propagate unchanged
ordThe Ord<T> trait: def cmp(self, other: Self) -> Ordering, with Less, Equal and Greater. It is the single total-order primitive List.sort and the canonical Map encoding build on. Ord also provides default min, max and clamp, defined once over cmp, and every Ord type gets them without a per-type copy; clamp(low, high) limits to the closed range and answers high where the bounds cross. <, <=, > and >= dispatch through it the way == and != dispatch through Eq: a numeric operand uses the type's direct relational opcode, which leaves every numeric tier 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, < being Less, <= being 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, and the fix is impl Ord<T> or a comparison through .cmp(…). On a generic param, <, <=, > and >= require a T: Ord bound and dispatch through it (§10), and 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 with -inf < finite < +inf < undefined, which leaves them sortable, plus bool, string and bytes. f64 is excluded, IEEE NaN breaking a total order, though a bare f64 < still works through the IEEE float opcode
hashThe Hash<T> trait: prop hash(self) -> u64, a bare-dot read (§10), with supertrait Hash: Eq (§10), and an impl must satisfy a.eq?(b) => a.hash == b.hash. There are builtin impls for every integer type (int and i8/i16/i32/i64, u8/u16/u32/u64), the exact tier's decimal and rational, bool, string and bytes; f64 and f32 are excluded, having no Eq on IEEE grounds. decimal's impl hashes a scale-free spelling and not the display text: 1.5 and 1.50 are one value at two scales, a literal normalizes while parse preserves the written scale, both reach a program, and hashing the text would give one value two hashes and break the supertrait's promise. rational needs no such step, reducing on construction, and its p/q text is already the value's one spelling. The trait surface is frozen ahead of 1.0, and the HAMT-backed Map (§17) consumes it: a Map key must be Hash
testAssertion conveniences for test blocks. The test keyword doubles as this module's head, on the actor precedent, and test.within_epsilon? resolves like any core-qualified name while the keyword goes on introducing test blocks. within_epsilon?(a, b, epsilon) and within_epsilon32? are the absolute-distance float assertions; f64 and f32 have no Eq, assert!(a == b) on floats is a compile error, and a float assertion states its tolerance. within_ulps?(a, b, ulps) is the relative one, exact bit arithmetic over to_bits, with both tiers agreeing, 0.0 and -0.0 zero ULPs apart, and non-finite operands answering false in all three. diff(expected, actual) renders a readable delta, both values through Display plus the byte offset where the renderings first part, for assert!-adjacent interpolation; it is no matcher DSL. Recorded is the capturing side of a provide-based test double for user effects, with empty() and record(call) threading a transcript. The built-in capabilities io, net and fs are not provider-based and cannot be intercepted, which is the intent: a test that needs to observe them is an integration test and should say so
serializerThe Serializer trait: the format-agnostic write events an Encode walk emits, put_u8! through put_bytes!, put_none! and put_some!, begin_seq!/begin_map!/begin_struct!/begin_variant!, plus the exact-tier put_int!/put_decimal!/put_rational!/put_f32!. A concrete impl translates them to one wire format, and one @derive(Encode) serves every format. The four exact-tier events have default bodies written over the other twelve, and a Serializer impl predating them goes on compiling and gains a correct encoding at no cost; a self-describing format overrides them with its own numeric forms. put_int! is the one ground case, put_decimal! and put_rational! reducing to it through the exact decomposition (§3), which is also where the in-band sentinels are handled. It writes a class byte, 0 finite, 1 inf, 2 -inf, 3 undefined, and then, when finite, the decimal digits as a string: int is unbounded, pure Hanki has no byte view of a bignum, and the sentinels have no digit spelling at all, int.parse refusing "inf". A digits-only encoding would lose the values division produces. BinarySerializer, over a BytesBuilder, is the built-in fixed-width format and takes those four defaults unchanged, and the compatibility path is the one the built-in format exercises
deserializerThe Deserializer trait: the read inverse, take_u8! and its siblings, take_is_some!, take_seq!/take_map!, take_struct!(fields) and take_variant!(payload_counts), plus the exact-tier take_int!/take_decimal!/take_rational!/take_f32!, each returning Result<_, DecodeError>. A derive supplies the exact struct field count and the ordered list of each sum variant's payload count; a framed format checks them and an unframed format may ignore them. The four exact-tier reads have default bodies mirroring the Serializer side, and a class byte outside 0 ..= 3, or digits int.parse rejects, is BadTag. DecodeError is Truncated(int) / BadTag(u8, int) / BadUtf8(int) / TooDeep(int) / InvalidValue(validation.ValidationError, int) / InvalidByteLimit(int, int) / ByteLimitExceeded(int, int, int) / InvalidBodyHeader(int). BinaryDeserializer reads BinarySerializer's format over a BytesReader cursor, and a short read is Err and never a crash. A decoded List or Map bounds its element count against the unread input through remaining!, and a count exceeding the bytes left is rejected in place of looped, which stops a crafted huge count amplifying a few bytes into a giant collection. Standard containers and derived decoders enforce the recursion-depth guard in §16; custom recursive implementations must apply it to their own calls. Count guards are local to each container and impose no cumulative element or allocation budget
encodeThe Encode trait: def encode!<S: Serializer>(self, s: S), the structural serializer (§16), with @derive(Encode) for any struct or sum whose fields are Encode. It writes format-agnostic events to a Serializer, and the built-in BinarySerializer gives a compact fixed-width layout, with Map canonical, its entries sorted by key bytes, which makes ==-equal maps encode identically. There are builtin impls for the fixed-width integers, f64, bool, string, bytes, List<T>, Option<T>, Map<K, V>, and the exact tier int, decimal, rational and f32. Those four were the H0405 hole: both language defaults are among them, an unsuffixed integer literal being int and an unsuffixed float being decimal, and the first struct a newcomer derived on was likely to be refused
decodeThe Decode trait: def decode!<D: Deserializer>(d: D, depth: int) -> Result<Self, DecodeError>, static and with no self, the inverse of Encode, with @derive(Decode). It reads from a Deserializer cursor, BinaryDeserializer over a BytesReader. DecodeError is Truncated(int) / BadTag(u8, int) / BadUtf8(int) / TooDeep(int) / InvalidValue(validation.ValidationError, int) / InvalidByteLimit(int, int) / ByteLimitExceeded(int, int, int) / InvalidBodyHeader(int), and malformed input yields Err and never a crash. Standard containers and derived bodies reject depth above 128 with TooDeep before their frame or fields, then recurse at depth + 1; custom recursive implementations must preserve that guard. from_bytes seeds it at 0
codecto_bytes<T: Encode>(v) -> bytes and from_bytes<T: Decode>(input) -> Result<T, DecodeError>: the @encapsulated, pure-callable serialization entry points over Encode and Decode, in the built-in BinarySerializer and BinaryDeserializer format (§16), plus encoded_len. Callable from ordinary and pure code, and foldable at comptime (§22)
actorActorRef.id(self) -> ActorId for per-instance supervision; ActorId.name; SendFailed typed throw (variants MailboxFull, Died(ActorId, DeathCause), Timeout), which every send picks up; the actor.await_timeout!(f, ms) bounded-await form; and stop_all!(targets: List<ActorRef<T>>, kill_after: i32) -> List<Shutdown>, which signals every target and then collects every Shutdown outcome under one deadline, the completion boundary a request-scoped operation returns through (§15)
moduleDynamic source-module loading: m: Module<T> = module.load!(path), unload! and reload! (§14). It has its own @intrinsic native seam, like actor, which is what makes it core and no pure extra face. Runtime-coupled and niche
configComptime config loading. CONF: T = config.load(path) reads a .config.hk file at build time, type-checks and evaluates it, against the stdlib plus the module whose config.load this is, which lets the config open that module and construct its types. That module is the entry whenever the entry contains the call, and the sibling's own module when a sibling's meta-const does the loading. It then bakes the trailing value into a const, the binding's annotation pinning T. It is pure and comptime-only, with no effect row, and a failure is a compile error. The path resolves relative to the entry file and must stay within the enclosing project's root, the nearest ancestor holding hanki.config.hk, or the entry's own directory where nothing encloses it. An entry at src/main.hk therefore reaches a config beside the manifest, while an absolute path, one that climbs past the root with .., and one that resolves through a symlink planted inside the project are all rejected. The same confinement applies to the manifest's own entry, and a build cannot be steered into reading an arbitrary file on the build machine. A config value of any form, a primitive, a string, or a struct, list or 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 values that cannot bake are the ones with no constant form: a closure, a bytes buffer, a comptime resource, and an in-band ±inf or undefined. Runtime reading is the other half, and it is a parser and no evaluator. config.parse(src) -> Result<Map<string, ConfigValue>, ConfigError> reads the flat declarative subset of the same format out of a string a program obtained at run time: NAME = value settings over a string, a whole number, true or false, or a nestable and possibly multi-line list of those, plus # comments. It evaluates nothing. A .config.hk that arrived from a peer or a user's home is data, and running it would be arbitrary code execution. ConfigValue is the four-variant sum Str/Int/Bool/Items with as_string/as_int/as_bool/as_items accessors, and ConfigError reports the 1-based line and a reason. It is @encapsulated, a cursor walk inside and externally pure. Everything else the format allows is refused and never reinterpreted: open, def, a struct construction (Dep(source = ...)), a trailing expression, #{...} interpolation, a fractional or width-suffixed number, and the typed NAME: TYPE = value binding form of §21. Anything parse accepts, load accepts and agrees with, and cli::config_subset_parity checks the two against each other over every tracked .config.hk. Which names are known is the caller's question and no part of the parser's: it reports every setting it finds
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 and doc_items; the two version accessors a generated file should stamp itself with, toolchain_version(), what hanki version prints, saying which hanki wrote the file, and api_version(), the reflection schema revision, saying whether the file's form is still current, which answer different questions and are both plain constants and no seams; stdlib_modules, the embedded stdlib sources; project_modules!, a project's own modules with reachability and source, and project_diagnostics!, a module checked in its package with a supplied source standing in for it, the two seams that read the filesystem and hence [fs_read]; bundled_card and bundled_reference, the embedded HANKI-CARD.md and HANKI.md; and bundled_skills, the embedded agent-agnostic skills hanki new ships. It is the seam the off-path dev tools (fmt, doc, lint, effects, api-diff, cddl, new) reflect through. The surface is unstable until the tool ports validate it. It works on both tiers, and 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 and ExportExtras, mirroring the wire format. ManifestInfo.required_host_symbols is manifest v7's lexicographically sorted, deduplicated list of Hanki ABI symbols the module imports from its host. A v7 payload must encode the field even when the set is empty; only v5/v6 artifacts, which predate it, default to an empty list. A native .so leaves those Hanki ABI references undefined: it owns its lowered Hanki code, manifest and rifts, never a private Hanki runtime, and resolves the one process runtime from the exact hanki_* allowlist derived from the embedding executable's runtime archive. Host rift shims and user functions named hanki_* are not runtime-owned and remain outside .dynsym; this prevents them from preempting a module's own definitions. Before dlopen, the native loader resolves every listed symbol from that live host; a miss raises module.ModuleLoadError::MissingHostExports(path, required, missing), carrying the complete requirement list and complete missing subset without running a module constructor. Every AOT image has an address-valued identity under which its actor descriptors, shapes, throw types, positional actor-method names, SendFailed tag and actor_died method index register. Generated spawn, send, await, throw, module-loader and rendering calls carry the caller image; a supervisor death cast resolves the supervisor image. Cast, delayed, blocking and async sends translate the caller-local method id through its stable name into the target actor image's local id before enqueueing. Numeric method and throw ids therefore remain local even when two loaded .sos intern them in different orders or reuse all of them. A shared module never registers the executable-owned capability baseline. The native loader pins one mapping reference for every successfully loaded image until process exit: even an actor- and rift-free module can leave a closure trampoline, captured environment or layout descriptor in a host-owned resource such as a SQLite connection. Later handles balance their own loader references, while module.unload! still invalidates the logical handle. A foreign embedder must apply the same rule or prove every image-owned callback and value retired before dlclose. It is the data behind hanki inspect. It is kept out of the pure compiler.* source-reflection namespace because it reads a file. It works on both tiers, and 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, api, 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, and a manifest or lockfile open pkgs them
validationValidationError, the error value a where-invariant or smart constructor (Email.new, §9) returns on a failed predicate, as Err(ValidationError), with a Display rendering. Pure data
genThe property-testing generator core (§20): Gen<T>, a pure choice-sequence sampler over a seeded ChoiceSource with no [random] effect, with map, bind, filter and list combinators. The integrated shrinker and arbitrary build on it
arbitraryThe Arbitrary<T> trait with builtin instances (bool, the fixed-width ints, the arbitrary-precision int, f64, bytes, string, decimal, rational, Option, List, Map and Entry) and @derive(Arbitrary). It draws a typed test parameter as a Gen<T> (§20). Arbitrary<f64> is a mixture and no one-line bit reinterpretation of the kind the integers use, and the reason is shrinking. For an integer the bit pattern is the magnitude, and a smaller choice is a smaller number; the float patterns just above zero are the subnormals, around 5e-324, and a bit draw would shrink every counterexample toward a denormal nobody can read. It draws whole numbers, then readable fractions, then the IEEE boundary values (both zeros, both units, the smallest subnormal, the largest normal), then full bit-pattern coverage with the all-ones exponent rewritten in place of filtered, ordered simplest-first, since Gen.frequency picks an earlier entry for a smaller choice. It is finite: NaN and the infinities are drawn only by any_f64(), a property comparing a drawn float to itself being the first thing anybody writes and NaN breaking reflexivity. finite_f64() names the default mixture. Arbitrary<f32> is the same mixture at 32 bits, finite_f32 and any_f32, built on f32.from_bits: nothing in core converts into f32, and its whole-number component therefore encodes the IEEE pattern directly in place of casting, exact below 2^24 and no decimal-string round-trip per draw
Numeric towerThe 13 numeric types along the Num / Integer / Real / Float hierarchy (§3): int, the fixed-width i8 to i64 and u8 to u64, the IEEE floats f32 and f64, and the lowercase arbitrary-precision decimal and rational. Each is a core module, and the trait rows above enumerate the same 13

Checked string and byte bodies. Deserializer exposes take_string_header!() and take_bytes_header!(), each returning Result<BodyHeader, DecodeError>. A header parses only the wire type and length; it does not check body availability, inspect UTF-8, copy body bytes, or acquire ownership of the body backing. BodyHeader is opaque, with byte_length: int, offset: int (header start), and kind: BodyKind properties. BodyKind has StringBody and BytesBody variants. Lengths count bytes, including UTF-8 bytes, and preserve the complete binary u32 or CBOR u64 advertised length as an exact int.

take_string_body!(header) and take_bytes_body!(header) consume a checked header and return detached string or bytes storage. skip_body!(header) -> Result<(), DecodeError> advances past the advertised body without reading, copying, retaining, or validating UTF-8. It supports string and byte bodies; nested-value traversal requires an application decoder. Applications can inspect lengths, debit their own aggregate budget, then read or skip. Generic Decode and derived decoders continue to use unbounded reads unless a handwritten decoder explicitly applies a policy; this API does not impose an aggregate budget, container limit, or depth policy.

take_string_bounded!(max_bytes: int) and take_bytes_bounded!(max_bytes: int) are trait defaults over header then body. Limits must be finite and nonnegative; zero admits only an empty body. InvalidByteLimit(supplied, operation_start) occurs before any input consumption. A malformed, truncated, or wrong-kind wire header returns the format's existing header error first. After a valid header, an advertised length exceeding the limit returns ByteLimitExceeded(advertised, limit, header_start) before body availability or UTF-8 checks, leaving the cursor immediately after the header, even when the body is missing. No body allocation, copy, read, or new body backing ownership occurs on that rejection. The caller's existing reader still retains its original input. Within the limit, a short body returns Truncated(header_start) without advancing beyond the header; invalid UTF-8 consumes the complete body and returns BadUtf8(header_start). Successful bounded reads have the same detached ownership as unbounded reads. A failed header can consume a partial header: binary reports its prefix start; CBOR reports the initial byte's offset if absent, or the argument start if the argument is short.

A header is bound to its source reader's current checkpoint. Copying it does not duplicate permission to consume a body. A successful body claim consumes the checkpoint even for an empty or truncated body; a second attempt returns InvalidBodyHeader(current_position). A different reader or body kind returns the same error without consuming the valid checkpoint or moving either cursor. Any actual advancement through a reader alias invalidates its outstanding checkpoint. Peeking, querying position or remaining length, and zero-byte operations do not invalidate it. A new checkpoint replaces the previous one. Sharing the same underlying reader through another deserializer wrapper shares its cursor and checkpoint as well.

Format implementors supply the two header methods, two body methods, and skip method; the unbounded and bounded materializers are defaults. BodyHeader.new!(src: BytesReader, length: u64, offset: int, kind: BodyKind) -> Result<BodyHeader, DecodeError> issues the checked metadata after an implementor has parsed a header. It rejects a non-finite offset or one outside 0..=src.position!() with InvalidBodyHeader(current_position), without replacing the current checkpoint. header.take_string!(src), header.take_bytes!(src), and header.skip!(src) implement the body operations for reader-backed formats. Implementors are responsible for parsing their format correctly before issuing metadata.

BytesReader.checkpoint!() -> ReadCheckpoint mints a one-use marker; consume_checkpoint!(checkpoint) -> bool checks and consumes it. ReadCheckpoint is a native resource, also spelled bytes.ReadCheckpoint. It retains only identity storage, never the reader or input. BytesReader.skip!(n: int) -> int advances without accessing the body and returns the count skipped, with the same clamping as take!. All three operations are actions and comptime-foldable; checkpoints cannot escape as runtime constants. Header and checkpoint resources follow the existing resource ownership and derivation restrictions.

Planned for core, and not yet implemented: float. The numeric-tower trait hub is numeric, above.

Tier 2: extra

Curated, foundational capability and utility modules, evolving and not locked like core. They are import-gated (§14), in scope only after a file uses or opens them, and a file therefore declares the capability vocabulary it pulls in. They are qualified by default: write use net in place of open net. io is conventionally opened, for a bare print!, and still requires the import like every other extra module.

ModuleNotes
ioThe terminal I/O face over the sys stdio seam: print! to stdout, eprint! to stderr, their checked twins write! and ewrite!, returning Result<(), sys.WriteFailure> for a writer that must outlive its consumer, the byte-level print_bytes! and eprint_bytes! with their own checked twins write_bytes! and ewrite_bytes!, over sys.stdout_write_bytes! and its three siblings, for the payload that need not be UTF-8 that a relay of another program's output has, read_line!, a Line(string) or Eof sum interruptible by actor.shutdown! (§15) so that an actor parked on a half-typed line can still be reclaimed, and the read-to-end pair read_all! and read_all_bytes!, returning Result<T, ReadFailure> with Failed(message) and NotUtf8. The read-to-end pair is byte-faithful where read_line! is not: it adds and removes nothing, a trailing newline's presence and \r\n against \n both survive, which any filter or editor round-trip needs and a read_line! reassembly cannot recover. Like the seam beneath it, it reads the raw descriptor with no buffering layer, and interleaving with read_line! swallows no bytes. There is no file IO. Each write reaches the terminal as it is made, on both tiers, and 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, with no @intrinsic, delegating to sys, and conventionally opened for a 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, and the overlay wins on a collision. It charges [process, env], handing a value to a subprocess being the same capability as reading one out, and --deny env therefore reaches it. The overlay is per-spawn by design: there is no process-wide env.set!, ambient mutable state racing 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 inheriting the parent's, matching run! and run_with!, and a spawned tool's own relative paths resolve against it in place of the caller's cwd. Same per-spawn design, with no ambient chdir, and the same [process, env]. process.run_attached!(cmd, args) is the odd one out, and a separate operation in place of a flag: the child inherits this process's terminal, stdin, stdout and stderr, in place of pipes, and an editor, a pager, or any child that draws its own screen can be handed the tty and waited on. Nothing is captured, and it returns a bare sys.ProcessOutcome and no ProcessResult whose two string fields could only ever come back empty; a different return type is what earns it its own name. It charges [process, io] on the rule that gave run_with! its [env]: handing the terminal to a subprocess is the same capability as writing to it, and --deny io reaches it. Two dispositions are scoped around the wait and nothing wider. Signals: the parent ignores the terminal's interrupt and quit signals for the duration, and Ctrl-C reaches the child, which shares its process group, in place of killing the program waiting on it. The child's own dispositions are reset before exec, an ignore surviving it where a handler does not. This is the system(3) discipline and no job control; the child is not independently stoppable. Raw mode: if the parent had any it is dropped for the child and restored on return, and a full-screen program shelling out to $EDITOR hands over a cooked terminal and gets its own mode back without waiting for the exit-time restore. Only what the parent itself saved is put back: a parent that was never raw restores nothing, which leaves a child that crashed mid-raw-mode as it left things in place of stomping a pre-spawn snapshot over state it may legitimately have set. Concurrent attached spawns serialize, the terminal being one resource. It is refused under --deterministic (§6), the only sys.* seam that is. The wait is interruptible: actor.shutdown! and program exit (§15) wake it and kill the child, SIGTERM, a short grace, then SIGKILL, and a never-exiting child cannot pin the actor past shutdown; program exit terminating its own subprocesses matches actor-termination semantics. process.run_opts!(cmd, args, stdin_input, opts) and process.run_attached_opts!(cmd, args, opts) take a process.SpawnOptions in place of a growing parameter chain, on the sqlite.OpenOptions precedent, built from SpawnOptions.defaults() and the with_cwd, with_env, with_env_remove, with_env_clean and with_merge_stderr updaters, its fields public for direct construction. It has the cwd and env the named faces already took, plus the two an overlay cannot express. env_remove unsets names in the child, the env -u NAME a test harness depends on: overlaying "" sets the variable to the empty string, and a child that tells set-but-empty from unset sees two different worlds. env_clean inherits nothing at all, and the child's environment is a function of the program in place of whoever's shell started it, which also lets --deterministic mean something for subprocesses; the child then has no PATH, and cmd wants an absolute path. The three apply in the order clean, remove, overlay, and a name in both env and env_remove ends up set, the one reading under which the two compose without one voiding the other. run_opts! charges [process, env] like run_with!. run_attached_opts! charges [process, io, env], being run_attached! plus the environment capability, and is refused under --deterministic for the same reason. Capture and attach stay a face pair in place of a flag, the return types differing, and run_with! and run_in! remain the short spellings of the two common cases. Streaming is process.start!(cmd, args, opts) -> Result<Child, sys.ProcessFailure> [env, process], which returns immediately with an actor-owned, single-owner native resource. The name is start!: spawn is the actor keyword and cannot name a callable (§2). read_stdout! and read_stderr! use sys.StdinRead's Bytes / TimedOut / Eof / Failed shape and timeout convention; the stdin-only Resized arm is never produced by a child pipe; write_stdin!(data, timeout_ms) -> Result<int, sys.ProcessFailure> writes one prefix and reports its byte count; write_all_stdin!(data) waits for the whole buffer, close_stdin! delivers EOF, wait! and kill! reap while retaining output for a final drain, and close! releases everything. with_merge_stderr(true) redirects stderr into stdout and removes the second pipe, the safe one-pipe form when separation does not matter. Separate pipes require alternating timed reads from both, since draining only one while the child fills the other can deadlock. A child read follows the pending-deadline rule for native calls under --deterministic (§6). A finite read timeout is a wall-clock bound that retries do not restart. A parked child read, write or wait is shutdown-cancellable on Unix; final drop, actor death, and program exit terminate and reap a live child. Non-Unix hosts report timed child reads, finite-timeout nonempty writes and merged stderr as unsupported; a negative-timeout native write cannot be interrupted
fsSymbolic links: fs.symlink!(target, link) -> Result<(), sys.FsFailure> [fs_write] creates a Unix symbolic link and stores target as supplied, including relative and dangling targets. Relative targets resolve from the link's parent when followed. The destination must be absent and its parent directories must exist; existing entries are never replaced. This operation does not inspect the target. Creation on a non-Unix host returns Other("symbolic-link creation is supported only on Unix hosts"). Every creation failure names link. fs.read_link!(path) -> Result<string, sys.FsFailure> [fs_read] returns the stored target without canonicalization, including dangling targets. Non-Unicode target data is NotUtf8; an ordinary entry or missing path is an error. fs.is_symlink!(path) -> Result<bool, sys.FsFailure> [fs_read] inspects the final entry without following it: dangling symbolic links are true, ordinary entries and missing paths false. Other host failures remain errors. Reading and detection use host operations on Unix and non-Unix hosts, independently of DirEntry.mode; failures name path. Parent links resolve normally. All three operations have matching bytecode and AOT behavior and public sys.file_* wrappers. Exclusive file creation is fs.write_new!(path, content: string) and fs.write_bytes_new!(path, content: bytes), both Result<(), sys.FsFailure> [fs_write]. They atomically create the destination, then write its contents. An existing file, directory or final symlink, including a dangling link, is never opened or truncated. Competing creators of an absent destination have one winner; the others return AlreadyExists. Other OS failures use the existing error kinds and name the requested path. Parent directories must exist and parent symlinks resolve normally. Permissions use host defaults, Unix mode 0666 masked by the process umask. Both tiers use the host exclusive-create operation on Unix and non-Unix hosts. A later write failure can leave an empty or partial new file, which the call does not remove. Success does not synchronize durable storage or prevent subsequent writes. Filesystem read and write (fs.read!, fs.write!), path resolution (fs.canonicalize!(path) -> Result<string, FsFailure>, the OS realpath), handle open (fs.open_file! -> Result<File, FsFailure>, §4), the project-root walk, one file's stat, and directory traversal. fs.find_up!(start, marker) -> Result<Option<string>, FsFailure> walks upwards from start to the first directory holding marker, the way git, cargo and npm find a root. The marker may be a file or a directory, .git being a directory in a clone and a file in a worktree, and the walk ends where path.dirname stops changing in place of at a hard-coded /, which terminates a relative start bottoming out at . too. It is lexical over the path given; pass canonicalize!'s answer where symlinks must be resolved first. A stat failure part-way up is Err and no quiet Ok(None). fs.metadata!(path) -> Result<Metadata, FsFailure> gives size in bytes, modified in epoch nanoseconds, permissions and directory?, all four from the one stat(2) that already returns them. modified is a raw int, and reading a size does not pull the calendar in; datetime.Instant.from_epoch_nanos(m.modified) is the one call that converts. A missing path is Err(NotFound) naming the path and no empty success: the module answers absence as Ok(false) for its predicates, exists!, is_dir! and is_symlink!, where absence is the answer, and as Err for its retrievals, read!, open_file!, list_dir! and metadata!, where it is a failure to retrieve. Permissions.from_mode and Access.from_bits are the inverses that read a raw mode back, ignoring the file-type bits a stat reports. fs.list_dir!(path) -> Result<List<sys.DirEntry>, FsFailure> reads one level in name order. Each DirEntry describes the entry itself without following its final symlink; dangling links retain their own metadata. name is the basename, with invalid Unicode replaced by the replacement character. is_dir is false for every symlink. size is the host-reported byte length capped at the largest signed 64-bit integer; a link reports its own host length, which on Unix is the byte length of its stored target. modified is signed epoch nanoseconds, saturated at signed 64-bit limits. On Unix, mode retains raw file-type, special and access bits (a regular file with mode 0644 commonly gives 33188, or 0100644); on non-Unix hosts it is zero. By contrast, fs.metadata! follows the target and Metadata.permissions.mode contains only its nine access bits, also zero on non-Unix hosts. If an entry's metadata or modification time cannot be read, size, modified and mode are all zero; its name remains and is_dir uses its file type if available, otherwise false. Those zeros do not prove an empty file or an epoch timestamp. Reading entry metadata can require an extra OS call on Unix. fs.walk!(root) -> Result<List<string>, FsFailure> gives every file beneath it. walk! returns paths relative to root, depth-first with each subtree standing in place of its entry and each directory's entries in list_dir!'s name order, and the sequence is therefore a function of the tree alone, and a generator built on it emits byte-identical output across runs. Directories are not themselves reported and symlinks are not followed, sys.DirEntry.is_dir not reporting one as a directory, and a link cycle cannot make the walk diverge. The pending set is an explicit worklist and no recursion: depth costs heap and not stack, and there is no depth parameter to get wrong. There is no glob, and walk!(root).filter(|p| p.ends_with?(".hk")) is the pattern. Relocation is fs.rename!(src, dst, overwrite) -> Result<(), FsFailure>, the OS's own rename: the destination goes from old to new with nothing in between, which the copy-then-delete it replaces cannot promise. It is atomic or nothing: endpoints on different filesystems are Err(CrossDevice) in place of a fallback that would not be atomic, and a caller wanting that writes it and knows what it is getting. It charges [fs_write] and not [fs]: a rename moves a directory entry and never reads the file's contents, and a program granted only writes can still move files. overwrite is a required argument on both rename! and fs.copy!(src, dst, overwrite), and neither value is the default. false is Err(AlreadyExists), and true is what the write-to-temp-then-swap idiom needs, which is the reason to want an atomic rename at all. Replacing a file is not something an API should do because nobody said otherwise, and making it a parameter in place of a policy tells every existing call site at compile time in place of at run time. The existence check before a non-overwriting move is a check-then-act, and a file appearing in the window loses the race, the same window every language's equivalent has, and a guard against the ordinary mistake in place of against a concurrent writer. Every failure is a sys.FsFailure, { path, kind }, where kind is the sys.FsError sum (NotFound, PermissionDenied, NotUtf8, Other(msg), AlreadyExists, NotADirectory, CrossDevice) and path is the path the failing call named. The path is there because a caller reading twenty files and getting back NotFound cannot otherwise say which one, and walk! is the case with no workaround: the directory it could not descend into is one the caller never named. Display renders <path>: <kind>, or the kind alone where the path is empty. It is empty for the two calls that name no single path: a File handle operation, and copy! and rename!, the OS not saying which endpoint a two-path call tripped on, and naming one would assert something the call never established
pathPure-Hanki lexical slash-path helpers: no effects, no sys, both tiers, and they never touch the filesystem. path.join(base, child) puts one separator at the seam; path.dirname(p); path.basename(p); path.normalize(p) collapses //, drops . and resolves .. without escaping an absolute root, on Go path.Clean semantics; and path.split_ext(p) -> PathSplit { root, ext }, where root is the path without its extension and ext the extension without its dot, a dotfile or extensionless name giving an empty ext
netBlocking TCP and pathname Unix-domain sockets. TCP: net.connect! -> Result<TcpStream, NetError>, net.listen! -> Result<TcpListener, NetError>, and listener.local_address! -> Result<LocalAddress{host, port}, NetError>, where an ephemeral listen!(host, 0) reports the port the OS chose and host is always a textual IP. connect! starts one absolute 60-second deadline before resolution and retains it through later I/O; connect_with_deadline! chooses another initial budget and TcpStream.set_deadline! resets it. Unix: net.connect_unix!(path) -> Result<UnixStream, NetError> [net] and net.listen_unix!(path) -> Result<UnixListener, NetError> [net, fs_write]; bind never unlinks an existing entry, close/drop leaves the pathname for explicit fs.delete! cleanup after concurrent replacement is excluded, abstract namespace is out of scope, and the listener has no local_address!. Either full stream has split!, returning independently movable read and write halves so full-duplex code can use one actor per I/O direction (§4). net.write_all! takes S: WriteStream, accepting a full stream or a write half. Connect, accept, read and write are interruptible by actor.shutdown! (§15)
tlsTLS over TCP, both ends (use tls), the native-seam half of https. connect!(host, port) -> Result<TlsStream, sys.TlsFailure> opens the socket and completes the handshake under the same absolute 60-second deadline later record I/O retains; connect_with_deadline! chooses another budget and TlsStream.set_deadline! resets it. The connection's read!, write!, version!, split!, set_deadline! and close! are in core/tls_stream, and write_all! loops the single-write primitive. There is no wrap!(TcpStream): an ordinary call does not consume its argument, and a caller who handed a plaintext stream in would keep a live handle to the same descriptor and could write past the encryption. The same reasoning rules out STARTTLS, which needs that upgrade in place. The peer is verified against the system trust store and there is no way to skip it: an insecure escape hatch is a posture decision nobody has asked for, and one far easier to add later than to take away. TLS 1.3 and 1.2 are both spoken, 1.3 whenever the peer offers it, and 1.2 only for a peer that speaks no more than that, as a great many government, utility and grid-operator APIs still do. The version moves; the guarantee does not. Every compiled-in 1.2 suite is ECDHE with an AEAD cipher, and forward secrecy and authenticated encryption hold on either version, with no CBC, RC4 or static-RSA key exchange to fall back to; renegotiation and compression are not implemented. Nor can a 1.3-capable peer be talked down: a 1.2 handshake carrying RFC 8446's downgrade sentinel is refused. Which version a connection settled on is observable. TlsStream.version! reports a sys.TlsVersion, V12 or V13, declared ascending and Ord, and a version floor is version >= sys.V13 in place of a string comparison. This face is the only one that answers; http.Response has no transport fields. sys.TlsFailure{host, kind} pairs the peer with a TlsError of TlsHandshake, TlsCertificate, TlsTransport or payloadless TlsTimedOut. The certificate case is its own variant, being the one a caller acts on differently, and the host rides on the struct for the reason FsFailure reports a path: a reader meeting "certificate rejected" needs to know whose. TlsStream implements ReadStream, WriteStream and Stream, and directional or duplex framing runs over it unchanged. ReadStream and WriteStream faces report sys.NetError, folding a TLS failure into Other; the duplex Stream face retains sys.TlsFailure as its associated error (core/stream). split! returns independently movable TlsReadHalf and TlsWriteHalf application capabilities over one shared record layer; each implements only its direction. Either half may drive both underlying socket directions because TLS reads can require transport writes and TLS writes can require transport reads, and no actor parks while holding their shared state. Cancellation is not bolted on: the library is sans-io, the handshake, reads and writes all drive against the same non-blocking socket and wakeup pipe as a plaintext socket, and actor.shutdown! interrupts a parked one. --deterministic stops at this seam: a handshake consumes real entropy and wall clock inside the native library, as any real socket does. The server half is ServerConfig{chain: bytes, key: secret.Secret<bytes>} plus listen!(host, port, config) -> Result<TlsListener, sys.TlsFailure>, whose listener has accept!, address! and close! in core/tls_listener. The configuration is a value, and the material may come from a secret store and not only from a file, and its key is a Secret (§4), which bars the struct holding it from being rendered, serialised, compared or sent; listen! reveals it once, straight into the seam. The chain and key are compiled once, at bind time, and never per accepted connection, and a key that does not match its certificate therefore fails at startup and not under traffic. A handshake failure is a per-connection value that the next accept! survives, and never fatal to the listener. That distinction is in the type and not left to a caller: accept! answers Result<sys.TlsAccept, sys.TlsFailure>, where Ok(Accepted(s)) is a connection, Ok(Rejected(f)) is this peer failing, and only Err means the listener is finished. It has to be. A peer that resets mid-handshake and a dead listening socket are both TlsTransport, and a loop reading the variant would end the server on the first client that walked away abruptly
httpHTTP/1.1 over net: Request and Response structs, a Method sum, and HttpError. Client: http.get!(url) and http.request!(method, url, headers, body) -> Result<Response, HttpError> parse an http:// or https:// URL and use one connection under the transport's absolute 60-second default. Client<S> instead owns a caller-opened concrete transport and its request! sends several sequential requests over that connection, and set_deadline! on a TCP/TLS client starts one shared budget for the next write and response read. Its operations require <S: Stream[e], S.Error: Into<HttpError>>; framing preserves the transport's row and converts its associated error explicitly. Client<TcpStream> and Client<TlsStream> remain distinct types. A caller supporting both schemes retains a two-arm connect branch. A close between exchanges surfaces as a typed HttpError, TimedOut for either transport's deadline, Net for another OS failure, or Incomplete for a clean EOF before the response head, and is never retried: only the caller knows whether replay is safe. An https:// one connects over TLS 1.3, or 1.2 for a peer that speaks no more than that, with the peer verified against the system trust store, and a failure there is HttpError.Tls(sys.TlsFailure), which names the host and is distinct from Net, being the case a caller acts on differently. Server: read_request! and read_response! take any S: ReadStream, including a full transport or read half; write_response! takes any S: WriteStream, including a full transport or write half; plus the pure parse_request, parse_response, render_request and render_response, each giving Result<bytes, HttpError>. The framing functions take no !, a parser performing no I/O, and they are callable from a pure def, a where or a meta block. A blocking serve!(listener, handle) runs one connection at a time; concurrency is a per-connection worker actor with move (§15). serve! refuses a listener bound anywhere but loopback, answering ExposedBind(host) in place of serving: it speaks cleartext, and a credential sent to a port the network can reach travels in the open. serve_exposed! is the same loop without the check, for a process behind a terminating proxy or bound to 0.0.0.0 inside a container. serve_tls!(TlsListener, handle) is the encrypted twin (tls): the same framing, _serve_one! being generic over S: Stream and TlsStream implementing it, and with no ExposedBind check. Over TLS a reachable bind is the intended deployment, and the check is absent there in place of serve! losing it. A handshake failure there does not end the accept loop either: a client offering nothing the server speaks must not take the service down. Headers are a Headers value and no bare map: a field name maps to the list of its values, HTTP letting a name repeat, as Set-Cookie does per cookie. empty, get for the first value, get_all for every value, set to replace, add to append, and names, rendered one wire line per value, folding Set-Cookie not being permitted. Every name-taking method lower-cases its argument, stored keys are always lower case, and reads agree with writes whatever case the caller spells (RFC 9110 5.1). That is a rule and no convenience: render_request and render_response set the computed content-length, and under a case-sensitive lookup a caller's Content-Length would survive beside it and put two on the wire. Repeats are represented and not collapsed, and two Content-Length fields whose values differ are BadContentLength in place of a last-wins pick (RFC 9112 6.3, the CL.CL request-smuggling shape); identical repeats fold. A request buffer holding fewer bytes than Content-Length declares is Incomplete, and a caller can tell a truncated message from a complete one; a response body may still end at connection close. parse_url rejects userinfo and IPv6 authorities, and render_* reject a bare CR or LF in any header value or in the request line, method and target, which guards against request and response splitting. v0 wire: Content-Length or Transfer-Encoding: chunked bodies, persistent requests through Client<S>, rendered responses carrying Connection: close, size-bounded. The one-shot request! supplies Connection: close before rendering, while public render_request preserves the caller's field and defaults to HTTP/1.1 persistence. Chunked is decoded in the framing step, on both faces and in the pure parse_* pair: a size line, that many bytes, repeating to a zero size, hexadecimal in either case, ;-introduced chunk extensions dropped, the trailer section discarded, and decoding byte-level throughout, which lets a multi-byte character straddle a boundary. It is the one transfer coding an HTTP/1.1 recipient may not skip (RFC 9112 7.1) and the one a server reaches for whenever it cannot know the length up front, which is most generated content. The body bound applies to the decoded total and is checked as chunks accumulate, and chunking gains a peer nothing. A body that stops mid-chunk is Incomplete and no short read, which matters more here than under Content-Length because truncation is otherwise indistinguishable from a complete small response, and malformed framing is BadChunk. Any other coding is UnsupportedTransferEncoding(value) and no body: gzip, chunked is still gzip once the chunk framing comes off, and reading only the list's last coding would wave through a chunked, gzip head as though it were plain, and anything other than chunked, identity aside as a no-op wherever it sits, refuses. Handing framing back as a payload is the worse failure: it is a 200 with a plausible body, and the corruption surfaces in whatever parses it next. A message declaring both a Transfer-Encoding and a Content-Length is ConflictingFraming, refused outright and framed by neither (RFC 9112 6.3, the CL.TE request-smuggling shape, CWE-444): they say different things about where this message ends, and a recipient further along the chain is free to believe the other one and read a second message out of this one's body. A decoded message retains its Transfer-Encoding field, the headers reporting what arrived and the body being the payload, while render_request and render_response drop the field, framing by the Content-Length they compute; emitting both would produce the very message the reader refuses
urlURLs taken apart and put back (use url), pure Hanki on both tiers. parse(text) -> Result<Url, UrlError> follows RFC 3986 §3's generic syntax into Url { scheme, userinfo: Option, host: Option, port: Option<u16>, path, query: Option, fragment: Option }, every part verbatim, with no case folding, no dot-segment removal and no decoding, which decoding applies being a property of the part. host is absent where there is no authority (mailto:a@b) and Some("") for an empty one (file:///p), and an IPv6 literal retains its brackets. It refuses only what no URL may carry, and each UrlError names the byte offset: BadScheme (not a letter, then letters, digits, +, - or ., then :), BadCharacter (whitespace, a control byte, or one of the RFC 3986 excluded marks, the double quote, angle brackets, backslash, caret, backtick, braces and the vertical bar; a byte past ASCII is left to the caller's decoding), BadHost (an unclosed [, or a : in an unbracketed host), and BadPort (not digits in 0..65535; an empty port is no port). render(u), also Display, spells it back so that parse(render(u)) == Ok(u), and Url and UrlError have Eq. There are two total encoders, the two jobs looking interchangeable and being different. encode_component(text) is RFC 3986 §2.3: the unreserved set A-Z a-z 0-9 - . _ ~ survives, everything else becomes uppercase %XX over the UTF-8 bytes, and a space is %20, for a path segment, a header value or a signed request. encode_form(text) is application/x-www-form-urlencoded per the WHATWG URL serializer, where a space is +, * survives and ~ does not, for an HTML form body and most query strings. A + in the input is itself encoded by both, and the two spellings of a space stay distinguishable. The decoders mirror them: decode_component reads %XX only, decode_form reads + as a space too, and both refuse a malformed escape, BadEscape(at), and a result that is not UTF-8, BadUtf8(at). Query strings: parse_query(q) -> Result<Map<string, List<string>>, UrlError> takes the http.Headers multimap form, a repeated name keeping every value in order, a pair without = having the value "", an empty segment skipped, and names and values form-decoded. render_query(pairs: List<Pair<string, string>>) is its inverse up to order. http.parse_url is the transport's narrower reading over this parse, http and https only, no userinfo, no IPv6 literal, a Url of host, port, path and tls, the query kept on the path as the request target and the fragment dropped; this is the general one. The builders are @encapsulated, and meta can fold a constant query string
cborThe CBOR (RFC 8949) codec: CborSerializer and CborDeserializer are pure-Hanki impls of Serializer and Deserializer (§16), and @derive(Encode, Decode) yields a real-CBOR codec for any type. Both tiers. v0 covers definite-length items, whose declared lengths are contracts: a derived struct rejects an array count other than its field count, and a selected sum variant rejects an array count other than one tag plus its payload count. f64 is read as the 64-bit form; foreign half and single floats are a follow-up. The exact tier overrides the structural defaults with its IANA-registered forms, and a non-Hanki consumer therefore sees numbers in place of opaque strings: an int is a native CBOR integer where one fits and a tag 2 or tag 3 bignum beyond that; a decimal is a tag 4 decimal fraction, [exponent, mantissa], CBOR's exponent counting the other way from Hanki's scale and therefore negated; a rational is tag 30, [numerator, denominator], already reduced; and an f32 is a native single-precision float in place of its bit pattern as an integer. None of those forms can hold an in-band sentinel, and one goes out as CBOR's own non-finite spelling in place of an invented tag: undefined (0xf7), and a half-precision infinity (0xf9 0x7c00 or 0xfc00), which is what those values mean. A sentinel decimal or rational writes the bare sentinel in place of a tag-4 or tag-30 array wrapping one. Decoding is unambiguous, each take_* already knowing the type it wants, and it accepts a foreign infinity at any of the three float widths. The matching CDDL (RFC 8610) schema for your types is emitted by hanki cddl (below), which describes the exact tier as the choice it is, tag rules included
base64Base64 (RFC 4648) for bytes, pure Hanki on both tiers. encode uses the standard alphabet with = padding, and encode_url the URL-safe alphabet (- and _) with none: two functions and not one with a flag, that pairing being what a MIME header and a JWT respectively want. decode is one permissive reader: either alphabet, the two being unable to collide, and padding optional but correct where present. Failure is a Base64Error reporting the byte offset: BadCharacter outside both alphabets, BadLength for a group of one character, no byte count producing one, and BadPadding for a stray or trailing =. Nothing is skipped, whitespace and newlines included: a codec that drops characters unannounced cannot tell a wrapped MIME body from a corrupted one. Whole-input only, with no streaming form, and @encapsulated, which leaves encode and decode alike callable from meta and from pure code
base32Base32 for bytes, pure Hanki on both tiers, in two variants that, unlike base64's two alphabets, do not share a decoder: every letter means something different in each, B being 1 under RFC 4648 and 11 under Crockford. decode reads RFC 4648 §6 and decode_crockford reads Crockford, and a caller has to know which one it has. encode is RFC 4648 with = padding, padding being optional on decode, since a TOTP secret usually arrives without it, and correct where present, the same rule base64 states, tightened with it so that the family remains alike; lower case is accepted. encode_crockford is unpadded, Crockford specifying no padding at all. Crockford's decoder forgives what its specification says and no more: case is ignored, O means 0, and I and L mean 1, and no other letter is forgiven. S is a symbol in its own right, 25, and giving it a second meaning would make the encoding ambiguous, and U is in no alphabet and is rejected. Failure is a Base32Error reporting the byte offset, BadCharacter, BadLength or BadPadding, the same form as base64. Crockford's optional check symbol and its ignore-hyphens rule are not implemented. Whole-input, @encapsulated, no streaming form
hexHexadecimal for bytes, pure Hanki on both tiers, and the simpler codec: two characters per byte, no grouping, no padding, no alphabet variant. encode renders lowercase, what a digest and a lock-file entry print, and decode accepts either case and rejects everything else, as a HexError reporting the offset: BadDigit outside 0-9a-fA-F, and OddLength where the input cannot pair its characters. The same form as base64 otherwise: whole-input, @encapsulated, no streaming form
aeadAuthenticated encryption, one algorithm and no way to choose another: XChaCha20-Poly1305 (use aead). encrypt(key, nonce, plaintext, aad) -> bytes and decrypt(key, nonce, ciphertext, aad) -> Result<bytes, AeadError>, the ciphertext having its 16-byte Poly1305 tag appended (tag_length), and decrypt verifies before it returns a single byte, an altered message being an error in place of plausible-looking plaintext. Algorithm agility is the failure mode this omits: a caller who can pick has to know which to pick. XChaCha is the pick for its 192-bit nonce, wide enough that a random nonce per message is safe with no counter and no persisted state, which is what breaks AES-GCM deployments. Key, 32 bytes, and Nonce, 24, are opaque, built by key and nonce, which return Result and check the length once, or minted by new_key! and new_nonce!, effect [random] and the only effectful members. A key and a nonce therefore cannot be passed in each other's place, and encrypt is total, with no error case left to handle. Key has no Display, a key that renders being a key in a log line, and its Eq is constant-time, which makes == safe without anyone remembering; Nonce renders as hex, being public. Err(AuthFailed) is a wrong key, a wrong nonce, mismatched aad and altered ciphertext at once: one tag check cannot tell them apart, and reporting them apart would say how close a guess came. aad is authenticated and not encrypted: it travels in the clear, is bound to the ciphertext, and is where a version tag or record id goes, which stops a valid ciphertext being replayed into another context. It is a native seam, sys.aead_encrypt and sys.aead_decrypt, the seam's only pure members, and no pure Hanki, by measurement: sha2 is pure Hanki and manages about 30 KB/s on the bytecode VM. There is no password KDF and no keyring: key takes key material, and a passphrase run through a hash is not that
sha2Cryptographic hashing, pure Hanki on both tiers: digest(bytes) -> bytes, SHA-256 in 32 bytes, hmac(key, message) -> bytes, HMAC-SHA-256 per RFC 2104, and constant_time_eq?(a, b) -> bool. It is distinct from core hash, which is unkeyed structural hashing for Map keys; a value an adversary may choose, a package's content pin, a checksum-log entry, or anything signed, hashes here. It is named for the family, and SHA-512 can join it. It needs only the wrapping u32 operators and the total bitwise methods, every entry point is pure and meta-callable, and a digest renders through hex or base64. Verify a tag with constant_time_eq?, never ==: an ordinary compare stops at the first differing byte, and that timing alone recovers a tag byte by byte. Throughput is the stated cost of a pure-Hanki hash, and the two tiers are a hundredfold apart. Measured over 64 KiB, hash-only, about 4.8 MiB/s on the AOT tier and about 56 KiB/s on bytecode, and a hanki run script spends about twenty seconds on a megabyte. Hash a few KB there and compile the program where the input is a file tree; tools/site/deploy.hk shells out to sha256sum for that reason. It is a cost and no defect, and no fix is promised: the round is a few hundred u32 bit operations, the bytecode tier pays interpreter dispatch on each, and what would move the number is a contiguous unboxed array and nothing in the module
shThe scripting-facing way to run a program, beside process and not over it, no extra module wrapping another. run!(cmd, args) and run_stdin!(cmd, args, stdin) return a Run with cmd, out, err and code, plus ok?, text for trimmed stdout, and lines. capture! demands success and returns the trimmed stdout, crashing with the child's own stderr otherwise. Both take [process, Crash]: a program that will not spawn is a bug in the script, and it crashes in place of returning a case to match, a narrowing with extra/process unchanged for callers that must branch on it. Argument lists only, with no shell-string parsing: splitting on spaces is how injection bugs are written, and the name is the reason to say so out loud. A secret belongs in run_stdin! and never in args: argv is world-readable through ps for the life of the call
flagsDeclarative command-line parsing, pure, argv arriving from main!. A Spec is a literal, spec(program, about, flags, positionals) over switch, valued, repeated and positional declarations, and the same declaration both parses and generates help, and the two cannot drift. It supports long and short forms, a value attached (--name=v) or separate (--name v), repeated options collecting into a list, positionals by name, and a bare -- after which everything is a free argument. Errors are values: ArgError is UnknownFlag, MissingValue, MissingPositional or BadValue, each reporting the offending token, plus the three subcommand cases below, with render_error for the message. It is no shell: there is no globbing, no -abc bundling and no abbreviation matching, all three turning an unknown flag into a guess in place of an error, and --name --other reports a missing value in place of eating the next flag. A declaration is a list literal and no chained builder, a method chain being unable to span lines today. Subcommands are one level deep and not representable deeper. sub(name, about, flags, positionals) builds a Sub, spec(...).with_subs([...]) -> Result<Spec, ArgError> attaches them, and Parsed.sub reports which matched, with the subcommand's own flags and positionals folded into the same Parsed maps, and a caller reads a value from one place whichever level declared it. Parent flags come before the subcommand word (prog --verbose build x), the rule that parses without lookahead. Giving no subcommand is no error: sub is None and the caller decides, usually by printing the index help, and the module retains its stance of reporting in place of deciding what a flag means. help(spec) gains a commands: section, and help_sub(spec, name) -> Result<string, ArgError> renders one subcommand's page; --help after a subcommand is the caller's business. The three further errors are all build-time or first-word: UnknownSubcommand(word, known), reporting the declared names, since a bad command is where a bare "unknown" helps least, and, raised by with_subs because a spec is literal and its mistakes are the author's, DuplicateFlag(long) for a long name declared by both levels and PositionalWithSubcommands(name) for a parent positional beside subcommands, which no line could satisfy
jsonJSON (RFC 8259) as a value tree: a JsonValue sum (Null, Bool, Num(f64), Str, Arr, Obj), four parsers giving Result<JsonValue, JsonError>, and Display rendering compact JSON. parse and parse_bytes retain the final value for a repeated object key. parse_strict and parse_bytes_strict reject a repeated decoded key at any nesting level with DuplicateKey(offset), where the payload is the second key's opening-quote byte offset and contains no key text. This includes equal decoded keys with different escape spellings. Both tiers. No parser takes a bang: the decode buffer is allocated and finished inside each parse_bytes face, which is @encapsulated (§6), reading a document is an ordinary pure call, and a pure def, a prop or a where predicate may do it. All four parsers fold at comptime (§16, §22). They bound array and object nesting depth, a TooDeep error past the limit, and deeply nested untrusted input cannot exhaust the stack. They cap the members decoded into a single object, a TooManyKeys error past the limit, and untrusted keys cannot hash-flood the backing Map, whose FNV-1a hash is unkeyed for cross-tier determinism and therefore attacker-predictable. It is the compat shim (§16), and it is 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 and null?, chained through Option.and_then, or (c) decode into typed values through the FromJson trait: a hand-written from_json per struct, with builtin scalar, List, Option and Map impls that compose, a JSON array of objects becoming a List<YourStruct> at no cost, and located errors, JsonShapeError, such as [1].y: expected number. The inverse ToJson, to_json, is total and has no error, and encodes your types back, and v.to_json().to_string() round-trips with parse plus from_json. A non-finite f64 renders as null, and object members render with keys in sorted, canonical order, deterministic whatever the build order, since Obj is backed by a hash-order Map and RFC 8259 §4 leaves object members unordered. To reproduce instead a foreign envelope that fixes its field order byte for byte, a serde struct's declaration order for instance, the field-order emitters object, array, quote and boolean build object and array JSON text with members in the given order: object over a List<JsonMember>, and quote rendering a string with serde-matching escapes. It is a separate emission path kept off the value tree, field order being no part of the parsed model, and parse never yields an ordered object
xmlAn XML 1.0 (5th ed) pull parser, structurally secure: the parser defines no custom entities and performs no I/O of any kind, and XXE and entity-expansion attacks are impossible in place of mitigated. Only the five predefined references (&amp;, &lt;, &gt;, &apos;, &quot;) and numeric character references decode, any other named reference being a typed UndefinedEntity error, and a DOCTYPE is tolerated but skipped inert, bracket-matched and never interpreted. Pure Hanki, with no native seam and no effect atom; parsers doing no 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 of Syntax, UnexpectedEof, MismatchedTag, DuplicateAttribute, UndefinedEntity, UnsupportedEncoding, InvalidChar, TooDeep and MultipleRoots, with a byte offset and a line and column. Well-formedness is enforced: a single root, tag balance, attribute uniqueness, XML name validity, character validity including after reference decoding, no raw < in attribute values, and no ]]> in character data. UTF-8 only, the BOM stripped, and any other declared encoding is UnsupportedEncoding. Nesting depth is capped, at 1024 by default, the one resource guard the entity ban does not cover. Text is verbatim, with no trimming, under §2.11 line-end normalization, and attribute values normalize literal whitespace to spaces (§3.3.3). The public reader is namespace-aware, Namespaces in XML 1.0, always on, and there is no raw-prefix public mode. reader(input) and reader_bytes(input) build an XmlReader value, next pulls one event through the XmlStep sum (Next(successor, event), Done, Fail), and element and attribute names arrive resolved as XmlName { uri, local, prefix }. xmlns and xmlns:p declarations are scope and no data, scoped and shadowing, xmlns="" un-binds the default, and 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's element and attribute asymmetry, and two attributes may not share one expanded (uri, local) name. The tree layer is the convenience surface. parse(s) and parse_bytes(b) give Result<XmlDocument, XmlError>, both @encapsulated, and pure code and where predicates may read XML. They drain the reader into a plain immutable value tree: XmlDocument { declaration, before_root, root, after_root }, XmlNode (Element, Text, Comment, ProcessingInstruction) and XmlElement { name, attributes, namespace_declarations, children }, sendable across actors like any data, hand-constructible for writing, and built iteratively, document depth never growing the call stack. CDATA merges into Text and adjacent character data coalesces at this level, the event layer preserving the distinction, and each element records the namespace declarations written on its tag for faithful re-emission. Navigation is children-only, there being no parent pointers, plain values being unable to cycle: elements, elements_named(local), elements_ns(uri, local), first, first_ns, attribute(name) for the no-namespace attribute, attribute_ns(uri, local), and text for all descendant character data in document order, text-content semantics. Writing goes through a streaming XmlWriter value: writer(), then chained start, attribute, namespace, text, cdata, comment, processing_instruction, declaration and close, and finish giving Result<string, XmlError>. Errors are sticky, an invalid operation poisoning the writer and finish reporting the first, and generation needs no per-step match. Output is well-formed: escaping is context-correct, text escaping &<> so that ]]> cannot appear, attributes always double-quoted and escaping &<", CDATA ]]> split across sections, and comment -- and PI ?> unescapable and typed errors; names are validated; a prefix must be declared, with the reader's reserved rules; and tag balance and a single root are enforced. The tree renderer rides on it: render(doc) compact, and render_pretty(doc, indent) indenting element-only content and never reformatting mixed content, whitespace being data. Re-rendering a parsed document preserves original prefixes, and render, parse, render is a fixed point, a structural round-trip modulo the documented normalizations: CDATA arrives back as text, and quoting is normalized. Typed decode is the FromXml trait, T.from_xml(element) -> Result<T, XmlShapeError>, on the FromJson model, with scalar impls for string, bool, int, i32, i64 and f64, decoding an element's text content, trimmed, and the decode accessors require_attribute, require_child and child_text, with attribute, first and elements_named as the optional and repeated forms. XmlShapeError (MissingElement, MissingAttribute, UnexpectedShape, BadScalar) reports a cheap feed/entry/title path, @name for attributes, extended upward through nested decodes with within(parent, result), and Display renders path: what went wrong. The v1 mapping convention, a record field decoding from the child element of the same local name with attributes reached explicitly, is what @derive(FromXml), staged, will mechanize. Scope excludes DTD processing, validation, XPath, XML 1.1 and non-UTF-8 encodings
csvA CSV (RFC 4180) reader and writer (use csv), pure Hanki on both tiers. parse(input) -> Result<List<List<string>>, CsvError> reads records of fields: a field wrapped in " may contain commas, line breaks and doubled quotes, "" reading as one, and an unquoted one runs to the next , or line break. Records end at \r\n or \n, a final terminator adds nothing, a blank line is a record of one empty field, and the empty input is no records. It refuses what is not well-formed, each CsvError naming the byte offset: Unterminated for a quoted field the text ends inside, BadQuote for a " inside an unquoted field or anything but ,, a line break or the end after a closing one, and TooManyFields for a record past 10000 fields, the json TooManyKeys posture against a header chosen to flood the maps below. parse_records(input) -> Result<List<Map<string, string>>, CsvError> takes the first record as the header and keys every later record by it, RaggedRow where a row's count differs. render(rows) writes \r\n-terminated records and quotes a field only where it contains a comma, a quote or a line break, and parse(render(rows)) == Ok(rows). It is hand-written over the bytes, as the stdlib's parsers are, and the tools/bench csv group states the grammar form's cost beside it
terminalLow-level terminal control over the sys.term_*! seam. The escape builders are pure: move_to(column, row), 0-based with the 1-based ANSI conversion inside; cursor_hide and cursor_show; clear_screen, clear_line and clear_to_line_end; enter_alternate_screen and exit_alternate_screen, CSI ?1049h and l; and reset. There is a face-owned Color sum, the sixteen ANSI names plus Indexed(u8) and Rgb(u8, u8, u8), with a chainable Style: Style.new().fg(Red).bold(), where sgr(style) always starts from 0 so that a sequence stands alone, and styled(s, style) wraps with a reset. The thin [io] delegations are stdin_is_tty!, stdout_is_tty!, size!, enable_raw_mode!, disable_raw_mode!, write! and its byte-level twin write_bytes!, which a multiplexer relaying a child terminal's raw output takes. The full-screen pair every TUI calls: enter_full_screen! registers its undo bytes, leave alt screen and show cursor, through sys.term_restore_write! before switching, and no crash strands the terminal; leave_full_screen! undoes and clears the registration. The model is one actor owning the terminal, building frames as a joined List<string> and never as a quadratic rebind. Key input goes through the pure incremental decoder decode(input: bytes) -> Decoded { events: List<TermEvent>, rest: bytes }: complete sequences are consumed, the incomplete tail is returned for the next chunk, and malformed or unknown sequences are consumed and dropped, which leaves it unable to desync and unable to fail. It covers UTF-8 characters, C0 controls (Ctrl+letter, Enter, Tab, Backspace), lone-against-prefixing ESC for alt, CSI (arrows, Home, End, tilde navigation and function keys, the ;2, ;3 and ;5 modifier params, back-tab), and SS3 (F1 to F4, app-mode arrows). Key is the face-owned sum: Char(string) for one scalar, the navigation keys, and Function(u8). KeyEvent { key, ctrl, alt, shift } reports best-effort modifiers, documented as such. A decoded event is a TermEvent sum: Key(KeyEvent); Paste(string), one bracketed-paste burst, ESC[200~ to ESC[201~, delivered as a single event, which makes a pasted control character literal text; or Mouse(MouseEvent { kind, column, row, ctrl, alt, shift }), one SGR-1006 report at zero-indexed cells, where MouseKind is Press, Release or Drag of a MouseButton, plus Moved and Scroll{Up,Down,Left,Right}. Both are intercepted in the byte stream before the CSI path, and an incomplete burst or report parks in rest. enter_full_screen! turns paste and mouse tracking on for the session, and registers the off bytes as crash-restore. read_key!(timeout_ms) -> KeyRead, with Pressed(KeyEvent), Pasted(string), Moused(MouseEvent), Unrecognized(bytes), TooLarge(int), Truncated(bytes), TimedOut, Eof, Failed and Resized, reads byte at a time and never over-reads, disambiguating a lone ESC from a sequence start with a follow-up window of about 25 ms, silence after ESC being the Escape key. Unrecognized is the low-level face declining to destroy data: bytes decode consumed and could not name come back to the caller, a complete sequence this decoder does not cover on the pass that consumed it and a partial one at the follow-up timeout. decode itself still drops them, its contract being that it cannot desync and cannot fail; a program forwarding input onward reads through read_key! and writes them out, and an application ignores the arm. display_width(s) -> int and first_width(s) give terminal cell widths: UAX #11 East-Asian Wide and Fullwidth, and emoji-presentation, are 2; combining marks, format characters, default-ignorables and controls are 0, and a Wide-but-ignorable scalar such as a Hangul filler counts 0; everything else is 1. They come from committed range tables pinned to Unicode 16.0.0, with the extraction provenance in-file. Widths sum grapheme clusters, and an emoji sequence therefore measures as the one glyph a terminal draws. clusters(s) -> List<string> is the segmentation, joining combining marks and variation selectors, ZWJ sequences such as a family emoji, emoji modifiers such as a skin tone, and regional-indicator pairs such as a flag, while leaving a control on its own for a caller to drop. A cluster's width is its first scalar's, which is what lets a cell-placing caller walk clusters and measure each one with first_width. The known residual is a keycap sequence, which measures 1 where most terminals draw 2
envEnvironment-variable reads. env.get!(name) -> Option<string>, effect [env], gives Some(value) where set and None where unset; a non-UTF-8 value also answers None in v0. It is effect-gated for a reason: environment reads are the classic exfiltration target, cloud credentials and CI tokens, and a program that can touch the environment says so in its signatures and is refused wholesale by --deny env. Writes are absent, being a different authority and rarely needed. Beyond get!: vars!() -> Map<string, string> reads the whole environment, entries that are not UTF-8 being skipped as get! skips a value; home!() -> Option<string> is a named get!("HOME") with no password-database fallback; and temp_dir!() -> string is the POSIX rule spelled out, TMPDIR where set and /tmp otherwise. cwd!() -> Result<string, sys.FsError> takes [fs_read] and not [env]: the answer comes from the filesystem and names a location on it. Three things this module refuses. There is no set!: a process-wide write is invisible in a signature and changes what every later call sees, and process.run_with! takes an explicit overlay, which vars! feeds. There is no chdir!, on the same objection to ambient mutable state, and process.run_in! takes the directory explicitly. There is no unset!: the per-spawn answer is process.SpawnOptions's env_remove and env_clean, unsetting a variable being asked for only on behalf of a child. And there is no exit!: main!'s int return is the exit path, and a mid-script exit skips whatever the caller stacked behind the call. Each would be a one-line delegation, and the reason is recorded in the module doc in place of reading as an oversight
randomRandom draws: random.u64!() -> u64 and random.bytes!(n: i32) -> bytes, effect [random]. It is effect-gated for a reason: minting randomness is an authority a signature must declare and a run can refuse with --deny random, and pure code can never mint a nonce unannounced. Under --deterministic the stream is seeded from the run seed, reproducible so that property tests replay, and not cryptographic in that mode, and it is drawn from a PRNG distinct from the scheduler's, which stops consuming randomness reordering the actor schedule. Otherwise it draws from the OS CSPRNG, /dev/urandom. Range and float helpers are a later pure-Hanki surface
timeActor-shaped time. time.sleep!(ms: i32), effect [time], blocks only the calling actor, and a 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. A deterministic run therefore sleeps in zero real time, remains byte-replayable per seed, and a program that is merely sleeping is never misreported as deadlocked. The one exception is a sleep whose deadline comes due against an actor parked in a real OS call, which is spent in real time and never jumped over (§6). time.sleep_ns!(ns: i64) is sleep! at the clock's full tick, which makes a sub-millisecond pace expressible, a 16.667 ms frame being sleep_ns!(16666667), with the same virtualization. A virtual (gated) sleep is shutdown-interruptible on both tiers: when the program root returns, the terminating gate wakes a parked sleeper immediately with ExplicitShutdown and does not advance the virtual clock to its deadline, and the handler does not execute user code after the program has ended. A real, ungated sleep is shutdown-interruptible on the bytecode tier, and a REPL :reset need not wait it out, and it is not on the AOT tier: the known tier difference, which the ns sleep inherits. actor.send_after! provides scheduler-owned backoff and periodic delivery without parking an actor (§15). time.sleep! remains the primitive for delaying the current action. Clock reads: time.now_ms!() -> i64, wall-clock milliseconds since the Unix epoch; time.monotonic_ms!() -> i64, milliseconds since an unspecified fixed origin and never decreasing, for durations; and time.monotonic_ns!() -> i64, the same origin at full tick, for benchmarks and frame budgets a millisecond read cannot resolve, i64 nanoseconds holding about 292 years of uptime. All three take [time]. Under --deterministic all three read the gate's virtual clock, which ticks in nanoseconds, and sleep_ns! advances monotonic_ns! by its own duration, and even a program that prints timestamps replays the same bytes per seed. Beside them, time.local_offset_seconds_at!(epoch_second: i64) -> Option<i32> reports how far east of UTC the host's own zone runs at that second, 7200 on +02:00, per instant, and a daylight-saving zone therefore answers correctly either side of its transition. None means the host cannot place that second on its calendar. It has the same [time] gate, and it follows the clock under --deterministic by answering UTC. datetime.local_offset_at! is the typed face over it
datetimeAn offset-based calendar, clock and duration library, in the chrono, JS-Temporal and arrow class, pure Hanki over the arbitrary-precision int tier. Howard Hinnant's civil-calendar algorithms are total and exact, with no overflow and no year bounds, int's // flooring and % taking the divisor's sign. Eight opaque value types: Instant, an absolute UTC-timeline point in epoch nanoseconds; Date, proleptic-Gregorian year, month and day; Time, a nanosecond wall-clock time of day; DateTime, a date and time with no offset; Offset, a fixed UTC offset with |seconds| < 86400; OffsetDateTime, an Instant viewed through an Offset, the RFC 3339 timestamp, comparing by instant with the offset a display attribute; Duration, signed elapsed nanoseconds and not calendar months or years, whose length is not fixed; and Period, calendar-variable whole months, rendered ISO as P1Y2M, P0D or -P1Y2M, normalized by total months and compared by duration, which makes Period.of_years(1) equal Period.of_months(12) and 1.years > 11.months. Plus Weekday and Month enums, in ISO numbering with 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 and of_hours_minutes, DateTime.of. Parsers return Result<T, ParseError>. Calendar ops: weekday, day_of_year, iso_week, add_days, add_weeks, add_months and add_years, month and year shifts clamping to month end, days_until, and leap-year and month-length queries. Fluent units are properties on int after open datetime: 2.days, 5.minutes and 3.weeks build a Duration, and 2.months and 1.years a Period, read bare per §10, 2.days and never 2.days(). Date, DateTime and OffsetDateTime gain add_period(p), years then months, clamping to month end, and a Duration gains the Rails clock helpers ago!() and from_now!(), as in 3.hours.ago!() and 2.days.from_now!(). ISO 8601 and RFC 3339 throughout: to_iso and to_rfc3339, and Date.parse, Time.parse, Offset.parse, DateTime.parse, Instant.parse, OffsetDateTime.parse and Duration.parse, the last reading ISO 8601 durations PnDTnHnMnS and PnW. A zero offset renders Z there, and the _numeric siblings render it +00:00: Offset.to_iso_numeric, OffsetDateTime.to_rfc3339_numeric and Instant.to_rfc3339_numeric. SQLite, Postgres, Crystal and Rust all write the numeric form, and a program comparing output against theirs byte for byte needs to spell it their way. Both are valid RFC 3339, and the choice is a sibling method and no setting on the Offset value, which stops two offsets that compare equal rendering differently. Internal precision is nanoseconds, though the host clock resolves to milliseconds. Every function is pure except the host seam: now!() -> Instant, now_at!(offset) and today!(offset), effect [time], delegating to time.now_ms!, virtual and replayable under --deterministic, plus the two local-offset reads below. Scope: fixed UTC offsets only. Named IANA zones such as America/New_York, and DST transitions, need an embedded, yearly-updated tz database and are left to a community library, the chrono-core and chrono-tz split, and this surface is complete for offset-based work. The host's own offset is the one zone a program can ask about without that database, the machine already knowing it: local_offset_at!(instant) -> Option<Offset> and the paired local_now!() -> Option<OffsetDateTime>, both [time], over time.local_offset_seconds_at! and ultimately sys.local_offset_seconds_at!. It is asked per instant and never once: a machine that observes daylight saving is two offsets, and reads +02:00 in January where it reads +03:00 in July. The result is an ordinary fixed Offset, no zone is named or stored, and the scope above is unchanged. None means the host could not place that instant on its local calendar, one beyond the platform's range, and never that the machine is unconfigured; a host with no zone set answers UTC, which is an answer. Under --deterministic the read answers UTC on both tiers, following the virtual clock it is taken alongside: a run whose timestamps replay but whose offset came from the host would still print different bytes in Helsinki than in Reykjavik. Operators ride on the core Add and Sub traits: instant ± duration, instant - instant -> Duration, positive where the left is later, datetime ± duration, datetime - datetime -> Duration, odt ± duration, odt - odt -> Duration, date ± period, period ± period and duration ± duration, each delegating to the named method beside it. Two omissions: there are no Time operators, its add wrapping mod 24h and the named method's doc saying so, and no Date + Duration, Date being calendar-only, shifted with a Period or add_days. Format is the timestamp display builder. There are no strftime strings, and every piece is a value the checker sees. Format is an opaque sequence of parts, built from the part constants year, month, day, hour, minute, second, weekday_short_name, offset and offset_numeric, numeric parts zero-padded and matching the to_iso family, offset rendering zero as Z where offset_numeric writes +00:00, the same split as the to_iso and to_iso_numeric siblings; plus literal(text), and the fragments iso_date, year-month-day, and iso_time, hour:minute:second in whole seconds, which unlike Time.to_iso never appends a fraction. Concatenate with Format.of([...]), the canonical base, or with + through impl Add<Format> and impl Add<Format, string>, and after a leading Format a plain string is a literal: STAMP: Format = iso_date + "T" + iso_time + offset_numeric. Two places still need literal(...): a format's leading token, impl Add<string, Format> being barred since a builtin-primitive Self is H0573, and the elements of a Format.of list, List being homogeneous with no implicit coercion. Render with FMT.render(odt) against an OffsetDateTime, the one type with every part's accessor; a Date or DateTime render would force a Result on offset-bearing formats and can arrive later, additively. Unpadded variants arrive later the same way, as sibling parts. A top-level Format binding folds at compile time like any other constant, in both the list form and the + form
sqliteSecure-modern-strict SQLite (use sqlite), a pure-Hanki face over the sys.sqlite_*! native seam (§4), effect [db], both tiers. open_path!(path) and open_memory!() open with a hardened default profile most wrappers leave off: WAL, foreign_keys on, DQS off through strict_sql, defensive and trusted_schema hardening, a 5 s busy_timeout, synchronous=NORMAL, and a 64 MiB journal cap. open_with!(path, opts) overrides through a functional OpenOptions, defaults() plus the with_* updaters. It is named open_path! because open is a keyword. 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 to statement length, expression depth, virtual-machine program size, column count, ATTACHED 0 and the rest, through 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 and trusted_schema off, which is overridable, unlike the native lockdown, which is not an option, with cell_size_check as the paid extra. hardened() bounds hostile SQL, and the engine limits cap only what SQLite itself will accept, a different axis from --max-steps and --max-bytes, which bound the Hanki side; neither implies the other. The table policy, with_allowed_tables([...]) at open for file databases, or the one-way conn.confine_tables!([...]) after schema setup for the :memory: workflow, statically confines all later statements to the named tables. Reads and writes elsewhere, schema reads, all DDL, and pragmas, this face's own pragma-backed conveniences included, are denied at prepare time and surface as SQLite's authorization error. It is a static allow-list evaluated natively, with no callback into Hanki, it installs at most once per connection, a second install that could widen the set being Misuse, and it composes with the always-on lockdown and never weakens it. The boundary it draws is confinement within a database the caller legitimately opened; file reach is the lockdown's separate, non-optional job. The always-on security lockdown, no ATTACH, no URI filenames and no extension loading, is in the native seam and is not an option, and [db] grants a database and 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 and with 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, are a mid-statement lock wait, up to the busy_timeout, the busy wait running no virtual-machine ops, and time between statements. Its methods: batch! for multi-statement scripts; execute! for one statement, giving rows changed; query!, giving a materialized DbRows; execute_named! and query_named! for named parameters as a Map<string, DbValue>, where each :x, @x or $x in the SQL binds the map's bare-key x entry, keys taking no prefix, with coverage checked in each direction: a statement name missing from the map, a map key the statement never uses, and a positional ? slot in a named call are each Err(BindName(name)) and no quiet NULL; last_insert_rowid! and changes!; the transaction verbs begin!, begin_immediate!, commit! and rollback!; the savepoint verbs savepoint!(name), release!(name) and rollback_to!(name), nestable partial-rollback points, the name having to be a bare ASCII identifier with the hanki_ prefix reserved case-insensitively, SQLite matching savepoint names without case, and anything else is rejected before reaching SQL, a savepoint name being the one caller string in this face that cannot be a bound parameter; with_tx!<T>(f), which runs f inside a BEGIN IMMEDIATE, committing on Ok and best-effort rolling back on Err, and which composes with itself: called inside an open transaction it scopes an auto-savepoint in place of the BEGIN SQLite would reject, an inner Err rolls back only the inner work, and the outer transaction remains open; in_transaction!; busy_timeout!; integrity_check!; optimize!; backup_to!(dest_path), which online-snapshots a live database to a new file and returns pages copied, SQLite's backup API in shutdown-interruptible steps. Its effect row is [db, fs_write], the answer to a backup writing a caller-chosen path: in place of stretching what [db] grants, the one file-reaching sqlite call charges the file-write capability, and --allow db --deny fs_write reaches the database and cannot export it, leaving "[db] grants a database, never arbitrary file access" literally true. And close!, which optimizes and then closes. Query results are plain data, DbRows, Row and DbValue, and a result is sendable across actors. Results larger than memory stream instead through conn.stream!(sql, params), giving a Cursor with next!, columns! and close!, plus fold!(init, f) to drain through a closure, reading row at a time with bounded heap. The stream state sits inside the connection's own native cell, one stream per connection, a second stream! while one is open being Misuse, exhaustion or any error closing the stream, and ordinary calls interleaving freely, and there is therefore no separate resource and no finalization-order hazard. Read them with Row.col(name) and Row.get(i) and the strict, coercion-free DbValue accessors as_int, as_real, as_text, as_blob, as_bool and null?, or in one step through the same-named Row getters, row.int(name), real, text, blob and bool, each giving Option<T> and each being col composed with its accessor. Those collapse an absent column, a SQL NULL and a wrong kind into one None, the terse read for a query whose form the caller already knows; req_* and opt_* below tell those cases apart. DbRows iterates with map(f) -> List<T>, to_list() -> List<Row>, the bridge to List's own combinators, and each!(f), effect-polymorphic [e], and reading a result therefore never needs the index loop, which had to match an Option that could not be None, the index having come from length. Map a row to a typed value with the FromRow trait, T.from_row(row) -> Result<T, MapError>, on FromJson's model: a hand-written impl reads each field by name with the req_* and opt_* Row accessors, an absent column being MissingColumn and a wrong kind WrongType(column, expected, got), and opt_* mapping SQL NULL to None. @derive(FromRow) generates that field-name-equals-column-name impl. Bind parameters through the ToValue trait and the v(x) sugar: int and i64 become Int, f64 Real, string Text, bytes Blob, bool Int 0 or 1, and Option<T> NULL. Failures are sys.DbError: Constraint(kind, msg), BindCount, BindName, IntOutOfRange, MultiStatement, Misuse and the rest. Full-text search (FTS5) needs no flag and no wrapper, the bundled engine compiling it in, and CREATE VIRTUAL TABLE notes USING fts5(title, body) through batch! and WHERE notes MATCH ? through query! work today, with rank ordering and bm25, snippet and highlight like any other SQL function. Two non-obvious things ride with it. Search text from a user wants sqlite.match_all(text) or sqlite.match_phrase(text), both pure. Binding a parameter stops the text being read as SQL, and an FTS5 MATCH operand is then read as an FTS5 query: a raw milk OR bread from a search box is a boolean OR nobody asked for, title:x is a column filter, mil* is a prefix search, and a lone " is an outright error. match_all turns a line of words into a query requiring every word, splitting on whitespace runs, quoting each word literal and joining with an explicit AND; empty input gives "", which the caller must skip before MATCH, an empty FTS5 query being an error. That is the search-box case. match_phrase quotes the whole text into one literal phrase, where no character is an operator. And FTS5 does not compose with table confinement: every FTS5 query reads PRAGMA data_version, which with_allowed_tables and confine_tables! deny along with every other pragma, and a MATCH on a confined connection fails with the authorization error however many shadow tables the allow-list names. Search and untrusted-SQL confinement want separate connections. 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 being rejected at the call site (H0524), every UDF is therefore deterministic, and it registers SQLITE_DETERMINISTIC, usable in indexes and generated columns, plus DIRECTONLY, which bars it from untrusted-schema triggers and views. The function receives each call's arguments as one List<DbValue>, at 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, or an over-i64 int return, fails the statement the same way; no fault ever unwinds through the SQLite C frames, on either tier. The connection owns each registered function for its own lifetime, the conn being the closure's counted parent, and re-registering a name replaces the SQL binding. Under --deterministic replay the enclosing statement remains one OS-seam crossing, the UDF being pure compute inside it, and the replay log never sees it. Aggregates, window functions and effectful UDFs are out of scope for v1
supervisorPure restart-policy bookkeeping for actor supervision. RestartPolicy(max_restarts, base_ms, max_ms) supplies capped exponential backoff. RestartIntensity(max_restarts, within_ms), RestartWindow.empty(), expired(epoch) and reset() implement per-child or per-strategy-group sliding windows: next(window) yields RestartAllowed(updated) or IntensityExceeded, and one epoch-tagged scheduler expiry ages out each allowed restart without a stale timer touching a replacement window. The actor-specific wiring uses ActorRef.id() for same-type child slots, actor.send_after! for both backoff and expiry, ignores ExplicitShutdown, and chooses its own escalation. See §15 and examples/v0_1/supervised_restart; bytecode and AOT parity is covered
parsecheckBrute-force verification of a parser's failure diagnostics (use parsecheck), pure Hanki. A parser can report the wrong failure offset, or an expected set that has drifted from the grammar, and no ordinary test notices: the parse still rejects the input, and the only thing wrong is the message. This checks both against the parser itself. It needs nothing but the parse function, adapted to answer Outcome, accepted() or failed_at(offset), for any input, and it therefore reads a hand-written, table-driven or generated parser the same way. Everything rests on one observation: a prefix is viable when the subject either accepts it or fails only for want of more input, at the prefix's own end. viable? asks that, frontier walks it up to the longest viable prefix, the true failure offset, and continuations probes all 256 bytes there for the ones that keep the parse alive, the real expected set, as bytes and not as labels. check(probe, input, reported_at, expected, covers) measures a parser's own diagnostic against both and answers a Report whose missing names bytes that would have parsed and went unmentioned, and whose phantom names labels nothing would have parsed. explain renders it as one line, empty where clean?. The caller supplies covers because only the caller knows that "a number" means the ASCII digits. It is expensive, 256 probes per rejected input plus one per prefix, being a test and no parse
diagFailure reporting for hand-written recursive-descent parsers: a farthest-frontier accumulator Diag { farthest, expected, labelled }. want(d, at, label) records a terminal, farthest winning, an equal offset unioning labels, and an earlier one a no-op. expect(d, at, label) records a named phrase that outranks the raw terminals at that offset, innermost label winning, as extra/peg's own expect does. render(input, d) produces the line:column: expected ... message, with place_of behind it counting scalars the way hanki check does. The whole protocol is one rule, that every terminal test's failing branch calls want once, and reporting therefore costs no return-value threading, and a branch that later succeeds leaves a record the frontier filters out. Carrying the accumulator is not free: a parse function that takes and returns a Diag costs the parameter on every call, measured at roughly a quarter more on tools/bench's calculator. Thread it only through a parser that reports. A hot parser retains its plain signature and, on failure, runs a diagnosing twin over the same input to say why, parsing twice, and the success path pays nothing. parsecheck certifies each twin's diagnostics from outside, and the pair needs no harness to agree
vcsVersion-control facts for the build-time manifest host (§21): sha() -> string, the commit sha, and describe() -> string, git describe --tags --always --dirty, effect [Vcs], the block being effect Vcs. Reach them bare under open vcs, or as vcs.Vcs.sha() under use vcs. They are injected by hanki check, build, run and test when evaluating a hanki.config.hk, and a git-less source tarball reads cached values from a hanki.vcs.lock.config.hk, written by hanki build --refresh-manifest, in place of failing
uuidRFC 9562 UUIDs (use uuid): an opaque 16-byte Uuid with Display in the canonical lowercase 8-4-4-4-12 form, FromString accepting that form in either case, dashes required, a bare 32-digit string being a different format, plus Eq, Ord and Hash, which lets it serve as a Map key, nil(), to_bytes() and a version prop. Two generators: new_v4!, charging [random], 122 random bits, the identity saying nothing about when it was made; and new_v7!, charging [random, time], a 48-bit Unix-millisecond prefix and a random tail, which sorts identifiers by creation time as text, as bytes and as a database key, the sortable primary key v4 cannot be. Parsing and rendering are pure, and only generation is effectful. Under --deterministic both replay: the draw comes from the seeded stream, and new_v7!'s prefix from the gate's virtual clock
logLeveled diagnostics on standard error (use log), a thin face over the stderr seam. Level is Debug, Info, Warn or Error, with a Display impl rendering the uppercase tag, an Eq impl, and an Ord impl ordering by severity, and a caller keeping its own threshold writes if level >= min then log.emit!(level, msg). format(level, message) -> string is the pure line renderer, "[WARN] msg\n", and emit!(level, message) plus the severity-named debug!, info!, warn! and error! write it. The effect row is plain [io] and no user effect Log with a provide block. A provider whose head row is [io] contributes that [io] to the program's capability set anyway (§6, Capability accounting), and the effect machinery would buy precision in hanki effects output in place of authority, at the cost of making this the one module unusable before learning §6. Redirection is therefore the shell's job, 2>app.log, and the module inherits that seam's failure behaviour: a failed stderr write faults the run, and 2>&1 | head ends the program once the reader quits. One record is always one line. format escapes \n and \r in the message to their two-character spellings, and untrusted text cannot forge a second record indistinguishable from a real one. There is a process-wide minimum level, and a record ranking below it is dropped, on the runtime side of the seam and before rendering. A dropped record therefore costs the call and not the formatting, and merely logging remains plain [io]. It defaults to Debug, every record written. HANKI_LOG, taking debug, info, warn or error case-insensitively, seeds it for a run, and set_min_level!(level) and min_level!() change and read it mid-run from any actor, effective immediately for every actor. An explicit set outranks HANKI_LOG whichever order the two happen in, and an unusable HANKI_LOG costs one line of complaint on stderr and leaves the default, a misspelled diagnostics knob having to be unable to kill a run. Those three actions take [runtime_state] (§6): they observe process-global state the program does not own, the atom is viral and a caller declares it, and it is no capability and cannot be denied. They are in particular not [env]: the program performs no environment read, and the runtime consults its own diagnostic knob. What the threshold saves is the write and not the message. #{...} interpolation happens at the call site, and a costly one belongs behind a caller-side if. Ask enabled!(level) -> bool in place of comparing against min_level!(). It is the same question and the same [runtime_state] row, and it reads the rank off the level you pass and compares two u8s, where the min_level!() form builds a second Level from the stored rank and then dispatches Eq and Ord over the pair. It is not allocation-free, a nullary variant being a heap value and the argument itself still built, and it is the cheaper of the two, and cheaper than the call it guards. Level remains Ord for a caller keeping its own threshold. For a threshold fixed at build time, a comptime config.load costs the run nothing. One omission: there is no timestamp. Reading the clock would not widen every caller's row to [io, time], for the same reason [env] does not apply above, the runtime reading its own clock as it consults its own threshold knob. This is a gap and no principle. The _with! family, emit_with!(level, build) and debug_with!, info_with!, warn_with! and error_with!(build: () -> string), takes a pure thunk and builds the message only where the record survives the threshold. Hanki is strict, the string-taking forms interpolate at the call site whatever the level, and a dropped six-value message measured about 3.8 us per call on the AOT tier against about 0.55 us through the thunk form, the residue being the per-call closure. The _with! rows take [runtime_state] beside [io] because they check the level, which preserves the division that a checker declares the atom and a plain logger does not
regexRegular expressions matched by a Pike VM (use regex), pure Hanki, both tiers. One left-to-right pass tracks a set of live threads and runs each instruction at most once per input position, and matching therefore costs pattern by text, and no input can make it explode: (a+)+$ against a long run of as finishes in the time its length suggests. The price is stated: there are no backreferences and no lookaround, both refused at compile time with an error naming which and why, either making the language non-regular and costing the guarantee. compile(pattern) -> Result<Regex, RegexError> runs once, then matches?, find, find_all, captures, replace, replace_all and split run per input, the two replaces taking a Replacement, below. RegexError reports the byte offset, and a malformed pattern is a value that points at itself and no crash. The syntax is the familiar one: ., [a-z], [^…], \d, \w and \s and their negations, |, *, +, ? and {n,m} with a trailing ? for lazy, (e) capturing and (?:e) not, and ^, $, \b and \B. It is defined in the module header, implementations differing at the edges. Matching is over Unicode scalars and not bytes: . is one scalar and a class range compares scalars, which makes [α-ω] work, while the \d, \w and \s shorthands remain ASCII. ^ and $ bound the whole input and never a line. Two inherent bounds are reported as ordinary compile errors, a nesting depth of 128 and a 10000-instruction program, and together they stop a pattern exhausting the host stack on the AOT tier, where no other bound would. Matching itself needs no bound, having one already. compile is pure, a caller may fold a pattern in meta, and no separate comptime path exists. replace and replace_all take a Replacement and no string, built by Regex.replacement(spec) against the pattern that will expand it. $0 is the whole match, $n the nth group, digits read as one number so that $12 is reachable, $$ a literal dollar, and everything else literal text; a group that took no part in the match expands to nothing. The two steps are what make replace total: a reference to a group the pattern does not have is refused when the replacement is built, by the only code that knows the group count, in place of handing every call site a Result for a typo in a literal. Both steps are pure, a literal pattern and a literal replacement fold, and the typo becomes a build error. Named groups, a case-insensitive flag and anchored-search variants are all additive later. escape(s) goes the other way, quoting every metacharacter so that a value never written as a pattern becomes one matching itself: a path in a generated pattern, or whatever a stranger typed into a search box. The quoted set is this engine's, the backslash, ., ^, $, ?, *, +, (, ), [, ], {, } and the alternation bar, and no set borrowed from PCRE. A backslash before a letter or digit is a compile error here, quoting more would emit patterns that do not compile, and quoting less would emit patterns that compile and match the wrong text
pegParsing expression grammars as plain data (use peg), pure Hanki, both tiers, the structured complement to regex. The split between them is a threat model. regex covers flat lexical scanning where the pattern itself may be hostile, and it takes linear time from a Pike VM at the cost of backreferences and lookaround; peg covers recursive, structured matching where the grammar is author code and only the input is untrusted. A pattern is a value and no closure: sequence([literal("("), zero_or_more(ascii_digit()), literal(")")]) builds an inspectable tree. That is what lets a top-level binding fold at compile time, top-level value bindings always being compile-time-evaluated (§16) and the tree being lists and small structs with no Map and no closures, and what lets a grammar be validated statically in place of hanging at run time. The constructors, all pure defs: literal, any, one_of, none_of, scalar_range, ascii_digit, ascii_alphabetic, ascii_alphanumeric, ascii_whitespace, sequence, choice, optional, zero_or_more, one_or_more, repeat(p, min, max), ahead, not_ahead and end_of_input. matched_length(pattern, input) -> Result<int, ParseFailure> matches anchored at offset 0 and answers how many bytes the pattern consumed. It never searches forward. The semantics, each locked by a test: ordered choice, the first option that matches and never the longest; possessive repetition, a completed repeat never being re-entered, and one that ate too much gives none of it back; a sequence that commits, a later part failing failing the whole sequence, the enclosing choice being what retries; zero-width ahead, not_ahead and end_of_input; and scalar-safe stepping, which makes a matched length always a valid slice boundary while a literal still matches its bytes. The one bound is depth, 128, json's tradition, reported as a typed TooDeep and no fault: possessive repetition means unbounded work needs recursion, bounding recursion bounds the module, and the same cap bars deep native recursion from the AOT tier's host stack, where no other bound would catch it. There is no step budget, the grammar being trusted author code unlike a regex pattern, and no packrat memo table, whose input-by-rules storage is against this runtime's memory posture. ParseFailure reports the farthest offset any branch reached, and not wherever the last branch stopped, which is the offset a reader wants pointed at. Named rules arrive with rule(name), define(name, pattern) and grammar(start, definitions) -> Result<Grammar, GrammarError>, and that compile is where a PEG earns its keep: every way a grammar could misbehave at run time is a static error naming the rule, UnknownRule, DuplicateRule, LeftRecursion with the cycle in reference order, NullableLoop, and RuleTooDeep. Left recursion is the one that matters: sum <- sum "+" term is how arithmetic is written in most grammar formalisms and is what a PEG cannot run, and it is refused by name in place of looping. Detecting it needs to know which rules can match nothing, and nullability is computed first by fixpoint, a rule's nullability depending on the rules it calls and one pass under-reporting a mutual pair. The same nullability feeds the NullableLoop check, and the same leftmost-reference graph will feed a later step's expected sets. A validated Grammar has every reference already resolved to an index, and matching is List.get and never a Map. A top-level NUMBER: Result<peg.Grammar, peg.GrammarError> = peg.grammar(...) binding folds at compile time, validation and all, which is the payoff of the closure-free data representation, and such a binding sits in the module as the standing check. Grammar.parse(input) consumes the whole input, parse_prefix allows leftovers, and parse_with_max_depth names the cap. What a parse produces arrives with captures. matched_length still matches a bare pattern with none of these checks around it, and it tolerates a nullable loop and reports a stray rule(...) as UnresolvedRule. Captures are opt-in, the LPeg stance, and a grammar that only recognises builds no tree and allocates nothing per scalar. capture(p) retains the text p matched, tag(name, p) folds whatever p captured into one Node under that name, the tree builder, and it nests, and position() records an offset without consuming anything. Grammar.parse(input) -> Result<List<Capture>, ParseFailure> hands the tree back, and parse_prefix answers a Prefix { length, captures }. Capture is a public sum, Text(string), Node(string, List<Capture>) and Index(int), read by matching it, the way json is read. Two capture rules are stated. A branch that failed contributes nothing, a repetition's last, failed iteration included, which falls out of the accumulator being an ordinary immutable value, with no length to record and slice back to. And ahead and not_ahead discard whatever was captured inside them even where they succeed, which is standard PEG semantics and the thing people are surprised by. A Text payload is always a valid slice, every primitive consuming whole scalars and no capture offset landing mid-scalar; the hazard designed against is str.slice rounding a mid-scalar offset down, which would lose a character without saying so. Failures are the module's other stated ambition. A ParseFailure reports the farthest offset any branch reached, its 1-based line and column, what could have advanced there, and why it stopped. The farthest part matters: an ordered choice whose first option dies at offset 0 must not report offset 0 when a later option got to 20. The merge rule is megaparsec's, the greater offset winning outright and equal offsets unioning their expected sets, and that is enough on its own, and the grammar's first sets are never precomputed, a terminal that failed already knowing what it wanted and saying so on the way out. expect(p, label) names what p is for in one phrase, and a failure reads 1:1: expected a number in place of listing terminals; an expect inside another leaves the inner one alone, the innermost label being the specific one. Line and column are computed once at failure-construction time, in a single pass, and never during matching, and count scalars and not bytes, the convention hanki check prints for its own diagnostics, and a line of ééé puts its fourth column at byte 6. TooDeep is a distinct reason on the same form, and a depth-cap trip is distinguishable from an ordinary no-match. The text notation is the spelling to reach for. compile(source) -> Result<Grammar, NotationError> reads Ford syntax: name <- pattern rules with the first as the start, juxtaposition for sequence, / for ordered choice, *, + and ?, the & and ! predicates, '…' and "…" literals, [a-z] classes, . for any, (…) grouping, and {…} capture. It is self-hosted, parsed by a grammar built from this module's own constructors, which is the dogfood statement and the proof that the core is expressive enough. Two dialect choices need stating. Comments are -- and not #, a grammar living inside a Hanki string literal where #{ starts interpolation, and a #-commented grammar is a trap and no style question. And a missing <- makes the following line a continuation, a definition's expression running until the next name <-, Ford's notation as specified. What saves that from passing unnoticed is the grammar compile: the run-on word becomes a rule reference, nothing defines it, and validation answers unknown rule broken, referred to by good . It passes only where the run-on word happens to name a real rule, the case a test pins. The notation also names a capture, {:name: p :}, and records an offset, {}, LPeg re's spellings for tag and position, which is what lets parse_into decode a grammar written as text: without a named capture the notation could capture text and never label it, and the whole FromCaptures bridge would be unreachable from the readable spelling. A named capture wraps whatever the inner pattern captured, and {:word: {[a-z]+} :} is the named-scalar form while {:word: [a-z]+ :} names an empty node, tag over a non-capturing pattern. All three brace forms start with {, and ordered choice tries {: and {} before { p }. expect and a bounded repeat have no spelling, which is a decision and no gap. The notation is a readable subset, every candidate spelling either borrowed a symbol LPeg uses for something nearby (^), gave the brace a fourth reading ({n,m}), or added a reserved word (as), and the combinators already express both, as expect(p, label) and repeat(p, min, max). A grammar wanting either writes that one rule with constructors, the documented mixed style. Both staying combinator-only, together with a grammar built from data, a keyword list becoming a choice of literals where text could only splice strings and lose the static check, is what the constructors are for: LPeg's arrangement, where re is the sugar and the combinator API is for generated patterns. The two spellings measure within a few percent across the calc, json, match and url fixtures, all six of which are written as text. rfc3339_peg is not, as the documented form and no outstanding gap: its fixed-width fields need a bounded repeat(p, n, n), which remains combinator-only by the same decision. The notation's own grammar is a top-level binding, built once at compile time, and this module cannot ship a bootstrap that fails to fold. A caller's grammar folds the same way: a top-level G = peg.compile("…") is evaluated at compile time, notation parse and grammar validation together, and the readable spelling costs no more at run time than the constructor one. What neither spelling escapes is §16's aggregate caveat, smaller than it once was. The folded grammar interns and loads in one op, and it is a heap object in a per-actor heap and still has to be materialised once, the bytecode tier per actor and the AOT tier per thread. Every reference after that reads a cache slot on both tiers, and naming G inside the function that runs per input no longer costs the roughly 860 ns per call an AOT rebuild did. The probe that covers the whole path is a caller, crates/hanki-cli/tests/lang/peg_notation_folds.rs, since compile reads the _NOTATION constant and one constant cannot depend on another in the same module; examples/v0_1/peg_calc writes one grammar both ways and says which is which. A syntax error points into the grammar text with the same line, column and expected set any other parse failure reports. What it costs, and where not to use it. A grammar runs about 5x a hand-written parser on a realistic workload and much more on a small one: 4.84x on a roughly 500-byte JSON document, about 17x on a short URL, measured on the AOT tier by tools/bench.hk. The overhead is per parse and not per byte, and it amortises over a document and dominates a short string. Reach for a grammar for application-level parsing, where the parse is not the inner loop. Do not reach for it where parsing is performance-critical, and not inside the stdlib: a stdlib author cannot know whether a caller parses one URL at startup or a million in a loop, and the cost is inherited and cannot be reasoned about locally. json, http and datetime retain their hand-written parsers for that reason. Typed decode closes the loop from a capture tree back to a value. FromCaptures mirrors json's FromJson and xml's FromXml, with Grammar.parse_into(input) -> Result<T, DecodeError> dispatching through it. It is the post-parse half of the pair, and no transform combinator inside the grammar: a stored closure would cost the pattern tree its closure-free form and with it the compile-time fold, and decoding afterwards spends neither, which leaves every grammar foldable. The mapping convention is the one a grammar already suggests: a tag is a record and its children are the fields, read by position with child(at) or by their own tag with tagged, optional_tagged and all_tagged, the T, Option<T> and List<T> field reads, and a capture is a scalar. A scalar impl reads a capture, or the single child of a tag wrapping one, tag("age", capture(digits)) being how a named scalar field is spelled and the accessors handing back the node; a tag with several children is a record and is never unwrapped. The peel happens before the kind is inspected and not after, which is what lets tag("at", position()) decode as the offset it names, naming an offset having no other spelling. Impls ship for string, int, which also takes a position() capture, being one already, decimal and List<T>. There is no bool impl, nothing in a PEG saying which text means true, and decimal in place of f64, a capture being text and parsing it into a rounding type losing precision the input still had. parse_into requires one top-level capture, a decoding grammar wrapping its start rule in the tag that gives the record its fields, and anything else is a refusal and no guess about which capture was meant. A CaptureShapeError reports the path it failed at as a person/age name chain, a positional step reading [2], extended upward by within_capture, and DecodeError tells a parse failure, Unparsed, from a shape one, Unshaped
globPure lexical glob matching over slash paths, fs.walk!'s missing filter, which that module's own doc points to. compile(pattern) -> Result<Pattern, PatternError> runs once, then pattern.matches?(path) runs per path: filtering a walk means matching one pattern against thousands of paths, and re-parsing per path would dominate. matches?(pattern, path) is the one-shot for a single test. Syntax: * is any run that does not cross /, ** any run crossing it freely, ? one non-/ character, [abc], [a-z], [!abc] and [^abc] classes, a leading ] being a literal and a trailing - a literal dash, and \\x a literal. ** is the only construct that crosses a separator, and src/*.hk therefore misses src/a/b.hk while src/**.hk finds it. **/ spans zero or more whole components, and **/*.hk finds a file at the root as well as one nested, the edge implementations disagree on, settled that way because the alternative is a papercut on the most common glob anyone writes. A malformed pattern is an Err reporting the offset, and never a pattern that matches nothing without saying so, a typo'd filter returning no files being the worst outcome available. Pure and lexical like path: no filesystem access, and the same on both tiers

read_key! buffers unfinished input behind a documented finite byte cap and processes a stream in amortized linear time. Once an event crosses that cap, the sole terminal reader enters a persistent constant-memory discard state. Per-call timeout and resize results do not clear it or expose the unread tail as later key events; the reader drains through the event's syntactic boundary, or EOF, and then returns TooLarge(limit), where limit is the enforced cap; the attacker-controlled observed length is not reported. EOF with nonempty pending bytes that never crossed the cap returns Truncated(pending); the payload is bounded by the same cap, and bare Eof means no bytes were pending. Unrecognized(bytes) remains the lossless result for a complete sequence the decoder does not know and for an ordinary partial sequence abandoned at its follow-up timeout.

Pseudoterminal subprocesses: process.start_pty!(cmd, args, opts, columns, rows) -> Result<Child, sys.ProcessFailure> [env, process] is the third standard-stream disposition beside capture and attach. It returns immediately like start!, but creates a fresh POSIX pseudoterminal whose slave is the child's controlling terminal and stdin, stdout and stderr. This is the streaming form for preserving colour, isatty behaviour and interactive line discipline without lending the child the program's own terminal. The initial dimensions are in terminal cells; Child.set_size!(columns, rows) changes them and the kernel delivers SIGWINCH to the slave's foreground process group. The master is one bidirectional endpoint: write_stdin! sends input, read_stdout! receives the merged stdout and stderr stream, and read_stderr! returns Failed. SpawnOptions.merge_stderr is irrelevant to this face because a pseudoterminal is intrinsically merged. A pseudoterminal cannot half-close: close_stdin! hangs it up and makes later output unavailable. The Child remains actor-owned and has the same interruptible operations, termination, reaping and final-drop rules as a piped child. Dimensions must fit the platform's unsigned 16-bit terminal fields. The operation reports Err on non-Unix hosts; it does not pretend Windows ConPTY has the POSIX contract.

Child.write_stdin!(data, timeout_ms) returns after the first successful native write, which may accept only a prefix. A zero timeout polls, a positive timeout bounds the complete operation across retries, and a negative timeout waits for the first progress. Expiry returns Ok(0). An empty buffer on an open stdin also returns Ok(0); a closed stdin returns Err. An error accepts no bytes in that call. Retain data.view(count, data.length) after success and alternate writes with output reads. The caller owns every unwritten byte; the runtime queues none privately. PTY and piped children have the same write contract. Timeout neither closes a stream nor terminates the child.

Child.try_wait!() -> sys.ChildPoll [process] performs one immediate exit-status poll for piped and pseudoterminal children. ChildPoll has three cases: Running, Exited(i32), and Failed(ProcessFailure). An exit is reaped and cached; later polls, waits, and kills return the recorded exit code. The code follows ProcessOutcome.Exited, including -1 for signal termination. Polling preserves stdin and unread output, sends no signal, and treats pipe EOF independently from process exit. A polling error records no exit and releases the process handle, which a later kill or release must not signal; its failure operation is try_wait. A closed handle returns Failed. A cached failure from wait! or kill! also returns Failed.

Child.write_all_stdin!(data) -> Result<(), sys.ProcessFailure> repeats negative-timeout writes until all input is accepted. An error may follow an already-written prefix. Use it when the child consumes input without requiring this actor to drain output. Simultaneously full input and output queues can deadlock this helper. Both write forms charge [process], and Unix actor shutdown interrupts a parked write. Resize, wait, kill, explicit close, final drop, and PTY hangup follow the single-owner lifecycle above; neither write creates directional handles. Drain output before waiting for a child that may block while writing it.

A full-duplex owner can perform one bounded input/output step as follows. This example uses merged output, from a PTY or SpawnOptions.with_merge_stderr(true):

use process

open result

struct ChildProgress
  pending: bytes
  output: sys.StdinRead
end

def poll_child!(child: Child, pending: bytes) -> Result<ChildProgress, sys.ProcessFailure> [process]
  Ok(count) = child.write_stdin!(pending, 0i32) else Err(failure) -> return Err(failure)
  Ok(ChildProgress(
    pending=pending.view(count, pending.length),
    output=child.read_stdout!(8192, 0i32)
  ))
end

An actor stores the pending suffix in state, handles the read result, and returns from its handler after this step. Schedule the next step through actor.send_after! with a finite delay while work remains; a zero-progress result must not cause an immediate busy loop. Each return permits queued input, resize, and ordinary control messages to run. Separate stdout and stderr pipes need one bounded read from each per step. wait! belongs after output has reached EOF; kill! terminates the child while retaining its output for a final drain, and close! releases everything.

The lifetime exemption is process.spawn_detached!(cmd, args, opts) -> Result<(), sys.ProcessFailure> [env, process]. It reports whether the final executable started, then returns no Child, streams or exit status; actor death and program exit cannot reclaim it. Its three standard streams are the platform null device. On POSIX it starts a new session and double-forks. The final process is not a session leader and cannot acquire a controlling terminal later. SpawnOptions supplies cwd and environment while merge_stderr has no meaning. A stronger capability atom would be fictitious: [process] already permits launching a shell that backgrounds its own grandchild, and a second atom therefore could not form a sandbox boundary. The distinct name makes the exemption visible during audit; it is no option on an owned spawn.

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

regex's engine is fixed: a Pike-VM and Thompson-NFA matcher written in pure Hanki, linear in pattern by text, with no backreferences and no lookaround. The reason is §23's. The step budget, --max-steps, is bytecode-only and the AOT tier omits it, and for an AOT binary a linear-time guarantee is the only bound standing between a hostile pattern and unbounded work. A matcher is also the one decoder whose pattern is as likely to be untrusted as its input, any grep-shaped tool taking the pattern from its user. That places it alongside the other inherent, tier-agnostic bounds this section's decoders have. A derivative-based scanning layer over the same core is the intended later optimisation and no competing design, which is also how the production engines are built: .NET's non-backtracking engine is derivative-based and recovers capture groups through an NFA simulation, and Rust's regex pairs a lazy DFA for the search with a Pike VM for the groups.

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, and is not the primary format. The two take different forms. CBOR is type-driven, @derive(Encode, Decode) giving any type a codec through the Serializer and Deserializer framework. JSON is a standalone JsonValue tree you parse and then pattern-match, walk with accessors, or decode into and encode from your own types through hand-written FromJson and ToJson impls; its arbitrary key order, single number type and text escapes do not fit the positional framework, and it is therefore not auto-derived. Types are the single schema source: a type's @derive(Encode, Decode) fixes its CBOR encoding, and hanki cddl <path> emits the RFC 8610 CDDL describing that encoding, a publishable contract for non-Hanki consumers, where a struct becomes a positional array, a sum a choice of [tag, …] arrays, and List, Option and Map become [* T], (T) / null and {* K => V}. That structural inference applies to generated codecs only. If the schema closure reaches a type with a hand-written canonical Encode or Decode impl, each such impl must carry an impl-local @cddl("type-expression") attribute; where both impls exist, both attributes are required and their parsed forms must agree. The payload is one RFC 8610 type-expression right-hand side: rule definitions, group entries and trailing text are rejected. A named reference must resolve to a generated project rule, the CDDL prelude, or a generic parameter on the target declaration's emitted CDDL rule. Such a parameter binds by its position in the rule head when parsed; peer Encode/Decode forms compare that positional binding, independent of the parameter's source spelling. Thus @cddl("[T]") truthfully describes a hand-written codec for Box<T>. The original fragment is emitted, while parsed canonical form is used for the Encode/Decode agreement check. Without valid metadata, hanki cddl exits nonzero, names the type and impl on stderr, and emits no schema on stdout. An impl body is ordinary code and compiler reflection exposes no expressions by design; neither body inference nor a declaration-shaped fallback may publish a guessed wire contract. Requiring metadata on each direction makes each codec self-describing and prevents one annotated impl from standing in for its unannotated peer. The schema covers a project and no single file: a directory is read through its manifest's programs and exports, and a file is read as the entry of the project around it. The emitted rules span every module reachable from those roots. Relative and absolute spellings of a file path select the same entry and modules. Rule names follow the source spelling, bare for the entry module's own types and module-qualified for the rest (Envelope, wire.Frame), which is what makes the cross-module references resolve. RFC 8610 §3.1 blesses the dot for this, and its one caveat, that a dot continues a name and a range or control operator therefore needs surrounding space, cannot reach a generator that emits neither. A module the entry cannot reach contributes nothing and is named on stderr. 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, and the Hanki-native human-readable need is met by the configuration DSL (§21). Binary payloads and codecs traffic in the bytes core type (§4). A CBOR integer decoded into u8, u16, u32, i32 or i64 must fit that target's range; an out-of-range wire value is a decode error and never the modular fixed-width conversion an explicit Hanki to_* call would request.

Tier 3: contrib

Globally useful and neither foundational nor 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, and realized as a bundled package and no baked code. A contrib library is an ordinary package the manager resolves from a pre-seeded local source shipped in the toolchain directory, and it never bloats the compiler binary or stdlib_baked.bin. It is versioned with independent SemVer per library. The set is curated, and contrib libraries therefore earn short flat names (use money), the curation being what authorizes the name. Pure Hanki only: like every package, a contrib library has no @intrinsic and builds only on the effect faces core and extra expose.

Contrib resolution is the package manager's local-source path, with no bespoke loader. A deps entry with no URL binding is contrib (§21), and the resolver maps it to the pre-seeded contrib/<name>/ source the toolchain installs. Where that tree sits is derived from the executable's own location at run time, which leaves a toolchain relocatable with nothing baked in at build time: contrib/ beside the hanki executable, which a flat unpack produces; <prefix>/contrib for a <prefix>/bin/hanki, the split layout a release bundle installs; and <prefix>/share/hanki/contrib, the FHS spelling of that same split and the only one a /usr/bin/hanki can use, its prefix being /usr and no distro package being allowed to create a top-level /usr/contrib. In a dev tree the executable sits under cargo's target/, and discovery falls back to the repo-root contrib/. HANKI_CONTRIB_DIR overrides the search for any layout those miss, the counterpart of the link driver's HANKI_RUNTIME_DIR and authoritative in the same way: set-but-not-a-directory is an error and no quiet fall-through to the probes, an override existing to replace the search. The root is threaded and not re-read, and tests point resolution at a scratch dir. A toolchain that ships no tree at all is a partial install, and every diagnostic about it says so and names both remedies, reinstalling or pointing the override at a checkout. The toolchain bundles one version per contrib package. Resolution checks the declared constraint against that shipped version and errors naming it where unsatisfied, and the lockfile pins the reserved contrib:<name>@<version> identity with a deterministic sha256: tree hash over the seeded source, which has no git. --frozen hash-verifies that tree and never fetches, the tier shipping with the toolchain. Three packages exist in-tree and are resolvable: contrib/sql/, an sqlx-flavored SQL toolkit over extra/sqlite; contrib/tui/, a ratatui-flavored immediate-mode terminal UI toolkit over extra/terminal; and contrib/tz/, IANA timezone offsets and abbreviations for a named zone, over a committed tzdata table. tz is the tier's first data package, and the reason the tier exists: named zones and daylight saving have a release cadence of their own, and they sit beside the language in place of in it, the chrono and chrono-tz split, and datetime.local_offset_at! remains what it says it is, the host's zone with no database opened. hanki list enumerates the tier locally (§21). The curation and inclusion policy is 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), namespacing is therefore inherent, and name-squatting is structurally impossible, there being no flat global namespace to grab. The project offers at most a discovery index, a searchable directory of opt-in-listed repositories, and 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"), and its source then reads use http without minting a global short name; the alias is scoped to that project, which leaves the global namespace alias-free and squatting-free. That one binding does double duty: it is both the 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 and not contrib (§21). The alias is a handle on the whole package: use http reaches its http.hk and use http.<module> any other module it exports (§14), and a multi-module dependency is one binding and one deps entry, and not one pair per module. Independent SemVer, and pure Hanki only, as with contrib. universe is a provisional working name, the final name deferred, and unlike the curated tiers it is no governed set, only everything else, discoverable.

The boundary, locked

The decision locks these commitments, the parts a future change must not break without saying so. The descriptive table above is not itself the contract.

  1. The native seam is core-only. No package, curated contrib or open universe, may add native code, and the entire trusted native surface therefore remains the @intrinsic defs in core even with a full third-party ecosystem. This is enforced at check time: an @intrinsic declared outside the core stdlib, in extra or in user or package code, is a compile error at the def site and no deferred runtime failure.

The application root is the one amendment, and it reverses nothing. A program's own root may open a rift: a Rust implementation of the ops an effect declaration names, supplied by the application and linked into, or loaded by, that application (§6, Rifts). The sentence above remains literally true, no package gains native code, and nothing new is reachable through the package manager. What the amendment adds is scoped by who ships it: a provider sits in the application's own repository, is written by the same author as the program it serves, and is first-party by definition. A dependency cannot introduce one, cannot ask for one, and cannot tell whether one is present.

Three properties make that a scoping and no hole, and each is a rule the implementation enforces. A rift is bound only at the application root and never by a resolved package at any depth, a dependency's manifest carrying rifts failing resolution (H0641). The surface it implements is a checked effect declaration, and the boundary is typed and no open FFI. And the effect it answers is an ordinary capability atom that --allow and --deny gate, more strictly than the rest, a blanket allowance not covering it (§23). The atom is spelled two ways and a binding contributes both: the module-qualified pty.Pty, for effect Pty in pty.hk, and the spelling the program root's own effect rows use. An effect declared in the entry file is keyed bare, and a binding of main.Device therefore contributes Device (the H0645 rule below). A package may declare an effect and leave it unprovided, which is the capability-interface form, and only the root may answer with native code.

  1. 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.
  2. Universe identity is its git URL, which makes name-squatting structurally impossible and leaves the index discovery-only. Short flat names are reserved for the curated tiers, core, extra and contrib, and universe is aliased only project-locally.

Mechanism: the directory hierarchy

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

A 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, and user modules are untiered and can freely use or open either tier.

Seams and rifts are two words for one story, and both are enforced. A seam is core's @intrinsic boundary and is core-only. A rift is the application's own opening, typed by an effect, bound in its manifest, and accepted by name under an effect allowance; §6, Rifts, has the mechanics. 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, with a check-phase diagnostic at the def site in each case. The OS-capability primitives sit in one core module, sys, and the extra capabilities io, fs and process are pure-Hanki faces that declare their effect and delegate to a sys primitive: io.print! calls sys.stdout_write!, and fs.read! calls sys.file_read!. The entire trusted native surface is therefore the @intrinsic defs in core, the same closure The boundary, locked, invariant 1, applies to user and package code. The one addition invariant 1 makes to that closure is an application-root rift, which is first-party code in the program's own repository and nothing the package manager can reach. @intrinsic itself is core-only and is not the mechanism, and every sentence above about the seam remains literally true: the amendment adds that an application root may open a rift, and it does not widen the seam. module retains its own intrinsics as core substrate; it is core, and no face.

Almost every sys primitive is an OS capability and declares an effect row. The seam is defined by being native and not by being effectful, and two members show the difference: sys.aead_encrypt and sys.aead_decrypt, behind the extra/aead face, are pure, encryption being a deterministic function of its inputs, with no authority to gate and nothing to declare. They are native for speed alone, which the seam rule still confines to core, and the one effectful step in that flow, minting a key or nonce, is sys.random_bytes! and gated as always. A native primitive that needs no capability is therefore pure, and is not given a decorative effect row, which would make --deny and the signature both lie.

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 and never as free functions. Call sites use receiver-first method-chain form:

open option

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

The associated function for empty containers is on the type: xs: List<i32> = List.empty(). The annotation fixes the element type; there is no inline-type-argument spelling (§11). Accumulate with append; no ListBuilder is needed. A chain of n successive appends from empty takes O(n) structural work on both bytecode and AOT, including child-handle accounting, cached byte-budget charges and final release. Any subset of earlier versions may remain live. For example, old = xs, then xs = xs.append(value), then a read of old.length preserves this bound. Receiver uniqueness and element representation impose no restriction. Element construction, callbacks, indexed reads and actor transfer have separate costs.

For k appends starting at an existing List of height h, construction takes O(k + h), plus eventual release of the pre-existing storage. Each independently forked chain has this initial term; repeatedly forking at a carry boundary can repeat O(h) work. The amortized bound concerns append chains and includes the runtime's ownership and budget accounting, using the usual expected constant-time hash-table operations.

Let h count tree levels, including the leaf level. Length and cloning are O(1); indexed reads, updates, slices and concatenation take O(h) structural work, with the greater input height for concatenation. List height is O(log(n + 2)) in its current length, including after slicing. Slicing normalizes sparse top levels and shares unchanged interior subtrees. The stdlib combinators use indexed reads. Visiting m elements therefore takes O(m * h) traversal work, plus callbacks and result construction. A linear callback count is separate from total running time: partition makes n predicate calls, unique makes O(n²) equality comparisons, and merge sort makes O(n log n) comparisons, each with additional indexed traversal. Full operation descriptions are available through hanki doc --find list.

Low-level terminal control sits on the same native seam. sys.stdin_is_tty!() and sys.stdout_is_tty!() give -> bool [io], and false where piped or redirected. sys.term_size!() -> Result<TermSize, TermError> [io] gives TermSize { columns, rows } with int fields, per the index-surface convention, and NotATty where stdout is not a terminal. sys.term_set_raw!(enabled) -> Result<(), TermError> [io] is character-at-a-time with no echo, idempotent whichever way it is called, and the first enable saves the original terminal state. sys.term_restore_write!(s) [io] registers bytes written to stdout at process exit, a single slot with overwrite semantics, empty clearing it, and a full-screen face registers alt-screen-leave and 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, giving input bytes or a timeout: Bytes(bytes), TimedOut, Eof, Failed(message) or Resized, one OS read of up to max bytes, clamped to at least 1 with no accumulation, timeout_ms < 0 blocking indefinitely and 0 polling immediately. It reads the raw descriptor directly with no buffering layer, and interleaving with stdin_read_line! cannot swallow bytes. A parked read is cancellable by actor.shutdown! (§15). A finite timeout is a wall-clock bound across all read retries. Under --deterministic, a deadline already pending on the virtual clock bounds the native wait in real time (§6). 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 that returns through the C runtime, a normal main! return and the fatal 254, 253 and 252 exits alike. A terminating signal is not a return through the C runtime, and the same registration therefore takes over SIGTERM, SIGINT and SIGHUP. Each restores the saved terminal state and attempts the registered output in signal context, then re-raises at the default disposition; the process dies of the signal with status 143, 130 or 129. Without an actor watcher, a watchdog bounds the output attempt to 100 ms after terminal state is restored, subject to OS scheduling. A watcher uses its registered grace deadline for restoration and actor cleanup together. Blocked stdout cannot postpone termination indefinitely. Signal-path output is best effort and may be incomplete at the deadline. If the watchdog cannot be prepared or notified, the handler skips output, restores terminal state and re-raises. Stdout file-status flags are unchanged. A registered signal watcher receives bounded actor cleanup (§15). A signal already carrying a non-default disposition is left as it is, an inherited SIG_IGN under nohup and any handler an embedding host installed among them. The signal path writes a registration of up to 1024 bytes, past which the exit hook alone writes it, half an escape sequence being worse than none. abort bypasses both, SIGABRT not being a return and its disposition never displaced: that is how the runtime ends an unrecoverable seam failure, an allocation failure among them. SIGPIPE is the single signal the runtime disposes of, and its disposition is what leaves that one case on an ordinary exit path (§22). TermError is NotATty or Other(message). The ergonomic escape-sequence and key-input face over this seam is a staged extra library.

On Unix, terminal input also parks on a process-wide SIGWINCH self-pipe. StdinRead appends a dimensionless Resized arm, and terminal.KeyRead passes it through unchanged. The sole terminal reader receives it, then queries term_size! once; draining the self-pipe coalesces a resize burst. Thus timeout_ms < 0 remains idle-quiet while still waking for input, actor shutdown, or resize, and a timeout controls only an application's own tick cadence. The runtime installs a persistent resize handler only while the signal still has its default disposition, as for its terminating-signal ownership rule. Child stdout and stderr reads reuse the StdinRead type but never produce its stdin-only Resized arm.

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

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

@intrinsic is a top-of-item attribute, a 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 alone, and user code cannot declare its own intrinsics; the call site looks like any other Hanki call. The signature is the entire contract, and the body may be empty.

io.read_line! returns a sum and no bare string. Empty input on a still-open stream is a legitimate value, the user having pressed Enter, and must be distinguishable from end-of-stream, Ctrl+D or a closed pipe. To hold both cases, 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. Under use io the call site and variants are qualified, and under 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 being clearer than None for the end-of-file case, and because it leaves room for future variants such as Interrupted and Closed without breaking the API. Reading past Eof is the caller's responsibility: the scheduler goes on returning Eof, and 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, and the off-path developer tools, fmt, doc, lint, effects and the rest, can therefore be written in Hanki over a faithful parse and check model in place 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 (21)
@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, module_name: string) -> List<Diagnostic>
                                                         # `module_name` is the stdlib module `src`
                                                         # IS, so the shipped copy stands aside;
                                                         # `""` for ordinary source
@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, tier) set
@intrinsic def project_modules!(root: string) -> Result<Project, string> [fs_read]  # a project's own modules
@intrinsic def project_diagnostics!(path: string, src: string) -> Result<List<Diagnostic>, string> [fs_read]  # check a module IN ITS PACKAGE, `src` standing in for it
@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 and not meta, which is a keyword, since the modifier shows in the rendered signature. It reports encapsulated and intrinsic for the same reason, neither being recoverable from the signature. An @encapsulated def presents the same empty effect row a plain one does, and anything accounting for purity has to count it on its own line in place of folding it into either side; an @intrinsic body is a placeholder dispatching into the runtime, which is what makes it the hole in a pure-Hanki claim. Only a def can be @encapsulated, and both a def and a def! can be @intrinsic. parse's Item reports property for a @property test, which is otherwise indistinguishable from a plain one, both carrying kind == "test". That flag is reachable because parse hands back the raw items the source wrote, with no desugar run: a doctest fence inside a doc comment is not a synthesized test item, and a property test still shows the typed parameters the property desugar would otherwise consume. Anything counting a source's tests depends on that, the desugared view reporting a test per doctest as well. A meta constant's reflected body reports 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, two spellings differing only in layout rendering identically and a reformat being no value change, and a tool showing the user what they wrote wants the slice. Experimental. The surface is destined for the 1.0 contract and remains unstable until the tool ports validate its form. 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 and the REPL, directly, and in an AOT-compiled binary that calls one by linking the embedded front end, the same statically-linked compile pipeline and VM a load!-using build links (§14), and 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 retains the lean, LLVM-free AOT baseline and pays nothing, while one that does pulls the front end into the binary's size and trust surface, the same trade load! makes. Comptime reflection is a follow-up.