Introducing Koru Stack
.NET Aspire made a real dent this year because it fixed an honest problem: clone the repo, and you cannot run the system. Its AppHost declares services in C#, dotnet run starts the whole graph — containers, processes, a dashboard — and WaitFor(db) sequences readiness instead of startup. People love it. They should; it’s good work.
It also embeds a choice we deliberately did not make: the AppHost is a program that builds a deployment model at runtime. In Aspire, the orchestration graph is invisible to the compiler — a bad depends_on is a runtime exception, a dev-only service is a runtime if that stays in the model, and nothing anywhere can notice that you leaked a fleet.
koru/stack is the same idea through the other door: the graph is compile-time data, and lifecycle is a debt the compiler tracks.
What ships today
One file. The program and its services live in the same source, in the same language:
import koru/stack
koru/stack:service(db) {
"image": "postgres:16",
"healthcheck": "pg_isready -U shop",
"env": { "POSTGRES_DB": "shop" }
}
koru/stack:service(web) {
"image": "shop/web",
"port": "web_port",
"depends_on": ["db"]
}
[build("dev")]
koru/stack:service(debug_ui) {
"image": "shop/debug"
} koruc app.k stack manifest collects the declarations, validates the graph — a depends_on edge to nothing is no service named 'missing' is declared, a cycle is depends_on cycle involving: a b, both exit 1 — and emits compose YAML that docker compose config accepts.
Then the part a runtime model can’t do: koruc app.k stack manifest --build=prod produces a manifest in which debug_ui does not exist — not skipped, not commented, absent — and web_port resolves to 80 instead of 3000 through build:config. Two flags, two artifacts, diff them.
In Aspire terms: your orchestration is not a runtime model you build and hope is right. It is an artifact the compiler produced or refused to.
Variants: same slot, different shape
[build("dev")] gives include/exclude. The serious version is variant-spelled services — same name, different body per build:
koru/stack:service(db) {
"image": "postgres:16"
}
koru/stack:service(db)|dev {
"image": "postgres:16",
"env": { "POSTGRES_PASSWORD": "dev" }
}
koru/stack:service(db)|prod {
"image": "registry.internal/db:release",
"replicas": "3"
} Under --build=prod the |prod decl is the db service — redefinition, not presence. Prod’s db is a different image with replicas; it’s not “dev db minus the debug flag.” The variant mechanism is the language’s own — path(args)|variant is existing grammar — and stack reads the tag as declaration metadata. Two decls competing for one build is a refusal, not a coin flip; a reference to a service the active build gates out refuses with both names. Measured: variant_probe.k emits postgres:16-dev under --build=dev, registry.internal/db:release under --build=prod, and {{ db.image }} resolves against whichever spec won.
One wrinkle this surfaced: a call-site |variant used to mean proc dispatch unconditionally — service(db)|dev emitted a call to a handler__dev that doesn’t exist. For a [norun] decl the site is data, so the compiler now dispatches a variant tag only when a variant proc is actually declared; otherwise the base handler is emitted and the tag lives purely as metadata for collectors.
Interpolation: Liquid, not a new $
Service bodies interpolate through Koru’s existing Liquid templating — {{ }} is already the language’s vocabulary (src/liquid.zig, dotted paths and filters); nothing new enters the grammar:
koru/stack:service(web) {
"image": "shop/web",
"port": "{{ env.web_port }}",
"env": {
"DATABASE_URL": "${DATABASE_URL}",
"LOG_LEVEL": "{{ config.log_level }}"
}
} Three spellings, three timings, and the distinctions are the whole point:
{{ env.x }}/{{ config.x }}resolve at manifest time —.envoverlaid by.env.<build>under--build, with the process environment winning last.config.xreadsbuild:configfor the active flag. Structural values (port,image,replicas) bake into the artifact, socompose.yamlis deterministic andgit diffon it means something. Every miss is a named refusal —service 'api' references unset env 'PG_USER',references config key 'api_port' the active build doesn't define— never a silently-empty field.{{ db.port }}refers to another declaration — and the reference is the dependency. More on this below; it’s the important one.{{ "X" | env_late }}defers to the deploy host — emits compose’s own${X}verbatim, resolved atupwhere the secret lives.${}is a deprecated Koru spell, so deferral is spelled through Liquid as a filter, not smuggled in as raw text — one interpolation vocabulary, three timings.
The graph derives itself
A reference inside a service body reaches across declarations — web’s env names db, so the graph knows web needs db without anyone writing depends_on:
koru/stack:service(db) {
"image": "postgres:16",
"port": "5432",
"healthcheck": "pg_isready -U shop"
}
koru/stack:service(web) {
"image": "shop/web",
"env": {
"DATABASE_URL": "postgres://{{ db.name }}:{{ db.port }}/shop"
}
} The collector runs two passes — gather declarations, then interpolate — and this is implemented and measured: koruc refs.k stack manifest emits DATABASE_URL: postgres://db:5432/shop and a depends_on: db: condition: service_healthy edge that nobody declared. Every {{ svcname.field }} records an edge web → db, unioned with any declared depends_on and fed into the same topological sort — a reference cycle is a cycle refusal, a reference to an undeclared service is a dangling refusal. Aspire makes you say .WithReference(db) and separately interpolate the connection string — two places that can disagree. Here the edge can’t disagree with the usage: the dependency is what the env var actually says.
Readiness rides along for free. A reference means “needs it ready, not started” — since db declares a healthcheck, the derived edge emits condition: service_healthy automatically. The surface a service exposes is small and positional: name/host (the compose DNS name — what a peer on the network dials), port (container side of the first mapping), host_port (the published side), image. And the new check this unlocks is real: referencing a service the active --build gates out refuses at manifest time — service 'api' references service 'db', which the active build gates out — where the runtime-model version of that failure is a deploy-time null.
Two honest limits: {% %} logic tags are refused inside service bodies — substitution only — and a bare string that names a declared build:config key but doesn’t resolve under the active build refuses rather than passing through as a literal. The manifest never ships a value the graph didn’t bind.
Environment is wiring, not data
The manifest is one consumer of the resolved context; the other is the program itself — this arm is the design, not yet the code. A service doesn’t only ship as an image — stack up is meant to also bind and spawn processes:
koru/stack:service(web) {
"run": "zig-out/bin/web",
"env": { "DATABASE_URL": "postgres://{{ db.addr }}/shop" }
} up composes each service’s environment from the same layered context — process env, .env, .env.<build>, resolved references — then injects it: environment: into the compose manifest for the container subset, envp into the spawned process for the run: subset. The program reads DATABASE_URL through ordinary env lookup; the stack is what guarantees the name is bound when it starts.
Which surfaces the rule that makes it honest: a reference resolves to wherever the referencer actually is. {{ db.port }} bound for a container peer on the compose network means db:5432 — service DNS, container port. Bound for a spawned process on the host it means 127.0.0.1:<host_port> — the mapped port on loopback. Same logical dependency, different wire address, and the difference isn’t a bug to smooth over — it’s the fact of the deployment. db.addr/db.port resolve against the referencer’s placement; db.host_port names the host mapping explicitly when you need the raw fact.
The part nobody else can say
Compose YAML is a materialization format, not the product. The product is the graph — and the graph’s lifecycle.
koru/docker already ships the proof-of-concept for the interesting half: docker:run produces Container<running!> — a value that owes a debt, and the compiler refuses any path that doesn’t discharge it through stop or kill. Leaking a container is a type error.
stack up is shaped to scale that to the fleet: bind every service’s environment against the live graph, bring the container subset up (compose up -d --wait, health-gated by the declared "healthcheck"), spawn the run: processes under std/process custody, and hand back Stack<running!> — stack:down is the discharge, and a program that can leak a stack doesn’t compile.
Aspire asks “what if your infra were code?” This asks the question one rung deeper: what if your infra were an obligation?
What it cost the compiler — the honest ledger
The package exists to stress the toolchain, and it did. Building this one consumer surfaced seven real defects, all fixed in koruc this arc:
- command dispatch ran before comptime registries were primed —
build:configwas unreadable in a|commandproc; - annotations attach at two different AST points depending on
.kvs.kzspelling; - an empty source block ate the following declaration (parser);
- an empty Source emitted a dangling
.text =(emitter); koruc f.k --build=dev stack manifestsilently performed a normal build;=>producer arms inside[comptime]impl bodies panickedflow.inv();- Source args spliced raw Koru text into emitted Zig on two call paths, and a
*wildcard payload dropped its|b|binding.
Every one has a regression pin. That’s not overhead — it’s the point. The package is the instrument; a better compiler is the product.
What’s honestly missing
The dashboard, the telemetry, the unified log surface — that’s Aspire’s moat and it is real, and we are not pretending otherwise. stack up/down/logs are live today — validate, emit, docker compose up -d --wait (with a docker info daemon preflight), verified end-to-end against a real redis service in up_probe.k — but they shell to compose rather than carrying Stack<running!> custody, and the run: process arm is design, not code. The reference and variant machinery is the other way: measured in stack/tests/ — variant_probe.k/dup_variant.k/ref_gated_variant.k pin selection, duplicate refusal, and gated-out reference refusal; refs.k derives the edge, ref_dangling/ref_cycle/ref_gated/ref_self/ref_unset pin the refusal shapes, and .env/.env.dev overlay is exercised. Obligation tracking erases inside comptime walked flows — measured, pinned 310_102 — so up lives as a runtime flow, which is where it belongs anyway. And transforms don’t fire inside [comptime] impl bodies yet — the walker refuses them by name (310_137), because transforms own call sites at elaboration.
The bet underneath all of it: a deployment graph you can see at compile time, validate before anything runs, and hold accountable after it’s up — is a better thing to build than a faster AppHost.