Skip to Content
Design decisions

Decisions

Frozen outcomes of the design brainstorm (2026-08-28). Changes to this file are design changes and should be deliberate.

Identity

  • Name: Kora
  • File extension: .ko
  • Config file: kora.toml
  • CLI: kora
  • Implementation language: Rust, single-language monorepo

Thesis

The checked workflow is the unit of execution. An agent is a callable budget and tool boundary inside that workflow, not a separate process by itself. Everything derives from this:

  1. Runs can be durable and resumable. With --durable, replay is based on an append-only effect journal, not a stack snapshot. A resumed run executes from the top with completed effects served from the journal. Without --durable, execution is intentionally ordinary and no resume promise is made. The contract is that code between effects must be deterministic. Output is journaled too, so a resumed run continues rather than retells.
  2. Real parallelism. OS threads + work-stealing scheduler, no GIL, no async/await coloring. parallel for is the isolation boundary: each worker receives portable copies, while a normal function or agent call stays in the caller’s interpreter.
  3. Information flow control. classified values cannot reach a model without a scoped declassify ... for <sink>: block. Direct model flow is checked statically; the runtime is authoritative for deep labels, dynamic values, and the sink policy declared in kora.toml. kora audit lists every declassify site.
  4. Budgets are native. Token-denominated (max_tokens, max_calls, max_steps, max_time), lexically scoped, nested (child may tighten, never loosen), shared across parallel for. Exhaustion is a value (Exhausted), not an exception; partial work survives. This holds inside a tool loop too: a meter that trips between turns returns Exhausted(meter) rather than raising, because a call that spends its budget answering tool requests is no different from one that had nothing left to begin with. Money is an optional display layer via [budget.pricing] in config — never enforcement.
  5. Context is a separate, lexical policy. with context(...) governs the request material sent to an analyze call; it is not a second way to spend or reserve the run’s budget. The policy estimates input deterministically, reserves room for the requested output, and retains only the newest whole tool exchanges that fit. It never silently truncates a prompt, data value, or exchange, and never calls a model to summarize. If the base prompt and data cannot fit, analyze returns Failed(reason).

Language surface

  • Python-like: indentation blocks, def, if/elif/else, for x in xs, f-strings, list/dict literals, comprehensions. Zero learning curve is a goal.
  • Conservative static types, checked before effects when facts are local. Type declarations go on the left: e: Expense = .... Dynamic integration boundaries remain runtime-validated.
  • New words (whole list): analyze, tool, agent, budget, context, parallel for, classified / declassify, ask_human, test, mock. use covers stdlib modules, Python, MCP servers, and other .ko files. Guards and the else binding reuse if and else rather than adding a word.
  • analyze(data, "prompt") returns a typed result via schema-constrained output. Result is Ok(T) / Uncertain(reason) / Exhausted(meter) / Failed(reason), handled with match.
  • No confidence float. Model must refuse explicitly (Uncertain).
  • A provider that does not answer is Failed(reason), not a crash. A refused connection, a timeout, a 429, a 5xx: the most common failure in a real agent program used to be the one failure the language had no value for, which made “failure is a value” true of everything except the part that fails most. The runtime decides nothing about what it is worth; the program does.
    • Failed is not Uncertain. One is the model answering no, the other is nobody answering. They are read by different people and fixed in different places — a prompt versus a provider — and a program that retries an Uncertain differently from a Failed is the normal case, not an exotic one. Collapsing them would hide an outage inside a refusal.
    • It is a breaking change, and deliberately not softened. Every existing three-arm match over a model call becomes non-exhaustive the first time a provider blinks. Mapping Failed onto Uncertain would have avoided that by lying. The unmatched-value error names the arm to add, and else was added first so the migration is one line rather than a fourth branch everywhere.
    • Journaled, never recorded to a cassette. A durable run that took the failure branch must take it again on resume, or the replay diverges from the history it is replaying. A cassette is the opposite: it is a fixture, and freezing one afternoon’s outage into it would fail every later run for a reason that no longer exists.
    • Tokens already spent stay spent. A tool loop that completed four turns and then lost the provider charged those turns as they happened. A budget that forgave them would let a failing call retry itself into a real bill.
  • Model calls retry, with backoff, and there is no way to mean “never”. http already retries a GET; the model transport was the one place the rule “timeouts and retries are mandatory with defaults” was not applied, and a hosted provider at its rate limit is the ordinary case rather than the exceptional one. [models] max_retries tunes it and 0 disables it — which is honest for a local model on the same machine, where a refused connection means the server is not running and waiting will not start it.
    • Retried: refused connections, timeouts, 408, 409, 429, and every 5xx. Not retried: the rest of the 4xx range, which is the request being wrong. The classification is made where the failure is observed, not recovered from the error message afterwards.
    • A provider’s Retry-After beats any locally computed backoff, capped so that a provider asking for an hour does not hold a thread for one.
    • The backoff is jittered, because parallel for fans out across every core and a shared rate limit hits every branch at once. Unjittered, they all march back into the provider together.
    • Retrying wraps one HTTP request, not the tool loop around it: a call that has already run three turns does not start over because the fourth request was rate limited.
  • A parallel for branch that raises still fails the loop. An expected failure is an outcome and comes back as a value in its own slot; a raise is a bug, and a bug in one branch is a bug. What changed is that the error now says how many branches had already finished, because “this is broken” and “this is broken on one input in two hundred” are different problems.
  • case arms take a guard: case Ok(a) if a.risk == "low":. A guard is evaluated with the pattern’s binders in scope, and a false one falls through to the next arm. Binders stay bound on a rejected arm, because Kora scopes by function like Python and an arm that had its own scope would be the single exception in the language.
  • A guard cannot call a model. Arms are tried in order, so a guard may run for an arm that never executes, and a model call there would make the token cost of a match depend on arm order — invisible spending is what budgets exist to prevent. The checker rejects a literal analyze in a guard; the runtime refuses one reached through a helper, because whether an arbitrary def eventually calls a model is not decidable at check time. Checker for the good message, runtime for the guarantee.
  • “Every matching arm was guarded off” is its own error, distinct from “no arm matched”. They have different fixes, and sending the reader to the wrong half of the match costs more than the branch that tells them apart.
  • x: T = <outcome> else: binds the payload or leaves. Every model call returns four ways, so chaining them with match costs one indentation level per call and buries the path that matters. The else form keeps the successful path at its own level and makes failure the exception.
    • The block must diverge (return / break / continue), checked, so the bound name is always defined afterwards. A name that might not exist is worse than a required keyword.
    • The right-hand side must be an outcome. Treating a plain value as success would mean the failure path could never run, which is a silently dead branch — the thing this language most wants not to have.
    • else (why, kind): preserves the lower-case outcome tag for the flat “record and leave” case. It avoids forcing four identical match arms merely to write a trace, without making else a disguised branch table. When categories need different behavior, match is still there.
  • Pattern alternatives bind the same names or do not parse. case Uncertain(reason) | Failed(reason): captures the ordinary shared-recovery branch without hiding which names a body may read. Alternatives with different binders are rejected instead of making a name conditionally exist.
  • stream is the standard-output shortcut, not a second result type. answer: str = analyze(...) stream means “show the prose as it arrives” while preserving the same outcome that an ordinary call returns. A custom on token(piece): handler remains for work beyond printing; a loop over pieces still cannot replace matching the terminal outcome.
  • Whether a stream may be retried is decided by what the program saw, in one place. The transport asks a single question — has a character of the answer been handed over — and every failure kind defers to it. Encoding the answer into the error type instead was wrong in the case that matters: a body can break after nothing but a keep-alive, a role-only delta, or a usage frame, and a run that forfeited its retry over one of those lost an answer it could have had for free.
  • An interrupted stream is a terminal outcome, not work to redo. Every other model call in a durable run can be sent again after a crash: nothing outside the process saw it, so the only cost of repeating it is tokens. A stream is the exception, because its pieces are output — the answer is on the user’s screen before the call has a result. So a streamed call marks its journal slot before it is sent, and a resume that finds that mark with output recorded under it returns Failed instead of asking the provider again. This is the live rule made durable: the transport already refuses to retry a stream that has emitted, for the same reason, and a crash is only a longer gap in the same stream. The mark also carries the other half of the rule — a stream that died before writing anything showed nobody anything, so it is sent again like ordinary unfinished work. Draining what the interrupted stream left behind is exact rather than heuristic: an effect that never returned can have nothing underneath it, so everything recorded after that slot in the same branch is the stream’s own fallout, and consuming it keeps the branch’s numbering where the resumed program will look for it.
  • Destructuring reads through a label and re-applies it. match on a classified outcome used to skip every Ok(...) arm, so a classified value silently took a different branch than the same value unclassified. Reading the structure through the wrapper fixes that; re-applying the label to every binding is what keeps the fix from becoming a laundering hole.
  • A mock is part of the test, so it crosses a parallel for boundary. Workers are fresh interpreters seeded with what they need, and the mock stack was the one thing not seeded — so a test that fanned out silently reached for a real model. The fan-out is the path most worth testing; leaving it the one path that could not be tested was a hole, not a design.
  • A mismatched mock falls through to the mock beneath it, rather than failing the call. with mock already nests like any other block, and Ok(SomeType(...)) already names its type through the constructor it calls — the missing piece was never syntax, it was that only the innermost mock was ever consulted, and a type mismatch there ended the search instead of continuing it. A flow that calls analyze for two different types now mocks each with its own nested block; the search tries the stack innermost first and only fails once nothing on it matches, reporting the innermost mismatch’s own error, since that is the one a test author is looking at. No new syntax, because none was needed.
  • Fixes over Python: no GIL, no async coloring, mandatory-at-boundary types, no bare except, one packaging story, no mutable-default-arg footgun.

