← 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
M agents/instagram-agent/build-registry.jsA agents/instagram-agent/enrollment-hold.jsonM agents/instagram-agent/probe-owned-pages.js
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 →