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>
Files touched
M server.js
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(); });