[object Object]

← back to George Gmail

TK-11552: drain asserts protected drafts SURVIVED, not just that it meant to skip them

b8f78c827cda8c05dd770469767d9d156fb80dfa · 2026-09-13 16:11:56 -0700 · Steve Abrams

The exemption filter and the success verdict were the same code path: matches was
built with !isExempt(d) and the verdict was (totalFail>0 ? WARN : PASS). If isExempt
were ever wrong, the run would permanently delete (no Trash) every keep-listed draft
AND report PASS. A filter cannot audit itself.

Adds an outcome-side check observed through a different read than the one that made
the decision:
  - census the keep-listed drafts that are LIVE before the drain loop
  - re-verify their presence before each batch's deletes; a loss ABORTS the pass
    (aborting can only prevent deletions, never cause one)
  - re-read once more at the end; any loss is FAIL with the ids named

NOT-MEASURED is never PASS (TK-11431 rule 1): a truncated 500-draft listing, a failed
post-run re-read, or a keep-list whose ids resolve to no live draft each degrade to
WARN with protected_verified null, rather than a clean PASS that implies protection
was proven. New heartbeat fields: protected_before, protected_lost_count,
protected_lost_ids, protected_verified, census_truncated, protection_note.

Ships test/test-protected-survival.sh + test/mock-george.js (TK-11431 rule 3): 17
assertions over 5 cases against an isolated copy and a loopback mock, proving the
check goes RED on an injected fault on BOTH abort paths (mid-drain and post-loop) and
on all three not-measured paths. Harness guards rc=127 and a missing heartbeat so a
case that never ran cannot read as a pass.

Also fixes the archive filename: stamp.slice(0,8) on an ISO stamp yielded
"archive-before-delete-2026-09-.jsonl" (month, truncated). Now slice(0,10) = the date.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EBRBeHTnkTTdi3DYLePNSY

Files touched

Diff

commit b8f78c827cda8c05dd770469767d9d156fb80dfa
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Sun Sep 13 16:11:56 2026 -0700

    TK-11552: drain asserts protected drafts SURVIVED, not just that it meant to skip them
    
    The exemption filter and the success verdict were the same code path: matches was
    built with !isExempt(d) and the verdict was (totalFail>0 ? WARN : PASS). If isExempt
    were ever wrong, the run would permanently delete (no Trash) every keep-listed draft
    AND report PASS. A filter cannot audit itself.
    
    Adds an outcome-side check observed through a different read than the one that made
    the decision:
      - census the keep-listed drafts that are LIVE before the drain loop
      - re-verify their presence before each batch's deletes; a loss ABORTS the pass
        (aborting can only prevent deletions, never cause one)
      - re-read once more at the end; any loss is FAIL with the ids named
    
    NOT-MEASURED is never PASS (TK-11431 rule 1): a truncated 500-draft listing, a failed
    post-run re-read, or a keep-list whose ids resolve to no live draft each degrade to
    WARN with protected_verified null, rather than a clean PASS that implies protection
    was proven. New heartbeat fields: protected_before, protected_lost_count,
    protected_lost_ids, protected_verified, census_truncated, protection_note.
    
    Ships test/test-protected-survival.sh + test/mock-george.js (TK-11431 rule 3): 17
    assertions over 5 cases against an isolated copy and a loopback mock, proving the
    check goes RED on an injected fault on BOTH abort paths (mid-drain and post-loop) and
    on all three not-measured paths. Harness guards rc=127 and a missing heartbeat so a
    case that never ran cannot read as a pass.
    
    Also fixes the archive filename: stamp.slice(0,8) on an ISO stamp yielded
    "archive-before-delete-2026-09-.jsonl" (month, truncated). Now slice(0,10) = the date.
    
    Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
    Claude-Session: https://claude.ai/code/session_01EBRBeHTnkTTdi3DYLePNSY
---
 delete-old-drafts-info.js       | 94 +++++++++++++++++++++++++++++++++++++++-
 test/mock-george.js             | 59 +++++++++++++++++++++++++
 test/test-protected-survival.sh | 96 +++++++++++++++++++++++++++++++++++++++++
 3 files changed, 247 insertions(+), 2 deletions(-)

