← back to Sanderson Onboard

scripts/verify_cadence_weights.mjs

191 lines

// verify_cadence_weights.mjs — TK-11471 (TK-11414 follow-on).
// READ-ONLY proof that the SDG publish cadence no longer ships zero-weight products live.
//
// WHY THIS EXISTS: TK-11414 fixed create_sdg.mjs (commit 8d09eed) so the create payload carries
// weight and goLive self-heals any zero-weight variant before activating. But "the fix is committed"
// is NOT "the fix works in a real cadence run" — the cadence is the thing that put 385 zero-weight
// variants live overnight in the first place. This asserts the invariant on EXACTLY the products a
// given cadence run created, by reading their inventoryItem ids straight out of create-audit.jsonl
// and asking LIVE Shopify what weight each one actually carries.
//
// It is deliberately NOT "count the zero-weight rows in the catalog" — that is the canary's job and
// it cannot attribute an offender to a run. This measures the RUN.
//
// NEGATIVE TEST BUILT IN (CLAUDE.md TK-11431 amendment 3): point it at a PRE-fix run
// (--date=YYYY-MM-DD before 2026-09-11) and it MUST come back FAIL. A verifier that only ever
// goes green on the happy path proves nothing. See --self-test.
//
// Usage:
//   node scripts/verify_cadence_weights.mjs                 # the most recent cadence run
//   node scripts/verify_cadence_weights.mjs --date=2026-09-12
//   node scripts/verify_cadence_weights.mjs --self-test     # assert a pre-fix run goes RED
// READ-ONLY: issues GraphQL *queries* only. No mutation, no write, $0.
import fs from 'node:fs';

const env = fs.readFileSync('/Users/macstudio3/Projects/secrets-manager/.env', 'utf8');
const STORE = (env.match(/^SHOPIFY_STORE=(.+)$/m) || [])[1].trim();
const TOKEN = (env.match(/^SHOPIFY_FULL_ACCESS_TOKEN=(.+)$/m) || [])[1].trim();
const GQL = `https://${STORE}/admin/api/2024-10/graphql.json`;
const H = { 'X-Shopify-Access-Token': TOKEN, 'Content-Type': 'application/json' };
const AUDIT = new URL('../pilot/create-audit.jsonl', import.meta.url).pathname;

const arg = k => (process.argv.find(a => a.startsWith(`--${k}=`)) || '').split('=')[1] || null;
const SELF_TEST = process.argv.includes('--self-test');
const sleep = ms => new Promise(r => setTimeout(r, ms));

// ── audit -> the iids a run created ────────────────────────────────────────────────────────────
// create-audit.jsonl has no timestamp field, so a run is identified by file mtime ordering is NOT
// safe. Instead we bucket by the audit line order against cadence.log run headers: every line
// appended between two "SDG cadence run" headers belongs to that run. We reconstruct that by
// reading cadence.log for the per-run CREATED sku lists.
function runsFromLog() {
  const log = fs.readFileSync(new URL('../pilot/cadence.log', import.meta.url).pathname, 'utf8').split('\n');
  const runs = []; let cur = null;
  for (const line of log) {
    const h = line.match(/^=== (\d{4}-\d{2}-\d{2})T[\d:]+Z SDG cadence run/);
    if (h) { cur = { date: h[1], skus: [] }; runs.push(cur); continue; }
    const c = line.match(/^\s*✓ (\S+) → \d+ ACTIVE/);
    if (c && cur) cur.skus.push(c[1]);
  }
  return runs.filter(r => r.skus.length);
}

function iidsForSkus(skus) {
  const want = new Set(skus);
  const out = [];               // {dw, sku, iid, isSample}
  for (const line of fs.readFileSync(AUDIT, 'utf8').split('\n')) {
    if (!line.trim()) continue;
    let r; try { r = JSON.parse(line); } catch { continue; }
    if (!want.has(r.dw) || r.action !== 'CREATED') continue;
    for (const v of (r.variants || [])) {
      if (v.iid) out.push({ dw: r.dw, sku: v.sku, iid: v.iid, isSample: /-Sample$/i.test(v.sku || '') });
    }
  }
  return out;
}

