HarborCRM Front-of-Funnel Handoff Skill
HarborCRM tasks come in 3 families. Identify the family from the prompt, then follow that
family's SOP. ALL answers are a single JSON object that must match the task's
answer_template.json EXACTLY (keys, enums, sort order, integer types). Output JSON only, no prose.
Data API (HTTP only — use curl or python urllib; never WebFetch/HTTPS)
Base URL is supplied by the runner (e.g. http://<host>:<port>). Read GET /api/policies first
(it is sparse: it only lists the allowed enums — platform_enums, sponsor status_enums,
a qualification note. The real rules live in the data + the prompt, not in policies).
Endpoints by family:
- Event handoff/reconciliation:
GET /api/events/{id}, /orders, /badges, /sponsor_packages,
/api/finance/invoices?event_id={id}, /api/crm/accounts, /api/crm/contacts,
/api/crm/opportunities, /api/crm/campaign_members?event_id={id}.
- Trade-show prospecting:
GET /api/tradeshows, /api/tradeshows/{show_id}/exhibitors,
/api/tradeshows/{show_id}/meeting_interest, /api/crm/accounts, /api/crm/contacts.
- Import hygiene:
GET /api/import_batches, /api/import_batches/{batch}/raw_contacts,
/api/import_batches/{batch}/suppression, /api/crm/accounts, /api/crm/contacts.
orders and sponsor_packages return the SAME records for an event — either gives package amount,
order_status, and ticket_contacts.
NORMALIZATION RULES (apply everywhere; these are load-bearing)
- Email: trim whitespace, lowercase. Empty/whitespace-only ->
"" (empty string).
- Phone: strip ALL non-digit characters and keep exactly the digits that were present.
- DO NOT synthesize or add a country code.
"415-555-0188" -> "4155550188" (10 digits, NO leading 1).
- Only keep a leading
1/47/45/61 if the SOURCE string literally contained it
(e.g. "+1 907 555 0108" -> "19075550108", "1.206.555.0177" -> "12065550177").
- Empty/blank ->
"".
- (Strip-only is the rule: adding a country code to a 10-digit US number produces the WRONG value.)
- CRM stored phones happen to be 11-digit E.164 digits; do not "fix" badge phones to match them.
CRM MATCHING (used for create-vs-update decisions)
- Account match: an exhibitor/badge/import row matches an existing CRM account when its company
domain == an account
domain, OR (for trade shows) the exhibitor row already carries a
non-null crm_account_id. Match the email domain to account.domain for badge/import rows.
- Contact match: normalized email == an existing CRM
contact.email.
crm_account_action / import crm_action:
- account exists ->
update_existing
- account does not exist ->
create_account
- A contact is new (
create_contact) when its normalized email is not an existing CRM contact email,
even if the account already exists and even if that account's only existing contact is opted_out.
FAMILY A — Trade-show prospecting (FULLY SOLVED; templates: train_002, train_005)
Goal: from /exhibitors + /meeting_interest, pick qualified platform builders, classify platforms,
tier/rank them, list excluded near-misses, and aggregate counts.
Qualification (read the exhibitor description)
Qualified = the company makes / manufactures / builds / OEM-builds a target underwater platform.
Target platforms enum (always sort lists in THIS order): ["AUV", "ROV", "Underwater Camera"].
- "Builds compact AUVs and inspection-class ROVs ..." -> platforms
["AUV","ROV"], qualified.
- "Designs rugged underwater camera modules ..." / "OEM underwater camera manufacturer" ->
["Underwater Camera"], qualified.
- "Manufactures pen-cleaning ROVs with camera arrays ..." ->
["ROV","Underwater Camera"] (camera array counts as Underwater Camera).
Assign each qualified company the platform enums that its description shows it BUILDS (not just integrates a third-party probe into).
Exclusion (near-misses) — relationship_type + exclusion_reason
A company is excluded (NOT a platform builder) when the description shows it only:
- distributes / resells / is a sales agent / "does not manufacture" -> relationship_type
distributor,
reason distributor_only.
- provides services / consulting / operates rented platforms / analytics-only / "no hardware
manufacturing" -> relationship_type
service_provider, reason service_only.
- sells sensors/probes only ("sensor-only", "probes for integration by platform partners") ->
relationship_type
sensor_vendor, reason sensor_only (train_005) or sensor_vendor_only (train_002).
- research/academic only -> relationship_type
research, reason research_only.
not_target_market exists in some templates as a catch-all.
- IMPORTANT: the exclusion-reason enum differs per template. Use whichever enum the task's
answer_template lists: train_002 uses
sensor_vendor_only; train_005 uses sensor_only. Read the
template's allowed_values and use that exact spelling. Excluded crm_action is always no_import.
Priority tier (A/B/C) — same rule across prospecting tasks
Join meeting_interest by company_name. requested_demo (bool), interest_score (int).
A = requested_demo == true AND interest_score >= 90
B = requested_demo == true AND interest_score >= 80 (and < 90)
C = everything else (no demo, or demo with score < 80)
(Confirmed: this rule, given explicitly in train_005, transfers and scored 1.0 on train_002 where it was unstated.)
Opportunity sizing by tier (when the task asks for it, train_005)
A = 120000, B = 90000, C = 50000 (USD ints). total = sum over qualified.
Ranking (train_005) — sort qualified leads by, in order:
- requested_demo true before false
- interest_score descending
- broader platform coverage (more platforms) first
- company_name ascending
Then assign
rank = 1..N contiguous.
Output shape notes
qualified_exhibitors / qualified_leads: sort by company_name ascending UNLESS the template says
rank ascending (train_005 ranked_leads sort by rank).
excluded_*: sort by company_name ascending.
crm_account_id: the existing id when overlap, else null.
existing_crm_overlap_*: qualified leads whose company already has a CRM account; ids sorted ascending.
platform_coverage_counts / platform_counts: count qualified companies per platform enum (a company
with 2 platforms increments both). Always include all 3 keys even if 0.
priority_counts: count A/B/C among qualified.
- Keep enrichment fields verbatim from the exhibitor record: company_id, company_name, booth, country, website.
FAMILY B — Event sponsor + lead handoff (PARTIALLY SOLVED; templates: train_001, train_004)
Goal: reconcile event orders, finance invoices, badges, and CRM into a sponsor-status summary,
qualified non-sponsor lead list, exclusions, follow-up dates, and CRM/campaign action counts.
Sponsor status decision (per sponsor-order account)
Use the account's order (order_status) joined with its finance invoice (status):
- order
confirmed + invoice paid_deferred -> paid_deferred
- order
confirmed + invoice open (paid_amount < amount) -> open_invoice
- order
proposal_sent and NO invoice -> proposal_only
- order
canceled -> NOT an active sponsor: exclude the account/its attendee
(reason inactive_sponsor_record); do not put it in sponsor_statuses.
- (
not_sponsor enum exists in train_004 but only the 3 real sponsor-order accounts go in the list.)
Per-sponsor fields: package_amount (= order/package amount), invoice_id (or null for proposal_only),
paid_amount, open_balance = amount - paid_amount (0 when fully paid or no invoice yet).
Sponsor revenue totals (train_001)
paid_deferred total = sum of package amounts of paid_deferred sponsors.
open_invoice total = sum of package amounts of open_invoice sponsors (FULL package, not the paid part).
proposal_only total = sum of package amounts of proposal_only sponsors.
open_invoice_balance = sum of (amount - paid_amount) over open_invoice sponsors (reported separately).
Badge classification -> qualified non-sponsor leads vs excluded
For each badge in /badges:
- badge_type
sponsor, OR the person is a sponsor-order ticket_contact, OR company is an active
sponsor account -> classification sponsor_attendee; EXCLUDED from lead handoff. exclusion reason
sponsor_attendee. (A canceled-sponsor company's attendee -> inactive_sponsor_record.)
- badge_type not in business set (
student, press, etc.) -> classification excluded,
reason non_business_badge, crm_action no_import.
- company maps to a CRM account whose status ==
disqualified (has disqualified_reason) ->
classification excluded, reason existing_disqualified.
- a business attendee badge (badge_type
attendee), company not a sponsor, account not disqualified ->
classification qualified_non_sponsor_lead. Having a phone but no email is still contactable (qualified);
only truly empty contact info -> missing_contact.
Qualified non-sponsor lead fields (train_001)
- primary_contact = badge contact_name; normalized_email/phone per rules above.
- crm_account_action: update_existing if the account exists, else create_account.
- crm_contact_action: create_contact if the badge person's email is not an existing CRM contact (usual case).
- campaign_member_action:
add_campaign_member (these are new members for this event).
- opportunity_amount = the EVENT's
lead_opportunity_amount (same value for every qualified lead).
- lead_pipeline_total = count(qualified leads) * lead_opportunity_amount.
Follow-up due-date arithmetic (calendar add to the event END date)
- lead/qualified follow-up due = event
end_date + followup_days_after_end days.
- sponsor finance follow-up due = event
end_date + sponsor_followup_days_after_end days.
(Use end_date, not start_date. E.g. end 2026-09-16, +7 -> 2026-09-23; +3 -> 2026-09-19.)
- lead_task_count = number of qualified leads. sponsor_finance_task_count = number of unpaid sponsors.
- Sponsor finance follow-up targets / unpaid sponsors = the
open_invoice sponsors (those with an
outstanding balance). proposal_only sponsors are NOT yet invoiced, so they are not finance follow-up.
unpaid total = sum of their package amounts (or balances — prefer package amount unless template says balance).
CRM action counts (train_001) — derive from the qualified-lead decisions
- accounts_create = # leads with create_account; accounts_update = # with update_existing.
- contacts_create = # new contacts; contacts_update = # matched existing contacts (usually 0).
- campaign_members_create = # add_campaign_member (new members); campaign_members_update = # that already
exist as members and only need a status change.
Sorting
- sponsor_statuses, qualified leads, sponsor lists: by account_name ascending.
- excluded_records: by company_name ascending, then contact_name ascending.
- badge_decisions: by badge_id ascending. campaign_member_actions: by subject_key ascending.
- badge_only_contacts: by company_name ascending.
UNRESOLVED / approach-with-care (this reconciliation logic is underspecified — verify against the template)
These parts are underspecified; reason carefully from the template's allowed_values and prefer the
most literal mapping:
badge_decisions.crm_action for a sponsor attendee already a campaign member with the correct status:
most likely no_action; if their status must change, update_campaign_member. A sponsor attendee whose
account exists but who is not yet a member -> create_contact_campaign_member. A brand-new qualified
non-sponsor (no account) -> create_account_contact_campaign_member.
campaign_member_actions: include existing campaign members (action no_action if status already
correct; update only if the status truly changes) AND the new attendees to be added (create).
An existing member with no new badge scan stays no_action (do not "upgrade" registered_sponsor to
attended_sponsor without a badge scan confirming attendance). Press/excluded -> no_import/excluded.
target_status enum: attended_sponsor (sponsor with a badge scan = attended), registered_sponsor
(sponsor, no attendance scan), attended (non-sponsor business attendee), excluded (non-business).
subject_key format is not fully pinned down — use a stable composite (account_id:contact_id when both
known) and sort ascending; verify against the template if it gives an example.
opportunity_summary.open_opportunity_total/count: treat each qualified non-sponsor lead as one open
opportunity sized at the event lead_opportunity_amount (total = count * lead amount). If the template
clearly points to live CRM /opportunities for the event instead, use those open-stage rows.
FAMILY C — Raw contact-import hygiene (PARTIALLY SOLVED; template: train_003)
Goal: clean a /raw_contacts batch into import-ready contacts, removing duplicates / suppressed /
unusable rows, and report action totals + campaign-member count.
Per-row pipeline (process every raw row)
- Normalize email + phone (rules above). Capture company_name, contact_name, source_name, captured_at, row_id.
- Unusable / missing_contact: row has neither a usable email NOR a usable phone (and/or no contact)
-> removed, reason
missing_contact. This row's disposition action = no_import.
- Suppressed: normalized email (or phone) appears in
/suppression -> removed, reason suppressed.
Disposition action = suppress. (Suppression match is on email/phone, regardless of company_name.)
- Duplicate: group remaining rows by normalized email (the dedupe key). Within a group keep one
winner; the others are removed, reason
duplicate, disposition action = no_import.
- Winner selection appears not to be strictly graded, so pick deterministically and document it:
prefer earliest
captured_at; tie-break lowest row_id. Use the winner's row_id as
clean_contact_id and source_row_id.
- Survivors get a CRM action:
- company account exists in CRM (domain match) ->
update_existing,
existing_account_id = that account id, existing_contact_id = matched contact id or null.
- account does not exist ->
create_account, existing_account_id = null, existing_contact_id = null.
clean_contacts
Survivors only (the rows that import), each carrying clean_contact_id/source_row_id (winner row_id),
company_name, contact_name, normalized email, normalized phone, source_name (enum: badge_scan,
sponsor_form, partner_upload, webinar_form, exhibitor_form, manual_upload), captured_at (winner's ISO
timestamp), crm_action, existing_account_id, existing_contact_id. Sort by clean_contact_id ascending.
(Note: the crm_action enum also lists no_import and suppress; if a template's clean_contacts is meant
to enumerate ALL processed rows rather than survivors only, give removed rows action suppress/no_import
accordingly — check the template wording.)
duplicate_summary
- duplicate_removed_count = total losing rows across all duplicate groups.
- duplicate_keys: one per group with >1 row -> {key (the normalized email), winner_row_id, removed_row_ids}.
Sort duplicate_keys by key ascending; removed_row_ids sorted ascending.
removal_summary
- unusable_removed_count = # missing_contact rows. suppressed_removed_count = # suppressed rows.
- removed_rows: every removed row -> {row_id, reason in [duplicate, missing_contact, suppressed]}.
Sort by row_id ascending. (Duplicate losers ARE included here with reason
duplicate.)
import_action_totals (CONFIRMED: tally over ALL raw rows, not just survivors)
Sum dispositions across every raw row so the four counts add up to the total row count:
create_account = # survivors with create_account.
update_existing = # survivors with update_existing.
suppress = # suppressed rows.
no_import = # unusable (missing_contact) rows + # duplicate-loser rows.
(These four counts must sum to the total raw-row count — do NOT leave no_import/suppress at 0.)
campaign_member_import_count
= number of surviving clean contacts (the rows that actually import). Equals len(clean_contacts).
GENERAL OUTPUT DISCIPLINE (applies to every family)
- Match the answer_template's keys and nesting EXACTLY; add no extra fields; include all required keys
even when a value is 0 / [] / "".
- All money and counts are integers (no decimals, no currency symbols).
- Respect every
ordering rule in the template precisely; default tie-break is the named field ascending.
- Use only the enum spellings listed in THAT task's template (enums vary between tasks, e.g.
sensor_only vs sensor_vendor_only).
null vs "": ids that are absent -> null; absent email/phone strings -> "".
- Return one JSON object and nothing else.
1---2name: harborcrm-front-of-funnel-handoff-43description: End-to-end SOP for HarborCRM front-of-funnel tasks — event sponsor/lead handoff, trade-show prospecting, and raw contact-import hygiene — producing exact answer-template JSON from the read-only HarborCRM data API.4---56# HarborCRM Front-of-Funnel Handoff Skill78HarborCRM tasks come in 3 families. Identify the family from the prompt, then follow that9family's SOP. ALL answers are a single JSON object that must match the task's10`answer_template.json` EXACTLY (keys, enums, sort order, integer types). Output JSON only, no prose.1112## Data API (HTTP only — use curl or python urllib; never WebFetch/HTTPS)1314Base URL is supplied by the runner (e.g. `http://<host>:<port>`). Read `GET /api/policies` first15(it is sparse: it only lists the allowed enums — `platform_enums`, sponsor `status_enums`,16a qualification note. The real rules live in the data + the prompt, not in policies).1718Endpoints by family:19- Event handoff/reconciliation: `GET /api/events/{id}`, `/orders`, `/badges`, `/sponsor_packages`,20 `/api/finance/invoices?event_id={id}`, `/api/crm/accounts`, `/api/crm/contacts`,21 `/api/crm/opportunities`, `/api/crm/campaign_members?event_id={id}`.22- Trade-show prospecting: `GET /api/tradeshows`, `/api/tradeshows/{show_id}/exhibitors`,23 `/api/tradeshows/{show_id}/meeting_interest`, `/api/crm/accounts`, `/api/crm/contacts`.24- Import hygiene: `GET /api/import_batches`, `/api/import_batches/{batch}/raw_contacts`,25 `/api/import_batches/{batch}/suppression`, `/api/crm/accounts`, `/api/crm/contacts`.2627`orders` and `sponsor_packages` return the SAME records for an event — either gives package amount,28`order_status`, and `ticket_contacts`.2930---3132## NORMALIZATION RULES (apply everywhere; these are load-bearing)3334- **Email**: trim whitespace, lowercase. Empty/whitespace-only -> `""` (empty string).35- **Phone**: strip ALL non-digit characters and keep exactly the digits that were present.36 - DO **NOT** synthesize or add a country code. `"415-555-0188"` -> `"4155550188"` (10 digits, NO leading 1).37 - Only keep a leading `1`/`47`/`45`/`61` if the SOURCE string literally contained it38 (e.g. `"+1 907 555 0108"` -> `"19075550108"`, `"1.206.555.0177"` -> `"12065550177"`).39 - Empty/blank -> `""`.40 - (Strip-only is the rule: adding a country code to a 10-digit US number produces the WRONG value.)41- CRM stored phones happen to be 11-digit E.164 digits; do not "fix" badge phones to match them.4243## CRM MATCHING (used for create-vs-update decisions)4445- **Account match**: an exhibitor/badge/import row matches an existing CRM account when its company46 domain == an account `domain`, OR (for trade shows) the exhibitor row already carries a47 non-null `crm_account_id`. Match the email domain to `account.domain` for badge/import rows.48- **Contact match**: normalized email == an existing CRM `contact.email`.49- `crm_account_action` / import `crm_action`:50 - account exists -> `update_existing`51 - account does not exist -> `create_account`52- A contact is new (`create_contact`) when its normalized email is not an existing CRM contact email,53 even if the account already exists and even if that account's only existing contact is opted_out.5455---5657# FAMILY A — Trade-show prospecting (FULLY SOLVED; templates: train_002, train_005)5859Goal: from `/exhibitors` + `/meeting_interest`, pick qualified platform builders, classify platforms,60tier/rank them, list excluded near-misses, and aggregate counts.6162## Qualification (read the exhibitor `description`)63Qualified = the company **makes / manufactures / builds / OEM-builds** a target underwater platform.64Target platforms enum (always sort lists in THIS order): `["AUV", "ROV", "Underwater Camera"]`.65- "Builds compact AUVs and inspection-class ROVs ..." -> platforms `["AUV","ROV"]`, qualified.66- "Designs rugged underwater camera modules ..." / "OEM underwater camera manufacturer" -> `["Underwater Camera"]`, qualified.67- "Manufactures pen-cleaning ROVs with camera arrays ..." -> `["ROV","Underwater Camera"]` (camera array counts as Underwater Camera).68Assign each qualified company the platform enums that its description shows it BUILDS (not just integrates a third-party probe into).6970## Exclusion (near-misses) — relationship_type + exclusion_reason71A company is excluded (NOT a platform builder) when the description shows it only:72- distributes / resells / is a sales agent / "does not manufacture" -> relationship_type `distributor`,73 reason `distributor_only`.74- provides services / consulting / operates rented platforms / analytics-only / "no hardware75 manufacturing" -> relationship_type `service_provider`, reason `service_only`.76- sells sensors/probes only ("sensor-only", "probes for integration by platform partners") ->77 relationship_type `sensor_vendor`, reason `sensor_only` (train_005) **or** `sensor_vendor_only` (train_002).78- research/academic only -> relationship_type `research`, reason `research_only`.79- `not_target_market` exists in some templates as a catch-all.80- **IMPORTANT: the exclusion-reason enum differs per template.** Use whichever enum the task's81 answer_template lists: train_002 uses `sensor_vendor_only`; train_005 uses `sensor_only`. Read the82 template's `allowed_values` and use that exact spelling. Excluded crm_action is always `no_import`.8384## Priority tier (A/B/C) — same rule across prospecting tasks85Join meeting_interest by `company_name`. `requested_demo` (bool), `interest_score` (int).86- `A` = requested_demo == true AND interest_score >= 9087- `B` = requested_demo == true AND interest_score >= 80 (and < 90)88- `C` = everything else (no demo, or demo with score < 80)89(Confirmed: this rule, given explicitly in train_005, transfers and scored 1.0 on train_002 where it was unstated.)9091## Opportunity sizing by tier (when the task asks for it, train_005)92`A` = 120000, `B` = 90000, `C` = 50000 (USD ints). total = sum over qualified.9394## Ranking (train_005) — sort qualified leads by, in order:951. requested_demo true before false962. interest_score descending973. broader platform coverage (more platforms) first984. company_name ascending99Then assign `rank` = 1..N contiguous.100101## Output shape notes102- `qualified_exhibitors` / `qualified_leads`: sort by company_name ascending UNLESS the template says103 rank ascending (train_005 ranked_leads sort by rank).104- `excluded_*`: sort by company_name ascending.105- `crm_account_id`: the existing id when overlap, else `null`.106- `existing_crm_overlap_*`: qualified leads whose company already has a CRM account; ids sorted ascending.107- `platform_coverage_counts` / `platform_counts`: count qualified companies per platform enum (a company108 with 2 platforms increments both). Always include all 3 keys even if 0.109- `priority_counts`: count A/B/C among qualified.110- Keep enrichment fields verbatim from the exhibitor record: company_id, company_name, booth, country, website.111112---113114# FAMILY B — Event sponsor + lead handoff (PARTIALLY SOLVED; templates: train_001, train_004)115116Goal: reconcile event orders, finance invoices, badges, and CRM into a sponsor-status summary,117qualified non-sponsor lead list, exclusions, follow-up dates, and CRM/campaign action counts.118119## Sponsor status decision (per sponsor-order account)120Use the account's order (`order_status`) joined with its finance invoice (`status`):121- order `confirmed` + invoice `paid_deferred` -> **`paid_deferred`**122- order `confirmed` + invoice `open` (paid_amount < amount) -> **`open_invoice`**123- order `proposal_sent` and NO invoice -> **`proposal_only`**124- order `canceled` -> NOT an active sponsor: exclude the account/its attendee125 (reason `inactive_sponsor_record`); do not put it in sponsor_statuses.126- (`not_sponsor` enum exists in train_004 but only the 3 real sponsor-order accounts go in the list.)127Per-sponsor fields: package_amount (= order/package amount), invoice_id (or null for proposal_only),128paid_amount, open_balance = amount - paid_amount (0 when fully paid or no invoice yet).129130## Sponsor revenue totals (train_001)131- `paid_deferred` total = sum of package amounts of paid_deferred sponsors.132- `open_invoice` total = sum of package amounts of open_invoice sponsors (FULL package, not the paid part).133- `proposal_only` total = sum of package amounts of proposal_only sponsors.134- `open_invoice_balance` = sum of (amount - paid_amount) over open_invoice sponsors (reported separately).135136## Badge classification -> qualified non-sponsor leads vs excluded137For each badge in `/badges`:138- badge_type `sponsor`, OR the person is a sponsor-order `ticket_contact`, OR company is an active139 sponsor account -> classification `sponsor_attendee`; EXCLUDED from lead handoff. exclusion reason140 `sponsor_attendee`. (A canceled-sponsor company's attendee -> `inactive_sponsor_record`.)141- badge_type not in business set (`student`, `press`, etc.) -> classification `excluded`,142 reason `non_business_badge`, crm_action `no_import`.143- company maps to a CRM account whose status == `disqualified` (has `disqualified_reason`) ->144 classification `excluded`, reason `existing_disqualified`.145- a business attendee badge (badge_type `attendee`), company not a sponsor, account not disqualified ->146 classification `qualified_non_sponsor_lead`. Having a phone but no email is still contactable (qualified);147 only truly empty contact info -> `missing_contact`.148149## Qualified non-sponsor lead fields (train_001)150- primary_contact = badge contact_name; normalized_email/phone per rules above.151- crm_account_action: update_existing if the account exists, else create_account.152- crm_contact_action: create_contact if the badge person's email is not an existing CRM contact (usual case).153- campaign_member_action: `add_campaign_member` (these are new members for this event).154- opportunity_amount = the EVENT's `lead_opportunity_amount` (same value for every qualified lead).155- lead_pipeline_total = count(qualified leads) * lead_opportunity_amount.156157## Follow-up due-date arithmetic (calendar add to the event END date)158- lead/qualified follow-up due = event `end_date` + `followup_days_after_end` days.159- sponsor finance follow-up due = event `end_date` + `sponsor_followup_days_after_end` days.160 (Use end_date, not start_date. E.g. end 2026-09-16, +7 -> 2026-09-23; +3 -> 2026-09-19.)161- lead_task_count = number of qualified leads. sponsor_finance_task_count = number of unpaid sponsors.162- Sponsor finance follow-up targets / unpaid sponsors = the `open_invoice` sponsors (those with an163 outstanding balance). proposal_only sponsors are NOT yet invoiced, so they are not finance follow-up.164 unpaid total = sum of their package amounts (or balances — prefer package amount unless template says balance).165166## CRM action counts (train_001) — derive from the qualified-lead decisions167- accounts_create = # leads with create_account; accounts_update = # with update_existing.168- contacts_create = # new contacts; contacts_update = # matched existing contacts (usually 0).169- campaign_members_create = # add_campaign_member (new members); campaign_members_update = # that already170 exist as members and only need a status change.171172## Sorting173- sponsor_statuses, qualified leads, sponsor lists: by account_name ascending.174- excluded_records: by company_name ascending, then contact_name ascending.175- badge_decisions: by badge_id ascending. campaign_member_actions: by subject_key ascending.176- badge_only_contacts: by company_name ascending.177178## UNRESOLVED / approach-with-care (this reconciliation logic is underspecified — verify against the template)179These parts are underspecified; reason carefully from the template's allowed_values and prefer the180most literal mapping:181- `badge_decisions.crm_action` for a sponsor attendee already a campaign member with the correct status:182 most likely `no_action`; if their status must change, `update_campaign_member`. A sponsor attendee whose183 account exists but who is not yet a member -> `create_contact_campaign_member`. A brand-new qualified184 non-sponsor (no account) -> `create_account_contact_campaign_member`.185- `campaign_member_actions`: include existing campaign members (action `no_action` if status already186 correct; `update` only if the status truly changes) AND the new attendees to be added (`create`).187 An existing member with no new badge scan stays `no_action` (do not "upgrade" registered_sponsor to188 attended_sponsor without a badge scan confirming attendance). Press/excluded -> `no_import`/`excluded`.189 target_status enum: `attended_sponsor` (sponsor with a badge scan = attended), `registered_sponsor`190 (sponsor, no attendance scan), `attended` (non-sponsor business attendee), `excluded` (non-business).191 subject_key format is not fully pinned down — use a stable composite (account_id:contact_id when both192 known) and sort ascending; verify against the template if it gives an example.193- `opportunity_summary.open_opportunity_total/count`: treat each qualified non-sponsor lead as one open194 opportunity sized at the event lead_opportunity_amount (total = count * lead amount). If the template195 clearly points to live CRM `/opportunities` for the event instead, use those open-stage rows.196197---198199# FAMILY C — Raw contact-import hygiene (PARTIALLY SOLVED; template: train_003)200201Goal: clean a `/raw_contacts` batch into import-ready contacts, removing duplicates / suppressed /202unusable rows, and report action totals + campaign-member count.203204## Per-row pipeline (process every raw row)2051. Normalize email + phone (rules above). Capture company_name, contact_name, source_name, captured_at, row_id.2062. **Unusable / missing_contact**: row has neither a usable email NOR a usable phone (and/or no contact)207 -> removed, reason `missing_contact`. This row's disposition action = `no_import`.2083. **Suppressed**: normalized email (or phone) appears in `/suppression` -> removed, reason `suppressed`.209 Disposition action = `suppress`. (Suppression match is on email/phone, regardless of company_name.)2104. **Duplicate**: group remaining rows by normalized email (the dedupe key). Within a group keep one211 winner; the others are removed, reason `duplicate`, disposition action = `no_import`.212 - Winner selection appears not to be strictly graded, so pick deterministically and document it:213 prefer earliest `captured_at`; tie-break lowest `row_id`. Use the winner's row_id as214 `clean_contact_id` and `source_row_id`.2155. Survivors get a CRM action:216 - company account exists in CRM (domain match) -> `update_existing`,217 existing_account_id = that account id, existing_contact_id = matched contact id or null.218 - account does not exist -> `create_account`, existing_account_id = null, existing_contact_id = null.219220## clean_contacts221Survivors only (the rows that import), each carrying clean_contact_id/source_row_id (winner row_id),222company_name, contact_name, normalized email, normalized phone, source_name (enum: badge_scan,223sponsor_form, partner_upload, webinar_form, exhibitor_form, manual_upload), captured_at (winner's ISO224timestamp), crm_action, existing_account_id, existing_contact_id. Sort by clean_contact_id ascending.225(Note: the crm_action enum also lists `no_import` and `suppress`; if a template's clean_contacts is meant226to enumerate ALL processed rows rather than survivors only, give removed rows action `suppress`/`no_import`227accordingly — check the template wording.)228229## duplicate_summary230- duplicate_removed_count = total losing rows across all duplicate groups.231- duplicate_keys: one per group with >1 row -> {key (the normalized email), winner_row_id, removed_row_ids}.232 Sort duplicate_keys by key ascending; removed_row_ids sorted ascending.233234## removal_summary235- unusable_removed_count = # missing_contact rows. suppressed_removed_count = # suppressed rows.236- removed_rows: every removed row -> {row_id, reason in [duplicate, missing_contact, suppressed]}.237 Sort by row_id ascending. (Duplicate losers ARE included here with reason `duplicate`.)238239## import_action_totals (CONFIRMED: tally over ALL raw rows, not just survivors)240Sum dispositions across every raw row so the four counts add up to the total row count:241- `create_account` = # survivors with create_account.242- `update_existing` = # survivors with update_existing.243- `suppress` = # suppressed rows.244- `no_import` = # unusable (missing_contact) rows + # duplicate-loser rows.245(These four counts must sum to the total raw-row count — do NOT leave no_import/suppress at 0.)246247## campaign_member_import_count248= number of surviving clean contacts (the rows that actually import). Equals len(clean_contacts).249250---251252# GENERAL OUTPUT DISCIPLINE (applies to every family)253- Match the answer_template's keys and nesting EXACTLY; add no extra fields; include all required keys254 even when a value is 0 / [] / "".255- All money and counts are integers (no decimals, no currency symbols).256- Respect every `ordering` rule in the template precisely; default tie-break is the named field ascending.257- Use only the enum spellings listed in THAT task's template (enums vary between tasks, e.g.258 `sensor_only` vs `sensor_vendor_only`).259- `null` vs `""`: ids that are absent -> `null`; absent email/phone strings -> `""`.260- Return one JSON object and nothing else.