diff --git a/delete-old-drafts-info.js b/delete-old-drafts-info.js
index 672a1a0..73e9352 100644
--- a/delete-old-drafts-info.js
+++ b/delete-old-drafts-info.js
@@ -196,17 +196,62 @@ async function discoverSet() {
     process.exit(0);
   }
 
+  // ---- PROTECTED-DRAFT SURVIVAL CENSUS (TK-11552) ----
+  // Why this exists: the exemption filter and the success verdict were the SAME code
+  // path. `matches` is built with `!isExempt(d)` and the verdict was
+  // `totalFail > 0 ? 'WARN' : 'PASS'` — so if isExempt were ever wrong, this run would
+  // permanently delete (no Trash) every protected draft AND report PASS. A filter
+  // cannot audit itself; the outcome has to be observed through a different path.
+  // So: census which keep-listed drafts are LIVE right now, then re-read the mailbox
+  // and assert those same drafts are still there. Note this also replaces a misleading
+  // number — the `keep-list: N draft(s) exempt` line above counts keep-list ENTRIES
+  // (currently ~27, since each item carries both a message id and a draft-id pin), not
+  // live drafts. Only the census says how many drafts are actually being protected.
+  const censusArr0 = await jget(`${BASE}/api/drafts?account=${ACC}&maxResults=500`);
+  const censusArr = Array.isArray(censusArr0) ? censusArr0 : [];
+  // maxResults caps at 500. A protected draft beyond that window is invisible to BOTH
+  // the before and after reads, so it would look "fine" while being unmeasured.
+  // NOT-MEASURED is never PASS (CLAUDE.md TK-11431 rule 1), so say so out loud.
+  const censusTruncated = censusArr.length >= 500;
+  const protectedBefore = new Map(); // live draftId -> messageId
+  for (const d of censusArr) if (isExempt(d)) protectedBefore.set(d.id, d.message && d.message.id);
+  const protectionMeasured = protectedBefore.size > 0 && !censusTruncated;
+  console.log(`protected-draft census: ${protectedBefore.size} keep-listed draft(s) LIVE` +
+    (censusTruncated ? '  [LISTING TRUNCATED AT 500 — census incomplete]' : '') +
+    (protectedBefore.size === 0 ? '  [nothing to protect — invariant not exercised this run]' : ''));
+
+  // Keep-listed drafts that were live at census time and are ABSENT from `list` now.
+  // Checked before each batch's deletes and again at the end. Deletion is permanent,
+  // so a loss ABORTS the run — aborting can only prevent deletions, never cause one.
+  const lostProtected = (list) => {
+    const live = new Set((Array.isArray(list) ? list : []).map((d) => d && d.id));
+    return [...protectedBefore.keys()].filter((id) => !live.has(id));
+  };
+
   // ---- DRAIN LOOP ----
   const logFile = `/tmp/george-old-drafts-deleted-${stamp}.jsonl`;
   // Archive path (TK-11609): fetch and save draft metadata+snippet BEFORE permanent-delete so
   // there is a local recovery record. Failure to archive is non-fatal — we log but still delete.
