[object Object]

← back to Norma

TK-11383: make build-registry.js stop silently changing who posts publicly

123570ce23e301ccbe18501c72dabac3ff8b333c · 2026-09-13 09:41:55 -0700 · Steve Abrams

accounts.json drives com.steve.dw-ig-cadence, which posts PUBLICLY without
--dry. build-registry.js rebuilds that file from GET /me/accounts and, until
now, could silently change its contents in BOTH directions with no output:

1. SILENT DROP. /me/accounts is filtered by the `pages_show_list` granular
   scope, a fixed 80-Page allowlist. @beverlyandhillsdesigns and
   @filthyrichlivingcom are fully IG-linked but outside it, so a rebuild
   dropped them and printed a plausible total. Now every run also reads the
   uncapped business_management owned_pages/client_pages edge and reports any
   IG-linked owned Page /me/accounts could not see -- to stderr AND into the
   written registry (unlisted_ig_linked), so the omission survives the
   scrollback. An unreadable edge records null + unlisted_scan_error, never a
   confident zero (CLAUDE.md TK-11431 rule 1). --include-unlisted enrolls them
   once Steve approves.

2. SILENT ADD (the dangerous one). @designerschat was linked on 2026-09-11 but
   its enrollment was explicitly REVERTED by Steve in 42016d6. Nothing enforced
   that: the Page is inside the allowlist and account-themes.json gives it a
   keyword with no skip flag, so a plain rebuild re-enrolled it and the next
   cadence run would have posted to it. The decision now lives in
   enrollment-hold.json and is enforced; --include-held overrides.

Default behaviour is now strictly MORE correct: a default rebuild reproduces
the committed accounts.json exactly (35 keys, identical records). Before this
change it produced 36.

Also retracts a false claim in probe-owned-pages.js's header that "POSTING
still requires the app-access grant". The publish path never uses a Page token
(POST /{ig-user-id}/media_publish with the shared user token;
instagram_content_publish is target=ALL), and content_publishing_limit returns
a live quota for both allowlist-invisible accounts. Recorded as strong
inference, not proof -- the decisive test is a public post, which is gated.

Tested (--print only, nothing written, no post): default reproduces the
committed registry and reports both drops loudly; --include-unlisted yields 38;
--include-held yields 36. Negative tests: an injected me/businesses fault
reports NOT-MEASURED rather than zero, and a corrupt enrollment-hold.json fails
CLOSED with exit 1 rather than enrolling held accounts.

Undo: git revert HEAD (3 files, no runtime state touched).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M85r8WQP2nf5jssn8RiuDA

Files touched

Diff

commit 123570ce23e301ccbe18501c72dabac3ff8b333c
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Sun Sep 13 09:41:55 2026 -0700

    TK-11383: make build-registry.js stop silently changing who posts publicly
    
    accounts.json drives com.steve.dw-ig-cadence, which posts PUBLICLY without
    --dry. build-registry.js rebuilds that file from GET /me/accounts and, until
    now, could silently change its contents in BOTH directions with no output:
    
    1. SILENT DROP. /me/accounts is filtered by the `pages_show_list` granular
       scope, a fixed 80-Page allowlist. @beverlyandhillsdesigns and
       @filthyrichlivingcom are fully IG-linked but outside it, so a rebuild
       dropped them and printed a plausible total. Now every run also reads the
       uncapped business_management owned_pages/client_pages edge and reports any
       IG-linked owned Page /me/accounts could not see -- to stderr AND into the
       written registry (unlisted_ig_linked), so the omission survives the
       scrollback. An unreadable edge records null + unlisted_scan_error, never a
       confident zero (CLAUDE.md TK-11431 rule 1). --include-unlisted enrolls them
       once Steve approves.
    
    2. SILENT ADD (the dangerous one). @designerschat was linked on 2026-09-11 but
       its enrollment was explicitly REVERTED by Steve in 42016d6. Nothing enforced
       that: the Page is inside the allowlist and account-themes.json gives it a
       keyword with no skip flag, so a plain rebuild re-enrolled it and the next
       cadence run would have posted to it. The decision now lives in
       enrollment-hold.json and is enforced; --include-held overrides.
    
    Default behaviour is now strictly MORE correct: a default rebuild reproduces
    the committed accounts.json exactly (35 keys, identical records). Before this
    change it produced 36.
    
    Also retracts a false claim in probe-owned-pages.js's header that "POSTING
    still requires the app-access grant". The publish path never uses a Page token
    (POST /{ig-user-id}/media_publish with the shared user token;
    instagram_content_publish is target=ALL), and content_publishing_limit returns
    a live quota for both allowlist-invisible accounts. Recorded as strong
    inference, not proof -- the decisive test is a public post, which is gated.
    
    Tested (--print only, nothing written, no post): default reproduces the
    committed registry and reports both drops loudly; --include-unlisted yields 38;
    --include-held yields 36. Negative tests: an injected me/businesses fault
    reports NOT-MEASURED rather than zero, and a corrupt enrollment-hold.json fails
    CLOSED with exit 1 rather than enrolling held accounts.
    
    Undo: git revert HEAD (3 files, no runtime state touched).
    
    Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
    Claude-Session: https://claude.ai/code/session_01M85r8WQP2nf5jssn8RiuDA
