The Queries Are the Layout
Every framework that manages application state makes the same purchase: reactivity, paid for at runtime. Observer lists you subscribe to. Proxies that trap your property reads. Dirty-checking passes. Virtual DOM diffs. Signals with dependency graphs rebuilt on every run. The machinery differs; the receipt is identical — there is a data structure, alive at runtime, whose job is remembering who cares about what.
Koru is designing std/store to refuse the purchase. A subscription is not
registered; it is compiled into the write path. The store knows every
watcher at compile time, so the write site itself carries the guard and the
call — and a write nobody watches costs a store, nothing more.
This is a design post, and the design is pinned the way Koru pins
everything: as regression tests, red on purpose, that go green the day the
implementation lands. Every std/store test below fails today with module not found. That is the point — the tests are the spec, and the spec is
already executable enough to fail honestly.
The shape
~import std/io
~import std/store
~std/store:create(game) { entities: 0[i64] }
~std/store:watch(game)
! entities e |> std/io:print.ln("entities: {{ e:d }}")
~std/store:stored { game.entities: game.entities + 1 }
~std/store:stored { game.entities: game.entities + 1 } Three constructs. create declares the store — the name is compile-time
(anyone anywhere can link to game by name), the instance is runtime. watch subscribes: an ordinary effect branch, named by field, firing on
every write to it. stored is the only way to write — and that
exclusivity is not a style rule, it is the load-bearing fact the whole
design stands on. If every mutation flows through one gate, the compiler
standing at that gate sees everything.
The watch’s placement in your source tree carries no semantics. Put it next to the code it serves. It compiles to the same place regardless: the write path.
The producer owns the if
Koru already ships one construct that works exactly this way: event taps. A tap’s handler is spliced into
every matching producer site at compile time, and the tap’s when guard is
planted on the producer’s continuation — no call happens unless the guard
passes. The store generalizes the same machinery, so:
~std/store:watch(game)
! entities e when e > 1 |> std/io:print.ln("big: {{ e:d }}") compiles the comparison into the write site. A write of 1 costs one
branch-not-taken. There is no dispatch, no list to walk, no allocation —
because there is no subscription object anywhere in the binary.
Two altitudes: contract and subscription
Effect branches attached to create are different in kind from branches
attached to watch, and the difference is where, not what:
~std/store:create(global) { total: 0[i64] }
~std/store:create(game) { hp: 0[i64] }
! updated { old, new } |> std/store:stored { global.total: global.total + new - old } Branches on create are interceptors — part of the store’s contract,
firing for every write from anywhere, forever. They are how you keep a
store coherent and how stores synchronize each other. Branches on watch are subscriptions — consumer-side, plural, local. Same grammar, two
altitudes, and the planner gets to trust that every schema-level derivation
is visible at the declaration.
That { old, new } payload is itself derived from usage: the transform
sees this destructure binds both images, so this field’s write path
materializes the pre-image. A program where nothing binds old never pays
the read.
And a watch body is held to one law, checked at compile time: it may reference its own bindings and compile-time-known names — other stores, qualified calls — and nothing else. A body that reaches for runtime state of its enclosing flow is rejected with a diagnostic naming the escape, because that body does not execute where it is written; it executes at every write site. The principle underneath is worth stating on its own: adding or removing an observer must never change whether the program is correct. Observation is obligation-neutral, by construction.
The query language already existed
Here is the part of the design that felt like finding money in a coat. Stores are plural-native — a store is a table, its fields are columns, and the single-value application store is just a table with one row. So subscriptions want to say things like “rows where this holds.” A query language. Except Koru already has one, and it predates the store:
~std/store:query(game)
! query { entity.hp, enemy_kind: entity.kind } when hp > 40 and enemy_kind == 1
|> std/io:print.ln("enemy hp {{ hp:d }}") Destructuring is projection — which columns you touch. The when guard is selection — which rows you care about. Renames are the projector’s
ordinary work, and punning is mandatory (entity.hp binds hp; a
redundant label is rejected). None of this grammar was designed for the
store. Destructure-plus-guard on effect branches was pinned and green
before the store design finished — including one hole the design walk
exposed: no test in the entire corpus had ever exercised a boolean
connective in a Koru-level guard. The plumbing existed; the suite had
simply never asked. It has now been asked, and answered green.
Which enables the design’s central claim, the one the title makes: the physical layout of a store is derived from the closure of its queries. Every projection, every predicate, every rename in the whole program is comptime-visible — the complete workload, known with certainty before a byte of layout is chosen. Columns nobody projects don’t get built. Predicates that gate hot watches become maintained membership sets. A database query planner spends its life guessing at the workload from runtime samples; an ECS lets queries shape archetypes but registers them at startup. The store planner doesn’t guess and doesn’t wait — it reads.
One path grammar, four addressing heads
Once the store is a table, every write and every extraction needs to say which row — and this is where reactive systems usually fray into three unrelated APIs. The design’s answer is one lvalue grammar with four addressing heads. Insert returns the row’s handle, the primary identity:
~std/store:insert(game) { hp: 50, kind: 1 }
| row r |> std/store:stored { game[r].hp: game[r].hp - 10 } A field declared as a key gives you game[id: 7].hp — and the key
index is a declared cost, visible at the declaration that pays it. Inside
a query branch, the bound row is itself an addressing head — which quietly
makes the query system the whole of SQL’s write surface:
~std/store:query(game)
! query { entity.hp } when hp <= 0 |> std/store:take(game[entity]) UPDATE WHERE is a stored block under a query branch; DELETE WHERE is
a take. And the singleton application store is the same grammar with the
head elided: game.entities is a one-row table’s field. Positional index
is deliberately not identity — remove a row and positions rename, which
is how you get the ABA bug as a feature. Demoting position to query
ordering is also what makes swap-remove a legal layout move.
take itself is not a new mechanism — it is the removed lifecycle event
with a phantom obligation attached. The row exits every view and aggregate
through the ordinary delta machinery (taking is removing, so nothing
special-cases), and the caller now holds the row under an obligation
synthesized from the store’s own name. Abandon it and the phantom checker
refuses the program — the same machinery that already makes connections
unclosable-to-forget makes game entities unleakable-to-spawn. No entity
system we know of can say that sentence.
Built like a game engine, because it is one
The allocation posture is the game-dev pool discipline, made doctrine: capacity is declared at create — memory is fixed and static, the SoA
columns are fixed arrays, and there is no reallocation to spike a frame.
Autogrow exists only as an explicit opt-in; a silent mid-frame realloc is
exactly the shape Koru’s no-silent-performance-degradation rule exists to
kill. Exhaustion is a branch, not an exception: fixed-capacity insert
carries a | full sibling you handle or explicitly decline. And handles
over a reusable pool get the honest treatment — a generational check as
the safe floor, elided where the phantom checker can prove a handle can’t
be stale — the same floor-then-prove-the-cost-away arc as Koru’s
escape-driven stack allocation.
No torn state
Because the store is the sole write path, “a write plus its full interceptor cascade” is a well-defined unit — and the design’s ruling is that this unit is atomic and writes interleave, never overlap. No watch ever observes mid-cascade state. The floor implementation is deliberately unclever: one lock around one function (all writes route through one generated subflow per store). What makes the unclever floor sound is a compile-time theorem: the interceptor cascade graph is fully visible at compile time, and cycles are rejected as compile errors — so the coarse lock can never self-deadlock. Above the floor, the ladder is the same one Koru’s escape-driven stack allocation already climbed: per-event execution contracts are the threading proof, and a program whose contract graph never parallelizes around a store compiles the lock away entirely.
The seam for an ORM
One more consequence, almost free. Koru’s optional resume arms — ruled and pinned this week — give producers a compile-time presence test: did any consumer install this handler? The store’s internal read/persist path is declared as optional arms. Install handlers over the SQLite wrapper in koru-libs and the store fronts a database; install nothing and the presence test resolves at compile time to the in-memory layout, and the seam costs zero bytes. Dependency injection, resolved statically. The classic price of pluggable storage — indirection on every access — is not discounted; it is absent.
What the adversaries said
The design ran a gauntlet before the pins were written: three independent
adversarial reviews attacked the theses against the actual compiler source.
They confirmed the substrate claims (the tap splice generalizes; the guard
really is planted producer-side) and drew blood where blood was due: the
delta algebra behind maintained aggregates covers linear folds and does not
generalize to MIN/MAX without auxiliary structure; float-typed maintained
sums drift and need an honesty tier; naive per-call-site splicing multiplies
code size and the write path wants a centralizing subflow; firing order
needs one deterministic rule. All of it is recorded, with rulings and open
questions, in the design document that lives inside the test cluster — tests/regression/600_STDLIB/690_STORE/DESIGN.md — a file whose stated
contract is to delete itself as the pins absorb its intent. Prose drifts;
tests cannot lie.
The pins
The substrate — green today, exercising the grammar the store’s queries stand on:
The store itself — red on purpose, each one a promise with an acceptance test attached:
The store goes green the way everything in Koru goes green: when the language can express it, not before. The queries are already written.