# Render the root composite like any other composite (slot stubs + role-name entries) — Design Spec **Date:** 2026-06-09 **Status:** Draft — awaiting user spec review **Authors:** orchestrator + Claude ## Goal Close issue #49, and in doing so remove the **last render special-case the root still carries**. After the render-path unification (#48) the blueprint main graph and a composite's `where:`-interior share one core (`render_graph`, `crates/aura-cli/src/graph.rs`), but the root is still rendered specially in two places, both vestiges of the pre-0024 `struct Blueprint`: 1. A composite-**interior** multi-input leaf renders its wired slots as `#…` source-derived stubs (`[cross := Sub(#Sf,#Ss)]`); a **top-level** leaf does not — `SimBroker` renders as bare `[SimBroker]` — because `render_blueprint` threads `stub_ctx: None`. 2. An interior role renders as a `[price]` entry marker (the role **name**); a root role renders as `[source:F64]` (the ingestion **kind**), because `render_blueprint` builds entries from `format!("source:{kind}")`. Cycle 0024 (`1b39093`) deleted `struct Blueprint`: the root is now an ordinary `Composite` with `input_roles: Vec`. So both special-cases are obsolete — the root has roles, and the stub machinery is already keyed on `&Composite` + `&[Role]`. This cycle makes the root render **identically to an interior**: a root role becomes `[price]` marker + `#price` stub, byte-symmetric with how the same role renders inside a `where:` definition. The payoff is the C12 "render the structure so any mis-wiring is visible before a run" guarantee, now complete and uniform at every level — a swapped exposure/price wiring at a top-level `SimBroker` becomes visible, and the root no longer reads as a different kind of graph than its interiors. ## Architecture Two coordinated changes, both dissolving root-specialness; neither adds code that branches on "is this the root": **(A) Thread the stub context.** `render_blueprint` passes the root composite as `stub_ctx` instead of `None`. Since 0024 the root **is** a `&Composite` carrying `input_roles` + edges, exactly what `fan_in_identifiers` → `slot_source` → `signature_of` already consume. `stub_ctx` drops its now-vestigial `Option` (both `render_graph` callers pass `&Composite`). No new stub function — acceptance #3 holds — the existing machinery serves a top-level leaf verbatim. This makes the stubs mechanically determined: - An **interior-fed** root slot (SimBroker slot 0, the `Exposure` edge) takes the producer's sibling-unique `signature_of` prefix → `#E`. - A **source-fed** root slot (SimBroker slot 1, the `price` role) takes the role name verbatim via `slot_source`'s role branch → `#price`. **(B) Name root entries by role.** `render_blueprint` builds its external-entry markers from `role.name` (filtered to bound roles, `source.is_some()`), the same construction `render_definition` already uses for interior roles. The `source:{kind}` naming is removed. A bound root role therefore renders `[price]`, and its slot stub `#price` now refers to a marker that carries the same name — the marker/stub mismatch the `source:{kind}` vestige caused is gone at the source, not patched at the stub. **What stays special is only what is genuinely special.** The root still filters its entries to *bound* roles (`source: Some`) — an interior role is an open port, a root role is a bound ingestion feed (C3). That filter is semantic, not cosmetic, and is the sole remaining distinction; the *rendering* of a role is now identical across levels. **The compiled view is deliberately left as `[source:F64]`.** `render_flat_graph` (graph.rs:495) builds source labels from the flat `SourceSpec.kind` — post-inline the role *name* has dissolved (C23, names are non-load-bearing debug symbols that do not reach the flat graph), so only the kind survives there. Thus `[price]` pre-inline (name alive) and `[source:F64]` post-inline (name gone) is not an inconsistency — it is C23 made visible, and it is the negative control proving the change is confined to the structural blueprint view. Scope: the CLI render layer only (`crates/aura-cli/src/graph.rs`). No engine change, no run-loop change; read-only render (C9), behaviour-preserving for the run path (C1). `signature_of` (`crates/aura-engine/src/blueprint.rs`) is read, not modified. ## Concrete code shapes ### User-facing: `aura graph --macd` (the empirical evidence) The MACD strategy root wires `SimBroker` (node 2) slot 0 (exposure) from the interior `Exposure` edge and slot 1 (price) from the `price` source role. Two lines of the main graph change — the source marker and the broker leaf: ```text # before — root reads as a different graph: kind-named source, bare fan-in leaf [source:F64] ... [Recorder] [SimBroker] # after — root reads like an interior: role-named source, stubbed fan-in leaf [price] ... [Recorder] [SimBroker(#E,#price)] ``` `#E` is the exposure producer's sibling-unique signature prefix (`Exposure`'s `signature_of` is `Em` at the macd root / `Es` at the sample root; the leading `E` already separates it from `price`). `#price` is the source role's name, verbatim — and now matches the `[price]` marker feeding the slot. The same two changes appear in the default `aura graph` (sample) view, whose root is wired identically. Both marker and leaf change width, so the ascii-dag Sugiyama layout of the whole main graph re-flows. The full golden is re-captured from the actual `aura graph` output (not a line edit); the load-bearing facts are: the source marker reads `[price]`, the `SimBroker` leaf carries the two `#…` stubs, and the `where:` section stays byte-identical. ### Implementation before → after ```rust // render_blueprint entry construction (crates/aura-cli/src/graph.rs ~line 70) // before — kind-named source marker (pre-0024 vestige) let entries: Vec = bp.input_roles().iter() .filter_map(|role| role.source.map(|kind| Entry { name: format!("source:{kind:?}"), targets: &role.targets, })) .collect(); // after — role-named, identical to render_definition modulo the bound-only filter let entries: Vec = bp.input_roles().iter() .filter(|role| role.source.is_some()) .map(|role| Entry { name: role.name.clone(), targets: &role.targets }) .collect(); ``` ```rust // render_graph signature (crates/aura-cli/src/graph.rs ~line 111) // before: stub_ctx: Option<&Composite> after: stub_ctx: &Composite ``` ```rust // leaf_label stub gate (crates/aura-cli/src/graph.rs ~line 283) // before — Some/None gate; the None arm was the root carve-out let stubs: Vec = match stub_ctx { Some(c) if slots.len() > 1 => fan_in_identifiers(c, index, &slots), _ => Vec::new(), }; // after — context always present; the fan-in test alone decides let stubs: Vec = if slots.len() > 1 { fan_in_identifiers(stub_ctx, index, &slots) } else { Vec::new() }; ``` ```rust // the two render_graph call sites (crates/aura-cli/src/graph.rs ~80, ~454) // render_blueprint: None, -> bp, // render_definition: Some(c), -> c, ``` The `Entry` doc-comment (graph.rs ~41, "the blueprint from `SourceSpec` (name = `source:{kind}`)") and `leaf_label`'s item-3 doc-comment (graph.rs ~243, "At the blueprint root no such context is threaded…") are rewritten: a root entry is named by its role like any interior role, and a multi-input fan-in renders its slot stubs in both views (the "root has no context" carve-out is removed). ## Components - **`render_blueprint`** (graph.rs): entries built from `role.name` (bound-only filter retained); passes the root composite as `stub_ctx`. - **`render_graph` / `leaf_label`** (graph.rs): `stub_ctx` becomes `&Composite`; the fan-in gate keys on `slots.len() > 1` alone. - **`render_definition`** (graph.rs): passes `c` instead of `Some(c)` (mechanical). - **`render_flat_graph`** (graph.rs): **unchanged** — keeps `source:{kind}` from the flat `SourceSpec` (C23). - **`slot_source` / `fan_in_identifiers` / `signature_of`**: unchanged, reused verbatim. ## Data flow `render_blueprint(bp)` builds entries `{ name: role.name, targets }` for each bound root role, then calls `render_graph(bp.nodes(), bp.edges(), entries, …, stub_ctx = bp)`. Per node, `leaf_label(…, stub_ctx = bp)`; for a multi-input leaf, `fan_in_identifiers(bp, index, slots)` → per slot `slot_source(bp, index, slot)`: an edge-fed slot resolves through `signature_of(bp.nodes(), bp.edges(), bp.input_roles(), bp.params(), e.from)`; a role-fed slot returns the role name. The `where:` definitions still flow through `render_definition(c)` → `render_graph(…, stub_ctx = c)`, untouched and byte-stable. The compiled view flows through the independent `render_flat_graph`, untouched. ## Error handling No new failure modes. Read-only structural rendering (C9): the stub path indexes `bp.nodes()`/`bp.edges()`/`bp.input_roles()` the caller already holds; `slot_source` returns an empty signature for an unwired slot (never panics); `fan_in_identifiers` falls back to a positional `#A` for genuinely interchangeable inputs. The dropped `Option` and the dropped `source:{kind}` format both remove branches rather than add them. No engine validity surface is touched (the `derive_signature` raw-indexing gap is the separate, still-open #24). ## Testing strategy All affected tests live in `crates/aura-cli/src/main.rs`: 1. **`blueprint_view_main_graph_shows_composite_as_opaque_node`** (~line 410): the needle list asserts the sample render contains `[SimBroker]` (bare) with the comment "paramless leaves (SimBroker, Recorder) stay bare". Update: the `SimBroker` needle becomes `[SimBroker(#E,#price)]`, and the comment is corrected — a paramless **single-input** leaf (`Recorder`) stays bare, a paramless **multi-input fan-in** (`SimBroker`) now shows its slot stubs. 2. **`blueprint_view_golden`** (~line 526): full byte golden of the sample blueprint render — re-captured from the actual `aura graph` output. Two changes: the source marker `[source:F64]` → `[price]`, the `SimBroker` leaf gains `(#E,#price)`; the main graph re-flows around both. The `where:` section stays byte-identical (acceptance #2). 3. **`macd_blueprint_renders_a_nested_composite_definition`** (~line 616): add an assertion that the macd root `SimBroker` shows its two slot stubs — `out.contains("[SimBroker(#E,#price)]")`. The macd root source marker also becomes `[price]`; no existing assertion in this test pins the source marker, so the `where:`-interior and opaque-composite assertions are unchanged (acceptance #1 for `--macd` + acceptance #2). 4. **`compiled_view_golden`** (~line 569): asserted **unchanged** — renders through `render_flat_graph`, a separate path that keeps `[source:F64]` (C23: name dissolved post-inline) and `[SimBroker(0.0001)]`. The negative control. 5. **`nested_composite_renders_without_panic`** (~437) and **`reused_composite_defined_once`** (~475): use a `src`-named bound root role. Their `[source:F64]` marker becomes `[src]`, but neither asserts on the source marker text (only on composite markers `[outer]`/`[inner]`/`[dup]` and definition counts), so both stay green unmodified. Confirms the entry-naming change carries the role name through generally, not just for `price`. Build/test/lint gates: `cargo build --workspace`, `cargo test --workspace`, `cargo clippy --workspace --all-targets -- -D warnings`. ## Acceptance criteria Verbatim from #49: - [ ] `aura graph --macd` renders `SimBroker`'s two input slots as `#…` stubs (`[SimBroker(#E,#price)]`). - [ ] The `where:` section stays byte-identical (the composite stub output is unchanged). - [ ] No second parallel stub function is introduced (the de-duplication from #48 holds) — `stub_ctx` loses its `Option`; both views call the same `fan_in_identifiers`/`slot_source` path. Plus the cycle's structural-cleanliness goals: - [ ] A bound root role renders `[price]` (role name), byte-symmetric with how the same role renders as an interior entry marker; the `source:{kind}` special-case is removed from the blueprint view. - [ ] The sample `aura graph` view shows both `[price]` and `[SimBroker(#E,#price)]` (identical root wiring); `blueprint_view_golden` re-captured value-asserted. - [ ] `compiled_view_golden` is byte-unchanged: the compiled view keeps `[source:F64]` (C23 — name dissolved post-inline). Negative control. - [ ] Run-path determinism untouched (C1): only `crates/aura-cli/src/graph.rs` changes. - [ ] Closes #49 on land.