← back to George Gmail
drain: FAIL CLOSED when the keep-list cannot be loaded (it failed OPEN)
4411b8ce92eb97757532f4de7d6875b85a4d6d64 · 2026-09-13 02:36:50 -0700 · Steve Abrams
The keep-list loader was:
try { JSON.parse(readFileSync(KEEP_PATH)) } catch (_) { return {}; }
That fails OPEN. A corrupted, truncated, wrong-shaped, or deleted keep-list
silently became 'zero exemptions', and the very next 04:15 run would
PERMANENTLY delete (Gmail drafts.delete, no Trash) every draft the keep-list
existed to protect. Every protection this file provides — TK-11231's held vendor
asks and TK-11552's 9 live customer replies — rested on a file whose read
failure was swallowed without a word.
A no-Trash deleter must never infer 'nothing is protected' from 'I could not
read what is protected'. Now any failure to load a usable keep-list ABORTS the
deletion pass (exit 1) and writes a FAIL heartbeat so it surfaces on the fleet
panel instead of dying quietly. ENOENT aborts too: 'nothing is exempt' must be
STATED by writing '{}' — a deliberate one-second act — because an absent file is
indistinguishable from one that went missing. Dry runs only warn, so the script
stays usable for diagnosis when the keep-list is broken.
Strictly preservative: this change can only ever prevent deletions, never cause
one.
Ships test-keeplist-failclosed.sh — 5 cases (missing / corrupt / wrong-shape must
ABORT; explicit {} and dry-run must NOT). SAFETY: it runs an isolated copy of the
script in a throwaway dir pointed at a dead GEORGE_BASE port, so even a FAILED
abort has no reachable mailbox to delete from. The harness also guards itself
against rc=127 — the first draft of this test silently 'passed' a case because
the command never ran.
Found by an adversarial second-model review (Kimi k3) of the TK-11552 keep-list
work, which correctly pointed out I had fail-safed the canary but not the
delete site.
TK-11552
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ux1A8z45sLwUTNzz8kxn68
Files touched
M delete-old-drafts-info.jsA test-keeplist-failclosed.sh
Diff
commit 4411b8ce92eb97757532f4de7d6875b85a4d6d64
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Sun Sep 13 02:36:50 2026 -0700
drain: FAIL CLOSED when the keep-list cannot be loaded (it failed OPEN)
The keep-list loader was:
try { JSON.parse(readFileSync(KEEP_PATH)) } catch (_) { return {}; }
That fails OPEN. A corrupted, truncated, wrong-shaped, or deleted keep-list
silently became 'zero exemptions', and the very next 04:15 run would
PERMANENTLY delete (Gmail drafts.delete, no Trash) every draft the keep-list
existed to protect. Every protection this file provides — TK-11231's held vendor
asks and TK-11552's 9 live customer replies — rested on a file whose read
failure was swallowed without a word.
A no-Trash deleter must never infer 'nothing is protected' from 'I could not
read what is protected'. Now any failure to load a usable keep-list ABORTS the
deletion pass (exit 1) and writes a FAIL heartbeat so it surfaces on the fleet
panel instead of dying quietly. ENOENT aborts too: 'nothing is exempt' must be
STATED by writing '{}' — a deliberate one-second act — because an absent file is
indistinguishable from one that went missing. Dry runs only warn, so the script
stays usable for diagnosis when the keep-list is broken.
Strictly preservative: this change can only ever prevent deletions, never cause
one.
Ships test-keeplist-failclosed.sh — 5 cases (missing / corrupt / wrong-shape must
ABORT; explicit {} and dry-run must NOT). SAFETY: it runs an isolated copy of the
script in a throwaway dir pointed at a dead GEORGE_BASE port, so even a FAILED
abort has no reachable mailbox to delete from. The harness also guards itself
against rc=127 — the first draft of this test silently 'passed' a case because
the command never ran.
Found by an adversarial second-model review (Kimi k3) of the TK-11552 keep-list
work, which correctly pointed out I had fail-safed the canary but not the
delete site.
TK-11552
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ux1A8z45sLwUTNzz8kxn68
---
delete-old-drafts-info.js | 49 +++++++++++++++++++++++++++++++++++++++++----
test-keeplist-failclosed.sh | 45 +++++++++++++++++++++++++++++++++++++++++
2 files changed, 90 insertions(+), 4 deletions(-)
diff --git a/delete-old-drafts-info.js b/delete-old-drafts-info.js
index b367882..c0b53bd 100644
--- a/delete-old-drafts-info.js
+++ b/delete-old-drafts-info.js
@@ -39,12 +39,53 @@ const SET_IN = process.env.SET || '';
// priced) was days from being destroyed with no Trash. A wanted draft must be
// 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');
-const KEEP = (() => {
- try { return JSON.parse(fs.readFileSync(KEEP_PATH, 'utf8')); } catch (_) { return {}; }
-})();
+// FAIL CLOSED (TK-11552). This loader used to be
+// try { ...readFileSync... } catch (_) { return {}; }
+// which failed OPEN: a corrupted, truncated, or deleted keep-list silently
+// became "zero exemptions", and the very next 04:15 run would PERMANENTLY
+// delete (no Trash) every draft the keep-list existed to protect. The whole
+// protection rested on a file whose read failure was swallowed.
+//
+// A no-Trash deleter must never infer "nothing is protected" from "I could not
+// read what is protected". So: any failure to load a usable keep-list ABORTS
+// the deletion pass. ENOENT aborts too — "I want no exemptions" must be stated
+// explicitly by writing `{}`, which takes a second and is a deliberate act,
+// whereas an absent file is indistinguishable from one that went missing.
+// Dry runs (CONFIRM unset) only warn: they delete nothing, so they stay usable
+// for diagnosis even when the keep-list is broken.
+let KEEP = {};
+let KEEP_LOAD_ERROR = null;
+try {
+ const parsed = JSON.parse(fs.readFileSync(KEEP_PATH, 'utf8'));
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
+ throw new Error(`keep-list must be a JSON object of {messageId: reason}, got ${Array.isArray(parsed) ? 'array' : typeof parsed}`);
+ }
+ KEEP = parsed;
+} catch (e) {
+ KEEP_LOAD_ERROR = `${e.code === 'ENOENT' ? 'MISSING' : 'UNREADABLE'}: ${e.message}`;
+}
const KEEP_IDS = new Set(Object.keys(KEEP));
-if (KEEP_IDS.size) console.log(`keep-list: ${KEEP_IDS.size} draft(s) exempt from deletion at any age`);
+if (KEEP_LOAD_ERROR) {
+ const msg = `keep-list could not be loaded (${KEEP_PATH}) -> ${KEEP_LOAD_ERROR}`;
+ if (process.env.CONFIRM === '1') {
+ console.error(`ABORT: ${msg}`);
+ console.error('Refusing to permanently delete while the exemption list is unknown.');
+ console.error(`Fix the file, or write '{}' to it to state explicitly that nothing is exempt.`);
+ try {
+ fs.writeFileSync(HB_PATH_EARLY, JSON.stringify({
+ ts: new Date().toISOString(), skill: 'delete-old-drafts-info', account: 'info',
+ verdict: 'FAIL', status: 'FAIL', deleted: 0, failed: 0,
+ note: `aborted before deleting: ${msg}`,
+ }, null, 2));
+ } catch (_) {}
+ process.exit(1);
+ }
+ console.warn(`WARN: ${msg} (dry run continues; a CONFIRM=1 run would ABORT)`);
+} else if (KEEP_IDS.size) {
+ console.log(`keep-list: ${KEEP_IDS.size} draft(s) exempt from deletion at any age`);
+}
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);
diff --git a/test-keeplist-failclosed.sh b/test-keeplist-failclosed.sh
new file mode 100755
index 0000000..c2b9a19
--- /dev/null
+++ b/test-keeplist-failclosed.sh
@@ -0,0 +1,45 @@
+#!/usr/bin/env bash
+# test-keeplist-failclosed.sh — prove delete-old-drafts-info.js FAILS CLOSED when
+# its keep-list cannot be loaded (TK-11552).
+#
+# Why this test exists: the loader used to be `catch (_) { return {} }`, i.e. it
+# failed OPEN — a corrupted or deleted keep-list silently meant "nothing is
+# exempt" and the next run PERMANENTLY deleted (no Trash) every draft the
+# keep-list existed to protect. A positive-only test would never catch that.
+#
+# SAFETY: the script under test is copied into a throwaway dir (so it reads that
+# dir's empty//broken data/, never the real keep-list) AND pointed at a dead
+# GEORGE_BASE port. So even if the abort FAILED, there is no reachable mailbox
+# to delete from. Nothing here can touch real drafts.
+set -u
+SRC="$(cd "$(dirname "$0")" && pwd)/delete-old-drafts-info.js"
+DEAD_GEORGE="http://127.0.0.1:9" # discard port: never serves
+TMP="$(mktemp -d)"; trap 'rm -rf "$TMP"' EXIT
+mkdir -p "$TMP/data"; cp "$SRC" "$TMP/"
+fails=0
+check() { # name expect_abort expect_exit
+ local name="$1" want_abort="$2"
+ local out rc
+ # `env` so the conditional CONFIRM=1 is a real assignment, not a command word.
+ out="$(cd "$TMP" && env GEORGE_BASE="$DEAD_GEORGE" ${CONFIRM_ENV:+CONFIRM=1} timeout 25 node delete-old-drafts-info.js 2>&1)"; rc=$?
+ local aborted=no; grep -q "ABORT:" <<<"$out" && aborted=yes
+ # Guard the HARNESS: rc=127 means the command never ran, which would otherwise
+ # read as a clean "did not abort" pass. A test that cannot run is not a pass.
+ if [ "$rc" -eq 127 ]; then echo " FAIL $name (HARNESS BROKEN: rc=127, command never ran)"; echo "$out" | head -3 | sed 's/^/ /'; fails=$((fails+1)); return; fi
+ if [ "$aborted" = "$want_abort" ]; then echo " PASS $name (aborted=$aborted rc=$rc)"
+ else echo " FAIL $name (aborted=$aborted want=$want_abort rc=$rc)"; echo "$out" | head -4 | sed 's/^/ /'; fails=$((fails+1)); fi
+}
+echo "NEGATIVE TEST — keep-list fail-closed (isolated copy, dead George):"
+# A: keep-list MISSING, real run -> must ABORT
+rm -f "$TMP/data/drain-keep-list.json"; CONFIRM_ENV=1 check "A missing keep-list + CONFIRM=1 -> ABORT" yes
+# B: keep-list CORRUPT, real run -> must ABORT
+echo '{ this is not json' > "$TMP/data/drain-keep-list.json"; CONFIRM_ENV=1 check "B corrupt keep-list + CONFIRM=1 -> ABORT" yes
+# C: keep-list is a JSON ARRAY (wrong shape) -> must ABORT
+echo '["1a002b133c517293"]' > "$TMP/data/drain-keep-list.json"; CONFIRM_ENV=1 check "C wrong-shape keep-list + CONFIRM=1 -> ABORT" yes
+# D: explicit empty object = deliberate "nothing exempt" -> must NOT abort
+echo '{}' > "$TMP/data/drain-keep-list.json"; CONFIRM_ENV=1 check "D explicit {} + CONFIRM=1 -> proceeds (no abort)" no
+# E: missing keep-list but DRY RUN -> warn only, no abort
+rm -f "$TMP/data/drain-keep-list.json"; CONFIRM_ENV= check "E missing keep-list + dry run -> warn, no abort" no
+echo ""
+[ "$fails" -eq 0 ] && { echo "FAIL-CLOSED TEST PASSED — an unloadable keep-list can no longer silently permit deletion."; exit 0; }
+echo "FAIL-CLOSED TEST FAILED ($fails)"; exit 1
← 1fb6464 george drain keep-list: exempt the 9 content-bearing TK-1155
·
back to George Gmail
·
fix: add 'calendar' to resolveAccount() map + add agentabram 920a946 →