[object Object]

← back to Norma

TK-11383: bring the PUBLISHER's enrollment hold to parity with the builder

51c03059cf2f091c8b5feddf55de8f1315556345 · 2026-09-13 16:27:22 -0700 · Steve Abrams

36f4ede hardened build-registry.js to match the hold three ways (registry key,
handle field, ig_user_id) but left daily-cadence.js matching only the registry
key. That made the publisher the WEAKER of the two layers -- and the publisher is
where the irreversible public post happens, so it is the one that must not be
bypassable. Verified in an offline sandbox against three injected bypass shapes:
the old publisher caught 1 of 3, this one catches 3 of 3.

The two shapes the key-only match let through, both of which would have posted
publicly to a held account:
  - PAGE_ID-keyed record with handle:null. The unlisted merge keys an account by
    page_id whenever the Graph edge returns username:null, and a handle lookup
    can never match that.
  - RENAMED account. A handle-keyed hold stops matching the moment the owner
    renames; ig_user_id is immutable and is literally what post-to.js publishes
    to (POST /{ig-user-id}/media_publish).

Also reports hold entries that matched NOTHING -- a typo'd handle is otherwise
indistinguishable from a working denylist. That report is computed from what
actually fired, not by re-scanning the registry, because the held accounts have
already been deleted by then and a re-scan reports every entry that just fired
as "matched nothing": a guard must not misreport its own successful match.

Unchanged and re-verified: corrupt hold file aborts (exit 1), absent hold file
warns loudly and continues, a clean registry drops nothing.

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

Files touched

Diff

commit 51c03059cf2f091c8b5feddf55de8f1315556345
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Sun Sep 13 16:27:22 2026 -0700

    TK-11383: bring the PUBLISHER's enrollment hold to parity with the builder
    
    36f4ede hardened build-registry.js to match the hold three ways (registry key,
    handle field, ig_user_id) but left daily-cadence.js matching only the registry
    key. That made the publisher the WEAKER of the two layers -- and the publisher is
    where the irreversible public post happens, so it is the one that must not be
    bypassable. Verified in an offline sandbox against three injected bypass shapes:
    the old publisher caught 1 of 3, this one catches 3 of 3.
    
    The two shapes the key-only match let through, both of which would have posted
    publicly to a held account:
      - PAGE_ID-keyed record with handle:null. The unlisted merge keys an account by
        page_id whenever the Graph edge returns username:null, and a handle lookup
        can never match that.
      - RENAMED account. A handle-keyed hold stops matching the moment the owner
        renames; ig_user_id is immutable and is literally what post-to.js publishes
        to (POST /{ig-user-id}/media_publish).
    
    Also reports hold entries that matched NOTHING -- a typo'd handle is otherwise
    indistinguishable from a working denylist. That report is computed from what
    actually fired, not by re-scanning the registry, because the held accounts have
    already been deleted by then and a re-scan reports every entry that just fired
    as "matched nothing": a guard must not misreport its own successful match.
    
    Unchanged and re-verified: corrupt hold file aborts (exit 1), absent hold file
    warns loudly and continues, a clean registry drops nothing.
    
    Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
    Claude-Session: https://claude.ai/code/session_01XQTf1fpLGZCEv79K8G9Gma
---
 agents/instagram-agent/daily-cadence.js | 47 +++++++++++++++++++++++++++++----
 1 file changed, 42 insertions(+), 5 deletions(-)

diff --git a/agents/instagram-agent/daily-cadence.js b/agents/instagram-agent/daily-cadence.js
index 960126b..1fe9e10 100644
--- a/agents/instagram-agent/daily-cadence.js
+++ b/agents/instagram-agent/daily-cadence.js
@@ -53,12 +53,49 @@ const HANDLES = Object.keys(reg);
     }
     console.error('WARNING: enrollment-hold.json not present; proceeding with no enrollment holds.');
   }
-  const heldPresent = Object.keys(hold).filter((h) => Object.prototype.hasOwnProperty.call(reg, h));
-  for (const h of heldPresent) {
-    delete reg[h];
-    const i = HANDLES.indexOf(h);
+  // Match the hold the SAME three ways build-registry.js does -- registry key,
+  // the record's own `handle` field, and ig_user_id -- and normalise every key.
+  // This side is the one that matters: build-registry only WRITES a file, whereas
+  // the post below is IRREVERSIBLE and PUBLIC, so the publisher must never be the
+  // weaker of the two guards. Each narrower match is a real fail-open:
+  //   - case/whitespace: one capital in hand-typed JSON matched nothing, silently;
+  //   - registry key only: the unlisted merge keys an account by PAGE_ID whenever
+  //     the Graph edge returns username:null, which a handle lookup never matches;
+  //   - handle only: a hold stops matching the moment the owner RENAMES the account,
+  //     while ig_user_id is immutable and is literally what post-to.js publishes to
+  //     (POST /{ig-user-id}/media_publish).
+  const holdByHandle = {};
+  for (const [k, v] of Object.entries(hold)) holdByHandle[String(k).trim().toLowerCase()] = v;
+  const holdByIgId = {};
+  for (const [k, v] of Object.entries(holdByHandle)) if (v && v.ig_user_id) holdByIgId[String(v.ig_user_id)] = k;
+
+  const fired = new Set();
+  for (const key of Object.keys(reg)) {
+    const acct = reg[key] || {};
+    const k = String(key).trim().toLowerCase();
+    const byKey = holdByHandle[k];
+    const byHandle = acct.handle ? holdByHandle[String(acct.handle).trim().toLowerCase()] : null;
+    const igName = acct.ig_user_id ? holdByIgId[String(acct.ig_user_id)] : null;
+    const entry = byKey || byHandle || (igName ? holdByHandle[igName] : null);
+    if (!entry) continue;
+    const how = byKey ? 'registry key' : (byHandle ? 'handle field' : 'ig_user_id');
+    fired.add(byKey ? k : (byHandle ? String(acct.handle).trim().toLowerCase() : igName));
+    delete reg[key];
+    const i = HANDLES.indexOf(key);
     if (i >= 0) HANDLES.splice(i, 1);
-    console.error(`HOLD ENFORCED: @${h} is on the enrollment hold list (${hold[h].ticket || 'no ticket'}) but was present in accounts.json — dropped from this run so it will NOT post publicly. ${hold[h].reason || ''}`);
+    console.error(`HOLD ENFORCED: @${acct.handle || key} is on the enrollment hold list (${entry.ticket || 'no ticket'}, matched by ${how}) but was present in accounts.json — dropped from this run so it will NOT post publicly. ${entry.reason || ''}`);
+  }
+
+  // A hold entry that matches NOTHING is reported, never silent. An unmatched entry
+  // is the signature of a typo'd handle, and a denylist that cannot say "I matched
+  // nothing" is indistinguishable from one that is working.
+  // Computed from what actually FIRED, not by re-scanning `reg` -- the held
+  // accounts have already been deleted from it by this point, so a re-scan would
+  // report every entry that just fired as "matched nothing". A guard must not
+  // misreport its own successful match.
+  const unmatched = Object.keys(holdByHandle).filter((k) => !fired.has(k));
+  if (unmatched.length) {
+    console.error(`HOLD: ${unmatched.length} hold entr(ies) matched no account in this registry — expected if never enrolled, but a typo'd handle looks identical: ${unmatched.join(', ')}`);
   }
 })();
 const now = new Date();

← 36f4ede TK-11383: close the allowlist-expansion path to unapproved p  ·  back to Norma  ·  auto-data-snapshot: 2026-09-14T08:21:43 (1 data files) — age 11eb4af →