hanki

23. Working in Hanki as an agent - the loop

The tooling below is one loop, not disconnected flags: sketch the program with typed holes and let hanki check dictate each gap; learn the surfaces you call from hanki doc --format=json and hanki query instead of reading source into context; close diagnostics with *explain → --fix → re-run* rather than re-deriving edits; test with hanki test --format=json (property tests for ∀-claims, --deterministic for actor code); run just-written code under the allowance hanki effects reports, via --allow/--deny; and before landing a change to a module others consume, let hanki api-diff classify the contract delta. HANKI-CARD.md §22 carries the same loop in compact form - the version every scaffolded project's agent reads. The bullets below are the per-tool depth:

The allowance also travels into the runtime: every module.load! / module.reload! re-enforces it against the loaded file's declared surface - same declaration-level semantics as the launch gate - and refuses a widening load with a catchable ModuleLoadError, so runtime-loaded code cannot escape the sandbox (bytecode tier). Default-deny for loaded code: even with no --allow/--deny, a program that loads modules at runtime pins its load-boundary allowance to its own declared capability surface, so a loaded module may perform only what the host itself advertised - a dependency understating its manifest simply cannot execute the undeclared effect (capability denied: in the caught ModuleLoadError). Because a host charges the effects of every Module<T> action it calls (§14), the host's surface is the ceiling it grants loaded code: to let a plugin do net, the host declares net on the trait method it calls - which also means --deny net on the host stops the whole composition, no capability-leak hole. This bites only at the load boundary; a program with no module.load! site keeps the unconstrained fast paths (cache, unbounded fuel) untouched, and an explicit --allow is the user's deliberate allowance that replaces the derived baseline. Bytecode tier (the AOT-embed loader passes no capability policy yet, so its loaded modules are still ungated - a tracked tier-parity gap).

Capabilities bound what a run may do; a companion bound caps how much: hanki run --max-steps N faults the run (exit 250) once N bytecode steps total across every actor are spent - the run can never execute more than N; steps are metered in small per-actor grants, so with several actors running at once the fault may fire marginally early, and a single-actor or --deterministic run trips exactly at N - and a sandboxed run (any --deny/--allow) applies a generous default budget when --max-steps is unset, so untrusted code can neither perform an undeclared capability nor spin forever. hanki run --max-bytes N is the memory companion - it caps total heap allocation (collections, tuples, closures, and the string / bytes / bignum backing bytes those objects hold) at N bytes across the run, faulting the same way (exit 250, with the same sandbox default), so CPU and memory are both bounded. The byte count is a deliberately conservative over-approximation - an Arc-shared payload held by several objects is charged once per holder - since a security ceiling should fail early, never late. hanki test takes both --max-steps and --max-bytes too - they bound each test, and a test that exceeds a budget fails (reported directly, never shrunk into a counterexample like a logic failure is). This is the bytecode tier's fuel-sandboxable mode (§22); the AOT throughput tier omits it.

Where those flags bound a whole run from the CLI, with_budget(bytes, steps) BODY end bounds a nested scope from within the language: it carves a sub-quota from the parent's remaining step + byte budgets — a child can never exceed the parent, and its spend debits the parent — runs BODY under it, and evaluates to Result<T, sys.BudgetExceeded> (T is BODY's type): Ok(v) on completion, Err(Exceeded) the moment the sub-quota is spent. Unlike the whole-run ceiling (which faults the run, exit 250), a with_budget overrun is catchable — the host survives an over-budget child and recovers by matching the result — so it is the in-language primitive for running untrusted or unbounded nested work (an embedded interpreter, a user-supplied predicate) under a bounded, recoverable ceiling. It nests: an inner with_budget sub-quotas the outer one, and a normal throw inside the body still propagates through it (only budget exhaustion is intercepted). Bytecode tier only, like the flags it scopes — the AOT lowering rejects it (that tier has no resource metering).