[object Object]

← 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

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 →