Skip to content

feat(actor): parent-as-compositor — aggregate inline-child draws into one ordered emit #1945

@iamacoffeepot

Description

@iamacoffeepot

Description

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.render once 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 1) A new substrate self-carry host fn — unnecessary: feat(actor): in-cluster relative addressing and in-place local dispatch #1977's registry-lookup ctx.parent() yields the parent's real folded id with no new host fn and no substrate change.
  • (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

Implementation plan

The dependencies (#1958 ctx.source_mailbox(), #1977 ctx.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.

  1. 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).
  2. 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.
  3. 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.
  4. 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:

Metadata

Metadata

Assignees

No one assigned

    Labels

    crate:actoraether-actor (host actor framework)model:opusRequired dispatch model — set by /scope at Plan (fable = human pin only)phase:planLifecycle: Plan (impl plan, awaiting /approve) — set by /scopesize:mEstimated size — set by /scope at Plantype:featNew feature

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions