Proto and Foreign: Owned and Borrowed Identity
Koru has no struct, and that is not a missing feature. A struct fuses two unrelated facts — what a shape is called and how it lies in memory — into one declaration. Koru keeps them apart: a proto describes relationships between concepts without binding them in any way to memory. No layout, no addresses, no bodies. Player has health, and that health is Health — a relationship between two concepts, stated completely, with nothing said about where anything sits.
A terminal is the same act at field count zero. std/proto:float(Health) mints Health: a name for a scalar concept, usable wherever a type is expected, carrying no layout of its own. It is not a wrapper and not an alias you are meant to notice — it is the thing two compounds can point at and mean the same thing by. Each consumer derives its own physics from it; the name is the identity, and the layout belongs to whoever instantiates it.
A list never holds a proto. std/list:new(Player) synthesizes the appropriate shape of scalars from the proto definition — arranged scalars carrying the recipe’s name and none of its body.
References compose; declarations collide
One Health, two compounds, one receiver:
import std/io
import std/proto
import std/list
std/proto:float(Health)
pub tor report-health { health: Health } -> Health
report-health -> health
std/proto(Player) {
health: Health
}
std/proto(Enemy) {
health: Health
}
std/list:new(Player)
| list players |> std/list:push(xs: players, v: 98.6) |> std/list:pop(xs: players)
| item player |> report-health(health: player): player_out |> std/io:print.ln("player {{ player_out:d }}") |> std/list:free(xs: players)
| empty |> std/io:print.ln("player EMPTY") |> std/list:free(xs: players)
| err e |> std/io:print.ln("ERR {{ e:s }}")
std/list:new(Enemy)
| list enemies |> std/list:push(xs: enemies, v: 72.5) |> std/list:pop(xs: enemies)
| item enemy |> report-health(health: enemy): enemy_out |> std/io:print.ln("enemy {{ enemy_out:d }}") |> std/list:free(xs: enemies)
| empty |> std/io:print.ln("enemy EMPTY") |> std/list:free(xs: enemies)
| err e |> std/io:print.ln("ERR {{ e:s }}") Player and Enemy do not each mint a separate health idea. Both name the registered Health, and both pop values the same Health-typed receiver accepts — player 98.6, enemy 72.5. Referencing a terminal is composition, and the registry tells declarations apart from references: minting happens once, pointing happens everywhere.
The other half of the rule is a wall. Declaring Health twice is not a merge and not an override — it is two sites claiming one identity, and the compiler says so, naming both:
import std/proto
std/proto:float(Health)
std/proto:float(Health) error[KORU030]: duplicate proto declaration 'Health' — a proto entry is registered once; the declaration collides with the prior registration And a compound may only name what exists. A field type is a scalar material or a declared terminal — an unregistered word is a declaration fault, caught where it is written:
import std/proto
std/proto(Player) {
health: Mystery
} error[KORU173]: std/proto(Player): field 'health' names unknown terminal 'Mystery' — a field must use a scalar material or a declared proto terminal Same spelling is not same concept
The sharp edge of the law faces the other way. Two compounds may each carry health: f32 — same name, same material — and they are still two separate fields. A shared spelling proves nothing; only a shared terminal proves sameness:
import std/io
import std/proto
import std/list
std/proto(Player) {
health: f32
}
std/proto(Enemy) {
health: f32
}
std/list:new(Player)
| list players |> std/list:push(xs: players, v: 1.5) |> std/list:pop(xs: players)
| item player |> std/io:print.ln("player {{ player:d }}") |> std/list:free(xs: players)
| empty |> std/io:print.ln("player EMPTY") |> std/list:free(xs: players)
| err e |> std/io:print.ln("ERR {{ e:s }}")
std/list:new(Enemy)
| list enemies |> std/list:push(xs: enemies, v: 2.5) |> std/list:pop(xs: enemies)
| item enemy |> std/io:print.ln("enemy {{ enemy:d }}") |> std/list:free(xs: enemies)
| empty |> std/io:print.ln("enemy EMPTY") |> std/list:free(xs: enemies)
| err e |> std/io:print.ln("ERR {{ e:s }}") Each entry gets its own registry identity, its own container, its own rows — player 1.5, enemy 2.5, isolated. Nothing about the coincidence of the spelling merges them.
One bare name, two homes
Identity is module-spaced. Two homes may each mint Health — alpha’s and beta’s — and they coexist as distinct concepts, because the identity is the pair of module and name, never the spelling alone.
alpha.k:
// One home's Health — a distinct concept from beta's Health despite the
// shared bare name. Sameness by (module, name), never by spelling.
import std/proto
std/proto:float(Health) beta.k:
// The other home's Health. Coexists with alpha's Health as a distinct
// concept; unqualified refs stay unambiguous while one home declares the name.
import std/proto
std/proto:float(Health) // Two homes mint one bare name: alpha's Health and beta's Health coexist as
// distinct identities keyed by (module, name). The two-registrant wall fires
// only within one home.
import app/alpha
import app/beta
import std/io
std/io:print.ln("coexist") Output: coexist. No wall fires — the two declarations coexist untouched; a bare name answers to its own module, and anything else is spelled with its home.
The default door travels across the boundary too. A compound declared through std/proto(Name) inside a library module rewrites exactly as in the entry file — each scope resolves the module path against its own imports.
lib.k:
// A compound declared through the default door from inside a library
// module. Same spelling as the entry-file door (660_030); the pipeline
// rewrites it regardless of which module spells it.
import std/proto
std/proto:float(Health)
std/proto(Scout) {
health: Health
} Names you borrow: the airlock
Not every concept is Koru’s to define. A file handle lives in the host — its bytes, its layout, its spelling over there are facts Koru will never see inside. What Koru can still own is the name and the questions asked about it. std/foreign:struct(File) registers a host-owned identity: a name plus bare field names — presence claims, never types. Koru checks what it owns (does File have path); the host checks what it owns (what path is, and how it sits). Deep-typing a field in Koru is refused outright; that road ends in a Koru-side Zig grammar, and the airlock registers presence, never substance.
// A host concept enters through the airlock: `File` is minted as an
// identity with observable fields, no derivation, no container. It erases
// to a marker; the name is what the type system can now reason about.
import std/io
import std/foreign
std/foreign:struct(File) {
path
handle
}
std/io:print.ln("registered") The entry erases to a marker — no container, no derived physics, nothing downstream synthesizes from it. Same registry and same collision law as proto, facing the other direction: declaring File twice in one home collides loudly, exactly like a duplicate proto.
The Koru layer checks what it owns
A registered name makes a new question askable before Zig: is this field one of the claims? f.bogus names no registered field of File, so the projection is refused here, naming field and entry — it never reaches the backend:
// The Koru-layer deref check (rung 2): `f.bogus` names no registered field,
// so the projection is refused here, in Koru's voice, naming the field and
// the entry — it never reaches the backend. The CONTAINS below discriminates
// that layer (Zig's undeclared-identifier error never names `bogus`).
// Present fields wave through to host linkage (667_005).
import std/io
import std/foreign
std/foreign:struct(File) {
path
handle
}
pub tor get-bogus { f: *File } -> string
get-bogus -> f.bogus error[KORU030]: foreign entry 'File' has no field 'bogus' (fields: path, handle) — a foreign deref names a registered presence claim; the host owns substance The rule is one-directional on purpose. Present fields wave through untouched; a deref against a name nothing registered sails past to whatever owns it. It refuses the confusion an author actually makes — a misspelled field on a registered name — and invents no rejections anywhere else. Unknown stays unknown.
Across the import boundary
Entries travel with imports, and the check is not confined to the declaring module. The entry lives in the library; the consumer derefs through the import with the same checking the declarer gets:
lib.k:
// One home's File — the airlock entry lives where the host concept lives,
// not in the consumer. Importers deref through the import; the presence
// check travels with the entry.
import std/foreign
std/foreign:struct(File) {
path
handle
} // Cross-file deref check: the entry is declared in the imported lib, the
// deref in the consumer. `f.bogus` names no registered claim, so the Koru
// layer refuses it here — entries travel across imports; the check is not
// confined to the declaring module. The CONTAINS in EXPECT discriminates
// the layer (Zig's error never names `bogus`).
import app/lib
pub tor get-bogus { f: *File } -> string
get-bogus -> f.bogus Linkage: the host name is the type
// The full journey of a present-field deref, across an import: the entry
// and its host proof live in app/lib; the consumer derefs through the
// import. `f.path` is registered, so the Koru layer waves it through (rung
// 2); host linkage routes `*File` to the declaring home and the projection
// compiles end-to-end. Execution with a real File is a further rung still —
// get-path is never called. The print proves the program RAN, not just
// compiled, on that day.
import app/lib
import std/io
pub tor get-path { f: *File } -> string
get-path -> f.path
std/io:print.ln("ok") Nothing is generated and no substance is invented: the host name is the type, and the reference canonicalizes to the declaring home at emission. The program compiles end to end and prints ok. Execution with a real File — constructing one, passing one across the boundary — remains open.
Compounds compose
A compound field may name another compound. pos: Position expands to its scalar leaves — pos.x, pos.y — each keeping its identity: the shared terminal stays shared, raw leaves stay positional. There is no pos value anywhere; the compound dissolves and only leaves materialize:
// A compound may name another compound: `pos: Position` expands to its
// scalar leaves, each keeping its identity — Health stays the shared
// terminal, raw leaves stay positional. Position sits FIRST, so expansion
// feeds synthesis its first leaf; without the walk the emitter would name
// an undeclared `Position` one stage later. The push value is f32-exact:
// pos.x is f32, and 98.6 is not representable there.
import std/io
import std/proto
import std/list
std/proto:float(Health)
pub tor report-health { health: Health } -> Health
report-health -> health
std/proto(Position) {
x: f32
y: f32
}
std/proto(Player) {
pos: Position
health: Health
}
std/list:new(Player)
| list players |> std/list:push(xs: players, v: 98.5) |> std/list:pop(xs: players)
| item player |> report-health(health: player): player_out |> std/io:print.ln("player {{ player_out:d }}") |> std/list:free(xs: players)
| empty |> std/io:print.ln("player EMPTY") |> std/list:free(xs: players)
| err e |> std/io:print.ln("ERR {{ e:s }}") Identity survives the walk
The terminal keeps its promise through nesting. Player and Enemy both expand a pos: Position alongside their direct health: Health, and popped values from both lists flow into the one Health-typed receiver — the chain preserves what the terminal guarantees globally:
// The shared terminal keeps its identity through nesting: Player and Enemy
// both expand a `pos: Position` alongside their direct `health: Health`,
// and popped values from both lists flow into the one Health-typed
// receiver. Chains preserve what terminals promise globally.
import std/io
import std/proto
import std/list
std/proto:float(Health)
pub tor report-health { health: Health } -> Health
report-health -> health
std/proto(Position) {
x: f32
y: f32
}
std/proto(Player) {
health: Health
pos: Position
}
std/proto(Enemy) {
health: Health
pos: Position
}
std/list:new(Player)
| list players |> std/list:push(xs: players, v: 98.6) |> std/list:pop(xs: players)
| item player |> report-health(health: player): player_out |> std/io:print.ln("player {{ player_out:d }}") |> std/list:free(xs: players)
| empty |> std/io:print.ln("player EMPTY") |> std/list:free(xs: players)
| err e |> std/io:print.ln("ERR {{ e:s }}")
std/list:new(Enemy)
| list enemies |> std/list:push(xs: enemies, v: 72.5) |> std/list:pop(xs: enemies)
| item enemy |> report-health(health: enemy): enemy_out |> std/io:print.ln("enemy {{ enemy_out:d }}") |> std/list:free(xs: enemies)
| empty |> std/io:print.ln("enemy EMPTY") |> std/list:free(xs: enemies)
| err e |> std/io:print.ln("ERR {{ e:s }}") The graph must be acyclic — A reaching B reaching A is an infinite regress of scalars, not a type — and the declaration refuses it with the chain named:
// A compound graph must be acyclic: A reaches B reaches A, an infinite
// regress of scalars rather than a type. Refused at the declaration with
// the cycle named — the DAG law lives with the declaration, never with a
// consumer that might never walk it.
import std/proto
std/proto(A) {
b: B
}
std/proto(B) {
a: A
} error[KORU173]: std/proto(A): compound cycle detected: A -> B -> A — a compound graph must be acyclic; expansion would never terminate What this is not claiming
Nesting keeps the same modesty: values flow first-leaf scalars, records have no construction surface yet, and shared columns are still the open merge — declaration composes today so the fold has something legal to read tomorrow.
A shared terminal does not yet mean a shared column. When two stores hold the same proto, the layout layer may hold one array instead of two — but that merge is a second system with its own proof burden, and this post is the first system: the declaration law that makes the merge legal to consider. Cross-home references — a compound in one home naming another home’s terminal — are not part of this rung either. The rule it enforces is deliberately one-directional. A name may declare sameness; a spelling may never imply it. The day the stores start merging, they will merge only what a terminal already proved identical — never two anonymous fields that merely look alike.
Relationships between concepts, bound to nothing. Health is Health. Everything else is just spelled the same — unless it is borrowed: a foreign name is a claim on something bound elsewhere, checked where Koru owns the question, routed where the host owns the answer, and never derived from, ever. Owned or borrowed, the registry holds the name either way.