[object Object]

← 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

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 →