Promoted from a defect-note after a second, independent signal.
Measured while taking #18763's AC-1 (PR #18774, merged): the Colors grid went null from controller.Component#getReferencewhile alive, mounted and rendering. At the throwing moment its ancestor chain read dashboard.Panel → DragProxyContainer → dashboard.Container — a dashboard drag had re-parented the panel into the proxy. Nothing was destroyed; the lookup simply could not see it. The ticket's own premise (a null proves a destroy) was falsified by that measurement.
Flagged independently by @neo-opus-grace reviewing the app-side fix: warming the references at construction leaves a literal widget list in apps/colors/view/ViewportController.mjs that duplicates what three call sites already name. "A fourth widget or a rename loses the protection silently and returns as the same one-in-three CI red." The workaround is load-bearing precisely because the engine cannot answer the question the app is asking.
The Problem
The engine has two notions of parent, and only one of them survives a drag:
notion
written by
read by
parentId — structural, the render parent
DragZone#createDragProxy sets the proxy's to proxyParentId ('document.body'); the hosted widget's becomes the proxy's id
ComponentManager#down via childMap / getDirectChildren, and ComponentManager#up, which walks component.parentId (src/manager/Component.mjs:634)
parentComponent — logical, the owner
DragZone#createDragProxy sets it to me.owner, the container the drag started in
viewport.down({reference: 'grid'}) → null. down() descends childMap, the panel is no longer a child of the dashboard, and the proxy hangs off document.body.
ComponentManager.up(grid.id, ...) → leaves the logical tree at the proxy, because it walks parentId too.
grid.parent.parent… → does reach the dashboard, because the parent getter bridges the proxy.
One of those three is right and the other two are not, and the disagreement is invisible until a controller resolves a reference mid-drag — which is a normal thing for a timer-driven app to do, and produced a ~1-in-3 CI red for a day.
controller.Component#getReference is only the first victim: its cache is a hint and its fallback is me.component.down({reference: name}), so any consumer of down() / up() has the same blind spot. #18763 explicitly put getReference out of scope, which is why this is filed separately rather than folded in.
The Architectural Reality
src/manager/Component.mjs:112down() — matches the component, then recurses through getDirectChildren(parentId); :618up() — while (component?.parentId).
src/component/Abstract.mjs:183get parent() — the only logical-aware traversal in the trio.
src/draggable/DragZone.mjs:315 — isContainer proxies get config.parentComponent = me.owner and parentId: me.proxyParentId; src/draggable/dashboard/SortZone.mjs#startRemoteDrag creates the proxy with the live widget as its item.
src/draggable/DragProxyContainer.mjs#destroy — items = [] first, so the hosted widget survives the proxy. Object permanence is intended and is honoured; only the lookup disagrees.
src/controller/Component.mjs:113 — the existing getReference TODO covers a different failure (remove-then-add with the same key, returning a stale component). This one returns nothing rather than the wrong thing.
The Fix
Make the two manager traversals agree with the parent getter about who a component's parent is, rather than teaching each caller to compensate:
up() — walk component.parent instead of parentId, so a logical owner set by a drag proxy is followed.
down() — when descending, include components whose parentComponent is the current node, not only the childMap entries. The proxy's own subtree then hangs where its owner is.
Both are one-place changes with engine-wide reach, which is why this needs measurement before code: childMap is the O(1) optimisation getDirectChildren exists for, and a naive union walk risks a component being visited twice (proxy hosted AND child-mapped) or an infinite walk if a parentComponent cycle is possible.
The deletion that pays for it (§self_evolving_systems accretion defense): apps/colors/view/ViewportController.mjs loses its warm-the-references line and its literal widget list — the workaround this ticket makes unnecessary — which is also @neo-opus-grace's drift note discharged rather than papered over.
Acceptance Criteria
AC-1 — a unit arm builds a real container tree, hosts a subtree in a real DragProxyContainer (parentId: 'document.body', parentComponent: owner), and asserts that down() from the owner finds a component inside the proxy, and up() from that component reaches the owner. Red-first against dev. test/playwright/unit/apps/colors/ViewportController.spec.mjs has the hosting recipe.
AC-2 — a component reachable through both paths (child-mapped AND proxy-hosted) is returned once, not twice, by down(..., false).
AC-3 — the traversal cost is measured, not assumed: the childMap fast path stays the fast path, with a number for a tree of realistic depth before and after.
AC-4 — apps/colors/view/ViewportController.mjs drops the construction-time warm list, and tearOutMatrix.spec.mjs runs clean ×10 consecutively without it. That is the proof the engine now answers what the app was working around.
AC-5 — the dashboard and draggable component suites stay green, and the Workstation tour arms are run as the widest consumer of down().
Out of Scope
The getReference cache and its :113 TODO (remove-then-add staleness) — a different failure mode; #18763's Architectural Reality enumerates the cache's mutation sites.
Changing what a drag proxy does. parentId / parentComponent as written are deliberate and the proxy is the reason the widget keeps rendering; this leaf changes the readers, not the writer.
Multi-window re-parenting (startRemoteDrag's appName handover) beyond what AC-1's arm needs.
Avoided Traps
Fixing it in getReference. The controller is one caller; down() and up() are engine surfaces with many. A guard there would leave the next consumer to rediscover this.
Another app-side warm list. That is the workaround this ticket exists to delete; adding a second copy elsewhere would make the engine gap permanently invisible.
Assuming a null means destroyed.#18763's body inferred exactly that, and the instrumented run falsified it. Anything here that reasons from a null needs the same instrumentation.
Skipping the cost measurement.getDirectChildren's docblock names the childMap as the optimisation that avoids iterating the registry; a union walk that quietly reintroduces the iteration would trade a correctness bug for a performance one.
Related
#18763 / PR #18774 — where this was measured; its Deltas section carries the falsification.
#18731 / #18741 / #18752 — the resume-liveness family the app-side symptom belonged to.
defect-note MESSAGE:3bcc610f (2026-09-16 09:38Z) — the capture this promotes.
unowned-rationale: filed from a merged lane's review note rather than claimed. @neo-opus-ada is on #18780 (menu focus rest) and #18772 (component-suite flake); AC-1 is the entry point and is pickable by anyone — it needs the unit arm before any traversal change, and the recipe already exists in the spec named there.
Live latest-open sweep: checked the latest open issues at 2026-09-16T10:40:36Z; no equivalent found (#18776 is unit-spec namespace isolation, #18771 is a grid renderer scope). A2A in-flight claim sweep over the last 30 messages (all read-states): no competing claim on manager/Component or draggable/. Memory Core rationale sweep on the symptom nouns: returned #18763's own measurement thread and no prior decision on the traversal split. Own-assignment sweep: no same-surface hit. Structure-map gate: N/A — no .mjs created or relocated, nothing under ai/, Agent OS, MCP, Memory Core, orchestration or .agents/skills.
Decision Record impact: none — it aligns the two managers with component.Abstract's existing definition of parent rather than introducing one.
Retrieval Hint: "drag proxy hosted component invisible to down and up, parentId versus parentComponent" · query_raw_memories on "a null reference proved unreachability, not destruction"
tobiu referenced in commit 7710767 - "fix(manager): both traversals agree with parent about who a component's parent is (#18781) on Sep 16, 2026, 1:43 PM
tobiu referenced in commit 856e667 - "fix(manager): the ownership walk visits each component once, however many keys hold it (#18781) on Sep 16, 2026, 2:38 PM
tobiu referenced in commit 6a1f10b - "fix(manager): both traversals agree with parent about who a component's parent is (#18781) (#18784) on Sep 16, 2026, 3:31 PM
Context
Promoted from a
defect-noteafter a second, independent signal.nullfromcontroller.Component#getReferencewhile alive, mounted and rendering. At the throwing moment its ancestor chain readdashboard.Panel → DragProxyContainer → dashboard.Container— a dashboard drag had re-parented the panel into the proxy. Nothing was destroyed; the lookup simply could not see it. The ticket's own premise (anullproves a destroy) was falsified by that measurement.apps/colors/view/ViewportController.mjsthat duplicates what three call sites already name. "A fourth widget or a rename loses the protection silently and returns as the same one-in-three CI red." The workaround is load-bearing precisely because the engine cannot answer the question the app is asking.The Problem
The engine has two notions of parent, and only one of them survives a drag:
parentId— structural, the render parentDragZone#createDragProxysets the proxy's toproxyParentId('document.body'); the hosted widget's becomes the proxy's idComponentManager#downviachildMap/getDirectChildren, andComponentManager#up, which walkscomponent.parentId(src/manager/Component.mjs:634)parentComponent— logical, the ownerDragZone#createDragProxysets it tome.owner, the container the drag started incomponent.Abstract#parent, which prefers it:me.parentComponent || (me.parentId === 'document.body' ? null : Neo.getComponent(me.parentId))(src/component/Abstract.mjs:186)So while a drag is in flight:
viewport.down({reference: 'grid'})→ null.down()descendschildMap, the panel is no longer a child of the dashboard, and the proxy hangs offdocument.body.ComponentManager.up(grid.id, ...)→ leaves the logical tree at the proxy, because it walksparentIdtoo.grid.parent.parent…→ does reach the dashboard, because theparentgetter bridges the proxy.One of those three is right and the other two are not, and the disagreement is invisible until a controller resolves a reference mid-drag — which is a normal thing for a timer-driven app to do, and produced a ~1-in-3 CI red for a day.
controller.Component#getReferenceis only the first victim: its cache is a hint and its fallback isme.component.down({reference: name}), so any consumer ofdown()/up()has the same blind spot. #18763 explicitly putgetReferenceout of scope, which is why this is filed separately rather than folded in.The Architectural Reality
src/manager/Component.mjs:112down()— matches the component, then recurses throughgetDirectChildren(parentId);:618up()—while (component?.parentId).src/component/Abstract.mjs:183get parent()— the only logical-aware traversal in the trio.src/draggable/DragZone.mjs:315—isContainerproxies getconfig.parentComponent = me.ownerandparentId: me.proxyParentId;src/draggable/dashboard/SortZone.mjs#startRemoteDragcreates the proxy with the live widget as its item.src/draggable/DragProxyContainer.mjs#destroy—items = []first, so the hosted widget survives the proxy. Object permanence is intended and is honoured; only the lookup disagrees.src/controller/Component.mjs:113— the existinggetReferenceTODO covers a different failure (remove-then-add with the same key, returning a stale component). This one returns nothing rather than the wrong thing.The Fix
Make the two manager traversals agree with the
parentgetter about who a component's parent is, rather than teaching each caller to compensate:up()— walkcomponent.parentinstead ofparentId, so a logical owner set by a drag proxy is followed.down()— when descending, include components whoseparentComponentis the current node, not only thechildMapentries. The proxy's own subtree then hangs where its owner is.Both are one-place changes with engine-wide reach, which is why this needs measurement before code:
childMapis the O(1) optimisationgetDirectChildrenexists for, and a naive union walk risks a component being visited twice (proxy hosted AND child-mapped) or an infinite walk if aparentComponentcycle is possible.The deletion that pays for it (
§self_evolving_systemsaccretion defense):apps/colors/view/ViewportController.mjsloses its warm-the-references line and its literal widget list — the workaround this ticket makes unnecessary — which is also @neo-opus-grace's drift note discharged rather than papered over.Acceptance Criteria
DragProxyContainer(parentId: 'document.body',parentComponent: owner), and asserts thatdown()from the owner finds a component inside the proxy, andup()from that component reaches the owner. Red-first againstdev.test/playwright/unit/apps/colors/ViewportController.spec.mjshas the hosting recipe.down(..., false).childMapfast path stays the fast path, with a number for a tree of realistic depth before and after.apps/colors/view/ViewportController.mjsdrops the construction-time warm list, andtearOutMatrix.spec.mjsruns clean ×10 consecutively without it. That is the proof the engine now answers what the app was working around.down().Out of Scope
getReferencecache and its:113TODO (remove-then-add staleness) — a different failure mode; #18763's Architectural Reality enumerates the cache's mutation sites.parentId/parentComponentas written are deliberate and the proxy is the reason the widget keeps rendering; this leaf changes the readers, not the writer.startRemoteDrag'sappNamehandover) beyond what AC-1's arm needs.Avoided Traps
getReference. The controller is one caller;down()andup()are engine surfaces with many. A guard there would leave the next consumer to rediscover this.nullmeans destroyed. #18763's body inferred exactly that, and the instrumented run falsified it. Anything here that reasons from anullneeds the same instrumentation.getDirectChildren's docblock names thechildMapas the optimisation that avoids iterating the registry; a union walk that quietly reintroduces the iteration would trade a correctness bug for a performance one.Related
defect-noteMESSAGE:3bcc610f (2026-09-16 09:38Z) — the capture this promotes.unowned-rationale: filed from a merged lane's review note rather than claimed. @neo-opus-ada is on #18780 (menu focus rest) and #18772 (component-suite flake); AC-1 is the entry point and is pickable by anyone — it needs the unit arm before any traversal change, and the recipe already exists in the spec named there.
Live latest-open sweep: checked the latest open issues at 2026-09-16T10:40:36Z; no equivalent found (#18776 is unit-spec namespace isolation, #18771 is a grid renderer scope). A2A in-flight claim sweep over the last 30 messages (all read-states): no competing claim on
manager/Componentordraggable/. Memory Core rationale sweep on the symptom nouns: returned #18763's own measurement thread and no prior decision on the traversal split. Own-assignment sweep: no same-surface hit. Structure-map gate: N/A — no.mjscreated or relocated, nothing underai/, Agent OS, MCP, Memory Core, orchestration or.agents/skills.Decision Record impact: none — it aligns the two managers with
component.Abstract's existing definition ofparentrather than introducing one.Origin Session ID: cd67869d-cec2-4c36-99a6-aa521a73fddb
Retrieval Hint: "drag proxy hosted component invisible to down and up, parentId versus parentComponent" ·
query_raw_memorieson "a null reference proved unreachability, not destruction"