[object Object]

← back to Dw Rotation Activator

Restore draft readiness checks and verify live activation canary

c88ff695999da048cf903780bf9d77baec6fe8fb · 2026-09-09 10:20:13 -0700 · Steve Abrams

Files touched

Diff

commit c88ff695999da048cf903780bf9d77baec6fe8fb
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Wed Sep 9 10:20:13 2026 -0700

    Restore draft readiness checks and verify live activation canary
---
 .gitignore                                 |   3 +
 lib/activation-result.js                   |   8 +++
 lib/live-product-fields.js                 |  61 +++++++++++++++++
 lib/readiness-repairs.js                   |  51 ++++++++++++++
 lib/settlement-gate.js                     |  59 ++++++++++------
 lib/vision-http.js                         |  53 +++++++++++++++
 rotate-activate.js                         |  74 +++++++++++---------
 test/activation-result.test.js             |  15 +++++
 test/live-product-fields.test.js           |  55 +++++++++++++++
 test/readiness-repairs.test.js             |  21 ++++++
 test/vision-http.test.js                   |  69 +++++++++++++++++++
 verification/e2e-proof-before-TK11314.json |  45 +++++++++++++
 verification/e2e-proof.json                | 104 +++++++++++++++++++++--------
 13 files changed, 535 insertions(+), 83 deletions(-)

