std/ctx: Dependency Injection as a Lexical Scope Stack
Twenty components deep in a GUI tree, every leaf wants the current theme row — palette, font size, spacing. Without a context mechanism, the cost lands on every tor between the provider and the leaf:
// the ambient-data tax: declare it, forward it, never read it
pub tor card { fs: f64, accent: i64, title: i64 }
card = label(fs, accent, title)
pub tor label { fs: f64, accent: i64, title: i64 }
label = draw(fs, accent, title) Two layers of fs and accent doing nothing but traveling. At twenty layers the signatures are mostly plumbing, and a re-theme means editing all of them by hand. The same shape recurs wherever live data has many consumers under one spine: a docs[sel] row every editing verb resolves against, a settings row feeding locale and accessibility flags to every formatter, a request record a server pipeline shares.
std/ctx removes the plumbing — lexically, at compile time:
import std/io
import std/store
import std/ctx
std/store:new(outer) { a: 1[i64], b: 2[i64], c: 3[i64] }
std/store:new(inner) { a: 9[i64] }
pub tor ctxprobe { a: i64, b: i64, c: i64 }
ctxprobe = std/io:print.ln("ctxprobe a={{ a:d }} b={{ b:d }} c={{ c:d }}")
std/io:print.ln("seed")
|> std/ctx(outer) |> ctxprobe()
|> std/ctx(inner) |> ctxprobe()
|> ctxprobe(b: 42) seed
ctxprobe a=1 b=2 c=3
ctxprobe a=9 b=2 c=3
ctxprobe a=9 b=42 c=3 The author wrote ctxprobe(). The program called ctxprobe(outer.a, outer.b, outer.c) — a call nobody typed.
The stack is the semantics
ctx(X) scopes everything downstream of it: unfilled parameters are injected from the store X names, read off its std/store:new schema — “provides” is a compile-time fact, not a lookup. The three calls above show the whole precedence chain: outer fills all three params; inner shadows only a while b and c fall through to outer; and the explicit b: 42 beats both scopes. explicit argument → innermost compatible ctx → outward ctx → declared default → refusal, and the refusal needs no new machinery — an unfilled param is just a missing argument, and the ordinary requires input 'b' diagnostic fires exactly as it would with no ctx anywhere.
One refinement: innermost compatible, not innermost. Matching is name + exact canonical type — a same-named column whose type can’t serve the param doesn’t shadow, it falls through:
std/store:new(outer) { a: 7[i64], b: 8[i64] }
std/store:new(inner) { a: 1.5[f64] }
// a callee wanting a: i64 gets outer.a — past the mistyped inner.a
// a callee wanting a: f64 gets inner.a — innermost compatible wins Same stack, same name, different answers per callee signature. A param no compatible scope provides stays unfilled and refuses normally.
There is no context
The transform fills argument lists and removes itself. The emitted program reads store cells exactly the way hand-written code would:
main_module.ctxprobe_event.handler(.{ .a = __koru_store_outer.a, .b = __koru_store_outer.b, .c = __koru_store_outer.c });
main_module.ctxprobe_event.handler(.{ .a = __koru_store_inner.a, .b = __koru_store_outer.b, .c = __koru_store_outer.c });
main_module.ctxprobe_event.handler(.{ .b = 42, .a = __koru_store_inner.a, .c = __koru_store_outer.c }); Both names here are slightly dishonest, and that is the point. Context suggests a runtime object you resolve through; store suggests a container you query. Neither exists — ctx is a spelling for where arguments come from, store a spelling for how data is laid out. Both name models, not machinery.
The optimizer is the oracle (bench_ctx.k, ReleaseFast, min of runs): literal arguments probe(a: 1, b: 2, c: 3), injected reads ctx(outer) |> probe(), and the deliberately un-hoistable ctx(rows[h]) |> probe() with the handle alternating per call against its hand-written rows[h].a equivalent. The optimizer cannot distinguish an injected read from a literal — where the calls can fold, every arm folds to the same closed-form residue, checksums exact; where they cannot, the injected and hand-written calls emit identical instructions. Measured: 0.90 ns/call either way with a serial dependence chain forcing real work, 1.23 vs 1.24 for variable-handle hand-written vs ctx. A context implemented as runtime machinery could produce neither outcome.
The honest edge: the handle resolve is real code — brand, generation, and slot checks — and it shows: an isolated ctx(rows[h]) read measured 0.67 ns/call where a whole-store read folds to 0.008. Under real per-call work the check hides entirely. And unoptimized builds keep all of it — Debug ran the row arm roughly 2.8× slower — because the checked resolve is the safety feature, not overhead.
A scope can name a row
The scope value is a source expression, and themes[i] is a legal one: the schema is read off the base store, while the injected argument keeps the full subscript text — each call site emits themes[i].field, a row-subscript read that resolves i at run:
std/store:new(outer, capacity: 8) { a: i64, b: i64, c: i64 }
std/store:insert(outer) { a: 1, b: 2, c: 3 }
| row r0 |> std/store:insert(outer) { a: 10, b: 20, c: 30 }
| row r1 |> std/io:print.ln("seed")
|> std/ctx(outer[r0]) |> ctxprobe()
|> std/ctx(outer[r1]) |> ctxprobe() seed
ctxprobe a=1 b=2 c=3
ctxprobe a=10 b=20 c=30 Because the subscript evaluates per call site rather than binding once at the ctx step, a live handle makes the scope re-targetable: ctx(themes[cur]) injects reads of themes[cur], and a stored write that flips cur re-points every consumer in the subtree on their next evaluation — a theme switch with no propagation machinery.
A function nobody wrote answers the call
The hard question: does the scope cross the tor boundary? A tor emits one shared impl, and different callers carry different scopes — so the boundary can’t be crossed by rewriting one body. It is crossed by specializing it:
pub tor needs-scope { }
needs-scope = ctxprobe()
std/io:print.ln("seed")
|> std/ctx(inner) |> needs-scope() seed
ctxprobe a=9 b=8 c=7 needs-scope never mentions the scope — yet the call dispatches to needs_scope__ctx_inner, a clone keyed on the scope stack whose body is the original’s with injection applied. A function nobody wrote answers the call; the author’s needs-scope becomes a template, marked [abstract] once specialized so a bare call outside any scope refuses rather than silently stubbing.
Subscripted scopes lift their binding into the clone’s input — ctx(themes[r]) emits consume_scope__ctx_themesIDX(.{ .__ctxarg0 = r }) and the clone reads themes[__ctxarg0].a, so call sites with different handles share one specialization. Recursion terminates by construction: a clone’s recursive call resolves to its own key.
Three probes, three bugs
The feature above is what survived falsification. Probes against real consumers caught three genuine defects, and each fix made the mechanism smaller:
The scope’s edge carries payload. insert(theme) | row r |> ctx(theme[r]) |> card() binds r on the edge feeding the ctx step; the splice that removes the step originally dropped that edge, so theme[r] compiled against a binding that never emitted. The fix transfers the whole edge — branch, binding, effect kind, when clause — onto each promoted child. A component probe found it: libs/ui:component-generated tors take ctx(theme[r]) on exactly such an edge.
Ordering was positional luck. ctx and component shared the pre transform stage, so a ctx flow written before the component declaration saw a program with no card decl yet and refused KORU080 — while the identical program in the other order compiled fine. Consumers of synthesized decls now sit in their own stage, pre → pre_late → main → post: after producers emit, before subtree-claimers lift. std/ctx wears [transform|pre_late]; source order no longer decides whether a scope resolves.
The emitted argument was never legal. Injection synthesized a: outer.a — but forced punning makes that label redundant, and redundant labels are a parse refusal in real Koru. Generated args now pun where the lexer would pun (outer.a, clean paths) and keep their label only where punning fails (fs: theme[r].fs — brackets can’t pun).
A fourth probe asked the question this post opened with — same name, different types — and produced the typed-matching semantics above rather than a bug fix: matching was honest, just too weak.
What it composes with
The clone rung serves hand-written tors whose bodies leave calls unfilled; generated components take the simpler path — ctx(theme[r]) |> card() emits card(fs: theme[r].fs), prop injection at the call site, no clone, because the component transform writes complete calls and there is nothing to fill. Both rungs share one scope-stack vocabulary.
And ctx is orthogonal to [abstract], not overlapping: an abstract tor chooses who answers a call, program-globally; ctx chooses what fills a call, subtree-lexically. A scope stack is exactly what module-keyed dispatch cannot express.