File modules

  • A program may span several .ko files: use "./lib/tax.ko" as tax.
  • A quoted path is a file; a bare word is a stdlib module. Two syntaxes rather than one namespace, so an import can never be ambiguous and adding a stdlib module can never shadow somebody’s file.
  • Paths resolve against the importing file, never the working directory. A program is a directory, and it moves or vendors whole. A package is named rather than pathed (see Ecosystem strategy); paths need no infrastructure to work, so they came first.
  • as <name> is required. A path has no natural bare name, and inventing one from the file stem would bind a name the source never mentions.
  • Everything top-level is exported. No export keyword yet: adding privacy later only removes names, which is a change a checker can report, whereas guessing a privacy rule now would be one we could not take back.
  • Each file reads its own top level. A function resolves free names in the file it was written in, so importing a module cannot change what its code means. This is why functions carry their home module at runtime.
  • Types are global across the files of one package. A Money is one type everywhere inside it, so values cross file boundaries without conversion; declaring the same name differently in two of its files is an error, not two types. The sharing stops at the package boundary: two dependencies may each declare Config and they are different types, because the alternative is a hard error the consumer cannot fix, owning neither package. A type is identified internally by its package and name, and displayed by its name alone — except where two short names collide, which is the one case a reader cannot otherwise resolve.
  • A file’s top level runs once per run. Imports are cached by canonical path, so a diamond is one module, not two with separate state.
  • Cycles are an error, reported with the chain. A half-initialized module is the failure mode Python accepted; we would rather refuse it.
  • Budgets, labels, the journal, and kora audit all cross file boundaries unchanged. A module boundary is an organizing device, not a security one.

Memory model

  • Per-worker isolated heaps across parallel for. Whole-heap free on worker exit.
  • Within a worker: a small GC for v1 (swappable implementation detail).
  • No user-facing memory syntax at all. No Rust-style ownership for users.
  • Message passing: copy-only for v1; immutable shared buffers for big read-only data later if needed.
  • The isolated heap is parallel for’s, not every agent call’s. A worker gets a fresh Interpreter and a Portable snapshot of its inputs because it runs on a real thread alongside every other worker, and sharing live Rc<RefCell<_>> values across threads is the actual hazard isolation exists to remove. Calling an agent any other way — directly, or as tools=[specialist] inside another analyze() — runs in the caller’s own heap through the same call_function a plain function call uses, because nothing about it is concurrent: it is one more synchronous step in the same loop. Giving a tool-called agent a separate heap would make tools=[specialist] behave differently from calling specialist(...) by name for no reason the call site has — the thread is the boundary that matters, not the keyword.

Models

  • Providers: OpenAI (API key) + Ollama (localhost HTTP) from Phase 2.

  • A model is named the way its provider names it. [models] takes an entry written out in full, giving name, endpoint, and api_key_env side by side:

    [models] default = { name = "openrouter/free", endpoint = "https://openrouter.ai/api/v1", api_key_env = "OPENROUTER_API_KEY" }

    name is copied from the provider’s own documentation, never parsed. The older provider:model shorthand still works and is what the built-in defaults use, but it made the model name a Kora spelling of itself: a developer reading openrouter/free in a provider’s docs had to know to write openai:openrouter/free, and the prefix looked like a vendor when it was a request shape.

  • No provider is hardcoded beyond two request shapes. An entry says where it goes (endpoint) and which variable holds its key (api_key_env). The one thing it cannot read off a URL is which body to build, so api picks that: "openai" (the default, what nearly every hosted provider and gateway speaks) or "ollama". Two values, both naming a wire format rather than a company. Adding a vendor to Kora is then not a code change.

  • The endpoint belongs to the entry, not to the provider, so one program can route smart at OpenAI and cheap at OpenRouter. A single per-provider endpoint would have made the second role impossible to express.

  • No key configured means no key sent. An Authorization header is omitted rather than sent empty when an entry with its own endpoint names no variable — a local vLLM or llama.cpp server wants none, and a bearer token of "" turns that working setup into a 401. Reaching OpenAI itself with no key is still an error, because there silence is a mistake.

  • The name of the key variable is configuration; the key never is. kora.toml is a checked-in file, so it holds the name, and a missing key is reported under the name the config asked for — sending someone to export a key they do not have is worse than saying nothing.

  • local_model sink = Ollama. In-process GPU inference (llama.cpp/candle) is parked (Phase 7, optional — measure first).

  • Model choice/config: call-site > block > agent > main > kora.toml. The call-site level is analyze(..., model="vision"), and it takes a name from [models], never a provider spec — a vendor’s model name in a source file is how a program ends up routing on an environment variable.

  • Images are values, not an integration. fs.image loads one, analyze takes it, and it crosses a parallel for boundary like anything else. An agent-first language that can only read text cannot look at a receipt, a screenshot, or a scanned form, which is most of the work people actually hand a model. Kept deliberately narrow: raster images only (PNG, JPEG, GIF, WebP), no audio, no video, and no PDF — a document needs page extraction, which is a different problem wearing the same coat.

  • The pixels ride beside the JSON, not inside it. The data argument keeps an <image> marker in each image’s place, so a cassette key does not move when a file is renamed, and a base64 blob never reaches a log line.

  • A model call’s timeout is [models] timeout_secs, not a constant: a local vision model reading a 900px receipt runs minutes past what a text call needs, and a timeout that fires on ordinary work teaches people to disable it.

  • analyze(..., tools=[...]) on tool_call(name, args): watches one call before it runs. It hangs off the assignment rather than becoming a separate API, for the same reason as the streaming handler: the tool loop still produces one outcome to match on, so the handler is a thing the call does, not a second call. args is an ordinary mutable dict, so rewriting it is ordinary index-assignment rather than new syntax. return <str> inside the handler skips the tool and hands that string back as its result — the one place a handler is allowed to return, where on token’s explicitly isn’t, because this handler watches a call that has not happened yet and return is how it says “don’t.” Only one on ... block is accepted per call: watching tokens and watching tool calls are two different calls’ concerns (str results don’t take tools; typed results with tools don’t stream), not two things to attach to the same one.

  • A rewritten call’s actual arguments, not the model’s original ones, are what the tool history records. The next turn, and any trace of the run, should agree with what really happened rather than with what the model asked for and didn’t get.

Context engineering, phase 2

Proposed, not decided. Everything below is a design, not a build — no code, syntax, or config in this section exists yet. It is recorded here, ahead of implementation, for the same reason the rest of this file exists: so the shape is argued out loud once rather than improvised per pull request. It follows Anthropic’s effective context engineering for AI agents: context is a finite, curated resource, not something to maximize; retrieval should be just-in-time, not front-loaded; and long-horizon agents need compaction, note-taking, and clean-context sub-agents.

Kora already ships the compaction piece — with context(max_input_tokens=N, reserve_output_tokens=N):, a lexical fence with deterministic, journaled pruning that fails closed (Exhausted("context")) rather than truncating instructions or data, and never breaks label enforcement. What follows treats that fence as a given foundation and designs the four things the article calls for that Kora does not have: durable memory, retrieval, sub-agent handoffs with distilled returns, and curation of what a tool result adds back to context.

Notes before sessions, not sessions before notes

Kora’s shape changes what “note-taking” even means here. In the frameworks the article is written against, the model is the thing running the loop, so anything it should remember past a context-window compaction has to be written to an external file the next invocation can re-read — that is the entire reason note-taking exists as a pattern there. In Kora the program is the thing running the loop; analyze() is one stateless call inside it, and an ordinary local variable already survives for the program’s whole lifetime, independent of what any one call’s prompt contains. The classic problem the article’s note-taking pattern solves is already solved by Kora’s basic architecture. What Kora is actually missing is narrower: durability past process death, and visibility from outside the running process — because a plain variable dies with the interpreter, and nothing else can read it while the agent is still running.

That narrower thing needs exactly one identity: which run wrote it. A cross-conversation, cross-user session needs a second identity: which conversation (or user, or ticket) a given fact belongs to, independent of which run happened to record it. The second is a strict generalization of the first — a session is a note store addressed by an explicit key instead of an implicit one.

Recommendation: build the single-key form first, generalize to the explicit-key form second. The smaller primitive proves the mechanic — a storage tier distinct from the journal, label propagation on read and write, and a journaled record of what was read so replay doesn’t diverge when the backing store keeps moving — without also having to get the harder session questions right on the first attempt: capability grants, key collisions across unrelated callers, and eviction across many keys instead of one. This reverses the naming order the two are usually discussed in, but not the dependency: sessions are notes plus an explicit key, never the other way around.

Notes: a run’s own scratch space, made durable and visible

The problem. A long tool loop accumulates state — a plan, partial results, things learned two turns ago — that a program already holds in local variables. None of it survives a crash unless the whole run is journaled (it is, but only for that run’s own replay), and none of it is readable by anything other than the running process itself — not a human checking progress, not a differently-invoked kora command.

The design.

use notes notes.write("plan", ["find sources", "check dates", "draft"]) plan: list[str] = notes.read("plan", default=[])

A bare-word stdlib import, like use fs or use json — no name to give it, because there is exactly one notes store and it is implicit: the current durable run. read/write return Ok(T) / Err(reason), the same shape as every other stdlib effect (fs, http, sql), not the four-arm outcome analyze returns — nothing here is a model call. A classified value written with notes.write keeps its label on the way back out of notes.read, the same transitivity a value already has crossing a parallel for boundary or a function return; reading old notes back into a prompt is subject to the same declassify rule as any other classified value. A value read out of notes is unverified, matching every other value that arrives from outside the program’s own evaluation — a notes store is file- or database-backed, and what is on disk is not proven trustworthy merely because this program wrote it there once.

