[object Object]

← back to Carnegie Reprice

feat: Carnegie importer gate — skip ACTIVE if no real mfr_sku (TK-10792 stop-the-bleed)

4de1b96e1eb55255e21c790242281ba5f4a840cb · 2026-08-23 04:11:48 -0700 · steve@designerwallcoverings.com

Add carnegie-mfr-gate.mjs (canonical guard + CLI audit) and wire into all
activation paths: rebuild-line.mjs, phase2-poc-acapella.mjs. Products with
null/empty or DWAG-* mfr_sku are held as DRAFT + tagged Needs-Mfr-SKU.
Durable run-summary written to ledger JSONL. archive-redirect.mjs rollback
path documented (intentionally exempt — restores original Carnegie products
with real vendor codes). CODE ONLY — no Shopify writes.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

Files touched

Diff

commit 4de1b96e1eb55255e21c790242281ba5f4a840cb
Author: steve@designerwallcoverings.com <steve@designerwallcoverings.com>
Date:   Sun Aug 23 04:11:48 2026 -0700

    feat: Carnegie importer gate — skip ACTIVE if no real mfr_sku (TK-10792 stop-the-bleed)
    
    Add carnegie-mfr-gate.mjs (canonical guard + CLI audit) and wire into all
    activation paths: rebuild-line.mjs, phase2-poc-acapella.mjs. Products with
    null/empty or DWAG-* mfr_sku are held as DRAFT + tagged Needs-Mfr-SKU.
    Durable run-summary written to ledger JSONL. archive-redirect.mjs rollback
    path documented (intentionally exempt — restores original Carnegie products
    with real vendor codes). CODE ONLY — no Shopify writes.
    
    Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
---
 archive-redirect.mjs    |   3 ++
 carnegie-mfr-gate.mjs   | 109 ++++++++++++++++++++++++++++++++++++++++++++++++
 phase2-poc-acapella.mjs |  15 +++++++
 rebuild-line.mjs        |  32 ++++++++++++--
 4 files changed, 155 insertions(+), 4 deletions(-)

