[object Object]

← back to Norma

TK-11383: close the allowlist-expansion path to unapproved public IG posting

36f4edea1e5d29e171ca2f26c3b2fd1458ce548c · 2026-09-13 16:20:47 -0700 · Steve Abrams

accounts.json decides who com.steve.dw-ig-cadence posts to PUBLICLY. Two
defects let never-approved accounts reach it. Both were verified by an
injected fault BEFORE fixing, not by a happy-path check.

1. Transient-property gate. @beverlyandhillsdesigns + @filthyrichlivingcom
   are IG-linked and publish-capable today (live content_publishing_limit on
   the shared token). The ONLY thing excluding them was that they sit outside
   Meta's pages_show_list allowlist, so they fell behind --include-unlisted.
   That property is transient, and the FB app-access grant this ticket is
   waiting on Steve for is exactly what removes it -- after which a plain
   `node build-registry.js` would enrol both with no gate at all. The most
   likely next event on this ticket silently armed two brand accounts.
   Both are now on enrollment-hold.json, keyed on identity, so the hold
   survives that expansion. Recorded honestly as never-decided, NOT a revert
   (unlike designerschat/42016d6) -- their enrolment is Steve's open decision.

2. Hold bypass, found while testing 1. The hold was applied only to the
   /me/accounts set; the --include-unlisted merge wrote into the same object
   without consulting it. Measured: with both handles ON the hold list,
   `--print --include-unlisted` still emitted both (37 accounts, "HOLD: 1").
   The flag an operator would reach for defeated the guard.

Hardening from the second-model review (codex 429 spend-capped; Kimi k3):

* Holds match three ways -- normalised key, the account's handle field, and
  ig_user_id. ig_user_id is load-bearing: it is immutable and is what the
  publisher posts to (post-to.js -> /{ig-user-id}/media_publish), while a
  username can be renamed by the owner at any time. It also covers the
  unlisted merge keying an account by page_id when the edge returns
  username:null, which a handle lookup can never match.
* Write-boundary assertion. Both defects were the same shape: a guard that
  ran before a later mutation. Re-applying after each merge only works if
  every future merge site remembers. The assertion re-checks the assembled
  object immediately before serialisation and exits 1 rather than write.
* A hold matching nothing now reports HOLD ARMED instead of being silent --
  a legitimately-not-yet-visible account and a typo'd one looked identical.

Includes a peer session's complementary isEnrolled() change from the same
working tree: enrolment is reported from actual membership in `accounts`,
never asserted from the requested flag (it was durably recording held-out
handles as enrolled:true).

Verified (live accounts.json never written -- --print and an isolated copy):
  --print --include-unlisted              35, all 3 held absent, HOLD: 3
                                          (was 37 / HOLD: 1 -- goes red)
  --print --include-held --include-unlisted  38, all 3 enrolled -- YES path works
  plain --print                           35, same handle set as committed,
                                          no record differs outside volatile counts
  publisher, synthetic 38-account registry  HOLD ENFORCED on all 3, dropped
  guard neutered + real write attempted   exit 1, accounts.json NOT written

Reversible: git revert this commit. Steve's YES/NO decision is untouched;
this removes only the silent path. Enrolling is still one explicit command.

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

Files touched

Diff

