← back to Dw Rotation Activator
Unify pre-activation five-field checks
ee136fcec5001c75c553dc04d947892a33dcbfae · 2026-08-28 21:37:35 -0700 · Steve Abrams
Files touched
M cmo-activate.jsA lib/five-field-extra.jsM mdc-activate.jsM rotate-activate.jsA test/five-field-extra.test.jsA verification/e2e-proof.json
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 Rotation Activator
·
Fail closed on malformed activation fields c4f1747 →