[object Object]

← back to Designer Wallcoverings

trade-approved-retag: gate --apply against the default full-cohort query

52658b4daa8af824cacf980b7c5c4ba8fc140098 · 2026-09-22 14:40:13 -0700 · Steve

Bare `--apply` used the default query (~15,943 trade customers, a free-sample
money surface) with no confirmation. Now it refuses unless the operator scopes
(--query), bounds (--limit), or explicitly acknowledges (--confirm-all) the run.
Dry-run unaffected. (TK-11786 review finding #3.)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NzqsJ5FDwqLugc4msSfLcV

Files touched

Diff

commit 52658b4daa8af824cacf980b7c5c4ba8fc140098
Author: Steve <steve@designerwallcoverings.com>
Date:   Tue Sep 22 14:40:13 2026 -0700

    trade-approved-retag: gate --apply against the default full-cohort query
    
    Bare `--apply` used the default query (~15,943 trade customers, a free-sample
    money surface) with no confirmation. Now it refuses unless the operator scopes
    (--query), bounds (--limit), or explicitly acknowledges (--confirm-all) the run.
    Dry-run unaffected. (TK-11786 review finding #3.)
    
    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
    Claude-Session: https://claude.ai/code/session_01NzqsJ5FDwqLugc4msSfLcV
---
 shopify/scripts/trade-approved-retag.js | 111 ++++++++++++++++++++++++++++++++
 1 file changed, 111 insertions(+)

diff --git a/shopify/scripts/trade-approved-retag.js b/shopify/scripts/trade-approved-retag.js
new file mode 100644
index 00000000..fd2caeee
--- /dev/null
+++ b/shopify/scripts/trade-approved-retag.js
@@ -0,0 +1,111 @@
+#!/usr/bin/env node
+'use strict';
+/**
+ * trade-approved-retag.js — migrate trade customers to the `trade_approved` tag ahead of the
+ * Free Samples Function go-live (TK-12016). The function now gates trade on the EXACT tag
+ * `trade_approved`; customers carrying only the legacy `trade` tag would lose their discount.
+ *
+ * DRY-RUN BY DEFAULT. Refuses to run without an explicit mode (matches the repo convention).
+ *   node trade-approved-retag.js --dry-run            # preview: count + a sample, NO writes
+ *   node trade-approved-retag.js --dry-run --limit 20 # preview only 20
+ *   node trade-approved-retag.js --apply              # REFUSED against the default query — see the blast-radius gate below
+ *   node trade-approved-retag.js --apply --limit 50   # write only the first 50 (recommended canary)
+ *   node trade-approved-retag.js --apply --confirm-all # WRITE to the FULL default cohort (~15,943) — explicit ack required
+ *   node trade-approved-retag.js --query "tag:trade AND -tag:trade_approved AND tag:'Interior Designer - Residential'"
+ *
+ * SCOPE = the --query (default: every `trade`-but-not-`trade_approved` customer = 15,943 today).
+ *   ⚠ POLICY GATE: promoting ALL 15,943 grants each unlimited free trade samples. If the
+ *   `trade`→`trade_approved` rename was meant to TIGHTEN (require explicit approval), narrow
+ *   the --query to the vetted subset instead of running the default. This script does NOT
+ *   decide that — Steve does.
+ *
+ * SAFE: uses SHOPIFY_FULL_ACCESS_TOKEN (has write_customers). Reversible per-customer via
+ * tagsRemove. Every write is logged to data/trade-retag-<ts>.jsonl (id + before-tags) so the
+ * exact set is undoable. Rate-limited + throttle-aware.
+ */
+const fs = require('fs');
+const path = require('path');
+
+const args = process.argv.slice(2);
+if (!args.includes('--apply') && !args.includes('--dry-run')) {
+  console.error('Refusing to run: pass --apply to WRITE or --dry-run to preview.');
+  process.exit(2);
+}
+const APPLY = args.includes('--apply');
+const li = args.indexOf('--limit'); const LIMIT = li >= 0 ? Math.max(1, parseInt(args[li + 1], 10) || 0) : 0;
+const qi = args.indexOf('--query'); const QUERY = qi >= 0 ? String(args[qi + 1] || '') : 'tag:trade AND -tag:trade_approved';
+const EXPLICIT_QUERY = qi >= 0;
+
+// BLAST-RADIUS GATE (TK-11786 review, finding #3): a bare `--apply` uses the DEFAULT query,
+// which matches the ENTIRE ~15,943 `trade` cohort and grants each unlimited free trade samples
+// fleet-wide (a money surface). Refuse the unbounded full-cohort write unless the operator has
+// consciously SCOPED it (--query), BOUNDED it (--limit), or explicitly ACKNOWLEDGED the full run
+// (--confirm-all) — so a fat-fingered `node trade-approved-retag.js --apply` can never fire the
+// whole set. Dry-run is exempt (it never writes and is how you learn the real matched count).
+if (APPLY && !EXPLICIT_QUERY && !LIMIT && !args.includes('--confirm-all')) {
+  console.error('Refusing --apply against the DEFAULT query — the FULL `trade`-but-not-`trade_approved` cohort (~15,943 customers).');
+  console.error('This tags EVERY matched customer `trade_approved` and grants free trade samples fleet-wide.');
+  console.error('Run `--dry-run` first to see the exact matched count, then choose ONE:');
+  console.error('  • narrow the scope:       --query "<your vetted filter>"');
+  console.error('  • bound a canary batch:   --limit 50');
+  console.error('  • acknowledge the FULL run explicitly:  --confirm-all');
+  process.exit(2);
+}
+
+const envPath = path.join(process.env.HOME, 'Projects/secrets-manager/.env');
+const env = fs.readFileSync(envPath, 'utf8');
+const TOKEN = (env.match(/^SHOPIFY_FULL_ACCESS_TOKEN=(.*)$/m) || [])[1]?.replace(/['"]/g, '').trim();
+if (!TOKEN) { console.error('missing SHOPIFY_FULL_ACCESS_TOKEN (needs write_customers)'); process.exit(2); }
+const STORE = 'designer-laboratory-sandbox.myshopify.com', API = '2024-10';
+const sleep = ms => new Promise(r => setTimeout(r, ms));
+
+async function gql(query, variables) {
+  for (let a = 0; a < 8; a++) {
+    const r = await fetch(`https://${STORE}/admin/api/${API}/graphql.json`, {
+      method: 'POST', headers: { 'X-Shopify-Access-Token': TOKEN, 'Content-Type': 'application/json' },
+      body: JSON.stringify({ query, variables }) });
+    if (r.status === 429) { await sleep(2000 * (a + 1)); continue; }
+    const j = await r.json().catch(() => null);
+    const throttled = j?.errors && JSON.stringify(j.errors).toLowerCase().includes('throttled');
+    if (throttled) { await sleep(2000 * (a + 1)); continue; }
+    return { status: r.status, json: j };
+  }
+  return { status: 429, json: { errors: 'throttle-exhausted' } };
+}
+
+(async () => {
+  const LOGDIR = path.join(__dirname, 'data');
+  fs.mkdirSync(LOGDIR, { recursive: true });
+  const LOG = path.join(LOGDIR, `trade-retag-${new Date().toISOString().replace(/[:.]/g, '-')}.jsonl`);
+  console.log(`${APPLY ? '🔴 APPLY' : 'DRY-RUN'} — query: ${QUERY}${LIMIT ? ` — limit ${LIMIT}` : ''}`);
+
+  let cur = null, seen = 0, wrote = 0, failed = 0, pages = 0;
+  outer: for (;;) {
+    const r = await gql(
+      // sortKey:ID — immutable key + forward-only cursor, so tagging rows OUT of the filtered set
+      // (they stop matching -tag:trade_approved) never reorders or skips the not-yet-visited rows.
+      `query($q:String,$c:String){customers(first:250,query:$q,sortKey:ID,after:$c){edges{node{id tags}}pageInfo{hasNextPage endCursor}}}`,
+      { q: QUERY, c: cur });
+    const d = r.json?.data?.customers;
+    if (!d) { console.error('query error:', JSON.stringify(r.json?.errors || r.status).slice(0, 300)); process.exit(1); }
+    pages++;
+    for (const e of d.edges) {
+      if (LIMIT && seen >= LIMIT) break outer;
+      seen++;
+      const { id, tags } = e.node;
+      if (tags.includes('trade_approved')) continue; // already has it — skip (idempotent)
+      if (!APPLY) { if (seen <= 10) console.log(`  [DRY] would tag ${id}  (tags: ${tags.join('|')})`); continue; }
+      // record preimage BEFORE the write (reversibility)
+      fs.appendFileSync(LOG, JSON.stringify({ ts: new Date().toISOString(), id, before_tags: tags }) + '\n');
+      const w = await gql(`mutation($id:ID!){tagsAdd(id:$id,tags:["trade_approved"]){userErrors{field message}}}`, { id });
+      const ue = w.json?.data?.tagsAdd?.userErrors;
+      if (w.status !== 200 || (Array.isArray(ue) && ue.length)) { failed++; console.error(`  ❌ ${id}: ${JSON.stringify(ue || w.status)}`); }
+      else { wrote++; if (wrote % 250 === 0) console.log(`  …tagged ${wrote}`); }
+      await sleep(120); // ~8/s, well under the GraphQL bucket
+    }
+    if (!d.pageInfo.hasNextPage) break; cur = d.pageInfo.endCursor;
+  }
+  console.log(`\nDone. matched(seen)=${seen} pages=${pages} ${APPLY ? `tagged=${wrote} failed=${failed}` : '(dry-run — no writes)'}`);
+  if (APPLY) console.log(`preimages logged → ${LOG}\nUNDO a run: for each id in the log, tagsRemove ["trade_approved"].`);
+  if (APPLY && failed) process.exitCode = 1;
+})();

← c029dafa auto-data-snapshot: 2026-09-22T14:27:31 (1 data files) — sho  ·  back to Designer Wallcoverings  ·  trade-retag: guard empty --query and log preimage only after 8dca38a7 →