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:
- 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. - Real parallelism. OS threads + work-stealing scheduler, no GIL, no
async/await coloring.
parallel foris the isolation boundary: each worker receives portable copies, while a normal function or agent call stays in the caller’s interpreter. - Information flow control.
classifiedvalues cannot reach a model without a scopeddeclassify ... for <sink>:block. Direct model flow is checked statically; the runtime is authoritative for deep labels, dynamic values, and the sink policy declared inkora.toml.kora auditlists every declassify site. - Budgets are native. Token-denominated (
max_tokens,max_calls,max_steps,max_time), lexically scoped, nested (child may tighten, never loosen), shared acrossparallel 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 returnsExhausted(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. - Context is a separate, lexical policy.
with context(...)governs the request material sent to ananalyzecall; 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,analyzereturnsFailed(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.usecovers stdlib modules, Python, MCP servers, and other.kofiles. Guards and theelsebinding reuseifandelserather than adding a word. analyze(data, "prompt")returns a typed result via schema-constrained output. Result isOk(T)/Uncertain(reason)/Exhausted(meter)/Failed(reason), handled withmatch.- 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.Failedis notUncertain. 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 anUncertaindifferently from aFailedis 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
matchover a model call becomes non-exhaustive the first time a provider blinks. MappingFailedontoUncertainwould have avoided that by lying. The unmatched-value error names the arm to add, andelsewas 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”.
httpalready 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_retriestunes it and0disables 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-Afterbeats 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 forfans 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 forbranch 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. casearms 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
matchdepend on arm order — invisible spending is what budgets exist to prevent. The checker rejects a literalanalyzein a guard; the runtime refuses one reached through a helper, because whether an arbitrarydefeventually 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
matchcosts 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 withmatchcosts one indentation level per call and buries the path that matters. Theelseform 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 identicalmatcharms merely to write a trace, without makingelsea disguised branch table. When categories need different behavior,matchis still there.
- The block must diverge (
- 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. streamis the standard-output shortcut, not a second result type.answer: str = analyze(...) streammeans “show the prose as it arrives” while preserving the same outcome that an ordinary call returns. A customon 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
Failedinstead 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.
matchon a classified outcome used to skip everyOk(...)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 forboundary. 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 mockalready nests like any other block, andOk(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 callsanalyzefor 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
.kofiles: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
exportkeyword 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
Moneyis 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 declareConfigand 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 auditall 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 freshInterpreterand aPortablesnapshot of its inputs because it runs on a real thread alongside every other worker, and sharing liveRc<RefCell<_>>values across threads is the actual hazard isolation exists to remove. Calling an agent any other way — directly, or astools=[specialist]inside anotheranalyze()— runs in the caller’s own heap through the samecall_functiona 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 maketools=[specialist]behave differently from callingspecialist(...)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, givingname,endpoint, andapi_key_envside by side:[models] default = { name = "openrouter/free", endpoint = "https://openrouter.ai/api/v1", api_key_env = "OPENROUTER_API_KEY" }nameis copied from the provider’s own documentation, never parsed. The olderprovider:modelshorthand still works and is what the built-in defaults use, but it made the model name a Kora spelling of itself: a developer readingopenrouter/freein a provider’s docs had to know to writeopenai: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, soapipicks 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
smartat OpenAI andcheapat OpenRouter. A single per-provider endpoint would have made the second role impossible to express. -
No key configured means no key sent. An
Authorizationheader 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.tomlis 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_modelsink = 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.imageloads one,analyzetakes it, and it crosses aparallel forboundary 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.argsis an ordinary mutabledict, 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 toreturn, whereon token’s explicitly isn’t, because this handler watches a call that has not happened yet andreturnis how it says “don’t.” Only oneon ...block is accepted per call: watching tokens and watching tool calls are two different calls’ concerns (strresults 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 amem.readreaches 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.notesbut notsupport.history) — deferred, not rejected: field- level grants exist nowhere else in the capability model (net,fs,sqlare 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/Errembed(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_indexeffect that can be backed by an external process today, with a nativekora-vectorbackend 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 githubcoexists with the nativehttpmodule — 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
unverifiedplus the ordinarysourcefield, 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 thetools=[...]match onFuncKind::Agentincrates/kora-runtime/src/interp.rs(tool_list), and runs through the samecall_functiona directspecialist(...)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 aPortablesnapshot (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 toreturn— 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:
- 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 inexamples/patterns/, not a compiler change. - 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 foralways does), not by intent. A supervisor delegating one expensive, stateful sub-task throughtools=[...]has no way to ask for the isolation aparallel forbranch gets, short of wrapping it in a one-itemparallel 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 throughtools=[...]. When called viatools=[...], anisolatedagent gets aPortablesnapshot of its arguments and its own freshInterpreter, the same machineryrun_parallelalready 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
Digestshould come from the specialist’s own explicit code (including, if it chooses, ananalyze()call it makes on purpose) — never synthesized invisibly by the handoff mechanism itself. - A new
handoffstatement, separate fromtools=[...]— rejected: it would duplicate the calling convention agent-as-tool already has (samecall_function, same nested budget) for no capability beyond isolation, which the smallerisolateddeclaration 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.
resultisNonebefore the call and populated after) — rejected:argsandresultare different types with different mutation targets, and telling the two phases apart by checking whether a field isNoneis exactly the kind of implicit mode-switch the language avoids elsewhere (see the guard/casedesign, 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.
printandwriterender public structure but replace classified leaves with__CLASSIFIED__by default, configurable per project under[output]. Adeclassifyrelease names a sink such aslocal_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
unverifiedand 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
--reportcost 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: alongsidetest/mockwork, Phase 6.
Debugging
- Debugging is a first-class tool, not a print statement:
kora dapis 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
Debuggertrait whether to stop; the translation lives inkora-dap. With none attached the cost is oneOptioncheck 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 forbodies 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 andscripts/bench.pyruns 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.pyseparately 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-historyon both scripts append tobenches/history.jsonl/benches/stress_history.jsonlso 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_depthnow 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 + xin a loop reachedbinop’sStr(a), Str(b) => Rc::new(format!("{a}{b}")), which copies both operands on every append. Fixed for the two idiomatic forms (x = x + yandx += y, plain local names, no label/type/streaming complications) viaInterpreter::try_inplace_str_concat: the current value is taken out of scope first, and if nothing else holds a reference (Rc::get_mutsucceeds), 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); thestringsthroughput 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"leavesauntouched). -
The durable journal was O(n²). Fixed by making the file append-only.
Journal::persistserialized 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 — andrecord()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.Runis 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.
fsynccannot 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. fsyncper effect, except output. A killed process loses nothing without it — the bytes are in the page cache, which outlives the process — so the questionfsyncanswers 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 aprintit is a repeated line, and a chatty program produces thousands of those: syncing them made--durablecost 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 (flockon 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
fsyncat 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. Aparallel forwriting rows therefore spent nearly all of its wall clock insidefsync, 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
fsyncis 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. - A torn last line is dropped, not refused.
-
Writes are journaled effects, so a durable pipeline is exactly-once. Before this,
--durablecovered 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-executedfs.write,fs.appendandsql.executefrom 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.globandsql.queryre-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::persistwrote the whole run with an atomic write-temp-then-rename, which was the crash-safety mechanism, and its format was read bykora runs,kora answerand 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--durableexists to provide silently. It got its own change instead, with the crash-injection tests it needed (crates/kora-cli/tests/durable_crash_test.rskills 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 holds | Meaning | Resume |
|---|---|---|
| nothing | Prepared, or an unmarked call | re-execute |
| a mark, nothing under it | Sent, nothing observed | re-execute |
| a mark on a write | Sent, outcome unknown | stop, naming the call in doubt |
| a mark on a tool-using call | Sent, tools may have run | stop, naming the call in doubt |
| a mark on a stream, output under it | Observed | return Failed; never re-send |
| an outcome | Terminal | replay 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()andrandom()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. SameOk/Errshape asanalyze. - Timeouts and retries are mandatory with defaults, never optional extras.
- Classified data never reaches logs or telemetry.
Per module, the specific defect being fixed:
| module | what everyone gets wrong | what Kora does |
|---|---|---|
http | no default timeout (hangs forever); non-2xx looks like success until you call raise_for_status(); retries are a separate library | timeout always set; non-2xx is Err, not a success object; retry with backoff built in; response body is unverified |
json | parses 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 |
csv | everything is a string, or types are guessed and zip codes lose leading zeros; ragged rows pass silently | declared schema, no guessing; ragged or mistyped rows are errors naming row and column; BOM handled |
sql | string interpolation, therefore injection | parameters only; an unverified value cannot become query text at all |
fs | path traversal from untrusted input; silent overwrite; partial writes on crash; listdir/glob return filesystem order, which differs per machine; mimetypes trusts the extension | paths 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 |
time | naive datetimes with no zone, then DST arithmetic bugs | every instant is zone-aware; there is no naive type; now() is journaled |
re | catastrophic backtracking (ReDoS) | linear-time engine, no backtracking to exploit |
pdf | a 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 document | no 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 |
yaml | a 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 |
xml | a 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 list | a 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:
| module | crate | note |
|---|---|---|
http | reqwest / ureq | equal to requests |
json | serde_json | faster |
csv, excel | csv, calamine | equal |
data | polars | better than pandas |
sql | sqlx, rusqlite | equal |
time | chrono / jiff | better |
re | regex | no catastrophic backtracking |
s3, aws | aws-sdk-rust | official |
pdf | lopdf, pdf-extract | text 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 |
search | tantivy | Lucene-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 pythonmeans 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; theuse pkgstatements 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 makesdepcheckand 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.testis 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
toola 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.bytesexists for this, because a PDF has no honeststrform.
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 filesThe 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 foronly) - 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,
unverifiedlabels (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
- Freeze + skeleton (
kora --version) — done - Core Python-like language + types + good errors + VS Code basics (highlighting, icon, run command) — done
analyze(OpenAI + Ollama), typed results, cassettes — doneagent,tool,parallel for, budgets — doneclassified/declassify+kora audit— done- Durability (journal/replay,
ask_human) — done test/mock, LSP (squiggles, hover, go-to-def, outline, completion), and OpenTelemetry tracing — done- (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, multimodalanalyze,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/.