[object Object]

← back to Sample Followup Sweep

sent-stamp watcher: fold review findings (never overwrite a chase date)

d523fb1916a8e0cbb3b0ae043358665c4d56dac4 · 2026-09-26 08:56:41 -0700 · Steve Abrams

- covered-only is no longer terminal: a sibling item in the same letter
  may not be in FileMaker yet, so the send keeps retrying.
- per-record read-before-write: skip any record that already carries a
  chase date, independent of the already-stamped set; refuse to stamp a
  send if that set hit its 800-row cap.
- an older unfinished send in backoff is pulled forward when a newer send
  to the same recipient is due, so the older letter keeps its date.
Tests extended; negative fault injection still goes red. TK-12320

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

Files touched

Diff

commit d523fb1916a8e0cbb3b0ae043358665c4d56dac4
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Sat Sep 26 08:56:41 2026 -0700

    sent-stamp watcher: fold review findings (never overwrite a chase date)
    
    - covered-only is no longer terminal: a sibling item in the same letter
      may not be in FileMaker yet, so the send keeps retrying.
    - per-record read-before-write: skip any record that already carries a
      chase date, independent of the already-stamped set; refuse to stamp a
      send if that set hit its 800-row cap.
    - an older unfinished send in backoff is pulled forward when a newer send
      to the same recipient is due, so the older letter keeps its date.
    Tests extended; negative fault injection still goes red. TK-12320
    
    Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
    Claude-Session: https://claude.ai/code/session_01NDCCv6ZcKPKfWxof41UzvC
---
 scripts/sent-stamp-core.mjs   | 21 +++++++++++++++------
 scripts/watch-sent-stamp.mjs  |  6 ++++++
 test/sent-stamp-retry.test.js | 16 ++++++++++++++--
 3 files changed, 35 insertions(+), 8 deletions(-)

