[object Object]

← back to Dw Daily Catchup 20260909

Unify pre-activation five-field checks

ee136fcec5001c75c553dc04d947892a33dcbfae · 2026-08-28 21:37:35 -0700 · Steve Abrams

Files touched

Diff

commit ee136fcec5001c75c553dc04d947892a33dcbfae
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Fri Aug 28 21:37:35 2026 -0700

    Unify pre-activation five-field checks
---
 cmo-activate.js               |  8 +++-----
 lib/five-field-extra.js       | 18 ++++++++++++++++++
 mdc-activate.js               |  6 +++---
 rotate-activate.js            | 21 +--------------------
 test/five-field-extra.test.js | 43 +++++++++++++++++++++++++++++++++++++++++++
 verification/e2e-proof.json   | 43 +++++++++++++++++++++++++++++++++++++++++++
 6 files changed, 111 insertions(+), 28 deletions(-)

diff --git a/cmo-activate.js b/cmo-activate.js
index 033c6df..6409933 100644
--- a/cmo-activate.js
+++ b/cmo-activate.js
@@ -2,6 +2,7 @@
 // + validateBeforeActivate (5-field/specs gate). Only the loop/load/activate is local.
 const fs=require('fs'), os=require('os'), path=require('path');
 const { SettlementGate } = require('./lib/settlement-gate.js');
+const { fiveFieldExtra } = require('./lib/five-field-extra.js');
 const { validateBeforeActivate } = require(path.join(os.homedir(),'Projects/Designer-Wallcoverings/shopify/scripts/lib/validate-before-activate.js'));
 const { execSync } = require('child_process');
 const TOK=fs.readFileSync('/Users/macstudio3/Projects/secrets-manager/.env','utf8').split('\n').find(l=>l.startsWith('SHOPIFY_ADMIN_TOKEN=')).split('=')[1];
