Context
Found while diagnosing #17578 (cross-window drag finds no target). It is not that bug's cause — the coordinator's own toJSON projection, captured in that ticket's failure payload, shows both windows present in sortZones at failure time, so the registry is intact there. This is a separate, independently reproducible defect that the search surfaced.
The Problem
Neo.manager.DragCoordinator keys its registry by [sortGroup, windowId], and register overwrites that key (DragCoordinator.mjs:1078):
me.sortZones.get(sortGroup).set(windowId, sortZone)
unregister then deletes by key without checking which zone it is evicting:
let group = me.sortZones.get(sortGroup);
group.delete(windowId);
if (group.size === 0) {
me.sortZones.delete(sortGroup)
}A window whose sort zone is replaced therefore has two zone objects contending for one key, and the order they resolve in decides what survives. Neo.draggable.dashboard.SortZone registers in construct (:52) and unregisters in destroy (:236).
Replacement is not universal, and the original wording here overstated it (corrected 2026-08-23 after @neo-gpt-emmy's RA-1). DockProjectionReconciler branches at :313: geometryOnly || retainTopology routes to reconcileStableTopology, which reconciles the existing shell in place and swaps no zone at all. Only the staged structural path (:356-446) builds the successor shell before retiring the predecessor (:273 — "move retained tab ancestors plus swap shell visibility, then destroy the empty"). So the live sequence, on each staged structural re-projection, is:
register(successor) → destroy(predecessor) → unregister(predecessor) → delete(windowId)
and the predecessor evicts the successor that replaced it. Because that was the group's last entry, group.size === 0 then fires and the entire sort group is pruned.
The Architectural Reality
The consequence is remote from the cause and silent. resolveClaimedTarget opens with:
let group = this.sortZones.get(sortGroup);
if (!group) {
return null
}so it returns before its loop runs. Nothing throws; the surviving zone is perfectly healthy and still answers acceptsRemoteDrag; and any app-side reconstruction of "would this zone accept?" — which iterates the app's own live zones, not the coordinator's map — reports a willing target. The two collections disagree and only one of them is consulted for the claim.
The identity discipline is already present one line below, on activeTargetZone: if (me.activeTargetZone === sortZone), with a spec pinning it — "unregister leaves an UNRELATED live target alone — the guard is identity-scoped, not a blanket reset." The registry delete sitting directly above it is not identity-scoped. That adjacency is likely why it survived review: the reviewer's attention landed on the guard that is correct.
Existing coverage confirms the gap. DragCoordinator.spec.mjs tests register → unregister → register ("re-registering the same identity after unregister starts clean") — the safe ordering. Nothing tests register → register → unregister(first), which is the ordering the reconciler's staged structural path produces.
The Fix
Evict only the zone's own registration:
if (group.get(windowId) === sortZone) {
group.delete(windowId);
if (group.size === 0) {
me.sortZones.delete(sortGroup)
}
}Contract Ledger Matrix
| # |
Target surface |
Source of authority |
Before |
After |
Fallback |
Evidence |
| 1 |
DragCoordinator.unregister(sortZone) |
src/manager/DragCoordinator.mjs |
deletes whatever holds [sortGroup, windowId] |
deletes only when the holder is sortZone |
none — a non-holder had nothing to remove |
red-first spec below |
| 2 |
sortZones group pruning |
same |
prunes when the key delete empties the group |
unchanged, but only reachable via an owned delete |
none |
second arm asserts the last holder still prunes |
Acceptance Criteria
Out of Scope
- #17578's root cause. Refuted here by the coordinator's own registry projection; that ticket's search continues on its own evidence.
- The
activeTargetZone / activeSourceZone guards below the delete — already identity-scoped and correct.
nativeWindowDropCandidates and the native-window drag path.
Avoided Traps
- Reading the app-side
candidateDiagnostics as the coordinator's view. They are different collections; the whole failure mode is that they can disagree. Only the coordinator's own sortZones answers what the claim loop can see.
- Fixing this by making
register refuse to overwrite. The overwrite is correct — a successor legitimately takes the key. The defect is in the eviction, not the acquisition.
- Asserting only the positive arm. "Successor survives" alone is satisfiable by never deleting anything, which would leave departed windows permanently droppable.
Decision Record impact
none — a correctness fix inside an existing contract; ADR 0029 §2.8.1's claim protocol is unchanged.
Structure-map gate: N/A — no file introduced or relocated.
Live latest-open sweep: checked latest 20 open issues at 2026-08-23T18:35Z; no equivalent found. A2A in-flight claim sweep: latest 30 all-state messages; no overlapping claim.
Related
#17578 (where this was found; not its cause) · #15248 (the teardown-hygiene work that added the adjacent identity guard)
Origin Session ID: eb671e6e-ca17-4a53-8069-64fd5885ce84
Retrieval Hint: query_raw_memories("DragCoordinator unregister deletes successor sortZones key identity guard re-projection")
Context
Found while diagnosing #17578 (cross-window drag finds no target). It is not that bug's cause — the coordinator's own
toJSONprojection, captured in that ticket's failure payload, shows both windows present insortZonesat failure time, so the registry is intact there. This is a separate, independently reproducible defect that the search surfaced.The Problem
Neo.manager.DragCoordinatorkeys its registry by[sortGroup, windowId], andregisteroverwrites that key (DragCoordinator.mjs:1078):me.sortZones.get(sortGroup).set(windowId, sortZone)unregisterthen deletes by key without checking which zone it is evicting:let group = me.sortZones.get(sortGroup); group.delete(windowId); if (group.size === 0) { me.sortZones.delete(sortGroup) }A window whose sort zone is replaced therefore has two zone objects contending for one key, and the order they resolve in decides what survives.
Neo.draggable.dashboard.SortZoneregisters inconstruct(:52) and unregisters indestroy(:236).Replacement is not universal, and the original wording here overstated it (corrected 2026-08-23 after @neo-gpt-emmy's RA-1).
DockProjectionReconcilerbranches at:313:geometryOnly || retainTopologyroutes toreconcileStableTopology, which reconciles the existing shell in place and swaps no zone at all. Only the staged structural path (:356-446) builds the successor shell before retiring the predecessor (:273— "move retained tab ancestors plus swap shell visibility, then destroy the empty"). So the live sequence, on each staged structural re-projection, is:and the predecessor evicts the successor that replaced it. Because that was the group's last entry,
group.size === 0then fires and the entire sort group is pruned.The Architectural Reality
The consequence is remote from the cause and silent.
resolveClaimedTargetopens with:let group = this.sortZones.get(sortGroup); if (!group) { return null }so it returns before its loop runs. Nothing throws; the surviving zone is perfectly healthy and still answers
acceptsRemoteDrag; and any app-side reconstruction of "would this zone accept?" — which iterates the app's own live zones, not the coordinator's map — reports a willing target. The two collections disagree and only one of them is consulted for the claim.The identity discipline is already present one line below, on
activeTargetZone:if (me.activeTargetZone === sortZone), with a spec pinning it — "unregister leaves an UNRELATED live target alone — the guard is identity-scoped, not a blanket reset." The registry delete sitting directly above it is not identity-scoped. That adjacency is likely why it survived review: the reviewer's attention landed on the guard that is correct.Existing coverage confirms the gap.
DragCoordinator.spec.mjstestsregister → unregister → register("re-registering the same identity after unregister starts clean") — the safe ordering. Nothing testsregister → register → unregister(first), which is the ordering the reconciler's staged structural path produces.The Fix
Evict only the zone's own registration:
if (group.get(windowId) === sortZone) { group.delete(windowId); if (group.size === 0) { me.sortZones.delete(sortGroup) } }Contract Ledger Matrix
DragCoordinator.unregister(sortZone)src/manager/DragCoordinator.mjs[sortGroup, windowId]sortZonesortZonesgroup pruningAcceptance Criteria
unregisterevicts only its own registration: afterregister(a)→register(b)on one key →unregister(a),bis still registered. Verified red-first — the arm fails on the current code.unregisterentirely.unit/manager,unit/dashboardandunit/draggablestay green.Out of Scope
activeTargetZone/activeSourceZoneguards below the delete — already identity-scoped and correct.nativeWindowDropCandidatesand the native-window drag path.Avoided Traps
candidateDiagnosticsas the coordinator's view. They are different collections; the whole failure mode is that they can disagree. Only the coordinator's ownsortZonesanswers what the claim loop can see.registerrefuse to overwrite. The overwrite is correct — a successor legitimately takes the key. The defect is in the eviction, not the acquisition.Decision Record impact
none— a correctness fix inside an existing contract; ADR 0029 §2.8.1's claim protocol is unchanged.Structure-map gate: N/A — no file introduced or relocated.
Live latest-open sweep: checked latest 20 open issues at 2026-08-23T18:35Z; no equivalent found. A2A in-flight claim sweep: latest 30 all-state messages; no overlapping claim.
Related
#17578(where this was found; not its cause) ·#15248(the teardown-hygiene work that added the adjacent identity guard)Origin Session ID: eb671e6e-ca17-4a53-8069-64fd5885ce84
Retrieval Hint:
query_raw_memories("DragCoordinator unregister deletes successor sortZones key identity guard re-projection")