---
 agents/instagram-agent/build-registry.js    | 179 +++++++++++++++++++++++++++-
 agents/instagram-agent/enrollment-hold.json |  11 ++
 agents/instagram-agent/probe-owned-pages.js |  21 +++-
 3 files changed, 204 insertions(+), 7 deletions(-)

diff --git a/agents/instagram-agent/build-registry.js b/agents/instagram-agent/build-registry.js
index d6e9daa..70532fe 100644
--- a/agents/instagram-agent/build-registry.js
+++ b/agents/instagram-agent/build-registry.js
@@ -8,11 +8,30 @@
  * shared never-expiring META_ACCESS_TOKEN + graph.facebook.com (Facebook-Login
  * flow), which is what Norma's _ig-api already uses when IG_GRAPH_HOST is set.
  *
- * This is SELF-HEALING: link a new IG account to its Page in Meta Business
- * Suite, re-run this, and the new handle appears automatically.
+ * Re-run this after linking a new IG account in Meta Business Suite and the new
+ * handle appears. It is NOT self-healing on its own -- no LaunchAgent runs it;
+ * a human has to.
  *
- * Usage:  node build-registry.js         # writes accounts.json
- *         node build-registry.js --print # print, don't write
+ * BLIND SPOT THIS FILE COMPENSATES FOR (TK-11383):
+ *   GET /me/accounts is filtered by the `pages_show_list` GRANULAR scope, which
+ *   on this token is a FIXED allowlist of 80 Page IDs. A Page created after that
+ *   grant is INVISIBLE here even when it is fully IG-linked -- so this script
+ *   used to DROP such an account with no output at all, and the runner would see
+ *   a plausible total and never learn anything was missing. Silent omission is
+ *   the false-green class (CLAUDE.md TK-11431 rule 1: an unmeasured input is
+ *   never PASS). So every run now ALSO reads the uncapped `business_management`
+ *   owned_pages/client_pages edge and reports, loudly and in the written
+ *   registry, any IG-linked owned Page that /me/accounts could not see.
+ *
+ *   Those accounts are REPORTED, not enrolled. accounts.json drives
+ *   com.steve.dw-ig-cadence, which posts PUBLICLY without --dry, so adding an
+ *   account is a gated decision -- pass --include-unlisted to actually enroll
+ *   them once Steve has approved it.
+ *
+ * Usage:  node build-registry.js                    # writes accounts.json
+ *         node build-registry.js --print            # print, don't write
+ *         node build-registry.js --include-unlisted # GATED: also enroll the
+ *                                                   # allowlist-invisible ones
  *
  * Token resolution (first hit wins):
  *   process.env.META_ACCESS_TOKEN  →  ~/Projects/secrets-manager/.env
@@ -36,6 +55,75 @@ function readSecretsToken() {
   return '';
 }
 
