← 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
M .gitignoreA lib/activation-result.jsA lib/live-product-fields.jsA lib/readiness-repairs.jsM lib/settlement-gate.jsA lib/vision-http.jsM rotate-activate.jsA test/activation-result.test.jsA test/live-product-fields.test.jsA test/readiness-repairs.test.jsA test/vision-http.test.jsA verification/e2e-proof-before-TK11314.jsonM verification/e2e-proof.json
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 →