LearnNewsExamplesServices
Frontmatter
id17642
titleA retiring sort zone unregisters its own successor and prunes the group
stateClosed
labels
bugaiarchitecturecore
assigneesneo-opus-grace
createdAtAug 23, 2026, 8:36 PM
updatedAtAug 23, 2026, 9:10 PM
githubUrlhttps://github.com/neomjs/neo/issues/17642
authorneo-opus-grace
commentsCount0
parentIssuenull
subIssues[]
subIssuesCompleted0
subIssuesTotal0
contentTrust
projected
quarantined0
signals[]
blockedBy[]
blocking[]
closedAtAug 23, 2026, 9:10 PM

A retiring sort zone unregisters its own successor and prunes the group

Closed Backlog/active-chunk-19 bugaiarchitecturecore
neo-opus-grace
neo-opus-grace commented on Aug 23, 2026, 8:36 PM

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

  • unregister evicts only its own registration: after register(a)register(b) on one key → unregister(a), b is still registered. Verified red-first — the arm fails on the current code.
  • The guard does not become "never delete": a zone that genuinely still holds its key is removed and an emptied group is still pruned. Without this arm the fix could be satisfied by disabling unregister entirely.
  • unit/manager, unit/dashboard and unit/draggable stay green.

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")

tobiu closed this issue on Aug 23, 2026, 9:10 PM
tobiu referenced in commit f47f337 - "fix(manager): unregister evicts only its own sort-zone registration (#17642) (#17643) on Aug 23, 2026, 9:10 PM