HarborCRM Front-of-Funnel Solver
You produce ONE JSON object that exactly matches the task's answer_template.json. No prose
outside the JSON. Add no keys not in the template; keep every key the template declares.
Environment / API (HTTP-only; use curl or python urllib, never WebFetch)
Base URL is supplied by the runner (e.g. <remote-env-url>).
Read-only endpoints:
GET /api/policies — controlled enums (sponsor status, platform enums, qualification note).
GET /api/events, /api/events/{id}, /api/events/{id}/orders, /api/events/{id}/badges,
/api/events/{id}/sponsor_packages
GET /api/finance/invoices?event_id={id}
GET /api/crm/accounts, /api/crm/contacts, /api/crm/opportunities,
/api/crm/campaign_members?event_id={id}
GET /api/tradeshows, /api/tradeshows/{show_id}/exhibitors,
/api/tradeshows/{show_id}/meeting_interest
GET /api/import_batches, /api/import_batches/{batch_id}/raw_contacts,
/api/import_batches/{batch_id}/suppression
crm/accounts, crm/contacts, crm/opportunities are GLOBAL (not event-scoped) — fetch once
and filter yourself. Always GET /api/policies first to lock the enums.
Decide the task family from the prompt:
- Sponsor / post-event handoff reconciliation (event_id; orders+invoices+badges+campaign_members).
- Trade-show prospecting (show_id; exhibitors+meeting_interest).
- Raw contact-import hygiene (batch_id; raw_contacts+suppression).
Normalization rules (apply everywhere)
- email: trim leading/trailing whitespace, then lowercase. Empty/whitespace-only →
"".
- phone: keep digits only (strip
+ ( ) - . spaces). Empty → "".
Do NOT strip a leading country-code 1; e.g. "+1 415 555 0188" → "14155550188",
but "(415) 555-0188" → "4155550188" (no 1 was present). Keep exactly the digits given.
- A contact is "contactable" if it has a non-empty email OR non-empty phone after normalization.
Account matching (existing vs new)
- Match a lead/exhibitor/badge company to a CRM account by account name and/or email
domain == account.domain. If matched → existing account (update). If no match → create.
- A CRM account with
status == "disqualified" (non-null disqualified_reason) is DISQUALIFIED.
- A CRM contact with
opted_out == true is suppressed; do not treat it as a usable contact, but a
NEW differently-named badge/lead contact at that same account is still a fresh contact.
FAMILY 1 — Sponsor / post-event handoff reconciliation
(Seen as: full handoff audit, and as badge-level reconciliation. Output shape varies by template;
the underlying decision rules below are constant.)
Event facts to read
end_date, start_date, followup_days_after_end, sponsor_followup_days_after_end,
lead_opportunity_amount, name, campaign_code.
Sponsor status (one row per sponsor account = each order/sponsor_package account)
Join order/package → finance invoice (by account_id+event). Controlled status enum:
paid_deferred, open_invoice, proposal_only, not_sponsor.
- order_status
confirmed + invoice.status paid_deferred (paid_amount == amount) → paid_deferred.
- order_status
confirmed + invoice.status open (paid_amount < amount, incl. paid_amount 0)
→ open_invoice; open_balance = amount - paid_amount.
- order_status
proposal_sent + NO invoice → proposal_only (invoice_id null, paid 0, open_balance 0).
- order_status
canceled → NOT an active sponsor; EXCLUDE the record (reason inactive_sponsor_record),
do not emit a sponsor_status row for it.
amount/package_amount is the order/package amount as integer USD.
- For revenue-by-status totals: bucket each active sponsor's package/invoice amount by its status
(paid_deferred amount, open_invoice amount, proposal_only amount). For open invoices also report the
open balance separately (
open_invoice_balance = sum(amount - paid_amount)).
CONFIRMED RULE — sponsor finance follow-up set
"Sponsor finance handoff / unpaid sponsors" = ALL not-fully-paid active sponsors =
open_invoice PLUS proposal_only (NOT just open invoices). Include proposal_only accounts in the
finance follow-up account list, count, and unpaid total. (do X: include proposal_only; not Y: only
open_invoice.) paid_deferred (fully paid) is excluded from unpaid follow-up.
unpaid_sponsor_total_usd = sum of those accounts' amounts (open_invoice amount + proposal_only amount).
Follow-up due dates (date arithmetic — calendar days, roll over month length)
- lead/qualified follow-up due =
end_date + followup_days_after_end days.
- sponsor finance follow-up due =
end_date + sponsor_followup_days_after_end days.
(e.g. end 2026-09-16 +7 → 2026-09-23; +3 → 2026-09-19.) Format YYYY-MM-DD.
Badge classification → qualified non-sponsor leads vs exclusions
For each badge:
badge_type sponsor, OR the contact is a sponsor account's ticket_contacts member →
classification sponsor_attendee; exclusion_reason sponsor_attendee.
- Non-business badge types (
student, press) → classification excluded;
exclusion_reason non_business_badge; crm_action no_import.
- Company maps to a DISQUALIFIED CRM account → exclude; reason
existing_disqualified.
- A canceled-sponsor company → reason
inactive_sponsor_record.
- Otherwise (business attendee, account not disqualified, not a sponsor contact) →
qualified_non_sponsor_lead.
missing_contact: badge with neither email nor phone after normalization.
- Reason precedence when several apply (prompt lists them in this order): sponsor_attendee →
inactive_sponsor_record → non_business_badge → existing_disqualified → missing_contact.
CRM create-vs-update for qualified leads / badge handling
- Account exists (non-disqualified) →
update_existing (account) / for the badge a
create_contact_campaign_member if the badge person is a new contact.
- No account →
create_account / create_account_contact_campaign_member.
- Each qualified lead becomes a NEW campaign member →
add_campaign_member (a create).
- Existing campaign members: if they only registered and did not attend (no badge) →
no_action
(keep registered_sponsor). Sponsor attendees already members with status attended_sponsor
→ no_action/update_campaign_member. New attendees get target_status attended
(non-sponsor) or attended_sponsor (sponsor contact who attended).
- campaign-member target_status enum:
attended_sponsor, registered_sponsor, attended, excluded.
Opportunities & pipeline
- Use the event's
lead_opportunity_amount as the opportunity amount for EACH qualified non-sponsor
lead. lead_pipeline_total / open-opportunity total = (#qualified leads) × lead_opportunity_amount;
count = #qualified leads. lead_opportunity_amount_usd field = the per-lead constant itself.
- Integer USD everywhere.
Counts / task counts
lead_task_count = number of qualified non-sponsor leads.
sponsor_finance_task_count = number of unpaid sponsors (open_invoice + proposal_only).
- crm_action_counts: accounts_create/update, contacts_create/update,
campaign_members_create/update derived from the per-lead create-vs-update decisions above.
Sorting (Family 1)
- sponsor_statuses by
account_name asc.
- qualified_lead_accounts / qualified_non_sponsor_account_names by
account_name asc.
- badge_decisions by
badge_id asc.
- campaign_member_actions by
subject_key asc.
- excluded_records by
company_name asc, then contact_name asc.
- badge_only_contacts by
company_name asc; list only the qualified non-sponsor badge leads,
with normalized_email/normalized_phone.
FAMILY 2 — Trade-show prospecting (HIGH CONFIDENCE — fully validated)
Goal: qualified import-ready exhibitors that make / OEM-build the target underwater platforms.
Platform enum (always this order): AUV, ROV, Underwater Camera.
Qualify vs exclude (read the exhibitor description)
QUALIFIED = the company manufactures / builds / designs / OEM-builds one or more target
platforms. Assign every platform it builds:
- "AUV", "autonomous underwater vehicle", "AUV scouts" → AUV.
- "ROV", "remotely operated", "inspection-class ROV", "pen-cleaning ROV" → ROV.
- "underwater camera", "camera modules", "camera arrays", "OEM underwater camera",
camera arrays mounted on its own ROVs → Underwater Camera. (do X: count "camera arrays" on a
built ROV as Underwater Camera; that was correct.)
EXCLUDE (near-miss) when the company does NOT build platforms. relationship_type → exclusion_reason:
- distributor / reseller / sales agent / "does not manufacture" →
distributor / distributor_only.
- service / consulting / operates rented platforms / analytics-dashboard-only / "no hardware
manufacturing" →
service_provider / service_only.
- sensor-only / probe maker for integration by others →
sensor_vendor / sensor_only
(some templates spell the near-miss reason sensor_vendor_only — use the template's enum).
- research/academic only →
research / research_only.
not_target_market only if an enum offers it and nothing else fits.
- Excluded exhibitors always get crm_action
no_import and stay in the exclusion list.
CRM action for qualified
- exhibitor
crm_account_id present → update_existing, carry that id as crm_account_id.
crm_account_id null → create_account, crm_account_id null.
existing_crm_overlap_* = the qualified exhibitors that already have a crm_account_id;
list those account ids ascending.
Priority tiers & opportunity sizing (join meeting_interest by company_name)
- A = requested_demo == true AND interest_score >= 90 → USD 120000.
- B = requested_demo == true AND interest_score >= 80 → USD 90000.
- C = everything else qualified (incl. no demo, or demo with score < 80) → USD 50000.
(These exact tier dollar values appear in the prospecting prompts; reuse them. The 90/80 demo
thresholds also apply even when a prompt only names the campaign and not the thresholds.)
- A company with no meeting_interest row → treat requested_demo false, score 0 → tier C.
Ranking (when template asks for ranked_leads)
Sort by: requested_demo true first, then interest_score DESC, then broader platform coverage
(more platforms first), then company_name asc. Assign contiguous 1-based rank.
Aggregates
- qualified_total / qualified_lead_count, excluded count.
- platform_coverage_counts: count qualified exhibitors that include each platform (a multi-platform
company increments every platform it builds).
- priority_counts A/B/C; total_estimated_opportunity_usd = sum of opportunity_estimate_usd.
Sorting (Family 2)
- qualified/ranked list per template (company_name asc, or rank asc when ranked).
- excluded list by
company_name asc.
- platforms within each item in enum order AUV, ROV, Underwater Camera.
FAMILY 3 — Raw contact-import hygiene
Goal: dedupe + suppress + classify raw rows for CRM import.
campaign_code comes from the import_batch record (e.g. WEB-FALL-2026).
Per-row processing
- Normalize email/phone (rules above) on every raw row.
- Suppression: a row is suppressed if its normalized email matches a suppression
email
OR its normalized phone matches a suppression phone (suppression phones are already digits).
Suppressed rows → crm_action suppress, removal reason suppressed.
- Missing contact: no usable email AND no usable phone → crm_action
no_import,
removal reason missing_contact (counts as "unusable").
- Dedup: group surviving rows by normalized email (primary key). Pick ONE winner per group;
the losers are removed with reason
duplicate.
- clean_contact_id / source_row_id = the WINNING row's
row_id; carry the winning row's
company_name/contact_name/email/phone/source_name/captured_at.
- duplicate_keys:
{key, winner_row_id, removed_row_ids[]}, key = the normalized email; sort
by key asc; removed_row_ids are the loser row_ids.
- NOTE (uncertain across rounds): winner tiebreak — within the same dedup key prefer the row by
a deterministic rule. Test order: lowest
row_id, else earliest captured_at, else latest.
When two rows share the same captured_at, break by lowest row_id. Compute counts the same way
regardless of which row is named winner.
- CRM action for surviving unique contacts: company matches an existing CRM account
(name or email-domain) →
update_existing with existing_account_id set
(existing_contact_id usually null if the person is new). No match → create_account
(existing_account_id/existing_contact_id null).
Output assembly
clean_contacts: the importable surviving winners. (Whether suppressed/missing winners also
appear here flagged with crm_action suppress/no_import vs. live only in removal_summary was NOT
resolvable from feedback — keep clean_contacts to the importable create/update rows and put
suppressed/missing/duplicate rows in removal_summary; report import_action_totals over ALL unique
decisions.) Sort by clean_contact_id asc.
duplicate_summary: {duplicate_removed_count, duplicate_keys[]}.
removal_summary: {unusable_removed_count (missing), suppressed_removed_count,
removed_rows[{row_id, reason}]}; reason enum duplicate|missing_contact|suppressed;
sort removed_rows by row_id asc.
import_action_totals: integer counts of create_account, update_existing, no_import,
suppress across the unique deduped decisions (one decision per unique key:
create/update for importable, suppress for suppressed, no_import for missing).
campaign_member_import_count: number of surviving importable cleaned contacts
(create_account + update_existing rows) to add to the batch campaign.
- source_name enum:
badge_scan, sponsor_form, partner_upload, webinar_form, exhibitor_form, manual_upload (carry the winning row's value).
Output discipline (all families)
- Match the template's keys and value enums EXACTLY; integers for all USD and counts (no decimals,
no currency symbols).
- Use the literal required_value strings (show_id, campaign codes) where the template fixes them.
- Apply every declared sort; nested lists (platforms) also have their own ordering.
- Empty strings (not null) for missing normalized email/phone unless the template says null.
- Recompute every total/count from your own derived rows so they stay internally consistent.
Concrete "do X, not Y" lessons from feedback
- DO include proposal_only sponsors in the sponsor finance follow-up set/count/total; NOT only
open_invoice sponsors.
- DO exclude canceled-sponsor orders entirely from sponsor_status rows and tag them
inactive_sponsor_record; NOT as a sponsor row and NOT (preferentially) as existing_disqualified.
- DO count "camera arrays"/OEM camera language as the
Underwater Camera platform; do not drop it.
- DO use demo-then-score≥90→A / score≥80→B / else→C with $120k/$90k/$50k even when a prompt omits
the thresholds (the rule is stable across prospecting tasks).
- DO key dedup and suppression on the NORMALIZED email/phone (trim+lowercase email, digits-only
phone); never on the raw string.
- DO derive opportunity pipeline from the event's
lead_opportunity_amount × qualified-lead count,
not from CRM opportunity records for the event.
1---2name: harborcrm-front-of-funnel3description: End-to-end SOP for HarborCRM front-of-funnel CRM-marketing tasks — event sponsor/post-event handoff reconciliation, trade-show prospecting, and raw contact-import hygiene — over the read-only HarborCRM data API.4---56# HarborCRM Front-of-Funnel Solver78You produce ONE JSON object that exactly matches the task's `answer_template.json`. No prose9outside the JSON. Add no keys not in the template; keep every key the template declares.1011## Environment / API (HTTP-only; use curl or python urllib, never WebFetch)12Base URL is supplied by the runner (e.g. `<remote-env-url>`).13Read-only endpoints:14- `GET /api/policies` — controlled enums (sponsor status, platform enums, qualification note).15- `GET /api/events`, `/api/events/{id}`, `/api/events/{id}/orders`, `/api/events/{id}/badges`,16 `/api/events/{id}/sponsor_packages`17- `GET /api/finance/invoices?event_id={id}`18- `GET /api/crm/accounts`, `/api/crm/contacts`, `/api/crm/opportunities`,19 `/api/crm/campaign_members?event_id={id}`20- `GET /api/tradeshows`, `/api/tradeshows/{show_id}/exhibitors`,21 `/api/tradeshows/{show_id}/meeting_interest`22- `GET /api/import_batches`, `/api/import_batches/{batch_id}/raw_contacts`,23 `/api/import_batches/{batch_id}/suppression`2425`crm/accounts`, `crm/contacts`, `crm/opportunities` are GLOBAL (not event-scoped) — fetch once26and filter yourself. Always GET `/api/policies` first to lock the enums.2728Decide the task family from the prompt:291. **Sponsor / post-event handoff reconciliation** (event_id; orders+invoices+badges+campaign_members).302. **Trade-show prospecting** (show_id; exhibitors+meeting_interest).313. **Raw contact-import hygiene** (batch_id; raw_contacts+suppression).3233---3435## Normalization rules (apply everywhere)36- **email**: trim leading/trailing whitespace, then lowercase. Empty/whitespace-only → `""`.37- **phone**: keep digits only (strip `+ ( ) - . spaces`). Empty → `""`.38 Do NOT strip a leading country-code `1`; e.g. `"+1 415 555 0188"` → `"14155550188"`,39 but `"(415) 555-0188"` → `"4155550188"` (no `1` was present). Keep exactly the digits given.40- A contact is "contactable" if it has a non-empty email OR non-empty phone after normalization.4142## Account matching (existing vs new)43- Match a lead/exhibitor/badge company to a CRM account by **account name** and/or **email44 domain == account.domain**. If matched → existing account (update). If no match → create.45- A CRM account with `status == "disqualified"` (non-null `disqualified_reason`) is DISQUALIFIED.46- A CRM contact with `opted_out == true` is suppressed; do not treat it as a usable contact, but a47 NEW differently-named badge/lead contact at that same account is still a fresh contact.4849---5051## FAMILY 1 — Sponsor / post-event handoff reconciliation52(Seen as: full handoff audit, and as badge-level reconciliation. Output shape varies by template;53the underlying decision rules below are constant.)5455### Event facts to read56`end_date`, `start_date`, `followup_days_after_end`, `sponsor_followup_days_after_end`,57`lead_opportunity_amount`, `name`, `campaign_code`.5859### Sponsor status (one row per sponsor account = each order/sponsor_package account)60Join order/package → finance invoice (by account_id+event). Controlled status enum:61`paid_deferred`, `open_invoice`, `proposal_only`, `not_sponsor`.62- order_status `confirmed` + invoice.status `paid_deferred` (paid_amount == amount) → **paid_deferred**.63- order_status `confirmed` + invoice.status `open` (paid_amount < amount, incl. paid_amount 0)64 → **open_invoice**; `open_balance = amount - paid_amount`.65- order_status `proposal_sent` + NO invoice → **proposal_only** (invoice_id null, paid 0, open_balance 0).66- order_status `canceled` → NOT an active sponsor; EXCLUDE the record (reason `inactive_sponsor_record`),67 do not emit a sponsor_status row for it.68- `amount`/`package_amount` is the order/package amount as integer USD.69- For revenue-by-status totals: bucket each active sponsor's **package/invoice amount** by its status70 (paid_deferred amount, open_invoice amount, proposal_only amount). For open invoices also report the71 **open balance separately** (`open_invoice_balance = sum(amount - paid_amount)`).7273### CONFIRMED RULE — sponsor finance follow-up set74"Sponsor finance handoff / unpaid sponsors" = **ALL not-fully-paid active sponsors =75open_invoice PLUS proposal_only** (NOT just open invoices). Include proposal_only accounts in the76finance follow-up account list, count, and unpaid total. (do X: include proposal_only; not Y: only77open_invoice.) paid_deferred (fully paid) is excluded from unpaid follow-up.78- `unpaid_sponsor_total_usd` = sum of those accounts' amounts (open_invoice amount + proposal_only amount).7980### Follow-up due dates (date arithmetic — calendar days, roll over month length)81- lead/qualified follow-up due = `end_date + followup_days_after_end` days.82- sponsor finance follow-up due = `end_date + sponsor_followup_days_after_end` days.83 (e.g. end 2026-09-16 +7 → 2026-09-23; +3 → 2026-09-19.) Format `YYYY-MM-DD`.8485### Badge classification → qualified non-sponsor leads vs exclusions86For each badge:87- `badge_type` sponsor, OR the contact is a sponsor account's `ticket_contacts` member →88 classification **sponsor_attendee**; exclusion_reason `sponsor_attendee`.89- Non-business badge types (`student`, `press`) → classification **excluded**;90 exclusion_reason `non_business_badge`; crm_action `no_import`.91- Company maps to a DISQUALIFIED CRM account → exclude; reason `existing_disqualified`.92- A canceled-sponsor company → reason `inactive_sponsor_record`.93- Otherwise (business attendee, account not disqualified, not a sponsor contact) →94 **qualified_non_sponsor_lead**.95- `missing_contact`: badge with neither email nor phone after normalization.96- Reason precedence when several apply (prompt lists them in this order): sponsor_attendee →97 inactive_sponsor_record → non_business_badge → existing_disqualified → missing_contact.9899### CRM create-vs-update for qualified leads / badge handling100- Account exists (non-disqualified) → `update_existing` (account) / for the badge a101 `create_contact_campaign_member` if the badge person is a new contact.102- No account → `create_account` / `create_account_contact_campaign_member`.103- Each qualified lead becomes a NEW campaign member → `add_campaign_member` (a create).104- Existing campaign members: if they only registered and did not attend (no badge) → `no_action`105 (keep `registered_sponsor`). Sponsor attendees already members with status `attended_sponsor`106 → `no_action`/`update_campaign_member`. New attendees get target_status `attended`107 (non-sponsor) or `attended_sponsor` (sponsor contact who attended).108- campaign-member target_status enum: `attended_sponsor`, `registered_sponsor`, `attended`, `excluded`.109110### Opportunities & pipeline111- Use the event's `lead_opportunity_amount` as the opportunity amount for EACH qualified non-sponsor112 lead. `lead_pipeline_total` / open-opportunity total = (#qualified leads) × lead_opportunity_amount;113 count = #qualified leads. `lead_opportunity_amount_usd` field = the per-lead constant itself.114- Integer USD everywhere.115116### Counts / task counts117- `lead_task_count` = number of qualified non-sponsor leads.118- `sponsor_finance_task_count` = number of unpaid sponsors (open_invoice + proposal_only).119- crm_action_counts: accounts_create/update, contacts_create/update,120 campaign_members_create/update derived from the per-lead create-vs-update decisions above.121122### Sorting (Family 1)123- sponsor_statuses by `account_name` asc.124- qualified_lead_accounts / qualified_non_sponsor_account_names by `account_name` asc.125- badge_decisions by `badge_id` asc.126- campaign_member_actions by `subject_key` asc.127- excluded_records by `company_name` asc, then `contact_name` asc.128- badge_only_contacts by `company_name` asc; list only the qualified non-sponsor badge leads,129 with normalized_email/normalized_phone.130131---132133## FAMILY 2 — Trade-show prospecting (HIGH CONFIDENCE — fully validated)134Goal: qualified import-ready exhibitors that **make / OEM-build** the target underwater platforms.135Platform enum (always this order): `AUV`, `ROV`, `Underwater Camera`.136137### Qualify vs exclude (read the exhibitor `description`)138QUALIFIED = the company **manufactures / builds / designs / OEM-builds** one or more target139platforms. Assign every platform it builds:140- "AUV", "autonomous underwater vehicle", "AUV scouts" → AUV.141- "ROV", "remotely operated", "inspection-class ROV", "pen-cleaning ROV" → ROV.142- "underwater camera", "camera modules", "camera arrays", "OEM underwater camera",143 camera arrays mounted on its own ROVs → Underwater Camera. (do X: count "camera arrays" on a144 built ROV as Underwater Camera; that was correct.)145EXCLUDE (near-miss) when the company does NOT build platforms. relationship_type → exclusion_reason:146- distributor / reseller / sales agent / "does not manufacture" → `distributor` / `distributor_only`.147- service / consulting / operates rented platforms / analytics-dashboard-only / "no hardware148 manufacturing" → `service_provider` / `service_only`.149- sensor-only / probe maker for integration by others → `sensor_vendor` / `sensor_only`150 (some templates spell the near-miss reason `sensor_vendor_only` — use the template's enum).151- research/academic only → `research` / `research_only`.152- `not_target_market` only if an enum offers it and nothing else fits.153- Excluded exhibitors always get crm_action `no_import` and stay in the exclusion list.154155### CRM action for qualified156- exhibitor `crm_account_id` present → `update_existing`, carry that id as crm_account_id.157- `crm_account_id` null → `create_account`, crm_account_id null.158- `existing_crm_overlap_*` = the qualified exhibitors that already have a crm_account_id;159 list those account ids ascending.160161### Priority tiers & opportunity sizing (join meeting_interest by company_name)162- A = requested_demo == true AND interest_score >= 90 → USD 120000.163- B = requested_demo == true AND interest_score >= 80 → USD 90000.164- C = everything else qualified (incl. no demo, or demo with score < 80) → USD 50000.165 (These exact tier dollar values appear in the prospecting prompts; reuse them. The 90/80 demo166 thresholds also apply even when a prompt only names the campaign and not the thresholds.)167- A company with no meeting_interest row → treat requested_demo false, score 0 → tier C.168169### Ranking (when template asks for ranked_leads)170Sort by: requested_demo true first, then interest_score DESC, then broader platform coverage171(more platforms first), then company_name asc. Assign contiguous 1-based `rank`.172173### Aggregates174- qualified_total / qualified_lead_count, excluded count.175- platform_coverage_counts: count qualified exhibitors that include each platform (a multi-platform176 company increments every platform it builds).177- priority_counts A/B/C; total_estimated_opportunity_usd = sum of opportunity_estimate_usd.178179### Sorting (Family 2)180- qualified/ranked list per template (company_name asc, or rank asc when ranked).181- excluded list by `company_name` asc.182- platforms within each item in enum order AUV, ROV, Underwater Camera.183184---185186## FAMILY 3 — Raw contact-import hygiene187Goal: dedupe + suppress + classify raw rows for CRM import.188`campaign_code` comes from the import_batch record (e.g. `WEB-FALL-2026`).189190### Per-row processing1911. Normalize email/phone (rules above) on every raw row.1922. **Suppression**: a row is suppressed if its normalized email matches a suppression `email`193 OR its normalized phone matches a suppression `phone` (suppression phones are already digits).194 Suppressed rows → crm_action `suppress`, removal reason `suppressed`.1953. **Missing contact**: no usable email AND no usable phone → crm_action `no_import`,196 removal reason `missing_contact` (counts as "unusable").1974. **Dedup**: group surviving rows by normalized email (primary key). Pick ONE winner per group;198 the losers are removed with reason `duplicate`.199 - clean_contact_id / source_row_id = the WINNING row's `row_id`; carry the winning row's200 company_name/contact_name/email/phone/source_name/captured_at.201 - duplicate_keys: `{key, winner_row_id, removed_row_ids[]}`, key = the normalized email; sort202 by key asc; removed_row_ids are the loser row_ids.203 - NOTE (uncertain across rounds): winner tiebreak — within the same dedup key prefer the row by204 a deterministic rule. Test order: lowest `row_id`, else earliest `captured_at`, else latest.205 When two rows share the same captured_at, break by lowest row_id. Compute counts the same way206 regardless of which row is named winner.2075. **CRM action for surviving unique contacts**: company matches an existing CRM account208 (name or email-domain) → `update_existing` with `existing_account_id` set209 (existing_contact_id usually null if the person is new). No match → `create_account`210 (existing_account_id/existing_contact_id null).211212### Output assembly213- `clean_contacts`: the importable surviving winners. (Whether suppressed/missing winners also214 appear here flagged with crm_action suppress/no_import vs. live only in removal_summary was NOT215 resolvable from feedback — keep clean_contacts to the importable create/update rows and put216 suppressed/missing/duplicate rows in removal_summary; report import_action_totals over ALL unique217 decisions.) Sort by `clean_contact_id` asc.218- `duplicate_summary`: {duplicate_removed_count, duplicate_keys[]}.219- `removal_summary`: {unusable_removed_count (missing), suppressed_removed_count,220 removed_rows[{row_id, reason}]}; reason enum `duplicate|missing_contact|suppressed`;221 sort removed_rows by `row_id` asc.222- `import_action_totals`: integer counts of `create_account`, `update_existing`, `no_import`,223 `suppress` across the unique deduped decisions (one decision per unique key:224 create/update for importable, suppress for suppressed, no_import for missing).225- `campaign_member_import_count`: number of surviving importable cleaned contacts226 (create_account + update_existing rows) to add to the batch campaign.227- source_name enum: `badge_scan, sponsor_form, partner_upload, webinar_form, exhibitor_form,228 manual_upload` (carry the winning row's value).229230---231232## Output discipline (all families)233- Match the template's keys and value enums EXACTLY; integers for all USD and counts (no decimals,234 no currency symbols).235- Use the literal required_value strings (show_id, campaign codes) where the template fixes them.236- Apply every declared sort; nested lists (platforms) also have their own ordering.237- Empty strings (not null) for missing normalized email/phone unless the template says null.238- Recompute every total/count from your own derived rows so they stay internally consistent.239240## Concrete "do X, not Y" lessons from feedback241- DO include proposal_only sponsors in the sponsor finance follow-up set/count/total; NOT only242 open_invoice sponsors.243- DO exclude canceled-sponsor orders entirely from sponsor_status rows and tag them244 `inactive_sponsor_record`; NOT as a sponsor row and NOT (preferentially) as existing_disqualified.245- DO count "camera arrays"/OEM camera language as the `Underwater Camera` platform; do not drop it.246- DO use demo-then-score≥90→A / score≥80→B / else→C with $120k/$90k/$50k even when a prompt omits247 the thresholds (the rule is stable across prospecting tasks).248- DO key dedup and suppression on the NORMALIZED email/phone (trim+lowercase email, digits-only249 phone); never on the raw string.250- DO derive opportunity pipeline from the event's `lead_opportunity_amount` × qualified-lead count,251 not from CRM opportunity records for the event.