← back to Dw Rotation Activator
Add per-product settlement gate to rotation activator (texture auto-pass + Gemini vision, fail-closed, dual-key)
329e3f2d9dfad7ce3061cf50519d090ea3b3db00 · 2026-07-21 13:26:11 -0700 · Steve
Files touched
A lib/settlement-gate.jsM rotate-activate.jsA rotate-activate.js.bak
Diff
commit 329e3f2d9dfad7ce3061cf50519d090ea3b3db00
Author: Steve <steve@designerwallcoverings.com>
Date: Tue Jul 21 13:26:11 2026 -0700
Add per-product settlement gate to rotation activator (texture auto-pass + Gemini vision, fail-closed, dual-key)
---
lib/settlement-gate.js | 282 ++++++++++++++++++++++++++++++++++++++++++++
rotate-activate.js | 46 ++++++++
rotate-activate.js.bak | 311 +++++++++++++++++++++++++++++++++++++++++++++++++
3 files changed, 639 insertions(+)
diff --git a/lib/settlement-gate.js b/lib/settlement-gate.js
new file mode 100644
index 0000000..dbc2d11
--- /dev/null
+++ b/lib/settlement-gate.js
@@ -0,0 +1,282 @@
+'use strict';
+/*
+ * settlement-gate.js — per-product SETTLEMENT gate for the rotation activator.
+ *
+ * The activator flips DRAFT→ACTIVE at ~500/day. WITHOUT this gate it would
+ * publish tropical/botanical products (Gracie chinoiserie, Zuber scenics,
+ * Fromental florals, Madagascar "Real Banana Leaf") unchecked — a hard-rule
+ * violation of the Designer Wallcoverings Settlement Agreement. This module
+ * closes that hole. It runs ALONGSIDE the existing 5-field gate: a product must
+ * pass BOTH the 5-field gate AND settlement before it can activate.
+ *
+ * Binding rule (from ~/.claude/skills/settlement/binding/SETTLEMENT-BINDING-TEXT.md):
+ * A design is PROHIBITED iff BOTH
+ * Part A fully (ALL of: directional-variation foliage + open space between
+ * leaves + >1 ink color), AND
+ * Part B (≥1 of: bananas/banana-pods, grapes, birds, butterflies).
+ * The defendant-favorable reading is the only reading; BORDERLINE / UNKNOWN
+ * cases must be ruled BLOCK by default.
+ *
+ * Three outcomes per product:
+ * PASS — settlement clear → activator may proceed to ACTIVE (if 5-field also ok).
+ * BLOCK — full Part A + a Part B element → leave DRAFT + hold metafield + log.
+ * HELD — borderline / vision-unavailable / unknown → leave DRAFT + hold + log.
+ * (Treated identically to BLOCK by the caller: never activates.)
+ *
+ * Two tiers of check:
+ * (0) CHEAP AUTO-PASS ($0, no vision): a plain natural-texture material whose
+ * TITLE carries no foliage/motif keyword is settlement-MOOT (a solid
+ * grasscloth/cork/silk with no leaves can't satisfy Part A). This covers
+ * the entire tier-0 textures block (the first ~10 days of the rotation),
+ * so the loop is settlement-safe immediately at ~$0/run.
+ * (1) VISION GATE (Gemini 2.5-flash, ~$0.0006/image): everything else — every
+ * motif/print/botanical product that is NOT an auto-pass texture. Downloads
+ * the primary image and asks the model the Part A / Part B / Acceptable
+ * booleans, then applies the binding verdict locally.
+ *
+ * FAIL-CLOSED: lock.sh (settlement SHA verifier) runs once at construction. If it
+ * fails, no motif/vision product may activate this run (they all return HELD);
+ * plain auto-pass textures still pass (they never depended on the vision path).
+ */
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+const https = require('https');
+const { execFileSync } = require('child_process');
+
+// ── texture material lexicon (auto-pass tier) ────────────────────────────────
+// Prompt-specified plain-texture set. A product is a candidate auto-pass only if
+// its material (metafield OR title-derived) is one of these solid naturals.
+const TEXTURE_MATERIALS = [
+ 'silk', 'grasscloth', 'cork', 'sisal', 'raffia', 'abaca', 'jute', 'linen',
+ 'hemp', 'paperweave', 'grass', 'seagrass', 'leather', 'suede', 'wool', 'mohair',
+];
+const TEXTURE_MAT_RE = new RegExp('\\b(' + TEXTURE_MATERIALS.join('|') + ')\\b', 'i');
+// 'grass' would match 'grasscloth' anyway; 'paperweave' also spelled 'paper weave'.
+const TEXTURE_MAT_RE2 = /paper\s*weave/i;
+
+// Motif / foliage keywords. If ANY appears in the title, the product is NOT a
+// solid texture — it carries a motif and must be vision-gated (never auto-passed).
+const MOTIF_RE =
+ /floral|flower|leaf|leaves|palm|frond|banana|bird|butterfly|tropical|scenic|toile|chinoiserie|bloom|vine|jungle|garden|grape|peacock|foliage|botanic/i;
+
+// ── orchestrator forensic log (shared with the settlement skill) ─────────────
+const ORCH_LOG = path.join(os.homedir(),
+ 'Projects/wallco-ai/data/settlement-audit/orchestrator-log.jsonl');
+function appendOrchestrator(entry) {
+ try {
+ fs.mkdirSync(path.dirname(ORCH_LOG), { recursive: true });
+ fs.appendFileSync(ORCH_LOG, JSON.stringify(entry) + '\n');
+ } catch (_) { /* forensic log best-effort; never block a run on it */ }
+}
+
+// ── cost logging (cost-tracker) ──────────────────────────────────────────────
+const COST_LOG = path.join(os.homedir(), '.claude/skills/cost-tracker/scripts/log.js');
+// Gemini 2.5-flash vision: a settlement image call is ~1 image (~258 vision
+// tokens/tile) + a short prompt + a tiny JSON answer. Bill a flat conservative
+// estimate so the ledger stays honest without token accounting per call.
+const GEMINI_INPUT_TOKENS_PER_CALL = 800; // prompt + ~258 image tokens, rounded up
+const GEMINI_OUTPUT_TOKENS_PER_CALL = 120; // small JSON verdict
+function logVisionCost() {
+ try {
+ execFileSync('node', [COST_LOG,
+ '--api', 'gemini_2_5_flash',
+ '--units', `${GEMINI_INPUT_TOKENS_PER_CALL}:input_token,${GEMINI_OUTPUT_TOKENS_PER_CALL}:output_token`,
+ '--app', 'dw-rotation-activator',
+ '--note', 'settlement vision gate'], { encoding: 'utf8', stdio: 'ignore' });
+ } catch (_) { /* ledger best-effort */ }
+ // computed dollar value for inline display + return
+ return GEMINI_INPUT_TOKENS_PER_CALL * 7.5e-8 + GEMINI_OUTPUT_TOKENS_PER_CALL * 3e-7;
+}
+
+// ── the gate ─────────────────────────────────────────────────────────────────
+class SettlementGate {
+ constructor() {
+ // FAIL-CLOSED lock check — verify the settlement binding SHAs once per run.
+ this.lockOk = false;
+ this.lockMsg = '';
+ try {
+ execFileSync('bash', [path.join(os.homedir(), '.claude/skills/settlement/lock.sh')],
+ { encoding: 'utf8', stdio: 'pipe' });
+ this.lockOk = true;
+ } catch (e) {
+ this.lockMsg = (e.stdout || e.stderr || e.message || 'lock.sh failed').toString().slice(0, 200);
+ }
+ // Gemini keys — keep BOTH so a per-key quota cap (429 "monthly spending cap")
+ // on one key falls through to the other instead of HELD-ing every tier-1
+ // product. Order: generic GEMINI_API_KEY first (its own cap), then wallco.
+ const env = fs.readFileSync(os.homedir() + '/Projects/secrets-manager/.env', 'utf8');
+ const clean = (v) => (v || '').replace(/['"]/g, '').trim();
+ const kGen = clean((env.match(/^GEMINI_API_KEY=(.*)$/m) || [])[1]);
+ const kWallco = clean((env.match(/^GEMINI_API_KEY_WALLCO=(.*)$/m) || [])[1]);
+ this.geminiKeys = [kGen, kWallco].filter((k, i, a) => k && a.indexOf(k) === i);
+ this.geminiKey = this.geminiKeys[0] || ''; // primary (back-compat)
+ this.visionCalls = 0;
+ this.visionCostTotal = 0;
+ this.autoPassCount = 0;
+ }
+
+ lockStatus() { return { ok: this.lockOk, msg: this.lockMsg }; }
+ stats() {
+ return { visionCalls: this.visionCalls, visionCostTotal: this.visionCostTotal,
+ autoPassCount: this.autoPassCount };
+ }
+
+ // Cheap auto-pass test: solid texture material + NO motif keyword in title.
+ isAutoPassTexture(title, material) {
+ const t = String(title || '');
+ if (MOTIF_RE.test(t)) return false; // any motif keyword → NOT solid texture
+ const matStr = `${material || ''} ${t}`; // material metafield OR title-derived
+ return TEXTURE_MAT_RE.test(matStr) || TEXTURE_MAT_RE2.test(matStr);
+ }
+
+ // Fetch an image URL to a base64 buffer (follows one redirect).
+ fetchImageB64(url, depth = 0) {
+ return new Promise((resolve) => {
+ if (!url || depth > 3) return resolve(null);
+ https.get(url, (r) => {
+ if (r.statusCode >= 300 && r.statusCode < 400 && r.headers.location) {
+ r.resume();
+ return resolve(this.fetchImageB64(r.headers.location, depth + 1));
+ }
+ if (r.statusCode !== 200) { r.resume(); return resolve(null); }
+ const chunks = [];
+ r.on('data', (c) => chunks.push(c));
+ r.on('end', () => resolve(Buffer.concat(chunks).toString('base64')));
+ }).on('error', () => resolve(null));
+ });
+ }
+
+ // Gemini 2.5-flash: return the settlement booleans for the produced image.
+ // Iterates over available keys, falling through a 429 / quota-cap to the next.
+ // Returns { ok:true, partA, partB, acceptable } or { ok:false, why }.
+ async visionDetect(b64, mime) {
+ const keys = this.geminiKeys && this.geminiKeys.length ? this.geminiKeys
+ : (this.geminiKey ? [this.geminiKey] : []);
+ if (!keys.length) return { ok: false, why: 'no-gemini-key' };
+ let last = { ok: false, why: 'no-attempt' };
+ for (const key of keys) {
+ const r = await this._visionCall(b64, mime, key);
+ if (r.ok) return r;
+ last = r;
+ // only fall through on rate/quota; a hard error (parse) shouldn't loop keys.
+ if (!/^vision-http-429$/.test(r.why || '')) break;
+ }
+ return last;
+ }
+
+ async _visionCall(b64, mime, key) {
+ const prompt =
+ 'You are a legal-compliance image classifier for a wallcovering catalog. Look ONLY at what is ' +
+ 'visibly depicted in this pattern image. Answer STRICTLY as JSON with these boolean keys:\n' +
+ '"a1_directional_foliage": true if the design shows a REPEATING pattern of leaves, palm fronds, or ' +
+ 'similar foliage with DIRECTIONAL VARIATION (leaves point in more than one direction).\n' +
+ '"a2_open_space": true if there is visible OPEN / negative space between the leaves (not edge-to-edge coverage).\n' +
+ '"a3_multiple_ink_colors": true if the foliage/leaf layer uses MORE THAN ONE color (excluding the background).\n' +
+ '"partB_element": true if ANY of these specific elements is visibly depicted: bananas or banana pods, ' +
+ 'grapes, birds (any species), or butterflies (any species).\n' +
+ '"acceptable_element": true if the design shows tree trunks, clearly represented branches, OR fruit/animal ' +
+ 'elements OTHER THAN bananas/banana-pods/grapes/birds/butterflies.\n' +
+ 'Report only what is actually visible. Return ONLY the JSON object, no prose.';
+ const body = {
+ contents: [{ parts: [
+ { inline_data: { mime_type: mime || 'image/jpeg', data: b64 } },
+ { text: prompt },
+ ] }],
+ generationConfig: { temperature: 0, responseMimeType: 'application/json' },
+ };
+ const data = JSON.stringify(body);
+ const res = await new Promise((resolve) => {
+ const req = https.request({
+ host: 'generativelanguage.googleapis.com',
+ path: `/v1beta/models/gemini-2.5-flash:generateContent?key=${key}`,
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data) },
+ }, (r) => { let d = ''; r.on('data', (c) => d += c);
+ r.on('end', () => resolve({ status: r.statusCode, body: d })); });
+ req.on('error', (e) => resolve({ status: 0, err: e.message }));
+ req.write(data); req.end();
+ });
+ if (res.status !== 200) return { ok: false, why: `vision-http-${res.status}` };
+ let j;
+ try { j = JSON.parse(res.body); } catch { return { ok: false, why: 'vision-parse' }; }
+ let obj;
+ try { obj = JSON.parse(j.candidates?.[0]?.content?.parts?.[0]?.text || '{}'); }
+ catch { return { ok: false, why: 'verdict-parse' }; }
+ // require the 5 expected keys — a partial answer is UNKNOWN (fail-closed).
+ const keys = ['a1_directional_foliage', 'a2_open_space', 'a3_multiple_ink_colors',
+ 'partB_element', 'acceptable_element'];
+ if (!keys.every((k) => typeof obj[k] === 'boolean')) return { ok: false, why: 'incomplete-verdict', obj };
+ const partA = obj.a1_directional_foliage && obj.a2_open_space && obj.a3_multiple_ink_colors;
+ return { ok: true, partA, partB: obj.partB_element, acceptable: obj.acceptable_element, raw: obj };
+ }
+
+ // Apply the BINDING verdict rule to the vision booleans.
+ // PROHIBITED iff (full Part A) AND (Part B). Everything else PASS.
+ verdictFromVision(v) {
+ if (v.partA && v.partB) {
+ return { verdict: 'BLOCK', reason: `fullPartA+partB(${JSON.stringify(v.raw)})` };
+ }
+ return { verdict: 'PASS', reason: v.partA ? 'partA-but-no-partB' : 'partA-not-fully-satisfied' };
+ }
+
+ /*
+ * evaluate(product) — the per-product entry point.
+ * product = { shopify_id, title, vendor, dw_sku, material, imageUrl }
+ * Returns { verdict:'PASS'|'BLOCK'|'HELD', tier:'auto-pass-texture'|'vision', reason, cost }.
+ * Caller activates ONLY on verdict === 'PASS'.
+ */
+ async evaluate(product) {
+ const { shopify_id, title, vendor, dw_sku, material, imageUrl } = product;
+
+ // (0) CHEAP AUTO-PASS — solid texture, no foliage motif → settlement MOOT.
+ if (this.isAutoPassTexture(title, material)) {
+ this.autoPassCount++;
+ return { verdict: 'PASS', tier: 'auto-pass-texture',
+ reason: 'solid-texture-no-motif', cost: 0 };
+ }
+
+ // (1) VISION GATE for everything else.
+ // Fail-closed on lock failure: no motif/vision product activates this run.
+ if (!this.lockOk) {
+ const rec = { verdict: 'HELD', tier: 'vision', reason: 'lock-failed:' + this.lockMsg, cost: 0 };
+ appendOrchestrator({ ts: new Date().toISOString().slice(0, 10), skus: dw_sku || shopify_id,
+ pattern: title, verdict: 'NEEDS REVIEW', reason: 'settlement-lock-failed',
+ source: 'dw-rotation-activator' });
+ return rec;
+ }
+
+ const b64 = await this.fetchImageB64(imageUrl);
+ if (!b64) {
+ // No image to check → cannot clear settlement → HELD (fail-closed). (The
+ // 5-field gate independently requires an image, so this is belt-and-braces.)
+ appendOrchestrator({ ts: new Date().toISOString().slice(0, 10), skus: dw_sku || shopify_id,
+ pattern: title, verdict: 'NEEDS REVIEW', reason: 'no-image-for-settlement',
+ source: 'dw-rotation-activator' });
+ return { verdict: 'HELD', tier: 'vision', reason: 'no-image', cost: 0 };
+ }
+ const mime = /\.png(\?|$)/i.test(imageUrl || '') ? 'image/png' : 'image/jpeg';
+ const v = await this.visionDetect(b64, mime);
+ this.visionCalls++;
+ const cost = logVisionCost();
+ this.visionCostTotal += cost;
+
+ if (!v.ok) {
+ // vision unavailable / incomplete → BORDERLINE → HELD by default.
+ appendOrchestrator({ ts: new Date().toISOString().slice(0, 10), skus: dw_sku || shopify_id,
+ pattern: title, verdict: 'NEEDS REVIEW', reason: 'vision-' + (v.why || 'error'),
+ source: 'dw-rotation-activator' });
+ return { verdict: 'HELD', tier: 'vision', reason: 'vision-' + (v.why || 'error'), cost };
+ }
+
+ const dec = this.verdictFromVision(v);
+ if (dec.verdict === 'BLOCK') {
+ appendOrchestrator({ ts: new Date().toISOString().slice(0, 10), skus: dw_sku || shopify_id,
+ pattern: title, verdict: 'BLOCK', reason: dec.reason, source: 'dw-rotation-activator' });
+ return { verdict: 'BLOCK', tier: 'vision', reason: dec.reason, cost };
+ }
+ return { verdict: 'PASS', tier: 'vision', reason: dec.reason, cost };
+ }
+}
+
+module.exports = { SettlementGate, TEXTURE_MATERIALS, MOTIF_RE };
diff --git a/rotate-activate.js b/rotate-activate.js
index b93fb41..28bc1a4 100644
--- a/rotate-activate.js
+++ b/rotate-activate.js
@@ -42,6 +42,7 @@ const os = require('os');
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 { validateBeforeActivate, toImageList } =
require(path.join(os.homedir(), 'Projects/Designer-Wallcoverings/shopify/scripts/lib/validate-before-activate.js'));
@@ -156,6 +157,20 @@ const STATUS_Q = `query($ids:[ID!]!){nodes(ids:$ids){... on Product{
const mfVal = (mfs, ns, key) => { const m = (mfs || []).find((x) => x.namespace === ns && x.key === key); return m ? m.value : ''; };
const ACTIVATE = `mutation($id:ID!){productUpdate(input:{id:$id,status:ACTIVE}){product{id status} userErrors{field message}}}`;
const TAGS_ADD = `mutation($id:ID!,$tags:[String!]!){tagsAdd(id:$id,tags:$tags){userErrors{field message}}}`;
+// internal.settlement_hold metafield — marks a DRAFT held by the settlement gate.
+const SET_HOLD = `mutation($mfs:[MetafieldsSetInput!]!){metafieldsSet(metafields:$mfs){userErrors{field message}}}`;
+async function setSettlementHold(pid, verdict, reason) {
+ const value = JSON.stringify({ verdict, reason: String(reason).slice(0, 400),
+ ts: new Date().toISOString(), by: 'dw-rotation-activator' });
+ return gqlRetry(SET_HOLD, { mfs: [{ ownerId: pid, namespace: 'internal', key: 'settlement_hold',
+ type: 'json', value }] });
+}
+// primary image + material from the live product node (reused by the gate).
+function primaryImageUrl(n) { return (n.images?.nodes || [])[0]?.url || ''; }
+function materialFromNode(n) {
+ const mfs = n.metafields?.nodes || [];
+ return mfVal(mfs, 'global', 'material') || mfVal(mfs, 'custom', 'material') || mfVal(mfs, 'specs', 'material') || '';
+}
function pidToGid(shopify_id) {
// shopify_id already looks like gid://shopify/Product/NNN in this table.
@@ -213,6 +228,14 @@ function fiveFieldExtra(n) {
const tier0 = queue.filter((q) => q.mat_tier === 0).length;
console.log(`queue: ${queue.length} DRAFTs in canonical order (tier0 textures=${tier0}, rest=${queue.length - tier0})`);
+ // SETTLEMENT GATE — construct once (runs lock.sh fail-closed). Plain textures
+ // auto-pass at $0; motif/print/botanical products are Gemini-vision-gated.
+ const settlement = new SettlementGate();
+ const ls = settlement.lockStatus();
+ console.log(`settlement gate: lock=${ls.ok ? 'PASS' : 'FAIL (' + ls.msg + ')'} — ` +
+ `${ls.ok ? 'motif products vision-gated' : 'motif products HELD this run (textures still pass)'}`);
+ let settlementHeld = 0, settlementBlocked = 0;
+
// budget: activation lane
const used = ledgerUsed();
let cap;
@@ -264,6 +287,26 @@ function fiveFieldExtra(n) {
continue;
}
+ // ── SETTLEMENT GATE (runs AFTER the 5-field gate, BEFORE any DRAFT→ACTIVE) ──
+ // Plain textures auto-pass at $0; everything else is Gemini-vision-checked.
+ // Only verdict === 'PASS' may proceed; BLOCK/HELD leave the product DRAFT,
+ // set internal.settlement_hold, and log to the activator + orchestrator audits.
+ const sv = await settlement.evaluate({
+ shopify_id: n.id, title: n.title, vendor: q && q.vendor, dw_sku: q && q.dw_sku,
+ material: materialFromNode(n), imageUrl: primaryImageUrl(n),
+ });
+ rec.settlement = { verdict: sv.verdict, tier: sv.tier, reason: sv.reason, cost: sv.cost };
+ if (sv.verdict !== 'PASS') {
+ if (sv.verdict === 'BLOCK') settlementBlocked++; else settlementHeld++;
+ skipped++;
+ // hold + skip. In COMMIT mode set the metafield so the hold is durable on
+ // the product; in DRY mode we only record it (no writes).
+ if (!DRY) { try { await setSettlementHold(n.id, sv.verdict, sv.reason); } catch (_) {} }
+ fs.appendFileSync(AUDIT, JSON.stringify({ ...rec,
+ action: sv.verdict === 'BLOCK' ? 'settlement-block' : 'settlement-hold' }) + '\n');
+ continue;
+ }
+
if (DRY) {
projection.push(rec);
activated++; // count projected activations toward the cap so the dry-run shows the real next-N
@@ -296,8 +339,11 @@ function fiveFieldExtra(n) {
await sleep(150);
}
+ const sstat = settlement.stats();
console.log(`\n=== ROTATION ACTIVATOR ${DRY ? 'DRY-RUN' : 'RUN'} DONE ===`);
console.log(`scanned=${scanned} ${DRY ? 'would-activate' : 'activated'}=${activated} published=${published} skipped(gate-fail)=${skipped} alreadyActive=${alreadyActive}`);
+ console.log(`settlement: auto-pass-textures=${sstat.autoPassCount} vision-calls=${sstat.visionCalls} blocked=${settlementBlocked} held=${settlementHeld}`);
+ console.log(`settlement cost: textures $0 (local) + vision ${sstat.visionCalls} calls = $${sstat.visionCostTotal.toFixed(5)} (Gemini 2.5-flash; ~$0.0006/img)`);
if (!DRY) console.log(`daily activation ledger: ${ledgerUsed()}/${DAILY_ACTIVATION_CAP}`);
console.log(`audit → ${AUDIT}`);
diff --git a/rotate-activate.js.bak b/rotate-activate.js.bak
new file mode 100644
index 0000000..b93fb41
--- /dev/null
+++ b/rotate-activate.js.bak
@@ -0,0 +1,311 @@
+#!/usr/bin/env node
+/*
+ * rotate-activate.js — the ROTATION ACTIVATOR.
+ *
+ * Companion to dw-five-field-step0/bulk-fivefield-exec.py. That drain builds the
+ * MISSING Sample/roll variants for the ~5k drafts that need field-fixes. THIS
+ * activator handles the OTHER ~10k drafts that are ALREADY 5-field-complete and
+ * just need to go live — plus any field-fixed product that now passes the gate.
+ *
+ * Each run it takes the next N drafts from the CANONICAL ordered queue
+ * (lib/rotation-order.js — textures-first + vendor round-robin, identical to the
+ * calendar's projection), RE-VERIFIES the 5-field gate LIVE per product
+ * (sample variant + sellable variant + price>0 + description + >=2 tags + width),
+ * and ONLY flips status→ACTIVE + adds the 'New Arrival' tag + publishes to the
+ * Online Store on a PASS. Incomplete products are SKIPPED and logged (never
+ * activate a broken product) — they'll be picked up once the field-fix drain
+ * completes them, and re-tried on the next rotation pass.
+ *
+ * HARD RAILS:
+ * - Never touches Schumacher (excluded at the SQL source — internal-only).
+ * - Never activates without image AND width AND real price AND sample variant.
+ * - Daily activation cap (default 500/day) via its OWN date-stamped ledger
+ * (activation ≠ variant-create, so it does NOT touch budget.cjs; the two
+ * lanes are independent and neither starves the other).
+ * - Idempotent + resumable: already-ACTIVE products fall out of the DRAFT query;
+ * the audit JSONL records every decision.
+ * - Never mass-activate: per-run cap (--max) drives a small per-slot batch.
+ * - GMC exclusion: publishes to all channels EXCEPT Google & YouTube (the $4.25
+ * sample-price-leak rule), mirroring activate-gated.js.
+ *
+ * Store = designer-laboratory-sandbox.myshopify.com (LIVE), API 2024-10.
+ *
+ * Usage:
+ * node rotate-activate.js --dry-run [--max N] # project next N, no writes
+ * node rotate-activate.js --max N --commit # activate up to N gate-passers
+ * node rotate-activate.js --commit # use the daily activation remainder
+ */
+'use strict';
+const https = require('https');
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+const { execFileSync } = require('child_process');
+const { ROTATION_ORDER_SQL } = require('./lib/rotation-order.js');
+const { validateBeforeActivate, toImageList } =
+ require(path.join(os.homedir(), 'Projects/Designer-Wallcoverings/shopify/scripts/lib/validate-before-activate.js'));
+
+const args = process.argv.slice(2);
+const flag = (n) => args.includes(n);
+const val = (n, d) => { const i = args.indexOf(n); return i >= 0 ? args[i + 1] : d; };
+const COMMIT = flag('--commit');
+const DRY = flag('--dry-run') || !COMMIT; // safe-by-default: no --commit ⇒ dry-run
+const CLI_MAX = val('--max', null);
+
+const STORE = 'designer-laboratory-sandbox.myshopify.com';
+const API = '2024-10';
+const OUTDIR = path.join(__dirname, 'out');
+const TODAY = new Date().toISOString().slice(0, 10);
+const AUDIT = path.join(OUTDIR, `rotation-activations-${TODAY}.jsonl`);
+// Daily activation ledger — its OWN lane (activation is not a variant-create, so it
+// must not debit budget.cjs). Simple date-stamped counter file.
+const LEDGER = path.join(OUTDIR, `activation-ledger-${TODAY}.json`);
+const DAILY_ACTIVATION_CAP = parseInt(process.env.DW_ACTIVATION_CAP || '500', 10);
+const GOOGLE_PUBLICATION_ID = 'gid://shopify/Publication/29646651457';
+
+fs.mkdirSync(OUTDIR, { recursive: true });
+
+const env = fs.readFileSync(os.homedir() + '/Projects/secrets-manager/.env', 'utf8');
+const TOKEN = (env.match(/^SHOPIFY_ADMIN_TOKEN=(.*)$/m) || [])[1].replace(/['"]/g, '').trim();
+if (!TOKEN) { console.error('no SHOPIFY_ADMIN_TOKEN'); process.exit(1); }
+
+const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
+
+function psql(sql) {
+ return execFileSync('psql', ['-At', '-F', '\t', '-d', 'dw_unified', '-c', sql],
+ { encoding: 'utf8', maxBuffer: 1 << 28 }).trim();
+}
+
+function gql(query, variables) {
+ return new Promise((res) => {
+ const data = JSON.stringify({ query, variables });
+ const req = https.request({
+ host: STORE, path: `/admin/api/${API}/graphql.json`, method: 'POST',
+ headers: { 'X-Shopify-Access-Token': TOKEN, 'Content-Type': 'application/json',
+ 'Content-Length': Buffer.byteLength(data) },
+ }, (r) => { let d = ''; r.on('data', (c) => d += c);
+ r.on('end', () => { try { res({ status: r.statusCode, json: JSON.parse(d) }); }
+ catch { res({ status: r.statusCode, raw: d.slice(0, 400) }); } }); });
+ req.on('error', (e) => res({ status: 0, err: e.message }));
+ req.write(data); req.end();
+ });
+}
+async function gqlRetry(q, v) {
+ for (let a = 0; a < 5; a++) {
+ const r = await gql(q, v);
+ const t = r.json && r.json.errors && JSON.stringify(r.json.errors).includes('THROTTLED');
+ if (r.status === 429 || t) { await sleep(2000 * (a + 1)); continue; }
+ await sleep(300); return r;
+ }
+ return { status: 429, raw: 'throttled' };
+}
+
+// ── daily activation ledger (own lane) ────────────────────────────────────────
+function ledgerUsed() {
+ try { return JSON.parse(fs.readFileSync(LEDGER, 'utf8')).used || 0; } catch { return 0; }
+}
+function ledgerBump(n) {
+ const used = ledgerUsed() + n;
+ try { fs.writeFileSync(LEDGER, JSON.stringify({ date: TODAY, used })); } catch (_) {}
+ return used;
+}
+
+// ── publish helpers (mirror activate-gated.js; GMC-excluded) ──────────────────
+let PUBS = null;
+async function loadPubs() {
+ if (PUBS) return PUBS;
+ const r = await gqlRetry(`{publications(first:50){edges{node{id name}}}}`, {});
+ PUBS = (r.json?.data?.publications?.edges || []).map((e) => e.node)
+ .filter((p) => p.id !== GOOGLE_PUBLICATION_ID);
+ return PUBS;
+}
+async function publishToChannels(pid) {
+ const pubs = await loadPubs();
+ if (!pubs.length) return { published: false, why: 'no-publications-scope' };
+ const input = pubs.map((p) => ({ publicationId: p.id }));
+ const r = await gqlRetry(
+ `mutation($id:ID!,$input:[PublicationInput!]!){publishablePublish(id:$id,input:$input){userErrors{field message}}}`,
+ { id: pid, input });
+ const ue = r.json?.data?.publishablePublish?.userErrors || [];
+ const real = ue.filter((e) => !/already|cannot be published to itself/i.test(e.message || ''));
+ if (real.length) return { published: false, errors: real };
+ return { published: true, channels: pubs.length };
+}
+
+// ── load the canonical ordered DRAFT queue (textures-first + round-robin) ──────
+// Enrich each row from its vendor staging table would be costly across 62 vendors;
+// instead we re-verify the gate from the LIVE Shopify product (images, variants,
+// metafields, body, tags) which is authoritative at activation time. The order
+// and identity come from shopify_products via lib/rotation-order.js.
+function loadQueue() {
+ const raw = psql(ROTATION_ORDER_SQL);
+ if (!raw) return [];
+ return raw.split('\n').map((l) => {
+ const [shopify_id, vendor, dw_sku, title, product_type, mat_tier, rr] = l.split('\t');
+ return { shopify_id, vendor, dw_sku, title, product_type,
+ mat_tier: parseInt(mat_tier, 10), rr: parseInt(rr, 10) };
+ });
+}
+
+const STATUS_Q = `query($ids:[ID!]!){nodes(ids:$ids){... on Product{
+ id status title vendor descriptionHtml tags
+ images(first:50){nodes{url}}
+ variants(first:20){nodes{price sku}}
+ metafields(first:80){nodes{namespace key value}}
+}}}`;
+const mfVal = (mfs, ns, key) => { const m = (mfs || []).find((x) => x.namespace === ns && x.key === key); return m ? m.value : ''; };
+const ACTIVATE = `mutation($id:ID!){productUpdate(input:{id:$id,status:ACTIVE}){product{id status} userErrors{field message}}}`;
+const TAGS_ADD = `mutation($id:ID!,$tags:[String!]!){tagsAdd(id:$id,tags:$tags){userErrors{field message}}}`;
+
+function pidToGid(shopify_id) {
+ // shopify_id already looks like gid://shopify/Product/NNN in this table.
+ return shopify_id.startsWith('gid://') ? shopify_id : `gid://shopify/Product/${String(shopify_id).replace(/.*\//, '')}`;
+}
+
+// Build the validate-before-activate shape from the LIVE product node and re-run
+// the SINGLE canonical gate. This is the "re-verify 5-field gate live" step.
+function gateFromLive(n, dwSku, vendor) {
+ const mfs = n.metafields?.nodes || [];
+ const liveImgs = (n.images?.nodes || []).map((x) => x.url);
+ const width = mfVal(mfs, 'global', 'width') || mfVal(mfs, 'custom', 'width');
+ const material = mfVal(mfs, 'global', 'material') || mfVal(mfs, 'custom', 'material') || mfVal(mfs, 'specs', 'material');
+ const specs = {
+ width, length: mfVal(mfs, 'global', 'length'), repeat: mfVal(mfs, 'global', 'repeat'),
+ material, unitOfMeasure: mfVal(mfs, 'global', 'unit_of_measure') || '',
+ };
+ return validateBeforeActivate({
+ title: n.title, dwSku: dwSku || '', vendor, tags: n.tags || [],
+ descriptionHtml: n.descriptionHtml || '',
+ specs,
+ // vendorSpecs mirrors specs: we treat what's present on the live product as the
+ // provided set (we're not blocking on a spec the vendor genuinely lacks; width
+ // stays hard-required inside the validator).
+ vendorSpecs: specs,
+ images: liveImgs, vendorImages: liveImgs,
+ variants: (n.variants?.nodes || []).map((v) => ({ sku: v.sku })),
+ });
+}
+
+// Extra "5-field" invariants the prompt calls out explicitly, layered on top of
+// the canonical gate: a sellable (non-sample) variant with price>0, a sample
+// 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');
+ if (!sellable.length) reasons.push('no-sellable-variant');
+ // quote-only lines legitimately have NULL roll price (only the $4.25 sample) →
+ // exempt from the price>0-on-sellable requirement, consistent with activate-gated.js.
+ 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 };
+}
+
+(async () => {
+ const queue = loadQueue();
+ const tier0 = queue.filter((q) => q.mat_tier === 0).length;
+ console.log(`queue: ${queue.length} DRAFTs in canonical order (tier0 textures=${tier0}, rest=${queue.length - tier0})`);
+
+ // budget: activation lane
+ const used = ledgerUsed();
+ let cap;
+ if (DRY) {
+ cap = CLI_MAX != null ? parseInt(CLI_MAX, 10) : 50;
+ } else {
+ const remaining = Math.max(0, DAILY_ACTIVATION_CAP - used);
+ const want = CLI_MAX != null ? parseInt(CLI_MAX, 10) : remaining;
+ cap = Math.min(want, remaining);
+ if (cap <= 0) {
+ console.log(`[budget] daily activation cap reached (${used}/${DAILY_ACTIVATION_CAP}). Nothing to do this run.`);
+ return;
+ }
+ console.log(`[budget] activation lane: used ${used}/${DAILY_ACTIVATION_CAP} today → activating up to ${cap} this run.`);
+ }
+ console.log(`mode=${DRY ? 'DRY-RUN' : 'COMMIT'} cap_this_run=${cap}\n`);
+
+ let activated = 0, published = 0, skipped = 0, alreadyActive = 0, scanned = 0;
+ const projection = []; // for dry-run reporting
+
+ // We walk the ordered queue, batch-fetching live status 50 at a time, and stop
+ // as soon as we've ACTIVATED (or, in dry-run, projected) `cap` gate-passers.
+ for (let i = 0; i < queue.length; i += 50) {
+ if (activated >= cap) break;
+ const batch = queue.slice(i, i + 50);
+ const byGid = new Map(batch.map((q) => [pidToGid(q.shopify_id), q]));
+ const r = await gqlRetry(STATUS_Q, { ids: [...byGid.keys()] });
+ for (const n of (r.json?.data?.nodes || [])) {
+ if (activated >= cap) break;
+ if (!n) continue;
+ const q = byGid.get(n.id);
+ scanned++;
+ if (n.status === 'ACTIVE') { alreadyActive++; continue; }
+ if (n.status !== 'DRAFT') { continue; } // ARCHIVED → leave (never un-archive)
+
+ const gate = gateFromLive(n, q && q.dw_sku, q && q.vendor);
+ const extra = fiveFieldExtra(n);
+ const passes = gate.ok && extra.ok;
+ const rec = { ts: new Date().toISOString(), shopify_id: n.id, vendor: q && q.vendor,
+ dw_sku: q && q.dw_sku, mat_tier: q && q.mat_tier, rr: q && q.rr,
+ title: n.title, passes,
+ reasons: [...(gate.ok ? [] : gate.reasons), ...(extra.ok ? [] : extra.reasons)] };
+
+ if (!passes) {
+ skipped++;
+ // NEVER activate a broken product — log + skip. It flows through the
+ // field-fix drain and is re-tried on the next rotation pass.
+ fs.appendFileSync(AUDIT, JSON.stringify({ ...rec, action: 'skip-gate' }) + '\n');
+ continue;
+ }
+
+ if (DRY) {
+ projection.push(rec);
+ activated++; // count projected activations toward the cap so the dry-run shows the real next-N
+ fs.appendFileSync(AUDIT, JSON.stringify({ ...rec, action: 'dryrun-would-activate' }) + '\n');
+ continue;
+ }
+
+ // COMMIT: flip → ACTIVE, add 'New Arrival', publish to channels (ex-Google).
+ const ar = await gqlRetry(ACTIVATE, { id: n.id });
+ const aue = ar.json?.data?.productUpdate?.userErrors;
+ if (aue && aue.length) {
+ skipped++;
+ fs.appendFileSync(AUDIT, JSON.stringify({ ...rec, action: 'activate-error', errors: aue }) + '\n');
+ continue;
+ }
+ activated++;
+ ledgerBump(1);
+ // add 'New Arrival' tag (idempotent — tagsAdd is a no-op if present)
+ await gqlRetry(TAGS_ADD, { id: n.id, tags: ['New Arrival'] });
+ // publish to Online Store (+ others, minus Google)
+ let pub = false;
+ try { const p = await publishToChannels(n.id); pub = !!p.published;
+ if (!pub) console.log(` ⚠ ${q && q.dw_sku} ACTIVE but publish failed: ${JSON.stringify(p.errors || p.why).slice(0, 120)}`);
+ } catch (e) { console.log(` ⚠ ${q && q.dw_sku} publish error: ${String(e.message).slice(0, 120)}`); }
+ if (pub) published++;
+ fs.appendFileSync(AUDIT, JSON.stringify({ ...rec, action: 'activated', published: pub, tag: 'New Arrival' }) + '\n');
+ if (activated % 25 === 0) process.stdout.write(`\r activated ${activated}/${cap}…`);
+ await sleep(350);
+ }
+ await sleep(150);
+ }
+
+ console.log(`\n=== ROTATION ACTIVATOR ${DRY ? 'DRY-RUN' : 'RUN'} DONE ===`);
+ console.log(`scanned=${scanned} ${DRY ? 'would-activate' : 'activated'}=${activated} published=${published} skipped(gate-fail)=${skipped} alreadyActive=${alreadyActive}`);
+ if (!DRY) console.log(`daily activation ledger: ${ledgerUsed()}/${DAILY_ACTIVATION_CAP}`);
+ console.log(`audit → ${AUDIT}`);
+
+ if (DRY && projection.length) {
+ console.log(`\n--- next ${Math.min(projection.length, 50)} activations (proof: textures lead, vendors interleave) ---`);
+ const pad = (s, n) => String(s == null ? '' : s).padEnd(n).slice(0, n);
+ console.log(pad('#', 4) + pad('tier', 5) + pad('vendor', 24) + 'title');
+ projection.slice(0, 50).forEach((p, idx) =>
+ console.log(pad(idx + 1, 4) + pad(p.mat_tier === 0 ? 'TEX' : '·', 5) + pad(p.vendor, 24) + String(p.title || p.dw_sku || '').slice(0, 44)));
+ }
+})().catch((e) => { console.error('rotate-activate failed:', e.message); process.exit(1); });
← 9976a6b add hourly drip drain.sh + STAGED launchd plist (not loaded
·
back to Dw Rotation Activator
·
activator: private-label leak guard — never activate a produ ade810f →