Parents & Children
Hale organizes loci into a strict parent-child ownership hierarchy where every child’s memory region is nested inside its parent’s arena. Parents can hold static children in params or dynamically admit children at runtime via accept(c: Child), inspect child state through contract { expose; consume; }, and automatically reclaim per-connection children upon completion via release(c: Child).
type Player { name: String; }
locus GameSession { params { host: Player; tick: Int = 0; }}
locus Room { accept(g: GameSession) { // runs after g's params are initialized, before g.birth() }
fn on_join(p: Player) { GameSession { host: p }; // attaches to Room via accept() }}Child Ownership Modes
Section titled “Child Ownership Modes”| Child Kind | Declaration on Parent | Lifetime & Arena Reclamation | Typical Use Case |
|---|---|---|---|
| Static child | params { db: Pool = Pool { }; } |
Constructed with parent; lives until replaced (self.db = Pool { }) or parent dissolves. |
Fixed subsystem components, connection pools, co-located counters. |
| Dynamic resident | accept(c: Worker) { } (no release) |
Constructed dynamically; stays alive after c.run() returns until c calls terminate; or parent dissolves. |
Long-lived dynamic entities (rooms, actors, subscribed shards). |
| Dynamic flow | accept(c: Conn) { } + release(c: Conn) { } |
c.run() is its lifetime; when c.run() returns, the runtime immediately drains, calls release(c), dissolves c, and frees its arena. |
Per-connection TCP/WebSocket handlers, short-lived request tasks. |
Accepting Dynamic Children (accept)
Section titled “Accepting Dynamic Children (accept)”A locus declares that it can parent dynamic instances of Child by implementing accept(c: Child):
- When
GameSession { host: p };is evaluated insideRoom(or a descendant ofRoom), the runtime allocates the child’s arena insideRoom’s arena and initializesg’sparams. - The runtime invokes
Room.accept(g)beforeg.birth()runs.acceptcan inspectg’s initializedparamsand update parent state, but cannot reject the child. - The runtime executes
g.birth()andg.run().
self.children and Single-Clause Rule
Section titled “self.children and Single-Clause Rule”- Built-in
self.childrentracking: Every locus exposesself.children.countandself.children.is_emptyto query currently live accepted children. - Reserved locus identifiers:
children,k_max(the displacement bound), anddraining(the shutdown flag) are synthetic fields on every locus. Naming a locus parameter, method, or capacity slotchildrenis a compile error (plain datatypestructs may still declare a field namedchildren). - One
acceptclause per parent: A locus can declare at most oneacceptclause. Multiple parent locus types may declareacceptfor the same child type; each child instance’s lifecycle hooks (acceptandrelease) dispatch to the specific parent instance that admitted it.
Polymorphic Acceptance via an Interface
Section titled “Polymorphic Acceptance via an Interface”To parent multiple child locus types under a single supervisor, declare accept(j: Interface) (and optionally release(j: Interface)) using an interface:
interface Job { fn weight() -> Int; }
locus Small { params { w: Int = 1; } fn weight() -> Int { return self.w; } }locus Big { params { w: Int = 9; } fn weight() -> Int { return self.w * 2; } }
locus Pool { params { load: Int = 0; } accept(j: Job) { self.load = self.load + j.weight(); } fn fill() { Small { w: 3 }; // accepted via Job Big { w: 5 }; // accepted via Job }}- Compile-time specialization: The compiler specializes
accept(j: Job)for each concrete child type (SmallandBig), turningj.weight()into a direct call. - Interface-only member access: Inside
accept(j: Job), only methods declared onJobare accessible (j.wis a compile error even if every concrete child has awfield). - Collection queries:
self.children.countandself.children.is_emptycount all accepted children across concrete types, whereas iterating withfor c in self.childrenis rejected whenaccepttakes an interface because the children do not share a single concrete type.
Ancestor Bubbling
Section titled “Ancestor Bubbling”When a locus instantiates a child whose type the immediate enclosing locus does not accept, ownership bubbles up the locus tower to the nearest ancestor that accepts it:
locus Ship { params { hull: Int = 0; } }
locus World { accept(s: Ship) { } // top-level owner of all Ship instances}
locus Fleet { fn spawn() { Ship { hull: 100 }; // Fleet has no accept(s: Ship), so Ship bubbles up to World }}Bubbling Rules
Section titled “Bubbling Rules”- Nearest accepting ancestor wins: If multiple ancestors accept
Ship(either directly or via an interfaceShipsatisfies), the innermost ancestor takes ownership. - Transient local fallback: If no ancestor in the enclosing tower accepts the type, the instance behaves as an unowned local scoped to the statement or function.
- Bubbling during parent
paramsinitialization: If a parameter ofWorld(such asFleet) spawnsShipinstances during its inlinerun()whileWorldis still initializing itsparams,World.accept(s)runs normally becauseWorld’s state andself.childrenare initialized before its first parameter is built. Declare any parent parameter mutated byaccept(such as a counter) before the parameter that spawns children so the counter’s default initializer does not overwrite the updated value. - Cross-pool bubbling: When a worker on one thread/pool spawns a child owned by an ancestor on a different thread/pool, the child is constructed on the owner’s thread. Consequently, cross-pool spawns must be written as bare fire-and-forget statements (
Ship { hull: 100 };).hale checkrejects binding a cross-pool spawn to a local variable (let s = Ship { ... };), storing it via aparamsdefault (s: Ship = Ship { ... }), or constructing it in a default function/method argument (fn take(s: Ship = Ship { ... })),const, ortypefield default that executes off the owner’s thread. If a worker locus has one instance on the owner’s thread and another on a separate pool, bareShip { ... };works in both; keeping the value or spawning toward an owner with multiple instances is rejected.
Bus Subscribers Created in Handlers Require an Owner
Section titled “Bus Subscribers Created in Handlers Require an Owner”Because a bus handler returns after processing each message, an unowned locus instantiated inside a bus handler dissolves immediately when the handler exits—meaning any bus subscriptions on that locus would never fire for future messages. hale check rejects unowned bus subscribers instantiated inside handlers:
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: bus subscriber instantiated without an accepting owner in handler }}
main locus App { params { h: Hub = Hub { }; } run() { }}To fix this error, declare accept(w: Watcher) on Hub or on an ancestor of Hub that encloses Hub on every construction path:
- Type aliases (
type W = Watcher) resolve to the underlying locus declaration, whereas a similarly named locus from another seed (lib::Watcher) or a different generic specialization (Cell<Int>vs.Cell<String>) does not match. - If
AppacceptsWatcherand holds oneHubinparams, butfn main()also constructs a standaloneHub { }outsideApp,hale checkstill rejectsWatcher { };and points to the unparentedHubinfn main(). - Subscribers instantiated inside
birth(),run(), or non-handler methods (or in library seeds with no entry point, or with--allow-unowned-subscriber) are permitted.
Parent-Child Contracts (contract { expose; consume; })
Section titled “Parent-Child Contracts (contract { expose; consume; })”A child locus declares which parameters its parent may inspect in a contract { expose ...; } block, and the parent declares which fields it reads in contract { consume ...; }:
type SessionState = enum { Lobby, Playing, Over };
locus GameSession { params { tick: Int = 0; state: SessionState = SessionState::Lobby; } contract { expose tick: Int; expose state: SessionState; }}
locus Room { contract { consume tick: Int; } accept(g: GameSession) { if g.tick > 1000 { /* ... */ } }}hale check verifies that the parent declares an accept clause and that every field listed in consume is listed in the accepted child’s expose block with the exact same type. (Note: contract checks the declared expose/consume pairs; use @sealed on a locus when you want the compiler to forbid all external field access.)
Flow Is Vertical Only
Section titled “Flow Is Vertical Only”In Hale’s ownership hierarchy, references flow strictly vertically—from parent to child and child to parent—never sideways between siblings. When sibling loci need to coordinate, they route through their shared parent or publish over the bus.
Because child memory arenas are sub-regions of their parent’s arena and no lateral pointers exist across siblings, tearing down a parent locus safely tears down every accepted child, deregisters all child bus subscriptions, and frees the entire memory subtree wholesale without garbage collection.
Flow Children vs. Resident Children (release and terminate;)
Section titled “Flow Children vs. Resident Children (release and terminate;)”By default, an accepted child is a resident: when its run() returns, the child stays alive in the parent’s arena (for example, to continue handling bus messages) until the parent dissolves.
For a long-lived server that accepts a new child per client connection, keeping children as residents until the server exits would accumulate memory indefinitely. Declaring release(c: Conn) on the parent marks Conn as a flow:
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 connection -> run() returns } }}
locus Server { accept(c: Conn) { } release(c: Conn) { } // marks Conn as a flow child}Flow vs. Resident Semantics
Section titled “Flow vs. Resident Semantics”- Automatic flow reclamation (
release): For a flow child,run()is its lifetime. As soon asc.run()returns, the runtime drainsc, invokes the parent’srelease(c)hook, runsc.dissolve(), and freesc’s memory arena immediately whileServercontinues running. - Program-wide flow classification (
hale check --flows): Declaringrelease(c: T)on any locus in the program (including uninstantiated loci or imported seeds) classifiesTas a flow type everywhere in the program. Runhale check --flowsto inspect everyrelease(c: T)declaration across your project and its imports. - Passing handler data to residents: When a bus handler spawns or updates a resident child, scalar values,
Strings, and record fields are deep-copied into the resident’s arena so they remain valid after the handler returns. Passing a handler-local locus collection (@form(vec)) by reference is rejected at compile time because the handler’s frame dissolves on return; instead, copy the elements into the resident’s own@form(vec)insidebirth(). - Self-termination (
terminate;): Any child (resident or flow) can end its own lifecycle from inside a method or bus handler by executingterminate;. On a resident child,terminate;exits the current method and runsdrain() → dissolve()upon handler return without invokingrelease.
Next: Failure & Supervision.