[object Object]

← back to Sample Followup Sweep

TK-11409: pass 2 requires send-history evidence, and can no longer fail silently

1c8e587759111d031ea85bffcad39e48cb649ad4 · 2026-09-10 10:47:15 -0700 · Steve Abrams

DTD verdict B, amended by the reviewer's dissent.

Root cause of the inversion, now explained: the 2nd-request field only started
being written on 2026-08-25 (32145b7). The 8 rows pass 2 was matching were stamped
08/14-08/15 -- BEFORE that field was ever populated -- so their empty 2nd-request
field meant 'this column did not exist yet', not 'awaiting a 2nd request'.
fmpro-posted.json corroborates (GREEN 7 + VER 1 = the exact 8). DB field state is
not contact history.

- sendEvidence(slug) reads sent.json as the evidence oracle and returns
  {count, addrs, known}.  (no record at all) is deliberately distinct
  from count<2, because conflating them is how a no-op hides.
- planVendor pass 2 refuses without >=2 recorded sends and sets an explicit _skip
  REASON. It never returns a bare empty list.
- plan --pass 2 prints a REFUSED block naming every vendor and why.
- stampVendor returns , so a caller can tell 'refused for lack of evidence'
  from 'nothing left to do'.
- stampFmpro no longer uses stdio:'ignore' -- the root enabler of BOTH bugs in this
  ticket -- and appends to data/fmpro-stamp.log. Still detached and non-blocking, so
  a FileMaker hiccup still cannot fail a send.

Verified read-only against LIVE FileMaker:
  pass 2 before: 8 records, all false positives
  pass 2 after:  0 records, 29 vendors refused with a named reason
  pass 1:        1 record, unchanged (no regression)
  the 5 slugs with a genuine 2nd send (dgd/osb/thi/thib/christian-lacroix-europe)
  pass the gate -- so it does not over-refuse.
No records written.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

Files touched

Diff

commit 1c8e587759111d031ea85bffcad39e48cb649ad4
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Thu Sep 10 10:47:15 2026 -0700

    TK-11409: pass 2 requires send-history evidence, and can no longer fail silently
    
    DTD verdict B, amended by the reviewer's dissent.
    
    Root cause of the inversion, now explained: the 2nd-request field only started
    being written on 2026-08-25 (32145b7). The 8 rows pass 2 was matching were stamped
    08/14-08/15 -- BEFORE that field was ever populated -- so their empty 2nd-request
    field meant 'this column did not exist yet', not 'awaiting a 2nd request'.
    fmpro-posted.json corroborates (GREEN 7 + VER 1 = the exact 8). DB field state is
    not contact history.
    
    - sendEvidence(slug) reads sent.json as the evidence oracle and returns
      {count, addrs, known}.  (no record at all) is deliberately distinct
      from count<2, because conflating them is how a no-op hides.
    - planVendor pass 2 refuses without >=2 recorded sends and sets an explicit _skip
      REASON. It never returns a bare empty list.
    - plan --pass 2 prints a REFUSED block naming every vendor and why.
    - stampVendor returns , so a caller can tell 'refused for lack of evidence'
      from 'nothing left to do'.
    - stampFmpro no longer uses stdio:'ignore' -- the root enabler of BOTH bugs in this
      ticket -- and appends to data/fmpro-stamp.log. Still detached and non-blocking, so
      a FileMaker hiccup still cannot fail a send.
    
    Verified read-only against LIVE FileMaker:
      pass 2 before: 8 records, all false positives
      pass 2 after:  0 records, 29 vendors refused with a named reason
      pass 1:        1 record, unchanged (no regression)
      the 5 slugs with a genuine 2nd send (dgd/osb/thi/thib/christian-lacroix-europe)
      pass the gate -- so it does not over-refuse.
    No records written.
    
    Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
---
 scripts/fmpro.mjs | 44 +++++++++++++++++++++++++++++++++++++++++++-
 server.js         | 12 +++++++++++-
 2 files changed, 54 insertions(+), 2 deletions(-)

