ADR 0007 — Parent-input match: name == type OR category OR source¶
Status: Accepted, 2026-05-17.
Supersedes: none.
Refines: the Sprint-17 parent-input model (system.md "Inputs, and the parent input").
Related: CLAUDE.md Rule #1 / Lineage ("strcmp on type names" v1 drift); ModuleManager.cpp parent_input_idx_.
Context¶
Sprint 17 made the parent be an input: reparenting C onto P sets the
parent flag on C's input whose name == P's registered factory type, or
the "source" wildcard, else the drop is rejected (system.md).
This produced a self-inconsistency, found while authoring a layout-swap
scenario. Effects (PixelEffectBase subclasses) declare exactly one input,
keyed "layout". Layouts register under distinct factory types:
GridLayoutModule→"layout", RingLayoutModule→"ring-layout",
WheelLayoutModule→"wheel-layout" — but all three derive LayoutModule,
whose virtual category() returns "layout".
Result:
- reparent effect → Grid (type
"layout"): input"layout"== type"layout"→ nests. ✓ - reparent effect → Ring/Wheel (type
"ring-layout"/"wheel-layout"): no input named that, no"source"on effects → rejected by design. Ring/Wheel layouts are structurally un-nestable by any effect.
Meanwhile data-flow resolution already matches layouts by category:
PixelEffectBase::resolve_layout_() walks the parent chain / find() and
matches category()=="layout", never the type string (the header comment
explicitly names type-string matching "the v1 strcmp-on-type-name
pattern"). So an effect can resolve a Ring/Wheel layout's geometry but
cannot be nested under it: the two halves of one relationship
(structural nesting vs. data resolution) use two different match rules.
That asymmetry is the defect — not a missing feature.
Decision¶
The parent-input match adds category as a match key. An input matches parent P if its name equals:
P.type()— exact factory type (unchanged; still wins), orP.category()— the module's domain category, or"source"— the type-agnostic wildcard (unchanged).
Precedence: exact type > category > source. Exact type returning first
preserves the dual-input case: an effect with both a generic "layout"
input and a specific "ring-layout" input, dropped on a Ring, binds the
specific input. Category is the new middle rung; "source" stays last.
parent_input_idx_(child, parent) now takes the parent module (to call
category()), not just its type string. Single call site (reparent())
already holds the parent module. Net ≈ +5 LOC in one core function; no new
file, no new concept — category() already exists and is already the match
key on the data-resolution side. This removes the type-vs-category split,
deleting the last "strcmp on type names" drift from the reparent path.
Consequences¶
- An effect's generic
"layout"input now nests under anycategory()=="layout"module (Grid/Ring/Wheel). Canvas drag of an effect onto a Ring/Wheel node, and the layout-swap scenario, now work. The reference pipeline is unchanged (ripples→layout binds via exact type; preview/artnet→ripples via"source"). - Structural nesting and data resolution now use the same category rule — the inconsistency is gone.
- Tradeoff (locked in): the type string can no longer make one layout
kind un-nestable. If a future requirement needs "nests under Grid but not
Ring", that must be a type-named input (
"ring-layout") — exact-type precedence already supports it, so the door stays open, but the absence of a category match is no longer the lever. test_reparent.cpp: existing assertions unchanged (their test parents'type()/category()don't collide); one new case pins category match, one pins exact-type-beats-category precedence.
Why an ADR (not a silent change)¶
system.md states the match rule explicitly in two load-bearing lines
("name equals P's type … or source"). Adding category() is a deliberate
amendment to the documented parent-input model — CLAUDE.md: "architecture
changes only via ADR — never silently." It is one model change, recorded
once; the paired system.md edits are surgical (the two match-rule
sentences). No // PATCH: — this is a true fix of an asymmetry, not a
workaround.