You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
The widget compositing handshake — step 6 of the ADR-0114 inline-child arc, the first consumer slice. The parent acts as compositor: inline children (#1916 / #1930 / #1939) send their draw geometry to the parent in local coords; the parent applies each child's offset keyed by origin = child address, aggregates the subtree into a few batches, and emits to one render sender — the fix for the #1852 render fan-in. Plus the one render-cap change: an explicit cross-component layer key for draw order (no overlay depth buffer; painter's order constrained only between overlapping elements).
Touches the render cap (crates/aether-capabilities/src/render.rs), the render kinds (aether-kinds — a layer-key field), and the guest-side aggregation (the actor SDK). ADR-0114's "Open questions" defers the Widget trait and the draw/compositing handshake to a consumer ADR, so /scope will draft that ADR — re-grounding the design converged across the earlier widget-design sessions (parent-as-compositor, local font metrics via #1883). The inline-child mechanism this builds on — spawn (#1916) / reload (#1930) / teardown (#1939) — is fully landed.
Problem statement
The inline-child mechanism (ADR-0114, steps 1–5) lets a component hold many co-located child actors cheaply, but nothing aggregates their drawing. Each inline child that draws calls ctx.actor::<RenderCapability>().send(...) and reaches aether.render directly, stamped with the child's own address — so a component with N drawing children is N render senders, re-creating the #1852 fan-in inside one component. And there is no draw order that composes: the draw kinds carry no ordering key, so order is submission-order within a pass and a fixed world→overlay split.
This is the first consumer slice of step 6, recorded in ADR-0117. Done: the compositor mechanism lands in the actor SDK — a parent aggregates its inline-child subtree's draws (each attributed by source address, offset by the layout the parent owns) and emits to aether.render once per frame, so a component is one render sender regardless of child count. Draw order is structural (the subtree's traversal), with no flat layer key. Verified by a raw inline-child fixture: a parent with N drawing children emits one ordered batch, ordered by the subtree's traversal. The two primitives this consumes both landed since the bounce — a child reaches its parent with ctx.parent() (in-cluster relative addressing, #1977) and the parent attributes each draw by ctx.source_mailbox() (inbound source, #1958 + #1977's in-place source) — so this issue is now an unblocked, single-crate aether-actor change.
Design notes
Recorded in ADR-0117 (merged, PR #1951) — the consumer ADR ADR-0114 deferred. The summary below is the decision; the ADR is the full record. ADR-0117 holds as written: Decision §1 ("the parent reads each draw's stamped origin = child address") is design-correct, and the landed addressing primitives (ctx.parent() from #1977, ctx.source_mailbox() from #1958) are how the implementation surfaces it.
Re-scoped 2026-06-16 after the bounce. The bounced plan threaded the parent's own_mailbox_id into the child as hash(NAMESPACE), which is the depth-1 ActorId fixed point and wrong for a loaded (depth ≥ 2) component, so the child's draw was dropped. #1977 (merged, PR #1981) resolved this generally: a guest now addresses its parent / siblings / children by registry-lookup relative addressing (ctx.parent() et al.), depth-correct, and an intra-cluster send dispatches in place through the membrane. The bounce's two proposed resolutions are moot — #1977 landed the real fix with no new substrate primitive and no redefinition of ctx.parent().
Chosen approach
A component is the compositor for its inline-child subtree. Children draw in local coords and emit their geometry to the parent with ctx.parent().send::<K>(&draw) (#1977) — an intra-cluster send that runs in place through the membrane (no scheduler hop), not a send to aether.render. The parent's draw handler reads each draw's sender with ctx.source_mailbox() (#1958 + #1977's in-place source), applies the per-child layout offset it owns, aggregates, and emits to aether.renderonce per frame — one render sender per component, the #1852 fix. Draw order is structural: the depth-first traversal of the subtree (sibling order + depth), which the inline-child address already encodes — no per-draw layer/z field. It is a total order (no ties) and composes (a subtree relocates without renumbering). Slots are named inline children; layout flows down, geometry is local, intrinsic size flows up (#1883 for text). The component is the grain of isolation: cheap cooperative widgets collapse inline (serialized — free for UI), heavy / independently-reloaded / failure-isolated aspects split into their own components (spawn_inline_child vs spawn_child).
Scope shifts (resolved in design + scoping + this re-scope). (1) Draw order is the tree structure, so there is no flat layer key — the explicit ordering key survives only as a deferred escape hatch (named in ADR-0117, forward-compatible, built when a real overlay needs it). (2) The addressing the compositor needs — child→parent reach and per-child source attribution — is now entirely landed (#1977 + #1958); this issue consumes ctx.parent() and ctx.source_mailbox() rather than building any addressing or touching the substrate. The only new surface is the compositor aggregation type.
Rejected options
A flat ordering key on every draw kind — an absolute key on a shared axis needs global agreement and does not compose; structural order is relative, local, and tie-free. (ADR-0117.)
An overlay depth buffer / per-quad z — 2-D UI composes by painter's order; a depth buffer fights alpha blending.
Payload-carried origin (the child stamps its own address into a wrapper draw kind) — bloats every draw payload and contradicts the ADR's "stamped origin" framing; ctx.source_mailbox() reuses data the substrate already stamps.
(Bounce resolution 2) Redefining ctx.parent() as "sender of the current inbound mail" — conflates "my parent" with "who last drove me", forces a Manual-mode handler, and is not host-unit-testable; feat(actor): in-cluster relative addressing and in-place local dispatch #1977's ctx.parent() is a clean, type-free, depth-correct accessor instead.
A central retained UI engine / a parallel widget API — the nodes are live actors; a widget is just an actor that mails its parent (ADR-0114).
Affected surfaces
aether-actor (SDK), this issue — the parent-compositor aggregation: an SDK type the parent holds that buckets inbound draws by ctx.source_mailbox(), applies the parent-owned per-slot offset, and emits one ordered batch in structural order to RenderCapability. The child→parent send is the landed ctx.parent().send().
docs/adr/0117-widget-compositing.md: the record (merged) — the design gate, already cleared.
Deferred (not this issue): the ordering escape-hatch key; the Widget trait API surface (its own consumer ADR + slice).
Untouched: the scheduler, the inline-child spawn / reload / teardown mechanism, MailboxId semantics, the substrate.
Implementation plan
The dependencies (#1958ctx.source_mailbox(), #1977ctx.parent() + in-place dispatch) are landed on main; the steps below consume them. model:opus / size:m — down from size:l: the cross-crate addressing dependencies landed, leaving a single-crate aether-actor change, and the compositor mechanism is already prototyped in the preserved worktree; opus because the compositor API surface and the structural-order semantics need design judgment, not a verbatim-mechanical plan. Branch from fresh main (it now carries #1977). The preserved worktree .claude/worktrees/issue-1945 holds a reusable reference implementation — the compositor SDK type (Compositor<K>, Composable / Offset, slot / record / composite / flush, 3 green unit tests) + an e2e fixture — built before #1977; port the compositor mechanism and rebuild step 1 on the landed primitives.
Child→parent draw send (consume feat(actor): in-cluster relative addressing and in-place local dispatch #1977). The child emits its local-coord geometry with ctx.parent().send::<K>(&draw) — registry-lookup relative addressing, depth-correct (the bounced hash(NAMESPACE) premise is gone), dispatched in place through the membrane. The parent's draw handler attributes the sender with ctx.source_mailbox(). No new addressing code, no substrate change. — crates/aether-actor/src/ffi/inline/* already proves child→parent source_mailbox() attribution (the RecordingChild tests); this step points the compositor's child at ctx.parent(). — unit test: a child's draw via ctx.parent() reaches the parent's handler and the parent reads source_mailbox() == that child's id (largely covered by the landed inline tests; assert it through the compositor's child).
Compositor aggregation helper. An SDK type the parent holds: record(source, &[draws]) buckets inbound draws by source (from ctx.source_mailbox()) into the parent's slot order; flush(&mut ctx) applies each slot's parent-owned offset to the child's local geometry, concatenates into one batch, and emits once to RenderCapability. — port from the preserved worktree into crates/aether-actor/src/ffi/compositor.rs (+ re-export) — unit test: record draws from two sources, flush emits one offset-applied batch in slot order, and a child never sends to aether.render directly.
Structural-order flush. Order = the compositor's slot list (parent-owned); a child that is itself a compositor orders its own sub-slots, giving the ADR-0117 §2 DFS. First slice is single-level (parent + N children) = slot order. — compositor.rs — covered by step 2's ordering assertion; add a nested-compositor case asserting DFS order.
End-to-end fixture (TestBench). A parent component spawning N raw Instanced inline children (no Widget trait), each emitting a DrawTriangle in local coords to its parent via ctx.parent(); the parent offsets per slot and emits once. — a test fixture component + a test_bench scenario — TestBench assertion: exactly one render submission per frame regardless of N (the chore(substrate-bundle): measure widget-actor aggregate cost at scale #1852 fix) and the batch ordered by slot / traversal (rendered-output assertion → TestBench per CLAUDE.md).
Sub-issues
None — single PR. The arc dependencies have landed:
Description
Touches the render cap (
crates/aether-capabilities/src/render.rs), the render kinds (aether-kinds— a layer-key field), and the guest-side aggregation (the actor SDK). ADR-0114's "Open questions" defers theWidgettrait and the draw/compositing handshake to a consumer ADR, so/scopewill draft that ADR — re-grounding the design converged across the earlier widget-design sessions (parent-as-compositor, local font metrics via #1883). The inline-child mechanism this builds on — spawn (#1916) / reload (#1930) / teardown (#1939) — is fully landed.Problem statement
The inline-child mechanism (ADR-0114, steps 1–5) lets a component hold many co-located child actors cheaply, but nothing aggregates their drawing. Each inline child that draws calls
ctx.actor::<RenderCapability>().send(...)and reachesaether.renderdirectly, stamped with the child's own address — so a component with N drawing children is N render senders, re-creating the #1852 fan-in inside one component. And there is no draw order that composes: the draw kinds carry no ordering key, so order is submission-order within a pass and a fixed world→overlay split.This is the first consumer slice of step 6, recorded in ADR-0117. Done: the compositor mechanism lands in the actor SDK — a parent aggregates its inline-child subtree's draws (each attributed by source address, offset by the layout the parent owns) and emits to
aether.renderonce per frame, so a component is one render sender regardless of child count. Draw order is structural (the subtree's traversal), with no flat layer key. Verified by a raw inline-child fixture: a parent with N drawing children emits one ordered batch, ordered by the subtree's traversal. The two primitives this consumes both landed since the bounce — a child reaches its parent withctx.parent()(in-cluster relative addressing, #1977) and the parent attributes each draw byctx.source_mailbox()(inbound source, #1958 + #1977's in-place source) — so this issue is now an unblocked, single-crateaether-actorchange.Design notes
Chosen approach
A component is the compositor for its inline-child subtree. Children draw in local coords and emit their geometry to the parent with
ctx.parent().send::<K>(&draw)(#1977) — an intra-cluster send that runs in place through the membrane (no scheduler hop), not a send toaether.render. The parent's draw handler reads each draw's sender withctx.source_mailbox()(#1958 + #1977's in-place source), applies the per-child layout offset it owns, aggregates, and emits toaether.renderonce per frame — one render sender per component, the #1852 fix. Draw order is structural: the depth-first traversal of the subtree (sibling order + depth), which the inline-child address already encodes — no per-draw layer/z field. It is a total order (no ties) and composes (a subtree relocates without renumbering). Slots are named inline children; layout flows down, geometry is local, intrinsic size flows up (#1883 for text). The component is the grain of isolation: cheap cooperative widgets collapse inline (serialized — free for UI), heavy / independently-reloaded / failure-isolated aspects split into their own components (spawn_inline_childvsspawn_child).Scope shifts (resolved in design + scoping + this re-scope). (1) Draw order is the tree structure, so there is no flat layer key — the explicit ordering key survives only as a deferred escape hatch (named in ADR-0117, forward-compatible, built when a real overlay needs it). (2) The addressing the compositor needs — child→parent reach and per-child source attribution — is now entirely landed (#1977 + #1958); this issue consumes
ctx.parent()andctx.source_mailbox()rather than building any addressing or touching the substrate. The only new surface is the compositor aggregation type.Rejected options
ctx.source_mailbox()reuses data the substrate already stamps.ctx.parent()yields the parent's real folded id with no new host fn and no substrate change.ctx.parent()as "sender of the current inbound mail" — conflates "my parent" with "who last drove me", forces aManual-mode handler, and is not host-unit-testable; feat(actor): in-cluster relative addressing and in-place local dispatch #1977'sctx.parent()is a clean, type-free, depth-correct accessor instead.Affected surfaces
aether-actor(SDK), this issue — the parent-compositor aggregation: an SDK type the parent holds that buckets inbound draws byctx.source_mailbox(), applies the parent-owned per-slot offset, and emits one ordered batch in structural order toRenderCapability. The child→parent send is the landedctx.parent().send().docs/adr/0117-widget-compositing.md: the record (merged) — the design gate, already cleared.ctx.parent()+ in-place dispatch (feat(actor): in-cluster relative addressing and in-place local dispatch #1977, PR feat(actor): in-cluster relative addressing and in-place local dispatch #1981) andctx.source_mailbox()/source_of(feat(substrate): let a guest read its inbound mail's source mailbox #1958). No remaining dependency; no substrate touch in this issue.Widgettrait API surface (its own consumer ADR + slice).MailboxIdsemantics, the substrate.Implementation plan
The dependencies (#1958
ctx.source_mailbox(), #1977ctx.parent()+ in-place dispatch) are landed onmain; the steps below consume them.model:opus/size:m— down fromsize:l: the cross-crate addressing dependencies landed, leaving a single-crateaether-actorchange, and the compositor mechanism is already prototyped in the preserved worktree;opusbecause the compositor API surface and the structural-order semantics need design judgment, not a verbatim-mechanical plan. Branch from freshmain(it now carries #1977). The preserved worktree.claude/worktrees/issue-1945holds a reusable reference implementation — the compositor SDK type (Compositor<K>,Composable/Offset,slot/record/composite/flush, 3 green unit tests) + an e2e fixture — built before #1977; port the compositor mechanism and rebuild step 1 on the landed primitives.ctx.parent().send::<K>(&draw)— registry-lookup relative addressing, depth-correct (the bouncedhash(NAMESPACE)premise is gone), dispatched in place through the membrane. The parent's draw handler attributes the sender withctx.source_mailbox(). No new addressing code, no substrate change. —crates/aether-actor/src/ffi/inline/*already proves child→parentsource_mailbox()attribution (theRecordingChildtests); this step points the compositor's child atctx.parent(). — unit test: a child's draw viactx.parent()reaches the parent's handler and the parent readssource_mailbox() == that child's id(largely covered by the landed inline tests; assert it through the compositor's child).record(source, &[draws])buckets inbound draws bysource(fromctx.source_mailbox()) into the parent's slot order;flush(&mut ctx)applies each slot's parent-owned offset to the child's local geometry, concatenates into one batch, and emits once toRenderCapability. — port from the preserved worktree intocrates/aether-actor/src/ffi/compositor.rs(+ re-export) — unit test: record draws from two sources, flush emits one offset-applied batch in slot order, and a child never sends toaether.renderdirectly.compositor.rs— covered by step 2's ordering assertion; add a nested-compositor case asserting DFS order.Instancedinline children (noWidgettrait), each emitting aDrawTrianglein local coords to its parent viactx.parent(); the parent offsets per slot and emits once. — a test fixture component + atest_benchscenario — TestBench assertion: exactly one render submission per frame regardless of N (the chore(substrate-bundle): measure widget-actor aggregate cost at scale #1852 fix) and the batch ordered by slot / traversal (rendered-output assertion → TestBench per CLAUDE.md).Sub-issues
None — single PR. The arc dependencies have landed:
feat(substrate): let a guest read its inbound mail's source mailbox— landed (source_of/ctx.source_mailbox()).feat(actor): in-cluster relative addressing and in-place local dispatch— landed (PR feat(actor): in-cluster relative addressing and in-place local dispatch #1981;ctx.parent()/child()/sibling()+ in-place membrane dispatch). The compositor's child→parent send consumes it.