← 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
M agents/instagram-agent/build-registry.jsM agents/instagram-agent/enrollment-hold.json
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 →