[object Object]

← back to Wallco Live Viewer

viewer: documented settlement-override publish path

97cff8ec1d44b988498415a44a1fef84cd82769d · 2026-06-09 12:10:31 -0700 · Steve Abrams

When the PG settlement_publish_check trigger RAISEs on a publish, the card's
✅ Publish now surfaces the violation text and offers the trigger's own
sanctioned escape: prompt for a mandatory reason (min 5 chars), then
BEGIN; SET LOCAL settlement.allow_override='true'; UPDATE …; COMMIT; with the
reason appended to the design's notes as a permanent audit entry (the trigger
adds its own 'allow_override=true at <ts>' note alongside). One design per
override — no bulk override, deliberate friction. Bulk publish falls back to
per-id on a settlement RAISE so clean designs still land and blockers are
named (#id list) for card-level review.

E2E verified on #57684 (override→published+note→reverted clean). Trigger
source read: raises only on prompt-keyword PartA(leaves+directional+open-
space+multicolor)+PartB(banana/grape/bird/butterfly/moth) without acceptable
cue — independent of the settlement_verdict column.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Files touched

Diff

commit 97cff8ec1d44b988498415a44a1fef84cd82769d
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Tue Jun 9 12:10:31 2026 -0700

    viewer: documented settlement-override publish path
    
    When the PG settlement_publish_check trigger RAISEs on a publish, the card's
    ✅ Publish now surfaces the violation text and offers the trigger's own
    sanctioned escape: prompt for a mandatory reason (min 5 chars), then
    BEGIN; SET LOCAL settlement.allow_override='true'; UPDATE …; COMMIT; with the
    reason appended to the design's notes as a permanent audit entry (the trigger
    adds its own 'allow_override=true at <ts>' note alongside). One design per
    override — no bulk override, deliberate friction. Bulk publish falls back to
    per-id on a settlement RAISE so clean designs still land and blockers are
    named (#id list) for card-level review.
    
    E2E verified on #57684 (override→published+note→reverted clean). Trigger
    source read: raises only on prompt-keyword PartA(leaves+directional+open-
    space+multicolor)+PartB(banana/grape/bird/butterfly/moth) without acceptable
    cue — independent of the settlement_verdict column.
    
    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
 server.js | 74 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
 1 file changed, 72 insertions(+), 2 deletions(-)

diff --git a/server.js b/server.js
index 9c5869e..2802e16 100644
--- a/server.js
+++ b/server.js
@@ -91,6 +91,26 @@ function liveSignal() {
   return sig;
 }
 
+// ── settlement override publish ───────────────────────────────────────────
+// The PG trigger settlement_publish_check() RAISEs on designs whose
+// settlement verdict isn't OK. Its own hint documents the sanctioned escape:
+// SET LOCAL settlement.allow_override='true' + document why in notes.
+// This helper does exactly that — ONE design per call (no bulk override,
+// deliberate friction), reason mandatory, reason appended to notes with a
+// timestamp so the audit trail lives on the row itself.
+function overridePublish(id, reason) {
+  const esc = String(reason).replace(/'/g, "''");
+  const sql = `BEGIN;
+SET LOCAL settlement.allow_override = 'true';
+UPDATE spoon_all_designs
+   SET is_published = TRUE,
+       notes = COALESCE(notes,'') || E'\\n[settlement-override ' || now()::timestamptz(0) || ' (Steve via live-viewer): ${esc}]'
+ WHERE id = ${id};
+COMMIT;`;
+  return spawnSync(PSQL, ['-U', 'stevestudio2', '-d', 'dw_unified', '-v', 'ON_ERROR_STOP=1', '-c', sql],
+    { encoding: 'utf8', timeout: 15000 });
+}
+
 // Server-side proxy to wallco-ai. Sends NO client headers, so the upstream
 // socket peer is loopback with no x-forwarded-* → admin-gate grants admin.
 function proxyToWallco(method, actPath, bodyObj, cb) {
@@ -237,13 +257,32 @@ function stateBadge(d){
  if(s==='pub')return '<span class="state pub">published</span>';
  if(s==='unpub')return '<span class="state unpub">staged</span>';
  if(s==='gone')return '<span class="state gone">removed</span>';
+ // A file with no DB row after 30 min was REJECTED by a generation-time
+ // quality gate (ghost / seamless / frame-overlay) — the generator skips the
+ // INSERT on purpose. Without this check rejects show "awaiting" forever.
+ if(d.created && (Date.now()-d.created) > 30*60*1000)
+  return '<span class="state gone" title="Generated but never inserted — failed a quality gate (ghost/seamless/frame-overlay) at generation time. Not waiting on anything.">gate-rejected</span>';
  return '<span class="state">no row yet</span>';
 }
 
+function isSettlementErr(e){return /settlement/i.test(String(e&&e.message||e));}
+async function offerOverride(id,errMsg){
+ const reason=prompt('⚖️ Settlement gate blocked #'+id+':\\n\\n'+errMsg.slice(0,400)+
+  '\\n\\nTo OVERRIDE (Steve approval), type the reason — it is appended to the design notes as a permanent audit trail. Cancel = leave blocked.');
+ if(!reason||reason.trim().length<5){if(reason!==null)toast('Override needs a real reason (min 5 chars)','err');return false;}
+ const r=await fetch('/override-publish',{method:'POST',headers:{'content-type':'application/json'},
+  body:JSON.stringify({id:+id,reason:reason.trim()})});
+ const d=await r.json().catch(()=>({error:'bad json'}));
+ if(!r.ok||d.error){toast('#'+id+' override failed: '+(d.error||r.status),'err');return false;}
+ states[id]='pub';toast('#'+id+' published via settlement override ⚖️ (reason logged to notes)','ok');return true;
+}
 async function doAct(btn,id,kind){
  const card=btn.closest('.card');[...card.querySelectorAll('button')].forEach(b=>b.disabled=true);
  try{
-  if(kind==='publish'){await act('/api/designs/bulk-action',{action:'publish',ids:[+id]});states[id]='pub';toast('#'+id+' published ✅','ok');}
+  if(kind==='publish'){
+   try{await act('/api/designs/bulk-action',{action:'publish',ids:[+id]});states[id]='pub';toast('#'+id+' published ✅','ok');}
+   catch(e){if(isSettlementErr(e)){await offerOverride(id,e.message);}else throw e;}
+  }
   else if(kind==='unpublish'){await act('/api/designs/bulk-action',{action:'unpublish',ids:[+id]});states[id]='unpub';toast('#'+id+' unpublished','ok');}
   else if(kind==='remove'){if(!confirm('Remove #'+id+' from the catalog? (undoable)'))return;
    await act('/api/design/'+id+'/remove',{});states[id]='gone';toast('#'+id+' removed 🗑 (click ↩ to undo)','ok');}
@@ -270,7 +309,20 @@ async function bulkAct(action){
   await act('/api/designs/bulk-action',{action,ids});
   ids.forEach(id=>states[id]=action==='publish'?'pub':action==='unpublish'?'unpub':'gone');
   toast(label+' done for '+ids.length+' designs','ok');clearSel();
- }catch(e){toast('Bulk '+action+' failed: '+e.message,'err');}
+ }catch(e){
+  // A settlement RAISE inside the bulk UPDATE rolls back the WHOLE statement.
+  // Fall back to per-id so clean designs still publish and blockers get named.
+  if(action==='publish'&&isSettlementErr(e)){
+   toast('Settlement gate hit in bulk — retrying one-by-one…');
+   const blocked=[];let okN=0;
+   for(const id of ids){
+    try{await act('/api/designs/bulk-action',{action:'publish',ids:[id]});states[id]='pub';okN++;}
+    catch(e2){if(isSettlementErr(e2))blocked.push(id);else toast('#'+id+' failed: '+e2.message,'err');}
+   }
+   toast(okN+' published ✅ · '+blocked.length+' blocked by settlement'+(blocked.length?': #'+blocked.join(' #')+' — use ✅ Publish on the card to review/override each':''),blocked.length?'err':'ok');
+   clearSel();
+  }else toast('Bulk '+action+' failed: '+e.message,'err');
+ }
  render(last);
 }
 function clearSel(){sel.clear();document.getElementById('selall').checked=false;syncBulk();render(last);}
@@ -349,6 +401,24 @@ const server = http.createServer((req, res) => {
     res.writeHead(200, { 'content-type': 'application/json' });
     return res.end(JSON.stringify({ items, live: liveSignal() }));
   }
+  if (u.pathname === '/override-publish' && req.method === 'POST') {
+    let raw = '';
+    req.on('data', (c) => { raw += c; if (raw.length > 4096) req.destroy(); });
+    req.on('end', () => {
+      let p;
+      try { p = JSON.parse(raw || '{}'); } catch { res.writeHead(400); return res.end('{"error":"bad json"}'); }
+      const id = parseInt(p.id, 10);
+      const reason = String(p.reason || '').trim();
+      res.setHeader('content-type', 'application/json');
+      if (!Number.isFinite(id) || id < 1) { res.writeHead(400); return res.end('{"error":"bad id"}'); }
+      if (reason.length < 5) { res.writeHead(400); return res.end('{"error":"reason required (min 5 chars) — it goes into the design notes audit trail"}'); }
+      const r = overridePublish(id, reason);
+      if (r.status === 0) { res.writeHead(200); return res.end(JSON.stringify({ ok: true, id })); }
+      res.writeHead(500);
+      res.end(JSON.stringify({ error: (r.stderr || r.stdout || 'psql failed').trim().slice(-500) }));
+    });
+    return;
+  }
   if (u.pathname === '/act' && req.method === 'POST') {
     let raw = '';
     req.on('data', (c) => { raw += c; if (raw.length > 65536) req.destroy(); });

← f1c90e2 viewer: state filter (All/Published/Staged/Removed/Awaiting  ·  back to Wallco Live Viewer  ·  stale no-row cards: label gate-rejected after 30 min instead d1c8eec →