[object Object]

← back to Dw Photo Capture

TK-12126: add camera commit:true round-trip proof script (mock-tested, live path gated)

d9c961266e022b2594ab3423f354c34dca0d3640 · 2026-09-24 12:08:08 -0700 · Steve Abrams

Builds scripts/camera-committrue-proof.mjs per the approved memo — proves the one
remaining unmeasured link in the camera capture pipeline (commit:true -> Shopify DRAFT
-> re-fetch -> byte/pixel-match the baked capture) that the cycle-2 CTA run stopped
short of. Mock mode ($0, no network) runs the real ImageMagick normalize step plus a
simulated Shopify re-encode and proves the comparator via --self-test (PASS on clean
data, FAILs on injected corruption, per the negative-test rule). --live is hard-gated
and was never executed here. Ledger: data/camera-committrue-proof-ledger.jsonl (now
gitignored, runtime-generated).

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

Files touched

Diff

commit d9c961266e022b2594ab3423f354c34dca0d3640
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Thu Sep 24 12:08:08 2026 -0700

    TK-12126: add camera commit:true round-trip proof script (mock-tested, live path gated)
    
    Builds scripts/camera-committrue-proof.mjs per the approved memo — proves the one
    remaining unmeasured link in the camera capture pipeline (commit:true -> Shopify DRAFT
    -> re-fetch -> byte/pixel-match the baked capture) that the cycle-2 CTA run stopped
    short of. Mock mode ($0, no network) runs the real ImageMagick normalize step plus a
    simulated Shopify re-encode and proves the comparator via --self-test (PASS on clean
    data, FAILs on injected corruption, per the negative-test rule). --live is hard-gated
    and was never executed here. Ledger: data/camera-committrue-proof-ledger.jsonl (now
    gitignored, runtime-generated).
    
    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
    Claude-Session: https://claude.ai/code/session_017uUWK5VKgXgemKLA9UqwMZ
---
 .gitignore                          |   3 +
 scripts/camera-committrue-proof.mjs | 432 ++++++++++++++++++++++++++++++++++++
 2 files changed, 435 insertions(+)

diff --git a/.gitignore b/.gitignore
index 463e696..d51d6fb 100644
--- a/.gitignore
+++ b/.gitignore
@@ -18,3 +18,6 @@ data/color-index.json
 
 # 5x screen-capture test artifacts (regenerated by the 5x test pass)
 5x/out/