-  const archiveFile = path.join(__dirname, 'data', `archive-before-delete-${stamp.slice(0, 8)}.jsonl`);
+  const archiveFile = path.join(__dirname, 'data', `archive-before-delete-${stamp.slice(0, 10)}.jsonl`);
   let totalOk = 0, totalFail = 0, totalArchived = 0, batch = 0;
   const target = older.size;
   console.log(`Archive path: ${archiveFile}`);
   while (batch < MAX_BATCHES) {
     batch++;
     const drafts = await jget(`${BASE}/api/drafts?account=${ACC}&maxResults=500`);
+    // Survival check BEFORE this batch deletes anything: if a draft the keep-list is
+    // protecting has already gone missing, stop immediately rather than continue a pass
+    // whose exemption logic is demonstrably not holding.
+    const lostNow = lostProtected(drafts);
+    if (lostNow.length) {
+      console.error(`\nABORT batch ${batch}: ${lostNow.length} keep-listed draft(s) are no longer present: ${lostNow.join(', ')}`);
+      console.error('Deletion is permanent (no Trash). Stopping the pass — do not re-run until this is explained.');
+      heartbeat({ verdict: 'FAIL', status: 'FAIL', deleted: totalOk, failed: totalFail, archived: totalArchived,
+        remaining_in_set: older.size, protected_before: protectedBefore.size, protected_lost_count: lostNow.length,
+        protected_lost_ids: lostNow, protected_verified: false,
+        note: 'ABORTED mid-drain: a keep-listed draft disappeared while the drain was running' });
+      process.exit(1);
+    }
     const matches = (drafts || []).filter((d) => d && d.message && older.has(d.message.id) && !isExempt(d));
     if (matches.length === 0) { console.log(`\nbatch ${batch}: 0 matches — drain complete.`); break; }
     console.log(`\nbatch ${batch}: ${matches.length} matches (of ${Array.isArray(drafts) ? drafts.length : '?'} listed) — archiving then deleting...`);
@@ -237,5 +282,50 @@ async function discoverSet() {
   console.log(`Deletion log: ${logFile}`);
   console.log(`Archive (recovery): ${archiveFile}`);
   if (older.size > 0) console.log('Some >30d drafts remain (batch cap or failures). Re-run to finish.');
-  heartbeat({ verdict: totalFail > 0 ? 'WARN' : 'PASS', status: totalFail > 0 ? 'WARN' : 'PASS', deleted: totalOk, failed: totalFail, archived: totalArchived, remaining_in_set: older.size, archive_path: archiveFile });
+
+  // ---- FINAL PROTECTED-DRAFT ASSERTION (TK-11552) ----
+  // Re-read the mailbox through the same listing route and confirm every draft the
+  // keep-list was protecting is still there. This is the only statement in this script
+  // that is about the OUTCOME rather than the intent.
+  let lostFinal = [];
+  let assertionRead = true;
+  try {
+    lostFinal = lostProtected(await jget(`${BASE}/api/drafts?account=${ACC}&maxResults=500`));
+  } catch (e) {
+    // Could not re-read => could not verify. That is NOT-MEASURED, not "fine".
+    assertionRead = false;
+    console.error(`post-run protection re-read FAILED: ${e.message}`);
+  }
+
+  let verdict = totalFail > 0 ? 'WARN' : 'PASS';
+  let protectionNote = null;
+  let protectedVerified = null;
+  if (lostFinal.length) {
+    verdict = 'FAIL';
+    protectedVerified = false;
+    protectionNote = `${lostFinal.length} keep-listed draft(s) LOST during this run — permanent, no Trash`;
+    console.error(`\nPROTECTION FAILURE: ${lostFinal.join(', ')}`);
+  } else if (!assertionRead) {
+    verdict = verdict === 'PASS' ? 'WARN' : verdict;
+    protectionNote = 'post-run re-read failed — protection NOT verified this run';
+  } else if (censusTruncated) {
+    verdict = verdict === 'PASS' ? 'WARN' : verdict;
+    protectionNote = 'draft listing truncated at 500 — protection NOT fully measured';
+  } else if (protectedBefore.size === 0 && KEEP_IDS.size > 0) {
+    // The keep-list is non-empty yet nothing it names is a live draft. Either every
+    // held item was sent/dropped (fine, prune the list) or the ids have rotted. Either
+    // way the protection was not exercised, so this run proves nothing about it.
+    verdict = verdict === 'PASS' ? 'WARN' : verdict;
+    protectionNote = `keep-list holds ${KEEP_IDS.size} id(s) but none resolve to a live draft — protection not exercised`;
+  } else if (protectionMeasured) {
+    protectedVerified = true;
+    console.log(`protection VERIFIED: all ${protectedBefore.size} keep-listed draft(s) survived this run.`);
+  }
+  if (protectionNote) console.log(`protection: ${protectionNote}`);
+
+  heartbeat({ verdict, status: verdict, deleted: totalOk, failed: totalFail, archived: totalArchived,
+    remaining_in_set: older.size, archive_path: archiveFile,
+    protected_before: protectedBefore.size, protected_lost_count: lostFinal.length,
+    protected_lost_ids: lostFinal, protected_verified: protectedVerified,
+    census_truncated: censusTruncated, protection_note: protectionNote });
 })().catch((e) => { console.error('FATAL:', e.message); process.exit(1); });
