← back to Norma
TK-11367: fix delete tool's silent false-negative + add real approval gates
09fe7b82aabb0778e5930679a3ef84ee322ecc82 · 2026-09-10 08:47:16 -0700 · Steve Abrams
THE BUG: IG_GRAPH_HOST in .env carries the scheme ('https://graph.facebook.com'),
and this script read it bare, so every request built
'https://https://graph.facebook.com/...' -> DNS lookup for host "https" ->
ENOTFOUND -> status 0 -> EVERY candidate silently classified NOT-LIVE. The tool
reported 'Total live Novasuede matches: 0' while 38 posts were in fact live. A
destructive tool that reports 'nothing to delete' for an environmental reason is
the false-green class: someone reads 0 and closes the ticket.
live-media.js already normalizes with .replace(/^https?:\/\//,'') — this was the
one script in the directory that forgot the house pattern. Blast radius: 1 file
(every other Graph script hardcodes the bare host).
Also, this tool hardcoded REASON='novasuede-removal-steve-approved' despite never
having run and despite that approval never having been given — every tombstone
would have asserted a sign-off that did not exist. The reason now carries the real
approving artifact via --approved-by, with no default that claims approval, and
--go REFUSES without it (exit 3) and refuses on a live-count mismatch (exit 4).
Both refusals precede the target set and the delete loop.
Tombstones now also capture media_url + caption, so what a delete destroys stays
re-creatable; media_url/thumbnail_url are pulled in the liveness re-check.
RECONCILE (the 33-vs-38 gap in the memo, now resolved): 33 was stale; 38 is the
true total across 4 accounts; but the tool can only SEE 16, because its input
data/live-media.json covers @designerwallcoverings only. The other 20 in-scope
posts (hospitalitywallcoverings 13, wallpaperinstallers 7) are invisible to it
until live-media.js is re-run for those accounts.
Verified: dry run now returns 16 live with correct permalinks; --go without
--approved-by refuses and deletes nothing. No post deleted or archived.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Files touched
M agents/instagram-agent/delete-novasuede-graph.js
Diff
commit 09fe7b82aabb0778e5930679a3ef84ee322ecc82
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Thu Sep 10 08:47:16 2026 -0700
TK-11367: fix delete tool's silent false-negative + add real approval gates
THE BUG: IG_GRAPH_HOST in .env carries the scheme ('https://graph.facebook.com'),
and this script read it bare, so every request built
'https://https://graph.facebook.com/...' -> DNS lookup for host "https" ->
ENOTFOUND -> status 0 -> EVERY candidate silently classified NOT-LIVE. The tool
reported 'Total live Novasuede matches: 0' while 38 posts were in fact live. A
destructive tool that reports 'nothing to delete' for an environmental reason is
the false-green class: someone reads 0 and closes the ticket.
live-media.js already normalizes with .replace(/^https?:\/\//,'') — this was the
one script in the directory that forgot the house pattern. Blast radius: 1 file
(every other Graph script hardcodes the bare host).
Also, this tool hardcoded REASON='novasuede-removal-steve-approved' despite never
having run and despite that approval never having been given — every tombstone
would have asserted a sign-off that did not exist. The reason now carries the real
approving artifact via --approved-by, with no default that claims approval, and
--go REFUSES without it (exit 3) and refuses on a live-count mismatch (exit 4).
Both refusals precede the target set and the delete loop.
Tombstones now also capture media_url + caption, so what a delete destroys stays
re-creatable; media_url/thumbnail_url are pulled in the liveness re-check.
RECONCILE (the 33-vs-38 gap in the memo, now resolved): 33 was stale; 38 is the
true total across 4 accounts; but the tool can only SEE 16, because its input
data/live-media.json covers @designerwallcoverings only. The other 20 in-scope
posts (hospitalitywallcoverings 13, wallpaperinstallers 7) are invisible to it
until live-media.js is re-run for those accounts.
Verified: dry run now returns 16 live with correct permalinks; --go without
--approved-by refuses and deletes nothing. No post deleted or archived.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
---
agents/instagram-agent/delete-novasuede-graph.js | 42 +++++++++++++++++++++---
1 file changed, 37 insertions(+), 5 deletions(-)
diff --git a/agents/instagram-agent/delete-novasuede-graph.js b/agents/instagram-agent/delete-novasuede-graph.js
index 8bf7b95..572b5e2 100644
--- a/agents/instagram-agent/delete-novasuede-graph.js
+++ b/agents/instagram-agent/delete-novasuede-graph.js
@@ -37,9 +37,20 @@ const TOMBS = path.join(DATA, 'deleted-posts.jsonl');
// Same shared META PAGE token env as delete-gate2-graph.js (.env in this dir, dotenv-loaded).
const TOKEN = process.env.IG_ACCESS_TOKEN;
const VER = process.env.IG_GRAPH_VERSION || 'v21.0';
-const HOST = process.env.IG_GRAPH_HOST || 'graph.facebook.com';
-const REASON = 'novasuede-removal-steve-approved';
-const EXPECTED_LIVE = 33;
+// IG_GRAPH_HOST in .env carries the scheme ('https://graph.facebook.com'), so a bare read built
+// `https://https://graph.facebook.com/...` -> DNS lookup for host "https" -> ENOTFOUND -> status 0 ->
+// EVERY candidate silently classified NOT-LIVE, i.e. the tool reported "0 live, nothing to delete"
+// while 38 posts were in fact live. Same normalization live-media.js already uses (house pattern).
+const HOST = (process.env.IG_GRAPH_HOST || 'graph.facebook.com').replace(/^https?:\/\//, '');
+// TK-11367 2026-09-10: this previously hardcoded REASON='novasuede-removal-steve-approved' in a tool
+// that had NEVER been run and for an approval Steve had never actually given — every tombstone row would
+// have asserted his sign-off regardless. The reason must now carry the REAL approving artifact, supplied
+// at run time via --approved-by; there is no default that claims approval.
+const APPROVED_BY = (() => { const i = process.argv.indexOf('--approved-by'); return i >= 0 ? String(process.argv[i + 1] || '').trim() : ''; })();
+const REASON = APPROVED_BY ? `novasuede-removal · approved-by: ${APPROVED_BY}` : 'novasuede-removal · UNAPPROVED';
+// EXPECTED_LIVE is a tripwire, not a target: a mismatch means the world moved since it was written.
+// Was 33 (pre-dates the Sept posts); the 2026-09-10 full-history sweep of all 35 IG accounts found 38.
+const EXPECTED_LIVE = Number(process.env.NOVASUEDE_EXPECTED_LIVE || 38);
const LEDGER = path.join(process.env.HOME, '.claude', 'yolo-queue', 'executed-reversible', 'log-exec.mjs');
const args = process.argv.slice(2);
@@ -110,6 +121,8 @@ function tombstone(row, permalink) {
deleted_at: new Date().toISOString(),
mode: 'graph-api',
verified: true,
+ media_url: row.media_url || null, // capture what the delete destroys, so the post is re-creatable
+ caption: row.caption || null,
reason: REASON,
}) + '\n');
}
@@ -148,9 +161,14 @@ function ledger(n) {
console.log(`Re-checking ${candidates.length} Novasuede candidate(s) live on Graph …\n`);
const live = [];
for (const c of candidates) {
- const chk = await get(`${c.id}?fields=id,permalink&access_token=${TOKEN}`);
+ // media_url/thumbnail_url are pulled here so the tombstone can preserve what the delete destroys.
+ const chk = await get(`${c.id}?fields=id,permalink,media_url,thumbnail_url&access_token=${TOKEN}`);
if (chk.status === 200 && chk.body && chk.body.id) {
- live.push({ ...c, permalink: chk.body.permalink || c.permalink });
+ live.push({
+ ...c,
+ permalink: chk.body.permalink || c.permalink,
+ media_url: chk.body.media_url || chk.body.thumbnail_url || null,
+ });
}
await sleep(300);
}
@@ -180,6 +198,20 @@ function ledger(n) {
return;
}
+ // APPROVAL GATE: --go is irreversible and external. Refuse unless the caller names the approving
+ // artifact, so a tombstone can never claim a sign-off that was not given.
+ if (!APPROVED_BY) {
+ console.error('\nREFUSING --go: no --approved-by supplied.');
+ console.error('This deletes live posts irreversibly. Re-run naming the approval, e.g.:');
+ console.error(' node delete-novasuede-graph.js --go --approved-by "steve 2026-09-10 TK-11367 option B"');
+ process.exit(3);
+ }
+ if (live.length !== EXPECTED_LIVE) {
+ console.error(`\nREFUSING --go: live count ${live.length} != expected ${EXPECTED_LIVE}.`);
+ console.error('Reconcile the discrepancy first, or set NOVASUEDE_EXPECTED_LIVE to the verified count.');
+ process.exit(4);
+ }
+
// HARD GUARD: only ids from the built (filtered) candidate set can reach the delete loop.
const guard = new Set(candidates.map((c) => c.id));
const targets = live.filter((c) => guard.has(c.id));
← ff18124 auto-data-snapshot: 2026-09-10T08:12:39 (1 data files) — age
·
back to Norma
·
auto-data-snapshot: 2026-09-10T08:48:14 (1 data files) — age 3c09c19 →