+
+# TK-12126 camera commit:true round-trip proof — fsync'd exec-ledger, runtime-generated
+data/camera-committrue-proof-ledger.jsonl
diff --git a/scripts/camera-committrue-proof.mjs b/scripts/camera-committrue-proof.mjs
new file mode 100644
index 0000000..5210c18
--- /dev/null
+++ b/scripts/camera-committrue-proof.mjs
@@ -0,0 +1,432 @@
+#!/usr/bin/env node
+/* ════════════════════════════════════════════════════════════════════════════════════════
+   TK-12126 — camera commit:true round-trip proof
+   Memo: ~/.claude/yolo-queue/pending-approval/2026-09-24-TK-12126-dwphoto-camera-committrue-roundtrip-proof.md
+
+   WHAT THIS PROVES (the one link the cycle-2 CTA run left NOT-MEASURED):
+     A commit:true POST to /api/create-item creates a Shopify DRAFT product whose STORED
+     image byte/pixel-matches the baked capture that was sent, within JPEG-re-encode
+     tolerance. Everything upstream of that (preview==capture parity, the baked JPEG ==
+     what the commit:false preview endpoint receives) was already proven by the CTA run —
+     this script does not re-prove it and does not need a browser/canvas to do its job.
+
+   TWO MODES, ONE SHARED comparison+ledger pipeline (verifyAndLedger below runs identically
+   either way — only the transport that produces `storedBuf` differs):
+
+     MOCK  (default, --self-test, --negative-test)
+       Zero network. Touches nothing on Shopify. $0. Simulates the server's ImageMagick
+       normalize step for real (same binary the deployed app shells out to) plus a
+       synthetic "Shopify re-store" re-encode, so the compare/tolerance/ledger code is
+       exercised against real re-encode noise, not a canned number. --negative-test
+       corrupts the "stored" bytes to prove the comparator actually goes RED on a real
+       defect (CLAUDE.md TK-11431 rule 3 — a check ships with a negative test or it
+       doesn't ship). --self-test runs both and asserts the expected PASS/FAIL, in one
+       network-free invocation — this IS the "unit-tested locally" proof required before
+       this script is allowed to be handed to Steve as a live-run candidate.
+
+     LIVE  (--live — HARD-GATED, Steve-only; this script's own author must never pass it)
+       Real network. Calls the DEPLOYED app (https://photo.designerwallcoverings.com by
+       default) exactly the way a real capture does: optionally /api/extract (Gemini OCR,
+       ~$0.0006/call — printed + ledgered per the always-show-costs rule) then
+       /api/create-item with commit:true. Independently RE-FETCHES the stored image via a
+       direct Shopify Admin REST call (never trusts the create response's own echo),
+       downloads the bytes, and runs the exact same pixelDiff+ledger code the mock path
+       already proved. Creates exactly ONE Shopify DRAFT product, never publishes it,
+       LEAVES it in place by default (--auto-delete deletes it only after a PASS verdict —
+       the memo's second checkbox). The undo command is always printed + ledgered even
+       when nothing is deleted.
+
+   Run:
+     node scripts/camera-committrue-proof.mjs --self-test        # $0, no network (prove it works)
+     node scripts/camera-committrue-proof.mjs                    # $0, no network (single mock PASS run)
+     node scripts/camera-committrue-proof.mjs --negative-test    # $0, no network (proves it goes RED)
+     node scripts/camera-committrue-proof.mjs --live [...]       # REAL Shopify write — Steve only
+     node scripts/camera-committrue-proof.mjs --delete-product <id>   # undo utility (real Shopify write)
+
+   Zero npm deps (matches this repo's "zero runtime deps" rule). Uses the ImageMagick
+   `convert` binary already on this Mac at /opt/homebrew/bin/convert for pixel decode —
+   NOTE (side finding, not fixed here, out of scope for TK-12126): server.js's own
+   normalizeImage() hardcodes /usr/bin/convert, which does NOT exist on this Mac2 (only
+   the homebrew path does) — on this host that call silently falls back to the raw,
+   un-normalized bytes (see normalizeImage()'s `err || !stdout ... ? buf : stdout`). On
+   the actual Kamatera deploy target (Linux) /usr/bin/convert is expected to exist, so
+   production normalize behavior differs from a local Mac2 run of server.js. This script
+   works either way because it compares against whatever Shopify actually stored, not
+   against an assumption about which host ran the normalize step.
+   ════════════════════════════════════════════════════════════════════════════════════════ */
+'use strict';
+
+import { execFile } from 'node:child_process';
+import { createHash } from 'node:crypto';
+import * as fs from 'node:fs';
+import * as http from 'node:http';
+import * as https from 'node:https';
+import * as os from 'node:os';
+import * as path from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+const __filename = fileURLToPath(import.meta.url);
+const __dirname = path.dirname(__filename);
+const ROOT = path.join(__dirname, '..');
+const TICKET = 'TK-12126';
+const AGENT = process.env.TK_AGENT || 'iterm-camera-proof';
+
+// ── CLI args ──────────────────────────────────────────────────────────────────────────────
+const argv = process.argv.slice(2);
+function flag(name) { return argv.includes(name); }
+function opt(name, dflt) { const i = argv.indexOf(name); return i >= 0 && argv[i + 1] !== undefined ? argv[i + 1] : dflt; }
+
+// ── Shopify config (mirrors server.js exactly — same store, same API version) ──────────────
+const SHOP = 'designer-laboratory-sandbox.myshopify.com';
+const API = '2024-10';
+function loadShopifyToken() {
+  if (process.env.SHOPIFY_ADMIN_TOKEN) return process.env.SHOPIFY_ADMIN_TOKEN;
+  try {
+    const envPath = path.join(os.homedir(), 'Projects/secrets-manager/.env');
+    for (const l of fs.readFileSync(envPath, 'utf8').split('\n')) {
+      if (l.startsWith('SHOPIFY_ADMIN_TOKEN=')) return l.slice('SHOPIFY_ADMIN_TOKEN='.length).trim();
+    }
+  } catch (e) { /* live mode will fail loudly on the missing token; mock mode never needs it */ }
+  return '';
+}
+
+// ── generic HTTP(S) helpers (zero deps) ─────────────────────────────────────────────────────
+function request(urlStr, { method = 'GET', headers = {}, body = null, timeout = 30000 } = {}) {
+  return new Promise((resolve, reject) => {
+    let u; try { u = new URL(urlStr); } catch (e) { return reject(e); }
+    const mod = u.protocol === 'https:' ? https : http;
+    const data = body ? (Buffer.isBuffer(body) ? body : Buffer.from(body)) : null;
+    const req = mod.request({
+      hostname: u.hostname, port: u.port || (u.protocol === 'https:' ? 443 : 80),
+      path: u.pathname + u.search, method,
+      headers: Object.assign({}, headers, data ? { 'Content-Length': data.length } : {}),
+      timeout,
+    }, (res) => {
+      const chunks = [];
+      res.on('data', (c) => chunks.push(c));
+      res.on('end', () => resolve({ status: res.statusCode, headers: res.headers, buf: Buffer.concat(chunks) }));
+    });
+    req.on('error', reject);
+    req.on('timeout', () => req.destroy(new Error(`timeout after ${timeout}ms: ${urlStr}`)));
+    if (data) req.write(data);
+    req.end();
+  });
+}
+async function requestJson(urlStr, opts) {
+  const r = await request(urlStr, opts);
+  let json = null; try { json = JSON.parse(r.buf.toString('utf8')); } catch (e) { /* non-JSON body */ }
+  return { status: r.status, headers: r.headers, json, raw: r.buf.toString('utf8').slice(0, 500) };
+}
+function basicAuthHeader(user, pass) { return 'Basic ' + Buffer.from(`${user}:${pass}`).toString('base64'); }
+
+// ── Shopify direct calls (used for the independent re-fetch + the undo/delete utility —
+//    NEVER for the create itself; the create goes through the app's real /api/create-item
+//    so this proof exercises the actual production code path, not a hand-rolled shortcut) ──
+function shopifyReq(method, p, token, payload) {
+  const data = payload ? JSON.stringify(payload) : null;
+  return request(`https://${SHOP}/admin/api/${API}${p}`, {
+    method,
+    headers: Object.assign({ 'X-Shopify-Access-Token': token }, data ? { 'Content-Type': 'application/json' } : {}),
+    body: data,
+  }).then((r) => { let body = null; try { body = JSON.parse(r.buf.toString('utf8')); } catch (e) {} return { status: r.status, body, raw: r.buf.toString('utf8').slice(0, 300) }; });
+}
+async function deleteProduct(productId, token) {
+  const r = await shopifyReq('DELETE', `/products/${productId}.json`, token);
+  return { ok: r.status >= 200 && r.status < 300, status: r.status, raw: r.raw };
+}
+
+// ── ImageMagick pixel decode + diff (no npm deps; resolves the binary defensively since
+//    server.js's own hardcoded /usr/bin/convert path is absent on this Mac — see header) ──
+const CONVERT_CANDIDATES = ['/opt/homebrew/bin/convert', '/usr/local/bin/convert', '/usr/bin/convert', 'convert'];
+let _convertBin = null;
+function convertBin() {
+  if (_convertBin) return _convertBin;
+  for (const c of CONVERT_CANDIDATES) {
+    try { if (c === 'convert' || fs.existsSync(c)) { _convertBin = c; return c; } } catch (e) {}
+  }
+  _convertBin = 'convert'; // last resort — rely on PATH, will error clearly if truly absent
+  return _convertBin;
+}
+function runConvert(args, inputBuf) {
+  return new Promise((resolve, reject) => {
+    const cp = execFile(convertBin(), args, { maxBuffer: 64 * 1024 * 1024, timeout: 15000, encoding: 'buffer' },
+      (err, stdout) => { if (err) return reject(err); resolve(stdout); });
+    if (inputBuf) { try { cp.stdin.on('error', () => {}); cp.stdin.write(inputBuf); cp.stdin.end(); } catch (e) { reject(e); } }
+  });
+}
+// Same normalize shape as server.js normalizeImage(), used ONLY by the mock path to
+// simulate the server-side step with a real re-encode instead of a canned diff number.
+async function normalizeLikeServer(buf) {
+  try { return await runConvert(['-', '-auto-orient', '-resize', '1600x1600>', '-quality', '85', 'jpg:-'], buf); }
+  catch (e) { return buf; } // matches server.js's fallback-to-original-on-failure behavior
+}
+async function identifyDims(buf) {
+  const out = await runConvert(['-', '-format', '%w %h', 'info:'], buf);
+  const [w, h] = out.toString('utf8').trim().split(/\s+/).map(Number);
+  if (!w || !h) throw new Error('could not read image dimensions');
+  return { w, h };
+}
+async function decodeRawRGB(buf, w, h) {
+  return runConvert(['-', '-resize', `${w}x${h}!`, '-depth', '8', 'RGB:-'], buf);
+}
+// Mean absolute per-channel difference on a 0-255 scale (matches the "X/255" units the
+// prior normalize-check evidence used), computed pixel-for-pixel after resizing BOTH
+// images to the SENT image's own dimensions so a resize step never falsely inflates the
+// diff. Also reports max per-channel delta and a plain byte-identical/SHA256 check
+// (informational only — a JPEG re-encode is expected to NEVER be byte-identical).
+async function pixelDiff(sentBuf, storedBuf) {
+  const sha256 = (b) => createHash('sha256').update(b).digest('hex');
+  const byteIdentical = sentBuf.equals(storedBuf);
+  const dims = await identifyDims(sentBuf);
+  const [rgbA, rgbB] = await Promise.all([decodeRawRGB(sentBuf, dims.w, dims.h), decodeRawRGB(storedBuf, dims.w, dims.h)]);
+  const n = Math.min(rgbA.length, rgbB.length);
+  let sum = 0, max = 0;
+  for (let i = 0; i < n; i++) { const d = Math.abs(rgbA[i] - rgbB[i]); sum += d; if (d > max) max = d; }
+  const mean = n ? sum / n : 0;
+  return { meanAbsDiff255: mean, maxAbsDiff255: max, width: dims.w, height: dims.h, byteIdentical, sha256Sent: sha256(sentBuf), sha256Stored: sha256(storedBuf), sentBytes: sentBuf.length, storedBytes: storedBuf.length };
+}
+
+// ── fsync'd JSONL exec-ledger, tagged TK-12126, with a mkdir-lock so concurrent runs
+//    can never interleave a partial line (same pattern as the global executed-reversible
+//    ledger's log-exec.mjs) ──
+const LEDGER_PATH = path.join(ROOT, 'data', 'camera-committrue-proof-ledger.jsonl');
+function appendLedgerFsync(row) {
+  fs.mkdirSync(path.dirname(LEDGER_PATH), { recursive: true });
+  const lock = LEDGER_PATH + '.lock';
+  let held = false;
+  for (let i = 0; i < 200 && !held; i++) {
+    try { fs.mkdirSync(lock); held = true; }
+    catch (e) { Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 15); }
+  }
+  try {
+    const line = JSON.stringify(row) + '\n';
+    const fd = fs.openSync(LEDGER_PATH, 'a');
+    try { fs.writeSync(fd, line); fs.fsyncSync(fd); } finally { fs.closeSync(fd); }
+  } finally { if (held) { try { fs.rmdirSync(lock); } catch (e) {} } }
+}
+
+// ── cost line — always shown, per Steve's standing "always show costs" rule ────────────────
+function costLine(label, usd, note) {
+  const s = usd > 0 ? `$${usd.toFixed(4)}` : '$0 (local)';
+  console.log(`  💵 ${label}: ${s}${note ? '  — ' + note : ''}`);
+}
+
+// ── default fixture: a REAL photo already checked into this repo (500x500 JPEG), standing
+//    in for "the locally-baked capture" — capture-pipeline.js's apply() bake was already
+//    proven byte-identical to the commit:false preview by the cycle-2 CTA run (browser
+//    canvas math, out of scope here); this script's job starts one step later, at whatever
+//    JPEG a real bake would have produced. Override with --image <path> to point at a
+//    fresher capture (e.g. a still-live CTA scratchpad evidence file) if one is available. ──
+const DEFAULT_FIXTURE = path.join(ROOT, 'ref-roll.jpg');
+
+// ── shared verify+ledger core — IDENTICAL code path for mock and live; only how
+//    `storedBuf` was obtained differs between the two callers ──
+async function verifyAndLedger({ mode, sentBuf, storedBuf, productId, productGid, mediaId, mediaGid, dwSku, tolerance, extra, undoCmd, leftInPlace }) {
+  const diff = await pixelDiff(sentBuf, storedBuf);
+  const verdict = diff.meanAbsDiff255 <= tolerance ? 'PASS' : 'FAIL';
+  const row = Object.assign({
+    ts: new Date().toISOString(), agent: AGENT, ticket: TICKET, mode,
+    action: 'camera commit:true round-trip proof — commit:true capture -> Shopify DRAFT -> re-fetch -> byte/pixel compare',
+    product_id: productId, product_gid: productGid, media_id: mediaId, media_gid: mediaGid, dw_sku: dwSku,
+    sent_bytes: diff.sentBytes, stored_bytes: diff.storedBytes,
+    byte_identical: diff.byteIdentical, sha256_sent: diff.sha256Sent, sha256_stored: diff.sha256Stored,
+    width: diff.width, height: diff.height,
+    diff_mean_abs_per_channel_0_255: Number(diff.meanAbsDiff255.toFixed(4)),
+    diff_max_abs_per_channel_0_255: diff.maxAbsDiff255,
+    tolerance_mean_abs_per_channel_0_255: tolerance,
+    verdict, blast_radius: productId ? 1 : 0,
+    undo_cmd: undoCmd || null, left_in_place: leftInPlace !== false,
+    verify: `node scripts/camera-committrue-proof.mjs --self-test   # re-proves the comparator; for a live row, re-run --live to re-check this exact product`,
+  }, extra || {});
+  appendLedgerFsync(row);
+
+  console.log(`\n  ── round-trip compare (${mode}) ──`);
+  console.log(`  sent:    ${diff.sentBytes} bytes   sha256 ${diff.sha256Sent.slice(0, 12)}…`);
+  console.log(`  stored:  ${diff.storedBytes} bytes   sha256 ${diff.sha256Stored.slice(0, 12)}…   byte-identical: ${diff.byteIdentical}`);
+  console.log(`  pixel diff (${diff.width}x${diff.height}, 0-255 scale): mean ${diff.meanAbsDiff255.toFixed(3)}/255   max ${diff.maxAbsDiff255}/255   tolerance ${tolerance}/255`);
+  console.log(`  VERDICT: ${verdict}`);
+  console.log(`  ledger row appended (fsync'd): ${LEDGER_PATH}`);
+  return { row, diff, verdict };
+}
+
+// ═══════════════════════════════════════ MOCK MODE ═══════════════════════════════════════
+// Zero network, $0. Runs the REAL ImageMagick normalize (same shape as server.js) so the
+// simulated "server processing" step is genuine re-encode noise, not a canned number, then
+// simulates Shopify's own ingest re-encode with one more small quality step. --negative
+// instead corrupts the result heavily to prove the comparator goes RED on a real defect.
+async function runMock({ negative = false, tolerance, imagePath } = {}) {
+  const sentBuf = fs.readFileSync(imagePath || DEFAULT_FIXTURE);
+  console.log(`\n[MOCK${negative ? ' / NEGATIVE-TEST' : ''}] fixture: ${imagePath || DEFAULT_FIXTURE} (${sentBuf.length} bytes)`);
+  costLine('Gemini OCR (mock — not called)', 0, 'mock mode never touches the network');
+
+  // 1) simulate the server's normB64() step for real
+  const normalized = await normalizeLikeServer(sentBuf);
+  // 2) simulate Shopify's own ingest re-encode
+  let stored;
+  try { stored = await runConvert(['-', '-quality', '92', 'jpg:-'], normalized); } catch (e) { stored = normalized; }
+  if (negative) {
+    // inject a real, detectable defect: heavy gaussian-ish noise + a color roll, well past
+    // any plausible JPEG re-encode tolerance — proves the comparator actually reddens.
+    try { stored = await runConvert(['-', '-modulate', '40,300,180', '+noise', 'Gaussian', 'jpg:-'], stored); }
+    catch (e) { stored = Buffer.concat([stored, Buffer.from('CORRUPT')]); }
+  }
+
+  const dw_sku = `PROV-MOCKTEST-${Date.now().toString(36).toUpperCase()}`;
+  const productId = 900000000000 + Math.floor(Math.random() * 1e6);
+  const mediaId = 800000000000 + Math.floor(Math.random() * 1e6);
+  const tol = tolerance != null ? tolerance : DEFAULT_TOLERANCE;
+  return verifyAndLedger({
+    mode: negative ? 'mock-negative' : 'mock',
+    sentBuf, storedBuf: stored,
+    productId, productGid: `gid://shopify/Product/${productId}`,
+    mediaId, mediaGid: `gid://shopify/ProductImage/${mediaId}`,
+    dwSku: dw_sku, tolerance: tol,
+    undoCmd: `node scripts/camera-committrue-proof.mjs --delete-product ${productId}   # NOT REAL — mock product, nothing exists on Shopify`,
+    leftInPlace: false,
+    extra: { simulated: true, note: 'mock run — no Shopify product was actually created' },
+  });
+}
+
+// ═══════════════════════════════════════ LIVE MODE ═══════════════════════════════════════
+// HARD-GATED. Real network. Real Shopify write. This function is NEVER invoked by the
+// author of this script — only Steve, via the exact paste recorded in the TK-12126 memo.
+const DEFAULT_TOLERANCE = 6; // mean abs diff per channel, 0-255 scale — see header for rationale
+async function runLive(o) {
+  const baseUrl = o.baseUrl || 'https://photo.designerwallcoverings.com';
+  const authUser = o.authUser || process.env.AUTH_USER || 'admin';
+  const authPass = o.authPass || process.env.AUTH_PASS || 'DW2024!';
+  const token = loadShopifyToken();
+  if (!token) { console.error('ERROR: no SHOPIFY_ADMIN_TOKEN (env or secrets-manager/.env) — cannot re-fetch/verify. Aborting before any write.'); process.exit(2); }
+
+  const imagePath = o.imagePath || DEFAULT_FIXTURE;
+  const sentBuf = fs.readFileSync(imagePath);
+  const dataUrl = 'data:image/jpeg;base64,' + sentBuf.toString('base64');
+  const authHeader = basicAuthHeader(authUser, authPass);
+
+  console.log(`\n[LIVE] target: ${baseUrl}`);
+  console.log(`[LIVE] fixture: ${imagePath} (${sentBuf.length} bytes)`);
+  console.log('[LIVE] *** THIS WILL CREATE ONE REAL SHOPIFY DRAFT PRODUCT ON', SHOP, '***');
+
+  // Step 1 (optional): Gemini OCR — mirrors a real "scan the label" capture step.
+  let ocrCost = 0;
+  if (!o.skipOcr) {
+    try {
+      const r = await requestJson(`${baseUrl}/api/extract`, { method: 'POST', headers: { Authorization: authHeader, 'Content-Type': 'application/json' }, body: JSON.stringify({ dataUrl }) });
+      ocrCost = (r.json && r.json.cost_usd) || 0;
+      costLine('Gemini OCR (/api/extract)', ocrCost, r.json && r.json.err ? `err: ${r.json.err}` : 'actual, per response');
+      if (r.json && r.json.fields) console.log('  OCR fields (expected mostly empty — fixture is a roll photo, not a label):', JSON.stringify(r.json.fields));
+    } catch (e) { console.log(`  OCR call failed (non-fatal, proceeding): ${e.message}`); }
+  } else {
+    costLine('Gemini OCR', 0, 'skipped (--skip-ocr)');
+  }
+
+  // Step 2: THE step under test — commit:true through the real production endpoint.
+  const mfr = o.mfr || `ZZTEST-CAM-${Date.now().toString(36).toUpperCase()}`;
+  const vendor = o.vendor || 'ZZTESTVENDOR';
+  const createBody = {
+    mfr, vendor, name: 'TK-12126 Camera Round-Trip Proof — DELETE ME', back_present: true,
+    photos: [dataUrl], commit: true,
+  };
+  const cr = await requestJson(`${baseUrl}/api/create-item`, { method: 'POST', headers: { Authorization: authHeader, 'Content-Type': 'application/json' }, body: JSON.stringify(createBody), timeout: 60000 });
+  if (!cr.json || cr.json.ok !== true || !cr.json.product_id) {
+    console.error('ERROR: create-item did not return a product — no draft was created (or response unparseable). Nothing to clean up.');
+    console.error('  status:', cr.status, '  body:', JSON.stringify(cr.json || cr.raw).slice(0, 500));
+    process.exit(2);
+  }
+  const productId = cr.json.product_id, dwSku = cr.json.dw_sku;
+  const productGid = `gid://shopify/Product/${productId}`;
+  console.log(`  ✅ created DRAFT product ${productId} (dw_sku ${dwSku})`);
+
+  // Step 3: INDEPENDENT re-fetch — direct Shopify Admin REST call, never the create response's
+  // own echo, so this genuinely proves what got PERSISTED, not just what got POSTed.
+  const imgList = await shopifyReq('GET', `/products/${productId}/images.json`, token);
+  const media = imgList.body && imgList.body.images && imgList.body.images[0];
+  if (!media || !media.src) {
+    console.error('ERROR: draft was created but has no image on re-fetch. Product left in place for inspection:', productId);
+    appendLedgerFsync({ ts: new Date().toISOString(), agent: AGENT, ticket: TICKET, mode: 'live', action: 'camera commit:true round-trip proof', product_id: productId, product_gid: productGid, verdict: 'FAIL', err: 'no image on re-fetch', undo_cmd: `node scripts/camera-committrue-proof.mjs --delete-product ${productId}`, left_in_place: true });
+    process.exit(2);
+  }
+  const mediaId = media.id;
+  const mediaGid = `gid://shopify/ProductImage/${mediaId}`; // REST-legacy id (create used the REST endpoint, not GraphQL productCreateMedia)
+  const dl = await request(media.src, { timeout: 30000 });
+  const storedBuf = dl.buf;
+  console.log(`  re-fetched image ${mediaId} (${storedBuf.length} bytes) from ${media.src.slice(0, 80)}…`);
+
+  const undoCmd = `node scripts/camera-committrue-proof.mjs --delete-product ${productId}`;
+  const { verdict } = await verifyAndLedger({
+    mode: 'live', sentBuf, storedBuf, productId, productGid, mediaId, mediaGid, dwSku,
+    tolerance: o.tolerance != null ? o.tolerance : DEFAULT_TOLERANCE,
+    undoCmd, leftInPlace: true,
+    extra: { gemini_ocr_cost_usd: ocrCost, target: baseUrl, mfr, vendor },
+  });
+
+  if (o.autoDelete) {
+    if (verdict === 'PASS') {
+      console.log(`\n  --auto-delete: verdict PASS, deleting draft product ${productId}…`);
+      const del = await deleteProduct(productId, token);
+      console.log(`  delete ${del.ok ? 'succeeded' : 'FAILED'} (HTTP ${del.status})`);
+      appendLedgerFsync({ ts: new Date().toISOString(), agent: AGENT, ticket: TICKET, mode: 'live-cleanup', action: 'auto-delete after PASS', product_id: productId, product_gid: productGid, deleted: del.ok, http_status: del.status });
+    } else {
+      console.log(`\n  --auto-delete requested but verdict was ${verdict} — LEAVING the draft in place for inspection (never auto-delete evidence of a failure).`);
+    }
+  } else {
+    console.log(`\n  draft product ${productId} LEFT IN PLACE (default). Undo when ready:\n    ${undoCmd}`);
+  }
+  process.exit(verdict === 'PASS' ? 0 : 1);
+}
+
+// ── self-test: proves the comparator PASSES clean data and FAILS corrupted data, entirely
+//    offline. This is the "unit-tested locally (mocked Shopify calls, no live writes)" gate
+//    this script must clear before it's allowed to be handed to Steve as a live candidate. ──
+async function runSelfTest() {
+  console.log('═══ SELF-TEST (mock, $0, no network) ═══');
+  const good = await runMock({ negative: false });
+  const bad = await runMock({ negative: true });
+  let ok = true;
+  if (good.verdict !== 'PASS') { console.error(`✗ FAIL: clean mock round-trip should PASS, got ${good.verdict} (mean diff ${good.diff.meanAbsDiff255})`); ok = false; }
+  else console.log(`✓ clean mock round-trip PASSED (mean diff ${good.diff.meanAbsDiff255}/255, tolerance ${DEFAULT_TOLERANCE}/255)`);
+  if (bad.verdict !== 'FAIL') { console.error(`✗ FAIL: negative-test (injected corruption) should FAIL, got ${bad.verdict} (mean diff ${bad.diff.meanAbsDiff255}) — the comparator cannot detect real defects!`); ok = false; }
+  else console.log(`✓ negative-test correctly FAILED (mean diff ${bad.diff.meanAbsDiff255}/255 >> tolerance ${DEFAULT_TOLERANCE}/255) — comparator proven to go RED on a real defect`);
+  console.log(`\n═══ SELF-TEST: ${ok ? 'PASS — script is ready to hand to Steve for a --live run' : 'FAIL — do not hand this to Steve yet'} ═══`);
+  process.exit(ok ? 0 : 1);
+}
+
+// ── dispatch ─────────────────────────────────────────────────────────────────────────────
+(async () => {
+  try {
+    if (flag('--help') || flag('-h')) {
+      console.log(fs.readFileSync(__filename, 'utf8').split('════\n')[0]);
+      process.exit(0);
+    }
+    if (flag('--self-test')) return await runSelfTest();
+    const delId = opt('--delete-product', null);
+    if (delId) {
+      const token = loadShopifyToken();
+      if (!token) { console.error('ERROR: no SHOPIFY_ADMIN_TOKEN available — cannot delete.'); process.exit(2); }
+      console.log(`Deleting Shopify product ${delId} on ${SHOP} …`);
+      const r = await deleteProduct(delId, token);
+      console.log(r.ok ? `✅ deleted ${delId}` : `❌ delete FAILED (HTTP ${r.status}): ${r.raw}`);
+      appendLedgerFsync({ ts: new Date().toISOString(), agent: AGENT, ticket: TICKET, mode: 'manual-delete', action: `delete product ${delId}`, product_id: Number(delId) || delId, deleted: r.ok, http_status: r.status });
+      process.exit(r.ok ? 0 : 1);
+    }
+    if (flag('--live')) {
+      return await runLive({
+        baseUrl: opt('--base-url', null),
+        authUser: opt('--auth-user', null),
+        authPass: opt('--auth-pass', null),
+        imagePath: opt('--image', null),
+        mfr: opt('--mfr', null),
+        vendor: opt('--vendor', null),
+        tolerance: opt('--tolerance', null) != null ? Number(opt('--tolerance', null)) : null,
+        skipOcr: flag('--skip-ocr'),
+        autoDelete: flag('--auto-delete'),
+      });
+    }
+    // default: single mock run (or --negative-test)
+    const r = await runMock({ negative: flag('--negative-test'), imagePath: opt('--image', null), tolerance: opt('--tolerance', null) != null ? Number(opt('--tolerance', null)) : null });
+    process.exit(r.verdict === 'PASS' ? 0 : 1);
+  } catch (e) {
+    console.error('FATAL:', e && e.stack || e);
+    process.exit(3);
+  }
+})();

← a1b8ee3 Extract shared acquireCamera() helper, adopt across 5 unboun  ·  back to Dw Photo Capture  ·  TK-12126: never report Gemini OCR cost for a call that error 8364615 →