Context
Foundation leaf of #15395 (epic #15239, G3 family), split per this epic's twice-proven tier pattern (#15407 contract ← #15244 host; #15426 foundation ← #15243 matrix): #15395's wire-in, empirical threshold calibration, and headed witness cells are all gated on the #15246 claim protocol — the ADR 0029 §2.8.1 arbitration that owns the pointerInTarget feed (wiring conversion to the legacy first-intersecting resolution §2.8.1 explicitly replaces would build on deprecated substrate). The conversion contract's mathematical heart is NOT gated, and every consuming leaf needs its decision semantics settled. This ticket is the Resolves target for the contract-tier PR; #15395 stays open owning calibration + wire-in + headed cells behind #15246.
The Problem
#15395 proves the landed single-denominator intersection ratio (src/draggable/container/SortZone.mjs checkWindowBoundary, area(intersection)/area(dragged)) is mathematically broken for popup-over-vessel: a large dragged window over a small target caps below any usable threshold, and the mirror case fails the mirror way — conversion is UNREACHABLE for realistic size pairs, in both directions, and both windows are freely resizable mid-session. No module owns the dual-window decision; without a settled contract, #15246/#15247 and the #15395 wire-in would each improvise geometry.
The Fix
One pure, zero-import decision module — src/dashboard/DockVesselConversion.mjs, factory createVesselConversionSensor matching the landed createDockTearOutHandlers idiom (closure state, injected seams, host parameterizes, witnesses drive without a browser):
- Metric: per-axis overlap normalized by the SMALLER extent per axis —
rx = overlapWidth / min(wA, wB), ry = overlapHeight / min(hA, hB) — composed via an injectable composeRatios seam (default min(rx, ry); #15395's empirical calibration owns the final pick). Computed from the LIVE rects of every sample; reachable (composed = 1.0) for ANY size pair in both directions.
- Dead-band hysteresis:
convertThreshold (in) > revertThreshold (out), validated fail-loud at creation. The band IS the Schmitt trigger — a decision fires only on crossing its own threshold, so the #15413 false-flip class is structurally excluded (no arming flag needed: the convert-in moment sits above the revert band by construction, the inverse of the landed exit-inside-reattach-zone geometry that required arming). Shipped defaults are REVIEWABLE PLACEHOLDERS (band width mirrors the landed grammar's proven 0.2); calibrated product values are #15395's deliverable.
- Pointer gate, both directions: convert-in requires
pointerInTarget; pointer leaving the target while converted fires convert-out regardless of rect overlap — rect overlap alone never converts AND never holds a conversion (intent stays pointer-owned, feeding the #15240 claim semantics unchanged).
- Decisions through seams, geometry through the record: required
onConvertIn / onConvertOut seams (the §2.3-named actuator pair suspendWindowDrag / resumeWindowDrag is what a host binds them to); sample() returns the per-frame geometry record for observability. Silent idempotent reset() for gesture terminals — terminal choreography (commit, close, park) belongs to the §2.8.2 outcome machine and #15396, never to the sensor.
- Fail-closed under garbage geometry: missing/degenerate/non-finite rects compose to 0 — a converted sensor fed garbage REVERTS (NaN comparisons would otherwise hold conversion forever).
Design-time resolution recorded (resolves #15395's "may amend ADR 0029" clause negatively): NO new outcome-machine state is required. Conversion-in IS the geometric predicate for the existing DETACHED_MOVING → HOVERING_CLAIM transition (§2.8.2); conversion-out is its inverse; the §2.3 source hooks are the contract-named actuators. The module documents this mapping in its JSDoc contract prose.
Contract Ledger
| Surface |
Direction |
Contract |
createVesselConversionSensor({composeRatios?, convertThreshold?, revertThreshold?, onConvertIn, onConvertOut}) → {converted, reset, sample} |
provided |
The dual-window conversion decision authority: per-sample {sourceRect, targetRect, pointerInTarget} in, decisions through seams, geometry record returned. Zero imports; no window/DOM/model access. |
| ADR 0029 §2.8.2 outcome machine + §2.3 source hooks |
consumed (upstream authority) |
Conversion-in/out = the DETACHED_MOVING ⇄ HOVERING_CLAIM transition predicate; suspendWindowDrag/resumeWindowDrag are the actuators. No amendment: the sensor adds a predicate, never a state. |
| #15395 (wire-in + calibration + headed cells), #15246 (claim feed), #15396 (park actuation on convert) |
downstream consumers |
They bind the sensor; they never re-derive geometry. Threshold/composition changes land HERE, calibration receipts land on #15395. |
Acceptance Criteria
Out of Scope
The wire-in into the drag path and the pointerInTarget production (#15395, behind #15246) · empirical threshold/composition calibration + headed matrix cells (#15395) · claim arbitration (#15246) · vessel park/re-show actuation (#15396) · any outcome-machine or ADR 0029 change (resolved: none needed).
Decision Record impact
depends-on ADR 0029 §2.8.2/§2.3 — consumed as upstream authority, explicitly NOT amended (the design-time resolution this ticket records).
Related
Parent #15395 (stays open; owns the empirical + headed remainder) · epic #15239 · #15240 (outcome machine) · #15246 (claim feed prerequisite for the wire-in) · #15396 (companion actuation) · #15408/PR (the createDockTearOutHandlers idiom this matches).
Live latest-open sweep: checked 2026-07-18T06:03Z (top-20 open via gh issue list); #15395/#15396 are the only conversion-family leaves — no contract-tier equivalent exists. A2A sweep: my own [lane-claim] f86f6c9a covers this lane; no competing claim.
Origin Session ID: 0c8fc4d9-2456-44fd-b120-048402bb9839
Retrieval Hint: query_raw_memories("vessel conversion sensor contract tier min-axis dead-band pointer gate no ADR amendment")
Context
Foundation leaf of #15395 (epic #15239, G3 family), split per this epic's twice-proven tier pattern (#15407 contract ← #15244 host; #15426 foundation ← #15243 matrix): #15395's wire-in, empirical threshold calibration, and headed witness cells are all gated on the #15246 claim protocol — the ADR 0029 §2.8.1 arbitration that owns the
pointerInTargetfeed (wiring conversion to the legacy first-intersecting resolution §2.8.1 explicitly replaces would build on deprecated substrate). The conversion contract's mathematical heart is NOT gated, and every consuming leaf needs its decision semantics settled. This ticket is the Resolves target for the contract-tier PR; #15395 stays open owning calibration + wire-in + headed cells behind #15246.The Problem
#15395 proves the landed single-denominator intersection ratio (
src/draggable/container/SortZone.mjscheckWindowBoundary, area(intersection)/area(dragged)) is mathematically broken for popup-over-vessel: a large dragged window over a small target caps below any usable threshold, and the mirror case fails the mirror way — conversion is UNREACHABLE for realistic size pairs, in both directions, and both windows are freely resizable mid-session. No module owns the dual-window decision; without a settled contract, #15246/#15247 and the #15395 wire-in would each improvise geometry.The Fix
One pure, zero-import decision module —
src/dashboard/DockVesselConversion.mjs, factorycreateVesselConversionSensormatching the landedcreateDockTearOutHandlersidiom (closure state, injected seams, host parameterizes, witnesses drive without a browser):rx = overlapWidth / min(wA, wB),ry = overlapHeight / min(hA, hB)— composed via an injectablecomposeRatiosseam (defaultmin(rx, ry); #15395's empirical calibration owns the final pick). Computed from the LIVE rects of every sample; reachable (composed = 1.0) for ANY size pair in both directions.convertThreshold(in) >revertThreshold(out), validated fail-loud at creation. The band IS the Schmitt trigger — a decision fires only on crossing its own threshold, so the #15413 false-flip class is structurally excluded (no arming flag needed: the convert-in moment sits above the revert band by construction, the inverse of the landed exit-inside-reattach-zone geometry that required arming). Shipped defaults are REVIEWABLE PLACEHOLDERS (band width mirrors the landed grammar's proven 0.2); calibrated product values are #15395's deliverable.pointerInTarget; pointer leaving the target while converted fires convert-out regardless of rect overlap — rect overlap alone never converts AND never holds a conversion (intent stays pointer-owned, feeding the #15240 claim semantics unchanged).onConvertIn/onConvertOutseams (the §2.3-named actuator pairsuspendWindowDrag/resumeWindowDragis what a host binds them to);sample()returns the per-frame geometry record for observability. Silent idempotentreset()for gesture terminals — terminal choreography (commit, close, park) belongs to the §2.8.2 outcome machine and #15396, never to the sensor.Design-time resolution recorded (resolves #15395's "may amend ADR 0029" clause negatively): NO new outcome-machine state is required. Conversion-in IS the geometric predicate for the existing
DETACHED_MOVING → HOVERING_CLAIMtransition (§2.8.2); conversion-out is its inverse; the §2.3 source hooks are the contract-named actuators. The module documents this mapping in its JSDoc contract prose.Contract Ledger
createVesselConversionSensor({composeRatios?, convertThreshold?, revertThreshold?, onConvertIn, onConvertOut})→{converted, reset, sample}{sourceRect, targetRect, pointerInTarget}in, decisions through seams, geometry record returned. Zero imports; no window/DOM/model access.DETACHED_MOVING ⇄ HOVERING_CLAIMtransition predicate;suspendWindowDrag/resumeWindowDragare the actuators. No amendment: the sensor adds a predicate, never a state.Acceptance Criteria
src/dashboard/, seam/JSDoc idiom matchingDockTearOut.mjs, with the §2.8.2 transition mapping + no-amendment resolution in the module contract prose.convertThreshold <= revertThreshold, out-of-range thresholds, or missing/non-function seams throw at creation with contract-naming messages.reset(): no seam emission, safe to call repeatedly, next gesture re-fires cleanly — witnessed.Out of Scope
The wire-in into the drag path and the
pointerInTargetproduction (#15395, behind #15246) · empirical threshold/composition calibration + headed matrix cells (#15395) · claim arbitration (#15246) · vessel park/re-show actuation (#15396) · any outcome-machine or ADR 0029 change (resolved: none needed).Decision Record impact
depends-on ADR 0029 §2.8.2/§2.3 — consumed as upstream authority, explicitly NOT amended (the design-time resolution this ticket records).
Related
Parent #15395 (stays open; owns the empirical + headed remainder) · epic #15239 · #15240 (outcome machine) · #15246 (claim feed prerequisite for the wire-in) · #15396 (companion actuation) · #15408/PR (the
createDockTearOutHandlersidiom this matches).Live latest-open sweep: checked 2026-07-18T06:03Z (top-20 open via
gh issue list); #15395/#15396 are the only conversion-family leaves — no contract-tier equivalent exists. A2A sweep: my own [lane-claim] f86f6c9a covers this lane; no competing claim.Origin Session ID: 0c8fc4d9-2456-44fd-b120-048402bb9839
Retrieval Hint:
query_raw_memories("vessel conversion sensor contract tier min-axis dead-band pointer gate no ADR amendment")