diff --git a/scripts/fmpro.mjs b/scripts/fmpro.mjs
index 60535e5..16aff9d 100644
--- a/scripts/fmpro.mjs
+++ b/scripts/fmpro.mjs
@@ -106,6 +106,14 @@ async function matchPageSku(dw, requested, pass = 1) {
 
 // --- plan/stamp one vendor from its exact page SKUs (fleet.json items) ---
 async function planVendor(v, pass = 1) {
+  // TK-11409 gate: pass 2 asserts "a 2nd request was sent", so it requires positive
+  // evidence that one actually was. Returns a REASON, never a bare empty list — a silent
+  // zero-row result is the exact failure this ticket exists to kill.
+  if (pass === 2) {
+    const ev = sendEvidence(v.slug);
+    if (!ev.known) { v._skip = `NO SEND RECORD for ${v.slug} (${ev.addrs.join(', ') || 'no address'}) — cannot assert a 2nd request; refusing`; return []; }
+    if (ev.count < 2) { v._skip = `only ${ev.count} recorded send(s) for ${v.slug} — no 2nd request happened; refusing`; return []; }
+  }
   const seen = new Set();
   const rows = [];
   for (const it of (v.items || [])) {
@@ -129,7 +137,9 @@ async function stampVendor(v, pass = 1) {
     const res = await fm.updateRecord(DB, LAYOUT, r.recordId, fields, { dryRun: false });
     if (res.committed) stamped.push(r);
   }
-  return { vid: v.vid, date: v.date, pass, count: stamped.length, found: rows.length, stamped };
+  // `skip` distinguishes "refused for lack of evidence" from "nothing left to do" —
+  // both yield 0 rows, and conflating them is what made the original bug invisible.
+  return { vid: v.vid, date: v.date, pass, count: stamped.length, found: rows.length, stamped, skip: v._skip || null };
 }
 
 // --- write a plain-language reply note to a specific memo record ---
@@ -153,6 +163,31 @@ function recordPosted(vid, r) {
   writePosted(p);
 }
 
+// --- how many times has this vendor actually been emailed? (TK-11409) ---
+// The evidence oracle for pass 2. DB field state is NOT contact history: rows stamped
+// before 2026-08-25 (commit 32145b7, when the 2nd-request field started being written)
+// carry a chase date with an EMPTY 2nd-request field purely because that field did not
+// exist yet — 8 such legacy rows are live right now. Matching on field-emptiness alone
+// would read those as "chased once, awaiting a 2nd request" and fabricate a 2nd-request
+// date for a resend that never happened. So pass 2 additionally demands positive evidence
+// from sent.json, the record of what was actually sent.
+// Returns { count, addrs, known } — `known:false` means we have NO send record for this
+// vendor at all, which is a DIFFERENT condition from "sent once" and must never be
+// silently treated as "no work to do".
+function sendEvidence(slug) {
+  let contacts = {}, sent = {};
+  try { contacts = JSON.parse(readFileSync(join(ROOT, 'data', 'contacts.json'), 'utf8')); } catch (e) {}
+  try { sent = JSON.parse(readFileSync(join(ROOT, 'data', 'sent.json'), 'utf8')).byEmail || {}; } catch (e) {}
+  const norm = (s) => String(s || '').toLowerCase().trim();
+  const sentMap = {}; for (const [k, v] of Object.entries(sent)) sentMap[norm(k)] = v;
+  const addrs = norm((contacts[slug] || {}).sample_email).split(',').map((a) => a.trim()).filter((a) => a.includes('@'));
+  const hits = addrs.map((a) => sentMap[a]).filter(Boolean);
+  if (!hits.length) return { count: 0, addrs, known: false };
+  // The vendor was chased N times only if EVERY recipient received N — a resend goes to
+  // the whole address list, so the minimum is the honest count.
+  return { count: Math.min(...hits.map((h) => h.count || 1)), addrs, known: true };
+}
+
 // --- sent-vendor roster: fleet vendors whose contacts email is in the Sent snapshot ---
 function sentVendors() {
   const fleet = JSON.parse(readFileSync(join(ROOT, 'data', 'fleet.json'), 'utf8')).vendors;
@@ -183,11 +218,18 @@ if (cmd === 'plan') {
   const planPass = String(arg('pass') || '1') === '2' ? 2 : 1;
   const vendors = sentVendors();
   const plan = [];
+  const refused = [];
   for (const v of vendors) {
     const rows = await planVendor(v, planPass);
+    if (v._skip) refused.push(v._skip);
     rows.forEach((r) => plan.push(r));
   }
   console.log(`PLAN (read-only, pass ${planPass}${planPass === 2 ? ' = 2nd request' : ' = first chase'}) — ${plan.length} record(s) would be stamped:\n`);
+  if (refused.length) {
+    console.log(`  REFUSED (${refused.length} vendor(s)) — no evidence a 2nd request was sent:`);
+    for (const r of refused) console.log(`    · ${r}`);
+    console.log('');
+  }
   for (const p of plan) console.log(`  ${p.recordId}  ${p.date}  [${p.vid}] ${p.sku}  ${p.client}  (req ${p.requested})  — ${p.vendor}`);
   console.log(`\nJSON:`); console.log(JSON.stringify(plan));
 } else if (cmd === 'backfill') {
diff --git a/server.js b/server.js
index c9ce23f..3f9ad51 100644
--- a/server.js
+++ b/server.js
@@ -31,8 +31,18 @@ function stampFmpro(slug, pass = 1) {
   try {
     const args = [p('scripts', 'fmpro.mjs'), 'stamp', '--slug', slug];
     if (pass === 2) args.push('--pass', '2');   // TK-11409: the resend writes the 2nd-request field
+    // TK-11409: output used to go to stdio:'ignore'. That is the ROOT ENABLER of this
+    // ticket's bug — a stamp that matched zero records was indistinguishable from one that
+    // wrote every row, so the resend wrote nothing for weeks with no signal anywhere. The
+    // send still must never fail on a FileMaker hiccup, so this stays detached and
+    // non-blocking; it just no longer throws the evidence away.
+    const logPath = p('data', 'fmpro-stamp.log');
+    let out = 'ignore';
+    try { out = fs.openSync(logPath, 'a'); } catch (e) { out = 'ignore'; }
+    const stamp = `\n===== ${new Date().toISOString()} slug=${slug} pass=${pass} =====\n`;
+    if (out !== 'ignore') { try { fs.writeSync(out, stamp); } catch (e) {} }
     const child = spawn(process.execPath, args,
-      { cwd: ROOT, stdio: 'ignore', detached: true });
+      { cwd: ROOT, stdio: out === 'ignore' ? 'ignore' : ['ignore', out, out], detached: true });
     child.on('error', () => {});
     child.unref();
   } catch (e) { /* never blocks the send */ }

← 173735d TK-11409: plan --pass 2 previews the resend match set read-o  ·  back to Sample Followup Sweep  ·  auto-data-snapshot: 2026-09-10T11:08:25 (2 data files) — dat 54f5ad4 →