[object Object]

← back to Gmc Titlefix

TK-11731: --apply fires the dry-run cohort snapshot with a canary_ts drift guard, not a fresh live-canary read

16e80784c6a453bdecab61132f76834dd4448e4f · 2026-09-22 14:51:06 -0700 · Steve

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

Files touched

Diff

commit 16e80784c6a453bdecab61132f76834dd4448e4f
Author: Steve <steve@designerwallcoverings.com>
Date:   Tue Sep 22 14:51:06 2026 -0700

    TK-11731: --apply fires the dry-run cohort snapshot with a canary_ts drift guard, not a fresh live-canary read
    
    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
    Claude-Session: https://claude.ai/code/session_01DP24DdLpG6PHXgmjr47TVb
---
 tk11731-gmc-exclude-unpublished.mjs | 346 ++++++++++++++++++++++++++++++++++++
 1 file changed, 346 insertions(+)

diff --git a/tk11731-gmc-exclude-unpublished.mjs b/tk11731-gmc-exclude-unpublished.mjs
new file mode 100644
index 0000000..7780c70
--- /dev/null
+++ b/tk11731-gmc-exclude-unpublished.mjs
@@ -0,0 +1,346 @@
+#!/usr/bin/env node
+/**
+ * TK-11731 — GATED interim exclude of the UNPUBLISHED cohort of live wrong GMC ads.
+ *
+ * PROBLEM (canary gmc-landing-handle-canary, TK-11444/TK-11650, run 2026-09-17T22:20:13Z):
+ *   serving_fail 831 = dead landing links whose offer is APPROVED (still serving). Of those,
+ *   class `unpublished` (handle exists in Shopify catalog but product NOT ACTIVE) = 141 APPROVED
+ *   + 109 DISAPPROVED = 250. The 141 approved ones are LIVE WRONG ADS: Google serves an ad whose
+ *   landing page is an unpublished (non-ACTIVE) Shopify product — customer clicks → dead page,
+ *   while the ad keeps serving. This script excludes exactly those 141.
+ *
+ * MECHANISM (DTD-reviewed TK-11773 precedent, verdict B unanimous): direct Merchant API
+ *   productInputs.delete on the owning dataSource. Chosen over Shopify publishableUnpublish
+ *   (TK-11559 shape) because all 141 cohort products are DRAFT/unpublished — they are NOT
+ *   published to any Shopify sales channel, so publishableUnpublish would be a NO-OP, and the
+ *   Shopify→Google channel push is stalled since 2026-08-26 anyway. productInputs.delete has no
+ *   linkage dependency (the LINKAGE TRAP that no-oped the 456-row link override).
+ *   Undo = productInputs.insert re-inserting the snapshotted productAttributes.
+ *
+ * SAFETY STRUCTURE (modeled on tk11559-gmc-exclude.mjs, hardened with TK-11773 red-team fixes):
+ *   1. ROLLBACK-MAP-FIRST: the complete per-offer prestate (full productAttributes + owning
+ *      dataSource + productStatus) is fetched and the rollback map + prestate JSONL are WRITTEN
+ *      TO DISK BEFORE any delete fires. No snapshot, no delete.
+ *   2. DRY-RUN BY DEFAULT: writes only its own data/ artifacts. Zero writes to Shopify/GMC.
+ *   3. FAIL-CLOSED STATUS GUARD: every offer's owning Shopify product status is resolved LIVE
+ *      (batched GraphQL, throws on throttle/null data — an unresolved status is never "safe").
+ *      DELETABLE default = {ARCHIVED} ONLY (per TK-11731 spec). DRAFT rows are admitted ONLY
+ *      with the explicit --include-drafts flag (NOTE: live pre-flight measured the 2026-09-17
+ *      cohort as 141/141 DRAFT, 0 ARCHIVED — so the operational run needs --include-drafts,
+ *      which is recorded in the rollback map). ACTIVE aborts the run. GONE/unknown blocked.
+ *   4. PER-OFFER PRESTATE SNAPSHOT: one JSONL line per offer with the FULL products.get body
+ *      captured immediately before its delete.
+ *   5. --apply requires --yes-i-am-steve. This is an external Google-feed write → HARD-GATED.
+ *      Do NOT run --apply autonomously. Steve fires after ticket approval.
+ *   6. NOT_MEASURED discipline (TK-11846): a clean 404 on products.get = offer already absent
+ *      (bucketed as already_gone_404). ANY other error = NOT MEASURED → skipped, never
+ *      treated as "safe to delete".
+ *   7. POST-DELETE VERIFY (best-effort): re-GET the processed product after delete; 404 =
+ *      verified_gone. Google notes processed-product propagation can take minutes, so a
+ *      non-404 right after delete is reported as delete_ineffective (investigate), NOT a
+ *      rollback. Final effect check = canary re-run.
+ *
+ * COHORT SOURCE: ~/.claude/skills/gmc-landing-handle-canary/data/dead-links.json
+ *   (full dead set; filter klass==='unpublished' && serving==='approved'). The DRY-RUN reads
+ *   this LIVE canary, derives the deduped keyset, and snapshots the exact keys + the canary's
+ *   `ts` into data/tk11731-run/tk11731-cohort-latest.json. The --apply run does NOT re-read the
+ *   live canary — it LOADS that dry-run snapshot and fires EXACTLY its keyset, guarded two ways:
+ *   it ABORTS (exit 6) if the snapshot is missing ("run a dry-run first to produce the reviewed
+ *   cohort snapshot"), and ABORTS (exit 7) if the snapshot's recorded canary_ts no longer equals
+ *   the live canary's current ts ("cohort drifted since the dry-run — re-run the dry-run and
+ *   review before firing"). `--keys <file>` overrides both, firing an explicit offer-keyset.
+ *   This is what makes the fired set drift-proof: the reviewed dry-run cohort is what fires,
+ *   never a silently-changed live set. A drift vs the expected 141 is reported loudly.
+ *
+ * USAGE:
+ *   node tk11731-gmc-exclude-unpublished.mjs                       # DRY-RUN: verify + snapshot + rollback map, no writes
+ *   node tk11731-gmc-exclude-unpublished.mjs --include-drafts      # DRY-RUN admitting the DRAFT cohort (still no GMC writes)
+ *   node tk11731-gmc-exclude-unpublished.mjs --keys <file>         # override the offer-keyset file (one offerId per line)
+ *   node tk11731-gmc-exclude-unpublished.mjs --apply --yes-i-am-steve --include-drafts  # GATED live exclude
+ *   node tk11731-gmc-exclude-unpublished.mjs --undo <prestate.jsonl> --apply --yes-i-am-steve  # re-insert from snapshot
+ *
+ * Cost: $0 (Shopify Admin API + Merchant API — no per-call charge). Paced: Shopify batches of
+ * 100 @ 400ms; Merchant writes @ 150ms.
+ */
+import fs from 'fs';
+import path from 'path';
+import crypto from 'crypto';
+import { fileURLToPath } from 'url';
+import pkg from './_auth.js';                 // { token, MERCHANT } — GMC service account
+import { getProduct, toV1Name } from './_mc-read-v1.js';
+
+const HOME = process.env.HOME;
+const HERE = path.dirname(fileURLToPath(import.meta.url));
+const RUN_DIR = path.join(HERE, 'data', 'tk11731-run');
+const CANARY_DEAD = HOME + '/.claude/skills/gmc-landing-handle-canary/data/dead-links.json';
+const EXPECTED_COHORT = 141;                  // brief: 141 approved+unpublished (canary 2026-09-17T22:20Z)
+
+const args = process.argv.slice(2);
+const APPLY = args.includes('--apply');
+const CONFIRMED = args.includes('--yes-i-am-steve');
+const INCLUDE_DRAFTS = args.includes('--include-drafts');
+const UNDO_PATH = args.includes('--undo') ? args[args.indexOf('--undo') + 1] : null;
+const KEYS_OVERRIDE = args.includes('--keys') ? args[args.indexOf('--keys') + 1] : null;
+const MODE = APPLY ? 'APPLY' : 'DRY-RUN';
+const sleep = ms => new Promise(r => setTimeout(r, ms));
+
+// ---- Fail-closed status allowlist ------------------------------------------------------------
+// Per TK-11731 brief: ONLY ARCHIVED is deletable by default. --include-drafts admits DRAFT
+// (the TK-11773 DTD-reviewed class this cohort actually is — recorded in the rollback map).
+// ACTIVE is NEVER deletable. GONE/UNRESOLVED/anything else is blocked (an unmeasured state is
+// never a delete target).
+const DELETABLE = new Set(INCLUDE_DRAFTS ? ['ARCHIVED', 'DRAFT'] : ['ARCHIVED']);
+
+// ---- Shopify Admin (fail-closed; throws on throttle / null data — TK-11773 lesson) -----------
+function env(k) { const m = fs.readFileSync(HOME + '/Projects/secrets-manager/.env', 'utf8').match(new RegExp('^' + k + '=(.*)$', 'm')); return m ? m[1].trim() : ''; }
+const SHOP = (() => { let s = env('SHOPIFY_STORE_DOMAIN') || env('SHOPIFY_STORE'); if (s && !s.includes('.')) s += '.myshopify.com'; return s; })();
+const SHOP_TOKEN = env('SHOPIFY_ADMIN_TOKEN');
+const SHOP_API = '2024-10';
+async function shopGql(query, variables) {
+  for (let a = 0; a < 5; a++) {
+    let r;
+    try {
+      r = await fetch(`https://${SHOP}/admin/api/${SHOP_API}/graphql.json`, {
+        method: 'POST', headers: { 'X-Shopify-Access-Token': SHOP_TOKEN, 'Content-Type': 'application/json' },
+        body: JSON.stringify({ query, variables }), signal: AbortSignal.timeout(30000)
+      });
+    } catch { await sleep(1500 * (a + 1)); continue; }
+    if (r.status === 429 || r.status >= 500) { await sleep(1500 * (a + 1)); continue; }
+    if (!r.ok) throw new Error(`Shopify GraphQL HTTP ${r.status}`);
+    const j = await r.json();
+    if (j.errors && !j.data) { await sleep(1500 * (a + 1)); continue; }   // throttled 200 → retry
+    if (!j.data) throw new Error('Shopify GraphQL returned no data');      // → ABORT, never "resolved"
+    return j;
+  }
+  throw new Error('Shopify GraphQL exhausted retries (throttle/5xx) — ABORTING fail-closed');
+}
+const VSTAT = `query($ids:[ID!]!){nodes(ids:$ids){__typename ... on ProductVariant{id product{id status handle}} ... on Product{id status handle}}}`;
+const variantIdOf = key => key.startsWith('shopify_US_') ? key.split('_')[3] : key;   // both canary key forms
+// Resolve keys → {status, handle}. NOT-found node = GONE (product deleted). A thrown shopGql
+// aborts the WHOLE run — no partial/silent skip (fail-closed).
+async function resolveStatus(keys) {
+  const map = {};
+  for (let i = 0; i < keys.length; i += 100) {
+    const batch = keys.slice(i, i + 100);
+    const j = await shopGql(VSTAT, { ids: batch.map(k => `gid://shopify/ProductVariant/${variantIdOf(k)}`) });
+    const nodes = j.data.nodes;
+    if (!Array.isArray(nodes) || nodes.length !== batch.length) {
+      throw new Error(`Shopify node count mismatch (${nodes?.length} != ${batch.length}) — ABORTING fail-closed`);
+    }
+    nodes.forEach((n, idx) => {
+      const k = batch[idx];
+      if (n && n.__typename === 'ProductVariant') map[k] = { status: n.product?.status || 'UNRESOLVED', handle: n.product?.handle || null };
+      else if (n && n.__typename === 'Product') map[k] = { status: n.status || 'UNRESOLVED', handle: n.handle || null };
+      else map[k] = { status: 'GONE', handle: null };
+    });
+    await sleep(400);
+  }
+  for (const k of keys) if (!map[k]) map[k] = { status: 'UNRESOLVED', handle: null };
+  return map;
+}
+
+// ---- Merchant Center -------------------------------------------------------------------------
+const mcH = () => pkg.token().then(t => ({ Authorization: 'Bearer ' + t }));
+async function mcFetch(url, opts = {}) {
+  const H = await mcH();
+  for (let a = 0; a < 6; a++) {
+    try {
+      const r = await fetch(url, { ...opts, headers: { ...H, ...(opts.headers || {}) }, signal: AbortSignal.timeout(60000) });
+      if (r.status === 429 || r.status >= 500) { await sleep(2000 * (a + 1)); continue; }
+      return r;
+    } catch { await sleep(2000 * (a + 1)); }
+  }
+  throw new Error('Merchant API exhausted retries: ' + url.slice(0, 120));
+}
+// products.get with NOT_MEASURED discipline: 404 = genuinely absent; anything else = UNKNOWN.
+async function prestate(offerId) {
+  const name = toV1Name('en~US~' + offerId);          // legacy online-feed name (verified live for both key forms)
+  try {
+    const p = await getProduct('en~US~' + offerId);
+    return { ok: true, offerId, name, ...p };
+  } catch (e) {
+    if (e.status === 404) return { ok: false, offerId, name, alreadyGone: true };
+    return { ok: false, offerId, name, notMeasured: true, err: String(e.message || e).slice(0, 200) };
+  }
+}
+const gmcIdSegment = offerId => `en~US~${encodeURIComponent(offerId)}`;
+
+// ---- UNDO (re-insert from prestate; fail-closed on the reverse path) --------------------------
+async function undo(snapPath) {
+  const lines = fs.readFileSync(snapPath, 'utf8').split('\n').filter(Boolean).map(l => JSON.parse(l));
+  console.log(`UNDO: re-insert candidates from ${snapPath}: ${lines.length}`);
+  const stat = await resolveStatus(lines.map(l => l.offerId));
+  const nowActive = lines.filter(l => stat[l.offerId]?.status === 'ACTIVE').map(l => l.offerId);
+  const reinsert = lines.filter(l => stat[l.offerId]?.status !== 'ACTIVE');
+  if (nowActive.length) console.log(`  SKIPPING ${nowActive.length} offers whose product is now ACTIVE (would resurrect a stale live offer):`, nowActive.slice(0, 10));
+  if (!APPLY || !CONFIRMED) { console.log(`DRY-RUN undo. Would re-insert ${reinsert.length}, skip ${nowActive.length} ACTIVE. To fire: add --apply --yes-i-am-steve`); return; }
+  const H = await mcH();
+  let ok = 0, fail = 0;
+  for (const rec of reinsert) {
+    if (!rec.dataSource || !rec.productAttributes) { fail++; continue; }
+    const url = `https://merchantapi.googleapis.com/products/v1/accounts/${pkg.MERCHANT}/productInputs:insert?dataSource=${encodeURIComponent(rec.dataSource)}`;
+    const body = { channel: 'ONLINE', offerId: rec.offerId, contentLanguage: rec.contentLanguage || 'en', feedLabel: rec.feedLabel || 'US', productAttributes: rec.productAttributes };
+    const r = await fetch(url, { method: 'POST', headers: { ...H, 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
+    if (r.ok) ok++; else { fail++; console.error('undo fail', rec.offerId, r.status, (await r.text()).slice(0, 120)); }
+    await sleep(150);
+  }
+  console.log(JSON.stringify({ reinserted: ok, failed: fail, skipped_now_active: nowActive.length }, null, 2));
+}
+
+// ---- Cohort selection -------------------------------------------------------------------------
+async function loadCohort() {
+  if (KEYS_OVERRIDE) {
+    const keys = fs.readFileSync(KEYS_OVERRIDE, 'utf8').split('\n').map(s => s.trim()).filter(l => l && !l.startsWith('#'));
+    return { keys, canaryTs: 'keys-override', source: KEYS_OVERRIDE, canaryFile: CANARY_DEAD };
+  }
+
+  // --apply with no explicit --keys: fire EXACTLY the dry-run's reviewed snapshot, NOT a fresh
+  // re-derivation from the live canary. This is what makes the fired set drift-proof (matching
+  // the header): the reviewed cohort is what fires, never a silently-changed live set.
+  if (APPLY) {
+    const snapPath = path.join(RUN_DIR, 'tk11731-cohort-latest.json');
+    if (!fs.existsSync(snapPath)) {
+      console.error(`REFUSED: no dry-run cohort snapshot at ${snapPath} — run a dry-run first to produce the reviewed cohort snapshot.`);
+      process.exit(6);
+    }
+    const snap = JSON.parse(fs.readFileSync(snapPath, 'utf8'));
+    const live = JSON.parse(fs.readFileSync(CANARY_DEAD, 'utf8'));
+    if (snap.canary_ts !== live.ts) {
+      console.error(`REFUSED: cohort drifted since the dry-run — snapshot canary_ts=${snap.canary_ts} but live canary ts=${live.ts}. Re-run the dry-run and review before firing.`);
+      process.exit(7);
+    }
+    return { keys: snap.keys, canaryTs: snap.canary_ts, source: snapPath, canaryFile: CANARY_DEAD, fromSnapshot: true };
+  }
+
+  // DRY-RUN: read the LIVE canary, derive + (later) snapshot the reviewed cohort.
+  const d = JSON.parse(fs.readFileSync(CANARY_DEAD, 'utf8'));
+  const cohort = d.dead.filter(x => x.klass === 'unpublished' && x.serving === 'approved');
+  const seen = new Set(); const keys = [];
+  for (const x of cohort) if (!seen.has(x.key)) { seen.add(x.key); keys.push(x.key); }   // dedupe by identity
+  return { keys, canaryTs: d.ts, meta: cohort[0] ? { sample: cohort[0] } : null, canaryFile: CANARY_DEAD };
+}
+
+(async () => {
+  if (UNDO_PATH) return undo(UNDO_PATH);
+
+  // Hard gate upfront (TK-11559 exit-2 semantics, checked BEFORE any side effect).
+  if (APPLY && !CONFIRMED) {
+    console.error('REFUSED: --apply needs --yes-i-am-steve (this is an external Google-feed write).');
+    process.exit(2);
+  }
+
+  fs.mkdirSync(RUN_DIR, { recursive: true });
+  const stamp = new Date().toISOString().replace(/[:.]/g, '-');
+
+  // (1) Cohort from canary serving_fail detail (unpublished ∩ approved).
+  const { keys, canaryTs, canaryFile } = await loadCohort();
+  console.log(`${MODE} | TK-11731 exclude-unpublished | ${keys.length} offers (expected ~${EXPECTED_COHORT}) | canary dead-links ts=${canaryTs}`);
+  if (keys.length !== EXPECTED_COHORT) {
+    console.log(`  NOTE: cohort size drifted vs brief (${keys.length} != ${EXPECTED_COHORT}) — offer churn between canary runs. The keyset snapshot pins exactly what this run targets.`);
+  }
+  const guardLabel = INCLUDE_DRAFTS ? 'ARCHIVED + DRAFT (--include-drafts)' : 'ARCHIVED ONLY';
+  console.log(`  status guard: ${guardLabel}${INCLUDE_DRAFTS ? '' : ' (per TK-11731 spec)'} | mechanism: productInputs.delete on owning dataSource`);
+
+  // (2) FAIL-CLOSED Shopify status resolve (always runs; throws abort the run).
+  console.log('Resolving live Shopify product status (fail-closed)...');
+  const stat = await resolveStatus(keys);
+  const byStatus = {};
+  keys.forEach(k => { const s = stat[k]?.status || 'UNRESOLVED'; byStatus[s] = (byStatus[s] || 0) + 1; });
+  console.log('Live Shopify status:', JSON.stringify(byStatus));
+
+  const active = keys.filter(k => stat[k]?.status === 'ACTIVE');
+  const unresolved = keys.filter(k => !['ACTIVE', 'ARCHIVED', 'DRAFT', 'GONE'].includes(stat[k]?.status));
+  const blocked = keys.filter(k => !DELETABLE.has(stat[k]?.status) && stat[k]?.status !== 'ACTIVE' && !unresolved.includes(k));   // e.g. DRAFT under ARCHIVED-only guard, or GONE
+  const targets = keys.filter(k => DELETABLE.has(stat[k]?.status));
+
+  if (unresolved.length) {
+    console.error(`\n*** ${unresolved.length} offers did NOT resolve to a known Shopify status — an UNMEASURED state is never a delete target. ABORTING fail-closed. ***`);
+    unresolved.slice(0, 20).forEach(k => console.error('   ', k, '->', stat[k]?.status));
+    process.exit(4);
+  }
+  if (active.length) {
+    console.error(`\n*** ${active.length} offers are on now-ACTIVE products — excluding those would kill LIVE ads on LIVE products: ***`);
+    active.slice(0, 20).forEach(k => console.error('   ', k, '->', stat[k]?.handle));
+    console.error('ABORTING fail-closed. Re-triage (these are NOT the unpublished cohort anymore).');
+    process.exit(3);
+  }
+
+  console.log(`Guard results: targets=${targets.length} blocked_by_guard=${blocked.length}${blocked.length ? ' [' + [...new Set(blocked.map(k => stat[k]?.status))].join(',') + ']' : ''}`);
+
+  // (3) PER-OFFER PRESTATE via products.get (NOT_MEASURED-aware) — also resolves owning dataSource.
+  console.log(`Fetching per-offer prestate (products.get) for ${targets.length} targets...`);
+  const prestates = [], notMeasured = [], alreadyGone = [];
+  for (const k of targets) {
+    const ps = await prestate(k);
+    if (ps.ok) prestates.push({ offerId: k, name: ps.name, dataSource: ps._v1?.dataSource || null, contentLanguage: 'en', feedLabel: 'US', productAttributes: ps._v1?.productAttributes || null, productStatus: ps._v1?.productStatus || null, shopifyStatus: stat[k]?.status, shopifyHandle: stat[k]?.handle, canary: { klass: 'unpublished', serving: 'approved', handle: null, title: ps.title || null } });
+    else if (ps.alreadyGone) alreadyGone.push(k);
+    else notMeasured.push({ offerId: k, err: ps.err });
+    await sleep(150);
+  }
+  if (notMeasured.length) {
+    console.error(`\n*** ${notMeasured.length} offers could NOT be measured in GMC (non-404 errors) — their state is UNKNOWN so they are NOT targets this run: ***`);
+    notMeasured.slice(0, 20).forEach(x => console.error('   ', x.offerId, '->', x.err));
+    console.error('ABORTING fail-closed (TK-11846 NOT_MEASURED discipline).');
+    process.exit(5);
+  }
+
+  // (4) ROLLBACK MAP FIRST — complete index + per-offer prestate JSONL, written BEFORE any write.
+  const prestatePath = path.join(RUN_DIR, `tk11731-prestate-${stamp}.jsonl`);
+  const rollbackPath = path.join(RUN_DIR, 'tk11731-rollback-map.json');
+  const cohortPath = path.join(RUN_DIR, 'tk11731-cohort-latest.json');
+  const rollbackMap = {
+    ts: new Date().toISOString(), ticket: 'TK-11731',
+    mode: MODE, guard: guardLabel, mechanism: 'Merchant API products/v1 productInputs.delete',
+    cohort: { source: canaryFile || KEYS_OVERRIDE, canary_ts: canaryTs, klass: 'unpublished', serving: 'approved', expected: EXPECTED_COHORT, actual: keys.length },
+    shopify_status_summary: byStatus,
+    undo: `node ~/Projects/gmc-titlefix/tk11731-gmc-exclude-unpublished.mjs --undo ${prestatePath} --apply --yes-i-am-steve`,
+    prestate_path: prestatePath,
+    targets: prestates.map(p => ({ offerId: p.offerId, name: p.name, dataSource: p.dataSource, shopifyStatus: p.shopifyStatus, shopifyHandle: p.shopifyHandle, undo: 'productInputs.insert (channel ONLINE, en/US, snapshotted productAttributes)' })),
+  };
+  fs.writeFileSync(prestatePath, prestates.map(p => JSON.stringify(p)).join('\n') + (prestates.length ? '\n' : ''));
+  fs.writeFileSync(rollbackPath, JSON.stringify(rollbackMap, null, 2));
+  fs.writeFileSync(cohortPath, JSON.stringify({ ts: new Date().toISOString(), canary_ts: canaryTs, keys, note: 'snapshot of the TK-11731 unpublished+approved cohort used by this run' }, null, 2));
+  console.log(`cohort snapshot  -> ${cohortPath} (${keys.length} keys)`);
+  console.log(`prestate (proof) -> ${prestatePath} (${prestates.length} full GMC bodies)`);
+  console.log(`ROLLBACK MAP     -> ${rollbackPath} (${rollbackMap.targets.length} entries) [rollback-map-first: written before any write]`);
+
+  // (5) DRY-RUN: stop here. APPLY: re-verify each offer still matches prestate, then delete + post-verify.
+  if (!APPLY) {
+    console.log(`\nDRY-RUN CHECK: would delete ${prestates.length} productInputs (already_gone_404=${alreadyGone.length} would be skipped at fire time; not_measured=${notMeasured.length} would abort).`);
+    console.log('Sample:', prestates.slice(0, 5).map(p => `${p.offerId} [${p.shopifyStatus}] ${p.shopifyHandle || ''} ds=${(p.dataSource || '').split('/').pop()}`));
+    console.log('\nNOTHING written to Shopify or Google. To fire (GATED, Steve only):');
+    console.log(`  node ~/Projects/gmc-titlefix/tk11731-gmc-exclude-unpublished.mjs --apply --yes-i-am-steve${INCLUDE_DRAFTS ? ' --include-drafts' : ''}`);
+    console.log(`UNDO (if ever needed): ${rollbackMap.undo}`);
+    return;
+  }
+
+  // ---- GATED FIRE ---------------------------------------------------------------------------
+  const H = await mcH();
+  const ledgerPath = path.join(RUN_DIR, `tk11731-apply-ledger-${stamp}.jsonl`);
+  let deleted = 0, verified_gone = 0, delete_ineffective = 0, failed = 0;
+  const fails = [];
+  for (const p of prestates) {
+    const seg = gmcIdSegment(p.offerId);
+    const ds = p.dataSource;
+    if (!ds) { failed++; fails.push({ offerId: p.offerId, err: 'no owning dataSource in prestate' }); continue; }
+    try {
+      const del = await fetch(`https://merchantapi.googleapis.com/products/v1/accounts/${pkg.MERCHANT}/productInputs/${seg}?dataSource=${encodeURIComponent(ds)}`, { method: 'DELETE', headers: H });
+      let verified = null;
+      if (del.ok) {
+        deleted++;
+        await sleep(150);
+        const chk = await fetch(`https://merchantapi.googleapis.com/products/v1/accounts/${pkg.MERCHANT}/products/${seg}`, { headers: H });
+        if (chk.status === 404) verified_gone++;
+        else { delete_ineffective++; verified = 'still-present-' + chk.status; }
+      } else {
+        failed++; fails.push({ offerId: p.offerId, status: del.status, body: (await del.text()).slice(0, 140) });
+      }
+      fs.appendFileSync(ledgerPath, JSON.stringify({ ts: new Date().toISOString(), offerId: p.offerId, dataSource: ds, httpStatus: del.status, verified, undo: 'productInputs.insert from ' + prestatePath }) + '\n');
+    } catch (e) { failed++; fails.push({ offerId: p.offerId, err: String(e.message || e).slice(0, 140) }); }
+    await sleep(150);
+  }
+  console.log(JSON.stringify({ targets: prestates.length, deleted, verified_gone, delete_ineffective, already_gone_404_skipped: alreadyGone.length, failed, ledger: ledgerPath, fails: fails.slice(0, 20) }, null, 2));
+  console.log(`\nEFFECT CHECK: verified_gone=${verified_gone}/${deleted}. delete_ineffective>0 = the input survived on another dataSource — investigate before re-firing.`);
+  console.log(`POST-FIRE: re-run gmc-landing-handle-canary — unpublished approved (serving_fail) should drop 141 → ~0 (churn aside).`);
+  console.log(`UNDO: ${rollbackMap.undo}`);
+})().catch(e => { console.error('FATAL', e.stack || e.message); process.exit(1); });
\ No newline at end of file

← 10a3116 TK-11898 Option B EXECUTED (Steve-approved): 30/30 dead-vid  ·  back to Gmc Titlefix  ·  TK-11847 09-23 re-measure (7/29 still LR, parity 28/1 unchan fa224f8 →