Notes are not journaled as part of Effect::Model / Effect::Tool / Effect::Human / Effect::Output (crates/kora-runtime/src/journal.rs) — those exist to make one run’s replay deterministic, and a notes store is explicitly meant to outlive the run that wrote it. But a notes.read inside one run is journaled, as a new Effect::Memory { key, value_json } variant, for the same reason time.now() and random() are effects: a replay must see the value the live run saw, even if another process appended more notes to the same store in the meantime. Without this, replay determinism would depend on the notes file’s contents at replay time instead of at run time.

Rejected alternatives.

  • A special result type on analyze() itself (e.g. a “scratchpad” argument the runtime manages) — rejected: it would make note-taking a property of one model call instead of the run, when the whole reason it is needed is state that outlives many calls.
  • No new primitive; just tell people to use fs.write — rejected: a plain file has no run identity, no label propagation, and no journal entry to keep replay deterministic against a file that moved between the live run and the replay. It would work once and be wrong the second time the file changed underneath it.
  • Automatic capture of every local variable — rejected: implicit, unbounded, and exactly the kind of ambient behavior the language avoids elsewhere (no auto-parallelization, no implicit try/except). A program says what it wants remembered.

Not in this slice. Querying notes by anything other than an exact key, a size cap or eviction policy, encryption at rest, and reading another run’s notes store by its run id (that is what sessions are for).

Sessions: the same primitive, addressed by an explicit key

The problem. An agent that should remember something about a user, a ticket, or a conversation across separate runs has no primitive at all — notes above is scoped to one run’s own identity, chosen implicitly. There is also no capability story: unlike the root program’s ambient access to fs/http/sql, a durable external store shared across every run of a program is closer in shape to an MCP server (a named, config-declared, long-lived thing outside the process) than to a bare stdlib module.

The design.

use session support as mem mem.write(ticket.id, "notes", updated_notes) history: list[Turn] = mem.read(ticket.id, "history", default=[])
[session.support] backend = "sqlite" path = "./support.db"

Named the way use mcp github as gh is named: support is declared in kora.toml the way [mcp.github] is, and the alias (mem) is the runtime handle. Everything about notes carries over unchanged — Ok/Err, label propagation on write, unverified on read, and a journaled Effect::Memory entry per read — with one addition: the first argument to read/write/forget is the caller-supplied key (a ticket.id, a user id, a conversation id) that notes fixed to the run’s own identity. notes becomes exactly session(<current run>).read/write with the key argument pre-filled — one runtime type underneath both.

A package needs a grant to use one, the same as net/fs/sql: grants = { session = ["support"] }, naming which declared stores it may reach — a dependency has no ambient authority to a store any more than it does to the filesystem. The root program needs no grant, per the existing rule that the root program is unrestricted, bounded only by its own kora.toml.

Eviction is explicit only, for v1: mem.forget(key, field). No native TTL — a scheduler primitive does not exist yet (see the capability roadmap in TODO.md , “Scheduler/cron: Build”), and syntax for automatic time-based expiry with no runtime mechanism to enact it would be decoration rather than a feature. An operational cap ([session.support] max_records) is a config-level knob enforced by the backend, surfaced as an ordinary Err(reason) when hit — not a new Exhausted variant, since Exhausted names a budget meter running out (tokens, calls, steps) and a storage quota is a different kind of limit wearing the same word would blur.

Rejected alternatives.

  • An implicit “current session” (thread-local or ambient context object) — rejected outright: Kora has no ambient anything — no implicit self, no request-local state — and which store a mem.read reaches should be visible at the call site, not inherited from an invisible context.
  • Storing sessions inside the existing Run/journal — rejected: Run (crates/kora-runtime/src/journal.rs) is scoped to one process invocation’s own replay and keyed by an internally chosen run id; conflating it with a durable, externally-keyed store would make the journal file grow across a user’s entire relationship with the program instead of one run, and would require replay determinism to reason about a different run’s history than the one actually being replayed.
  • A capability lattice keyed by store name AND field name (e.g. grant support.notes but not support.history) — deferred, not rejected: field- level grants exist nowhere else in the capability model (net, fs, sql are all-or-nothing), and introducing field-level scoping for exactly one effect would be an inconsistency the rest of the grant system does not share. Store-level grants match everything else; revisit only if a real program needs finer scoping and the pain is concrete.

Not in this slice. Encryption at rest, concurrent-write semantics for two parallel for branches writing the same key (today: last write wins, which is likely wrong and explicitly not designed here), a query language over stored history, cross-store queries, and automatic summarization on write — compaction is the context fence’s job, not memory’s.

Just-in-time retrieval

The problem. Kora has no embeddings, chunking, vector index, or retrieval primitive of any kind today (confirmed absent from crates/kora-runtime, crates/kora-models, and crates/kora-syntax). The article’s core claim — fetch what is needed when needed, rather than front-loading a whole corpus — is also the strongest existing argument for the context fence landing concurrently with this design: a retrieved chunk is exactly the kind of variable-sized, rankable content a token budget should gate before it ever reaches a prompt.

The design. Four small pieces, no vector database of Kora’s own yet:

use vector_index docs as idx e: Embedding = embed(paragraph) # model-transport effect chunks: list[Chunk] = chunk(document, size=500, overlap=50) # pure function idx.upsert(chunks) # Ok/Err hits: list[Chunk] = idx.search(embed(query), k=5) # Ok/Err

embed(text) -> Result[Embedding] is a model-transport effect exactly like analyze — it goes through the same provider abstraction, the same timeout/retry/backoff rules ([models] max_retries), and the same journaling, because it is the same kind of thing: a call to a hosted model that can time out, rate-limit, or 5xx. It returns Ok/Failed (no Uncertain — an embedding endpoint does not refuse, it either answers or does not) rather than the four-arm analyze outcome, because there is no model judgment to record as Uncertain.

chunk(text, size, overlap) -> list[Chunk] is a pure function, not an effect — deterministic string splitting needs no journal entry, the same way re and json parsing are non-effectful except for the unverified label already carried by whatever text they are given.

use vector_index <name> as <alias> is declared in kora.toml ([vector_index.docs], naming a backend) the same way use mcp <server> as <alias> is — a vector store is a long-lived external thing, and package grants apply to it the same way (grants = { vector_index = ["docs"] }).

A Chunk returned by idx.search is unverified by default — the same rule as fs.read, http response bodies, and model output: it is content that originated outside this evaluation, and a compromised or merely out-of-date index should not get to skip the narrowing every other external value needs before reaching a dangerous sink. Provenance (which document a chunk came from) rides as an ordinary field, Chunk.source, not a new label dimension — the label lattice is deliberately binary per axis today (see Parked/non-goals), and “which document” is data the program can already branch on, not a security property that needs its own channel.

Composition with the context fence is the whole point of sequencing this after Slice 1: hits handed into analyze() inside with context(max_input_tokens=N): is pruned or rejected the same deterministic way an oversized tool-history entry already is. Retrieval needs no budgeting logic of its own — it is a plain producer of ordinary values that the fence already knows how to gate.

Rejected alternatives.

  • Build a native vector store first (the “P3 native vector store” row in TODO.md ) — rejected as the first step, not as a destination. MCP shipped tool support before Kora had any native tool ecosystem of its own; the same sequencing applies here — a thin vector_index effect that can be backed by an external process today, with a native kora-vector backend behind the identical interface later if warranted, costs nothing to defer and de-risks the effect’s shape before committing to storage internals.
  • Leave retrieval entirely to MCP servers, no first-class construct — rejected as the only answer: MCP is the right way to reach a specific vector database (Pinecone, Weaviate) — the same way use mcp github coexists with the native http module — but leaving retrieval with no language-level shape at all would forgo the provenance labeling and context-fence composition that make it safe by construction, for every program that uses it, not just the ones that happen to write careful MCP wrappers.
  • A new label dimension for “retrieved” or “synthetic” content — rejected: redundant with plain unverified plus the ordinary source field, and the label lattice staying binary per axis is already a stated non-goal to relitigate only under real pressure, which this does not supply.

Not in this slice. Any specific vector database’s client, hybrid search or reranking, chunking heuristics tuned per document type, staleness or incremental re-indexing, and non-text embeddings — embed() here is text only, the same deliberate narrowness fs.image shipped with before video or audio were considered.

Sub-agent handoff with a distilled return

The problem. The article wants a sub-agent that runs with a clean context window and hands back a distilled summary, so a supervisor’s own context does not accumulate the sub-agent’s entire scratch work. Kora already has two relevant primitives, and neither is quite this:

  • Agent-as-tool (tools=[specialist]) resolves through the tools=[...] match on FuncKind::Agent in crates/kora-runtime/src/interp.rs (tool_list), and runs through the same call_function a direct specialist(...) call would — in the caller’s own heap, synchronously, by decision (see the Memory model section above): isolation exists to make concurrent threads safe, and a tool call is one more step in the same loop, not a second thread.
  • parallel for (Interpreter::run_parallel, same file) gives every branch a fully isolated heap via a Portable snapshot (crates/kora-runtime/src/portable.rs), but it is built for concurrent fan-out over a collection, not a single delegated sub-task, and a branch’s return value is whatever it happens to return — no summary convention.

One thing is already true by construction and needs no new design: the “clean context window” the article means is the model’s prompt, and in Kora every analyze() call already builds its prompt explicitly from whatever data and instructions the program passes it — there is no ambient conversation state to leak from a supervisor into a specialist’s call. A delegated agent’s model calls are already clean, because nothing in Kora ever implicitly carries a caller’s prompt history into a callee’s.

