← 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
M docs/ADVERTISER_INTELLIGENCE_BUILD_STATUS.mdM src/routes/api.js
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)