+/**
+ * Find IG-linked Pages that GET /me/accounts could NOT see.
+ *
+ * `business_management` is granted target=ALL on this token (no allowlist), so
+ * {business}/owned_pages sees every owned Page and its IG link. Returns:
+ *   { measured: true,  pages: [...] }  -> the edge was read; the list is complete
+ *   { measured: false, error: '...' }  -> the edge could NOT be read
+ * The caller must treat measured:false as NOT-MEASURED, never as "none found".
+ */
+async function findUnlistedLinkedPages(token, seenPageIds) {
+  const q = encodeURIComponent(token);
+  const getAll = async (url) => {
+    const out = [];
+    while (url) {
+      const r = await fetch(url);
+      const j = await r.json();
+      if (j.error) throw new Error(j.error.message);
+      out.push(...(j.data || []));
+      url = (j.paging && j.paging.next) || null;
+    }
+    return out;
+  };
+
+  let businesses;
+  try {
+    businesses = await getAll(`${GRAPH}/${VERSION}/me/businesses?fields=id,name&limit=100&access_token=${q}`);
+  } catch (e) {
+    return { measured: false, error: `me/businesses: ${e.message}` };
+  }
+
+  const found = [];
+  const seenIg = new Set();
+  let edgesRead = 0;
+  let edgesFailed = 0;
+  for (const b of businesses) {
+    for (const edge of ['owned_pages', 'client_pages']) {
+      let pages;
+      try {
+        pages = await getAll(
+          `${GRAPH}/${VERSION}/${b.id}/${edge}`
+          + `?fields=id,name,instagram_business_account{id,username}&limit=200&access_token=${q}`,
+        );
+        edgesRead += 1;
+      } catch {
+        // An edge we cannot read is UNKNOWN, not empty. Count it so the caller
+        // can downgrade the result instead of reporting a confident zero.
+        edgesFailed += 1;
+        continue;
+      }
+      for (const p of pages) {
+        const ig = p.instagram_business_account;
+        if (!ig || !ig.id) continue;          // not IG-linked -> not enrollable
+        if (seenPageIds.has(p.id)) continue;  // already visible via me/accounts
+        if (seenIg.has(ig.id)) continue;      // same Page reachable twice
+        seenIg.add(ig.id);
+        found.push({
+          username: ig.username || null,
+          ig_user_id: ig.id,
+          page_id: p.id,
+          page_name: p.name,
+          business: b.name,
+        });
+      }
+    }
+  }
+  if (edgesRead === 0) return { measured: false, error: 'no business page edge was readable' };
+  return { measured: true, partial: edgesFailed > 0, edges_failed: edgesFailed, pages: found };
+}
+
 async function main() {
   const token = readSecretsToken();
   if (!token) { console.error('No META_ACCESS_TOKEN found (env or secrets-manager/.env).'); process.exit(1); }
@@ -44,6 +132,7 @@ async function main() {
     + `?fields=name,instagram_business_account{id,username}&limit=100&access_token=${encodeURIComponent(token)}`;
   const linked = [];
   const unlinked = [];
+  const seenPageIds = new Set();
   let pages = 0;
 
   while (url) {
@@ -52,6 +141,7 @@ async function main() {
     if (j.error) throw new Error(`me/accounts failed: ${j.error.message}`);
     for (const p of j.data || []) {
       pages += 1;
+      seenPageIds.add(p.id);
       const ig = p.instagram_business_account;
       if (ig && ig.id) {
         linked.push({ username: ig.username || null, ig_user_id: ig.id, page_id: p.id, page_name: p.name });
@@ -90,15 +180,94 @@ async function main() {
     };
   }
 
+  // ---- Honour the explicit enrollment HOLD list ----------------------------
+  // accounts.json drives com.steve.dw-ig-cadence, which posts PUBLICLY. A handle
+  // can be fully IG-linked and still be deliberately NOT enrolled (designerschat:
+  // link done, enrollment reverted by Steve in e0dcc47). Nothing enforced that,
+  // so a plain rebuild silently re-enrolled it and the next cadence run would
+  // have posted. The decision now lives in enrollment-hold.json.
+  const includeHeld = process.argv.includes('--include-held');
+  let holdMap = {};
+  let holdReadError = null;
+  try {
+    holdMap = JSON.parse(fs.readFileSync(path.join(__dirname, 'enrollment-hold.json'), 'utf8')).hold || {};
+  } catch (e) {
+    // Fail CLOSED on a malformed file, fail OPEN only when it genuinely is absent.
+    if (e.code !== 'ENOENT') {
+      console.error(`FAILED: enrollment-hold.json is unreadable (${e.message}).`);
+      console.error('  Refusing to build: a lost hold list silently enrolls held accounts into a PUBLIC cadence.');
+      process.exit(1);
+    }
+    holdReadError = 'enrollment-hold.json not present';
+  }
+  const held = [];
+  for (const h of Object.keys(holdMap)) {
+    if (!accounts[h]) continue;
+    held.push({ handle: h, reason: holdMap[h].reason || null, ticket: holdMap[h].ticket || null });
+    if (!includeHeld) delete accounts[h];
+  }
+  if (held.length) {
+    console.error(`${includeHeld ? 'NOTICE' : 'HOLD'}: ${held.length} IG-linked handle(s) are on the enrollment hold list:`);
+    for (const h of held) console.error(`  @${h.handle}${h.ticket ? ` [${h.ticket}]` : ''} — ${h.reason || 'no reason recorded'}`);
+    console.error(includeHeld
+      ? '  --include-held was passed: they ARE enrolled in this registry.'
+      : '  Excluded from this registry. Pass --include-held to enroll (starts a PUBLIC cadence — Steve\'s call).');
+  }
+
+  // ---- Cross-check the uncapped business edge for accounts /me/accounts hid ----
+  const includeUnlisted = process.argv.includes('--include-unlisted');
+  const unlistedResult = await findUnlistedLinkedPages(token, seenPageIds);
+  const unlisted = unlistedResult.measured ? unlistedResult.pages : [];
+
+  if (includeUnlisted && unlistedResult.measured) {
+    for (const a of unlisted) {
+      const key = (a.username || a.page_id).toLowerCase();
+      if (accounts[key]) continue;
+      accounts[key] = {
+        handle: a.username,
+        ig_user_id: a.ig_user_id,
+        page_id: a.page_id,
+        page_name: a.page_name,
+        graph_host: GRAPH,
+        graph_version: VERSION,
+        enrolled_via: 'business_management edge (--include-unlisted)',
+        ...(await statsFor(a.ig_user_id)),
+      };
+    }
+  }
+
   const registry = {
     generated_at: new Date().toISOString(),
     token_source: 'META_ACCESS_TOKEN (shared, never-expiring)',
     pages_total: pages,
     accounts_postable: Object.keys(accounts).length,
     pages_without_ig: unlinked.sort(),
+    // Recorded in the file itself so the omission cannot be lost with the
+    // terminal scrollback. null => the business edge could NOT be read, which
+    // is NOT-MEASURED and must never be read as "none".
+    unlisted_ig_linked: unlistedResult.measured
+      ? unlisted.map((a) => ({ handle: a.username, ig_user_id: a.ig_user_id, page_id: a.page_id, page_name: a.page_name, enrolled: includeUnlisted }))
+      : null,
+    unlisted_scan_error: unlistedResult.measured ? null : unlistedResult.error,
+    held_out: held.map((h) => ({ ...h, enrolled: includeHeld })),
+    hold_list_status: holdReadError || 'enrollment-hold.json read ok',
     accounts,
   };
 
+  // Loud, unmissable: a silent drop is the bug this block exists to kill.
+  if (!unlistedResult.measured) {
+    console.error(`WARNING: could not read the business page edge (${unlistedResult.error}).`);
+    console.error('  NOT-MEASURED: there may be IG-linked Pages missing from this registry. Do not read this as "none".');
+  } else if (unlisted.length) {
+    console.error(`WARNING: ${unlisted.length} IG-linked Page(s) are invisible to GET /me/accounts (outside the pages_show_list allowlist):`);
+    for (const a of unlisted) console.error(`  @${a.username || a.ig_user_id}  (page ${a.page_id} "${a.page_name}", business ${a.business})`);
+    console.error(includeUnlisted
+      ? '  --include-unlisted was passed: the above ARE enrolled in this registry.'
+      : '  These are REPORTED ONLY and are NOT in this registry. Enrolling them starts a PUBLIC daily cadence,');
+    if (!includeUnlisted) console.error('  so it needs Steve\'s approval; re-run with --include-unlisted once approved.');
+    if (unlistedResult.partial) console.error(`  (note: ${unlistedResult.edges_failed} business edge(s) were unreadable; this list may be incomplete)`);
+  }
+
   if (process.argv.includes('--print')) {
     console.log(JSON.stringify(registry, null, 2));
     return;
@@ -108,6 +277,8 @@ async function main() {
   console.log(`Wrote ${out}`);
   console.log(`  postable IG accounts: ${registry.accounts_postable}`);
   console.log(`  pages without linked IG: ${registry.pages_without_ig.length}`);
+  console.log(`  IG-linked but outside the allowlist: ${
+    registry.unlisted_ig_linked === null ? 'NOT MEASURED' : `${registry.unlisted_ig_linked.length}${includeUnlisted ? ' (enrolled)' : ' (reported, NOT enrolled)'}`}`);
 }
 
 main().catch((e) => { console.error('FAILED:', e.message); process.exit(1); });
diff --git a/agents/instagram-agent/enrollment-hold.json b/agents/instagram-agent/enrollment-hold.json
new file mode 100644
index 0000000..19fcf63
--- /dev/null
+++ b/agents/instagram-agent/enrollment-hold.json
@@ -0,0 +1,11 @@
+{
+  "_note": "Handles DELIBERATELY held out of accounts.json. build-registry.js excludes these from the registry it writes and reports them loudly; daily-cadence.js posts to EVERY key of accounts.json, so an accidental re-add here is an accidental PUBLIC post. Re-run with --include-held to enroll one anyway (a gated decision -- Steve's).",
+  "hold": {
+    "designerschat": {
+      "reason": "IG<->Page link completed 2026-09-11, but enrollment into the live public cadence was explicitly REVERTED by Steve in commit e0dcc47 ('Revert TK-11383: enroll @designerschat'). Linked != enrolled. Without this hold, any plain `node build-registry.js` silently re-enrolls it, because the Page is inside the pages_show_list allowlist and account-themes.json gives it a keyword with no skip flag.",
+      "ticket": "TK-11383",
+      "held_since": "2026-09-11",
+      "held_by_commit": "e0dcc47"
+    }
+  }
+}
diff --git a/agents/instagram-agent/probe-owned-pages.js b/agents/instagram-agent/probe-owned-pages.js
index 5ae4741..a5f3659 100755
--- a/agents/instagram-agent/probe-owned-pages.js
+++ b/agents/instagram-agent/probe-owned-pages.js
@@ -16,10 +16,25 @@
  *   sees every owned Page and its IG link, including Pages outside the 80.
  *
  *   Direct `GET /{page-id}` and `?fields=access_token` still fail (#100) for
- *   Pages outside the allowlist — they need `pages_read_engagement`, which is
+ *   Pages outside the allowlist -- they need `pages_read_engagement`, which is
  *   capped at the same 80. So this probe can MEASURE link state but cannot
- *   obtain a Page token; POSTING still requires the app-access grant.
- *   Measurement blindness: solved. Posting: still gated on Steve.
+ *   obtain a Page token.
+ *
+ *   CORRECTION 2026-09-13: an earlier version of this comment claimed "POSTING
+ *   still requires the app-access grant". That is NOT supported by evidence and
+ *   is retracted. Nothing in the posting path uses a Page token: post-to.js,
+ *   fabric-friday-reshare.js and hashtag-reshare.js all POST
+ *   /{ig-user-id}/media_publish with the SHARED user token, and
+ *   instagram_content_publish / instagram_basic are granted target=ALL (not
+ *   capped at 80). Measured on both allowlist-invisible accounts:
+ *   GET /{ig-id}/content_publishing_limit returns a live quota (quota_usage 0),
+ *   and GET /{ig-id} returns username/followers -- identical in shape to an
+ *   enrolled control. So the app-access grant looks UNNECESSARY for posting.
+ *   Stated honestly: that is STRONG INFERENCE, not proof. The decisive test is
+ *   an actual media_publish, which is a public post and therefore gated.
+ *   What the allowlist gap definitely still breaks: Page-object reads, Page
+ *   tokens, and build-registry.js's /me/accounts DISCOVERY (now compensated for
+ *   there via this same business_management edge).
  *
  * Usage:  node probe-owned-pages.js            # human-readable
  *         node probe-owned-pages.js --json     # machine-readable

← 414dbe2 fix: extend dw-ig-link-drift with business_management edge f  ·  back to Norma  ·  TK-11383: enforce the designerschat enrollment hold at the P 72f0976 →