← back to George Gmail
TK-11552: keep-list exempts on EITHER id — Gmail rotates message.id on draft edit
b4c4762944d744c2067146cfcadb4dc9eb6c748e · 2026-09-13 09:47:44 -0700 · Steve Abrams
The drain matched exemptions on d.message.id but deletes by the draft id. Gmail mints a
NEW message id every time a draft is saved, while the draft id is stable — proven live:
draft r-5517420768746463086 kept its id while its message id went 1a09ba5cc706157b ->
1a09ba5ce13ee153 across one edit. So editing a protected draft silently dropped its
protection and the 04:15 permanent-delete (no Trash) could destroy it. That fired in
exactly the workflow the TK-11552 memo asks Steve to perform: open info@ and
send / rewrite-and-send / drop.
- isExempt(d): match message.id OR draft id. Strictly preservative — can only ADD
exemptions, never remove one.
- Pinned the 12 live keep-list entries by their stable draft id as well (15 -> 27).
- DRAIN_KEEP_LIST test seam (mirrors the canary's). Verified the launchd plist does NOT
set it; its env block carries only CONFIRM/MAX_AUTO_DELETE/PATH.
- test-draftid-exempt.sh 3/3 against the real script + real drafts, dry-run only. Case B
(pin removed -> deletable=1) is the negative control that proves the test can go red.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BdKLxdaTGYBRTVGAYwi8Vo
Files touched
M data/drain-keep-list.jsonM delete-old-drafts-info.jsA test-draftid-exempt.sh
Diff
commit b4c4762944d744c2067146cfcadb4dc9eb6c748e
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Sun Sep 13 09:47:44 2026 -0700
TK-11552: keep-list exempts on EITHER id — Gmail rotates message.id on draft edit
The drain matched exemptions on d.message.id but deletes by the draft id. Gmail mints a
NEW message id every time a draft is saved, while the draft id is stable — proven live:
draft r-5517420768746463086 kept its id while its message id went 1a09ba5cc706157b ->
1a09ba5ce13ee153 across one edit. So editing a protected draft silently dropped its
protection and the 04:15 permanent-delete (no Trash) could destroy it. That fired in
exactly the workflow the TK-11552 memo asks Steve to perform: open info@ and
send / rewrite-and-send / drop.
- isExempt(d): match message.id OR draft id. Strictly preservative — can only ADD
exemptions, never remove one.
- Pinned the 12 live keep-list entries by their stable draft id as well (15 -> 27).
- DRAIN_KEEP_LIST test seam (mirrors the canary's). Verified the launchd plist does NOT
set it; its env block carries only CONFIRM/MAX_AUTO_DELETE/PATH.
- test-draftid-exempt.sh 3/3 against the real script + real drafts, dry-run only. Case B
(pin removed -> deletable=1) is the negative control that proves the test can go red.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BdKLxdaTGYBRTVGAYwi8Vo
---
data/drain-keep-list.json | 14 ++++++++++-
delete-old-drafts-info.js | 20 +++++++++++++---
test-draftid-exempt.sh | 61 +++++++++++++++++++++++++++++++++++++++++++++++
3 files changed, 91 insertions(+), 4 deletions(-)
diff --git a/data/drain-keep-list.json b/data/drain-keep-list.json
index 31aec69..4b14da2 100644
--- a/data/drain-keep-list.json
+++ b/data/drain-keep-list.json
@@ -13,5 +13,17 @@
"1a01bb2bc8935bde": "TK-11552 — net-price quote: DWC138562, 36\" wide, sold per 8-yd roll, $523.07 per roll NET, 13 rolls in stock. In-thread reply body (thread 1a01ba39900f1f9b). Remove once sent or dropped.",
"1a01bd056444930f": "TK-11552 — mural panel-set spec + pricing: 8-Panel Set 157.5\" w x 110.23\" h (wall cannot exceed set dims); Non-Woven $1260.00/set, Vinyl $1750.00/set. In-thread reply body (thread 1a01bd056444930f). Remove once sent or dropped.",
"1a01fe8db459a3f4": "TK-11552 — stock/put-up detail: glm52019 (blue/black) 16x40yd + 43yd + 29yd = 712 yds / 651m; glm52010 (Red) 5x40yd + 47.5yd = 247.5 yds / 225m. In-thread reply body (thread 1a01fe7fee162033). Remove once sent or dropped.",
- "1a01ff31a4b817c8": "TK-11552 — quote + stock shortfall w/ open action: Cork502080, 36\" wide, 8-yd rolls only, $64.95 PER YD, 48 yds in stock; 443 sq ft needs ~56 yds = one roll more than stock; sender promised to check lead time and advise. In-thread reply body (thread 1a01fed13ca94ba7). Remove once sent or dropped."
+ "1a01ff31a4b817c8": "TK-11552 — quote + stock shortfall w/ open action: Cork502080, 36\" wide, 8-yd rolls only, $64.95 PER YD, 48 yds in stock; 443 sq ft needs ~56 yds = one roll more than stock; sender promised to check lead time and advise. In-thread reply body (thread 1a01fed13ca94ba7). Remove once sent or dropped.",
+ "r2754841368465389206": "[DRAFT-ID PIN, TK-11552] stable draft id for message 1a06d1ef99f846d1. Gmail rotates the message id on every edit; this entry keeps the exemption alive if the draft is revised. Same item as that entry — remove BOTH once sent or dropped. TK-11231 — Newmor/LBI Boyd cost-list ask (1,294 rows, 0 priced). READY and wanted; Steve is holding it, not ab",
+ "r-1919900401082588763": "[DRAFT-ID PIN, TK-11552] stable draft id for message 1a006aa890e66af3. Gmail rotates the message id on every edit; this entry keeps the exemption alive if the draft is revised. Same item as that entry — remove BOTH once sent or dropped. TK-11231 — Kravet sample follow-up (Acct 10087117, matt.schoffman@kravet.com). WANTED, held into draft per Ste",
+ "r-131429175170412175": "[DRAFT-ID PIN, TK-11552] stable draft id for message 1a005fc3ff6c56ff. Gmail rotates the message id on every edit; this entry keeps the exemption alive if the draft is revised. Same item as that entry — remove BOTH once sent or dropped. TK-11231 — BM Wallpaper sample follow-up (Acct On File, sales@bmwallpaper.com). WANTED, held into draft per St",
+ "r-492175422734020350": "[DRAFT-ID PIN, TK-11552] stable draft id for message 1a002b133c517293. Gmail rotates the message id on every edit; this entry keeps the exemption alive if the draft is revised. Same item as that entry — remove BOTH once sent or dropped. TK-11552 — LIVE customer logistics: ship agent says the truck driver cannot reach the customer; carries PRO NU",
+ "r-8694359492328002182": "[DRAFT-ID PIN, TK-11552] stable draft id for message 1a010fdd003663c6. Gmail rotates the message id on every edit; this entry keeps the exemption alive if the draft is revised. Same item as that entry — remove BOTH once sent or dropped. TK-11552 — vendor price quote: MR-W-70-113 @ $246.15 PER YD. In-thread reply body (thread 1a010fd3cabfd944). R",
+ "r-8250160488013551295": "[DRAFT-ID PIN, TK-11552] stable draft id for message 1a015f7222e36d52. Gmail rotates the message id on every edit; this entry keeps the exemption alive if the draft is revised. Same item as that entry — remove BOTH once sent or dropped. TK-11552 — vendor quote + stock + backorder terms: 307200F-100 / PALM GARDEN COPPER/HAZELNUT ON TINT @ $309 pe",
+ "r2167746391260446331": "[DRAFT-ID PIN, TK-11552] stable draft id for message 1a016a10d8db64b0. Gmail rotates the message id on every edit; this entry keeps the exemption alive if the draft is revised. Same item as that entry — remove BOTH once sent or dropped. TK-11552 — production spec: can produce on Vinyl, must print 60cm wide (design scales down 10%), min order 10 ",
+ "r-9012688096754019252": "[DRAFT-ID PIN, TK-11552] stable draft id for message 1a01affe1d71d94c. Gmail rotates the message id on every edit; this entry keeps the exemption alive if the draft is revised. Same item as that entry — remove BOTH once sent or dropped. TK-11552 — production spec + lead time: 60cm (23\") wide, design scaled down 10%, minimum 10 rolls, 4-5 week le",
+ "r-4182925933162231302": "[DRAFT-ID PIN, TK-11552] stable draft id for message 1a01bb2bc8935bde. Gmail rotates the message id on every edit; this entry keeps the exemption alive if the draft is revised. Same item as that entry — remove BOTH once sent or dropped. TK-11552 — net-price quote: DWC138562, 36\" wide, sold per 8-yd roll, $523.07 per roll NET, 13 rolls in stock. ",
+ "r-1369122316969637152": "[DRAFT-ID PIN, TK-11552] stable draft id for message 1a01bd056444930f. Gmail rotates the message id on every edit; this entry keeps the exemption alive if the draft is revised. Same item as that entry — remove BOTH once sent or dropped. TK-11552 — mural panel-set spec + pricing: 8-Panel Set 157.5\" w x 110.23\" h (wall cannot exceed set dims); Non",
+ "r-6881126913865955183": "[DRAFT-ID PIN, TK-11552] stable draft id for message 1a01fe8db459a3f4. Gmail rotates the message id on every edit; this entry keeps the exemption alive if the draft is revised. Same item as that entry — remove BOTH once sent or dropped. TK-11552 — stock/put-up detail: glm52019 (blue/black) 16x40yd + 43yd + 29yd = 712 yds / 651m; glm52010 (Red) 5",
+ "r-1714282644421247699": "[DRAFT-ID PIN, TK-11552] stable draft id for message 1a01ff31a4b817c8. Gmail rotates the message id on every edit; this entry keeps the exemption alive if the draft is revised. Same item as that entry — remove BOTH once sent or dropped. TK-11552 — quote + stock shortfall w/ open action: Cork502080, 36\" wide, 8-yd rolls only, $64.95 PER YD, 48 yd"
}
diff --git a/delete-old-drafts-info.js b/delete-old-drafts-info.js
index d9d1c12..672a1a0 100644
--- a/delete-old-drafts-info.js
+++ b/delete-old-drafts-info.js
@@ -40,7 +40,14 @@ const SET_IN = process.env.SET || '';
// protectable without sending it. Ids here are NEVER deleted, at any age.
// File: data/drain-keep-list.json -> { "<messageId>": "why it is kept" }
const HB_PATH_EARLY = path.join(__dirname, 'data', 'drain-old-drafts-latest.json');
-const KEEP_PATH = path.join(__dirname, 'data', 'drain-keep-list.json');
+// TK-11552: DRAIN_KEEP_LIST is a TEST-ONLY seam (mirrors the canary's env of the same
+// name) so the exemption logic can be exercised against a fixture without swapping the
+// real keep-list on disk. The launchd plist must NEVER set it. VERIFIED 2026-09-13: the
+// plist DOES have an EnvironmentVariables block (CONFIRM=1, MAX_AUTO_DELETE=800, PATH)
+// but does NOT set DRAIN_KEEP_LIST, so a scheduled run always reads the real file.
+// If that block ever gains DRAIN_KEEP_LIST, a scheduled drain would silently run against
+// a fixture and delete everything the real list protects — treat adding it as a defect.
+const KEEP_PATH = process.env.DRAIN_KEEP_LIST || path.join(__dirname, 'data', 'drain-keep-list.json');
// FAIL CLOSED (TK-11552). This loader used to be
// try { ...readFileSync... } catch (_) { return {}; }
// which failed OPEN: a corrupted, truncated, or deleted keep-list silently
@@ -86,6 +93,13 @@ if (KEEP_LOAD_ERROR) {
} else if (KEEP_IDS.size) {
console.log(`keep-list: ${KEEP_IDS.size} draft(s) exempt from deletion at any age`);
}
+// TK-11552 (2026-09-13): exempt on EITHER id. Gmail ROTATES message.id on every draft
+// save, while the DRAFT id (d.id, the one we actually delete by) is stable. A keep-list
+// keyed only on message.id therefore SILENTLY loses protection the moment a human edits a
+// protected draft — proven live: draft r-5517420768746463086 kept its id while its message
+// id went 1a09ba5cc706157b -> 1a09ba5ce13ee153 across one edit. Matching either id is
+// strictly preservative: it can only ADD exemptions, never remove one.
+const isExempt = (d) => !!d && (KEEP_IDS.has(d.message && d.message.id) || KEEP_IDS.has(d.id));
const PAGE = parseInt(process.env.PAGE || '40', 10);
const PAGE_SLEEP = parseInt(process.env.PAGE_SLEEP || '3000', 10);
const DEL_SLEEP = parseInt(process.env.DEL_SLEEP || '250', 10);
@@ -167,7 +181,7 @@ async function discoverSet() {
if (!DELETE) {
// dry-run: show how many of the first 500 drafts are deletable right now
const drafts = await jget(`${BASE}/api/drafts?account=${ACC}&maxResults=500`);
- const matches = (drafts || []).filter((d) => d && d.message && older.has(d.message.id) && !KEEP_IDS.has(d.message.id));
+ const matches = (drafts || []).filter((d) => d && d.message && older.has(d.message.id) && !isExempt(d));
console.log(`\n--- DRY RUN (no deletions). CONFIRM=1 to drain. ---`);
console.log(`total >30d drafts to remove : ${older.size}`);
console.log(`deletable in first 500 window: ${matches.length}`);
@@ -193,7 +207,7 @@ async function discoverSet() {
while (batch < MAX_BATCHES) {
batch++;
const drafts = await jget(`${BASE}/api/drafts?account=${ACC}&maxResults=500`);
- const matches = (drafts || []).filter((d) => d && d.message && older.has(d.message.id) && !KEEP_IDS.has(d.message.id));
+ 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...`);
for (const t of matches) {
diff --git a/test-draftid-exempt.sh b/test-draftid-exempt.sh
new file mode 100755
index 0000000..7d7a28c
--- /dev/null
+++ b/test-draftid-exempt.sh
@@ -0,0 +1,61 @@
+#!/bin/bash
+# TK-11552 negative test — proves the drain exempts a draft pinned by its STABLE DRAFT id.
+#
+# Why this exists: the keep-list matched only d.message.id, but Gmail ROTATES message.id on
+# every draft save while the draft id is stable (proven live: 1a09ba5cc706157b -> 1a09ba5ce13ee153
+# across one edit of draft r-5517420768746463086). So editing a protected draft silently dropped
+# its protection and the 04:15 drain could permanently delete it.
+#
+# A positive-only test would prove nothing here — case B is the point: with the pin REMOVED the
+# same draft MUST become deletable, or the test isn't measuring exemption at all.
+# DRY RUN ONLY. CONFIRM is never set, so this script cannot delete anything.
+set -uo pipefail
+cd "$(dirname "$0")"
+pass=0; fail=0
+ck(){ if [ "$2" = "$3" ]; then echo " PASS $1 ($2)"; pass=$((pass+1)); else echo " FAIL $1 — expected '$3', got '$2'"; fail=$((fail+1)); fi; }
+
+TMP=$(mktemp -d); trap 'rm -rf "$TMP"' EXIT
+
+# Pick a REAL live draft so we test the real matcher against real data, not a fixture shape.
+read -r MSG DRAFT < <(node -e '
+const fs=require("fs");
+const t=fs.readFileSync(process.env.HOME+"/Projects/Designer-Wallcoverings/DW-MCP/.env","utf8");
+let u="admin",p="";const m=t.match(/^GEORGE_BASIC_AUTH=(.+)$/m);
+if(m){const v=m[1].trim();if(v.includes(":")){const s=v.split(":");u=s[0];p=s.slice(1).join(":");}}
+if(!p){const mp=t.match(/^GEORGE_BASIC_AUTH_PASS=(.+)$/m);if(mp)p=mp[1].trim();}
+const H={Authorization:"Basic "+Buffer.from(u+":"+p).toString("base64")};
+fetch("http://127.0.0.1:9850/api/drafts?maxResults=500&account=info",{headers:H}).then(r=>r.json()).then(j=>{
+ const ds=j.drafts||j.items||(Array.isArray(j)?j:[]);
+ const d=ds.find(x=>x.message&&x.message.id&&x.id);
+ if(!d){console.error("no draft available");process.exit(1);}
+ console.log(d.message.id, d.id);});') || { echo "SETUP FAILED — could not read a live draft"; exit 1; }
+
+if [ -z "${MSG:-}" ] || [ -z "${DRAFT:-}" ]; then echo "SETUP FAILED — NOT_MEASURED (no ids)"; exit 1; fi
+echo "fixture: message=$MSG draft=$DRAFT"
+printf "[\"%s\"]" "$MSG" > "$TMP/set.json" # pretend this draft is >30d
+
+run(){
+ local out="$TMP/out.$$.txt"
+ SET="$TMP/set.json" DRAIN_KEEP_LIST="$1" node delete-old-drafts-info.js >"$out" 2>&1
+ local rc=$?
+ # A crashed run prints no count; without this guard an empty result would look like a
+ # measurement instead of a failure (the rc=127 false-PASS trap).
+ if [ $rc -ne 0 ]; then echo "RUN_FAILED_rc=$rc"; sed -n '1,5p' "$out" >&2; return; fi
+ local n
+ n=$(sed -n 's/.*deletable in first 500 window: \([0-9][0-9]*\).*/\1/p' "$out" | head -1)
+ if [ -z "$n" ]; then echo "NOT_MEASURED"; sed -n '1,8p' "$out" >&2; return; fi
+ echo "$n"
+}
+
+echo '{}' > "$TMP/empty.json"
+printf '{"%s":"draft-id pin"}' "$DRAFT" > "$TMP/bydraft.json"
+printf '{"%s":"message-id pin"}' "$MSG" > "$TMP/bymsg.json"
+
+echo "A. pinned by DRAFT id (the fix) -> must be exempt"
+ck "draft-id pin exempts" "$(run "$TMP/bydraft.json")" "0"
+echo "B. NO pin (injected fault) -> must be deletable, or the test measures nothing"
+ck "unpinned is deletable" "$(run "$TMP/empty.json")" "1"
+echo "C. pinned by MESSAGE id (legacy path) -> must still be exempt"
+ck "message-id pin exempts" "$(run "$TMP/bymsg.json")" "0"
+
+echo; echo "passed=$pass failed=$fail"; [ "$fail" -eq 0 ] || exit 1
← f5f274f auto-data-snapshot: 2026-09-13T04:15:01 (1 data files) — dat
·
back to George Gmail
·
TK-11552: drain asserts protected drafts SURVIVED, not just b8f78c8 →