commit 36f4edea1e5d29e171ca2f26c3b2fd1458ce548c
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Sun Sep 13 16:20:47 2026 -0700

    TK-11383: close the allowlist-expansion path to unapproved public IG posting
    
    accounts.json decides who com.steve.dw-ig-cadence posts to PUBLICLY. Two
    defects let never-approved accounts reach it. Both were verified by an
    injected fault BEFORE fixing, not by a happy-path check.
    
    1. Transient-property gate. @beverlyandhillsdesigns + @filthyrichlivingcom
       are IG-linked and publish-capable today (live content_publishing_limit on
       the shared token). The ONLY thing excluding them was that they sit outside
       Meta's pages_show_list allowlist, so they fell behind --include-unlisted.
       That property is transient, and the FB app-access grant this ticket is
       waiting on Steve for is exactly what removes it -- after which a plain
       `node build-registry.js` would enrol both with no gate at all. The most
       likely next event on this ticket silently armed two brand accounts.
       Both are now on enrollment-hold.json, keyed on identity, so the hold
       survives that expansion. Recorded honestly as never-decided, NOT a revert
       (unlike designerschat/42016d6) -- their enrolment is Steve's open decision.
    
    2. Hold bypass, found while testing 1. The hold was applied only to the
       /me/accounts set; the --include-unlisted merge wrote into the same object
       without consulting it. Measured: with both handles ON the hold list,
       `--print --include-unlisted` still emitted both (37 accounts, "HOLD: 1").
       The flag an operator would reach for defeated the guard.
    
    Hardening from the second-model review (codex 429 spend-capped; Kimi k3):
    
    * Holds match three ways -- normalised key, the account's handle field, and
      ig_user_id. ig_user_id is load-bearing: it is immutable and is what the
      publisher posts to (post-to.js -> /{ig-user-id}/media_publish), while a
      username can be renamed by the owner at any time. It also covers the
      unlisted merge keying an account by page_id when the edge returns
      username:null, which a handle lookup can never match.
    * Write-boundary assertion. Both defects were the same shape: a guard that
      ran before a later mutation. Re-applying after each merge only works if
      every future merge site remembers. The assertion re-checks the assembled
      object immediately before serialisation and exits 1 rather than write.
    * A hold matching nothing now reports HOLD ARMED instead of being silent --
      a legitimately-not-yet-visible account and a typo'd one looked identical.
    
    Includes a peer session's complementary isEnrolled() change from the same
    working tree: enrolment is reported from actual membership in `accounts`,
    never asserted from the requested flag (it was durably recording held-out
    handles as enrolled:true).
    
    Verified (live accounts.json never written -- --print and an isolated copy):
      --print --include-unlisted              35, all 3 held absent, HOLD: 3
                                              (was 37 / HOLD: 1 -- goes red)
      --print --include-held --include-unlisted  38, all 3 enrolled -- YES path works
      plain --print                           35, same handle set as committed,
                                              no record differs outside volatile counts
      publisher, synthetic 38-account registry  HOLD ENFORCED on all 3, dropped
      guard neutered + real write attempted   exit 1, accounts.json NOT written
    
    Reversible: git revert this commit. Steve's YES/NO decision is untouched;
    this removes only the silent path. Enrolling is still one explicit command.
    
    Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
    Claude-Session: https://claude.ai/code/session_014BUF96kL2o6FoEgNGaojd3
---
 agents/instagram-agent/build-registry.js    | 130 ++++++++++++++++++++++++----
 agents/instagram-agent/enrollment-hold.json |  24 ++++-
 2 files changed, 134 insertions(+), 20 deletions(-)

diff --git a/agents/instagram-agent/build-registry.js b/agents/instagram-agent/build-registry.js
index a42331e..f562b3d 100644
--- a/agents/instagram-agent/build-registry.js
+++ b/agents/instagram-agent/build-registry.js
@@ -208,20 +208,64 @@ async function main() {
     console.error('WARNING: enrollment-hold.json is absent — the builder-side hold guard is OFF.');
     console.error('  The publisher-side skip in account-themes.json still applies; restore this file to re-arm the builder guard.');
   }
-  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).');
+  // Hold keys are normalized to lowercase because `accounts` is keyed by
+  // (username || page_id).toLowerCase(). Matching a denylist case-SENSITIVELY
+  // against a lowercased map is fail-OPEN: one capital letter in
+  // enrollment-hold.json ("BeverlyAndHillsDesigns") would match nothing, delete
+  // nothing, and print nothing -- the guard would be silently off. A denylist
+  // must never be disarmed by a typo it does not report.
+  {
+    const norm = {};
+    const odd = [];
+    for (const k of Object.keys(holdMap)) {
+      const lk = String(k).toLowerCase();
+      if (lk !== k) odd.push(k);
+      norm[lk] = holdMap[k];
+    }
+    if (odd.length) console.error(`NOTICE: enrollment-hold.json key(s) not lowercase, normalized for matching: ${odd.join(', ')}`);
+    holdMap = norm;
   }
 