The design. What is missing is narrower than a new calling convention:

  1. Distillation is a type, not a mechanism. A specialist agent should declare a narrow return type (-> Digest, not -> RawTranscript); the type checker already enforces that the caller sees only the declared shape. No new syntax — this is a documented convention (docs/language.md, “Functions, agents, and tools”) plus an example in examples/patterns/, not a compiler change.
  2. An opt-in isolated heap for an agent called through tools=[...]. This is the one real gap: today isolation is fixed by call site (tools=[...] never isolates; parallel for always does), not by intent. A supervisor delegating one expensive, stateful sub-task through tools=[...] has no way to ask for the isolation a parallel for branch gets, short of wrapping it in a one-item parallel for, which pays the thread-pool machinery for something that is not concurrent. Proposed: an agent that opts in at its own declaration, agent specialist(...) -> Digest: isolated, so the effect is visible at the definition rather than at every call site that happens to reach it through tools=[...]. When called via tools=[...], an isolated agent gets a Portable snapshot of its arguments and its own fresh Interpreter, the same machinery run_parallel already builds — one worker, run inline rather than on a spawned thread, since there is nothing to parallelize.

Rejected alternatives.

  • Always isolating an agent reached through tools=[...] — rejected: it reverses the standing decision in the Memory model section above, for the majority of tool-agent calls that are cheap and share the heap on purpose; isolation should be something a specialist opts into, not something every caller pays for.
  • Automatic summarization of a sub-agent’s tool history, injected as its return value — rejected: this is model-generated compaction of a data path, exactly the failure mode the context fence deliberately avoided by choosing deterministic pruning over LLM summarization. A Digest should come from the specialist’s own explicit code (including, if it chooses, an analyze() call it makes on purpose) — never synthesized invisibly by the handoff mechanism itself.
  • A new handoff statement, separate from tools=[...] — rejected: it would duplicate the calling convention agent-as-tool already has (same call_function, same nested budget) for no capability beyond isolation, which the smaller isolated declaration already buys without inventing a second way to call an agent.

Not in this slice. Ownership transfer or supervision semantics (tracked separately in TODO.md , “Multi-agent/handoffs: Partial”), mid-call cancellation of a delegated agent, and cross-process or distributed handoff (“Distributed agents: Build”).

Curating a tool result before it re-enters context

The problem. analyze(..., tools=[...]) on tool_call(name, args): (crates/kora-syntax/src/ast.rs, ToolCallHandler; crates/kora-runtime/src/interp.rs, run_tool_call_handler) watches a call before it runs — a program can approve, rewrite, or substitute for a call the model is about to make. It has no visibility into what the tool returned, which is exactly the moment the article’s “just enough, right when needed” principle applies to what feeds back into the loop: a giant file read, a verbose API response, or a result containing something that should never reach the model’s context unredacted all need to be inspected after the tool runs and before the result is appended to tool history.

The design. A second, symmetric hook:

t: Ticket = analyze(raw, "triage this", tools=[search_kb]) on tool_result(name, args, result): if name == "search_kb" and len(result.body) > 2000: return truncate(result.body, 2000)

on tool_result(name, args, result): runs once per tool call, after the tool executes and before its result is recorded to tool history. result is a mutable value of the tool’s declared return type, edited the same way args is edited in on tool_call — ordinary field assignment, no new syntax. return <T> (matching the tool’s own declared return type, not hardcoded to str) replaces the result outright. The existing rule that a rewritten call’s actual arguments are what the tool history records gets a mirror: a rewritten result is what the tool history and journal record — the same reasoning, that any trace of the run should agree with what really happened, not with what the tool originally answered.

Both hooks may be attached to the same call — they watch two different moments of one tool loop and do not conflict, unlike the streaming handler which is mutually exclusive with tools by construction (str results don’t take tools; typed results with tools don’t stream):

analyze(raw, "...", tools=[search_kb]) on tool_call(name, args): print(f"calling {name}") on tool_result(name, args, result): result.body = redact(result.body)

Rejected alternatives.

  • One handler that sees both phases (e.g. result is None before the call and populated after) — rejected: args and result are different types with different mutation targets, and telling the two phases apart by checking whether a field is None is exactly the kind of implicit mode-switch the language avoids elsewhere (see the guard/case design, which forces separate arms rather than an implicit fallthrough).
  • Curation lives entirely inside the tool’s own body — rejected as the only mechanism: it works for a tool the program wrote, but an MCP tool is opaque, and result curation is a caller-side policy (redact before history, truncate a huge fetch) that should not require forking or wrapping every third-party tool to enforce.
  • Fold curation into the context fence instead of a hook — rejected as the only answer, though the two are complementary: the fence prunes deterministically by token budget once history exists, but it has no way to redact one field or replace one tool’s result with a one-line digest — that decision needs the tool’s name and arguments, which only a per-call hook has. The hook does semantic, per-tool curation; the fence does the token-budget-driven pruning of whatever the hook lets through.

Not in this slice. Retrying a tool call from inside the result hook, curating results for an MCP tool beyond its declared JSON shape, and any curation the program’s own handler code did not explicitly perform — nothing here redacts on the program’s behalf.

Security labels

  • classified (confidentiality, transitive through all operations, field-level granularity). Public by default; IO boundaries declare labels.
  • declassify <expr> for <sink>: scoped block only — no permanent declassified values. Sink-aware policy from config.
  • Redaction (redact()) is the blessed easy path: placeholders out, real values re-substituted, nothing secret leaves.
  • Terminal output is a redacting boundary. print and write render public structure but replace classified leaves with __CLASSIFIED__ by default, configurable per project under [output]. A declassify release names a sink such as local_model; it never doubles as permission to copy a secret into a terminal, test log, debugger console, or redirected output.
  • Integrity direction: data entering from outside is unverified and cannot reach a dangerous sink until narrowed. Shipped with the stdlib, since that is where the sinks are.
  • A release names one sink. declassify x for local_model: does not make x plain inside the block — it records what x was approved for, so a secret released to a model still cannot be written to a file three lines later.
  • Telemetry export is a labeled sink: classified values cannot reach spans.

Untrusted context and prompt injection

unverified is an integrity label, not a claim that text is safe for a model to read. Models must often inspect untrusted web pages, files, and tool output; no prompt delimiter creates a hard instruction/data boundary in a provider. Kora therefore does not claim to prevent prompt injection.

Tool results remain untrusted and enter the next model turn in an attributed envelope. The lexical context policy limits how much history remains. Named sinks, typed arguments, budgets, and human approval control what a confused or manipulated model can do. Delimiters improve model reliability; they are not a security boundary.

Testing & observability

  • Record/replay cassettes native, from Phase 2. Replay is CI default; (live) tests opt-in. Cassette format: human-readable JSON on disk.
  • A cassette key is a cryptographic digest, because a cassette is not a cache. SHA-256 over the length-prefixed key material — call site, model, prompt, resolved data, image fingerprints. A 64-bit mix was enough while the worst case was a slow run; it is not enough for a file that is committed, diffed, and replayed in CI, where a collision is a test passing against an answer recorded for a different question. The length prefixes do the same job the old separator byte did, more strictly: no split of the same bytes across two fields can produce one key. The previous algorithm survives as a read-only fallback, never a write path — an image cassette cannot be regenerated without the model that recorded it, and breaking every committed one to change a hash would be a worse trade than carrying sixty lines that only ever answer “is this an old file”.
  • mock analyze -> ... is a typed language construct, validated when the test runs. Local type facts around the call are also checked before effects.
  • Runtime is an OTel producer: agents and model calls are spans following the GenAI semantic conventions, declassifications are spans of their own, and budget spend rides on the agent span. Zero-config: a local file plus kora trace. OTLP JSON is emitted directly rather than through the SDK, which keeps an async runtime out of a synchronous interpreter. A metrics pipeline (as opposed to span attributes) is not built yet.
  • One internal event stream feeds cassettes, OTel, and --report cost output.
  • LLM eval (DeepEval-style metrics: answer relevancy, faithfulness, hallucination, G-Eval judge) ships as native stdlib primitives, not a Python bridge to the real DeepEval lib. Full DeepEval too big to reimplement; a subset of core metrics as Rust-backed builtins fits Kora’s no-Python-required packaging story and avoids the sidecar’s per-call serialization cost for what is likely a hot path in test. Same rationale as native stdlib layer in Ecosystem strategy below. Sequencing: alongside test/mock work, Phase 6.

Debugging

  • Debugging is a first-class tool, not a print statement: kora dap is a Debug Adapter Protocol server, so every DAP-capable editor gets breakpoints, stepping, a call stack, and a variables pane from one implementation.
  • The interpreter knows nothing about the protocol. It keeps a frame stack and asks a Debugger trait whether to stop; the translation lives in kora-dap. With none attached the cost is one Option check per statement.
  • A paused program is inspected from a snapshot, never from the live interpreter. When execution stops it pushes a complete copy of the stack and blocks; the adapter answers the editor out of that copy. Nothing reaches into a running interpreter, so inspecting a program cannot perturb it and there is no lock to get wrong.
  • Each frame snapshots its names before every statement it runs, so a frame that has called into another shows its names as they stood at the call. This is what makes an outer frame inspectable without unsafe pointers into a live stack.
  • Watch expressions are lookups, not evaluation: names and field paths only. A watch that could call a model, spend budget, or write a file is not inspecting the program, it is changing it — and a debugger that changes what it observes is worse than none.
  • A breakpoint on a blank line moves forward to the next statement, and the editor is told where it landed. A breakpoint that silently never fires is the worst outcome available.
  • parallel for bodies are not debuggable: branches are separate agents on worker threads with their own interpreters. Stopping one would mean stopping a thread the user cannot see, and the DAP thread model would have to grow to match. Deferred rather than faked.

