← 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
A shopify/scripts/trade-approved-retag.js
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 →