# 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<Role>`. 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<Entry> = 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<Entry> = 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<String> = 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<String> = 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.
