[object Object]

← back to Dwha Harlequin Onboard

Harden DWHA onboarding before gated canary

a4262a48f1f8581d0d106042bfabdaf01376e189 · 2026-08-31 11:20:56 -0700 · Steve

Files touched

Diff

commit a4262a48f1f8581d0d106042bfabdaf01376e189
Author: Steve <steve@designerwallcoverings.com>
Date:   Mon Aug 31 11:20:56 2026 -0700

    Harden DWHA onboarding before gated canary
---
 scripts/check-coverage.mjs  |   1 +
 scripts/onboard-batch.mjs   | 110 ++++++++++++++++++++++++++++++++++++++------
 verification/e2e-proof.json |  70 ++++++++++++++++++++++++++++
 3 files changed, 166 insertions(+), 15 deletions(-)

diff --git a/scripts/check-coverage.mjs b/scripts/check-coverage.mjs
index 020e435..95efed4 100644
--- a/scripts/check-coverage.mjs
+++ b/scripts/check-coverage.mjs
@@ -54,6 +54,7 @@ async function main() {
         width, material, discontinued, on_shopify, shopify_product_id,
         image_rejected, image_rejection_reason, design, features
       FROM harlequin_catalog
+      WHERE dw_sku LIKE 'DWHA-%' OR dw_sku IS NULL
       ORDER BY id ASC
     `);
     allRows = result.rows;
diff --git a/scripts/onboard-batch.mjs b/scripts/onboard-batch.mjs
index 2de71df..835863c 100644
--- a/scripts/onboard-batch.mjs
+++ b/scripts/onboard-batch.mjs
@@ -47,7 +47,8 @@ const CADENCE_FILE   = path.join(DATA_DIR, 'cadence-counter.json');
 const LEDGER_FILE    = path.join(DATA_DIR, 'onboard-ledger.jsonl');
 
 const DAY_LIMIT      = 25;  // Steve-approved cadence
-const SHOPIFY_API    = `https://${SHOPIFY_STORE}/admin/api/2024-10`;
+const SHOPIFY_VERSION = process.env.SHOPIFY_API_VERSION || '2026-07';
+const SHOPIFY_API    = `https://${SHOPIFY_STORE}/admin/api/${SHOPIFY_VERSION}`;
 const SHOPIFY_DELAY  = 500; // ms between API calls (rate-limit safe)
 
 const IS_DRY_RUN     = process.argv.includes('--dry-run');
@@ -250,6 +251,50 @@ async function shopifyPost(endpoint, payload) {
   });
 }
 
