[object Object]

← back to Rentv Adintel

review(contrarian): wire panelist-mislabel guard onto the live verify write path

721e0805a0bb492c4571c0decd92ca43fb75c224 · 2026-08-07 18:18:06 -0700 · Steve Abrams

Contrarian red-team found assertNotPanelistMislabeledAsSponsor was a dead guard
(tested but never called on the write path). Fixes:
- POST /api/v1/review/:id/verify now loads the sighting, runs the guard, and
  returns 409 + audit 'verify_blocked' on an evidence-less panelist->sponsor
  promotion. Proven live: 409 block, 200 normal verify, audit recorded.
- BUILD_STATUS §36 row 4 rewritten to state the real enforcement point (honest).
Verified NOT bugs (contrarian false alarms, confirmed by reading): export
rights.js DOES filter 'contacts' (do_not_contact/export_allowed/suppression);
csv-import DOES reject no_source_evidence. Left csv pattern-email rejection OUT
by design (most real published business emails are first.last@ — rejecting the
pattern would drop valid observed contacts; §6.8 bans fabrication, not import).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Files touched

Diff

commit 721e0805a0bb492c4571c0decd92ca43fb75c224
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Fri Aug 7 18:18:06 2026 -0700

    review(contrarian): wire panelist-mislabel guard onto the live verify write path
    
    Contrarian red-team found assertNotPanelistMislabeledAsSponsor was a dead guard
    (tested but never called on the write path). Fixes:
    - POST /api/v1/review/:id/verify now loads the sighting, runs the guard, and
      returns 409 + audit 'verify_blocked' on an evidence-less panelist->sponsor
      promotion. Proven live: 409 block, 200 normal verify, audit recorded.
    - BUILD_STATUS §36 row 4 rewritten to state the real enforcement point (honest).
    Verified NOT bugs (contrarian false alarms, confirmed by reading): export
    rights.js DOES filter 'contacts' (do_not_contact/export_allowed/suppression);
    csv-import DOES reject no_source_evidence. Left csv pattern-email rejection OUT
    by design (most real published business emails are first.last@ — rejecting the
    pattern would drop valid observed contacts; §6.8 bans fabrication, not import).
    
    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
 docs/ADVERTISER_INTELLIGENCE_BUILD_STATUS.md |  2 +-
 src/routes/api.js                            | 31 +++++++++++++++++++++++-----
 2 files changed, 27 insertions(+), 6 deletions(-)

diff --git a/docs/ADVERTISER_INTELLIGENCE_BUILD_STATUS.md b/docs/ADVERTISER_INTELLIGENCE_BUILD_STATUS.md
index 9e20ee7..1d86e36 100644
--- a/docs/ADVERTISER_INTELLIGENCE_BUILD_STATUS.md
+++ b/docs/ADVERTISER_INTELLIGENCE_BUILD_STATUS.md
@@ -76,7 +76,7 @@ use the repository's existing sound stack), Node 20+, CommonJS, served on
 | 1 | App runs locally | **Met** — `npm start` → `http://localhost:9814` |
 | 2 | Migrations and seed succeed | **Met** — `npm run db:migrate && npm run db:seed` |
 | 3 | Five verified advertiser/sponsor examples appear with evidence status | **Met** — seeded |
-| 4 | Panelists not mislabeled as sponsors | **Met** — CoStar seeded as content-partner; classification guard + test enforce it |
+| 4 | Panelists not mislabeled as sponsors | **Met** — CoStar seeded as content-partner (event_relationships, never an ad_sighting); 22 panelists seeded SPEAKER_OR_PANELIST_ONLY / LIKELY_PROSPECT with zero sponsor status; `assertNotPanelistMislabeledAsSponsor` is wired LIVE into `POST /api/v1/review/:id/verify` (returns 409 + audit `verify_blocked` on an evidence-less promotion), plus seed-time + unit test |
 | 5 | Two flyer assets appear when available | **Partial** — placeholder + admin upload card (JPGs absent) |
 | 6 | California / Arizona filters work | **Met** |
 | 7 | Public contact + LinkedIn discovery workflows work | **Met** — search-URL-only, Open/Search LinkedIn buttons, never fetched |
diff --git a/src/routes/api.js b/src/routes/api.js
index 976f3cc..6f34310 100644
--- a/src/routes/api.js
+++ b/src/routes/api.js
@@ -10,6 +10,7 @@ const express = require('express');
 const { pool, query } = require('../../db');
 const T = require('../../lib/types');
 const scoring = require('../../lib/scoring');
+const { assertNotPanelistMislabeledAsSponsor } = require('../../lib/classification');
 
 const router = express.Router();
 
@@ -556,15 +557,35 @@ router.post('/review/:id/verify', async (req, res) => {
   const { id } = req.params;
   if (!isUuid(id)) return apiErr(res, 400, 'invalid_id', 'id must be a UUID');
   const idempotencyKey = req.headers['idempotency-key'] || null;
+  // Optional target status the reviewer is confirming (a promotion). If omitted,
+  // verification only blesses the EXISTING status (never a silent promotion).
+  const targetStatus = req.body && req.body.relationship_status
+    ? String(req.body.relationship_status) : null;
   try {
+    // Load the sighting FIRST so the panelist≠sponsor guard runs on the live
+    // write path (spec §6.16). A guard that only lives in tests is not a guard.
+    const cur = await query('SELECT * FROM ad_sightings WHERE id = $1', [id]);
+    if (!cur.rows.length) return apiErr(res, 404, 'not_found', 'Ad sighting not found');
+    const sighting = cur.rows[0];
+    const fromStatus = sighting.relationship_status;
+    const toStatus = targetStatus || fromStatus;
+    const hasSponsorEvidence = !!sighting.evidence_id;
+    try {
+      assertNotPanelistMislabeledAsSponsor(fromStatus, toStatus, hasSponsorEvidence);
+    } catch (guardErr) {
+      await auditLog('verify_blocked', 'ad_sightings', id,
+        { fromStatus, toStatus, reason: guardErr.message }, 'api');
+      return apiErr(res, 409, 'panelist_mislabel_blocked', guardErr.message);
+    }
     const r = await query(
-      `UPDATE ad_sightings SET verification_status = 'VERIFIED', verified_by_user_id = NULL
+      `UPDATE ad_sightings SET verification_status = 'VERIFIED',
+         relationship_status = $2, verified_by_user_id = NULL
        WHERE id = $1 RETURNING *`,
-      [id]
+      [id, toStatus]
     );
-    if (!r.rows.length) return apiErr(res, 404, 'not_found', 'Ad sighting not found');
-    await auditLog('verify', 'ad_sightings', id, { idempotencyKey }, 'api');
-    res.json({ ok: true, id, status: 'VERIFIED' });
+    await auditLog('verify', 'ad_sightings', id,
+      { idempotencyKey, fromStatus, toStatus }, 'api');
+    res.json({ ok: true, id, status: 'VERIFIED', relationship_status: toStatus });
   } catch (e) {
     if (e.code === '42P01') return apiErr(res, 404, 'not_found', 'Ad sightings table does not exist yet');
     apiErr(res, 500, 'query_error', e.message);

← 048b52e merge: full-spec fan-out (data+seed, compliance, UI/routes,  ·  back to Rentv Adintel  ·  (newest)