[object Object]

← back to Dw Photo Capture

Contrarian gate fix: _updateNeedsConfirm invariant — ANY ambiguous visual bind (background OR mid-Push) requires a 2nd Push before a LIVE write; label OCR + chip-tap are explicit (no gate). Plus session checks after resolve/visual awaits in addExtract

5673cadd1ab07609e9ce6f5ba166439b4d167898 · 2026-07-06 22:15:46 -0700 · Steve Abrams

Files touched

Diff

commit 5673cadd1ab07609e9ce6f5ba166439b4d167898
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Mon Jul 6 22:15:46 2026 -0700

    Contrarian gate fix: _updateNeedsConfirm invariant — ANY ambiguous visual bind (background OR mid-Push) requires a 2nd Push before a LIVE write; label OCR + chip-tap are explicit (no gate). Plus session checks after resolve/visual awaits in addExtract
---
 public/index.html | 25 +++++++++++++++----------
 1 file changed, 15 insertions(+), 10 deletions(-)

diff --git a/public/index.html b/public/index.html
index 069f5f2..84dbd27 100644
--- a/public/index.html
+++ b/public/index.html
@@ -1476,7 +1476,7 @@ $('#fbGo').addEventListener('click',fbIdentify);
 const MAX_PHOTOS=10, MAX_VIDEOS=6;
 let _addPhoto=null,_vendorsLoaded=false,_addExtracted={};
 let _media=[];                                    // {type:'photo'|'video', dataUrl, name}
-let _addMode='add', _updatePid=null, _updateSku=null, _extracted=false, _addSession=0;
+let _addMode='add', _updatePid=null, _updateSku=null, _extracted=false, _addSession=0, _updateNeedsConfirm=false;
 function mediaCounts(){ return { p:_media.filter(m=>m.type==='photo').length, v:_media.filter(m=>m.type==='video').length }; }
 function renderMedia(){ const c=mediaCounts();
   $('#cPhoto').textContent=c.p+'/'+MAX_PHOTOS; $('#cVideo').textContent=c.v+'/'+MAX_VIDEOS;
@@ -1485,7 +1485,7 @@ function renderMedia(){ const c=mediaCounts();
     ? `<div class="media-cell"><video src="${m.dataUrl}" muted playsinline preload="metadata"></video><span class="badge">🎥</span><button class="rm" data-rm="${i}">✕</button></div>`
     : `<div class="media-cell" style="background-image:url('${m.dataUrl}')"><button class="rm" data-rm="${i}">✕</button></div>`).join('');
   $('#addMedia').querySelectorAll('.rm').forEach(b=>b.addEventListener('click',()=>{ _media.splice(+b.dataset.rm,1); renderMedia(); })); }