Execution strategy

  • Stage 1: tree-walking interpreter. Stage 2: bytecode VM. Stage 3 (maybe never): cranelift JIT. Native codegen is NOT on the critical path.
  • Every stage is measured before it is left. benches/ holds twelve programs and scripts/bench.py runs them; CI A/Bs a pull request against its base commit on one machine and fails above 1.25x. The set exists so the cost of the current stage is a published number rather than an opinion, and so a feature that quietly taxes the hot path is caught by the branch that added it.
  • The pairs in that set (sequential/parallel, durable_off/durable_on) are deliberate: the price of a guarantee is only legible next to a run without it.
  • Breaking points, not just throughput. benches/ measures steady-state cost; scripts/stress.py separately answers “how far before it stops working”, under a watchdog cage (RSS cap, timeout, system-memory abort) so a probe that finds a real limit cannot take the host machine down with it. --history/--against-history on both scripts append to benches/history.jsonl / benches/stress_history.jsonl so a change’s effect is a diff against a recorded run, not a one-off number. Findings from the first pass (2026-08-31, measured on a 10-CPU Apple Silicon laptop):
    • Recursion depth. A user function call recurses through the host Rust stack (see the durability note above — no VM frame stack exists to bound). Past ~1900 nested calls the OS stack overflowed and the process SIGABRT’d — uncatchable, not a language-level error. Fixed: Interpreter::call_depth now errors at 1000 nested calls (interp.rs, call_function/call_function_body), comfortably under the real overflow point. A future bytecode VM with an explicit frame stack should revisit the constant, since it will not share the host stack’s ceiling.

    • String accumulation was O(n²). s = s + x in a loop reached binop’s Str(a), Str(b) => Rc::new(format!("{a}{b}")), which copies both operands on every append. Fixed for the two idiomatic forms (x = x + y and x += y, plain local names, no label/type/streaming complications) via Interpreter::try_inplace_str_concat: the current value is taken out of scope first, and if nothing else holds a reference (Rc::get_mut succeeds), its buffer is grown and appended to in place — the same refcount-checked trick CPython uses for the same pattern. Measured: 1M-char accumulation went from 15s to 0.44s (34x); the strings throughput benchmark from 317ms to 67ms. s = "a" + s (target not on the left) and any aliased case still take the copying path — correctness over completeness, and aliasing safety was verified directly (b = a; b = b + "x" leaves a untouched).

    • The durable journal was O(n²). Fixed by making the file append-only. Journal::persist serialized and rewrote the entire run on every effect. The file is now one JSON line per record — a header, one line per effect, one line per status change — and record() appends a single line. Measured on the same probe shape: 40,000 effects in 0.27s, against a previous ceiling where 15,000 could not finish inside 45 seconds. Run is what you get by folding the lines back together, which is also what made two-phase write records (below) cheap: a slot written twice is an attempt superseded by its outcome, and the later line wins.

      Three things came with it, none optional once the format changed:

      • A torn last line is dropped, not refused. fsync cannot prevent a machine dying mid-write; it can only bound what a crash costs. A half-written final line describes an effect whose result nobody saw, so loading discards it. A bad line anywhere earlier is real corruption and is reported.
      • fsync per effect, except output. A killed process loses nothing without it — the bytes are in the page cache, which outlives the process — so the question fsync answers is the power cut, where a lost tail makes a resume repeat something. For a model call that is money; for a write it is a duplicated row; for a human answer it is asking a person twice. For a print it is a repeated line, and a chatty program produces thousands of those: syncing them made --durable cost more than the work it protects (1,000 printed steps went from 0.8s to 12s). So output is written and left to the operating system, and that is documented rather than implied.
      • One process per run, enforced by the OS. Two --resumes of the same id would each replay to a different point and then record effects the other never saw. The lock is an advisory lock on a sidecar file (flock on Unix, an unshared handle on Windows), not a flag written into a file: a durable run’s normal ending is being killed, and only a lock the kernel releases on process death avoids leaving every crashed run to be unstuck by hand.

      Group commit, so a fan-out is not one fsync at a time. The per-write cost is two synced appends (the attempt, then its outcome), and a sync is milliseconds of waiting on the disk rather than microseconds of work. A parallel for writing rows therefore spent nearly all of its wall clock inside fsync, one worker at a time, at roughly a hundred rows a second.

      The fix is the one a database uses, and it is emphatically not syncing less often: every record still reaches the disk before the program acts on it, and the ordering that makes a write exactly-once — the attempt durable before the write is performed — is untouched. What changed is that a sync one worker is already performing now also covers the records other workers appended beside it. It works because fsync is not per-record: it flushes the whole file, so a worker whose bytes were written before someone else’s sync began has exactly the guarantee it would have got by syncing itself.

      Two details carry it. The sync happens outside the journal’s lock, on a second handle to the same file — a sync holding that lock would stop every other worker from appending, and there would be nothing to batch. And a worker about to lead a sync yields first, so a worker still finishing its write can join the same one; that yield is skipped when nobody else is waiting, so a single-writer run does not pay for a batching opportunity it cannot have.

      Measured on a laptop SSD, 400 rows written from a parallel for: 4.79s to 0.82s, 5.8x, or 82 to 488 rows a second, with the same run performing 131 syncs instead of 800. A sequential loop is unchanged, and deliberately so: one thread has nobody to batch with, and the honest answer is that this optimization does not apply to it.

    • Writes are journaled effects, so a durable pipeline is exactly-once. Before this, --durable covered model calls, tools, ask_human, output, the clock, HTTP and Python — everything except the call a data pipeline exists to make. A killed run re-executed fs.write, fs.append and sql.execute from the top: the language promised exactly-once and delivered it for the model’s tokens but not for the customer’s rows. They are journaled now, and a resume replays the recorded outcome instead of performing the call again.

      Recorded in two lines, not one, and this is the whole subtlety: a process killed after the write landed but before the journal knew about it would otherwise repeat that write. So the attempt is recorded and synced before the call runs, and its outcome supersedes it after. A resume that finds an attempt with no outcome stops and names the call — the same rule the tool loop already applies to a timed-out MCP call, and for the same reason: a timeout is precisely when whether it ran is unknown. Repeating it could double a row, and assuming it succeeded could drop one; only stopping is honest.

    • Reads are verified by digest, not replayed. The symmetric choice, made the other way. A pipeline’s input is routinely larger than every other effect in its run put together, and a journal that must hold it stops being a log of decisions. But a resume that re-reads live and gets different data is a run silently mixing two inputs into one output. So fs.read, fs.lines, fs.list, fs.glob and sql.query re-read on every attempt, with a digest of what came back journaled (Effect::Input): same data, carry on; different data, stop with an error naming the read. The guarantee is deliberately not “the same bytes come back” — it is “a run never silently continues against data it did not start from”.

    • Why it was left standing for a pass. The old Journal::persist wrote the whole run with an atomic write-temp-then-rename, which was the crash-safety mechanism, and its format was read by kora runs, kora answer and resume. Measured then: 1K effects/1s, 5K/4s, 15K past a 45s budget. Fixing it in the same sitting as the recursion guard and the string-concat fix would have put a persistence-format migration inside a performance pass, where a subtle bug defeats the guarantee --durable exists to provide silently. It got its own change instead, with the crash-injection tests it needed (crates/kora-cli/tests/durable_crash_test.rs kills a running pipeline and resumes it).

Time is a budget meter, not a separate cancellation concept

The language already had a lexical fence that nests, only ever tightens, is shared across a parallel for, and reports exhaustion as a value rather than an exception. A deadline wants every one of those properties. Adding max_seconds to budget gets them by construction; a separate deadline or a cancellation-token API would have restated them, and then had to be argued about separately when the two disagreed.

The deadline is stored as the instant it expires, not as a duration. Every worker in a fan-out is then measured against one moment, instead of each starting its own clock when its thread happens to begin — which is what “thirty seconds” means to the person who wrote it.

Time is reported ahead of the other meters when several have run out. A scope short of tokens and out of time has run out of time; naming tokens would send someone to raise a limit that was not what stopped them.

It reaches work already in flight. The deadline travels into the transport: every model call, HTTP request, MCP tool call, and package-helper call is given the smaller of its own timeout and what the scope has left, and the backoff between retries stops rather than sleeping past it. A provider that accepts a connection and goes quiet no longer holds the run for the full [models] timeout_secs.

This was deliberately deferred when max_seconds first landed, on the grounds that interrupting an in-flight call is the same “did it happen” problem that makes a tool call unretryable. It is — and the resolution is that the deadline does not answer that question, it only stops waiting for the answer. The outcome is Failed, which is exactly the outcome a transport timeout already produced, so nothing downstream had to learn a new case. What is not claimed is that the far end stopped working: a tool call cut short by a deadline may still have opened the issue, which is why it is not retried, the same as a tool call cut short by its own timeout.

What it still does not do. It does not bound computation — a loop calling no model is never refused, because a budget bounds agent work rather than CPU. That follows from reusing the existing meter, and it is an honest limit rather than a gap to paper over.

Why this one meter is journaled. The other three are counted from the program’s own spending, so a replay re-derives them exactly and a refusal can be recomputed. A clock cannot: a replay runs faster than the original, so re-deciding would let the same run answer differently the second time — and worse, send a call the first run refused. The refusal is written into the call’s own journal slot and replayed like any other outcome. It occupies that slot rather than skipping it, so the effects after it keep their positions.

This is also why the budget check now runs after the journal lookup rather than before. A replayed call returns what it returned; only a call that is genuinely fresh consults a meter.

A fan-out stops when the work is done, not only when time runs out