diff --git a/.gitignore b/.gitignore
index a9645f7..851699e 100644
--- a/.gitignore
+++ b/.gitignore
@@ -7,3 +7,6 @@ dist/
 build/
 out/*.jsonl
 out/activation-ledger-*.json
+out/current-readiness-repairs.json*
+verification/live-products*.json
+verification/readiness-repairs*.json
diff --git a/lib/activation-result.js b/lib/activation-result.js
new file mode 100644
index 0000000..d8c93d3
--- /dev/null
+++ b/lib/activation-result.js
@@ -0,0 +1,8 @@
+'use strict';
+function confirmedActivation(response, productId) {
+  const update = response?.json?.data?.productUpdate;
+  return response?.status === 200 && !response.json.errors?.length &&
+    Array.isArray(update?.userErrors) && update.userErrors.length === 0 &&
+    update.product?.id === productId && update.product.status === 'ACTIVE';
+}
+module.exports = { confirmedActivation };
diff --git a/lib/live-product-fields.js b/lib/live-product-fields.js
new file mode 100644
index 0000000..9c9ef60
--- /dev/null
+++ b/lib/live-product-fields.js
@@ -0,0 +1,61 @@
+'use strict';
+
+// Explicit catalog namespaces only. Never infer a width from a title, price,
+// description, or an unrelated shipping/package dimension.
+const WIDTH_NAMESPACES = ['global', 'custom', 'dwc', 'specs', 'carnegie'];
+function field(nodes, namespace, key) {
+  return (nodes || []).find(m => m.namespace === namespace && m.key === key &&
+    typeof m.value === 'string' && m.value.trim())?.value.trim() || '';
+}
+function widthFromLive(product) {
+  const nodes = product.metafields?.nodes || [];
+  for (const namespace of WIDTH_NAMESPACES) {
+    if (namespace === 'carnegie' && String(product.vendor).toLowerCase() !== 'carnegie') continue;
+    const value = field(nodes, namespace, 'width');
+    if (value) return { value, source: `${namespace}.width` };
+  }
+  return { value: '', source: null };
+}
+function canonicalProduct(product, dwSku, vendor) {
+  const nodes = product.metafields?.nodes || [];
+  const get = (namespace, key) => field(nodes, namespace, key);
+  const images = (product.images?.nodes || []).map(image => image.url);
+  const specs = {
+    width: widthFromLive(product).value,
+    length: get('global', 'length'), repeat: get('global', 'repeat'),
+    material: get('global', 'material') || get('custom', 'material') || get('specs', 'material'),
+    unitOfMeasure: get('global', 'unit_of_measure'),
+  };
+  return { title: product.title, dwSku: dwSku || '', vendor, tags: product.tags || [],
+    descriptionHtml: product.descriptionHtml || '', specs, vendorSpecs: specs,
+    images, vendorImages: images,
+    variants: (product.variants?.nodes || []).map(v => ({ sku: v.sku, title: v.title })),
+  };
+}
+async function completeLiveProduct(product, query) {
+  const result = { ...product };
+  for (const connection of ['variants', 'metafields']) {
+    let page = result[connection];
+    const nodes = [...(page?.nodes || [])];
+    const cursors = new Set();
+    while (page?.pageInfo?.hasNextPage) {
+      const cursor = page.pageInfo.endCursor;
+      if (!cursor || cursors.has(cursor) || cursors.size >= 100)
+        throw new Error(`Cannot safely paginate ${connection}`);
+      cursors.add(cursor);
+      const fields = connection === 'variants' ? 'price sku title' : 'namespace key value';
+      const response = await query(`query($id:ID!,$cursor:String!){product(id:$id){id
+        ${connection}(first:100,after:$cursor){nodes{${fields}} pageInfo{hasNextPage endCursor}}
+      }}`, { id: product.id, cursor });
+      const next = response.json?.data?.product;
+      if (response.status !== 200 || response.json?.errors?.length || next?.id !== product.id ||
+          !Array.isArray(next[connection]?.nodes) || typeof next[connection]?.pageInfo?.hasNextPage !== 'boolean')
+        throw new Error(`Incomplete Shopify ${connection} response`);
+      page = next[connection];
+      nodes.push(...page.nodes);
+    }
+    result[connection] = { ...page, nodes };
+  }
+  return result;
+}
+module.exports = { WIDTH_NAMESPACES, widthFromLive, canonicalProduct, completeLiveProduct };
diff --git a/lib/readiness-repairs.js b/lib/readiness-repairs.js
new file mode 100644
index 0000000..c8cf311
--- /dev/null
+++ b/lib/readiness-repairs.js
@@ -0,0 +1,51 @@
+'use strict';
+function repairType(reason) {
+  if (/missing spec.*width/.test(reason)) return 'restore-width-metafield';
+  if (/mfr-sku/.test(reason)) return 'verify-manufacturer-code';
+  if (/description/.test(reason)) return 'repair-description';
+  if (/sample.variant/.test(reason)) return 'restore-sample-variant';
+  if (/price|sellable/.test(reason)) return 'verify-sellable-price';
+  if (/no product image/.test(reason)) return 'restore-product-image';
+  if (/private-label-leak/.test(reason)) return 'correct-private-label';
+  if (/banned word/.test(reason)) return 'correct-title';
+  return 'review-readiness';
+}
+function buildRepairReport(decisions, { generatedAt = new Date().toISOString(), complete = true } = {}) {
+  const latest = new Map();
+  for (const row of decisions) {
+    if (!row.shopify_id) continue;
+    const previous = latest.get(row.shopify_id);
+    if (!previous || String(row.ts) >= String(previous.ts)) latest.set(row.shopify_id, row);
+  }
+  const items = [];
+  const counts = {};
+  for (const row of latest.values()) {
+    if (!['skip-gate', 'skip-read-error', 'activate-error', 'settlement-hold', 'settlement-block'].includes(row.action)) continue;
+    const groups = new Map();
+    for (const reason of row.reasons || []) {
+      const type = repairType(reason);
+      if (!groups.has(type)) groups.set(type, []);
+      groups.get(type).push(reason);
+    }
+    if (row.action === 'settlement-hold') groups.set('retry-image-review', [row.settlement?.reason || 'unknown']);
+    if (row.action === 'settlement-block') groups.set('settlement-block-review', [row.settlement?.reason || 'unknown']);
+    if (row.action === 'activate-error') groups.set('verify-activation-result', ['Shopify did not confirm ACTIVE']);
+    for (const [type, reasons] of groups) {
+      counts[type] = (counts[type] || 0) + 1;
+      items.push({ id: `${row.shopify_id}:${type}`, shopify_id: row.shopify_id,
+        dw_sku: row.dw_sku, vendor: row.vendor, title: row.title,
+        observed_at: row.ts, created_at: row.ts, type, reasons: [...new Set(reasons)],
+        source: 'rotation-live-check', width_source: row.width_source || null,
+        diagnostics: row.settlement?.diagnostics || null,
+        requires: type === 'retry-image-review' ? 'provider-recovery-and-fresh-review' : 'verified-source-and-live-recheck',
+        apply_authorized: false,
+      });
+    }
+  }
+  return { schema_version: 1, generated_at: generatedAt, scan_complete: complete,
+    scope: 'Products observed in this activation run; not the entire draft catalog',
+    mode: 'review-only', products_observed: latest.size,
+    products_needing_attention: new Set(items.map(item => item.shopify_id)).size,
+    counts, items };
+}
+module.exports = { buildRepairReport, repairType };
diff --git a/lib/settlement-gate.js b/lib/settlement-gate.js
index dbc2d11..03702b0 100644
--- a/lib/settlement-gate.js
+++ b/lib/settlement-gate.js
@@ -43,6 +43,7 @@ const os = require('os');
 const path = require('path');
 const https = require('https');
 const { execFileSync } = require('child_process');
+const { requestVision, errorDetails, cooldownMs } = require('./vision-http.js');
 
 // ── texture material lexicon (auto-pass tier) ────────────────────────────────
 // Prompt-specified plain-texture set. A product is a candidate auto-pass only if
@@ -114,12 +115,20 @@ class SettlementGate {
     this.visionCalls = 0;
     this.visionCostTotal = 0;
     this.autoPassCount = 0;
+    this.visionRetryAt = 0;
+    this.lastVisionError = null;
+    this.visionHttpAttempts = 0;
+    this.visionThrottledSkips = 0;
   }
 
   lockStatus() { return { ok: this.lockOk, msg: this.lockMsg }; }
   stats() {
     return { visionCalls: this.visionCalls, visionCostTotal: this.visionCostTotal,
-      autoPassCount: this.autoPassCount };
+      autoPassCount: this.autoPassCount, visionHttpAttempts: this.visionHttpAttempts,
+      visionThrottledSkips: this.visionThrottledSkips,
+      visionKeysConfigured: this.geminiKeys?.length || 0,
+      retryAt: this.visionRetryAt ? new Date(this.visionRetryAt).toISOString() : null,
+      lastVisionError: this.lastVisionError };
   }
 
   // Cheap auto-pass test: solid texture material + NO motif keyword in title.
@@ -151,17 +160,26 @@ class SettlementGate {
   // 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) {
+    if (Date.now() < this.visionRetryAt) {
+      this.visionThrottledSkips++;
+      return { ok: false, why: 'vision-rate-limit-cooldown', details: this.lastVisionError };
+    }
     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) {
+      this.visionHttpAttempts++;
       const r = await this._visionCall(b64, mime, key);
-      if (r.ok) return r;
+      if (r.ok) { this.visionRetryAt = 0; this.lastVisionError = null; 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;
     }
+    if (last.why === 'vision-http-429') {
+      this.lastVisionError = last.details || { httpStatus: 429 };
+      this.visionRetryAt = Date.now() + cooldownMs(this.lastVisionError);
+    }
     return last;
   }
 
@@ -185,30 +203,20 @@ class SettlementGate {
       ] }],
       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}` };
+    const res = await requestVision(body, key);
+    if (res.status !== 200) return { ok: false, why: `vision-http-${res.status}`,
+      details: errorDetails(res, this.geminiKeys || [key]) };
     let j;
-    try { j = JSON.parse(res.body); } catch { return { ok: false, why: 'vision-parse' }; }
+    try { j = JSON.parse(res.body); } catch { return { ok: false, why: 'vision-parse', billable: true }; }
     let obj;
     try { obj = JSON.parse(j.candidates?.[0]?.content?.parts?.[0]?.text || '{}'); }
-    catch { return { ok: false, why: 'verdict-parse' }; }
+    catch { return { ok: false, why: 'verdict-parse', billable: true }; }
     // 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 };
+    if (!keys.every((k) => typeof obj[k] === 'boolean')) return { ok: false, why: 'incomplete-verdict', obj, billable: true };
     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 };
+    return { ok: true, partA, partB: obj.partB_element, acceptable: obj.acceptable_element, raw: obj, billable: true };
   }
 
   // Apply the BINDING verdict rule to the vision booleans.
@@ -246,6 +254,13 @@ class SettlementGate {
       return rec;
     }
 
+    // A provider outage is an operational hold, never a legal BLOCK or PASS.
+    // Stop retrying every draft (and downloading every image) until cooldown.
+    if (Date.now() < this.visionRetryAt) {
+      this.visionThrottledSkips++;
+      return { verdict: 'HELD', tier: 'vision', reason: 'vision-rate-limit-cooldown',
+        diagnostics: this.lastVisionError, retryAt: new Date(this.visionRetryAt).toISOString(), cost: 0 };
+    }
     const b64 = await this.fetchImageB64(imageUrl);
     if (!b64) {
       // No image to check → cannot clear settlement → HELD (fail-closed). (The
@@ -258,7 +273,7 @@ class SettlementGate {
     const mime = /\.png(\?|$)/i.test(imageUrl || '') ? 'image/png' : 'image/jpeg';
     const v = await this.visionDetect(b64, mime);
     this.visionCalls++;
-    const cost = logVisionCost();
+    const cost = (v.ok || v.billable) ? logVisionCost() : 0;
     this.visionCostTotal += cost;
 
     if (!v.ok) {
@@ -266,7 +281,9 @@ class SettlementGate {
       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 };
+      return { verdict: 'HELD', tier: 'vision', reason: v.why || 'vision-error',
+        diagnostics: v.details || null,
+        retryAt: this.visionRetryAt ? new Date(this.visionRetryAt).toISOString() : null, cost };
     }
 
     const dec = this.verdictFromVision(v);
diff --git a/lib/vision-http.js b/lib/vision-http.js
new file mode 100644
index 0000000..8b25361
--- /dev/null
+++ b/lib/vision-http.js
@@ -0,0 +1,53 @@
+'use strict';
+const https = require('node:https');
+
+function redact(value, secrets = []) {
+  let text = String(value || '');
+  for (const secret of secrets.filter(Boolean)) text = text.split(secret).join('[REDACTED]');
+  return text.replace(/AIza[\w-]{20,}/g, '[REDACTED]')
+    .replace(/Help Token:\s*\S+/gi, 'Help Token: [REDACTED]')
+    .replace(/([?&]key=)[^&\s"']+/gi, '$1[REDACTED]').slice(0, 800);
+}
+function errorDetails(response, secrets = []) {
+  let error = {};
+  try { error = JSON.parse(response.body || '{}').error || {}; } catch {}
+  const details = Array.isArray(error.details) ? error.details : [];
+  const retry = details.find(d => /RetryInfo$/.test(d['@type'] || ''))?.retryDelay;
+  const retrySeconds = typeof retry === 'string' && /^\d+(?:\.\d+)?s$/.test(retry)
+    ? Number(retry.slice(0, -1)) : 0;
+  const header = response.headers?.['retry-after'];
+  const headerMs = /^\d+(?:\.\d+)?$/.test(String(header || '')) ? Number(header) * 1000 : 0;
+  const quota = details.filter(d => /QuotaFailure$/.test(d['@type'] || ''))
+    .flatMap(d => Array.isArray(d.violations) ? d.violations : [])
+    .map(v => ({ metric: redact(v.quotaMetric, secrets), id: redact(v.quotaId, secrets),
+      value: redact(v.quotaValue, secrets) }));
+  return { httpStatus: response.status, status: redact(error.status, secrets),
+    message: redact(error.message || response.err || '', secrets), quota,
+    retryAfterMs: Math.min(86400000, Math.max(0, retrySeconds * 1000, headerMs)) };
+}
+function cooldownMs(error) {
+  const dailyOrSpend = /perday|per.day|monthly|spend|billing|budget/i.test(
+    `${error.message || ''} ${JSON.stringify(error.quota || [])}`);
+  return Math.max(dailyOrSpend ? 3600000 : 60000, error.retryAfterMs || 0);
+}
+function requestVision(body, key, { timeoutMs = 30000, request = https.request } = {}) {
+  const data = JSON.stringify(body);
+  return new Promise(resolve => {
+    let finished = false;
+    const finish = result => { if (!finished) { finished = true; resolve(result); } };
+    const req = request({ host: 'generativelanguage.googleapis.com',
+      path: '/v1beta/models/gemini-2.5-flash:generateContent', method: 'POST',
+      headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data),
+        'x-goog-api-key': key },
+    }, response => {
+      let body = '';
+      response.on('data', chunk => { body += chunk; });
+      response.on('error', error => finish({ status: 0, err: error.message }));
+      response.on('end', () => finish({ status: response.statusCode, body, headers: response.headers }));
+    });
+    req.setTimeout(timeoutMs, () => req.destroy(new Error('vision-request-timeout')));
+    req.on('error', error => finish({ status: 0, err: error.message }));
+    req.end(data);
+  });
+}
+module.exports = { redact, errorDetails, cooldownMs, requestVision };
diff --git a/rotate-activate.js b/rotate-activate.js
index c19eea9..9c3db9b 100644
--- a/rotate-activate.js
+++ b/rotate-activate.js
@@ -45,6 +45,9 @@ const { ROTATION_ORDER_SQL } = require('./lib/rotation-order.js');
 const { SettlementGate } = require('./lib/settlement-gate.js');
 const { fiveFieldExtra } = require('./lib/five-field-extra.js');
 const { mfrGate } = require('./lib/mfr-gate.js');
+const { canonicalProduct, widthFromLive, completeLiveProduct } = require('./lib/live-product-fields.js');
+const { buildRepairReport } = require('./lib/readiness-repairs.js');
+const { confirmedActivation } = require('./lib/activation-result.js');
 const { reusedMfrSet, stagingColorFor } = require('./lib/mfr-gate-resolve.js');
 // Canonical showroom-vendor primitive (list + logic live in fix-live-board/config). Showroom-only
 // vendors are addressable-not-discoverable: never rotate them into the New Arrivals discoverability
@@ -66,6 +69,12 @@ 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`);
+const REPAIR_REPORT = path.join(OUTDIR, 'current-readiness-repairs.json');
+const runDecisions = [];
+function recordDecision(record) {
+  fs.appendFileSync(AUDIT, JSON.stringify(record) + '\n');
+  runDecisions.push(record);
+}
 // 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`);
@@ -159,8 +168,8 @@ function loadQueue() {
 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}}
+  variants(first:20){nodes{price sku title} pageInfo{hasNextPage endCursor}}
+  metafields(first:80){nodes{namespace key value} pageInfo{hasNextPage endCursor}}
 }}}`;
 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}}}`;
@@ -196,25 +205,7 @@ function pidToGid(shopify_id) {
 // 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 })),
-  });
+  return validateBeforeActivate(canonicalProduct(n, dwSku, vendor));
 }
 
 // Extra "5-field" invariants the prompt calls out explicitly, layered on top of
@@ -305,9 +296,12 @@ function leakGuard(title, vendor) {
     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 (r.status !== 200 || r.json?.errors?.length || !Array.isArray(r.json?.data?.nodes))
+      throw new Error('Shopify product batch read failed; activation stopped');
+    for (const initialNode of r.json.data.nodes) {
       if (activated >= cap) break;
-      if (!n) continue;
+      if (!initialNode) continue;
+      let n = initialNode;
       const q = byGid.get(n.id);
       scanned++;
       if (n.status === 'ACTIVE') { alreadyActive++; continue; }
@@ -318,10 +312,17 @@ function leakGuard(title, vendor) {
       // a showroom line must never receive. Leave the product DRAFT for its own showroom flow. TK-11186.
       if (isShowroomVendor(q && q.vendor)) {
         skipped++;
-        fs.appendFileSync(AUDIT, JSON.stringify({ ts: new Date().toISOString(), shopify_id: n.id, vendor: q && q.vendor, dw_sku: q && q.dw_sku, action: 'skip-showroom-vendor' }) + '\n');
+        recordDecision({ ts: new Date().toISOString(), shopify_id: n.id, vendor: q && q.vendor, dw_sku: q && q.dw_sku, action: 'skip-showroom-vendor' });
         continue;
       }
 
+      try { n = await completeLiveProduct(n, gqlRetry); }
+      catch (error) {
+        skipped++;
+        recordDecision({ ts: new Date().toISOString(), shopify_id: n.id, vendor: q?.vendor,
+          dw_sku: q?.dw_sku, action: 'skip-read-error', reasons: [error.message] });
+        continue;
+      }
       const gate = gateFromLive(n, q && q.dw_sku, q && q.vendor);
       const extra = fiveFieldExtra(n);
       const leak = leakGuard(n.title, q && q.vendor);
@@ -338,7 +339,7 @@ function leakGuard(title, vendor) {
       if (!mfr.ok) mfrBlocked++;
       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, mfr_code: mfrCode,
+        title: n.title, passes, mfr_code: mfrCode, width_source: widthFromLive(n).source,
         reasons: [...(gate.ok ? [] : gate.reasons), ...(extra.ok ? [] : extra.reasons),
                   ...(leak.ok ? [] : [leak.reason]), ...(mfr.ok ? [] : mfr.reasons)] };
 
@@ -351,7 +352,7 @@ function leakGuard(title, vendor) {
         if (!DRY && !mfr.ok && gate.ok && extra.ok && leak.ok) {
           try { await gqlRetry(TAGS_ADD, { id: n.id, tags: ['Needs-MfrSKU'] }); } catch (_) {}
         }
-        fs.appendFileSync(AUDIT, JSON.stringify({ ...rec, action: 'skip-gate' }) + '\n');
+        recordDecision({ ...rec, action: 'skip-gate' });
         continue;
       }
 
@@ -363,31 +364,33 @@ function leakGuard(title, vendor) {
         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 };
+      rec.settlement = { verdict: sv.verdict, tier: sv.tier, reason: sv.reason, cost: sv.cost,
+        diagnostics: sv.diagnostics || null, retryAt: sv.retryAt || null };
       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');
+        recordDecision({ ...rec,
+          action: sv.verdict === 'BLOCK' ? 'settlement-block' : 'settlement-hold' });
         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');
+        recordDecision({ ...rec, action: 'dryrun-would-activate' });
         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) {
+      if (!confirmedActivation(ar, n.id)) {
         skipped++;
-        fs.appendFileSync(AUDIT, JSON.stringify({ ...rec, action: 'activate-error', errors: aue }) + '\n');
+        recordDecision({ ...rec, action: 'activate-error',
+          errors: aue?.length ? aue : (ar.json?.errors || ['Shopify did not confirm ACTIVE']), httpStatus: ar.status });
         continue;
       }
       activated++;
@@ -400,7 +403,7 @@ function leakGuard(title, vendor) {
         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');
+      recordDecision({ ...rec, action: 'activated', published: pub, tag: 'New Arrival' });
       if (activated % 25 === 0) process.stdout.write(`\r  activated ${activated}/${cap}…`);
       await sleep(350);
     }
@@ -408,10 +411,15 @@ function leakGuard(title, vendor) {
   }
 
   const sstat = settlement.stats();
+  const report = buildRepairReport(runDecisions, { complete: scanned >= queue.length });
+  fs.writeFileSync(REPAIR_REPORT + '.tmp', JSON.stringify(report, null, 2) + '\n');
+  fs.renameSync(REPAIR_REPORT + '.tmp', REPAIR_REPORT);
+  console.log(`current readiness repairs: ${report.products_needing_attention} products; report=${REPAIR_REPORT}`);
   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(`mfr-gate: blocked ${mfrBlocked} product(s) for blank/reused/fabricated mfr provenance`);
   console.log(`settlement: auto-pass-textures=${sstat.autoPassCount}  vision-calls=${sstat.visionCalls}  blocked=${settlementBlocked}  held=${settlementHeld}`);
+  console.log(`settlement service: configured-keys=${sstat.visionKeysConfigured} attempts=${sstat.visionHttpAttempts} cooldown-skips=${sstat.visionThrottledSkips} retryAt=${sstat.retryAt || 'none'} lastError=${JSON.stringify(sstat.lastVisionError)}`);
   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/test/activation-result.test.js b/test/activation-result.test.js
new file mode 100644
index 0000000..e2a00a1
--- /dev/null
+++ b/test/activation-result.test.js
@@ -0,0 +1,15 @@
+'use strict';
+const test = require('node:test');
+const assert = require('node:assert/strict');
+const { confirmedActivation } = require('../lib/activation-result');
+const ok = { status: 200, json: { data: { productUpdate: { product: { id: 'gid://shopify/Product/1', status: 'ACTIVE' }, userErrors: [] } } } };
+test('only a confirmed ACTIVE result for the requested product counts', () => {
+  assert.equal(confirmedActivation(ok, 'gid://shopify/Product/1'), true);
+  assert.equal(confirmedActivation(ok, 'gid://shopify/Product/2'), false);
+});
+test('HTTP, GraphQL, user errors and incomplete replies never count as activations', () => {
+  for (const response of [{ status: 429 }, { status: 200, json: { errors: [{ message: 'throttled' }] } },
+    { status: 200, json: {} }, { ...ok, status: 500 },
+    { status: 200, json: { data: { productUpdate: { userErrors: [{ message: 'blocked' }] } } } }])
+    assert.equal(confirmedActivation(response, 'gid://shopify/Product/1'), false);
+});
diff --git a/test/live-product-fields.test.js b/test/live-product-fields.test.js
new file mode 100644
index 0000000..ce69c8d
--- /dev/null
+++ b/test/live-product-fields.test.js
@@ -0,0 +1,55 @@
+'use strict';
+const test = require('node:test');
+const assert = require('node:assert/strict');
+const { widthFromLive, canonicalProduct, completeLiveProduct } = require('../lib/live-product-fields');
+const node = (fields, vendor = 'Carnegie') => ({ vendor, metafields: { nodes: fields } });
+test('verified Carnegie width is recognized with provenance', () => {
+  assert.deepEqual(widthFromLive(node([{ namespace: 'dwc', key: 'width', value: '54" (137 cm)' }])),
+    { value: '54" (137 cm)', source: 'dwc.width' });
+});
+test('canonical namespace order stays stable and blanks fall through', () => {
+  assert.deepEqual(widthFromLive(node([
+    { namespace: 'dwc', key: 'width', value: '54' },
+    { namespace: 'global', key: 'width', value: '  ' },
+    { namespace: 'custom', key: 'width', value: ' 52 cm ' },
+  ])), { value: '52 cm', source: 'custom.width' });
+});
+test('package dimensions, other vendors, and descriptions cannot invent width', () => {
+  const p = node([{ namespace: 'custom', key: 'package_width', value: '4 in' },
+    { namespace: 'untrusted', key: 'width', value: '54' },
+    { namespace: 'carnegie', key: 'width', value: '54' }], 'Other');
+  p.descriptionHtml = '<p>Width: 54 Inches</p>';
+  assert.deepEqual(widthFromLive(p), { value: '', source: null });
+});
+test('canonical adapter preserves sample title and required fields', () => {
+  const p = { ...node([{ namespace: 'specs', key: 'width', value: '54 inches' }]),
+    images: { nodes: [{ url: 'https://example.test/swatch.jpg' }] },
+    variants: { nodes: [{ sku: 'X', title: 'Sample' }] } };
+  const converted = canonicalProduct(p, 'DW-X', 'Carnegie');
+  assert.equal(converted.specs.width, '54 inches');
+  assert.equal(converted.variants[0].title, 'Sample');
+  assert.equal(converted.dwSku, 'DW-X');
+});
+test('sample and width beyond the first pages are fetched before validation', async () => {
+  const p = { id: 'gid://shopify/Product/1', vendor: 'Carnegie',
+    variants: { nodes: [{ sku: 'DW-1', price: '10' }], pageInfo: { hasNextPage: true, endCursor: 'v1' } },
+    metafields: { nodes: [], pageInfo: { hasNextPage: true, endCursor: 'm1' } } };
+  const calls = [];
+  const fetched = await completeLiveProduct(p, async (query, variables) => {
+    calls.push(variables.cursor);
+    const connection = query.includes('variants(') ? 'variants' : 'metafields';
+    return { status: 200, json: { data: { product: { id: p.id,
+      [connection]: { nodes: connection === 'variants' ? [{ sku: 'DW-1-Sample', price: '4.25' }]
+        : [{ namespace: 'dwc', key: 'width', value: '54 inches' }], pageInfo: { hasNextPage: false } },
+    } } } };
+  });
+  assert.deepEqual(calls, ['v1', 'm1']);
+  assert.equal(fetched.variants.nodes.length, 2);
+  assert.equal(widthFromLive(fetched).value, '54 inches');
+  assert.equal(p.variants.nodes.length, 1);
+});
+test('pagination errors and repeated cursors fail closed', async () => {
+  const p = { id: '1', variants: { nodes: [], pageInfo: { hasNextPage: true, endCursor: 'v1' } } };
+  await assert.rejects(completeLiveProduct(p, async () => ({ status: 429 })), /Incomplete Shopify/);
+  await assert.rejects(completeLiveProduct(p, async () => ({ status: 200, json: { data: { product: p } } })), /Cannot safely paginate/);
+});
diff --git a/test/readiness-repairs.test.js b/test/readiness-repairs.test.js
new file mode 100644
index 0000000..ccb80e7
--- /dev/null
+++ b/test/readiness-repairs.test.js
@@ -0,0 +1,21 @@
+'use strict';
+const test = require('node:test');
+const assert = require('node:assert/strict');
+const { buildRepairReport } = require('../lib/readiness-repairs');
+const row = { shopify_id: 'gid://shopify/Product/1', dw_sku: 'DW-1', ts: '2026-09-09T15:25:00Z',
+  action: 'skip-gate', reasons: ['missing sample variant', 'no-sample-variant', 'missing spec(s): width'] };
+test('duplicate sample rules become one repair; retries are idempotent', () => {
+  const report = buildRepairReport([row, row]);
+  assert.equal(report.items.length, 2);
+  assert.equal(report.counts['restore-sample-variant'], 1);
+  assert.equal(report.products_needing_attention, 1);
+  assert.ok(report.items.every(x => x.apply_authorized === false && x.created_at));
+});
+test('resolved latest result removes earlier failure', () => {
+  assert.equal(buildRepairReport([row, { ...row, ts: '2026-09-09T16:25:00Z', action: 'activated' }]).items.length, 0);
+});
+test('provider error stays operational; prohibited design stays blocked', () => {
+  const hold = { ...row, action: 'settlement-hold', reasons: [], settlement: { reason: 'vision-http-429' } };
+  assert.equal(buildRepairReport([hold]).items[0].type, 'retry-image-review');
+  assert.equal(buildRepairReport([{ ...hold, action: 'settlement-block' }]).items[0].type, 'settlement-block-review');
+});
diff --git a/test/vision-http.test.js b/test/vision-http.test.js
new file mode 100644
index 0000000..84a459a
--- /dev/null
+++ b/test/vision-http.test.js
@@ -0,0 +1,69 @@
+'use strict';
+const test = require('node:test');
+const assert = require('node:assert/strict');
+const { EventEmitter } = require('node:events');
+const { errorDetails, cooldownMs, requestVision } = require('../lib/vision-http');
+const { SettlementGate } = require('../lib/settlement-gate');
+const gate = () => Object.assign(Object.create(SettlementGate.prototype), {
+  lockOk: true, geminiKeys: ['test-key-one', 'test-key-two'], visionRetryAt: 0,
+  visionHttpAttempts: 0, visionThrottledSkips: 0, lastVisionError: null,
+});
+test('upstream quota details and retry delay retained with secrets redacted', () => {
+  const details = errorDetails({ status: 429, body: JSON.stringify({ error: {
+    status: 'RESOURCE_EXHAUSTED', message: 'quota exhausted key=secret-one https://api.test/?key=secret-two',
+    details: [{ '@type': 'type.googleapis.com/google.rpc.RetryInfo', retryDelay: '70s' },
+      { '@type': 'type.googleapis.com/google.rpc.QuotaFailure', violations: [{ quotaId: 'RequestsPerDay', quotaValue: '20' }] }],
+  } }) }, ['secret-one', 'secret-two']);
+  assert.equal(details.retryAfterMs, 70000);
+  assert.equal(details.quota[0].value, '20');
+  assert.ok(!JSON.stringify(details).includes('secret-'));
+  assert.equal(cooldownMs(details), 3600000);
+});
+test('429 across existing keys opens circuit; later drafts stay HELD without downloads', async () => {
+  const g = gate(); let calls = 0, downloads = 0;
+  g._visionCall = async () => { calls++; return { ok: false, why: 'vision-http-429', details: { httpStatus: 429 } }; };
+  g.fetchImageB64 = async () => { downloads++; return 'bytes'; };
+  assert.equal((await g.visionDetect('bytes', 'image/jpeg')).ok, false);
+  const result = await g.evaluate({ title: 'Tropical Birds', material: 'paper', imageUrl: 'https://example.test/p.jpg' });
+  assert.equal(result.verdict, 'HELD'); assert.equal(result.cost, 0);
+  assert.equal(calls, 2); assert.equal(downloads, 0);
+  assert.equal(result.reason, 'vision-rate-limit-cooldown');
+});
+test('cooldown expiration retries service; no fail-open during outage', async () => {
+  const g = gate(); g.visionRetryAt = Date.now() - 1;
+  g._visionCall = async () => ({ ok: true, partA: true, partB: true });
+  const v = await g.visionDetect('bytes', 'image/jpeg');
+  assert.equal(g.verdictFromVision(v).verdict, 'BLOCK');
+  assert.equal(g.visionRetryAt, 0);
+});
+test('authentication and malformed response errors never fall through as PASS', async () => {
+  for (const why of ['vision-http-401', 'vision-http-403', 'vision-parse', 'incomplete-verdict']) {
+    const g = gate(); let calls = 0;
+    g._visionCall = async () => { calls++; return { ok: false, why }; };
+    assert.equal((await g.visionDetect('x', 'image/jpeg')).ok, false);
+    assert.equal(calls, 1);
+  }
+});
+test('HTTP request uses header auth and propagates response details', async () => {
+  let options;
+  const request = (opts, callback) => {
+    options = opts;
+    const req = new EventEmitter(); req.setTimeout = () => {};
+    req.end = () => { const res = new EventEmitter(); res.statusCode = 429; res.headers = { 'retry-after': '90' };
+      callback(res); res.emit('data', '{"error":{"status":"RESOURCE_EXHAUSTED"}}'); res.emit('end'); };
+    return req;
+  };
+  const response = await requestVision({}, 'fake-secret', { request });
+  assert.ok(!options.path.includes('fake-secret')); assert.equal(options.headers['x-goog-api-key'], 'fake-secret');
+  assert.equal(errorDetails(response).retryAfterMs, 90000);
+});
+test('timeout resolves as an unavailable service rather than hanging', async () => {
+  const request = () => {
+    const req = new EventEmitter(); let timeout;
+    req.setTimeout = (_, callback) => { timeout = callback; };
+    req.destroy = error => req.emit('error', error);
+    req.end = () => timeout(); return req;
+  };
+  const response = await requestVision({}, 'fake-secret', { request });
+  assert.equal(response.status, 0); assert.equal(response.err, 'vision-request-timeout');
+});
diff --git a/verification/e2e-proof-before-TK11314.json b/verification/e2e-proof-before-TK11314.json
new file mode 100644
index 0000000..94399bd
--- /dev/null
+++ b/verification/e2e-proof-before-TK11314.json
@@ -0,0 +1,45 @@
+{
+  "intent": "Use one fail-closed five-field invariant check in every local product activation lane before any ACTIVE mutation.",
+  "risk_tier": "R1 isolated code; live Shopify activation is R4 and was intentionally not invoked",
+  "environment": "local macOS workspace; zero-network tests",
+  "timestamp": "2026-08-29T04:43:00Z",
+  "ticket": "TK-10947-unify-five-field-activation-checks-acros",
+  "precondition": "rotate-activate.js had the full extra invariant set; cmo-activate.js and mdc-activate.js duplicated only sample/tag checks.",
+  "checks": [
+    {
+      "verdict": "PASS",
+      "boundary": "pure validation",
+      "command": "node --test test/five-field-extra.test.js",
+      "assertions": "7/7 pass: normal pass, missing sample rejection, strict finite-decimal prices, explicit invalid array-shape holds, tag rejection, normalized quote-only exemption boundaries"
+    },
+    {
+      "verdict": "PASS",
+      "boundary": "script parse",
+      "command": "node --check rotate-activate.js; node --check cmo-activate.js; node --check mdc-activate.js",
+      "assertions": "all three activation entry points parse"
+    },
+    {
+      "verdict": "PASS",
+      "boundary": "activation mutation ordering",
+      "command": "zero-network source assertion over all three entry points",
+      "assertions": "shared fiveFieldExtra call precedes a fail-closed continue, which precedes the ACTIVE mutation"
+    },
+    {
+      "verdict": "PASS",
+      "boundary": "working-tree hygiene",
+      "command": "git diff --check",
+      "assertions": "no whitespace errors"
+    }
+  ],
+  "negative_checks": [
+    "missing sample",
+    "missing, blank, zero, malformed, negative, suffixed, and non-finite sellable price",
+    "non-array variants.nodes and tags shapes",
+    "string tags cannot trigger count or quote-only exemptions",
+    "fewer than two tags",
+    "quote-only product without sample"
+  ],
+  "side_effects": "none; no Shopify/Gmail/network call, no service restart, no deploy, no scheduled-job change",
+  "cleanup": "not required; tests create no retained state",
+  "verdict": "PASS for the local pre-activation decision boundary"
+}
diff --git a/verification/e2e-proof.json b/verification/e2e-proof.json
index 94399bd..560d6cc 100644
--- a/verification/e2e-proof.json
+++ b/verification/e2e-proof.json
@@ -1,45 +1,91 @@
 {
-  "intent": "Use one fail-closed five-field invariant check in every local product activation lane before any ACTIVE mutation.",
-  "risk_tier": "R1 isolated code; live Shopify activation is R4 and was intentionally not invoked",
-  "environment": "local macOS workspace; zero-network tests",
-  "timestamp": "2026-08-29T04:43:00Z",
-  "ticket": "TK-10947-unify-five-field-activation-checks-acros",
-  "precondition": "rotate-activate.js had the full extra invariant set; cmo-activate.js and mdc-activate.js duplicated only sample/tag checks.",
+  "ticket": "TK-11314",
+  "timestamp": "2026-09-09T17:17:00.070133+00:00",
+  "risk_tier": "R4",
+  "authorization": "Steve: go do it (approved rollout and one-product canary)",
+  "environment": "Running hourly DW jobs on Mac; Shopify designer-laboratory-sandbox live store",
+  "verdict": "PASS for rollout/canary; image-dependent throughput BLOCKED by depleted Gemini prepaid credits",
+  "baseline": {
+    "product_id": "gid://shopify/Product/7867560886323",
+    "sku": "DWDX-220334",
+    "status_before": "DRAFT",
+    "ledger_before": {
+      "date": "2026-09-09",
+      "used": 0,
+      "file_absent": true
+    }
+  },
   "checks": [
     {
+      "check": "Reviewed runtime bytes installed under both existing worker locks",
+      "verdict": "PASS",
+      "files": 19
+    },
+    {
+      "check": "Installed regressions and guards",
+      "verdict": "PASS",
+      "tests": 45,
+      "notes": "34 activation,4 validator,7 repair; existing reintroduction and never-activate guard also passed"
+    },
+    {
+      "check": "Canary DRAFT to ACTIVE with correct publication",
+      "verdict": "PASS",
+      "product_id": "gid://shopify/Product/7867560886323",
+      "storefront": {
+        "url": "https://www.designerwallcoverings.com/products/cork-dove-dwdx-220334",
+        "status": 200,
+        "product_id_found": true
+      },
+      "googlePublished": false
+    },
+    {
+      "check": "Price/variant preservation and activation ledger",
+      "verdict": "PASS",
+      "notes": "Independent fresh query matches all before variants; ledger increased exactly0to1"
+    },
+    {
+      "check": "Independent verifier",
       "verdict": "PASS",
-      "boundary": "pure validation",
-      "command": "node --test test/five-field-extra.test.js",
-      "assertions": "7/7 pass: normal pass, missing sample rejection, strict finite-decimal prices, explicit invalid array-shape holds, tag rejection, normalized quote-only exemption boundaries"
+      "implementation": "Separate read-only Shopify2026-07 query + public storefrontHTTP, no activation-module imports",
+      "evidence": "/Users/macstudio3/Projects/dw-activation-debug-TK11314/verification/rollout/canary-verified.json"
     },
     {
+      "check": "Existing repair worker read-only operational path",
       "verdict": "PASS",
-      "boundary": "script parse",
-      "command": "node --check rotate-activate.js; node --check cmo-activate.js; node --check mdc-activate.js",
-      "assertions": "all three activation entry points parse"
+      "notes": "--max1 --dry-run reads20034 tasks,17445 completed,2589 unmatched; first blankDW SKU safely skipped,no Shopify write or budget debit"
     },
     {
+      "check": "Vision failure classification",
       "verdict": "PASS",
-      "boundary": "activation mutation ordering",
-      "command": "zero-network source assertion over all three entry points",
-      "assertions": "shared fiveFieldExtra call precedes a fail-closed continue, which precedes the ACTIVE mutation"
+      "provider_result": {
+        "verdict": "HELD",
+        "tier": "vision",
+        "reason": "vision-http-429",
+        "diagnostics": {
+          "httpStatus": 429,
+          "status": "RESOURCE_EXHAUSTED",
+          "message": "Your prepayment credits are depleted. Please go to AI Studio at https://ai.studio/projects to manage your project and billing. Learn more at https://ai.google.dev/gemini-api/docs/billing#prepay. ",
+          "quota": [],
+          "retryAfterMs": 0
+        },
+        "retryAt": "2026-09-09T18:15:49.630Z",
+        "cost": 0
+      },
+      "notes": "One configured-key request; no product mutation or charge; confirmed depleted prepayment credits"
+    },
+    {
+      "check": "Image-review recovery",
+      "verdict": "BLOCKED",
+      "reason": "Gemini prepaid credits depleted; funding/credential changes outside approved rollout"
     },
     {
+      "check": "Source rollback",
       "verdict": "PASS",
-      "boundary": "working-tree hygiene",
-      "command": "git diff --check",
-      "assertions": "no whitespace errors"
+      "evidence": "/Users/macstudio3/Projects/dw-activation-debug-TK11314/verification/rollback-rehearsal.json"
     }
   ],
-  "negative_checks": [
-    "missing sample",
-    "missing, blank, zero, malformed, negative, suffixed, and non-finite sellable price",
-    "non-array variants.nodes and tags shapes",
-    "string tags cannot trigger count or quote-only exemptions",
-    "fewer than two tags",
-    "quote-only product without sample"
-  ],
-  "side_effects": "none; no Shopify/Gmail/network call, no service restart, no deploy, no scheduled-job change",
-  "cleanup": "not required; tests create no retained state",
-  "verdict": "PASS for the local pre-activation decision boundary"
+  "evidence_directory": "/Users/macstudio3/Projects/dw-activation-debug-TK11314/verification/rollout",
+  "rollback": "Exact pre-files in deployment-manifest backups. Source reversal rehearsed; retains one approved live canary product. No product rollback required.",
+  "schedule": "Existing minute25 activations21/hour,500/day; minute10 field repairs. No schedule changes.",
+  "project": "dw-rotation-activator"
 }

← 37bb28a TK-11186 Step D prep: rotation-activator skips showroom vend  ·  back to Dw Rotation Activator  ·  Record successful scheduled activation and remaining image f f9e5125 →