-function addResetMedia(){ _media=[]; _addPhoto=null; _addExtracted={}; _extracted=false; _updatePid=null; _updateSku=null; $('#addSpecs').innerHTML=''; renderMedia(); }
+function addResetMedia(){ _media=[]; _addPhoto=null; _addExtracted={}; _extracted=false; _updatePid=null; _updateSku=null; _updateNeedsConfirm=false; $('#addSpecs').innerHTML=''; renderMedia(); }
 function fileToDataUrl(f){ return new Promise(r=>{ const fr=new FileReader(); fr.onload=()=>r(fr.result); fr.readAsDataURL(f); }); }
 async function addMediaAdd(type,file){ if(!file)return; const c=mediaCounts();
   if(type==='photo'){ if(c.p>=MAX_PHOTOS){ toast('Max '+MAX_PHOTOS+' photos'); return; }
@@ -1547,11 +1547,13 @@ async function addCommit(){ const b=$('#addCommit'); b.disabled=true; $('#addNot
 }
 // UPDATE mode: resolve the existing product from the scanned mfr#, then push media LIVE.
 // resolve an mfr# / sku to a live Shopify product_id and bind it as the Update target
-async function bindProduct(mfr,sku){ const qs=mfr?('mfr='+encodeURIComponent(mfr)):('sku='+encodeURIComponent(sku||''));
+// confirmed=true only for EXPLICIT binds (label OCR match, or a user tapping a candidate).
+// A visual auto-bind is confirmed=false → addUpdate demands a 2nd Push before writing LIVE.
+async function bindProduct(mfr,sku,confirmed){ const qs=mfr?('mfr='+encodeURIComponent(mfr)):('sku='+encodeURIComponent(sku||''));
   try{ const r=await(await fetch('/api/resolve-product?'+qs)).json();
-    if(r.found){ _updatePid=r.product_id; _updateSku=r.dw_sku; return r; } }catch(e){} return null; }
+    if(r.found){ _updatePid=r.product_id; _updateSku=r.dw_sku; _updateNeedsConfirm=!confirmed; return r; } }catch(e){} return null; }
 async function addResolveUpdate(){ const mfr=$('#addMfr').value.trim(); if(!mfr) return;
-  const r=await bindProduct(mfr,null);
+  const r=await bindProduct(mfr,null,true);   // label OCR match = explicit → no extra confirm needed
   if(r){ $('#addTarget').innerHTML=`✓ Updating <b>${r.title||r.dw_sku}</b> <small>(${r.status||''} · ${r.dw_sku})</small>`; }
   else { _updatePid=null; _updateSku=null; $('#addTarget').innerHTML='⚠ No SKU matched “'+mfr+'”. Try a photo of the pattern, fix the mfr#, or use Add New SKU.'; } }
 // REALLY ID by DESIGN: front pattern (+ label) photo → /api/identify-multi (OCR+QR+vendor+CLIP visual)
@@ -1568,14 +1570,16 @@ async function visualIdentify(){ const photos=addPhotosList(); if(!photos.length
     $('#addTarget').innerHTML=`🔍 Visually matched <b>${bound.title||_updateSku}</b> <small>(${r.confidence} · ${_updateSku})</small>`; }
   else if(cands.length){ _vidCands=cands;
     $('#addTarget').innerHTML='🔍 Closest patterns — <b>tap to confirm</b>:<div class="vid-cands">'+cands.map((c,i)=>`<button class="vid-chip" data-i="${i}">${((c.pattern||c.mfr_sku||'?')+'').slice(0,20)}</button>`).join('')+'</div>';
-    document.querySelectorAll('#addTarget .vid-chip').forEach(b=>b.addEventListener('click',async()=>{ const c=_vidCands[+b.dataset.i]; const bd=await bindProduct(c.mfr_sku,c.dw_sku); if(bd){ if(c.mfr_sku)$('#addMfr').value=c.mfr_sku; $('#addTarget').innerHTML=`🔍 Using <b>${bd.title||_updateSku}</b>`; } else $('#addTarget').innerHTML='That pattern has no live product — use Add New SKU.'; })); }
+    document.querySelectorAll('#addTarget .vid-chip').forEach(b=>b.addEventListener('click',async()=>{ const c=_vidCands[+b.dataset.i]; const bd=await bindProduct(c.mfr_sku,c.dw_sku,true); if(bd){ if(c.mfr_sku)$('#addMfr').value=c.mfr_sku; $('#addTarget').innerHTML=`🔍 Using <b>${bd.title||_updateSku}</b>`; } else $('#addTarget').innerHTML='That pattern has no live product — use Add New SKU.'; })); }
   else $('#addTarget').innerHTML='No confident match — fix the mfr# or use Add New SKU.'; }
 let _vidCands=[];
 async function addUpdate(){ if(!_updatePid) await addResolveUpdate();
-  if(!_updatePid){ await visualIdentify();   // last resort: match by the pattern itself
-    if(_updatePid){   // a bind just happened silently during Push → REQUIRE a 2nd press to confirm the LIVE write
-      $('#addNote').innerHTML=`⚠ Matched <b>${_updateSku}</b> by pattern — tap Push again to confirm the LIVE update.`; return; } }
+  if(!_updatePid) await visualIdentify();   // last resort: match by the pattern itself
   if(!_updatePid){ $('#addNote').textContent='No product to update — scan the label/pattern or fix the mfr#.'; return; }
+  // INVARIANT: never write LIVE to a product bound by an ambiguous visual match without an explicit
+  // 2nd Push — covers BOTH the mid-Push bind AND the background bind set earlier during addExtract.
+  if(_updateNeedsConfirm){ _updateNeedsConfirm=false;
+    $('#addNote').innerHTML=`⚠ Matched <b>${_updateSku}</b> by pattern — tap Push again to confirm the LIVE update.`; return; }
   const photos=addPhotosList(); if(!photos.length && !addVideosList().length){ $('#addNote').textContent='Add at least one photo or video.'; return; }
   $('#addNote').textContent='⬆ Pushing to Shopify (LIVE)…'; let added=0;
   if(photos.length){ const r=await(await fetch('/api/photos',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({dw_sku:_updateSku,product_id:_updatePid,dataUrls:photos})})).json(); added=r.added||0; }
@@ -1604,7 +1608,8 @@ async function addExtract(){ if(!_addPhoto)return; const mySession=_addSession;
     const specs=[['Material',f.material],['Collection',f.collection],['Width',f.width],['Roll length',f.roll_length],['Repeat',f.repeat],['Match',f.pattern_match],['Substrate / contents',f.substrate],['How sold',f.how_sold],['Price code',f.price_code]].filter(x=>x[1]);
     $('#addSpecs').innerHTML = specs.length? ('<div class="samp-meta" style="margin:10px 0 2px">Captured specs:</div>'+specs.map(s=>`<div class="add-spec"><span>${s[0]}</span><b>${s[1]}</b></div>`).join('')) : '';
     $('#addNote').textContent='✓ Imported from label';
-    if(_addMode==='update'){ await addResolveUpdate(); if(!_updatePid) await visualIdentify(); }   // label first, then match by design
+    if(_addMode==='update'){ await addResolveUpdate(); if(_addSession!==mySession) return;   // discard if modal reset mid-resolve
+      if(!_updatePid){ await visualIdentify(); if(_addSession!==mySession) return; } }        // label first, then match by design
     else addPromptMissing();
   }catch(e){ $('#addNote').textContent=''; }
 }

← 1570feb yolo hardening (8 fixes from adversarial review): C1 visual  ·  back to Dw Photo Capture  ·  Wire in fine-tuned DW CLIP: sanity-check picks best epoch (e ec02bd7 →