← back to George Gmail
TK-11552: derive the protected set from the KEEP-LIST, not from isExempt() + gate contradictions
789f1c6ce78d74b5cb4eeb268b9c9bfe70662ac4 · 2026-09-13 16:33:39 -0700 · Steve Abrams
Second-model review (Grok) caught that b8f78c8 reproduced the very bug it was written
to fix. The census built protectedBefore with isExempt(d) -- the same predicate that
builds `matches`. A PARTIAL false-negative in isExempt (8 of 11 keep-listed drafts
recognized) drops the other 3 out of BOTH the census and the exemption: deleted,
never missed, reported PASS. Two paths cross-check each other only if they can
DISAGREE, which means they must be different code even when they compute the same set.
- protectedBefore is now resolved by iterating KEEP_IDS against draft-id / message-id
maps built from the listing. isExempt() is never consulted there.
- CONTRADICTION GATE: if the keep-list says protect and isExempt selected the draft
for deletion, the run ABORTS before any delete. That turns a partial false-negative
from "permanent loss detected afterwards" into "loss prevented".
- An O(1) re-check immediately before each DELETE closes the in-batch window (a batch
of up to 500 deletes was previously only re-checked on the next batch).
- A full 500-draft listing while a keep-list is in force now ABORTS: the mailbox
cannot be fully seen, so neither the census nor the survival check is trustworthy,
and deleting permanently while blind to what is protected is the one thing this
must not do. Previously that was only a WARN.
- A failed post-run re-read is FAIL when the run actually deleted something -- "I
could not check" must not be quieter than "I checked and it was fine". WARN when
nothing was deleted, since there was nothing to lose.
- An empty/unparseable listing is reported as a READ FAILURE, not as "everything was
lost": same safe action, but the reason must not be fabricated.
Tests 20 -> 25 assertions, 5 -> 6 cases. CASE 6 is the headline: it breaks isExempt on
purpose and asserts the run aborts with deleted=0. b8f78c8 passed every other case in
this file while still deleting the protected draft.
Live read-only check of the new logic against info@: 27 keep-list ids -> 12 protected
drafts + 3 unresolved (matches the canary); >22d population 14 -> would delete 3 (the
3 known-abandoned empties), 0 contradictions.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EBRBeHTnkTTdi3DYLePNSY
Files touched
M delete-old-drafts-info.jsM test/test-protected-survival.sh
Diff
commit 789f1c6ce78d74b5cb4eeb268b9c9bfe70662ac4
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Sun Sep 13 16:33:39 2026 -0700
TK-11552: derive the protected set from the KEEP-LIST, not from isExempt() + gate contradictions
Second-model review (Grok) caught that b8f78c8 reproduced the very bug it was written
to fix. The census built protectedBefore with isExempt(d) -- the same predicate that
builds `matches`. A PARTIAL false-negative in isExempt (8 of 11 keep-listed drafts
recognized) drops the other 3 out of BOTH the census and the exemption: deleted,
never missed, reported PASS. Two paths cross-check each other only if they can
DISAGREE, which means they must be different code even when they compute the same set.
- protectedBefore is now resolved by iterating KEEP_IDS against draft-id / message-id
maps built from the listing. isExempt() is never consulted there.
- CONTRADICTION GATE: if the keep-list says protect and isExempt selected the draft
for deletion, the run ABORTS before any delete. That turns a partial false-negative
from "permanent loss detected afterwards" into "loss prevented".
- An O(1) re-check immediately before each DELETE closes the in-batch window (a batch
of up to 500 deletes was previously only re-checked on the next batch).
- A full 500-draft listing while a keep-list is in force now ABORTS: the mailbox
cannot be fully seen, so neither the census nor the survival check is trustworthy,
and deleting permanently while blind to what is protected is the one thing this
must not do. Previously that was only a WARN.
- A failed post-run re-read is FAIL when the run actually deleted something -- "I
could not check" must not be quieter than "I checked and it was fine". WARN when
nothing was deleted, since there was nothing to lose.
- An empty/unparseable listing is reported as a READ FAILURE, not as "everything was
lost": same safe action, but the reason must not be fabricated.
Tests 20 -> 25 assertions, 5 -> 6 cases. CASE 6 is the headline: it breaks isExempt on
purpose and asserts the run aborts with deleted=0. b8f78c8 passed every other case in
this file while still deleting the protected draft.
Live read-only check of the new logic against info@: 27 keep-list ids -> 12 protected
drafts + 3 unresolved (matches the canary); >22d population 14 -> would delete 3 (the
3 known-abandoned empties), 0 contradictions.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EBRBeHTnkTTdi3DYLePNSY
---
delete-old-drafts-info.js | 136 +++++++++++++++++++++++++++-------------
test/test-protected-survival.sh | 68 +++++++++++++-------
2 files changed, 136 insertions(+), 68 deletions(-)
diff --git a/delete-old-drafts-info.js b/delete-old-drafts-info.js
index 73e9352..f308188 100644
--- a/delete-old-drafts-info.js
+++ b/delete-old-drafts-info.js
@@ -197,35 +197,70 @@ async function discoverSet() {
}
// ---- 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.
+ // The exemption filter and the success verdict used to be the SAME code path:
+ // `matches` was built with `!isExempt(d)` and the verdict was
+ // `totalFail > 0 ? 'WARN' : 'PASS'`. If isExempt were wrong, the run would
+ // permanently delete (no Trash) protected drafts AND report PASS.
+ //
+ // CRITICAL (adversarial review, 2026-09-13): the FIRST version of this census had the
+ // same disease one level up — it built the protected set by calling isExempt(), the
+ // very predicate it was meant to audit. A PARTIAL false-negative (say 8 of 11 keep-
+ // listed drafts recognized) would drop the other 3 out of BOTH the census and the
+ // exemption: deleted, never missed, reported PASS. Two paths cross-check each other
+ // only if they can DISAGREE, which requires different code even when they compute the
+ // same set today. So the protected set below is resolved from KEEP_IDS by direct map
+ // lookup and isExempt() is never consulted here. If the two ever disagree, that
+ // disagreement is caught BEFORE any delete (see the contradiction gate in the loop).
+ const asList = (x) => (Array.isArray(x) ? x : []);
+ const censusArr = asList(await jget(`${BASE}/api/drafts?account=${ACC}&maxResults=500`));
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 byDraftId = new Map(), byMsgId = new Map();
+ for (const d of censusArr) {
+ if (!d) continue;
+ if (d.id) byDraftId.set(d.id, d);
+ if (d.message && d.message.id) byMsgId.set(d.message.id, d);
+ }
+ const protectedBefore = new Map(); // live draftId -> the keep-list id that resolved it
+ const keepUnresolved = [];
+ for (const kid of KEEP_IDS) {
+ const d = byDraftId.get(kid) || byMsgId.get(kid);
+ if (d && d.id) protectedBefore.set(d.id, kid); else keepUnresolved.push(kid);
+ }
+ console.log(`protected-draft census: ${protectedBefore.size} of ${KEEP_IDS.size} keep-list id(s) resolve to a LIVE draft` +
+ (keepUnresolved.length ? ` (${keepUnresolved.length} unresolved)` : ''));
+ // Was the invariant actually exercised this run? Zero live protected drafts means the
+ // run proves nothing about protection, which must not be reported as "verified".
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]' : ''));
+
+ // The listing is capped at 500. If it came back full we cannot see the whole mailbox,
+ // so we can neither census every protected draft nor trust the survival check (a
+ // protected draft beyond the window reads as "missing" either way). Deleting
+ // permanently while blind to what is protected is the one thing this script must not
+ // do, so it refuses. Only bites when a keep-list is actually in force.
+ if (censusTruncated && KEEP_IDS.size > 0) {
+ console.error(`ABORT: draft listing came back full (${censusArr.length} = the 500 cap) while ${KEEP_IDS.size} keep-list id(s) are in force.`);
+ console.error('Cannot see the whole mailbox, so protection cannot be verified. Refusing to permanently delete. Prune the keep-list or drain with a reviewed SET file.');
+ heartbeat({ verdict: 'FAIL', status: 'FAIL', deleted: 0, failed: 0, archived: 0,
+ protected_before: protectedBefore.size, protected_verified: false, census_truncated: true,
+ note: 'ABORTED before any delete: draft listing truncated at 500 while a keep-list is in force — protection unverifiable' });
+ process.exit(1);
+ }
// 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.
+ // An empty/unparseable listing is reported as a READ FAILURE, not as "everything was
+ // lost" — same safe action (abort), but the reason must not be a fabrication.
const lostProtected = (list) => {
- const live = new Set((Array.isArray(list) ? list : []).map((d) => d && d.id));
- return [...protectedBefore.keys()].filter((id) => !live.has(id));
+ const arr = asList(list);
+ if (arr.length === 0 && protectedBefore.size > 0) return { readFailed: true, lost: [] };
+ const live = new Set(arr.map((d) => d && d.id));
+ return { readFailed: false, lost: [...protectedBefore.keys()].filter((id) => !live.has(id)) };
+ };
+ const abortRun = (note, extra) => {
+ console.error(`\nABORT: ${note}`);
+ console.error('Deletion here is permanent (no Trash). Stopping the pass.');
+ heartbeat({ verdict: 'FAIL', status: 'FAIL', protected_before: protectedBefore.size,
+ protected_verified: false, census_truncated: censusTruncated, note, ...extra });
+ process.exit(1);
};
// ---- DRAIN LOOP ----
@@ -238,21 +273,24 @@ async function discoverSet() {
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));
+ const drafts = asList(await jget(`${BASE}/api/drafts?account=${ACC}&maxResults=500`));
+ // Survival check BEFORE this batch deletes anything.
+ const chk = lostProtected(drafts);
+ if (chk.readFailed) abortRun(`batch ${batch}: draft listing came back empty while ${protectedBefore.size} draft(s) are under protection — refusing to delete blind`,
+ { deleted: totalOk, failed: totalFail, archived: totalArchived, remaining_in_set: older.size });
+ if (chk.lost.length) abortRun(`batch ${batch}: ${chk.lost.length} keep-listed draft(s) are no longer present: ${chk.lost.join(', ')}`,
+ { deleted: totalOk, failed: totalFail, archived: totalArchived, remaining_in_set: older.size,
+ protected_lost_count: chk.lost.length, protected_lost_ids: chk.lost });
+ const matches = drafts.filter((d) => d && d.message && older.has(d.message.id) && !isExempt(d));
+ // CONTRADICTION GATE. The keep-list (resolved above, independently) says protect;
+ // isExempt() says delete. They disagree, so one of them is wrong — and on an
+ // irreversible delete the only safe reading is the protective one. Catching this
+ // BEFORE the delete turns a partial isExempt false-negative from "permanent loss
+ // detected afterwards" into "loss prevented", which is the whole point.
+ const contradictions = matches.filter((m) => m && protectedBefore.has(m.id));
+ if (contradictions.length) abortRun(`batch ${batch}: ${contradictions.length} draft(s) are keep-list protected but the exemption filter selected them for deletion: ${contradictions.map((m) => m.id).join(', ')}`,
+ { deleted: totalOk, failed: totalFail, archived: totalArchived, remaining_in_set: older.size,
+ contradiction_count: contradictions.length, contradiction_ids: contradictions.map((m) => m.id) });
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) {
@@ -268,6 +306,12 @@ async function discoverSet() {
try {
fs.appendFileSync(archiveFile, JSON.stringify(archiveEntry) + '\n');
} catch (_) {}
+ // Last gate before the irreversible call. Costs nothing (a Map lookup) and closes
+ // the in-batch window: without it, a batch of up to 500 deletes would only be
+ // re-checked on the NEXT batch.
+ if (protectedBefore.has(t.id)) abortRun(`refused to delete keep-list protected draft ${t.id} — the exemption filter should never have selected it`,
+ { deleted: totalOk, failed: totalFail, archived: totalArchived, remaining_in_set: older.size,
+ contradiction_count: 1, contradiction_ids: [t.id] });
try {
await jreq(`${BASE}/api/drafts/${t.id}?account=${ACC}`, { method: 'DELETE' });
fs.appendFileSync(logFile, JSON.stringify({ ts: new Date().toISOString(), draftId: t.id, messageId: t.message.id }) + '\n');
@@ -290,7 +334,8 @@ async function discoverSet() {
let lostFinal = [];
let assertionRead = true;
try {
- lostFinal = lostProtected(await jget(`${BASE}/api/drafts?account=${ACC}&maxResults=500`));
+ const fin = lostProtected(await jget(`${BASE}/api/drafts?account=${ACC}&maxResults=500`));
+ if (fin.readFailed) assertionRead = false; else lostFinal = fin.lost;
} catch (e) {
// Could not re-read => could not verify. That is NOT-MEASURED, not "fine".
assertionRead = false;
@@ -306,11 +351,11 @@ async function discoverSet() {
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';
+ // Could not verify. If this run deleted nothing there was nothing to lose, so WARN.
+ // If it DID delete, an unverifiable outcome on an irreversible operation is a FAIL —
+ // "I could not check" must not be quieter than "I checked and it was fine".
+ verdict = totalOk > 0 ? 'FAIL' : (verdict === 'PASS' ? 'WARN' : verdict);
+ protectionNote = `post-run re-read failed — protection NOT verified after ${totalOk} permanent deletion(s)`;
} 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
@@ -327,5 +372,6 @@ async function discoverSet() {
remaining_in_set: older.size, archive_path: archiveFile,
protected_before: protectedBefore.size, protected_lost_count: lostFinal.length,
protected_lost_ids: lostFinal, protected_verified: protectedVerified,
+ keep_list_ids: KEEP_IDS.size, keep_list_unresolved_count: keepUnresolved.length,
census_truncated: censusTruncated, protection_note: protectionNote });
})().catch((e) => { console.error('FATAL:', e.message); process.exit(1); });
diff --git a/test/test-protected-survival.sh b/test/test-protected-survival.sh
index f9395f3..9dfe978 100755
--- a/test/test-protected-survival.sh
+++ b/test/test-protected-survival.sh
@@ -1,14 +1,18 @@
#!/usr/bin/env bash
# test-protected-survival.sh — negative test for the TK-11552 protected-draft
-# survival assertion in delete-old-drafts-info.js.
+# protection 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.
+# 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.
+# CASE 6 is the one that matters: it breaks isExempt() on purpose and proves the
+# keep-list-derived census disagrees with it and ABORTS BEFORE any delete. The first
+# version of this fix built the census with isExempt() itself and would have passed
+# every other case here while still permanently deleting a protected draft.
+#
+# Runs against an ISOLATED COPY of the drain + a mock George on loopback. Never reaches
+# a real mailbox: GEORGE_BASE points at the mock and the copy lives in a temp dir.
set -u
REPO="$(cd "$(dirname "$0")/.." && pwd)"
WORK="$(mktemp -d /tmp/tk11552-survival-XXXXXX)"
@@ -18,23 +22,27 @@ 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"
+run_case(){ # name keepfile sabotage_after ndrafts break_isexempt
+ local name="$1" keep="$2" sab="$3" nd="$4" brk="${5:-0}"
rm -f "$WORK/data/drain-old-drafts-latest.json"
+ cp "$REPO/delete-old-drafts-info.js" "$WORK/drain-under-test.js"
+ if [ "$brk" = "1" ]; then
+ # INJECTED FAULT: isExempt stops recognizing one keep-listed draft (the realistic
+ # partial false-negative — "8 of 11 recognized"). The keep-list still protects it,
+ # so the two paths must disagree and the run must stop before deleting.
+ perl -0pi -e "s/^const isExempt = .*\$/const isExempt = (d) => !!d && d.id !== 'draft-protected' && (KEEP_IDS.has(d.message && d.message.id) || KEEP_IDS.has(d.id));/m" "$WORK/drain-under-test.js"
+ grep -q "d.id !== 'draft-protected'" "$WORK/drain-under-test.js" || { no "$name: fault injection did not apply — case would measure nothing"; return 1; }
+ fi
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=$!
@@ -46,11 +54,11 @@ run_case(){ # name keepfile sabotage_after ndrafts
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").
+ # Guard the harness itself: rc=127 or 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
+ if [ ! -s "$WORK/data/drain-old-drafts-latest.json" ]; then no "$name: no heartbeat — case never executed"; sed -n '1,15p' "$WORK/$name.out"; return 1; fi
return 0
}
@@ -73,22 +81,36 @@ 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"
+ [ "$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"
+ [ "$(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"
+ [ "$(hb keep_list_unresolved_count)" = "1" ] && ok "reports the unresolved keep-list id" || no "keep_list_unresolved_count=$(hb keep_list_unresolved_count) want 1"
fi
-echo "== CASE 5 (NEGATIVE): listing truncated at 500 -> census incomplete, never clean PASS =="
+echo "== CASE 5 (NEGATIVE): listing truncated at 500 while a keep-list is in force -> refuse to delete blind =="
if run_case c5 "$WORK/keep.json" 0 600; then
+ [ "$RC" = "1" ] && ok "exited 1 (aborted before any delete)" || no "rc=$RC want 1"
+ [ "$(hb verdict)" = "FAIL" ] && ok "verdict FAIL" || no "verdict=$(hb verdict) want FAIL"
[ "$(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"
+ [ "$(hb deleted)" = "0" ] && ok "deleted nothing" || no "deleted=$(hb deleted) want 0"
+fi
+
+echo "== CASE 6 (NEGATIVE, the headline): isExempt() false-negatives a keep-listed draft =="
+echo " -> the keep-list-derived census must DISAGREE and abort BEFORE the delete."
+echo " -> this is the case the first version of the fix silently passed while deleting."
+if run_case c6 "$WORK/keep.json" 0 0 1; 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 contradiction_count)" = "1" ] && ok "contradiction detected" || no "contradiction_count=$(hb contradiction_count) want 1"
+ grep -q "draft-protected" <<<"$(hb contradiction_ids)" && ok "names the draft isExempt mis-classified" || no "contradiction_ids=$(hb contradiction_ids)"
+ [ "$(hb deleted)" = "0" ] && ok "LOSS PREVENTED — deleted 0, not detected-after-the-fact" || no "deleted=$(hb deleted) want 0"
fi
echo
← b8f78c8 TK-11552: drain asserts protected drafts SURVIVED, not just
·
back to George Gmail
·
auto-data-snapshot: 2026-09-13T23:50:25 (1 data files) — dat ca8ce70 →