The Queries Are the Layout

· 9 min read

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 clustertests/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.