budget: max_seconds bounds a scope by the clock. The other half of cancellation is stopping because the answer arrived, and it had no spelling: a program racing three providers had to run all three and throw two away.

parallel for x in xs first: is that spelling. It is a modifier on the loop rather than a stop() a branch calls, for the reason a cancellation token was already rejected: something that reaches out of the branch it was called in is ambient authority, and the loop is the thing that owns starting the work. It reuses the machinery that is already there — the same work-stealing over the same shared budget — and adds one atomic flag checked where the next item is taken.

The loop yields one value, not a list. A race is asking a question, and the answer is not a list with holes where the branches that never ran would have been. A branch that falls off the end of its body has not answered, so it does not stop the race; a race nobody wins is None, which is exactly what such a branch produces, so there is no separate empty case to remember.

The winner is the earliest in input order, not the earliest to finish. This is the design’s one real surprise, and it is deliberate. Wall-clock “first” makes the answer a function of how the threads happened to be scheduled, and a language that replays its own runs cannot have that: the same program on the same inputs would answer differently on a busier machine. Input order is the same rule parallel for already follows when it folds results back, so a race reads like the sequential loop it replaces.

It is journaled, and it is the second effect that has to be. The first was max_seconds, for the same reason: a replay cannot re-derive the decision. It runs against a warm cache on a differently loaded machine, so racing again could pick a different branch — or answer at all where the live run did not. The winner’s index is recorded beside its value, because a value alone cannot distinguish two branches that returned equal answers, and because “provider 2 won” is what makes a trace readable. A race with no winner is journaled too: otherwise a resume would find the slot empty, race again, and diverge by the other door.

A candidate stops later starts, not work already in flight. Once input N answers, workers do not start N or any later index. Lower indexes already in flight must finish because one of them can still become the input-order winner. Higher indexes already in flight also finish because killing a thread mid-statement could leave a half-written effect. This is the honest cost of a deterministic winner.

break has different semantics: it sets a stop flag that running siblings see at their next statement boundary. A request already sent still runs to its own deadline because interrupting one is the “did it happen” problem that makes a tool call unretryable. That deadline is what budget(max_seconds = N) bounds.

first is contextual, and stays out of the grammar file. Like stream and on, it is a keyword only in that one position, so a program that already uses first as a variable or a function name keeps working. It is deliberately not added to the editor’s keyword list: first is a common name, and colouring every one of them as a keyword would contradict the promise the contextual parse makes. A missing highlight is the smaller wrong.

break stops a fan-out that is still collecting

first covers the loop that is asking a question. It does not cover the loop that is still a map and has simply seen enough — a scan that found what it was looking for, a search whose answer was recorded elsewhere. break inside a parallel for body is that: the loop still yields its list, and the rest of the items are never started. break <value> also contributes the stopping branch’s own find, so the branch that decided to stop is not silent.

There was no other meaning available to take. A break in a parallel body did nothing at all before this, silently, which is the worst of the three possible behaviours. And a value on break is the only way a stopping branch can say what it found: a worker has an isolated heap, so it cannot set a flag the parent reads. Outside a parallel for the value is refused at check time rather than ignored — an ordinary loop has nowhere to put it, and a silently dropped value reads as working code.

The stop unwinds on the error channel, not on Flow. Flow cannot cross a call. A function that returned normally when its fan-out stopped would hand its caller a none, and the rest of that statement would then try to use it — turning a stop into a type error several lines from anything the program wrote. StopKind::Stopped rides the channel ask_human’s suspension already uses, for the same reason: nothing catches it on the way out.

Why both spellings exist, and which is the default. first is the one to reach for. It yields the answer directly, its winner is a function of the input rather than of scheduling, and it is journaled, so a durable run replays it. break cannot offer the last two: which branches finished before the stop depends on how the threads were scheduled, so the length of the results list differs between two runs on the same input. That is a real cost, accepted with open eyes, and the documentation says it rather than burying it — a program should not build durable logic on len(results) from a break. The alternative was to give every branch a slot and fill the stopped ones with none, which buys replay-stability at the price of salting a list of answers with holes; first already exists for programs that need the guarantee, so break keeps the shape that reads better for the case it is actually for.

What lands in the results. The branches that produced something, in input order. A branch that never started has no slot, and neither does one that was let go of part way through: both produced nothing, and a none in a list of answers would make sum and len lie about what the loop found.

An effect is identified by which call it is, not which line

Effect identity is what the journal checks on resume and what a cassette is keyed on, and it used to be file:line. A line number is a property of the text, not of the program. Adding a comment above a call moved it, which invalidated that call’s committed cassette entry and made an in-flight durable run refuse to resume — over an edit that changed nothing about what the program does. It happened during the work that wrote this section: a comment added to examples/14_streaming.ko took its cassette with it.

An operation id is structural instead: the enclosing function, and the position of this call among the calls that function makes (kora_syntax::ops). Comments, blank lines, and reformatting leave it alone. Adding, removing, or reordering a call moves the ones after it, which is right — a program that calls something new before this one is a different program, and a recorded answer for the old position is not an answer for the new one.

Calls are numbered, not every expression, so that x = 1 + 2 becoming x = 1 + 2 + 3 shifts nothing. Every effect this runtime journals happens at a call, so nothing is lost by the narrower rule and a great deal of stability is gained.

The table is per module. Spans are byte offsets into one file, so two modules both have a call starting at byte 40; one global table would give them the same identity. The site string names the module’s own path rather than the entry file’s, which also fixes a latent ambiguity: two imported files with an effect on the same line used to produce the same site.

Migration. Cassettes recorded under the old scheme keep replaying — the line-based key is computed alongside the structural one and used when the structural one misses — but they stay line-sensitive until re-recorded, since nothing in the file recovers which call a recorded line number was. Durable runs get no such bridge: the journal format is bumped, and a run journaled by an older build is refused with a sentence saying why rather than replayed into a misleading divergence.

This is deliberately not the typed effect-aware IR. There is no lowering and no second representation of the program — a side table over the AST, computed once, answering one question. The IR would subsume it, and is still worth doing on its own terms when streaming-with-tools or the bytecode VM needs it.

The four states every effect passes through

Retry, budget, journal, telemetry, and resume are five rules that used to be written five times, once per effect, and drift between them was where the exactly-once bugs lived. They are one rule with four states.

Prepared. Arguments are resolved, the budget has been asked whether there is room, the journal slot is claimed, and the telemetry span is open. Nothing has left the process. A crash here costs nothing: the resume re-executes and arrives in the same state.

Sent. The request is in flight. This is the state where “did it happen” stops being knowable, so an effect that is not free to repeat writes a mark into its journal slot before entering it. Three kinds do: a world-changing call (fs.write, fs.append, sql.execute), a streamed model call, and a model call given tools. A plain model call writes no mark, because repeating one costs tokens and changes nothing outside the process — the same trade the transport already makes when it retries.

Observed. A result has reached the program: characters handed to a token handler, rows appended, a tool’s answer returned. Retry ends here and not a moment earlier. The question is always “has the program seen anything”, never “did a frame arrive” — a stream opens with keep-alives, role-only deltas and usage totals that show nothing, and forfeiting a retry over one of those loses an answer for free.

Terminal. An outcome exists and is recorded. The journal entry replaces the mark, the budget is charged, and the span closes. From here the effect is replayed, never re-performed.

What a resume does with a slot follows from which state it stopped in:

Slot holdsMeaningResume
nothingPrepared, or an unmarked callre-execute
a mark, nothing under itSent, nothing observedre-execute
a mark on a writeSent, outcome unknownstop, naming the call in doubt
a mark on a tool-using callSent, tools may have runstop, naming the call in doubt
a mark on a stream, output under itObservedreturn Failed; never re-send
an outcomeTerminalreplay it, including the pieces it was written in

The two “stop” rows are the same answer to the same question, and they are deliberately not Failed: a program cannot recover from a tool that may or may not have charged a card, so the run ends where a person can look rather than branching on a value that would be a guess. The stream row can be a value because the loss is bounded and visible — the half-written answer is on screen, and the program taking its failure arm is the honest shape of that.

Budget is charged at Terminal, including on the paths that do not return an outcome. A handler that raises still charges the call it was watching: otherwise raising inside on token is the cheapest way to reach a provider, and the meter reads zero for work that really happened.

Ecosystem strategy

An agent program’s real imports are HTTP, JSON, CSV/Excel, SQL, dates, regex, file IO, and vendor SDKs that are themselves HTTP wrappers. The reasoning lives in the model, not in a library. That is ~20 libraries, not 500,000, so Kora does not need to replicate PyPI. Three layers, in this order:

What the stdlib fixes

Rewriting these libraries is only worth it if the rewrite fixes what everyone already knows is broken. Existing ecosystems cannot fix these because too much code depends on the current behaviour; a new language has exactly one chance.

Cross-cutting, enforced by the language rather than by discipline:

  • Every value that enters the program from outside is unverified. HTTP bodies, file contents, model output, tool results. An unverified value cannot reach a dangerous sink (SQL, shell, file path, HTTP URL) until it is narrowed by a parse or an allowlist. This is the integrity direction of the label system, and it makes SQL injection, SSRF, and path traversal type errors rather than review findings.
  • Nondeterminism goes through the journal. time.now() and random() are effects. Otherwise a durable replay silently produces different answers — a correctness requirement, not a nicety.
  • Failure is a value. No silent None, no exception that a caller forgets to catch. Same Ok / Err shape as analyze.
  • Timeouts and retries are mandatory with defaults, never optional extras.
  • Classified data never reaches logs or telemetry.

Per module, the specific defect being fixed:

