← 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
M agents/instagram-agent/daily-cadence.js
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 →