Parents & children

Coming from Go? This is structured concurrency — closer to an errgroup or a supervised tree than to bare goroutines. A parent locus accepts child loci; the…

Coming from Go? This is structured concurrency — closer to an errgroup or a supervised tree than to bare goroutines. A parent locus accepts child loci; the children live inside the parent’s scope, the parent sees their progress through a typed contract, and when the parent shuts down its children shut down first. No detached goroutine outliving the thing that spawned it.

A parent accepts children

Section titled “A parent accepts children”

A locus declares it can parent a child type by implementing accept:

type Player { name: String; }
locus GameSession {
params { host: Player; tick: Int = 0; }
}
locus Room {
accept(g: GameSession) {
// runs once g's params are built, before g's birth().
// It admits g; it cannot turn it away.
}
fn on_join(p: Player) {
// instantiating a child inside a parent method attaches it
GameSession { host: p };
}
}

When GameSession { ... } is evaluated inside Room’s body, the runtime allocates the child’s region inside the parent’s, builds its params, runs Room.accept(g), then births and runs it. accept sees the child’s params but not its running state, and whatever it does, the child is admitted. The parent’s self.children holds its accepted children (with self.children.count and self.children.is_empty for quick summaries).

children is one of three names every locus already carries — the others are k_max (the displacement bound) and draining (the drain flag) — and self.<name> always means the built-in one. So they’re reserved: naming a params field, a method, or a capacity slot children is an error at the declaration (“rename it”), not a confusing type mismatch wherever you read it back. A type’s struct field may still be called children — only loci carry the synthetic members.

A locus accepts one child type. Writing a second accept is a compile error that points at both clauses; if a parent needs to own two kinds of children, one of them belongs under a different owner (or the same owner one level down). The other direction is fine: several parent types may accept the same child type, and each child’s release fires on the parent that actually accepted it, running that parent’s own release body.

Bubbling: the nearest accepting ancestor collects the child

Section titled “Bubbling: the nearest accepting ancestor collects the child”

accept isn’t limited to direct children. If you instantiate a child where the enclosing locus doesn’t accept its type, the child doesn’t become a detached throwaway — it bubbles up to the nearest ancestor that does accept it.

locus Ship { params { hull: Int = 0; } }
locus World {
accept(s: Ship) { } // a top-level registry of ships
}
locus Fleet {
fn spawn() {
Ship { hull: 100 }; // Fleet doesn't accept Ship...
} // ...so this Ship bubbles to World
}

World collects every Ship spawned anywhere beneath it — through a Fleet that never mentions ships — with no manual registration. It’s the structural counterpart to the bus: the bus carries ephemeral messages; this carries ephemeral ownership — a live collection the ancestor holds and cleans up.

A few rules keep it predictable:

When the owner lives on a different thread — a main locus registry collecting entities that workers spawn on their own pools — the child is created over on the owner’s thread, so the spawning side can’t hold onto it. There a cross-pool spawn is fire-and-forget: write it as a bare statement, not let s = Ship { ... }. hale check points at the literal if you try to keep the value. That includes a Ship built in another locus’s params default: the default is built wherever that locus is instantiated without the field, so when a worker builds a Holder { } whose s: Ship = Ship { ... } default it leaves alone, the default’s Ship is a cross-pool spawn used as Holder’s field, and hale check points at it in the default. A function’s or a method’s argument default works the same way, one call at a time: fn take(s: Ship = Ship { ... }) builds its Ship at each call that leaves s out, in the caller, so a worker’s take() is refused at that call, while take(ship) and a take() made on the owner’s own thread are fine. A const or a type’s field default that builds a Ship is built wherever it is used, so it is refused where it is written as soon as some worker would spawn a Ship across pools.

“A different thread” counts instances, not types. A worker nested inside a pinned or pool-placed locus runs on that locus’s thread, so its spawns are cross-pool even though the worker itself has no placement entry. And when one worker type has an instance on the owner’s thread and another off it, the same Ship { ... }; line does the right thing in each: born directly where it can be, handed over where it can’t. Keeping the value at such a line, or spawning toward an owner that has several instances, is refused with a message that lists where each instance runs.

A subscriber born in a handler needs an owner

Section titled “A subscriber born in a handler needs an owner”

There is one place where “no owner is fine” stops being true. A bus handler returns after every message, so a locus it creates and nobody owns dissolves at that return — and if that locus subscribes to the bus itself, its subscription can never fire for a later message. The compiler refuses that shape:

type Ping { n: Int = 0; }
topic Tick { payload: Ping; }
locus Watcher {
bus { subscribe Tick as on_tick; }
fn on_tick(t: Ping) { }
}
locus Hub {
bus { subscribe Tick as on_msg; }
fn on_msg(p: Ping) {
Watcher { }; // error: instantiated unowned in a bus handler
}
}
main locus App {
params { h: Hub = Hub { }; }
run() { }
}

Owning it fixes it: accept(w: Watcher) on Hub, or on any ancestor of Hub — the nearest accepting ancestor collects it, exactly as bubbling does anywhere else. What counts is the declaration the accept names, not the spelling. accept(w: W) with type W = Watcher owns it; an accept(w: lib::Watcher) that names another seed’s Watcher doesn’t own yours, however alike the last segment reads. For a generic subscriber the specialization must match: accept(c: Cell<Int>) owns a birth declared Cell<Int>, not one declared Cell<String>. If two loci share a name, the compiler judges the first one declared and says which it judged.

The ancestor has to be there on every path that builds Hub. If App accepts Watcher and holds a Hub as a field, but fn main also builds a Hub { } of its own, that second Hub has no parent to collect what it births — so the compiler still refuses the birth, and its note points at the Hub that main builds. The same goes for a Hub built somewhere the compiler can’t follow: inside a plain function, or in a locus whose placement it can’t work out. An owner it can’t see is not an owner it can count on.

Where the compiler can’t tell who owns the child, it stays quiet rather than guess. A library seed with no entry point can’t be judged, because its consumer may supply the owner. Neither can a generic subscriber whose specialization nothing declares. A subscriber created in run(), birth() or an ordinary method is fine too: it lives for that scope and hears what’s published meanwhile. And if you manage its lifetime some other way, --allow-unowned-subscriber lets the shape through.

The contract: what crosses the boundary

Section titled “The contract: what crosses the boundary”

A child declares what its parent may see in a contract, and a parent declares what it reads:

type SessionState = enum { Lobby, Playing, Over };
locus GameSession {
params { tick: Int = 0; state: SessionState = SessionState::Lobby; }
contract {
expose tick: Int; // the parent may read this
expose state: SessionState;
}
}
locus Room {
contract { consume tick: Int; } // what Room reads from a session
accept(g: GameSession) {
if g.tick > 1000 { /* ... */ } // reading an exposed field
}
}

expose is what the child offers its parent; consume is what the parent reads from the child type it accepts. The compiler checks the two against each other: every name a parent consumes must be one its child type exposes, with the same type, and a parent that consumes with no accept to bind against is an error. The check is on the declarations: a parent’s direct read of a field the child did not expose (g.secret) still compiles today, so treat the contract as the boundary you have declared and checked, not as a wall around the rest.

Flow is vertical only

Section titled “Flow is vertical only”

The rule the whole tower rests on: a locus talks up to its parent and down to its children — never sideways. Two sibling sessions don’t reference each other; if they need to coordinate, they route through their shared parent (the Room is exactly the place that should know how sessions relate), or over the bus. No sibling pointer, no cousin back-channel.

This is what makes cleanup sound: a child’s memory is a sub-region of its parent’s, no pointer ever crosses sideways, so when a locus dissolves its whole subtree frees wholesale — no garbage collector, no per-object bookkeeping. A parent that accepts children tracks every one of them for exactly this moment, whether or not it ever reads self.children: when the parent goes — at shutdown, or because the field holding it was reassigned — each accepted child is torn down first, its bus subscriptions deregistered, before the memory it lived in is freed.

Flow children vs residents

Section titled “Flow children vs residents”

Here’s the piece that matters for any long-running parent — a server that accepts one child per connection. By default an accepted child lives until its parent dissolves. For a daemon whose parent never dissolves, that means per-connection children pile up forever. Two shapes fix it:

locus Conn {
params { conn_fd: Int = -1; }
run() {
let stream = std::io::tcp::Stream { conn_fd: self.conn_fd, owns_fd: false };
while true {
let chunk = stream.recv(4096) or "";
if len(chunk) == 0 { return; } // client closed → run() ends
// ... handle chunk
}
}
}
locus Server {
accept(c: Conn) { }
release(c: Conn) { } // ← declaring release marks Conn a *flow*
}

The same “run() returned” event means “reclaim me” for a flow and “I’m ready” for a resident — disambiguated by whether the parent declared release, never guessed. If you accept a child per connection and memory climbs with connection count, you have a resident that should be a flow.

Next: what happens when a child breaks — When things fail.