modulewhat everyone gets wrongwhat Kora does
httpno default timeout (hangs forever); non-2xx looks like success until you call raise_for_status(); retries are a separate librarytimeout always set; non-2xx is Err, not a success object; retry with backoff built in; response body is unverified
jsonparses to untyped Any; errors say “line 1 col 4318”parses into a declared type; errors name the path: $.users[2].email: expected str, got int
csveverything is a string, or types are guessed and zip codes lose leading zeros; ragged rows pass silentlydeclared schema, no guessing; ragged or mistyped rows are errors naming row and column; BOM handled
sqlstring interpolation, therefore injectionparameters only; an unverified value cannot become query text at all
fspath traversal from untrusted input; silent overwrite; partial writes on crash; listdir/glob return filesystem order, which differs per machine; mimetypes trusts the extensionpaths from unverified data are refused; writes are atomic (temp + rename); overwrite is explicit; listings are sorted and return full paths; fs.image reads the type from the magic bytes
timenaive datetimes with no zone, then DST arithmetic bugsevery instant is zone-aware; there is no naive type; now() is journaled
recatastrophic backtracking (ReDoS)linear-time engine, no backtracking to exploit
pdfa scan extracts as "" with no error, so a pipeline writes empty records; pages are concatenated, so nothing can say which page a clause was on; a page that will not parse silently truncates the documentno text layer is Err, and pdf.info says has_text_layer before the call; pdf.pages keeps page boundaries; the page count comes from the document catalogue, so an unreadable page is named rather than dropped
yamla duplicate key silently keeps the last one, so appending admin: true to a file is an edit no parsed diff shows; YAML 1.1 reads NO as false (the Norway problem); aliases expand eagerly, so nine lines can exhaust memory (“billion laughs”)a duplicate key is Err naming the key and its line; the 1.2 core schema, where only true/false are boolean; alias expansion metered against a node budget, so a bomb is a value to match on; a multi-document file is Err from yaml.parse rather than a silently truncated bundle
xmla DOCTYPE entity can name a local file or an internal URL, so the parser becomes a file reader and an HTTP client (XXE) — Python needed a separate library, defusedxml, because the standard one’s defaults could not change; ElementTree reports a tag as {uri}name, so code strips the braces and matches the wrong namespace; character data is split between .text and .tail, so the obvious read of <p>Hello <b>world</b>!</p> loses two thirds of it; xmltodict gives one child as an object and two as a lista DOCTYPE is refused outright, which takes the billion-laughs bomb with it since that needs one too; a namespace is a field and lookups match the local name; text is all of an element’s character data in document order; children is always a list, at any length

1. Native stdlib, Rust-backed. Users cannot write use serde::Deserialize in a .ko file, but the interpreter is Rust, so a crate becomes a Kora module through a thin binding — the way Python’s json is C underneath. Planned modules and their backing crates:

modulecratenote
httpreqwest / ureqequal to requests
jsonserde_jsonfaster
csv, excelcsv, calamineequal
datapolarsbetter than pandas
sqlsqlx, rusqliteequal
timechrono / jiffbetter
reregexno catastrophic backtracking
s3, awsaws-sdk-rustofficial
pdflopdf, pdf-extracttext landed; rendering is the pdf_render package, through a helper, not a stdlib module; weaker than PyPDF at exotic encodings. Rendering a page to pixels is not in: it needs a full renderer (fonts, shading) with a C++ library behind it, which is a separate decision from parsing
searchtantivyLucene-class

Known gaps: scipy, sklearn, matplotlib, torch/transformers, and niche SaaS SDKs. Only the last matters for agents, and layer 2 covers it.

2. MCP integration — borrow an existing ecosystem. MCP is already the standard for “tools an agent can call,” with hundreds of maintained servers (GitHub, Slack, Postgres, Sentry, Linear, filesystem, Stripe…). Kora already has tool as a first-class construct, and an MCP server is a bag of tools, so the mapping is mechanical — one implementation, whole ecosystem inherited:

use mcp github as gh r: Report = analyze(issue, "triage this", tools=gh.tools)

MCP servers are separate processes, so each is a labeled sink: declassify x for github is checkable exactly like a model sink. Highest leverage per line of code in the project.

A tool server that does not answer is Failed, and is never called twice. Three decisions, all forced by the same fact: a tool call is a side effect someone else runs.

It has a deadline. Every request carries [mcp] timeout_secs, default 60, overridable per server for one that reaches a slow API. A blocking read on a pipe cannot be given a deadline portably, so the transport reads on its own thread and waits with one — without which a server that accepts a request and never answers stops the program forever. That is the worst failure of the three, because there is no error, no exit, and nothing to match on.

It ends the analyze call as Failed(reason), not as a crash. The same outcome a provider that does not answer produces, for the same reason: the program, not the runtime, decides what an unreachable dependency is worth. It is deliberately not handed back to the model as a tool result. A model offered “that tool failed” will reasonably try it again, paying the timeout every remaining turn until the budget is gone — and then report Exhausted, which names the wrong cause and sends whoever reads it to the wrong fix. A tool that runs and reports its own failure is different: that is an answer, the model can act on it, and it still comes back as a tool result.

It is never retried. This is where MCP parts company with the model transport, which retries freely. Generating twice costs tokens and nothing else; calling a tool twice may open two issues, send two messages, or charge a card twice — and a timeout is precisely the case where whether it ran is unknown. MCP does carry readOnlyHint and idempotentHint, but those are claims made by the server, which sits outside the trust boundary; believing one in order to run a side effect a second time is not a trade worth making. Starting a server and shaking hands is retried, because nothing has run yet. So the timeout is the whole protection for a tool call, which is why it is not optional and why zero is clamped rather than honoured.

One consequence worth stating: after a timeout the server may still answer. That stale reply is matched by request id and skipped, so it cannot be read as the answer to whatever the program asked next — a wrong answer being worse than an error.

3. Python via sidecar worker — the long-tail escape hatch. A separate Python process, data in / data out over IPC. No live object handles, no Python callbacks into Kora.

use python statistics as stats match stats.mean(readings): case Ok(m): print(m) case Err(why): print(why)

Chosen over embedded CPython (PyO3) because embedding would break three of the four thesis pillars:

  • Threading: embedding reintroduces the GIL into Kora. A sidecar keeps Kora GIL-free, and N workers give real parallelism that embedding cannot.
  • Durability: a CPython call stack cannot be checkpointed. As an RPC, a Python call is atomic — checkpoint before and after, never mid-frame.
  • Labels: an explicit boundary is a declassification site the compiler sees; embedded objects would be opaque and labels would vanish.
  • Packaging: no use python means no Python needed; the binary stays a single download.

Cost accepted: per-call serialization, and no live-object interop (df.groupby().apply(lambda ...)). Negligible next to real work per call.

Kora’s own packages are being built, starting with local path dependencies. One tool, no global installs, reproducible. A new registry is a ghost town for years, so layers 1 and 2 carry the weight meanwhile, and fetching is deliberately the last piece rather than the first.

Four decisions distinguish it from what everyone else ships:

  • The graph is derived from the source, not the manifest. [dependencies] says where a package comes from; the use pkg statements say whether it is needed. Declaring a hundred and importing four resolves four, transitively. This is exact rather than heuristic because a package name is always a literal token and Kora has no dynamic import — the property that makes depcheck and its equivalents guess elsewhere. A typo’d dependency is therefore never fetched at all, which is where dependency-confusion attacks start.
  • There is no [dev-dependencies] table. test is a language construct, so test-only reachability is computed rather than declared, and there is no wrong half to put something in. Runtime and test reachability come from running the same walk twice rather than propagating a label down the graph: a package reached by both a test path and a runtime path is a runtime dependency, and propagating “dev” would drop a shared transitive dependency from a shipped program. A dependency’s own tests are never roots for its consumer.
  • Names resolve against the manifest that wrote them, never a global table — the same rule as file paths resolving against the importing file. Two packages may bind one bare name to different sources, and a program cannot reach its dependencies’ dependencies.
  • Confinement lands before fetching. Per-package capability grants come before git dependencies, so no package is ever published into an unconfined ecosystem. npm’s ordering — fetch first, security retrofitted — is the mistake being avoided, and it is not recoverable afterwards. Grants are checked where the effect happens, and confinement follows execution rather than the call site: a package cannot shed it by spawning, by being reached through a tool a model called, or by handing the work to a dependency of its own. A parent may only pass on what it holds, so compromising a leaf gains an attacker nothing that every link above it lacked. One package granted two different ways by two importers is refused: the union would let a permissive importer widen what a careful one withheld, and the intersection would break the permissive one’s code.

The lockfile is authoritative: once a repository is locked, its commit is what gets fetched, never the tag again. Re-resolving the reference is how a force-pushed tag lands on a machine with a cold cache — the lockfile would be rewritten to the attacker’s commit and nothing would look wrong. That case is a test, because it is the whole reason the file exists.

An append-only checksum log closes the window the lockfile cannot: a project’s first fetch has nothing to check against, so a backdoor published briefly and withdrawn leaves no trace in any lockfile. The first sighting of a commit fixes what it contains, and a later disagreement is refused rather than resolved. Two logs are consulted — the project’s committed kora.sums, and a machine-level one shared across every project on the computer, so an honest fetch in one project protects the next. It is deliberately not a hosted transparency log: that needs a service somebody runs, and this narrows the window without one.

A manifest has no field for install scripts, and will not gain one. That is the whole of the postinstall attack class, refused by the file format rather than by a setting someone can turn off.

kora update is the only sanctioned way past the lockfile, and so the place a new version’s authority is examined: it refuses a bump that asks for capabilities the old version did not, or that declassifies in more places, until someone says they have looked. Advisory rather than load-bearing — the runtime still refuses to grant what kora.toml did not — because two independent gates beat one.