+async function shopifyGraphql(query, variables = {}) {
+  const response = await shopifyPost('/graphql.json', { query, variables });
+  if (response.errors?.length) {
+    throw new Error(`Shopify GraphQL: ${JSON.stringify(response.errors).slice(0, 500)}`);
+  }
+  return response.data;
+}
+
+async function findLiveVariantBySku(sku) {
+  const data = await shopifyGraphql(`
+    query FindVariantBySku($query: String!) {
+      productVariants(first: 10, query: $query) {
+        nodes {
+          sku
+          product { id legacyResourceId handle status }
+        }
+      }
+    }
+  `, { query: `sku:${JSON.stringify(sku)}` });
+
+  return data.productVariants.nodes.find(node => node.sku === sku) || null;
+}
+
+async function shopifyDeleteProduct(productId) {
+  const url = new URL(`${SHOPIFY_API}/products/${productId}.json`);
+  return new Promise((resolve, reject) => {
+    const req = https.request({
+      hostname: url.hostname,
+      path: url.pathname,
+      method: 'DELETE',
+      headers: { 'X-Shopify-Access-Token': SHOPIFY_TOKEN },
+    }, res => {
+      let data = '';
+      res.on('data', chunk => data += chunk);
+      res.on('end', () => {
+        if ([200, 204, 404].includes(res.statusCode)) resolve(res.statusCode);
+        else reject(new Error(`Shopify DELETE ${res.statusCode}: ${data.slice(0, 300)}`));
+      });
+    });
+    req.on('error', reject);
+    req.end();
+  });
+}
+
 async function shopifyPostImage(productId, localPath, dw_sku) {
   const imageData = fs.readFileSync(localPath);
   const b64 = imageData.toString('base64');
@@ -321,18 +366,17 @@ async function main() {
         product_type, width, length, repeat_v, repeat_h, material,
         price_trade, price_retail, image_url, product_url,
         fire_rating, finish, application, match_type, design, features,
-        color_primary, color_secondary
+        color_primary, color_secondary, on_shopify, shopify_product_id
       FROM harlequin_catalog
       WHERE
-        price_trade IS NOT NULL
+        dw_sku LIKE 'DWHA-%'
+        AND price_trade IS NOT NULL
         AND image_url IS NOT NULL
         AND dw_sku IS NOT NULL
         AND (discontinued IS FALSE OR discontinued IS NULL)
         AND (image_rejected IS FALSE OR image_rejected IS NULL)
-        AND (on_shopify IS FALSE OR on_shopify IS NULL)
       ORDER BY id ASC
-      LIMIT $1
-    `, [IS_DRY_RUN ? 9999 : remaining]);
+    `);
     rows = result.rows;
   } catch (err) {
     console.error('DB query failed:', err.message);
@@ -340,7 +384,7 @@ async function main() {
     process.exit(1);
   }
 
-  console.log(`\nViable rows to process: ${rows.length}${IS_DRY_RUN ? ' (dry-run shows all)' : ` (capped at ${remaining} today)`}`);
+  console.log(`\nViable catalog rows to inspect: ${rows.length}${IS_DRY_RUN ? ' (dry-run candidate set; live SKU checks run only in approved live mode)' : ` (creates capped at ${remaining} today)`}`);
 
   if (IS_DRY_RUN) {
     console.log('\n--- DRY-RUN PREVIEW (first 10 rows) ---');
@@ -360,9 +404,31 @@ async function main() {
   let created = 0, skipped = 0, errors = [];
 
   for (const row of rows) {
+    if (created >= remaining) break;
     const { dw_sku, image_url } = row;
     console.log(`\n[${created + skipped + 1}/${rows.length}] Processing ${dw_sku} ...`);
 
+    // 0. Live idempotency check. DB on_shopify is only a cache and may be stale.
+    let existingVariant;
+    try {
+      existingVariant = await findLiveVariantBySku(dw_sku);
+    } catch (lookupErr) {
+      console.error(`  STOP — live SKU idempotency check failed: ${lookupErr.message}`);
+      errors.push({ dw_sku, stage: 'live-sku-check', error: lookupErr.message });
+      break; // fail closed: never create when duplicate detection is unavailable
+    }
+
+    if (existingVariant) {
+      const existingProductId = String(existingVariant.product.legacyResourceId);
+      console.log(`  SKIP — live SKU already exists on ${existingVariant.product.status} product ${existingProductId}`);
+      if (!row.on_shopify || String(row.shopify_product_id || '') !== existingProductId) {
+        await markOnShopify(pool, row.id, existingProductId);
+        console.log(`  db: reconciled stale on_shopify flag to live product ${existingProductId}`);
+      }
+      skipped++;
+      continue;
+    }
+
     // 1. Download image
     let localImagePath;
     try {
@@ -385,6 +451,8 @@ async function main() {
       productId = response.product?.id;
       if (!productId) throw new Error('No product ID returned from Shopify');
       console.log(`  shopify: created DRAFT product ${productId}`);
+      const mapPath = saveRestoreMap(dw_sku, String(productId), row);
+      console.log(`  restore-map: ${path.basename(mapPath)}`);
     } catch (shopErr) {
       console.warn(`  SKIP — Shopify create failed: ${shopErr.message}`);
       errors.push({ dw_sku, stage: 'shopify-create', error: shopErr.message });
@@ -398,9 +466,25 @@ async function main() {
       await shopifyPostImage(productId, localImagePath, dw_sku);
       console.log(`  image: attached to product ${productId}`);
     } catch (imgAttachErr) {
-      // Non-fatal — product is created, image can be added later; log it
-      console.warn(`  WARN — image attach failed (product still created): ${imgAttachErr.message}`);
+      console.warn(`  image attach failed; compensating DELETE required: ${imgAttachErr.message}`);
       errors.push({ dw_sku, stage: 'image-attach', error: imgAttachErr.message, shopify_product_id: productId });
+      try {
+        await shopifyDeleteProduct(productId);
+        fs.renameSync(
+          path.join(RESTORE_DIR, `${dw_sku}.json`),
+          path.join(RESTORE_DIR, `${dw_sku}.rolled-back.json`),
+        );
+        console.log(`  compensated: deleted incomplete DRAFT product ${productId}; DB unchanged`);
+        skipped++;
+        continue;
+      } catch (deleteErr) {
+        console.error(`  FATAL — incomplete DRAFT remains; restore-map retained: ${deleteErr.message}`);
+        errors.push({ dw_sku, stage: 'compensating-delete', error: deleteErr.message, shopify_product_id: productId });
+        created++;
+        cadence.count++;
+        saveCadenceState(cadence);
+        break; // stop the batch; do not compound an unresolved partial failure
+      }
     }
 
     // 4. DB: mark on_shopify
@@ -411,11 +495,7 @@ async function main() {
       console.warn(`  WARN — DB update failed: ${dbErr.message}`);
     }
 
-    // 5. Save restore-map
-    const mapPath = saveRestoreMap(dw_sku, String(productId), row);
-    console.log(`  restore-map: ${path.basename(mapPath)}`);
-
-    // 6. Ledger
+    // 5. Ledger
     appendLedger({
       agent: 'vp-dw-commerce',
       ticket: 'TK-10882',
@@ -427,7 +507,7 @@ async function main() {
       verify: `shopify product get ${productId}`,
     });
 
-    // 7. Also log to global executed-reversible ledger
+    // 6. Also log to global executed-reversible ledger
     try {
       const execLedger = '/Users/macstudio3/.claude/yolo-queue/executed-reversible/ledger.jsonl';
       fs.mkdirSync(path.dirname(execLedger), { recursive: true });
diff --git a/verification/e2e-proof.json b/verification/e2e-proof.json
new file mode 100644
index 0000000..78102c9
--- /dev/null
+++ b/verification/e2e-proof.json
@@ -0,0 +1,70 @@
+{
+  "intent": "Safely prepare TK-10882 for a one-product DWHA Shopify DRAFT canary followed by a 25/day LaunchAgent cadence.",
+  "risk_tier": "R4",
+  "environment": "macstudio3 local dw_unified mirror plus read-only Shopify Admin GraphQL preflight",
+  "timestamp": "2026-08-31T18:18:00.000Z",
+  "build_identity": "working tree based on 9d75a2e; final commit recorded after verification",
+  "baseline": {
+    "coverage": "631 READY, 29 settlement-flagged, 602 clean",
+    "launchagent": "not installed",
+    "restore_maps": 0,
+    "cadence_count": 0,
+    "prefix_state": "0 ACTIVE Harlequin DWHQ-* and 39 ACTIVE DWHF-335* in local mirror"
+  },
+  "checks": [
+    {
+      "name": "JavaScript parse and diff hygiene",
+      "command": "node --check scripts/onboard-batch.mjs; node --check scripts/check-coverage.mjs; git diff --check",
+      "verdict": "PASS"
+    },
+    {
+      "name": "DWHA coverage query",
+      "command": "npm run check",
+      "assertions": ["631 READY", "29 settlement-flagged", "602 clean", "no writes"],
+      "verdict": "PASS"
+    },
+    {
+      "name": "Onboarding dry-run",
+      "command": "npm run dry-run",
+      "assertions": ["only DWHA-* candidates", "658 catalog candidates inspected", "0 Shopify calls", "0 DB writes"],
+      "verdict": "PASS"
+    },
+    {
+      "name": "Live SKU lookup negative branch",
+      "artifact": "Shopify GraphQL productVariants exact query for DWHA-570086",
+      "assertions": ["errors=null", "nodes=[]"],
+      "verdict": "PASS"
+    },
+    {
+      "name": "Live SKU lookup positive branch",
+      "artifact": "Shopify GraphQL productVariants exact query for DWHF-335031",
+      "assertions": ["exact SKU returned", "legacy product ID 7787424448563", "status ACTIVE"],
+      "verdict": "PASS"
+    },
+    {
+      "name": "LaunchAgent static validation",
+      "command": "plutil -lint launchd/com.steve.dwha-onboard-daily.plist; test -x /opt/homebrew/bin/node",
+      "assertions": ["plist OK", "configured Node executable exists", "service remains uninstalled"],
+      "verdict": "PASS"
+    },
+    {
+      "name": "One-product external mutation and persisted-state assertion",
+      "reason": "Requires explicit Steve approval for Shopify DRAFT create plus canonical dw_unified update.",
+      "verdict": "SKIP"
+    },
+    {
+      "name": "Compensating-delete failure-path exercise against Shopify",
+      "reason": "Would require deliberately causing and deleting a production-side DRAFT; held under the same R4 approval gate.",
+      "verdict": "SKIP"
+    },
+    {
+      "name": "LaunchAgent install and first scheduled execution",
+      "reason": "Schedule installation is gated and must follow a successful one-product canary.",
+      "verdict": "SKIP"
+    }
+  ],
+  "cleanup": "No Shopify product, canonical row, or LaunchAgent was changed. Read-only preflights left no retained external test state.",
+  "rollback": "For an approved canary, scripts/rollback.mjs DWHA-XXXXXX deletes the created DRAFT, clears the catalog flag, and archives its restore-map.",
+  "verdict": "BLOCKED",
+  "blocker": "Critical R4 canary and schedule-install paths are intentionally skipped pending Steve approval; see pending-approval/2026-08-31-TK-10882-DWHA-canary-then-cadence.md."
+}

← 9e7a440 auto-data-snapshot: 2026-08-31T11:17:15 (3 data files) — .gi  ·  back to Dwha Harlequin Onboard  ·  Record DWHA verification commit identities 25a836f →