[object Object]

← 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

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 →