For third-party native packages, the destination is WASM components rather than dynamic libraries: sandboxed by construction, language-agnostic, and a sandboxed package cannot exfiltrate classified data. A component declares its capabilities, which makes it a named sink like any other, so declassify x for pkg:weather checks the same way MCP servers already do. Immature today.

Layers 1 through 3 are built. Packages are under way; WASM components wait.

Deferred, and what would start them

Neither of these is a hole in what exists. Both extend the package system, and each is waiting on something that is not code.

A hosted checksum log

What is built. kora.sums records what a commit contained the first time it was seen, in two places: the project’s own file, committed and shared with everyone who clones, and a machine-level one under ~/.kora shared across every project on that computer. A later fetch that disagrees is refused.

The window it does not close. If nobody in your world has ever fetched a package, and the attacker’s version is live the first time anyone does, that version becomes the record. The log is protecting the wrong bytes and there was never anything to compare against.

What a hosted log adds. One log everyone reports to, so the first fetch by anyone fixes what a version means for everyone after. Being the second person on Earth to fetch a package is then enough. This is what sum.golang.org does for Go.

Why it is deferred. It is a server: somebody runs it, pays for it, keeps it up, and every Kora user has to trust it. That is an operational and trust commitment, not a coding decision, and committing code cannot make it.

What would start it. Packages being fetched by people who did not write them — that is, a real third-party ecosystem rather than a handful of first-party ones. Until then the two local logs cover everyone who exists.

Package helpers: compiled work in its own process

The gap. Rendering a PDF page needs a real renderer — fonts, shading, transparency — and no production-grade pure-Rust one exists. Neither does one in Go, or Java. Everyone who rasterizes a PDF wraps PDFium, MuPDF, or Poppler. So “rewrite it in Kora” is not available for this, and will not be for the next capability like it either.

What it is. A package may declare one helper: a program Kora starts, sends a request to over a pipe, and kills. It speaks stdio/v1 — a length-prefixed JSON header and raw payloads, so a rendered page does not pay a third of its size in base64 — and the caller writes use helper inside the package that declared it.

Why a process and not a library. This is the same refusal as below, applied rather than restated. A .so loaded into the interpreter runs with the interpreter’s rights and never passes a grant check. A helper has its own address space, so PDFium — half a million lines of C++ parsing a file the program did not write — cannot reach the labels, the journal, or the budget even when it is exploited. Chrome does not run PDFium in its browser process either; it runs it in a sandboxed renderer. A separate process is the weakest version of that isolation, and it is still categorically stronger than linking.

Three properties follow, and each one is a thing an in-process library cannot offer:

  • A crash is a value. The child dies, the call is Err, the run continues.
  • A hang is survivable. The helper is killed on a timeout. An in-process loop cannot be interrupted at all.
  • It sees only what it is handed. Kora opens the file and sends bytes; a helper is never told a path. fs.bytes exists for this, because a PDF has no honest str form.

Why not in the compiler. Linking PDFium into kora would grow every release artifact by about 7 MB, add a per-platform binary to a release matrix that is currently pure cargo, and charge that to everyone who never opens a PDF. The capability belongs to the people who want it.

Why not the Python sidecar. It would work — pypdfium2 ships wheels with PDFium inside — and it was the cheapest path. It was rejected because “no use python means no Python needed” is a promise the language makes, and spending it to read a PDF is a bad trade. The sidecar stays for what it is good at.

Why not WASM, yet. WASM is the better sandbox and remains the end state (below). It needs a runtime compiled into the compiler, which grows the binary for everyone and is a subsystem rather than a module. A helper reaches the same capability today at native speed, with the isolation of a process rather than of a sandbox.

A helper is now confined, not merely separated. That last paragraph used to end by conceding that a helper was an ordinary program with ordinary operating-system rights. It no longer is. A helper declares what it needs:

[package.helper] needs = [] # the default: no network, no writing files

The operating system enforces it — a seccomp filter on Linux, a sandbox profile on macOS — so a confined helper cannot open a socket, cannot write to a file, and cannot ptrace the interpreter that started it. Absent means empty, because the alternative is that an author who forgets the line ships an unconfined process, which is the failure this field exists to close.

The author declares; the importer grants. needs = ["net"] is refused unless the program that imported the package holds net itself. This is not a new rule, it is the existing one applied where it matters most: the code on the other side of a helper is compiled, and nobody reads it.

It is a denylist, and saying so is the point. An allowlist of syscalls is stronger and is what a WASM component will eventually give. It is not what is written here, because an allowlist has to be right about every libc version, allocator, and C++ runtime a helper might link, and a wrong one shows up as a helper that dies on somebody else’s machine. The claim made is narrower and true: no network, no writing files, no reading the interpreter’s memory. Writes are bounded by RLIMIT_FSIZE rather than by the filter, because seccomp sees syscall numbers and never paths.

The confinement is inherited, so execve is not blocked. A seccomp filter survives fork and execve, so a helper that starts another program does not escape by doing so. Blocking execve would buy nothing and break helpers that legitimately shell out.

Windows is not confined, and is told so. There is no equivalent mechanism available to an unprivileged process, so a helper there runs with kora’s own rights and the person running it gets a warning on stderr saying exactly that. A sandbox that silently does nothing is worse than one that says so.

On macOS the helper is wrapped, not hooked. sandbox_init would have to be called from a pre_exec hook, which runs between fork and execve in a process whose other threads are gone but whose locks are not — and sandbox_init allocates, so that window can deadlock the child forever. sandbox-exec applies the profile and execs, with no such window. Linux seccomp stays in pre_exec, where it is two prctl calls over a filter built before the fork, which is async-signal-safe.

What WASM still adds over this is a jail rather than a fence: a component cannot escape, while a confined helper is an ordinary process denied specific things. That remains worth having, and worth having once.

How trust is anchored. A fetched helper carries a mandatory sha256; the manifest format has nowhere to put an unpinned one. kora install downloads only this platform’s entry, only for a package the program actually imports, refuses bytes that hash to anything else, and records what arrived in kora.sums. Nothing is executed to install it — the postinstall class stays closed. Whether a helper may run at all is the importer’s decision, through a helper grant like net or fs, and a helper is its own sink for classified data.

WASM components for native packages

The gap. A package is .ko source. Someone who wants to ship a fast PDF parser or an image codec, written in Rust, cannot: there is no way to include compiled code.

The answer that must never be taken. Loading a native shared library. A .so runs inside the process with full operating-system rights — it can read ~/.ssh, open sockets, and write anywhere, and none of it passes through call_module_fn, so no grant check ever sees it. One native package and the capability system is gone. This is a permanent refusal, not a deferral.

Why WASM instead. A component runs in a sandbox the runtime controls and can only touch what it is handed. A WASM package that declares net gets the network; one that does not, physically cannot reach it — the same rule .ko packages already follow, enforced by the sandbox rather than by our checks. Being language-agnostic is a bonus; being unable to escape is the reason.

Why it is still deferred. Package helpers now cover the capability gap — see above — at native speed and with no runtime compiled into kora. What WASM adds over a helper is a jail rather than a fence: a component cannot escape, while a confined helper is an ordinary process that has been denied specific things by a denylist somebody has to keep correct. That is worth having, and it is worth having once, as a general extension point, rather than being rushed for one package.

What would start it. A helper that needs to be confined rather than merely separated — one running code whose publisher is not trusted — or enough helpers that per-platform artifacts become the ecosystem’s main friction, which one .wasm would remove.

Parked / non-goals

  • Auto-parallelization (explicit parallel for only)
  • Embedded CPython (PyO3). Python support ships as a sidecar worker instead — see Ecosystem strategy.
  • GPU tensor compiler (that is Mojo’s war, not ours)
  • Native/JIT compilation, semantic-assert judging, label lattice beyond binary, unverified labels (designed, waiting)
  • Production-readiness claims before design-partner evidence and an independent security review

Status

The implementation covers phases 0 through 6, the standard library, MCP integration, the Python sidecar, and images as values. Packages have begun with path and git dependencies, capability grants, and a content-hashed lockfile, a checksum log, and the packaging commands; what remains is a hosted checksum log and WASM components. This is implementation status, not market validation: Kora is pre-alpha, has no compatibility promise, and still needs design-partner evidence plus independent security review before a production claim.

Reference documentation lives in docs/ : the language , the standard library , and the CLI . This file records why; those record what.

Phases

  1. Freeze + skeleton (kora --version) — done
  2. Core Python-like language + types + good errors + VS Code basics (highlighting, icon, run command) — done
  3. analyze (OpenAI + Ollama), typed results, cassettes — done
  4. agent, tool, parallel for, budgets — done
  5. classified / declassify + kora audit — done
  6. Durability (journal/replay, ask_human) — done
  7. test/mock, LSP (squiggles, hover, go-to-def, outline, completion), and OpenTelemetry tracing — done
  8. (parked) in-process GPU inference

Ecosystem work, sequenced alongside the phases above:

  • Native stdlib: json, yaml, xml, fs, time, re, http, csv, sql, env, notes, pdf — done
  • MCP integration (use mcp <server> as <alias>) — done
  • Python sidecar (use python <module> as <alias>) — done
  • Images as values (fs.image, multimodal analyze, fs.glob) — done
  • Package dependencies (use pkg, path dependencies, reachability-derived graph) — done
  • Per-package type namespacing — done
  • Per-package capability grants — done
  • Git dependencies, content-hashed lockfile, parallel fetch — done
  • Append-only checksum log — done
  • kora add / remove / update / vendor, kora audit --deps — done
  • A hosted checksum log, and WASM components — deferred; see Deferred, and what would start them

Each phase ends with a runnable demo program. Demo programs live in examples/.

Last updated on