async function gql(query, variables) {
  const res = await fetch(GQL, { method: 'POST', headers: H, body: JSON.stringify({ query, variables }) });
  if (!res.ok) throw new Error(`HTTP ${res.status}`);
  const j = await res.json();
  if (j.errors) throw new Error(JSON.stringify(j.errors).slice(0, 300));
  return j.data;
}

// Batched read of inventoryItem weights. `nodes` takes up to 250 ids; we chunk at 100 to stay light.
const Q = `query($ids:[ID!]!){ nodes(ids:$ids){ ... on InventoryItem { id measurement { weight { value unit } } } } }`;

async function weightsFor(iids) {
  const map = new Map();
  for (let i = 0; i < iids.length; i += 100) {
    const chunk = iids.slice(i, i + 100).map(n => `gid://shopify/InventoryItem/${n}`);
    const d = await gql(Q, { ids: chunk });
    for (const n of (d.nodes || [])) {
      if (!n || !n.id) continue;                       // a null node = id not found -> stays UNMEASURED
      const w = n.measurement?.weight;
      const val = w?.value == null ? null : Number(w.value);
      const unit = String(w?.unit || '').toLowerCase();
      const lb = val == null ? null : (unit === 'kilograms' ? val * 2.20462 : unit === 'grams' ? val / 453.59237 : val);
      map.set(Number(n.id.split('/').pop()), lb);
    }
    await sleep(250);
  }
  return map;
}

// PURE classifier (no I/O) — the offline negative test drives THIS directly, because every
// pre-fix cadence run has since been healed by the TK-11414 backfill, so a live "point it at a
// known-bad run" self-test is structurally unavailable. A positive-only test on a detector proves
// nothing, so the red path is proven against injected faults instead (CLAUDE.md TK-11431 am.3).
export function classify(rows, weightMap, date = 'fixture') {
  const zero = [], unmeasured = [];
  for (const r of rows) {
    if (!weightMap.has(r.iid)) { unmeasured.push(r); continue; }   // never silently treated as OK
    const lb = weightMap.get(r.iid);
    if (lb == null || !Number.isFinite(lb) || lb <= 0) zero.push({ ...r, lb });
  }
  // An UNMEASURED input is never PASS (CLAUDE.md TK-11431 amendment 1).
  const verdict = zero.length ? 'FAIL' : unmeasured.length ? 'UNKNOWN' : 'PASS';
  const status  = zero.length ? 'FAIL' : unmeasured.length ? 'WARN' : 'PASS';
  return {
    date, variants_measured: rows.length - unmeasured.length, variants_total: rows.length,
    zero_weight: zero.length,
    zero_weight_sample: zero.filter(z => z.isSample).length,
    zero_weight_sellable: zero.filter(z => !z.isSample).length,
    unmeasured: unmeasured.length, verdict, status,
    detail: zero.length
      ? `${zero.length} of ${rows.length} variants created by the ${date} cadence run are LIVE at zero/null weight (${zero.filter(z=>z.isSample).length} sample, ${zero.filter(z=>!z.isSample).length} sellable) — the SDG weight fix did NOT hold for this run.`
      : unmeasured.length
      ? `${unmeasured.length} of ${rows.length} variants could not be read from live Shopify — invariant NOT asserted (never reported as PASS).`
      : `all ${rows.length} variants created by the ${date} cadence run carry positive weight — SDG weight fix held.`,
    sample_offenders: zero.slice(0, 10).map(z => ({ sku: z.sku, iid: z.iid, lb: z.lb })),
  };
}

async function verifyRun(run) {
  const rows = iidsForSkus(run.skus);
  if (!rows.length) return { date: run.date, verdict: 'UNKNOWN', status: 'WARN',
    detail: `no inventoryItem ids in create-audit.jsonl for the ${run.date} run (${run.skus.length} skus in log) — cannot assert, NOT a pass` };

  const w = await weightsFor(rows.map(r => r.iid));
  return { products: run.skus.length, ...classify(rows, w, run.date) };
}

const FIX_DATE = '2026-09-11';   // create_sdg.mjs weight fix committed 2026-09-11T16:17Z, AFTER that day's 11:30Z run

