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 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:

locus GameSession {
params { players: [Player]; tick: Int = 0; }
}
locus Room {
accept(g: GameSession) {
// runs before g's region is allocated — the gatekeeper.
// return normally to admit; route through on_failure to reject.
}
fn on_join(p: Player) {
// instantiating a child inside a parent method attaches it
GameSession { players: [p] };
}
}

When GameSession { ... } is evaluated inside Room’s body, the runtime runs Room.accept(g) first, then allocates the child’s region inside the parent’s, then births and runs it. The parent’s self.children holds its accepted children (with self.children.count and self.children.is_empty for quick summaries).

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 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 { ... }. The compiler will tell you if you try to keep the value.

The contract: what crosses the boundary

Section titled “The contract: what crosses the boundary”

A child decides what its parent may see by declaring a contract:

locus GameSession {
params { tick: Int = 0; state: SessionState; }
contract {
expose tick: Int; // parent may read this
expose state: SessionState;
consume clock: Time; // parent must provide this
}
}
locus Room {
contract { consume clock: Time; }
accept(g: GameSession) {
if g.tick > 1000 { /* ... */ } // reading an exposed field
}
}

expose is what the child lets the parent read; consume is what the child needs the parent to provide. Anything not in the contract is invisible across the boundary — the compiler rejects reads of un-exposed fields. You don’t write hiding logic; the structural boundary does it.

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.

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.