diff --git a/test/mock-george.js b/test/mock-george.js
new file mode 100644
index 0000000..9943881
--- /dev/null
+++ b/test/mock-george.js
@@ -0,0 +1,59 @@
+#!/usr/bin/env node
+/*
+ * mock-george.js — a throwaway stand-in for the George bridge, used ONLY by
+ * test-protected-survival.sh. It never touches a real mailbox.
+ *
+ * Serves the four routes delete-old-drafts-info.js uses:
+ *   GET    /api/messages?q=...      -> the server-side >30d id set
+ *   GET    /api/drafts              -> the draft listing
+ *   GET    /api/messages/:id        -> archive-before-delete metadata
+ *   DELETE /api/drafts/:id          -> permanent delete
+ *
+ * FAULT INJECTION (this is the point of the file): SABOTAGE_AFTER_LIST=N makes the
+ * protected draft SABOTAGE_ID vanish after the Nth /api/drafts listing, without the
+ * drain ever asking for it. That models the class the survival assertion exists to
+ * catch — a protected draft lost to something other than the drain's own intent,
+ * which an intent-side check (`!isExempt(d)`) is structurally blind to.
+ */
+'use strict';
+const http = require('http');
+
+const PORT = parseInt(process.env.MOCK_PORT || '9871', 10);
+const SABOTAGE_AFTER_LIST = parseInt(process.env.SABOTAGE_AFTER_LIST || '0', 10);
+const SABOTAGE_ID = process.env.SABOTAGE_ID || '';
+const NDRAFTS = parseInt(process.env.MOCK_NDRAFTS || '0', 10); // bulk filler, for the truncation case
+
+// draftId -> messageId. "old" = in the >30d set.
+let drafts = new Map([
+  ['draft-protected', 'msg-protected'],  // keep-listed, old  -> must survive
+  ['draft-garbage',   'msg-garbage'],    // not kept, old     -> must be deleted
+  ['draft-fresh',     'msg-fresh'],      // not old           -> must survive
+]);
+for (let i = 0; i < NDRAFTS; i++) drafts.set(`draft-bulk-${i}`, `msg-bulk-${i}`);
+const OLD = new Set(['msg-protected', 'msg-garbage', ...[...Array(NDRAFTS).keys()].map((i) => `msg-bulk-${i}`)]);
+
+let listCount = 0;
+const send = (res, code, obj) => { res.writeHead(code, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(obj)); };
+
+http.createServer((req, res) => {
+  const u = new URL(req.url, `http://127.0.0.1:${PORT}`);
+  const p = u.pathname;
+
+  if (req.method === 'GET' && p === '/api/messages') {
+    return send(res, 200, { messages: [...drafts.values()].filter((m) => OLD.has(m)).map((id) => ({ id })), nextPageToken: '' });
+  }
+  if (req.method === 'GET' && p === '/api/drafts') {
+    listCount++;
+    if (SABOTAGE_AFTER_LIST && listCount > SABOTAGE_AFTER_LIST && SABOTAGE_ID) drafts.delete(SABOTAGE_ID);
+    const max = parseInt(u.searchParams.get('maxResults') || '500', 10);
+    return send(res, 200, [...drafts.entries()].slice(0, max).map(([id, mid]) => ({ id, message: { id: mid } })));
+  }
+  if (req.method === 'GET' && p.startsWith('/api/messages/')) {
+    return send(res, 200, { subject: 'mock', from: 'a@b.c', to: '', date: 'Thu, 14 Aug 2026 16:38:47 -0700', snippet: 'mock snippet' });
+  }
+  if (req.method === 'DELETE' && p.startsWith('/api/drafts/')) {
+    drafts.delete(decodeURIComponent(p.split('/').pop()));
+    return send(res, 200, { ok: true });
+  }
+  return send(res, 404, { error: 'not found' });
+}).listen(PORT, '127.0.0.1', () => console.error(`mock-george listening on ${PORT}`));
diff --git a/test/test-protected-survival.sh b/test/test-protected-survival.sh
new file mode 100755
index 0000000..f9395f3
--- /dev/null
+++ b/test/test-protected-survival.sh
@@ -0,0 +1,96 @@
+#!/usr/bin/env bash
+# test-protected-survival.sh — negative test for the TK-11552 protected-draft
+# survival assertion in delete-old-drafts-info.js.
+#
+# CLAUDE.md TK-11431 amendment 3: a check ships with a negative test proving it goes
+# RED on an injected fault, or it does not ship. A positive-only test on a detector
+# confirms the happy path and leaves the entire purpose of the component unverified.
+#
+# Runs against an ISOLATED COPY of the drain + a mock George on loopback. It never
+# reaches a real mailbox: GEORGE_BASE points at the mock, and the copy lives in a
+# temp dir so its heartbeat/archive writes land there, not in the repo.
+set -u
+REPO="$(cd "$(dirname "$0")/.." && pwd)"
+WORK="$(mktemp -d /tmp/tk11552-survival-XXXXXX)"
+PORT=9871
+PASS=0; FAIL=0
+ok(){ echo "  PASS  $1"; PASS=$((PASS+1)); }
+no(){ echo "  FAIL  $1"; FAIL=$((FAIL+1)); }
+
+mkdir -p "$WORK/data"
+cp "$REPO/delete-old-drafts-info.js" "$WORK/drain-under-test.js"
+cp "$REPO/test/mock-george.js" "$WORK/mock-george.js"
+
+# keep-list fixture: protects the draft by its DRAFT id (the stable one).
+cat > "$WORK/keep.json" <<'JSON'
+{ "draft-protected": "fixture: must never be deleted at any age" }
+JSON
+# fixture whose only id is stale — models the rotated-message-id hazard.
+cat > "$WORK/keep-stale.json" <<'JSON'
+{ "msg-rotated-away-1a09ba5cc706157b": "fixture: id no longer resolves to any live draft" }
+JSON
+
+hb(){ python3 -c "import json,sys;d=json.load(open('$WORK/data/drain-old-drafts-latest.json'));print(d.get(sys.argv[1]))" "$1" 2>/dev/null; }
+
+run_case(){ # name keepfile sabotage_after ndrafts
+  local name="$1" keep="$2" sab="$3" nd="$4"
+  rm -f "$WORK/data/drain-old-drafts-latest.json"
+  MOCK_PORT=$PORT SABOTAGE_AFTER_LIST="$sab" SABOTAGE_ID=draft-protected MOCK_NDRAFTS="$nd" \
+    node "$WORK/mock-george.js" >/dev/null 2>&1 &
+  local mp=$!
+  # NB: probe a route that does NOT increment the mock's listing counter, or the probe
+  # itself consumes list #1 and every sabotage offset is off by one.
+  for _ in 1 2 3 4 5 6 7 8 9 10; do curl -fs "http://127.0.0.1:$PORT/api/messages" >/dev/null 2>&1 && break; sleep 0.3; done
+  CONFIRM=1 GEORGE_BASE="http://127.0.0.1:$PORT" DRAIN_KEEP_LIST="$keep" \
+    DEL_SLEEP=0 PAGE_SLEEP=0 GEORGE_AUTH="admin:x" \
+    node "$WORK/drain-under-test.js" > "$WORK/$name.out" 2>&1
+  RC=$?
+  kill $mp 2>/dev/null; wait $mp 2>/dev/null
+  # Guard the harness itself: rc=127 / a missing heartbeat means the case never ran,
+  # which must not read as a pass (a crashing process prints nothing and "no rows
+  # matched" looks exactly like "clean run").
+  if [ "$RC" = "127" ]; then no "$name: runner exited 127 — case never executed"; return 1; fi
+  if [ ! -s "$WORK/data/drain-old-drafts-latest.json" ]; then no "$name: no heartbeat written — case never executed"; sed -n '1,15p' "$WORK/$name.out"; return 1; fi
+  return 0
+}
+
+echo "== CASE 1 (positive): protected draft survives a normal drain =="
+if run_case c1 "$WORK/keep.json" 0 0; then
+  [ "$(hb verdict)" = "PASS" ]            && ok "verdict PASS" || no "verdict=$(hb verdict) want PASS"
+  [ "$(hb protected_verified)" = "True" ] && ok "protected_verified true" || no "protected_verified=$(hb protected_verified) want True"
+  [ "$(hb protected_before)" = "1" ]      && ok "censused 1 live protected draft" || no "protected_before=$(hb protected_before) want 1"
+  [ "$(hb deleted)" = "1" ]               && ok "deleted the 1 unprotected old draft" || no "deleted=$(hb deleted) want 1"
+  [ "$(hb protected_lost_count)" = "0" ]  && ok "lost 0" || no "protected_lost_count=$(hb protected_lost_count) want 0"
+fi
+
+echo "== CASE 2 (NEGATIVE): protected draft disappears mid-drain -> must ABORT red =="
+if run_case c2 "$WORK/keep.json" 1 0; then
+  [ "$RC" = "1" ]                          && ok "exited 1 (aborted)" || no "rc=$RC want 1"
+  [ "$(hb verdict)" = "FAIL" ]             && ok "verdict FAIL" || no "verdict=$(hb verdict) want FAIL"
+  [ "$(hb protected_verified)" = "False" ] && ok "protected_verified false" || no "protected_verified=$(hb protected_verified) want False"
+  grep -q "draft-protected" <<<"$(hb protected_lost_ids)" && ok "names the lost draft" || no "protected_lost_ids=$(hb protected_lost_ids)"
+fi
+
+echo "== CASE 3 (NEGATIVE): protected draft disappears AFTER the loop -> final assertion red =="
+if run_case c3 "$WORK/keep.json" 3 0; then
+  [ "$RC" = "0" ]                          && ok "ran to completion (final assertion path, not the mid-drain abort)" || no "rc=$RC want 0"
+  [ "$(hb verdict)" = "FAIL" ]             && ok "verdict FAIL from the final re-read" || no "verdict=$(hb verdict) want FAIL"
+  [ "$(hb protected_verified)" = "False" ] && ok "protected_verified false" || no "protected_verified=$(hb protected_verified) want False"
+fi
+
+echo "== CASE 4 (NEGATIVE): keep-list ids resolve to nothing live -> not-measured, never clean PASS =="
+if run_case c4 "$WORK/keep-stale.json" 0 0; then
+  [ "$(hb verdict)" = "WARN" ]            && ok "verdict WARN not PASS" || no "verdict=$(hb verdict) want WARN"
+  [ "$(hb protected_before)" = "0" ]      && ok "censused 0 live protected" || no "protected_before=$(hb protected_before)"
+  [ "$(hb protected_verified)" = "None" ] && ok "protected_verified null (not-measured)" || no "protected_verified=$(hb protected_verified) want None"
+fi
+
+echo "== CASE 5 (NEGATIVE): listing truncated at 500 -> census incomplete, never clean PASS =="
+if run_case c5 "$WORK/keep.json" 0 600; then
+  [ "$(hb census_truncated)" = "True" ] && ok "census_truncated true" || no "census_truncated=$(hb census_truncated) want True"
+  [ "$(hb verdict)" = "WARN" ]          && ok "verdict WARN not PASS" || no "verdict=$(hb verdict) want WARN"
+fi
+
+echo
+echo "TOTAL: $PASS passed, $FAIL failed   (workdir $WORK)"
+[ "$FAIL" = "0" ] || exit 1

← b4c4762 TK-11552: keep-list exempts on EITHER id — Gmail rotates mes  ·  back to George Gmail  ·  TK-11552: derive the protected set from the KEEP-LIST, not f 789f1c6 →