+  // Applied TWICE -- once here over the /me/accounts set, and again after the
+  // unlisted (business-edge) merge below. Applying it only here was a real hole:
+  // the hold filtered the me/accounts-derived set, then --include-unlisted added
+  // accounts straight into the same object WITHOUT consulting holdMap, so a
+  // handle that was both held AND outside the pages_show_list allowlist was
+  // enrolled by the very flag an operator would reach for. Verified empirically
+  // before the fix: with beverlyandhillsdesigns + filthyrichlivingcom on the hold
+  // list, `--print --include-unlisted` still emitted both into the registry.
+  const held = [];
+  const heldSeen = new Set();
+  // Hold keys are hand-typed JSON; `accounts` keys are lowercased. Normalise so a
+  // capitalised or padded entry cannot silently no-op.
+  const holdByHandle = {};
+  for (const [k, v] of Object.entries(holdMap)) holdByHandle[String(k).trim().toLowerCase()] = v;
+  // ig_user_id is the IMMUTABLE identifier and the one the publisher actually posts
+  // to (post-to.js: POST /{ig-user-id}/media_publish). A handle-only hold silently
+  // stops matching if the owner renames the account -- and, separately, the unlisted
+  // merge keys an account by page_id whenever the Graph edge returns username:null
+  // (`username: ig.username || null`), which a handle lookup can never match. Match
+  // all three ways so neither drift nor a null username can slip a held account through.
+  const holdByIgId = {};
+  for (const [k, v] of Object.entries(holdByHandle)) if (v && v.ig_user_id) holdByIgId[String(v.ig_user_id)] = k;
+  const applyHold = () => {
+    for (const [key, acct] of Object.entries(accounts)) {
+      const byKey = holdByHandle[key];
+      const byHandle = acct && acct.handle ? holdByHandle[String(acct.handle).trim().toLowerCase()] : null;
+      const igKey = acct && acct.ig_user_id ? holdByIgId[String(acct.ig_user_id)] : null;
+      const entry = byKey || byHandle || (igKey ? holdByHandle[igKey] : null);
+      if (!entry) continue;
+      const name = igKey || (byKey ? key : String(acct.handle).trim().toLowerCase());
+      if (!heldSeen.has(name)) {
+        heldSeen.add(name);
+        held.push({ handle: name, reason: entry.reason || null, ticket: entry.ticket || null,
+                    matched_by: byKey ? 'registry key' : (byHandle ? 'handle field' : 'ig_user_id') });
+      }
+      if (!includeHeld) delete accounts[key];
+    }
+  };
+  applyHold();
+
   // ---- Cross-check the uncapped business edge for accounts /me/accounts hid ----
   const includeUnlisted = process.argv.includes('--include-unlisted');
   const unlistedResult = await findUnlistedLinkedPages(token, seenPageIds);
@@ -244,6 +288,32 @@ async function main() {
     }
   }
 