@@ -43,11 +44,8 @@ async function setHold(id,verdict,reason){ await gql(`mutation($mfs:[MetafieldsS
         descriptionHtml:n.descriptionHtml,variants:n.variants.nodes,
         specs:{width,material:matMap[mfr]||'',repeat:'Free match'},vendorSpecs:{width,material:matMap[mfr]||''},
         images:imgs,vendorImages:imgs});
-      // quote-only 5-field extra: sample + quotes-tag exemption + >=2 tags
-      const hasSample=(n.variants.nodes||[]).some(v=>/-sample$/i.test(v.sku||''));
-      const isQuote=(n.tags||[]).includes('quotes');
-      const extraOk=hasSample && (n.tags||[]).length>=2;
-      if(!val.ok || !extraOk){ heldValidate++; const why=[...(val.reasons||[]),...(!hasSample?['no-sample']:[]) ].join(',');
+      const extra=fiveFieldExtra(n);
+      if(!val.ok || !extra.ok){ heldValidate++; const why=[...(val.reasons||[]),...extra.reasons].join(',');
         P(`HOLD(validate) ${dwSku} ${mfr} :: ${why}`);
         if(!DRY) await gql(`mutation($id:ID!,$t:[String!]!){tagsAdd(id:$id,tags:$t){userErrors{message}}}`,{id:n.id,t:['Needs-Description']});
         continue; }
diff --git a/lib/five-field-extra.js b/lib/five-field-extra.js
new file mode 100644
index 0000000..87548f2
--- /dev/null
+++ b/lib/five-field-extra.js
@@ -0,0 +1,18 @@
+'use strict';
+
+function fiveFieldExtra(product) {
+  const variants = product?.variants?.nodes || [];
+  const tags = product?.tags || [];
+  const hasSample = variants.some((variant) => /-sample$/i.test(variant.sku || ''));
+  const sellable = variants.filter((variant) => !/-sample$/i.test(variant.sku || ''));
+  const hasSellablePriced = sellable.some((variant) => Number.parseFloat(variant.price) > 0);
+  const isQuoteOnly = tags.includes('quotes');
+  const reasons = [];
+  if (!hasSample) reasons.push('no-sample-variant');
+  if (!sellable.length && !isQuoteOnly) reasons.push('no-sellable-variant');
+  if (!hasSellablePriced && !isQuoteOnly) reasons.push('sellable-price-not-gt-0');
+  if (tags.length < 2) reasons.push('fewer-than-2-tags');
+  return { ok: reasons.length === 0, reasons };
+}
+
+module.exports = { fiveFieldExtra };
diff --git a/mdc-activate.js b/mdc-activate.js
index cfc55be..6fe7614 100644
--- a/mdc-activate.js
+++ b/mdc-activate.js
@@ -26,6 +26,7 @@
 //   node mdc-activate.js --commit              # LIVE activate (Steve-gated)
 const fs=require('fs'), os=require('os'), path=require('path');
 const { SettlementGate } = require('./lib/settlement-gate.js');
+const { fiveFieldExtra } = require('./lib/five-field-extra.js');
 const { validateBeforeActivate } = require(path.join(os.homedir(),'Projects/Designer-Wallcoverings/shopify/scripts/lib/validate-before-activate.js'));
 const { execSync } = require('child_process');
 const TOK=fs.readFileSync('/Users/macstudio3/Projects/secrets-manager/.env','utf8').split('\n').find(l=>l.startsWith('SHOPIFY_ADMIN_TOKEN=')).split('=')[1];
@@ -74,9 +75,8 @@ const SET=rows.map(l=>{const[pid,mfr,mat,width,dw,vital]=l.split('\t');return{pi
       descriptionHtml:n.descriptionHtml,variants:n.variants.nodes,
       specs:{width,material:row.mat,repeat:'Free match'},vendorSpecs:{width,material:row.mat},
       images:imgs,vendorImages:imgs});
-    const hasSample=(n.variants.nodes||[]).some(v=>/-sample$/i.test(v.sku||''));
-    const extraOk=hasSample && (n.tags||[]).length>=2;
-    if(!val.ok || !extraOk){ heldValidate++; const why=[...(val.reasons||[]),...(!hasSample?['no-sample']:[])].join(',');
+    const extra=fiveFieldExtra(n);
+    if(!val.ok || !extra.ok){ heldValidate++; const why=[...(val.reasons||[]),...extra.reasons].join(',');
       P(`HOLD(validate) ${row.dw} ${row.mfr} :: ${why}`);
       if(!DRY) await gql(`mutation($id:ID!,$t:[String!]!){tagsAdd(id:$id,tags:$t){userErrors{message}}}`,{id:n.id,t:['Needs-Description']}); continue; }
     // (2) settlement (legal) gate
diff --git a/rotate-activate.js b/rotate-activate.js
index 2ffc247..9b41ac8 100644
--- a/rotate-activate.js
+++ b/rotate-activate.js
@@ -43,6 +43,7 @@ const path = require('path');
 const { execFileSync } = require('child_process');
 const { ROTATION_ORDER_SQL } = require('./lib/rotation-order.js');
 const { SettlementGate } = require('./lib/settlement-gate.js');
+const { fiveFieldExtra } = require('./lib/five-field-extra.js');
 const { validateBeforeActivate, toImageList } =
   require(path.join(os.homedir(), 'Projects/Designer-Wallcoverings/shopify/scripts/lib/validate-before-activate.js'));
 
@@ -206,26 +207,6 @@ function gateFromLive(n, dwSku, vendor) {
 // variant, and >=2 tags. (The canonical gate already covers width+image+desc+
 // sample+title-guards; this makes the price>0 + sellable-variant + >=2-tags
 // requirement explicit and independent of vendor quote-only exemptions.)
-function fiveFieldExtra(n) {
-  const variants = n.variants?.nodes || [];
-  const hasSample = variants.some((v) => /-sample$/i.test(v.sku || ''));
-  const sellable = variants.filter((v) => !/-sample$/i.test(v.sku || ''));
-  const hasSellablePriced = sellable.some((v) => parseFloat(v.price) > 0);
-  const isQuoteOnly = (n.tags || []).includes('quotes');
-  const tagCount = (n.tags || []).length;
-  const reasons = [];
-  if (!hasSample) reasons.push('no-sample-variant');
-  // Designated quote-only lines (the `quotes` tag — e.g. Koroseal, Vahallan) legitimately
-  // ship sample-only: the customer requests a per-project quote, so there is NO sellable
-  // (non-sample) variant AND no roll price. Exempt BOTH the sellable-variant and the
-  // price>0 requirements for them (2026-07-23, per memory quote-only-ok-lines /
-  // no-cost-no-sellable-variant). Non-quote lines are unaffected — they still require both.
-  if (!sellable.length && !isQuoteOnly) reasons.push('no-sellable-variant');
-  if (!hasSellablePriced && !isQuoteOnly) reasons.push('sellable-price-not-gt-0');
-  if (tagCount < 2) reasons.push('fewer-than-2-tags');
-  return { ok: reasons.length === 0, reasons };
-}
-
 // ── PRIVATE-LABEL LEAK GUARD ──────────────────────────────────────────────
 // A product must NEVER go customer-facing (DRAFT→ACTIVE) with the upstream SOURCE
 // name in its title or vendor. Durable fix for the 2026-07-21 incident where the
diff --git a/test/five-field-extra.test.js b/test/five-field-extra.test.js
new file mode 100644
index 0000000..be1ee1b
--- /dev/null
+++ b/test/five-field-extra.test.js
@@ -0,0 +1,43 @@
+'use strict';
+const test = require('node:test');
+const assert = require('node:assert/strict');
+const { fiveFieldExtra } = require('../lib/five-field-extra.js');
+
+const product = (variants, tags = ['wallpaper', 'modern']) => ({ variants: { nodes: variants }, tags });
+
+test('passes sample plus positive-price sellable variant', () => {
+  assert.deepEqual(fiveFieldExtra(product([
+    { sku: 'DW-100-SAMPLE', price: '5.00' }, { sku: 'DW-100', price: '125.00' },
+  ])), { ok: true, reasons: [] });
+});
+
+test('fails closed without a sample', () => {
+  assert.deepEqual(fiveFieldExtra(product([{ sku: 'DW-100', price: '125.00' }])), {
+    ok: false, reasons: ['no-sample-variant'],
+  });
+});
+
+test('rejects missing, zero, malformed, and negative sellable prices', () => {
+  for (const price of [undefined, '', '0', 'not-a-price', '-1']) {
+    const result = fiveFieldExtra(product([
+      { sku: 'DW-100-SAMPLE', price: '5.00' }, { sku: 'DW-100', price },
+    ]));
+    assert.equal(result.ok, false, `price ${String(price)} should fail`);
+    assert.ok(result.reasons.includes('sellable-price-not-gt-0'));
+  }
+});
+
+test('requires at least two tags', () => {
+  assert.deepEqual(fiveFieldExtra(product([
+    { sku: 'DW-100-SAMPLE', price: '5.00' }, { sku: 'DW-100', price: '125.00' },
+  ], ['wallpaper'])), { ok: false, reasons: ['fewer-than-2-tags'] });
+});
+
+test('quote-only products may be sample-only but still need sample and tags', () => {
+  assert.deepEqual(fiveFieldExtra(product([
+    { sku: 'DW-QUOTE-SAMPLE', price: '5.00' },
+  ], ['quotes', 'wallpaper'])), { ok: true, reasons: [] });
+  assert.deepEqual(fiveFieldExtra(product([], ['quotes', 'wallpaper'])), {
+    ok: false, reasons: ['no-sample-variant'],
+  });
+});
diff --git a/verification/e2e-proof.json b/verification/e2e-proof.json
new file mode 100644
index 0000000..308cfff
--- /dev/null
+++ b/verification/e2e-proof.json
@@ -0,0 +1,43 @@
+{
+  "intent": "Use one fail-closed five-field invariant check in every local product activation lane before any ACTIVE mutation.",
+  "risk_tier": "R1 isolated code; live Shopify activation is R4 and was intentionally not invoked",
+  "environment": "local macOS workspace; zero-network tests",
+  "timestamp": "2026-08-29T04:37:05Z",
+  "ticket": "TK-10947-unify-five-field-activation-checks-acros",
+  "precondition": "rotate-activate.js had the full extra invariant set; cmo-activate.js and mdc-activate.js duplicated only sample/tag checks.",
+  "checks": [
+    {
+      "verdict": "PASS",
+      "boundary": "pure validation",
+      "command": "node --test test/five-field-extra.test.js",
+      "assertions": "5/5 pass: normal pass, missing sample rejection, invalid price rejection, tag rejection, quote-only exemption boundaries"
+    },
+    {
+      "verdict": "PASS",
+      "boundary": "script parse",
+      "command": "node --check rotate-activate.js; node --check cmo-activate.js; node --check mdc-activate.js",
+      "assertions": "all three activation entry points parse"
+    },
+    {
+      "verdict": "PASS",
+      "boundary": "activation mutation ordering",
+      "command": "zero-network source assertion over all three entry points",
+      "assertions": "shared fiveFieldExtra call precedes a fail-closed continue, which precedes the ACTIVE mutation"
+    },
+    {
+      "verdict": "PASS",
+      "boundary": "working-tree hygiene",
+      "command": "git diff --check",
+      "assertions": "no whitespace errors"
+    }
+  ],
+  "negative_checks": [
+    "missing sample",
+    "missing, blank, zero, malformed, and negative sellable price",
+    "fewer than two tags",
+    "quote-only product without sample"
+  ],
+  "side_effects": "none; no Shopify/Gmail/network call, no service restart, no deploy, no scheduled-job change",
+  "cleanup": "not required; tests create no retained state",
+  "verdict": "PASS for the local pre-activation decision boundary"
+}

← 3b23d46 chore: fail-closed activation guard in drain.sh (session clo  ·  back to Dw Daily Catchup 20260909  ·  Fail closed on malformed activation fields c4f1747 →