// HEARTBEAT — the verifier is the SINGLE writer of its own verdict. Doing this in shell was tried
// and silently wrote nothing (env var never reached the child), which is a false-NOTHING: the
// morning panel would show no row at all rather than a warning. A component must be able to report
// its own failure, so every exit path below — including an uncaught throw — writes a verdict.
const HEARTBEAT = (process.argv.find(a => a.startsWith('--heartbeat=')) || '').split('=')[1] || null;
function beat(o) {
  if (!HEARTBEAT) return;
  try {
    fs.mkdirSync(HEARTBEAT.replace(/\/[^/]+$/, ''), { recursive: true });
    fs.writeFileSync(HEARTBEAT, JSON.stringify({ ...o, scanned_at: new Date().toISOString(), skill: 'sdg-cadence-weight-verify' }, null, 2));
  } catch (e) { console.error('heartbeat write failed: ' + e.message); }
}

const INVOKED_DIRECTLY = process.argv[1] && import.meta.url.endsWith(process.argv[1].split('/').pop());
if (INVOKED_DIRECTLY) await (async () => {
  const runs = runsFromLog();
  if (!runs.length) { beat({ verdict: 'UNKNOWN', status: 'WARN', detail: 'no cadence runs found in pilot/cadence.log — the SDG per-run weight invariant was NOT asserted' }); console.error('no cadence runs found in pilot/cadence.log'); process.exit(2); }

  if (SELF_TEST) {
    // NEGATIVE TEST: the newest PRE-fix run must come back FAIL. If it does not, this verifier is
    // measuring the wrong thing and must not be trusted on a post-fix run.
    const pre = runs.filter(r => r.date <= FIX_DATE).pop();
    if (!pre) { console.error('SELF-TEST INCONCLUSIVE: no pre-fix run in the log'); process.exit(2); }
    const r = await verifyRun(pre);
    console.log(JSON.stringify(r, null, 2));
    if (r.verdict === 'FAIL') { console.log(`\nSELF-TEST PASS — verifier goes RED on the known-bad ${pre.date} pre-fix run.`); process.exit(0); }
    console.error(`\nSELF-TEST FAIL — pre-fix run ${pre.date} did not come back FAIL (got ${r.verdict}). Either those rows were backfilled after the fact, or this verifier does not actually measure weight. Do NOT trust a green post-fix result until this is explained.`);
    process.exit(1);
  }

  const want = arg('date');
  const run = want ? runs.find(r => r.date === want) : runs[runs.length - 1];
  if (!run) { beat({ verdict: 'UNKNOWN', status: 'WARN', detail: `no cadence run for date=${want} — invariant NOT asserted` }); console.error(`no cadence run for date=${want}`); process.exit(2); }
  const r = await verifyRun(run);
  r.post_fix = run.date > FIX_DATE;
  // A PASS on a PRE-fix run proves nothing about the fix — those variants were healed after the
  // fact by the TK-11414 backfill. Burying that in `detail` while shipping a top-level PASS is
  // itself the false-green this skill exists to prevent: fleet-health-rollup renders the panel
  // dot from the top-level verdict, so a glance-only reader sees green on evidence the code
  // itself admits is not evidence. Per CLAUDE.md TK-11431 amendment 1 — an unmeasured input is
  // never PASS — a pre-fix run is downgraded to WARN and only a post_fix run can be green.
  if (r.verdict === 'PASS' && !r.post_fix) {
    r.verdict = 'UNKNOWN';
    r.status  = 'WARN';
    r.domain_verdict = 'PASS-PREFIX';
    r.detail += ` NOT EVIDENCE: post_fix=false — the ${r.date} run predates the create_sdg.mjs weight fix (8d09eed, 2026-09-11T16:17Z), so these variants carry weight because the TK-11414 backfill healed them after the fact, NOT because the fix held. Downgraded PASS->WARN so the panel cannot read green on a run that proves nothing. The first genuinely post-fix run is 2026-09-12T11:30Z.`;
  }
  beat(r);
  console.log(JSON.stringify(r, null, 2));
  process.exit(r.verdict === 'PASS' ? 0 : r.verdict === 'FAIL' ? 1 : 2);
})().catch(e => {
  beat({ verdict: 'UNKNOWN', status: 'WARN', detail: 'weight verifier threw: ' + (e && e.message ? e.message : String(e)) + ' — the SDG per-run weight invariant was NOT asserted for this run' });
  console.error('verify_cadence_weights FAILED: ' + (e && e.stack ? e.stack : e));
  process.exit(2);
});