diff --git a/archive-redirect.mjs b/archive-redirect.mjs
index 897cb27..6665e59 100644
--- a/archive-redirect.mjs
+++ b/archive-redirect.mjs
@@ -49,6 +49,9 @@ if(MODE==='rollback'){
   if(!ROLLBACK_FILE||!fs.existsSync(ROLLBACK_FILE))throw new Error('rollback needs archive-run-map.json');
   const map=JSON.parse(fs.readFileSync(ROLLBACK_FILE,'utf8'));
   console.log(`[rollback] un-archiving ${map.archived.length} products + deleting ${map.redirects.length} redirects…`);
+  // TK-10792 note: rollback re-activates OLD multi-variant products that were intentionally
+  // archived. These are original Carnegie pattern pages with real vendor mfr_skus (pre-split),
+  // NOT new DWAG-* products. mfr_sku gate intentionally does not apply to this undo path.
   for(const a of map.archived){ await shop(`/products/${a.old_id}.json`,{method:'PUT',body:JSON.stringify({product:{id:a.old_id,status:'active'}})}); await sleep(550); }
   for(const r of map.redirects){ if(r.redirect_id){ try{await shop(`/redirects/${r.redirect_id}.json`,{method:'DELETE'});}catch{} await sleep(400);} }
   console.log(`[rollback] done`); process.exit(0);
diff --git a/carnegie-mfr-gate.mjs b/carnegie-mfr-gate.mjs
new file mode 100644
index 0000000..98f8844
--- /dev/null
+++ b/carnegie-mfr-gate.mjs
@@ -0,0 +1,109 @@
+#!/usr/bin/env node
+// carnegie-mfr-gate.mjs — TK-10792 stop-the-bleed guard
+//
+// Validates that a Carnegie product row has a REAL vendor mfr_sku before
+// any activator is allowed to set status='active'. Products that fail are
+// kept as draft and tagged Needs-Mfr-SKU.
+//
+// Usage as a library:
+//   import { mfrSkuValid, assertActivatable, SKIP_TAG } from './carnegie-mfr-gate.mjs';
+//   const { ok, reason } = assertActivatable(row);
+//   if (!ok) { /* keep draft, add SKIP_TAG to tags */ }
+//
+// Usage as a CLI audit:
+//   node carnegie-mfr-gate.mjs            # audit carnegie_catalog, print skip counts
+//   node carnegie-mfr-gate.mjs --verbose  # print every skipped SKU
+
+import { execFileSync } from 'node:child_process';
+
+// ---- Gate logic ----------------------------------------------------------------
+
+/** Tag applied to products that fail the gate */
+export const SKIP_TAG = 'Needs-Mfr-SKU';
+
+/**
+ * Returns true iff `mfr` is a real vendor mfr_sku:
+ *  - not null / empty / whitespace-only
+ *  - does NOT match /^DWAG-/i  (DW-internal placeholder codes)
+ *  - does NOT match /^DW[A-Z]{2}-/i in general (all DW-prefix internal codes)
+ *    NOTE: only DWAG is confirmed as the bleed source; the regex is intentionally
+ *    tight so legitimate vendor codes (DWSC-*, etc.) that happen to be known are
+ *    not blocked. Adjust the regex if new internal prefixes are discovered.
+ */
+export function mfrSkuValid(mfr) {
+  if (mfr === null || mfr === undefined) return false;
+  const s = String(mfr).trim();
+  if (!s) return false;
+  if (/^DWAG-/i.test(s)) return false;
+  return true;
+}
+
+/**
+ * Returns { ok: true } if the row is safe to activate, or
+ * { ok: false, reason: string } if it must stay draft.
+ *
+ * @param {object} row - any object with a `mfr_sku` property
+ */
+export function assertActivatable(row) {
+  const mfr = row?.mfr_sku;
+  if (!mfrSkuValid(mfr)) {
+    const display = (mfr === null || mfr === undefined) ? 'null' : `"${String(mfr)}"`;
+    return {
+      ok: false,
+      reason: `mfr_sku ${display} is null, empty, or a DWAG-* internal placeholder — product stays DRAFT (${SKIP_TAG})`,
+    };
+  }
+  return { ok: true };
+}
+
+/**
+ * Given a comma-separated tags string, ensure SKIP_TAG is present.
+ * Returns the updated tags string.
+ */
+export function addSkipTag(tags) {
+  const arr = (tags || '').split(',').map(t => t.trim()).filter(Boolean);
+  if (!arr.includes(SKIP_TAG)) arr.push(SKIP_TAG);
+  return arr.join(', ');
+}
+
+// ---- CLI audit (when run directly) --------------------------------------------
+
+if (import.meta.url === `file://${process.argv[1]}`) {
+  const VERBOSE = process.argv.includes('--verbose');
+  const DB = 'postgresql:///dw_unified?host=/tmp';
+  const PSQL = [
+    '/opt/homebrew/opt/postgresql@14/bin/psql',
+    '/usr/local/opt/postgresql@14/bin/psql',
+    'psql',
+  ].find(p => { try { execFileSync(p, ['--version'], { stdio: 'ignore' }); return true; } catch { return false; } }) || 'psql';
+
+  const q = sql => execFileSync(PSQL, [DB, '-At', '-c', sql], { encoding: 'utf8' }).trim();
+
+  console.log('=== Carnegie mfr_sku gate audit (carnegie_catalog) ===\n');
+
+  const total = +q('SELECT COUNT(*) FROM carnegie_catalog');
+  const nullCount = +q("SELECT COUNT(*) FROM carnegie_catalog WHERE mfr_sku IS NULL OR mfr_sku = ''");
+  const dwagCount = +q("SELECT COUNT(*) FROM carnegie_catalog WHERE mfr_sku ILIKE 'DWAG-%'");
+  const realCount = +q("SELECT COUNT(*) FROM carnegie_catalog WHERE mfr_sku IS NOT NULL AND mfr_sku <> '' AND mfr_sku NOT ILIKE 'DWAG-%'");
+
+  console.log(`Total rows          : ${total}`);
+  console.log(`Real mfr_sku (PASS) : ${realCount}  (${((realCount/total)*100).toFixed(1)}%)`);
+  console.log(`DWAG-* codes (SKIP) : ${dwagCount}  (${((dwagCount/total)*100).toFixed(1)}%)`);
+  console.log(`null/empty   (SKIP) : ${nullCount}  (${((nullCount/total)*100).toFixed(1)}%)`);
+  console.log(`Total would SKIP    : ${dwagCount + nullCount}`);
+
+  if (VERBOSE) {
+    console.log('\n--- Skipped SKUs (sample up to 50) ---');
+    const rows = q(
+      "SELECT dw_sku, mfr_sku, pattern_name FROM carnegie_catalog " +
+      "WHERE mfr_sku IS NULL OR mfr_sku = '' OR mfr_sku ILIKE 'DWAG-%' " +
+      "ORDER BY pattern_name, mfr_sku LIMIT 50"
+    );
+    for (const line of rows.split('\n').filter(Boolean)) {
+      console.log(' ', line);
+    }
+  }
+
+  console.log('\nProducts in this set would be created as DRAFT with tag "Needs-Mfr-SKU".');
+  console.log('They will NOT be activated until Steve provides the real vendor code.\n');
+}
diff --git a/phase2-poc-acapella.mjs b/phase2-poc-acapella.mjs
index c0699a6..22eb316 100644
--- a/phase2-poc-acapella.mjs
+++ b/phase2-poc-acapella.mjs
@@ -12,6 +12,8 @@
 import { execFileSync } from 'node:child_process';
 import fs from 'node:fs';
 import path from 'node:path';
+// TK-10792: canonical mfr_sku gate
+import { mfrSkuValid, SKIP_TAG as NEEDS_MFR_TAG } from './carnegie-mfr-gate.mjs';
 
 const APPLY = process.argv.includes('--apply');
 const DOMAIN = 'designer-laboratory-sandbox.myshopify.com';
@@ -148,11 +150,13 @@ function productPayload(it) {
 }
 
 // 5-field + image gate: catalog is complete, but verify per SKU
+// TK-10792: mfr_sku gate added — DWAG-* or null/empty → no activation
 function gatePass(it, imgSrc) {
   const reasons = [];
   if (!imgSrc && !it.local_image) reasons.push('no-image');
   if (!it.width) reasons.push('no-width');
   if (!it.description_text) reasons.push('no-desc');
+  if (!mfrSkuValid(it.mfr_sku)) reasons.push(`no-real-mfr-sku(${JSON.stringify(it.mfr_sku)})`);
   return { pass: reasons.length === 0, reasons };
 }
 
@@ -206,7 +210,18 @@ async function main() {
       created.status = 'active';
     } else {
       created.status = 'draft';
+      const hasMfrIssue = gate.reasons.some(r => r.startsWith('no-real-mfr-sku'));
       created.draft_reason = !imgLanded ? 'image-failed-to-attach' : gate.reasons.join(',');
+      // TK-10792: tag products blocked by mfr_sku gate so they surface in the Needs-Mfr-SKU filter
+      if (hasMfrIssue) {
+        try {
+          const t = await shopify('GET', `products/${pid}.json?fields=id,tags`);
+          const existingTags = (t.product.tags || '').split(',').map(s=>s.trim()).filter(Boolean);
+          if (!existingTags.includes(NEEDS_MFR_TAG)) {
+            await shopify('PUT', `products/${pid}.json`, { product: { id: pid, tags: [...existingTags, NEEDS_MFR_TAG].join(', ') } });
+          }
+        } catch(e) { console.warn(`  could not add ${NEEDS_MFR_TAG} tag to ${pid}: ${e.message.slice(0,80)}`); }
+      }
     }
     reversal.created.push(created);
     console.log(`[${i}/14] created ${pid} ${payload.title} -> ${created.status}`);
diff --git a/rebuild-line.mjs b/rebuild-line.mjs
index a9db665..c729732 100644
--- a/rebuild-line.mjs
+++ b/rebuild-line.mjs
@@ -8,9 +8,15 @@
 //   node rebuild-line.mjs --status      # print progress from the ledger and exit
 //
 // Archiving old products + folding the proof is a SEPARATE step: archive-old.mjs (end, after verify).
+//
+// TK-10792 stop-the-bleed: mfr_sku gate — products without a real vendor mfr_sku
+// (null, empty, or DWAG-* internal placeholder) are created as DRAFT with the
+// Needs-Mfr-SKU tag. They are NEVER set to ACTIVE until Steve resolves the real code.
 import { execFileSync } from 'node:child_process';
 import fs from 'node:fs';
 import path from 'node:path';
+// TK-10792: canonical mfr_sku gate — one source of truth, no inline copy
+import { mfrSkuValid, addSkipTag, SKIP_TAG as NEEDS_MFR_TAG } from './carnegie-mfr-gate.mjs';
 
 const DIR = new URL('.', import.meta.url).pathname;
 const ENV = `${process.env.HOME}/Projects/secrets-manager/.env`;
@@ -24,6 +30,7 @@ const DB = 'postgresql:///dw_unified?host=/tmp';
 const q1 = sql => execFileSync(PSQL,[DB,'-At','-c',sql],{encoding:'utf8',maxBuffer:1<<30}).trim();
 const sleep = ms => new Promise(r=>setTimeout(r,ms));
 
+
 const LEDGER = path.join(DIR,'rebuild-ledger.jsonl');
 const HALT    = path.join(DIR,'rebuild-HALTED.flag');
 const args = process.argv.slice(2);
@@ -114,11 +121,19 @@ function buildPayload(row){
     'Carnegie','carnegie-textile','Fabric', ptype, 'Carnegie Textiles',
     'display_variant',
   ].filter(Boolean);
-  const tagStr = [...new Set(tags.map(t=>String(t).trim()).filter(Boolean))].join(', ');
+  let tagStr = [...new Set(tags.map(t=>String(t).trim()).filter(Boolean))].join(', ');
+
+  // TK-10792: only allow ACTIVE if images exist AND mfr_sku is a real vendor code.
+  const hasMfr = mfrSkuValid(row.mfr_sku);
+  const finalStatus = (imgs.length && hasMfr) ? 'active' : 'draft';
+  if (!hasMfr) {
+    tagStr = addSkipTag(tagStr);
+    console.warn(`  [MFR-GATE] SKIP-ACTIVE dw_sku=${row.dw_sku} mfr_sku=${JSON.stringify(row.mfr_sku)} — keeping DRAFT+${NEEDS_MFR_TAG}`);
+  }
 
   const payload = { product: {
     title, body_html: body, vendor:'Carnegie', product_type: ptype,
-    handle, status: imgs.length ? 'active' : 'draft', published_scope:'web', tags: tagStr,
+    handle, status: finalStatus, published_scope:'web', tags: tagStr,
     options:[{name:'Format'}],
     images: imgs.map((src,i)=>({src, position:i+1})),
     variants:[
@@ -168,7 +183,7 @@ async function main(){
   const todo = skus.filter((s,i)=> (i%SHARD_M===SHARD_N) && !(done.get(s)&&done.get(s).product_id));
   console.log(`[shard ${SHARD_N+1}/${SHARD_M}] catalog ${skus.length} | already created ${[...done.values()].filter(r=>r.product_id).length} | this shard to create ${todo.length} (limit ${LIMIT})`);
 
-  let made=0, sinceCheck=0, imgFailStreak=0;
+  let made=0, mfrSkipped=0, sinceCheck=0, imgFailStreak=0;
   for(const sku of todo){
     if(made>=LIMIT) break;
     let row;
@@ -199,8 +214,11 @@ async function main(){
     const imageStarved = built.imgCount>0 && attached<=0;
     imgFailStreak = imageStarved ? imgFailStreak+1 : 0;
 
+    const wasMfrSkipped = (product.status === 'draft') && !mfrSkuValid(row.mfr_sku);
+    if (wasMfrSkipped) mfrSkipped++;
     ledgerAppend({ dw_sku:sku, product_id:product.id, handle:built.handle, color:built.colorName,
       retail:built.retail, imgExpected:built.imgCount, imgAttached:attached, status:product.status,
+      mfr_sku:row.mfr_sku, mfr_gate: wasMfrSkipped ? 'DRAFT-no-mfr' : 'ok',
       mf_ok:mf.ok, mf_err:mf.err, at:new Date().toISOString() });
     made++; sinceCheck++;
 
@@ -215,7 +233,13 @@ async function main(){
     }
     await sleep(250); // gentle pace, well under any burst limit
   }
-  console.log(`DONE this run: ${made} created. Ledger: ${LEDGER}`);
+  // TK-10792: durable skip-count so operators can see the gate's effect without parsing all JSONL
+  const summary = { type:'run-summary', shard:`${SHARD_N+1}/${SHARD_M}`, made, mfrDraftSkipped:mfrSkipped,
+    note: mfrSkipped > 0 ? `${mfrSkipped} products held as DRAFT — mfr_sku null/empty/DWAG-*; needs Steve review` : 'all activations had real mfr_sku',
+    at: new Date().toISOString() };
+  ledgerAppend(summary);
+  if (mfrSkipped > 0) console.warn(`\n[TK-10792] ${mfrSkipped} products held as DRAFT (tagged ${NEEDS_MFR_TAG}). Real vendor mfr_sku required before activation. See ledger run-summary.\n`);
+  console.log(`DONE this run: ${made} created (${mfrSkipped} kept DRAFT due to missing/DWAG mfr_sku — TK-10792). Ledger: ${LEDGER}`);
 }
 
 function haltStorage(sku, msg){

← 9f61e80 carnegie reconcile: reconcile.mjs (dedup plan, live-truth) +  ·  back to Carnegie Reprice  ·  carnegie-mfr-gate: add bodyHtmlValid + wire into assertActiv 015d3c8 →