← 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
M delete-old-drafts-info.jsA test/mock-george.jsA test/test-protected-survival.sh
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 →