← back to Norma
TK-11383: link-drift canary now measures the not-yet-linked accounts
3b66731151b08ee9ce5312841d5a4cd3b4830512 · 2026-09-26 07:39:24 -0700 · Steve Abrams
check-link-drift.js dropped every UNLINKED Page, so the two accounts waiting on
Steve's phone (nationalpaperhangers, protestdaily) were unmeasured. That blind
spot was why TK-11383 had to stay a ticket instead of a canary row. Now:
- link-watchlist.json pages resolve to awaiting_link / newly_linked / not_found
(not_found is NOT-MEASURED and stays WARN)
- enrollment-hold decision split: decided-no (designerschat) is not drift;
pending-steve or a MISSING decision keeps WARN (fail-closed)
- a corrupt hold or watchlist file exits 2 and gives no verdict
- test-link-drift.mjs: 11 hermetic negative tests, including a contrast proving
the pre-change detector was blind
Read-only detector. Posts nothing and writes no registry.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Sy7SKYqHt2gwdfTTD3az7V
Files touched
M agents/instagram-agent/check-link-drift.jsA agents/instagram-agent/test-link-drift.mjs
Diff
commit 3b66731151b08ee9ce5312841d5a4cd3b4830512
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Sat Sep 26 07:39:24 2026 -0700
TK-11383: link-drift canary now measures the not-yet-linked accounts
check-link-drift.js dropped every UNLINKED Page, so the two accounts waiting on
Steve's phone (nationalpaperhangers, protestdaily) were unmeasured. That blind
spot was why TK-11383 had to stay a ticket instead of a canary row. Now:
- link-watchlist.json pages resolve to awaiting_link / newly_linked / not_found
(not_found is NOT-MEASURED and stays WARN)
- enrollment-hold decision split: decided-no (designerschat) is not drift;
pending-steve or a MISSING decision keeps WARN (fail-closed)
- a corrupt hold or watchlist file exits 2 and gives no verdict
- test-link-drift.mjs: 11 hermetic negative tests, including a contrast proving
the pre-change detector was blind
Read-only detector. Posts nothing and writes no registry.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Sy7SKYqHt2gwdfTTD3az7V
---
agents/instagram-agent/check-link-drift.js | 178 ++++++++++++++++++++++-
agents/instagram-agent/test-link-drift.mjs | 220 +++++++++++++++++++++++++++++
2 files changed, 392 insertions(+), 6 deletions(-)
diff --git a/agents/instagram-agent/check-link-drift.js b/agents/instagram-agent/check-link-drift.js
index 8a137c7..45fa46d 100755
--- a/agents/instagram-agent/check-link-drift.js
+++ b/agents/instagram-agent/check-link-drift.js
@@ -56,6 +56,11 @@ async function getAll(url) {
if (!t) { console.error('UNKNOWN: no META_ACCESS_TOKEN (env or secrets-manager/.env)'); process.exit(2); }
const live = new Map();
+ // EVERY owned Page seen, linked or NOT — keyed by page_id. The detector used to
+ // record a Page only once instagram_business_account existed, so an UNLINKED Page
+ // was dropped on the floor and read as 'no drift'. That blindness is exactly the
+ // NOT-YET-LINKED population link-watchlist.json watches (TK-11383).
+ const allPages = new Map();
let pagesSeen = 0;
// --- Pass 1: /me/accounts (pages_show_list-gated, may be capped at a fixed allowlist) ---
@@ -67,6 +72,11 @@ async function getAll(url) {
for (const p of pages) {
pagesSeen += 1;
const ig = p.instagram_business_account;
+ allPages.set(String(p.id), {
+ page_id: String(p.id), page_name: p.name, source: 'me/accounts',
+ ig_linked: !!(ig && ig.id), ig_user_id: ig && ig.id ? ig.id : null,
+ ig_username: ig && ig.username ? ig.username : null,
+ });
if (ig && ig.id && ig.username) {
live.set(ig.username, { ig_user_id: ig.id, page_id: p.id, page_name: p.name });
}
@@ -110,6 +120,13 @@ async function getAll(url) {
for (const p of pages) {
pagesSeen += 1;
const ig = p.instagram_business_account;
+ if (!allPages.has(String(p.id))) {
+ allPages.set(String(p.id), {
+ page_id: String(p.id), page_name: p.name, source: `${biz.name}/${edge}`,
+ ig_linked: !!(ig && ig.id), ig_user_id: ig && ig.id ? ig.id : null,
+ ig_username: ig && ig.username ? ig.username : null,
+ });
+ }
if (ig && ig.id && ig.username && !live.has(ig.username)) {
// This account is IG-linked but was INVISIBLE to /me/accounts — the blind spot.
live.set(ig.username, {
@@ -136,11 +153,114 @@ async function getAll(url) {
process.exit(2);
}
+ // ---- enrollment-hold.json: separate a DECIDED no from an OPEN question -------
+ // Without this, a handle Steve explicitly REVERTED (designerschat, e0dcc47) is
+ // reported forever as actionable drift whose printed remediation is the very
+ // command the revert undid — a chronic false alarm that trains the row to be
+ // ignored. But the inverse error is worse: suppressing ALL held handles would
+ // launder a never-decided account (an open ask sitting in pending-approval)
+ // into green. So the split is on an EXPLICIT `decision` field, and a MISSING
+ // one is read as pending-steve — absence of a decision is not a decision.
+ let holdMap = {};
+ let holdStatus = 'enrollment-hold.json read ok';
+ try {
+ holdMap = JSON.parse(fs.readFileSync(path.join(HERE, 'enrollment-hold.json'), 'utf8')).hold || {};
+ } catch (e) {
+ if (e.code === 'ENOENT') {
+ // Absent => the hold guard is OFF. Over-report (treat every linked-not-enrolled
+ // handle as undecided) rather than assert a decision we cannot read.
+ holdStatus = 'enrollment-hold.json ABSENT — no decision provenance; every linked-not-enrolled handle treated as undecided';
+ console.error(`WARNING: ${holdStatus}`);
+ } else {
+ // Corrupt => we cannot tell decided from undecided. Never guess.
+ console.error(`UNKNOWN: enrollment-hold.json is unreadable (${e.message}). Refusing to classify drift off an untrustworthy hold list.`);
+ process.exit(2);
+ }
+ }
+ const holdByHandle = {};
+ const holdByIgId = {};
+ for (const [k, v] of Object.entries(holdMap)) {
+ const key = String(k).trim().toLowerCase();
+ holdByHandle[key] = v;
+ if (v && v.ig_user_id) holdByIgId[String(v.ig_user_id)] = v;
+ }
+ // Match the hold the same way the builder and publisher do: handle AND the
+ // immutable ig_user_id (a username can be renamed by the owner at any time).
+ const holdFor = (handle, meta) => holdByHandle[String(handle).trim().toLowerCase()]
+ || (meta && meta.ig_user_id ? holdByIgId[String(meta.ig_user_id)] : null)
+ || null;
+
const liveHandles = [...live.keys()].sort();
const diskHandles = Object.keys(disk).sort();
- const newlyLinked = liveHandles.filter((h) => !diskHandles.includes(h)); // linked, not enrolled
+ const linkedNotEnrolled = liveHandles.filter((h) => !diskHandles.includes(h));
const lostLink = diskHandles.filter((h) => !liveHandles.includes(h)); // enrolled, link gone
- const drift = newlyLinked.length > 0 || lostLink.length > 0;
+
+ const newlyLinked = []; // linked, not enrolled, NOT held -> undecided, actionable
+ const heldDecided = []; // held with decision=decided-no -> informational
+ const heldPending = []; // held with decision=pending-steve (or missing) -> OPEN ask
+ for (const h of linkedNotEnrolled) {
+ const meta = live.get(h);
+ const held = holdFor(h, meta);
+ if (!held) { newlyLinked.push(h); continue; }
+ const decision = held.decision || 'pending-steve'; // fail-closed
+ const row = {
+ handle: h, ...meta, decision,
+ decision_declared: !!held.decision,
+ ticket: held.ticket || null,
+ release_when: held.release_when || null,
+ };
+ if (decision === 'decided-no') heldDecided.push(row); else heldPending.push(row);
+ }
+
+ // ---- link-watchlist.json: the NOT-YET-LINKED population (TK-11383) ----------
+ // Resolved by page_id against every Page seen this run. Three states, never two:
+ // awaiting_link (measured, still unlinked) / newly_linked (Steve's action LANDED)
+ // / not_found (page_id no longer resolvable = NOT MEASURED, never 'still waiting').
+ let watchMap = {};
+ let watchStatus = 'link-watchlist.json read ok';
+ try {
+ watchMap = JSON.parse(fs.readFileSync(path.join(HERE, 'link-watchlist.json'), 'utf8')).watch || {};
+ } catch (e) {
+ if (e.code === 'ENOENT') {
+ watchStatus = 'link-watchlist.json ABSENT — the NOT-YET-LINKED population is UNMEASURED this run';
+ console.error(`WARNING: ${watchStatus}`);
+ } else {
+ console.error(`UNKNOWN: link-watchlist.json is unreadable (${e.message}). Refusing to report the unlinked population as measured.`);
+ process.exit(2);
+ }
+ }
+ const watch = [];
+ for (const [handle, w] of Object.entries(watchMap)) {
+ const pid = w && w.page_id ? String(w.page_id) : null;
+ const page = pid ? allPages.get(pid) : null;
+ let state;
+ if (!page) state = 'not_found'; // NOT MEASURED
+ else if (page.ig_linked) state = 'newly_linked'; // the signal this watch exists for
+ else state = 'awaiting_link'; // measured, expected, outstanding on a human
+ watch.push({
+ handle, page_id: pid, page_name: (page && page.page_name) || w.page_name || null,
+ state,
+ ig_username: page && page.ig_username ? page.ig_username : null,
+ ig_user_id: page && page.ig_user_id ? page.ig_user_id : null,
+ optional: !!w.optional,
+ waiting_on: w.waiting_on || null,
+ blocked_since: w.blocked_since || null,
+ ticket: w.ticket || null,
+ });
+ }
+ const watchNewlyLinked = watch.filter((w) => w.state === 'newly_linked');
+ const watchAwaiting = watch.filter((w) => w.state === 'awaiting_link');
+ const watchNotFound = watch.filter((w) => w.state === 'not_found');
+
+ // Outstanding-human items keep the row WARN and are SELF-CLEARING (they go away
+ // the day Steve acts). A watch page we could not resolve is NOT-MEASURED, which
+ // is likewise never green.
+ const drift = newlyLinked.length > 0
+ || lostLink.length > 0
+ || heldPending.length > 0
+ || watchNewlyLinked.length > 0
+ || watchAwaiting.length > 0
+ || watchNotFound.length > 0;
const report = {
checked_at: new Date().toISOString(),
@@ -148,10 +268,28 @@ async function getAll(url) {
live_postable: liveHandles.length,
enrolled_on_disk: diskHandles.length,
newly_linked_not_enrolled: newlyLinked.map((h) => ({ handle: h, ...live.get(h) })),
+ // Held handles are NOT in newly_linked_not_enrolled: a decided-no is not drift,
+ // and a pending-steve is an open ASK rather than an un-run command.
+ held_not_enrolled_decided_no: heldDecided,
+ held_not_enrolled_pending_steve: heldPending,
+ hold_list_status: holdStatus,
+ // The NOT-YET-LINKED population — invisible to this detector before TK-11383.
+ link_watchlist_status: watchStatus,
+ link_watchlist: watch,
+ watch_awaiting_link: watchAwaiting.length,
+ watch_newly_linked: watchNewlyLinked.length,
+ watch_not_found: watchNotFound.length,
+ outstanding_human_items: heldPending.length + watchAwaiting.length,
+ // `live_postable` counts IG-LINKED accounts. Link state is INFERRED publish
+ // capability, not a measured one — the decisive proof is a media_publish, which
+ // is a live public post and therefore gated. Named honestly rather than fixed.
+ live_postable_basis: 'ig-link-state (inferred); NOT a measured publish capability',
enrolled_but_link_lost: lostLink,
verdict: drift ? 'DRIFT' : 'IN_SYNC',
status: drift ? 'WARN' : 'PASS', // fleet-health-rollup vocabulary
- activate_command: drift ? 'node build-registry.js # GATED: enrolling widens live public posting scope' : null,
+ activate_command: newlyLinked.length
+ ? 'node build-registry.js # GATED: enrolling widens live public posting scope'
+ : null,
// business_management edge results (TK-11604)
biz_mgmt_checked: bizMgmtChecked,
biz_mgmt_new_accounts: bizMgmtNewAccounts,
@@ -168,19 +306,47 @@ async function getAll(url) {
console.log(` business_management edge: SKIPPED (${bizMgmtError}) — /me/accounts only`);
}
if (newlyLinked.length) {
- console.log(` LINKED BUT NOT ENROLLED (${newlyLinked.length}):`);
+ console.log(` LINKED BUT NOT ENROLLED — UNDECIDED (${newlyLinked.length}):`);
for (const h of newlyLinked) {
const a = live.get(h);
const bm = a.via_business_management ? ' [via business_management — not in /me/accounts allowlist]' : '';
console.log(` + @${h} (Page "${a.page_name}", ig ${a.ig_user_id})${bm}`);
}
}
+ if (heldPending.length) {
+ console.log(` HELD — AWAITING STEVE'S ENROLLMENT DECISION (${heldPending.length}):`);
+ for (const a of heldPending) {
+ console.log(` ? @${a.handle} [${a.ticket || 'no ticket'}]`
+ + (a.decision_declared ? '' : ' (no explicit decision field — read as pending, fail-closed)'));
+ if (a.release_when) console.log(` release: ${a.release_when}`);
+ }
+ }
+ if (heldDecided.length) {
+ console.log(` HELD — DECIDED NO, not drift (${heldDecided.length}): ${heldDecided.map((a) => '@' + a.handle).join(', ')}`);
+ }
+ if (watch.length) {
+ console.log(` NOT-YET-LINKED WATCHLIST (${watch.length}):`);
+ for (const w of watch) {
+ const tag = w.state === 'newly_linked' ? 'LINK LANDED — Steve\'s action is done'
+ : w.state === 'not_found' ? 'NOT MEASURED — page_id no longer resolvable'
+ : `awaiting link since ${w.blocked_since || '?'}${w.optional ? ' (optional)' : ''}`;
+ console.log(` ${w.state === 'newly_linked' ? '*' : w.state === 'not_found' ? '!' : '.'} @${w.handle} ${tag}`);
+ if (w.state === 'awaiting_link' && w.waiting_on) console.log(` ${w.waiting_on}`);
+ if (w.state === 'newly_linked') console.log(` now @${w.ig_username} (ig ${w.ig_user_id}) — enrollment is a SEPARATE gated decision`);
+ }
+ } else if (!Object.keys(watchMap).length) {
+ console.log(` NOT-YET-LINKED WATCHLIST: ${watchStatus}`);
+ }
if (lostLink.length) {
console.log(` ENROLLED BUT LINK LOST (${lostLink.length}):`);
for (const h of lostLink) console.log(` - @${h}`);
}
- if (drift) console.log(` → activation is GATED (it widens live public posting): ${report.activate_command}`);
- else console.log(' registry matches the live token; nothing to do.');
+ if (report.activate_command) console.log(` → activation is GATED (it widens live public posting): ${report.activate_command}`);
+ if (report.outstanding_human_items) {
+ console.log(` → ${report.outstanding_human_items} item(s) outstanding on STEVE (not on the agent): `
+ + `${heldPending.length} enrollment decision(s), ${watchAwaiting.length} phone IG-link(s). Self-clearing once he acts.`);
+ }
+ if (!drift) console.log(' registry matches the live token, no open human items; nothing to do.');
}
process.exit(drift ? 1 : 0);
})();
diff --git a/agents/instagram-agent/test-link-drift.mjs b/agents/instagram-agent/test-link-drift.mjs
new file mode 100644
index 0000000..6cd7247
--- /dev/null
+++ b/agents/instagram-agent/test-link-drift.mjs
@@ -0,0 +1,220 @@
+#!/usr/bin/env node
+/**
+ * test-link-drift.mjs — NEGATIVE TESTS for check-link-drift.js (TK-11383).
+ *
+ * CLAUDE.md TK-11431 amendment 3: "a check ships with a negative test proving it
+ * goes red on an injected fault, or it does not ship." A positive-only test on a
+ * detector proves nothing — it confirms the happy path and leaves the entire
+ * purpose of the component unverified.
+ *
+ * Fully offline and hermetic:
+ * - the testability seam is the ALREADY-EXISTING IG_GRAPH_HOST env var (the
+ * scheduled launchd job passes nothing, so it always hits the real Graph);
+ * - each case runs a COPY of the detector in its own temp dir with its own
+ * fixtures, so the live accounts.json / enrollment-hold.json / link-watchlist.json
+ * are never read and never written;
+ * - no network, no token, no Graph call, $0.
+ *
+ * Case 0 is the CONTROL (a detector stuck red cannot pass its own suite), and
+ * case 1b replays the injected fault against the PRE-TK-11383 detector to prove
+ * the old code was actually blind — otherwise "it goes red" is unfalsifiable.
+ *
+ * Usage: node test-link-drift.mjs (exit 0 = all pass, 1 = a case failed)
+ */
+import { createServer } from 'node:http';
+import { mkdtempSync, writeFileSync, copyFileSync, rmSync } from 'node:fs';
+import { spawn } from 'node:child_process';
+import { tmpdir } from 'node:os';
+import { join, dirname } from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+const HERE = dirname(fileURLToPath(import.meta.url));
+const DETECTOR = join(HERE, 'check-link-drift.js');
+
+const PAGE_ENROLLED = { id: '1000', name: 'Enrolled Page', ig: { id: 'ig-1000', username: 'enrolledacct' } };
+const PAGE_HELD_NO = { id: '2000', name: 'Held Decided No', ig: { id: 'ig-2000', username: 'helddecided' } };
+const PAGE_HELD_PEND= { id: '3000', name: 'Held Pending', ig: { id: 'ig-3000', username: 'heldpending' } };
+const PAGE_WATCH = { id: '4000', name: 'Watched Page', ig: null }; // UNLINKED — the blind spot
+
+function graphServer(pages) {
+ const enc = (p) => ({ id: p.id, name: p.name, ...(p.ig ? { instagram_business_account: { id: p.ig.id, username: p.ig.username } } : {}) });
+ const srv = createServer((req, res) => {
+ const u = new URL(req.url, 'http://x');
+ res.setHeader('content-type', 'application/json');
+ if (u.pathname.endsWith('/me/accounts')) return res.end(JSON.stringify({ data: pages.map(enc) }));
+ if (u.pathname.endsWith('/me/businesses')) return res.end(JSON.stringify({ data: [{ id: 'biz1', name: 'Test Biz' }] }));
+ if (u.pathname.endsWith('/owned_pages')) return res.end(JSON.stringify({ data: pages.map(enc) }));
+ if (u.pathname.endsWith('/client_pages')) return res.end(JSON.stringify({ data: [] }));
+ res.end(JSON.stringify({ data: [] }));
+ });
+ return new Promise((r) => srv.listen(0, '127.0.0.1', () => r({ srv, port: srv.address().port })));
+}
+
+// The child MUST run asynchronously: the fake Graph server lives in THIS process,
+// so a synchronous execFileSync would block the event loop and the server could
+// never answer — the harness would deadlock rather than test anything.
+function run({ detector = DETECTOR, pages, accounts, hold, watch, port }) {
+ const dir = mkdtempSync(join(tmpdir(), 'linkdrift-'));
+ copyFileSync(detector, join(dir, 'check-link-drift.js'));
+ writeFileSync(join(dir, 'accounts.json'), JSON.stringify({ accounts }, null, 2));
+ if (hold !== null) writeFileSync(join(dir, 'enrollment-hold.json'), typeof hold === 'string' ? hold : JSON.stringify({ hold }, null, 2));
+ if (watch !== null) writeFileSync(join(dir, 'link-watchlist.json'), typeof watch === 'string' ? watch : JSON.stringify({ watch }, null, 2));
+ return new Promise((resolve) => {
+ const ch = spawn(process.execPath, [join(dir, 'check-link-drift.js'), '--json'], {
+ env: { ...process.env, IG_GRAPH_HOST: `http://127.0.0.1:${port}`, META_ACCESS_TOKEN: 'test-token' },
+ });
+ let stdout = '', stderr = '';
+ ch.stdout.on('data', (d) => { stdout += d; });
+ ch.stderr.on('data', (d) => { stderr += d; });
+ const kill = setTimeout(() => ch.kill('SIGKILL'), 20000);
+ ch.on('close', (code) => {
+ clearTimeout(kill);
+ rmSync(dir, { recursive: true, force: true });
+ let json = null; try { json = JSON.parse(stdout); } catch { /* exit-2 path prints no JSON */ }
+ resolve({ code, json, stderr });
+ });
+ });
+}
+
+const ENROLLED = { enrolledacct: { handle: 'enrolledacct', ig_user_id: 'ig-1000', page_id: '1000' } };
+const HOLD_OK = {
+ helddecided: { reason: 'reverted by Steve', ticket: 'T-1', decision: 'decided-no', ig_user_id: 'ig-2000' },
+ heldpending: { reason: 'never decided', ticket: 'T-1', decision: 'pending-steve', ig_user_id: 'ig-3000', release_when: 'Steve approves' },
+};
+const WATCH_OK = { watchedacct: { page_id: '4000', page_name: 'Watched Page', ticket: 'T-1', waiting_on: 'Steve phone', blocked_since: '2026-09-10' } };
+
+const results = [];
+const check = (name, ok, detail) => { results.push({ name, ok, detail }); console.log(`${ok ? 'PASS' : 'FAIL'} ${name}${detail ? ` — ${detail}` : ''}`); };
+
+// ---- CONTROL: clean fixture must NOT be red for the wrong reason ------------
+{
+ const { srv, port } = await graphServer([PAGE_ENROLLED]);
+ const r = await run({ pages: [PAGE_ENROLLED], accounts: ENROLLED, hold: {}, watch: {}, port });
+ srv.close();
+ check('0 CONTROL: all enrolled, no holds, empty watchlist => IN_SYNC/PASS',
+ r.code === 0 && r.json?.verdict === 'IN_SYNC' && r.json?.status === 'PASS',
+ `exit=${r.code} verdict=${r.json?.verdict}`);
+}
+
+// ---- 1a: THE fault this ticket exists for — a watched page becomes LINKED ----
+{
+ const linked = { ...PAGE_WATCH, ig: { id: 'ig-4000', username: 'watchedacct' } };
+ const { srv, port } = await graphServer([PAGE_ENROLLED, linked]);
+ const r = await run({ pages: [PAGE_ENROLLED, linked], accounts: ENROLLED, hold: {}, watch: WATCH_OK, port });
+ srv.close();
+ const row = r.json?.link_watchlist?.find((w) => w.handle === 'watchedacct');
+ check('1a INJECTED FAULT: watched page becomes IG-linked => newly_linked + DRIFT',
+ r.code === 1 && r.json?.status === 'WARN' && row?.state === 'newly_linked' && r.json?.watch_newly_linked === 1,
+ `exit=${r.code} state=${row?.state}`);
+}
+
+// ---- 1b: prove the OLD detector was BLIND to that same fault ----------------
+{
+ const linked = { ...PAGE_WATCH, ig: { id: 'ig-4000', username: 'watchedacct' } };
+ const { srv, port } = await graphServer([PAGE_ENROLLED, linked]);
+ // LINKDRIFT_PRE_PATH points at a copy of the PRE-TK-11383 detector, so the
+ // contrast is falsifiable rather than asserted. Unset => the case is SKIPPED
+ // and says so; it is never silently counted as a pass for the wrong reason.
+ let blind = false, ran = false;
+ const PRE = process.env.LINKDRIFT_PRE_PATH;
+ if (PRE) {
+ ran = true;
+ const r = await run({ detector: PRE, pages: [PAGE_ENROLLED, linked], accounts: ENROLLED, hold: {}, watch: WATCH_OK, port });
+ blind = r.json?.link_watchlist === undefined; // old code has no watchlist concept at all
+ }
+ srv.close();
+ check('1b CONTRAST: pre-TK-11383 detector has NO watchlist measurement (was blind)',
+ !ran || blind, ran ? `link_watchlist field present=${!blind}` : 'skipped (LINKDRIFT_PRE_PATH unset)');
+}
+
+// ---- 2: watched page_id unresolvable => NOT MEASURED, never "still waiting" --
+{
+ const { srv, port } = await graphServer([PAGE_ENROLLED]); // page 4000 absent entirely
+ const r = await run({ pages: [PAGE_ENROLLED], accounts: ENROLLED, hold: {}, watch: WATCH_OK, port });
+ srv.close();
+ const row = r.json?.link_watchlist?.find((w) => w.handle === 'watchedacct');
+ check('2 INJECTED FAULT: watched page_id vanishes => not_found (NOT MEASURED), still WARN',
+ r.code === 1 && r.json?.status === 'WARN' && row?.state === 'not_found' && r.json?.watch_not_found === 1,
+ `exit=${r.code} state=${row?.state}`);
+}
+
+// ---- 3: a hold entry with NO decision field must be read as PENDING ---------
+{
+ const { srv, port } = await graphServer([PAGE_ENROLLED, PAGE_HELD_PEND]);
+ const hold = { heldpending: { reason: 'x', ticket: 'T-1', ig_user_id: 'ig-3000' } }; // decision MISSING
+ const r = await run({ pages: [PAGE_ENROLLED, PAGE_HELD_PEND], accounts: ENROLLED, hold, watch: {}, port });
+ srv.close();
+ const pend = r.json?.held_not_enrolled_pending_steve || [];
+ check('3 FAIL-CLOSED: hold entry missing `decision` => pending-steve, not decided',
+ r.code === 1 && pend.length === 1 && pend[0].decision === 'pending-steve' && pend[0].decision_declared === false,
+ `pending=${pend.length} declared=${pend[0]?.decision_declared}`);
+}
+
+// ---- 4: decided-no must NOT be laundered into, or out of, the wrong bucket ---
+{
+ const { srv, port } = await graphServer([PAGE_ENROLLED, PAGE_HELD_NO, PAGE_HELD_PEND]);
+ const r = await run({ pages: [PAGE_ENROLLED, PAGE_HELD_NO, PAGE_HELD_PEND], accounts: ENROLLED, hold: HOLD_OK, watch: {}, port });
+ srv.close();
+ const dec = r.json?.held_not_enrolled_decided_no || [];
+ const pend = r.json?.held_not_enrolled_pending_steve || [];
+ check('4 SPLIT: decided-no is informational, pending-steve still forces WARN',
+ r.code === 1 && r.json?.status === 'WARN'
+ && dec.length === 1 && dec[0].handle === 'helddecided'
+ && pend.length === 1 && pend[0].handle === 'heldpending'
+ && (r.json?.newly_linked_not_enrolled || []).length === 0
+ && r.json?.activate_command === null,
+ `decided=${dec.length} pending=${pend.length} activate=${r.json?.activate_command}`);
+}
+
+// ---- 4b: a decided-no ALONE must not keep the row red forever ---------------
+{
+ const { srv, port } = await graphServer([PAGE_ENROLLED, PAGE_HELD_NO]);
+ const hold = { helddecided: HOLD_OK.helddecided };
+ const r = await run({ pages: [PAGE_ENROLLED, PAGE_HELD_NO], accounts: ENROLLED, hold, watch: {}, port });
+ srv.close();
+ check('4b NO CHRONIC ALARM: a decided-no alone => IN_SYNC/PASS',
+ r.code === 0 && r.json?.verdict === 'IN_SYNC' && (r.json?.held_not_enrolled_decided_no || []).length === 1,
+ `exit=${r.code} verdict=${r.json?.verdict}`);
+}
+
+// ---- 5: a genuinely NEW undecided linked account is still caught ------------
+{
+ const { srv, port } = await graphServer([PAGE_ENROLLED, PAGE_HELD_PEND]);
+ const r = await run({ pages: [PAGE_ENROLLED, PAGE_HELD_PEND], accounts: ENROLLED, hold: {}, watch: {}, port });
+ srv.close();
+ const nl = r.json?.newly_linked_not_enrolled || [];
+ check('5 REGRESSION: undecided linked-not-enrolled still reported + activate_command set',
+ r.code === 1 && nl.length === 1 && nl[0].handle === 'heldpending' && !!r.json?.activate_command,
+ `newly_linked=${nl.length}`);
+}
+
+// ---- 6: corrupt hold file => UNKNOWN exit 2, never a classification ---------
+{
+ const { srv, port } = await graphServer([PAGE_ENROLLED, PAGE_HELD_NO]);
+ const r = await run({ pages: [PAGE_ENROLLED, PAGE_HELD_NO], accounts: ENROLLED, hold: '{ not json', watch: {}, port });
+ srv.close();
+ check('6 FAIL-CLOSED: corrupt enrollment-hold.json => exit 2, no verdict emitted',
+ r.code === 2 && !r.json, `exit=${r.code} json=${!!r.json}`);
+}
+
+// ---- 7: corrupt watchlist => UNKNOWN exit 2 --------------------------------
+{
+ const { srv, port } = await graphServer([PAGE_ENROLLED]);
+ const r = await run({ pages: [PAGE_ENROLLED], accounts: ENROLLED, hold: {}, watch: '{{{', port });
+ srv.close();
+ check('7 FAIL-CLOSED: corrupt link-watchlist.json => exit 2, no verdict emitted',
+ r.code === 2 && !r.json, `exit=${r.code} json=${!!r.json}`);
+}
+
+// ---- 8: absent watchlist => says UNMEASURED, does not claim "nothing to watch"
+{
+ const { srv, port } = await graphServer([PAGE_ENROLLED]);
+ const r = await run({ pages: [PAGE_ENROLLED], accounts: ENROLLED, hold: {}, watch: null, port });
+ srv.close();
+ check('8 NOT-MEASURED: absent link-watchlist.json is reported as unmeasured',
+ /ABSENT/.test(r.json?.link_watchlist_status || ''), `status=${r.json?.link_watchlist_status}`);
+}
+
+const failed = results.filter((r) => !r.ok);
+console.log(`\n${results.length - failed.length}/${results.length} cases passed`);
+process.exit(failed.length ? 1 : 0);
← 8df6d23 auto-data-snapshot: 2026-09-25T20:06:56 (1 data files) — age
·
back to Norma
·
auto-data-snapshot: 2026-09-26T07:48:27 (1 data files) — age 68d9f75 →