diff --git a/scripts/sent-stamp-core.mjs b/scripts/sent-stamp-core.mjs
index 5cad9e1..9da40e8 100644
--- a/scripts/sent-stamp-core.mjs
+++ b/scripts/sent-stamp-core.mjs
@@ -24,11 +24,11 @@ export function sendDateOf(msg, now) {
 // Only a send that actually stamped something, with no failed commits, is finished. A skip
 // (no vid mapping yet) or stamped:0 (mapping was wrong / records not visible yet) must be retried,
 // otherwise a send that landed before its mapping existed is poisoned forever and re-chased.
-// `covered` = letter items found already stamped (by an earlier send of the same letter or another
-// path) — nothing left for this send to do, so it is finished too.
+// `covered` (letter items already stamped by an earlier send) deliberately does NOT finish a send:
+// a sibling item in the same letter may not be visible in FileMaker yet, and retries are cheap.
 export function isTerminal(entry) {
   if (!entry || entry.skip || Number(entry.failed) > 0) return false;
-  return Number(entry.stamped) >= 1 || Number(entry.covered) >= 1;
+  return Number(entry.stamped) >= 1;
 }
 
 export function shouldProcess(entry, { now, retrySecs, dry }) {
@@ -97,11 +97,20 @@ export async function runPass({ msgs, ledger, now, dry, retrySecs, minAgeDays, m
   let acted = 0, changed = false;
   // Oldest send first: the FIRST letter that listed a memo owns its chase date; later resends then
   // find the record already stamped instead of overwriting the slot with a later date.
-  const ordered = [...msgs].sort((a, b) => sendDateOf(a, now).date - sendDateOf(b, now).date);
+  const ordered = msgs.filter(m => m && m.id && !m.error)   // metadata fetch failed — try again next pass
+    .sort((a, b) => sendDateOf(a, now).date - sendDateOf(b, now).date);
+  const decision = new Map(ordered.map(m => [m.id, shouldProcess(seen[m.id], { now, retrySecs, dry })]));
+  // An older non-terminal send in backoff must not let a newer send to the same recipient take the
+  // chase date first: if any newer send is due, pull every older unfinished one forward with it.
+  const recip = m => (String(m.to || '').toLowerCase().match(/[\w.+-]+@[\w.-]+\.\w+/g) || []).sort().join(',');
+  const dueRecips = new Set(ordered.filter(m => decision.get(m.id).go).map(recip));
+  for (const m of ordered) {
+    const d = decision.get(m.id);
+    if (!d.go && d.why === 'backoff' && dueRecips.has(recip(m))) decision.set(m.id, { go: true, why: 'retry-ahead-of-newer' });
+  }
   for (const m of ordered) {
-    if (!m || !m.id || m.error) continue;                 // metadata fetch failed — try again next pass
     const prev = seen[m.id];
-    const d = shouldProcess(prev, { now, retrySecs, dry });
+    const d = decision.get(m.id);
     if (!d.go) continue;
     const vids = vidsForRecipient(deps.emailVid, m.to);
     if (!vids.length) {
diff --git a/scripts/watch-sent-stamp.mjs b/scripts/watch-sent-stamp.mjs
index 1281216..641d694 100644
--- a/scripts/watch-sent-stamp.mjs
+++ b/scripts/watch-sent-stamp.mjs
@@ -82,6 +82,7 @@ async function stampVids(vids, bodyText, win, sentOn) {
   let Bs;
   try { Bs = await findAll(LAYOUT, { [FIELD_SENT]: '*', 'Date WP Sample Sent': '=', 'today for client': win }, 800); }
   catch (e) { console.warn(`  ⚠ FM already-stamped lookup failed (${win}): ${e.message}`); return { stamped: [], failed: 1, covered: 0 }; }
+  if (Bs.length >= 800) { console.warn(`  ⚠ already-stamped lookup hit its 800 cap (${win}) — not stamping this send`); return { stamped: [], failed: 1, covered: 0 }; }
   const already = new Set(Bs.map(r => String(r.recordId)));
   const stamped = [], done = new Set(); let failed = 0, covered = 0;
   for (const id of dryStamped) already.add(id);   // dry: mirror what earlier sends in this pass would have stamped
@@ -96,6 +97,11 @@ async function stampVids(vids, bodyText, win, sentOn) {
       done.add(id);
       if (already.has(id)) { covered++; continue; }        // already carries a chase date — never overwrite
       if (DRY_RUN) { dryStamped.add(id); stamped.push(mfr + ' [dry]'); continue; }
+      // Last-line guard, independent of the set above: never overwrite an existing chase date.
+      let cur;
+      try { cur = await fm.getRecord(DB, LAYOUT, r.recordId); }
+      catch (e) { failed++; console.warn(`  ⚠ FM read failed for ${mfr} (recordId ${r.recordId}): ${e.message}`); continue; }
+      if (String(cur?.fieldData?.[FIELD_SENT] || '').trim()) { covered++; continue; }
       // Steve 8/25 asked for BOTH fields here, and at the time that was right: the 2nd-request
       // field had no separate meaning, so writing it alongside was the only way to populate it.
       // TK-11409 (Steve, 2026-09-10) gave it a real meaning — "a 2nd request was sent" — and this
diff --git a/test/sent-stamp-retry.test.js b/test/sent-stamp-retry.test.js
index c61226d..5b64849 100644
--- a/test/sent-stamp-retry.test.js
+++ b/test/sent-stamp-retry.test.js
@@ -82,9 +82,21 @@ const { pathToFileURL } = require('node:url');
   await core.runPass({ msgs: [msgs[2], msgs[1], msgs[0]], ledger: {}, now: NOW, dry: true, retrySecs: 900, minAgeDays: 10, maxAgeDays: 60,
     deps: { ...deps, stamp: async (v, body) => { order.push(body); return { stamped: [] }; } } });
   assert.deepEqual(order, ['letter mvah', 'letter m1838', 'letter mkra'], 'must process oldest send first');
-  // every letter item already stamped (covered) is finished; stamped:0 with nothing covered is not
-  assert.equal(core.isTerminal({ vids: ['X'], stamped: 0, covered: 1, failed: 0 }), true);
+  // covered-only is NOT finished (a sibling letter item may not be in FM yet)
+  assert.equal(core.isTerminal({ vids: ['X'], stamped: 0, covered: 1, failed: 0 }), false);
   assert.equal(core.isTerminal({ vids: ['X'], stamped: 0, covered: 0, failed: 0 }), false);
+  // an older send in backoff is pulled forward when a newer send to the same recipient is due,
+  // so the older letter still owns the chase date
+  {
+    const older = { id: 'old', to: 'a@v.com', internalDate: String(SENT_VAH) };
+    const newer = { id: 'new', to: 'A <a@v.com>', internalDate: String(SENT_KRA) };
+    const ev = core.buildEmailVid([{ slug: 'v', vid: 'V' }], { v: { sample_email: 'a@v.com' } });
+    const seenOrder = [];
+    const led = { old: { vids: ['V'], stamped: 0, last_try: new Date(NOW - 60e3).toISOString() } };   // in backoff
+    await core.runPass({ msgs: [newer, older], ledger: led, now: NOW, dry: false, retrySecs: 900, minAgeDays: 10, maxAgeDays: 60,
+      deps: { emailVid: ev, getBody: async id => id, stamp: async (v, body, w, sentOn) => { seenOrder.push(`${body}@${sentOn}`); return { stamped: [] }; } } });
+    assert.deepEqual(seenOrder, ['old@09/13/2026', 'new@09/24/2026'], 'older backoff send must run before the newer one');
+  }
   // failed FM commit is NOT terminal even with stamped>=1
   assert.equal(core.isTerminal({ stamped: 2, failed: 1 }), false);
   // send-date fallbacks

← 523a32c watch-sent-stamp: refuse unknown args instead of running a l  ·  back to Sample Followup Sweep  ·  5x sweep 1: npm test runs .cjs suites and fails on any faili 32af305 →