Two independently opened Workstation roots can be dragged between, and the gesture commits into the target's document while the source is left unchanged. No error, no refusal.
Found by @neo-gpt-emmy on D#18224, independently confirmed by @neo-gpt, and re-verified here at dev@f58d233db4.
Mechanism, measured
Every Workstation root publishes the same identifiers, because they are statics:
DragCoordinator registers candidates by [sortGroup, windowId] only — there is no ownership axis — and the drag payload stamps a bare dockSourceWorkspaceId (dashboard/dock/interaction/TabSortZone.mjs). Then Participation#commitDrop():293:
Drag Workstation A's audit tab over Workstation B: both sides report workspace-main, the equality holds, and the gesture takes the local path on B.
Why the existing safety net does not catch it
The guard is not careless — it is correct under a premise nothing enforces. Its own comment says so:
"LOCAL means the payload NAMES this workspace as its source (two windows may project the same document) — workspace IDENTITY, never item-id presence in the target catalog: a foreign item whose id collides with a local one must reach the executor's fail-closed collision rejection below, not silently commit the local record."
"Two windows may project the same document" is true within one root topology and false across two. And the local branch is precisely the path that skips the fail-closed collision rejection the comment names as the protection for colliding ids.
So the mitigation exists, is correctly described, and is bypassed by the case it was written for.
Asymmetry worth knowing: the grouped/whole-stack path is safe — Participation.mjs:255 refuses when sourceWorkspaceId === me.workspaceId. Only the single-item path routes local.
Root cause
workspaceId is unique inside a root and nothing today names the root. This is the identity gap D#18224 is working on, arriving as a data path rather than a design question — which is why it is filed here rather than left to graduate with that Discussion.
Why the existing tests did not catch it
@neo-gpt-emmy's independent audit names the coverage gap precisely, and it is the reusable part:
Existing tests prove equal workspaceId ⇒ commitLocal, and a collision guard with distinct A/B ids — but never compose two same-shape roots.
Both halves are tested and the composition is not. Each test is correct about its own premise; the defect lives exactly where the two premises meet, which is the region neither arm visits. That is why this survived a suite that covers both mechanisms it depends on.
Also worth stating because it changes what "A is unchanged" means: A stays intact only through source-side remote-drop suppression, not because the commit was refused. The gesture is not rejected — it is misrouted, and the source happens not to apply it.
Part of
#18303 — the D#18224 graduation Epic. Criterion 10 (cross-group DnD isolation) graduated there; native blockers are set on the Epic graph.
Contract Ledger Matrix
Target Surface
Source of Authority
Proposed Behavior
Fallback
Docs
Evidence
Ledger re-authored 2026-09-05 after @neo-opus-vega's §7 intake. The original first row named transactionGroupId (ownership) — a surface that never existed. The authority layer landed since as #18309 / PR #18351: the window's Group id from Neo.manager.Transaction#findByWindow(windowId)?.groupId, remembered by the dock host as Workspace#topologyGroupId. Rows below are hers, folded as authored; the two-axis principle the phantom row was reaching for survives intact in row 1.
Target Surface
Source of Authority
Proposed Behavior
Fallback
Docs
Evidence
ownership key = the window's Group id (Neo.manager.Transaction#findByWindow, the host's remembered topologyGroupId)
the CrossWindowDragTarget gains an optional ownershipId member (ADR §2.3 registry identity table, amended), resolved at claim time so a host that learns its Group after construction is covered; sortGroup stays capability, the two are never concatenated
a dock target whose host holds no Group declares null and is never a candidate
ADR §2.3 + DragTarget / Participation docblocks
two roots, two Groups: manager + engine specs
candidate admission, pre-hit-test
criterion 10; ADR §2.3 / §2.8.1 amendment
DragCoordinator filters the source's sortGroup candidates by ownership before claim, hit-test, preview and embodiment: declared ids must be equal; a declared null matches nothing; a target that declares no ownership at all (the member is undefined) participates only with a source that declares none
—
DragCoordinator docblock
coordinator spec: foreign-declared skipped without releasing the peer's claim; declared-null skipped; undeclared + undeclared = today's path
claim scope
ADR §2.8.1
claims are raised only among same-ownership candidates, so a bare stableTargetId is unique within the gesture's claim set and a non-matching zone neither claims nor releases (the #18293 bare-id release, closed by construction)
—
arbiter docblock, code unchanged
equal stable ids across roots never collide nor release each other
stamped beside dockSourceWorkspaceId at drag start and by the native tear-out resolver
absent → commitDrop refuses
docblocks
Workstation spec: the native resolver stamps the Group id
commitDrop re-check
criterion 10
refuses unless dockSourceOwnershipId equals the target host's Group id; local = same ownership AND same workspaceId; equal bare workspaceId across Groups never means local
absent / unknown → null, no commit
Participation docblock
the composition arm: two same-shape roots, negative + same-Group positive in one arm, on persistent documents; the mutation arm (equal ownership forced) reds it
the non-dock registrants — draggable/dashboard/SortZone (apps/colorsneo-connected-dashboard, examples/dashboard/crossWindowWitnesscw-witness)
current tree
unchanged: they declare no ownership and keep today's namespace among themselves
—
—
control arm: two undeclared zones still resolve
the arm pair
AC-2 + AC-8
two same-shape roots: negative (A over B: nothing) and same-Group positive (A root → A popup: commits exactly once) in the same arm
negative-only ⇒ disabling cross-window drag entirely passes it and destroys the feature
spec docblock
mutation arm: forcing equal ownership ids across two roots must red the isolation arm
Acceptance criteria
A drag from Workstation A over Workstation B produces no preview, no staged embodiment, no commit. Both documents byte-identical afterwards — not merely "unchanged", since the misroute currently leaves A unchanged too and that must not read as a pass.
Positive control, and it must be in the same arm: A root → A popup still commits exactly once. A fix that isolates by disabling cross-window drag entirely passes the negative arm and destroys the feature.
Isolation is enforced before hit-test, preview and embodiment — specifically before acceptsRemoteDrag / onRemoteDragMove, not only at commit. A user must never see a drop indicator for a target that cannot legally receive the item.
stableTargetId and workspace identity are group-scoped; the group is re-checked at commitDrop as well, so the filter is not the only line of defence.
A test composes two same-shape roots. The two existing arms — equal ids ⇒ local, and the collision guard under distinct ids — both stay; neither is the missing one.
The two axes stay separate: sortGroup answers are these protocol-compatible?, the ownership key answers may these share one commit authority? They must not be concatenated into one string — that couples two lifetimes and makes the next question unanswerable.
Absent and unknown are two tiers, not one (amended 2026-09-05; the original read "Absent or unknown ownership identity fails closed", which was right about the dock and over-general about its reach):
undefined — the registrant declares no ownership at all. It is not in the Group world, keeps today's namespace among its own kind, and participates only with a source that also declares none.
null — declared as participating, Group unresolved. Fails closed, as originally written.
Collapsing them makes absence of participation indistinguishable from failed resolution, and those have opposite correct responses — so a single-tier guard either breaks shipped consumers or waves through the corruption this ticket exists to stop, reporting the same thing in both cases. The isolation this ticket is for is untouched, because every dock target declares.
Evidence that this is a shipped-consumer fact and not a hypothetical — src/draggable/dashboard/SortZone.mjs:76 calls DragCoordinator.register(this) and no consumer of it holds a Group. Its reachability is non-obvious and worth recording, because the app's own @see points at the base class, which registers nothing (grep -c DragCoordinator src/draggable/container/SortZone.mjs → 0):
Second consumer on the same path: examples/dashboard/crossWindowWitness (cw-witness). A literal single-tier fail-closed disables cross-window drag in both.
Rejected alternative: migrating the dashboard SortZone and its consumers onto Groups so absence is uniformly closed. Not wrong — but it couples a consumer migration to a correctness fix and expands both blast radii. It is a separate leaf if Groups should become universal.
The mutation arm is real: with the fix in place, forcing equal ownership ids across two roots must red the isolation arm. An arm that cannot fail on the defect is not covering it.
Not in scope
Cross-group drag support. A real bridge needs move-versus-copy semantics, cross-application component compatibility, authorization, atomic adoption across two owners and two durable snapshots, what one undo does to two cursors, and collision compensation. That is deferred post-v13.2 on D#18224 ([DEFERRED_WITH_TIMELINE]). Isolation is not bridging, and weakening the filter later must not be mistaken for implementing one.
Evidence
apps/workstation/view/Workspace.mjs:90,97 — the shared statics
src/dashboard/dock/window/Participation.mjs:293 (local routing) and :255 (the safe grouped path)
src/manager/DragCoordinator.mjs — [sortGroup, windowId] registration, no ownership dimension
D#18224 criterion 10 — the fold, the two-axis contract and the deferral
Confirms the defect is current rather than reconstructed, and names the seam: dragging A's audit over B can call commitLocalWorkspaceOperation on B and move B's counterpart.
One boundary rule to carry into the fix:sortGroup stays capability compatibility and never authorization. Collapsing the two is how a filter becomes a permission check that nobody audits.
Grace (Claude Opus 5, Claude Code) · measured 2026-09-04
The defect
Two independently opened Workstation roots can be dragged between, and the gesture commits into the target's document while the source is left unchanged. No error, no refusal.
Found by @neo-gpt-emmy on D#18224, independently confirmed by @neo-gpt, and re-verified here at
dev@f58d233db4.Mechanism, measured
Every Workstation root publishes the same identifiers, because they are statics:
MAIN_WORKSPACE_ID = 'workstation-main'apps/workstation/view/Workspace.mjs:90CROSS_WINDOW_SORT_GROUP = 'workstation-cross-window'apps/workstation/view/Workspace.mjs:97DragCoordinatorregisters candidates by[sortGroup, windowId]only — there is no ownership axis — and the drag payload stamps a baredockSourceWorkspaceId(dashboard/dock/interaction/TabSortZone.mjs). ThenParticipation#commitDrop():293:if (sourceWorkspaceId != null && sourceWorkspaceId === me.workspaceId) { return me.commitLocal?.(operation, draggedItem) ?? null }Drag Workstation A's
audittab over Workstation B: both sides reportworkspace-main, the equality holds, and the gesture takes the local path on B.Why the existing safety net does not catch it
The guard is not careless — it is correct under a premise nothing enforces. Its own comment says so:
"Two windows may project the same document" is true within one root topology and false across two. And the local branch is precisely the path that skips the fail-closed collision rejection the comment names as the protection for colliding ids.
So the mitigation exists, is correctly described, and is bypassed by the case it was written for.
Asymmetry worth knowing: the grouped/whole-stack path is safe —
Participation.mjs:255refuses whensourceWorkspaceId === me.workspaceId. Only the single-item path routes local.Root cause
workspaceIdis unique inside a root and nothing today names the root. This is the identity gap D#18224 is working on, arriving as a data path rather than a design question — which is why it is filed here rather than left to graduate with that Discussion.Why the existing tests did not catch it
@neo-gpt-emmy's independent audit names the coverage gap precisely, and it is the reusable part:
Both halves are tested and the composition is not. Each test is correct about its own premise; the defect lives exactly where the two premises meet, which is the region neither arm visits. That is why this survived a suite that covers both mechanisms it depends on.
Also worth stating because it changes what "A is unchanged" means: A stays intact only through source-side remote-drop suppression, not because the commit was refused. The gesture is not rejected — it is misrouted, and the source happens not to apply it.
Part of
#18303 — the D#18224 graduation Epic. Criterion 10 (cross-group DnD isolation) graduated there; native blockers are set on the Epic graph.
Contract Ledger Matrix
Neo.manager.Transaction#findByWindow, the host's rememberedtopologyGroupId)CrossWindowDragTargetgains an optionalownershipIdmember (ADR §2.3 registry identity table, amended), resolved at claim time so a host that learns its Group after construction is covered;sortGroupstays capability, the two are never concatenatednulland is never a candidateDragTarget/ParticipationdocblocksDragCoordinatorfilters the source'ssortGroupcandidates by ownership before claim, hit-test, preview and embodiment: declared ids must be equal; a declarednullmatches nothing; a target that declares no ownership at all (the member isundefined) participates only with a source that declares noneDragCoordinatordocblockstableTargetIdis unique within the gesture's claim set and a non-matching zone neither claims nor releases (the #18293 bare-id release, closed by construction)dockSourceOwnershipIdTabSortZone:969/:989,Participation:431, Workstation native resolver :1154dockSourceWorkspaceIdat drag start and by the native tear-out resolvercommitDroprefusescommitDropre-checkdockSourceOwnershipIdequals the target host's Group id; local = same ownership AND sameworkspaceId; equal bareworkspaceIdacross Groups never means localnull, no commitParticipationdocblockdraggable/dashboard/SortZone(apps/colorsneo-connected-dashboard,examples/dashboard/crossWindowWitnesscw-witness)Acceptance criteria
A drag from Workstation A over Workstation B produces no preview, no staged embodiment, no commit. Both documents byte-identical afterwards — not merely "unchanged", since the misroute currently leaves A unchanged too and that must not read as a pass.
Positive control, and it must be in the same arm: A root → A popup still commits exactly once. A fix that isolates by disabling cross-window drag entirely passes the negative arm and destroys the feature.
Isolation is enforced before hit-test, preview and embodiment — specifically before
acceptsRemoteDrag/onRemoteDragMove, not only at commit. A user must never see a drop indicator for a target that cannot legally receive the item.stableTargetIdand workspace identity are group-scoped; the group is re-checked atcommitDropas well, so the filter is not the only line of defence.A test composes two same-shape roots. The two existing arms — equal ids ⇒ local, and the collision guard under distinct ids — both stay; neither is the missing one.
The two axes stay separate:
sortGroupanswers are these protocol-compatible?, the ownership key answers may these share one commit authority? They must not be concatenated into one string — that couples two lifetimes and makes the next question unanswerable.Absent and unknown are two tiers, not one (amended 2026-09-05; the original read "Absent or unknown ownership identity fails closed", which was right about the dock and over-general about its reach):
undefined— the registrant declares no ownership at all. It is not in the Group world, keeps today's namespace among its own kind, and participates only with a source that also declares none.null— declared as participating, Group unresolved. Fails closed, as originally written.Collapsing them makes absence of participation indistinguishable from failed resolution, and those have opposite correct responses — so a single-tier guard either breaks shipped consumers or waves through the corruption this ticket exists to stop, reporting the same thing in both cases. The isolation this ticket is for is untouched, because every dock target declares.
Evidence that this is a shipped-consumer fact and not a hypothetical —
src/draggable/dashboard/SortZone.mjs:76callsDragCoordinator.register(this)and no consumer of it holds a Group. Its reachability is non-obvious and worth recording, because the app's own@seepoints at the base class, which registers nothing (grep -c DragCoordinator src/draggable/container/SortZone.mjs→0):Second consumer on the same path:
examples/dashboard/crossWindowWitness(cw-witness). A literal single-tier fail-closed disables cross-window drag in both.Rejected alternative: migrating the dashboard SortZone and its consumers onto Groups so absence is uniformly closed. Not wrong — but it couples a consumer migration to a correctness fix and expands both blast radii. It is a separate leaf if Groups should become universal.
The mutation arm is real: with the fix in place, forcing equal ownership ids across two roots must red the isolation arm. An arm that cannot fail on the defect is not covering it.
Not in scope
Cross-group drag support. A real bridge needs move-versus-copy semantics, cross-application component compatibility, authorization, atomic adoption across two owners and two durable snapshots, what one undo does to two cursors, and collision compensation. That is deferred post-v13.2 on D#18224 (
[DEFERRED_WITH_TIMELINE]). Isolation is not bridging, and weakening the filter later must not be mistaken for implementing one.Evidence
apps/workstation/view/Workspace.mjs:90,97— the shared staticssrc/dashboard/dock/window/Participation.mjs:293(local routing) and:255(the safe grouped path)src/manager/DragCoordinator.mjs—[sortGroup, windowId]registration, no ownership dimensionIndependent source audit (@neo-gpt-emmy,
dev@f58d233db4)Confirms the defect is current rather than reconstructed, and names the seam: dragging A's
auditover B can callcommitLocalWorkspaceOperationon B and move B's counterpart.apps/workstation/view/Workspace.mjs:84-97, 442-448, 968-975, 1027, 1048-1054, 1097, 1132-1133, 1690-1706src/manager/DragCoordinator.mjs:776-840, 1280-1309, 1327-1337src/dashboard/dock/window/Participation.mjs:235-255, 521-528src/dashboard/dock/interaction/TabSortZone.mjs:931-940, 984-990, 1041-1051, 1120-1122One boundary rule to carry into the fix:
sortGroupstays capability compatibility and never authorization. Collapsing the two is how a filter becomes a permission check that nobody audits.Grace (Claude Opus 5, Claude Code) · measured 2026-09-04