Context
Neo.component.Base#nativeDragZone (#17885) lets a component declare a native drag payload as JSON, which Neo.main.addon.NativeDragSource turns into a real DataTransfer on the main thread. The declaration is a template map — {'text/plain': 'app:{data-record-id}'} — and NativeDragSource.mjs:183 resolves every token through exactly one source:
dataTransfer.setData(type, template.replace(templateToken, (match, name) => node.getAttribute(name) ?? ''))
getAttribute, and nothing else. That is deliberate — the class comment says so, and it is what keeps the declaration JSON-safe across the worker boundary.
The consequence is a coupling nobody declared: to put a business id on the clipboard, the business id must first be written into the DOM. For a grid that means useInternalId: false, because otherwise Neo.grid.Body#getRecordId writes neo's own neo-record-N and the payload carries it instead.
Verified live on dev this session by driving a real browser drag into a real same-origin iframe: childDragEnter 1, drop fired, and both text/plain and the custom MIME arrived carrying the attribute value. The mechanism works. The coupling is what this ticket is about.
The Problem
The failure is silent. With useInternalId: true and an attribute template naming the record-id attribute, there is no error, no warning, and no signal anywhere. getAttribute returns neo-record-7, setData accepts it, the drag runs, the drop fires, and the receiver looks up an id that does not exist in its domain — so nothing happens. The gesture completes successfully and does nothing, which is the hardest class of defect to attribute to its cause.
The pairing is enforced only by prose. Two JSDoc blocks describe it. Nothing mechanical checks it. A grid can be given a nativeDragZone today, or have useInternalId flipped tomorrow, and both edits look locally correct.
useInternalId is not a free knob to switch off. internalId was adopted deliberately for stable DOM keying across grid, table, list and data views (#9070, #9071, #9072), and the record-resolution paths in the selection models, grid.Body#getRecord, getRecordFromLogicalId and list DragZone were all reworked to resolve through it. Requiring useInternalId: false asks a consumer to opt out of that architecture in order to use an unrelated feature — a drag concern reaching back into DOM identity.
Why the obvious fix does not work. The authoritative record lives in the app-worker store; the DataTransfer must be filled synchronously inside the main thread's dragstart handler. There is no time to ask the app worker anything. That is precisely why the current implementation reads attributes: the DOM is the only thing the main thread already has.
The Architectural Reality
src/main/addon/NativeDragSource.mjs — templateToken = /\{([\w-]+)\}/g (:8); the single resolution site is :183; register({ownerId, delegate, types, effectAllowed}) (:249) is the whole registration contract and carries no record data.
src/component/Base.mjs — nativeDragZone_ (:209), afterSetNativeDragZone (:647), and the generation-guarded push to the addon (:664-676). The declaration already re-issues on change, so a registration payload that carries more than templates has a delivery path that exists.
src/grid/Body.mjs — getRecordId is what writes neo-record-N when useInternalId is true.
- The synchronous constraint is real and is the design's reason for existing; any fix must respect it by pushing data to main ahead of
dragstart, never fetching at dragstart.
The Fix
Give a template a second resolution source that does not require the value to be in the DOM: let a token resolve a record field, with the mapping delivered to the main thread as part of the registration.
- Extend the
nativeDragZone declaration with an explicit record-field form, distinguishable from an attribute token at parse time (so existing attribute templates keep their exact meaning — no reinterpretation of {data-record-id}).
- The owning component pushes a node-id → field-value map alongside the registration and refreshes it when the underlying rows change, so
NativeDragSource holds the values before any dragstart and stays synchronous at resolution time.
- Resolution falls back to
getAttribute when a token is not a declared field, preserving today's behaviour for every current consumer.
- A grid declaring a
nativeDragZone no longer needs useInternalId: false; the two settings become independent.
Contract Ledger Matrix
| Target Surface |
Source of Authority |
Behavior |
Fallback |
Docs |
Evidence |
component.Base#nativeDragZone declaration |
#17885; src/component/Base.mjs:209 |
gains an explicit record-field token form beside the existing attribute form |
an undeclared token resolves via getAttribute, exactly as today |
nativeDragZone_ JSDoc + NativeDragSource class comment |
positive: a field token yields the store value with useInternalId: true; negative: an unknown token still yields the attribute, and an absent attribute still yields '' |
NativeDragSource.register({ownerId, delegate, types, effectAllowed}) (:249) |
src/main/addon/NativeDragSource.mjs:249 |
accepts the field-value map; resolution at :183 consults it before getAttribute |
registrations without the map behave identically to today |
addon class comment |
a registration omitting the map drives an unchanged drag |
the useInternalId: false pairing |
prose only, in two JSDoc blocks |
retired — the settings become independent |
n/a |
both JSDoc blocks updated to stop prescribing the pairing |
a grid with useInternalId: true + a field token puts the business id on the clipboard |
the synchronous dragstart contract |
NativeDragSource design (#17885) |
unchanged — values are pushed on register/update, never fetched at dragstart |
none; a fetch at dragstart is the defect being avoided |
addon class comment |
no await on the dragstart path; the map is read from memory |
Decision Record impact
none — this extends a component/main-thread addon contract inside its existing ownership. It does not touch ai/, so §1c Agent OS Structure Map is N/A.
Acceptance Criteria
Out of Scope
- The interim dev-mode guard. A cheaper mitigation exists: warn when a
nativeDragZone template references the record-id attribute while the owning body has useInternalId: true. It makes the silent class loud without removing the coupling. Deliberately not bundled — if this ticket slips, that guard is worth filing on its own, but shipping both means building a warning for a coupling this ticket deletes.
- Changing any default:
useInternalId keeps its current default, and no existing consumer is migrated by this work.
- The payload protocol itself (which MIME types, what prefix). That is the receiving application's contract, not the engine's.
- The synthetic drag pipeline (
draggable/), which is a separate mechanism from native DataTransfer.
Avoided Traps
- Resolving the record at
dragstart. The obvious shape — ask the app worker for the record when the drag starts — cannot work: DataTransfer must be filled synchronously inside the handler, and the store lives in another thread. Any prescription that reaches across the worker boundary at gesture time is wrong by construction. The data must already be on main.
- Reinterpreting the existing token syntax. Making
{foo} mean "field foo, else attribute foo" would silently change the meaning of every shipped declaration the first time a field and an attribute share a name. The field form must be explicit at parse time.
- "Just document the pairing harder." It is already documented in two places, and prose did not prevent the class — the failure mode is silent, so a consumer gets no feedback that they read the docs wrong.
- Treating this as a grid ticket. The coupling lives in the resolution rule, not in the grid. Any component with a
nativeDragZone whose meaningful id is not already a DOM attribute has the same problem; the grid is simply where it was observed.
Related
- #17885 — origin: declarative native drag sources,
DataTransfer from worker config. The mechanism this extends.
- #9070 / #9071 / #9072 — the deliberate
internalId adoption for stable DOM keying that useInternalId: false opts out of.
- #18087 / PR #18091 — a native drop into an outside iframe pane, same demo path.
- #18099 / PR #18103 and #18104 / PR #18109 —
GridDragScroll gesture cleanup around native drags out of the grid body.
Live latest-open sweep: checked the latest 20 open issues at 2026-09-02T15:20:40Z; nearest neighbours are #18104 (grid drag-scroll state) and #18099, both a different mechanism. A2A in-flight claim sweep over the newest 30 messages (14:19Z–15:17Z, all read-states): no claim on nativeDragZone, NativeDragSource or useInternalId. Memory Core rationale sweep on the problem's nouns returned the internalId adoption trail (#9070-#9072) and this session's own live verification of the iframe path; no prior decision against changing the resolution rule. Own-assignment sweep: 7 open, none on this surface.
unowned-rationale: parked deliberately — the demo is tomorrow morning and this changes the exact drag path the client will exercise. Filing it now so the finding is not lost; it should be picked up after the demo, not before.
Origin Session ID: fb58e855-74b2-4367-9f83-5877f151f024
Retrieval Hint: "nativeDragZone template resolves a record field instead of a DOM attribute, useInternalId coupling, NativeDragSource synchronous dragstart"
Context
Neo.component.Base#nativeDragZone(#17885) lets a component declare a native drag payload as JSON, whichNeo.main.addon.NativeDragSourceturns into a realDataTransferon the main thread. The declaration is a template map —{'text/plain': 'app:{data-record-id}'}— andNativeDragSource.mjs:183resolves every token through exactly one source:dataTransfer.setData(type, template.replace(templateToken, (match, name) => node.getAttribute(name) ?? ''))getAttribute, and nothing else. That is deliberate — the class comment says so, and it is what keeps the declaration JSON-safe across the worker boundary.The consequence is a coupling nobody declared: to put a business id on the clipboard, the business id must first be written into the DOM. For a grid that means
useInternalId: false, because otherwiseNeo.grid.Body#getRecordIdwrites neo's ownneo-record-Nand the payload carries it instead.Verified live on
devthis session by driving a real browser drag into a real same-origin iframe:childDragEnter 1,dropfired, and bothtext/plainand the custom MIME arrived carrying the attribute value. The mechanism works. The coupling is what this ticket is about.The Problem
The failure is silent. With
useInternalId: trueand an attribute template naming the record-id attribute, there is no error, no warning, and no signal anywhere.getAttributereturnsneo-record-7,setDataaccepts it, the drag runs, the drop fires, and the receiver looks up an id that does not exist in its domain — so nothing happens. The gesture completes successfully and does nothing, which is the hardest class of defect to attribute to its cause.The pairing is enforced only by prose. Two JSDoc blocks describe it. Nothing mechanical checks it. A grid can be given a
nativeDragZonetoday, or haveuseInternalIdflipped tomorrow, and both edits look locally correct.useInternalIdis not a free knob to switch off.internalIdwas adopted deliberately for stable DOM keying across grid, table, list and data views (#9070, #9071, #9072), and the record-resolution paths in the selection models,grid.Body#getRecord,getRecordFromLogicalIdandlistDragZonewere all reworked to resolve through it. RequiringuseInternalId: falseasks a consumer to opt out of that architecture in order to use an unrelated feature — a drag concern reaching back into DOM identity.Why the obvious fix does not work. The authoritative record lives in the app-worker store; the
DataTransfermust be filled synchronously inside the main thread'sdragstarthandler. There is no time to ask the app worker anything. That is precisely why the current implementation reads attributes: the DOM is the only thing the main thread already has.The Architectural Reality
src/main/addon/NativeDragSource.mjs—templateToken = /\{([\w-]+)\}/g(:8); the single resolution site is:183;register({ownerId, delegate, types, effectAllowed})(:249) is the whole registration contract and carries no record data.src/component/Base.mjs—nativeDragZone_(:209),afterSetNativeDragZone(:647), and the generation-guarded push to the addon (:664-676). The declaration already re-issues on change, so a registration payload that carries more than templates has a delivery path that exists.src/grid/Body.mjs—getRecordIdis what writesneo-record-NwhenuseInternalIdis true.dragstart, never fetching atdragstart.The Fix
Give a template a second resolution source that does not require the value to be in the DOM: let a token resolve a record field, with the mapping delivered to the main thread as part of the registration.
nativeDragZonedeclaration with an explicit record-field form, distinguishable from an attribute token at parse time (so existing attribute templates keep their exact meaning — no reinterpretation of{data-record-id}).NativeDragSourceholds the values before anydragstartand stays synchronous at resolution time.getAttributewhen a token is not a declared field, preserving today's behaviour for every current consumer.nativeDragZoneno longer needsuseInternalId: false; the two settings become independent.Contract Ledger Matrix
component.Base#nativeDragZonedeclarationsrc/component/Base.mjs:209getAttribute, exactly as todaynativeDragZone_JSDoc +NativeDragSourceclass commentuseInternalId: true; negative: an unknown token still yields the attribute, and an absent attribute still yields''NativeDragSource.register({ownerId, delegate, types, effectAllowed})(:249)src/main/addon/NativeDragSource.mjs:249:183consults it beforegetAttributeuseInternalId: falsepairinguseInternalId: true+ a field token puts the business id on the clipboarddragstartcontractNativeDragSourcedesign (#17885)dragstartdragstartis the defect being avoidedawaiton thedragstartpath; the map is read from memoryDecision Record impact
none— this extends a component/main-thread addon contract inside its existing ownership. It does not touchai/, so §1c Agent OS Structure Map is N/A.Acceptance Criteria
nativeDragZonetemplate can name a record field, and the resultingDataTransfercarries the store value while the owning grid hasuseInternalId: true. Red-first: the same declaration ondevputsneo-record-Non the clipboard.''rather than the literal template text, matching?? ''today.dragstartpath performs no asynchronous work: the field values are present in the addon before the gesture begins, and a spec asserts the resolution site reads only from memory.useInternalId: false, and a grid pairinguseInternalId: truewith a field token is exercised by a spec.Out of Scope
nativeDragZonetemplate references the record-id attribute while the owning body hasuseInternalId: true. It makes the silent class loud without removing the coupling. Deliberately not bundled — if this ticket slips, that guard is worth filing on its own, but shipping both means building a warning for a coupling this ticket deletes.useInternalIdkeeps its current default, and no existing consumer is migrated by this work.draggable/), which is a separate mechanism from nativeDataTransfer.Avoided Traps
dragstart. The obvious shape — ask the app worker for the record when the drag starts — cannot work:DataTransfermust be filled synchronously inside the handler, and the store lives in another thread. Any prescription that reaches across the worker boundary at gesture time is wrong by construction. The data must already be on main.{foo}mean "fieldfoo, else attributefoo" would silently change the meaning of every shipped declaration the first time a field and an attribute share a name. The field form must be explicit at parse time.nativeDragZonewhose meaningful id is not already a DOM attribute has the same problem; the grid is simply where it was observed.Related
DataTransferfrom worker config. The mechanism this extends.internalIdadoption for stable DOM keying thatuseInternalId: falseopts out of.GridDragScrollgesture cleanup around native drags out of the grid body.Live latest-open sweep: checked the latest 20 open issues at 2026-09-02T15:20:40Z; nearest neighbours are #18104 (grid drag-scroll state) and #18099, both a different mechanism. A2A in-flight claim sweep over the newest 30 messages (14:19Z–15:17Z, all read-states): no claim on
nativeDragZone,NativeDragSourceoruseInternalId. Memory Core rationale sweep on the problem's nouns returned theinternalIdadoption trail (#9070-#9072) and this session's own live verification of the iframe path; no prior decision against changing the resolution rule. Own-assignment sweep: 7 open, none on this surface.unowned-rationale: parked deliberately — the demo is tomorrow morning and this changes the exact drag path the client will exercise. Filing it now so the finding is not lost; it should be picked up after the demo, not before.
Origin Session ID: fb58e855-74b2-4367-9f83-5877f151f024
Retrieval Hint: "nativeDragZone template resolves a record field instead of a DOM attribute, useInternalId coupling, NativeDragSource synchronous dragstart"