+  // Re-apply after the unlisted merge so --include-unlisted cannot bypass the hold.
+  applyHold();
+
+  // Enrollment is REPORTED from actual membership in `accounts`, never asserted
+  // from the flag that was passed. Before this, `enrolled: includeUnlisted` was
+  // written into the registry for every unlisted page -- so a handle that was
+  // held out by applyHold() was still recorded, durably, as enrolled:true. The
+  // flag says what was REQUESTED; only `accounts` says what HAPPENED.
+  const isEnrolled = (k) => Object.prototype.hasOwnProperty.call(accounts, String(k).toLowerCase());
+  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).');
+  }
+
+  // A hold that matches nothing is NOT necessarily a bug -- an account outside the
+  // pages_show_list allowlist legitimately matches nothing on a plain build -- but it
+  // must never be invisible, because a typo'd or renamed entry looks exactly the same
+  // and offers zero protection. Report it as ARMED rather than failing.
+  const standby = Object.keys(holdByHandle).filter((h) => !heldSeen.has(h));
+  if (standby.length) {
+    console.error(`HOLD ARMED (matched nothing in this build, still enforced if they appear): ${standby.map((h) => '@' + h).join(', ')}`);
+  }
+
   const registry = {
     generated_at: new Date().toISOString(),
     token_source: 'META_ACCESS_TOKEN (shared, never-expiring)',
@@ -254,10 +324,10 @@ async function main() {
     // 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 }))
+      ? unlisted.map((a) => ({ handle: a.username, ig_user_id: a.ig_user_id, page_id: a.page_id, page_name: a.page_name, enrolled: isEnrolled(a.username || a.page_id) }))
       : null,
     unlisted_scan_error: unlistedResult.measured ? null : unlistedResult.error,
-    held_out: held.map((h) => ({ ...h, enrolled: includeHeld })),
+    held_out: held.map((h) => ({ ...h, enrolled: isEnrolled(h.handle) })),
     hold_list_status: holdReadError || 'enrollment-hold.json read ok',
     accounts,
   };
@@ -269,10 +339,15 @@ async function main() {
   } 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 (includeUnlisted) {
+      const merged = unlisted.filter((a) => isEnrolled(a.username || a.page_id)).length;
+      const heldBack = unlisted.length - merged;
+      console.error(`  --include-unlisted was passed: ${merged} of ${unlisted.length} ARE enrolled in this registry.`);
+      if (heldBack) console.error(`  The other ${heldBack} were kept OUT by the enrollment hold above (--include-unlisted does not override it).`);
+    } else {
+      console.error('  These are REPORTED ONLY and are NOT in this registry. Enrolling them starts a PUBLIC daily cadence,');
+      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)`);
   }
 
@@ -280,6 +355,25 @@ async function main() {
     console.log(JSON.stringify(registry, null, 2));
     return;
   }
+  // LAST LINE OF DEFENCE, at the only boundary that matters. The original bug and the
+  // --include-unlisted bypass were the SAME shape: a guard that ran before a later
+  // mutation. Re-applying the guard after each merge only works if every future merge
+  // site remembers to. This assertion does not care -- it re-checks the fully assembled
+  // object immediately before it is serialised, so any future code path that adds a held
+  // account crashes here instead of quietly publishing it.
+  if (!includeHeld) {
+    const leaked = Object.entries(accounts)
+      .filter(([key, acct]) => holdByHandle[key]
+        || (acct && acct.handle && holdByHandle[String(acct.handle).trim().toLowerCase()])
+        || (acct && acct.ig_user_id && holdByIgId[String(acct.ig_user_id)]))
+      .map(([key]) => key);
+    if (leaked.length) {
+      console.error(`FAILED: ${leaked.length} held account(s) survived to the write boundary: ${leaked.join(', ')}`);
+      console.error('  Refusing to write accounts.json — this file drives a PUBLIC posting cadence.');
+      process.exit(1);
+    }
+  }
+
   const out = path.join(__dirname, 'accounts.json');
   fs.writeFileSync(out, JSON.stringify(registry, null, 2));
   console.log(`Wrote ${out}`);
diff --git a/agents/instagram-agent/enrollment-hold.json b/agents/instagram-agent/enrollment-hold.json
index 19fcf63..9a65878 100644
--- a/agents/instagram-agent/enrollment-hold.json
+++ b/agents/instagram-agent/enrollment-hold.json
@@ -1,11 +1,31 @@
 {
-  "_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).",
+  "_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). KEYS ARE MATCHED THREE WAYS -- lowercased handle key, the account's own handle field, and ig_user_id. ig_user_id is the load-bearing one: it is IMMUTABLE and it is what the publisher actually posts to (post-to.js reads acct.ig_user_id and POSTs /{ig-user-id}/media_publish), whereas an Instagram USERNAME can be changed by the account owner at any time -- a hold keyed only on handle would silently stop matching after a rename while the account kept posting. Always fill in ig_user_id when adding an entry.",
   "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"
+      "held_by_commit": "e0dcc47",
+      "ig_user_id": "17841433398927953",
+      "page_id": "109985254012064"
+    },
+    "beverlyandhillsdesigns": {
+      "reason": "IG<->Page link is API-CONFIRMED (Page 1335819429611525 -> IG 17841461395280048) and the shared user token already returns a live content_publishing_limit quota, so this account is PUBLISH-CAPABLE today. It has NEVER been enrolled and Steve has NEVER decided to enroll it -- that decision is the one open item in pending-approval/TK-11383-ig-owned-account-link-action.md. NOT a revert (unlike designerschat): never-decided, not undone. Held because the only thing keeping it out of accounts.json today is that it sits OUTSIDE the pages_show_list allowlist, which is a TRANSIENT property that Steve's own pending FB app-access grant would remove -- after which a plain `node build-registry.js` would enroll it into the live public cadence with no gate at all. This hold is keyed on the handle, so it survives that allowlist expansion.",
+      "ticket": "TK-11383",
+      "held_since": "2026-09-13",
+      "held_by_commit": "pending",
+      "ig_user_id": "17841461395280048",
+      "page_id": "1335819429611525",
+      "release_when": "Steve approves enrollment; then re-run with --include-held (and --include-unlisted while still outside the allowlist) and remove this entry."
+    },
+    "filthyrichlivingcom": {
+      "reason": "IG<->Page link is API-CONFIRMED (Page 1358805777309079) and the shared user token already returns a live content_publishing_limit quota, so this account is PUBLISH-CAPABLE today. It has NEVER been enrolled and Steve has NEVER decided to enroll it -- that decision is the one open item in pending-approval/TK-11383-ig-owned-account-link-action.md. NOT a revert (unlike designerschat): never-decided, not undone. Held because the only thing keeping it out of accounts.json today is that it sits OUTSIDE the pages_show_list allowlist, which is a TRANSIENT property that Steve's own pending FB app-access grant would remove -- after which a plain `node build-registry.js` would enroll it into the live public cadence with no gate at all. This hold is keyed on the handle, so it survives that allowlist expansion.",
+      "ticket": "TK-11383",
+      "held_since": "2026-09-13",
+      "held_by_commit": "pending",
+      "page_id": "1358805777309079",
+      "release_when": "Steve approves enrollment; then re-run with --include-held (and --include-unlisted while still outside the allowlist) and remove this entry.",
+      "ig_user_id": "17841406859497455"
     }
   }
 }

← 3bdc7a5 TK-11383: enforce the enrollment hold at the PUBLISHER too,  ·  back to Norma  ·  TK-11383: bring the PUBLISHER's enrollment hold to parity wi 51c0305 →