Skip to content

Koine Studio

Koine Studio is the full IDE for .koi files: a live editor with push-based diagnostics, an emitted-code preview (C# / TypeScript / Python / PHP / Rust), the ubiquitous-language glossary, a context map, hover docs, and go-to-definition. It runs two ways from one codebase (tooling/koine-studio/):

  • Web edition (hosted) — runs entirely in the browser. The Koine compiler is published as a WebAssembly module (src/Koine.Wasm) and called directly from the page, so parsing, validation, and emit all happen client-side. This is what you get at atypical-consulting.github.io/Koine/studio/, and it shares the WASM bundle with the inline playground on the home page.
  • Desktop edition — a Tauri v2 app (a Rust host wrapping the same web frontend) that spawns the Koine language server (koine lsp) as a child process and talks to it over JSON-RPC. Build it from tooling/koine-studio/ — see Run it (desktop).

Both editions are currently an MVP and share their entire language backend with the VS Code extension — the same parser, validator, and emitters as the koine CLI. What you see in the browser is exactly what the build produces.

The editor tooling guide covers bringing Koine into an existing editor (Rider, VS Code) through koine lsp. Koine Studio is the other end of that idea: a self-contained window with nothing to configure. Open it, type, and the model is parsed, validated, and previewed — the same way the build does it, because it is the same compiler.

The web edition loads the WebAssembly compiler bundle once, then calls its language-service exports (DiagnoseWorkspace, EmitPreview, Glossary, Hover, …) from a dedicated Web Worker — there is no server and no koine lsp process. The worker runs off the UI thread; a main-thread client routes each call over postMessage and resolves its response as a Promise. Cancellation works in two modes: supersede (drop a stale call) and terminate-and-respawn (abort a runaway compile by terminating the worker and booting a fresh one). WasmEnableThreads stays false, so the worker uses plain structured-clone postMessageno COOP/COEP cross-origin-isolation headers are needed.

The desktop edition reaches the same language service over a koine lsp child process instead:

Koine Studio (Tauri v2)
├── Rust host (src-tauri/) ─ spawns `koine lsp`, brokers JSON-RPC over stdio
└── Web UI (src/) ─ CodeMirror 6 editor + LSP client over Tauri IPC
koine lsp ─ the compiler's own parser + validator
  • The Rust host (src-tauri/src/lib.rs) spawns the language server lazily on first use, owns its stdin, and runs a reader thread that parses Content-Length-framed JSON-RPC off the server’s stdout. Each message is re-emitted to the frontend as a Tauri event.
  • The web frontend (src/) is a small LSP client over Tauri IPC. It runs the standard initializeinitializeddidOpen / didChange handshake, routes textDocument/publishDiagnostics into CodeMirror’s lint state, and issues the custom koine/emitPreview request to populate the preview pane.

Because the backend is the real koine lsp, Studio’s diagnostics land at the same line and column as koine build. There is no second, drifting implementation of the rules.

The WebAssembly runtime is a multi-megabyte download (the trimmed BCL + the Koine compiler and ANTLR assemblies). To keep repeat visits fast, the home-page Playground registers a service worker that cache-first serves its koine-wasm/_framework/* assets: the first visit downloads them, every later visit boots the in-browser compiler from the local cache (only a small boot-manifest check touches the network), and the Playground keeps working offline once warmed. The cache is keyed on the bundle’s content hash (resources.hash from the wasm boot manifest), so a new release transparently supersedes the old one — no manual cache-busting, no half-old/half-new runtime.

A service worker is the lever here because the site is hosted on GitHub Pages, which cannot set custom response headers: there is no Cache-Control: immutable to make the browser trust the bundle across visits, and no Content-Encoding: br (Pages serves gzip, not Brotli). It does serve application/wasm, so streaming instantiation already works. So Cache-Control: immutable and Brotli are “if/when the host changes” follow-ups — not enabled today; the service worker delivers the instant-repeat-load and offline wins regardless of host headers.

Koine Studio surfaces the enriched language server, the same one the VS Code extension consumes:

  • Live diagnostics — syntax and semantic errors as you type, pushed via textDocument/publishDiagnostics.
  • Emitted-code preview — request the generated C#, TypeScript, Python, PHP, or Rust for the current model in a read-only pane (koine/emitPreview); if the model has errors, nothing is emitted and the diagnostics are shown instead. The list of targets the picker offers is derived from the backend — Studio asks the language server which targets the compiler’s emitter registry supports (koine/emitTargets) and renders the picker, the Generate-project wizard and the assistant’s compile tool from that one list, so a new emitter target appears automatically with no front-end change. Syntax highlighting for a target without a bundled editor mode degrades gracefully to plain (unhighlighted) text rather than hiding the target.
  • Glossary — the ubiquitous-language glossary the glossary emitter produces.
  • Context map — the bounded contexts and their relationships, in the Context Map bottom tab. A Graph | Table toggle switches between two views of the same data:
    • Graph (the default) draws the strategic context map as an interactive diagram on the same pan/zoom canvas as the domain diagrams. Each bounded context is a distinct accent tile; each relation is an edge whose direction reads upstream → downstream, with the relationship kind (Partnership, Shared Kernel, Customer/Supplier, Conformist, ACL, …) as its label — a bidirectional relation (Partnership / Shared Kernel) renders two-headed. Hover an edge for its kind and shared types / ACL; click a context to filter the workspace to it and jump the editor to its .koi declaration (the same jump-to-source a domain-diagram node gives), or click a relation to show its shared types and ACL in the details strip — so no detail from the table is lost.
    • Table keeps the dense, per-relation grid (Upstream · Direction · Downstream · Kind · Shared Types · ACL) for when you want every field at a glance.
  • Canvas notes & groups — free-text Notes and node Groups you can drop on the diagram canvas from the palette. These are annotations only: they never touch your .koi source. They persist per workspace in a committable koine.layout.json (alongside hand-dragged node positions), so they travel with the repo and diff cleanly. Double-click to edit, right-click to delete; a group draws a labelled region behind its member nodes and follows them as they move.
  • Scenario runner — exercise the domain without leaving the editor: pick an aggregate command or factory, give it a starting state and arguments as JSON, and run it to see the command → events → invariant-checks timeline. It lives in the Scenarios view of the Code surface (or command palette → Show Scenario Runner). Two engines can answer a run — the model interpreter (the default, everywhere) and, on the desktop, the model’s own generated code — and every result says which one did. See Scenario runner: interpreted vs executed.
  • Hover & navigation — type/member hover cards and go-to-definition, served by the same LSP that powers the editors.
  • Syntax tree — a right-rail panel (the tree glyph in the tool stripe, beside Properties / AI Chat / Source Control) that renders the raw parse tree of the active .koi file — the Koine equivalent of Roslyn’s Syntax Visualizer. One collapsible row per parse node shows its kind (the node’s type name, e.g. ValueObjectDecl, Invariant, BinaryExpr), its name where it has one, and its source span. It’s fed by a target-agnostic koine/syntaxTree request that walks the compiler’s grammar-agnostic syntax graph, so every present and future grammar construct appears automatically, with no per-node code. Because the tree is built over Koine’s error-tolerant parse, a half-typed file still yields a recovered tree: ErrorNode and inserted-IsMissing nodes render with a distinct style rather than blanking the panel — the point, not a failure. Navigation is bidirectional: click a node to select its span in the editor, or move the caret to highlight the deepest node whose span contains it. The tree collapses below the top-level contexts by default and expands on demand, and the rows are a keyboard-navigable ARIA tree. A large or deeply-nested model stays smooth: the panel virtualizes the render, mounting only the rows in (or near) the viewport while the arrow keys, the caret highlight, and the single roving tab stop still reach every row — an off-screen target is scrolled into view before it is focused or highlighted — so keyboard navigation and WCAG 2.1 AA hold across the whole tree, however big. Works in both the browser and desktop hosts.
  • Workspace search & replace — press Mod+Shift+F (⌘/Ctrl) to open the search panel and find a term across every .koi file in the open folder, including unsaved buffers. Toggle match case, whole word, and regular expression (with $1 capture groups in the replacement), and narrow the scan with an include glob (e.g. *.koi, src/*.koi). Results are grouped by file with per-file and total counts; click a hit to jump to it. Replace rewrites a single file or every match across the workspace — edits to open files flow through the normal dirty/save pipeline, so they stay undoable and the unsaved indicator updates.
  • Shareable linksCopy shareable link encodes your work into the URL fragment (it never leaves the browser) so a teammate who opens the link lands on the same model. The link carries the whole workspace — every open file, with the active file flagged. Old single-file links still open (as a one-file workspace), so links shared before this change keep working.
  • Export .koi sourceExport .koi source (.zip) bundles every open .koi file into a zip. A very large workspace can overflow a URL, so when Copy shareable link would produce an oversized link Studio declines to copy a broken one and steers you to this export instead.
  • Settings (Visual + JSON) — the Settings view edits your Studio preferences either through the Visual controls or directly as settings.json. The JSON pane is schema-aware: it validates every field against the settings schema and surfaces that schema’s per-field documentation inline — hover a field key for its title and description (e.g. editor.tabSizeTab size · Indent width in spaces.), and completion inside a group lists each field with the same human-readable title (as the option detail) and description (as the info panel). The secret API key is never part of the document, so it can never appear in a hover or completion.

The scenario runner answers “if I place this draft order, what happens?” against the live .koi. Two engines can produce that answer, and every result is labelled with the one that actually ranInterpreted or Executed, as a chip on the timeline header:

Interpreted (default)Executed (opt-in, desktop only)
What runsThe model itself — Koine walks the command body and evaluates its expressionsThe model’s generated C#: emitted, compiled with Roslyn, and driven for real
WhereBoth editions (browser and desktop)Desktop only — it needs a child process
SpeedImmediateProcess start plus a full emit and compile on every run
FidelityHigh for the modelled subset; anything it cannot evaluate is shown as ? rather than guessedExactly what ships — derived values are really computed

Interpreted is the default and stays the default. It is fast, it works in the browser, and it never runs anything. Reach for executed mode when the interpreter’s ? is the answer you actually needed — typically a derived value object (total = lines.sum(l => l.payable)), a value object’s own invariant firing on the given state, an illegal state transition, or the exact wording of a domain-invariant failure. Those four are precisely what executed mode adds — and so is a fifth: it follows your policy declarations into the aggregates they react on, instead of stopping at the command you ran (see Following a policy across aggregates). Interpreted mode still exercises one aggregate in isolation.

On the desktop edition the scenario panel shows a checkbox — “Execute generated code (high fidelity)” — under the arguments box. It is off by default and lasts for the session only: nothing is written to your settings, so every new window starts interpreted.

In the browser edition the checkbox is simply absent, because a tab has no process to run generated code in. If a request for executed mode reaches the browser backend anyway, it is answered by the interpreter and labelled interpreted, with a note saying execution was unavailable on this host — a degraded answer that says so, never a silent one. Bringing executed mode to the browser would mean a second Roslyn compile-and-load inside the tab, which runs into the per-tab memory ceilings tracked in #219.

Asking to execute is a request, not a promise: a host that cannot execute, a model that does not compile, or a run that overruns its budget all come back honestly labelled. Read the chip, not the checkbox.

An emit is where a story starts, not where it ends. If your model says

policy PostToLedger when ChargeCaptured then Books.record(amount: capturedAmount)

then capturing a charge should post a ledger entry — and an executed run really posts it. After the command you ran finishes, every event it actually emitted is matched against your policy declarations, and each reaction is run against the aggregate it names. Its steps join the same timeline, indented and tagged with a chip naming that aggregate, so you can see at a glance where the story left Charge and continued on LedgerEntry. Its resulting state joins the same list, under dotted <Entity>.<member> keys — your primary aggregate keeps its plain field names, and nothing collides.

Give the downstream aggregate a starting state. Your given describes the aggregate you are testing; the one a policy reacts on is a different object with a different state, and the runner will not invent one. Use a dotted key to describe it:

{
"amount": 12,
"settled": false,
"LedgerEntry.balance": 5,
"LedgerEntry.closed": false
}

Everything before the dot names the downstream entity (or its aggregate — Books.balance reaches the same root entity as LedgerEntry.balance); everything after it is a field on that entity, written exactly as you would write it for the primary one. Plain, undotted keys always belong to the aggregate under test and are never routed downstream. Leave the dotted keys out and the reaction is not run: you get a failed step against that aggregate and a note naming the exact key that would have driven it — an honest “I could not know this”, never a default instance whose invariants would be fiction.

A downstream failure does not change the verdict. Ok/rejected on the header reports the operation you ran. If a policy’s reaction is rejected — a precondition, an illegal transition, an invariant — it shows up as a failed step attributed to that aggregate, carrying the real rule text, plus a note. A green badge above a failed downstream step is intended: your command really did succeed; the reaction it triggered did not.

How far it goes. Fan-out stops at three levels past the command you ran, and it never runs the same reaction twice — so a policy chain that loops is reported as a cycle rather than left to run into the timeout. Both bounds are stated in the run’s notes, naming the reaction that was not explored, so a truncated run always looks truncated. Several policies reacting to the same event all run, including several onto the same aggregate: that aggregate is built once and they run against it in turn, so its resulting state reflects all of them.

What it does not run: cross-context subscriptions. publishes / subscribes declares that another bounded context reacts to an event — but not how, and the emitters produce only an empty handler seam for it. There is nothing to execute, so the runner says so: the subscribing contexts are named in the notes as declared with no executable handler. That is a fact about your model worth knowing, not a limitation being hidden — a policy is what makes a reaction runnable.

Told apart on the timeline: emit vs publish. A step a command published carries a published chip instead of the usual event one, and hovering it says which contract left the context. Both verbs record an event, but they mean different things — an emit stays inside the aggregate, a publish is a published-language contract crossing the boundary — so the timeline shows which one you are reading rather than rendering the two identically. Both engines label it, so an interpreted run says the same thing an executed one does (only the executed one also resolves the subscribers into a note, because that comes out of fan-out).

Timeouts, and what a runaway model costs you

Section titled “Timeouts, and what a runaway model costs you”

An executed run happens in a child process with a wall-clock deadline — 5 seconds by default. When the deadline expires the child and its whole process tree are killed, and the run comes back as a failed result carrying a note saying it timed out: the emitted code may simply not terminate (an unbounded loop or runaway allocation in a derived member or invariant). Nothing of that run survives — but your editor, its diagnostics and its open documents are untouched. The run happens off the language server’s message loop, so the editor keeps answering — diagnostics, hover and completion carry on while a scenario is compiling and running. A client can ask for a different budget (the timeoutMs parameter of koine/runScenario), clamped to 100 ms – 60 s.

Executed runs are also serialized per window: fire two in quick succession and they run one after the other, rather than putting two Roslyn compiles inside the editor backend at once.

Resource ceilings and OS-level confinement

Section titled “Resource ceilings and OS-level confinement”

Beyond the deadline, the child runs under limits the runtime and the operating system enforce. What you get depends on the platform, and the run tells you when something could not be applied — any gap is appended to the result’s notes rather than left implied:

macOSLinuxWindows
Managed-heap ceiling (1 GiB)(also capped by a Job Object)
Processor-time ceiling(Job Object)
Network denied⚠️ (only where unprivileged user namespaces are permitted)(reported)
Writes confined to the run directory⚠️ (kernel 5.13+, via Landlock)(low-integrity token)

The memory row is a managed-heap ceiling on macOS and Linux — the .NET runtime enforces it, and it bounds the managed heap, which is where an allocation storm in emitted code lands. It does not bound native allocations; only the Windows Job Object caps those too.

Linux network denial uses an unprivileged network namespace, which several distributions restrict — Ubuntu 24.04’s AppArmor policy blocks it by default, and this project’s own CI runners fall in that group. Where it is blocked, the run says so in its notes rather than pretending otherwise.

Linux write confinement uses Landlock, which needs no privileges and no user namespace, so it works even on the hosts that restrict namespaces. It needs a kernel of 5.13 or newer with Landlock enabled; on anything older the run says so in its notes. Because the ruleset has to be installed by the process it confines, koine re-executes itself as a small launcher that installs it and then execs the scenario child — which is why TMPDIR inside a confined run points at the run directory.

Windows write confinement runs the child under a low-integrity token, and gives the run directory a matching low label so the child keeps its scratch space. Windows then denies it write access to everything the user owns while leaving reads open — the same shape as the other two platforms. It needs no administrator rights. The network row stays ❌ there: the only unprivileged Windows mechanism that denies sockets is an AppContainer, and an AppContainer child cannot read the koine binary itself without a permanent change to that directory’s permissions, so the run reports the gap instead.

Reads are unrestricted everywhere — the child has to load the .NET runtime and its own assemblies. A run stopped by a resource ceiling says so by name, so an allocation storm is never reported as an infinite loop. And confinement is never allowed to break a run: if a mechanism is missing on your machine, the scenario still executes and the result says which confinement was skipped.

Koine Studio and the VS Code extension are two clients of one backend: the enriched koine lsp. The standard features (diagnostics, hover, completion, go-to-definition) and the Koine-specific requests (koine/emitPreview, glossary, context map) are implemented once, in the compiler’s language service, and reused by both. Fix a rule in the compiler and both clients get it — there is nothing IDE-specific to keep in sync.

The hosted web edition needs nothing to run. To build the desktop app: a helper script under scripts/run-ide/ builds the CLI (so the koine lsp sidecar exists), installs the frontend deps on first run, and launches the Tauri dev shell:

Terminal window
# from the repo root — pick the script for your shell
./scripts/run-ide/run-ide.sh # macOS / Linux
.\scripts\run-ide\run-ide.ps1 # Windows (PowerShell)
.\scripts\run-ide\run-ide.cmd # Windows (cmd)

By default the Rust host runs the Debug Koine.Cli.dll via dotnet. To use a self-contained published binary instead, point KOINE_LSP at it before launching:

Terminal window
KOINE_LSP=/path/to/koine ./scripts/run-ide/run-ide.sh
Terminal window
# Rust broker: compile + framing unit tests
cd tooling/koine-studio/src-tauri && cargo build && cargo test
# Frontend: typecheck + bundle
cd tooling/koine-studio && npm install && npm run build

While developing the IDE, a read-only store inspector overlay shows exactly what the app’s Zustand store holds right now — selection, active context, the panel/view fields, the active file, the dirty-files and diagnostics rollups, and a collapsible “Raw state” dump of the whole store. It’s the tool for diagnosing cross-panel-sync bugs. Open it from the command palette (Cmd/Ctrl +K) → Toggle store inspector (debug).

The command is registered only in dev builds. Both run-ide and run-ide-web launch Vite’s serve command, where import.meta.env.DEV === true, so the inspector is available there. Published builds go through vite build (import.meta.env.DEV === false), where the command isn’t registered and the panel’s code is excluded from the bundle — so it never appears in the shipped desktop app or the deployed web playground.

  • Local LLM in the Assistant — point the built-in Assistant at a local model, and how grammar-constrained generation keeps its output valid .koi.
  • Editor tooling — the TextMate grammar, koine lsp, and the VS Code extension.
  • CLI referencekoine build and koine check, which share the server’s parser and validator.
  • Reading the generated C# — what the emitted-code preview shows you.