List Building
A BDR, RevOps operator, or marketing lead with a TAM in hand has two failure modes: hand-stitch the audience-to-campaign handoff (slow, error-prone, ICP drift) or skip suppression and burn EB workspace reputation on already-contacted leads. This skill is the third option — it consumes any of three TAM sources, runs cross-workspace EB exclusion + SF Lead suppression where the source needs it, enriches to verified contact-level via the resolved enrichment provider, SMTP-verifies, applies the free-email filter, and emits a single enriched_leads.csv ready for launch-campaign or campaign-orchestration.
Before Starting
Check for product marketing context first. If docs/marketing-context.md exists, read it before asking questions and use that context for Brite entity selection, voice, and ICP. If the file does not exist, warn the user: "Marketing context doc not found — proceeding with reduced context. Run /marketing:product-marketing-context to generate it." Then continue using only user-provided information.
Input-source detection
The skill consumes ONE of three input sources per invocation. Detection logic when source-mode flags are not passed: call AskUserQuestion listing the 3 source modes with brief descriptions. NEVER silently default.
Source 1 — tam-mapping output. Detect by directory pattern: --input-dir docs/campaigns/labs/tam/<slug>/ (Labs path) or docs/research/tam/<vertical>-<YYYY-MM-DD>/ (Nites/Supply path). Skill reads the appropriate output file in stable order: tier-a.csv → tier-b.csv → tier-c.csv (Labs) OR enriched.jsonl → net_new_leads.csv (Nites/Supply). Skips Workflow 2 (EB-exclusion) — tam-mapping Phase 4.5 already ran exclusion against both EB workspaces + SF; re-running wastes EB API calls and may misclassify leads if EB state shifted between runs. Staleness gate: check the validated --input-dir's mtime via python3 -c 'import os, sys, time; print(time.time() - os.path.getmtime(sys.argv[1]))' "$INPUT_DIR" (argv-passed; no shell interpolation — --input-dir was already path-validated at Workflow 1 step 0). If age > 7 days, fire AskUserQuestion with TWO options: (1) Skip Workflow 2 (default — preserves the per-source routing rule). Default action; output proceeds with stale-but-honored upstream exclusion. (2) Override and re-run Workflow 2. Explicit user opt-in to re-run cross-workspace EB + SF exclusion against fresh state. Default = (1) so the routing-table contract holds; only an explicit user choice activates the override path documented in § Methodology > Per-source EB-exclusion routing. Never silently skip the staleness check itself.
Source 2 — dbt audience CSV. User passes --audience-csv <path> + --audience-view-name <name>. Skill reads CSV via Read. Skill reads the dbt model definition via Bash → gh api repos/Brite-Nites/brite-data-platform/contents/models/marts/<view>.sql for column-shape reference + audit logging only — does NOT execute the model (no Snowflake MCP exists; the dbt models materialize in Snowflake out-of-band). Runs Workflow 2 (EB-exclusion) before enrichment.
Source 3 — manual CSV. User passes --input-csv <path>. Skill validates required columns (domain + company_name minimum). Runs Workflow 2 (EB-exclusion) before enrichment.
Invocation flags
| Flag |
Default |
Notes |
--input-dir <path> |
(Source 1 mode) |
Path to a tam-mapping output directory. Mutually exclusive with --audience-csv and --input-csv. |
--audience-csv <path> |
(Source 2 mode) |
Path to a pre-exported dbt audience CSV. Requires --audience-view-name. |
--audience-view-name <name> |
(required with --audience-csv) |
dbt model name (e.g., audience_active_municipalities). MUST match ^[a-z0-9_]+$ (no slashes, dots, semicolons, query strings) — halt with validation error otherwise; this value flows into a gh api URL constructed via Bash interpolation, so unvalidated input is a path-traversal + shell-injection vector. Used to read model definition via gh api for column-shape + audit. |
--input-csv <path> |
(Source 3 mode) |
Path to a user-provided CSV. Required columns: domain, company_name. Optional columns flow through to enrichment unchanged. |
--criteria-file <path> |
(optional) |
JSON file with per-vertical ICP override (custom title cascade, max-contacts override, free-text title hints). Schema mirrors tam-mapping's --criteria-file for consistency. When absent, the entity-default ICP title cascade in § Methodology applies. |
--output-dir <path> |
docs/research/lists/<entity>-<YYYY-MM-DD>/ (auto-derived) |
Path-traversal segments (..) and absolute paths outside the worktree root are rejected. |
--enrichment-provider <id> |
(read from ${user_config.enrichment_provider}) |
Override per-run. Enum: blitz_waterfall | brite_cli | brite_mcp | skip per ADR-008. |
--max-contacts-per-company N |
3 |
Per Revgrowth 08 heuristic — beyond 3, marginal contact acquisition cost outweighs list-quality value. |
--max-records N |
(unset → cost gate fires) |
Cost-gate pre-approval. When set and N >= record count, gate is skipped (caller pre-approved cost). When N < record count, skill stops and reports overflow — does NOT silently truncate. (Mirrors tam-mapping semantics.) |
--sfdx-project-dir <path> |
(auto-detect via pwd if it contains sfdx-project.json, else error) |
Required for SF MCP directory parameter. Per salesforce.md § Known gotchas → directory parameter trap, calls from britenites-claude-plugins/ cwd reject without this. |
--resume |
(off) |
Force resume even when state is ambiguous (e.g., partial JSONL writes — validates the last record line and resumes from the next). Default behavior (no flag) auto-detects from file existence per § Resume detection. |
Enrichment-provider selection
Read in priority order (per ADR-008 § Unset resolution order):
--enrichment-provider <id> flag if passed.
${user_config.enrichment_provider} from plugin.json userConfig if explicitly set.
- Auto-detect (when both above are unset):
- Check for brite-enrichment MCP registration (
mcp__plugin_marketing_enrichment__bulk_enrich reachable in-session) → use brite_mcp.
- Else check for brite-enrichment CLI at
$BRITE_DATA_PLATFORM/services/enrichment/cli.py → use brite_cli; else fall through to blitz_waterfall.
skip is never auto-selected; it must be passed explicitly.
brite_mcp is the auto-detect default since the ADR-008 default-flip (executed 2026-07-09, BC-16888; opt-in lineage BC-6170 → BC-13165) — auto-detect selects it whenever the brite-enrichment MCP is registered, routing the whole candidate list through the bulk door (mcp__plugin_marketing_enrichment__bulk_enrich, granted in this skill's allowed-tools). If the brite-enrichment MCP server is unreachable in-session, it falls through to blitz_waterfall (logged) — the fail-open safety net is unchanged. The allowed-tools grant and the .mcp.json server-SHA pin are a config-invariant pair — keep them in lockstep: rolling the pin back to a SHA without bulk_enrich silently disables the default (it degrades to the fall-through, not an error).
The resolved provider is logged at skill invocation so the user sees which path ran (e.g., [list-building] enrichment_provider=brite_mcp (auto-detected; brite-enrichment MCP registered) or, on fall-through, [list-building] enrichment_provider=blitz_waterfall (auto-detected; brite-enrichment MCP not registered, $BRITE_DATA_PLATFORM unset → brite_cli unavailable)). The 4-row enum table is the canonical source — see tam-mapping § 3 Phase 5 for per-value implementation and fallback messages (single source of truth across both skills per ADR-008).
Resume detection
Per Operational Runbook Task D, if --output-dir already exists with partial output, the skill detects the resume point via file existence in this stable order:
- First read
<output-dir>/source.json to determine the active source. If source.json is missing, treat as a fresh run (resume from Workflow 1). The source field drives the branching at slot 2 below.
source.json (Workflow 1 — input source manifest)
suppression_set.json (Workflow 2 — only checked when source.json.source ∈ {dbt-view, manual-csv}; SKIPPED entirely for tam-output source per the per-source EB-exclusion routing rule — checking it would force a Workflow 2 re-run that the routing table forbids)
enriched.jsonl (Workflow 3)
verified.jsonl (Workflow 4)
enriched_leads.csv (Workflow 5 — terminal)
The skill NEVER restarts from Workflow 1 when resume state exists. Stop the file-existence loop at the first missing file; do not check subsequent entries. For tam-output source, slot 2 is skipped entirely — the loop checks slots 1 → 3 → 4 → 5.
Methodology
Adapted from Revgrowth1/ai-gtm-workflows workflow 08 (MIT) for the contact-discovery 3-step pipeline + max-contacts heuristic, and from Revgrowth1/ai-gtm-workflows workflow 02 (MIT) for the dual-source suppression pattern.
ICP framing
An Ideal Customer Profile (ICP) is the firmographic + decision-maker shape of the company most likely to convert. Outbound list assembly fails when the ICP is too loose (waste credits enriching the wrong companies) or too narrow (artificially small TAM, overspend on enrichment that returns 0 hits). The cleanest framing is the decision-maker title cascade: T1 (most-senior, smallest pool, highest reply weight), T2 (mid-senior, larger pool, balanced reply weight), T3 (operator-level, largest pool, lowest reply weight). Different verticals invert the cascade — for owner-operator categories (residential service, small contractor) T1 is "Owner" and T2/T3 collapse; for enterprise categories T2 is the largest reply-weighted pool. Cite Revgrowth 08 as canonical shape.
Contact-discovery 3-step pipeline
The conceptual pipeline matches Revgrowth 08:
Step 1 — Domain → LinkedIn Company URL. Given a domain, resolve to canonical LinkedIn company URL. Frequently a no-op when input data already includes the URL (most TAM sources and dbt audience views do).
Step 2 — Company URL → ICP decision-maker contacts. Cascade search for prospects matching entity-specific title tiers. Cap at max-contacts-per-company (default 3) per Revgrowth 08 heuristic.
Step 3 — Contact → verified work email. Waterfall via the resolved enrichment provider, then SMTP-verify the result. Drop unverified records.
Suppression theory
Two-source suppression catches more than either alone. Campaign-tool suppression (the sequencer's lead store — both b2b and personal workspaces in this skill's case) catches "leads we've already touched in any active or recent campaign." CRM suppression (the system-of-record's Lead/Contact store) catches "leads tied to existing accounts, opportunities, or sales motions." Either alone leaks: campaign-tool only misses prospects in long-running SF deals; CRM-only misses fresh outbound in flight. Skipping suppression entirely burns sender reputation (replies of "we've talked to your team three times already") and wastes per-record enrichment credits.
Contact count heuristic
max-contacts-per-company defaults to 3 per Revgrowth 08. Beyond 3 contacts per company, marginal contact acquisition cost outweighs list-quality value — the additional contacts are typically lower-tier roles with lower reply weight, while enrichment credits scale linearly with contact count.
ICP title cascade — entity-specific
Entity-specific title tier defaults for the 3 Brite entities. Use these as priors; override per-vertical via --criteria-file or per-invocation prompts.
| Tier |
Default |
Brite Nites adaptation |
Brite Labs adaptation |
Brite Supply adaptation |
| T1 |
CEO, Founder, Owner, President, Co-Founder |
Owner, GM (residential — skip VP cascade) |
Venue Director, Events Director |
Procurement |
| T2 |
VP Marketing, VP Sales, VP Growth, CMO, CRO |
(skipped — Owner is the decision-maker) |
Marketing Manager |
VP Ops, Buyer |
| T3 |
Marketing Director, Sales Director, Director of Growth |
(skipped) |
Catering Director, F&B Director |
Buyer |
Per-source EB-exclusion routing
Whether Workflow 2 (EB-exclusion) runs depends on whether the upstream source already ran it.
| Source |
Upstream EB-exclusion? |
This skill runs EB-exclusion? |
tam-mapping output (--input-dir), input-dir mtime ≤ 7 days |
Yes (tam-mapping Phase 4.5) |
No — skip (default routing rule) |
tam-mapping output (--input-dir), input-dir mtime > 7 days |
Yes (tam-mapping Phase 4.5) — but stale |
No by default; user-explicit override path runs Workflow 2 (per Source 1 staleness gate at § Before Starting → Input-source detection) |
dbt audience CSV (--audience-csv) |
No |
Yes |
Manual CSV (--input-csv) |
No |
Yes |
Brite Implementation
Tools this skill calls
Organized by phase + reason. Every row cites an ADR or a source file.
| What the skill needs to do |
MCP server / tool |
Repo or system |
Reason (ADR / source) |
| Read tam-mapping output (Source 1) |
Read + Glob |
local worktree |
tam-mapping --output-dir contract |
| Read dbt audience CSV (Source 2) |
Read |
local filesystem |
user-provided path |
| Read dbt model definition for audit (Source 2) |
Bash → gh api repos/Brite-Nites/brite-data-platform/contents/models/marts/<view>.sql |
brite-data-platform (cross-repo) |
ADR 2d (no local clones); reference_handbook_access.md pattern |
| Validate manual CSV columns (Source 3) |
Read |
local filesystem |
user-provided path |
| EB-exclusion availability check (Sources 2/3) |
mcp__emailbison-b2b__get_active_workspace_info + mcp__emailbison-personal__get_active_workspace_info + mcp__plugin_marketing_salesforce__run_soql_query (SELECT Id FROM User LIMIT 1) |
EB workspaces 55/13 + brite-salesforce prod |
ADR 2a (Salesforce CRM SoR; EB sole sequencer); 3-probe parallel batch mirrors tam-mapping § 3 Phase 4.5 |
| EB-exclusion bulk pagination (Sources 2/3) |
mcp__emailbison-b2b__list_leads + mcp__emailbison-personal__list_leads |
both EB workspaces |
ADR 2a (two-workspace requirement) |
| SF Lead suppression read (Sources 2/3) |
mcp__plugin_marketing_salesforce__run_soql_query with SELECT Id, Email, Status FROM Lead WHERE Email IN (:emails) LIMIT 2000 |
brite-salesforce prod |
salesforce.md § Common workflows → Lead suppression read |
| Contact-discovery enrichment (provider-routed) |
Bash → bws run --project-id 627506e6-db07-46b9-8b4d-b498016a0e17 -- "exec python '${CLAUDE_PLUGIN_ROOT}/scripts/tam-map/enrich_waterfall.py'" (fall-through) OR mcp__plugin_marketing_enrichment__bulk_enrich (auto-detect default brite_mcp, BC-16888; routes the list through the bulk door) |
BlitzAPI + Prospeo (fall-through) OR brite-enrichment bulk door (auto-detect default) |
ADR-008 + ADR-017 enrichment pluggability |
| SMTP verify |
Bash → bws run --project-id 627506e6-db07-46b9-8b4d-b498016a0e17 -- "exec python '${CLAUDE_PLUGIN_ROOT}/scripts/tam-map/verify_smtp.py'" |
MillionVerifier |
Same script as tam-mapping § 3 Phase 6 (single source of truth) |
| Contact-context augmentation (Step 1 fallback when enrichment provider returns no LinkedIn URL) |
mcp__plugin_marketing_spider__* |
Spider.cloud |
Crawl homepage + /about + /contact for an inline LinkedIn link; only invoked when enrichment provider misses Step 1 |
Architectural rules that apply
- MCP-cap exception ratified. Marketing plugin runs 4 plugin-level MCPs today (
salesforce, spider, aiark, discolike) — within the ~5–6 advisory cap. No new MCPs added by this skill. (CLAUDE.md MCP_cap_advisory.md + docs/research/tam-map-port-policy.md § 1.)
- EB-exclusion is HARD-FAIL when run (Sources 2 + 3). If either EB workspace OR SF unreachable, skill HALTS — does NOT silent-skip and does NOT proceed to enrichment. Reason: enrichment costs real money. (Mirrors tam-mapping § 3 Phase 4.5 HARD-FAIL rule.)
- EB-exclusion is SKIPPED when source is tam-mapping output (Source 1). tam-mapping Phase 4.5 already ran exclusion; running it again wastes EB API calls and may misclassify net-new leads as suppressed if EB state shifted between runs. (Per-source EB-exclusion routing table in § Methodology > Per-source EB-exclusion routing.)
- SF MCP
directory parameter trap. Skill MUST pass directory pointing at a local brite-salesforce/ checkout OR the skill MUST assert pwd contains an sfdx-project.json before calling. Calls from britenites-claude-plugins/ cwd reject with path-not-found. (salesforce.md § Known gotchas → directory parameter trap.)
run_soql_query usernameOrAlias must be the literal username, not the alias or the DEFAULT_TARGET_ORG sentinel. Pass the service user's literal username (Bitwarden Notes field). (salesforce.md § Known gotchas; gotcha_sf_mcp_username_not_alias.md.)
- Contact-discovery Step 1 is conditional. Skip when input row already has
linkedin_url. Most input sources (tam-mapping output, many dbt audience CSVs) include it; unconditional Step-1 calls waste credits.
- SMTP-verify pattern matches tam-mapping Phase 6 verbatim. Use the same
verify_smtp.py script (single source of truth). Filter result codes 1 + 2 (catch_all flagged); drop 3+ (forward-compatible against future MillionVerifier code additions).
- No free-email providers in B2B output. Filter
gmail.com/yahoo.com/hotmail.com/outlook.com/icloud.com rows to personal-contacts.csv before final enriched_leads.csv write. (Same rule as tam-mapping Operational rule 1.)
Cross-skill boundaries
- Owns: Input-source detection (tam-output / dbt-CSV / manual-CSV). Path-flag validation for all 6 path inputs (Workflow 1 step 0). Per-source EB-exclusion routing. Source-aware resume detection (reads
source.json first; skips slot 2 for tam-output). Source-1 staleness detection (gates re-run via explicit user override). SF Lead suppression. Contact-discovery enrichment orchestration (provider-routed). SMTP verify. Free-email filter. enriched_leads.csv emission.
- Receives from:
tam-mapping (BC-5832) — Source 1 directly via --input-dir <tam-output-dir>. EB-exclusion ALREADY done; this skill skips it.
- User invocation — Sources 2/3 with
--audience-csv + --audience-view-name or --input-csv.
- Indirect upstream: if a dbt audience CSV (Source 2) is built from a brite-data-platform model that itself ingests prior outbound state, the audit trail still shows dbt → CSV → list-building, not tam-mapping. The two upstream paths do not collide.
- Hands off to:
campaign-orchestration (BC-2718) when sequence design / multi-touch orchestration is needed. Handoff artifact: single enriched_leads.csv (schema in § MCP Tool Reference Workflow 5).
launch-campaign (BC-5826) for direct CSV → EB campaign activation. Same handoff artifact.
icp-scoring (BC-5831) optionally — if ABC tiering is desired, the caller invokes icp-scoring --rubric abc on enriched_leads.csv. list-building does NOT delegate this itself; ICP scoring is the caller's choice, not list-building's responsibility.
- Does not own:
- TAM construction →
tam-mapping (BC-5832).
- ABC tiering / ICP scoring →
icp-scoring (BC-5831).
- Sequence design + EB campaign activation →
campaign-orchestration (BC-2718) + launch-campaign (BC-5826).
- dbt model design + Snowflake materialization →
brite-data-platform.
- Enrichment provider implementation →
services/enrichment/cli.py in brite-data-platform (or the brite-enrichment bulk_enrich door → REST /enrich/batch).
Discoveries — title-discovery signals
This skill's contact-discovery pipeline (Workflow 3, Step 2) routinely surfaces decision-maker titles that are not in the entity's ICP title cascade (§ Methodology → ICP title cascade) — a venue with a "Director of Guest Experience" instead of the expected "Events Director", or a hotel chain that puts the buying decision under "Director of F&B Operations" rather than "Catering Director". These observations are signal: they should flow back into the handbook's title cascades + canonicals so the next campaign in the same vertical/persona stops missing the pattern.
The skill emits these observations as title-discovery signals to docs/campaigns/{entity}/{slug}/discoveries.json so BC-8726 (/marketing:icp-refinement-review) and humans can later promote them to plugins/marketing/data/canonicals/{vertical}.yaml via PR. See plugins/marketing/references/discoveries-promotion.md for the full signal → review → handbook PR flow; the schema lives at plugins/marketing/data/discoveries-schema.json and is enforced by plugins/marketing/scripts/lint_discoveries.py (wired into scripts/validate.sh).
When to emit
Emit a title-discovery signal when ALL three conditions hold for a contact-discovery hit:
- The returned
contact_title does NOT appear in the entity's ICP title cascade (default cascade OR the cascade supplied by --criteria-file).
- The contact still ranked highly enough to land in
enriched_leads.csv (i.e., the title is operationally useful even though it isn't canonical yet — operator already accepted the contact, the title pattern is the value).
- The pattern recurs at ≥2 distinct companies in the same run (one-off junior-title spelling variants are noise; cross-company repetition is the signal).
Do NOT emit a signal for: simple alias variations the title-normalization layer should catch (VP of Marketing ↔ VP Marketing); titles inside an already-active canonical alias chain; or single occurrences that read as one-off labelling quirks.
Confirm gate (one AskUserQuestion + one Write)
The emit path is operator-confirmed, never automatic. Mirror the gate campaign-debrief uses for transferable-insight propagation (§3 Transferable-insight flagging there).
Surface the candidate. After Workflow 5 emits enriched_leads.csv, scan the output for titles meeting the § When to emit conditions. Group by {vertical}/{persona} (operator-confirmed at invocation or inferred from --criteria-file). For each grouping with ≥2 distinct-company occurrences, fire AskUserQuestion:
"The run surfaced N contacts with title {contact_title} at {company_1}, {company_2}, … under vertical {vertical} + persona {persona} — this title is not in the canonical cascade. Log a title-discovery signal for human review?"
With options: Yes, log it / No, skip / No — title is alias of existing canonical entry (operator notes which).
Append the signal on Yes. Read docs/campaigns/{entity}/{slug}/discoveries.json (file-not-found branches to a fresh {schema_version: 1, signals: []} shape; do NOT use Glob first — a single Read is cheaper). Append one signal to signals[] with this payload shape:
{
"category": "title-discovery",
"emitted_at": "<ISO-8601 datetime — Z or +00:00 normalized>",
"emitted_by_skill": "list-building",
"payload": {
"vertical": "<vertical-slug>",
"persona": "<persona-slug>",
"candidate_title": "<contact_title-as-returned>",
"occurrences": <integer ≥2>,
"example_companies": ["company_1", "company_2"],
"notes": "<optional operator-supplied note from the AskUserQuestion fallback>"
},
"promotion_status": "pending"
}
Write the file back with a single Write call. The payload keys above are the convention this skill uses; the discoveries-schema.json deliberately holds payload open (type: object) so downstream /marketing:icp-refinement-review (BC-8726) can evolve the shape per category. No other path is allowed to mutate discoveries.json — neither this skill nor any sibling writes to the file outside the confirmed-emit gate.
On No, skip or No — alias of canonical, do NOT write to discoveries.json. The operator's alias note (when supplied) should be surfaced to the operator at the end of the run as a reminder to file a handbook-canonicals follow-up if appropriate — list-building does NOT create that PR itself.
Where this lands
Emitted signals collect in the per-campaign-run discoveries.json. Downstream, BC-8726 (/marketing:icp-refinement-review) reads all category: "title-discovery" signals across runs, presents them as candidates for promotion into plugins/marketing/data/canonicals/{vertical}.yaml personas[].titles[], and produces the canonicals PR. The discoveries-promotion reference doc carries the canonical flow.
MCP Tool Reference
Workflows grouped by phase, not by server. Bare semantic tool names; the allowed-tools frontmatter establishes the server prefix. See plugins/marketing/tools/integrations/ for the per-provider integration guides.
Workflow 1 — Input source detection + read
Validate ALL path-bearing flags before any Bash interpolation. This step is the canonical enforcement point for the path-validation guarantees asserted in the flag table at § Before Starting → Invocation flags. Apply to every set flag in {--output-dir, --input-dir, --input-csv, --audience-csv, --criteria-file, --sfdx-project-dir}:
- Determine safe-root. Try
git rev-parse --show-toplevel. On non-zero exit (cwd is not a git checkout), fall back to $PWD. Treat the resolved value as the prefix that all path flags must stay under.
- Resolve to absolute + reject escapes. Use Python (portable across BSD/GNU
realpath) — python3 -c 'import os, sys; print(os.path.realpath(sys.argv[1]))' "$FLAG_VALUE" — passing the flag via argv (no shell interpolation). For --output-dir, use os.path.realpath with strict=False so the dir need not exist yet; for input flags, the file/dir MUST exist — refuse if missing. If the resolved path contains .. literal-segments after resolution, OR does not start with the safe-root prefix, HALT with <flag> validation failed: <resolved-path> escapes safe-root <safe-root>.
- Symlink defense. When
<output-dir> already exists (resume mode or repeat run), refuse to write into pre-existing symlink targets — re-resolve every Write <output-dir>/<filename> via Python os.path.realpath immediately before write and re-prefix-check. A pre-existing symlink under <output-dir> pointing outside safe-root is rejected at the per-write check, not just at step 0.
$BRITE_DATA_PLATFORM validation (only when enrichment_provider resolves to brite_cli, i.e., per § Before Starting → Enrichment-provider selection): require $BRITE_DATA_PLATFORM non-empty AND begins with / AND matches ^[A-Za-z0-9_/.-]+$. On validation failure, fall through to blitz_waterfall AND log the validation reason (do NOT silently suppress — see Anti-Slop Bash-validation guardrail).
--criteria-file content sanitize (only when set): after path validation, parse the JSON; reject titles containing $, backticks, ;, |, &, single quotes, double quotes, or newlines. This is the workflow-step binding for the Anti-Slop title-validation rule.
These checks run in one batch before any source detection. Failure of any check HALTs the skill at this step.
If --input-dir set: Source 1 (tam-mapping output). Use Glob to find the canonical output file in stable order — tier-a.csv → tier-b.csv → tier-c.csv (Labs path) OR enriched.jsonl → net_new_leads.csv (Nites/Supply path). Read with Read. Write <output-dir>/source.json with {source: "tam-output", input_dir, files_found}. Skip Workflow 2.
If --audience-csv set: Source 2 (dbt audience CSV). Read CSV via Read. Validate --audience-view-name matches ^[a-z0-9_]+$ (halt with validation error if not — never string-interpolate unvalidated input into Bash). Read dbt model definition via Bash → gh api repos/Brite-Nites/brite-data-platform/contents/models/marts/<view>.sql for audit log. Do NOT suffix 2>/dev/null — let stderr flow so gh auth failures, 404s, and rate-limits surface; on non-zero exit, HALT with the stderr message rather than silently writing an empty dbt_model_sha. Write <output-dir>/source.json with {source: "dbt-view", audience_csv, audience_view_name, dbt_model_sha}. Continue to Workflow 2.
If --input-csv set: Source 3 (manual CSV). Read CSV via Read. Validate required columns (domain, company_name); halt with column-validation message if missing. Write <output-dir>/source.json with {source: "manual-csv", input_csv, columns_present}. Continue to Workflow 2.
If no source-mode flag set: call AskUserQuestion listing the 3 modes; never silently default.
Workflow 2 — EB-exclusion (Sources 2 + 3 only — SKIP for Source 1)
Procedure mirrors tam-mapping § 3 Phase 4.5. Keep in sync — when one changes, audit the other.
- Availability checks — issue all 3 in ONE assistant turn as a single message containing 3 parallel tool calls. Do not serialize. ALL THREE MUST PASS — HARD-FAIL on any failure:
mcp__emailbison-b2b__get_active_workspace_info.
mcp__emailbison-personal__get_active_workspace_info.
mcp__plugin_marketing_salesforce__run_soql_query with SELECT Id FROM User LIMIT 1 (per salesforce.md — get_username is NOT a valid liveness check).
- Bulk pagination via
mcp__emailbison-b2b__list_leads AND mcp__emailbison-personal__list_leads. Both workspaces.
- Salesforce Lead suppression query (per
salesforce.md § Common workflows → Lead suppression read):SELECT Id, Email, Status FROM Lead WHERE Email IN (:emails) LIMIT 2000
Bind variable (:emails); never string-interpolate (SOQL injection prevention). Chunking: SOQL IN () supports up to 4000 bind values per query and the LIMIT 2000 caps result rows (not the bind list). For input lists where > 1500 emails could plausibly match SF Leads, chunk the input to ≤ 1500 emails per query and union the result sets locally — guards against silent suppression-set truncation when match count exceeds the result-row LIMIT.
- Merge into a domain-level + email-level exclusion set.
- Filter input rows against exclusion set. Write
<output-dir>/suppression_set.json recording which rows were excluded + by which source (eb_b2b / eb_personal / sf_lead):{
"input_rows": 4200,
"eb_b2b_excluded": 850,
"eb_personal_excluded": 120,
"sf_lead_excluded": 310,
"total_excluded": 1280,
"output_rows": 2920,
"exclusion_rate_pct": 30.5
}
Typical exclusion rate: 20–40% (matches tam-mapping § 3 Phase 4.5 cited average).
Workflow 3 — Contact-discovery enrichment (provider-routed via Bash)
- Resolve
enrichment_provider per § Before Starting → Enrichment-provider selection (priority: --enrichment-provider flag → ${user_config.enrichment_provider} → auto-detect cascade).
- Cost gate before invocation: read MillionVerifier balance; compute
estimated enrichment cost: $X.XX for N records (BlitzAPI: $A, Prospeo: $B, MillionVerifier: $C). This verbatim string MUST appear in output before any enrichment call (grep test in evals). Then apply the gate per --max-records state (defined in § Before Starting → Invocation flags): if --max-records is set and N >= record_count, gate is skipped (caller pre-approved); if --max-records is set and N < record_count, HALT with overflow report (no silent truncation); if --max-records is unset and estimated cost > $20, fire AskUserQuestion confirmation; if --max-records is unset and estimated cost <= $20, proceed.
- Switch on
enrichment_provider enum. The 4-row enum table is the canonical source — see tam-mapping § 3 Phase 5 for per-value invocation, fallback message, and status. Quick reference:
blitz_waterfall (fall-through): Bash → bws run --project-id 627506e6-db07-46b9-8b4d-b498016a0e17 -- "exec python '${CLAUDE_PLUGIN_ROOT}/scripts/tam-map/enrich_waterfall.py' --in <input.jsonl> --out enriched.jsonl". The script accepts only --in + --out (verify against the script's argparse before invocation). Title-tier filtering and --max-contacts-per-company are skill-layer concerns: pre-filter the input JSONL to drop non-target titles before invoking the script, and post-filter enriched.jsonl to dedup by domain keeping the top-N entries by tier rank (T1 > T2 > T3).
brite_cli: Bash → shell to $BRITE_DATA_PLATFORM/services/enrichment/cli.py (subcommand and flags TBD per ADR-008 — verify against the actual cli.py argparse surface before invocation; falls through to blitz_waterfall if $BRITE_DATA_PLATFORM unset).
brite_mcp (auto-detect default, BC-16888): route the ENTIRE candidate list through the bulk_enrich bulk door (→ REST /enrich/batch, per ADR-017) — never a per-company loop of single enrich_contacts calls. If bulk_enrich is unavailable in-session (the brite-enrichment MCP server failed to register/spawn — e.g. the credentials broker or network is down), log brite_mcp selected but bulk_enrich unavailable in-session (MCP server not reachable) — using blitz_waterfall and run blitz_waterfall.
skip: pass-through unenriched (testing only).
- Step 1 conditionally at the skill layer: skip per-record if
linkedin_url already present in input row. When Step 1 is needed and the enrichment provider misses (returns no LinkedIn URL), fall back to Spider crawl of homepage + /about + /contact for an inline LinkedIn link.
Workflow 4 — SMTP verify
Bash → bws run --project-id 627506e6-db07-46b9-8b4d-b498016a0e17 -- "exec python '${CLAUDE_PLUGIN_ROOT}/scripts/tam-map/verify_smtp.py' --in enriched.jsonl --out verified.jsonl" (same script tam-mapping Phase 6 uses — single source of truth).
- Keep result codes 1 + 2 (with
catch_all flag preserved); drop 3+ (unknown, error, disposable, invalid, and any future codes — forward-compatible).
Workflow 5 — Free-email filter + final emission
Filter free-email rows. Rows whose email domain is gmail.com / yahoo.com / hotmail.com / outlook.com / icloud.com → write to <output-dir>/personal-contacts.csv for separate manual outreach. NEVER include in enriched_leads.csv. (Same rule as tam-mapping Operational rule 1.)
Emit enriched_leads.csv. Reshape remaining verified.jsonl rows to <output-dir>/enriched_leads.csv with these 16 columns (final handoff schema). The catch_all column is flattened from nested record.smtp.catch_all in verified.jsonl (mirrors tam-mapping § 3 Phase 7's JSONL→flat-CSV reshape — the column MUST be top-level + literally named catch_all so callers can invoke icp-scoring --rubric abc on enriched_leads.csv without the contract-break that missing required column 'catch_all' for --rubric abc produces). The 3 firmographic columns (industry, employees, geography) are pass-through — list-building does NOT enrich firmographics on its own; they exist to feed icp-scoring's pre-filter optimization (per icp-scoring SKILL.md "expects industry + employees populated where possible"). Note on the two icp-scoring upstream feeders. enriched_leads.csv (16 cols, this file) and tam-mapping's verified-flat.csv (6 cols — domain, company_name, industry, employees, geography, catch_all) are deliberately different shapes. list-building emits the fully-enriched feeder (contact-level columns); tam-mapping emits the tier-classification-only feeder. Both satisfy icp-scoring's required column set; downstream consumers needing contact-level fields (e.g., launch-campaign's post-icp-scoring step) MUST consume from enriched_leads.csv, not from verified-flat.csv. Cross-cite tam-mapping § 3 Phase 7 for the symmetric note on its side.
| Column |
Type |
Notes |
domain |
string |
normalized lowercase, www. stripped |
company_name |
string |
from input source |
contact_email |
string |
verified, lowercase |
contact_first_name |
string |
|
contact_last_name |
string |
|
contact_title |
string |
actual title returned by provider |
contact_linkedin_url |
string |
when available |
industry |
string |
optional pass-through from input source or enrichment provider; blank when unavailable; enables icp-scoring --rubric abc pre-filter cost optimization |
employees |
integer |
optional pass-through; blank when unavailable; same pre-filter optimization |
geography |
string |
optional pass-through; blank when unavailable; same pre-filter optimization |
source |
enum |
tam-output | dbt-view | manual-csv |
source_provenance |
string |
tam-mapping output dir, dbt view name, or manual CSV path |
suppression_status |
enum |
eligible | sf_suppressed | eb_b2b_suppressed | eb_personal_suppressed (ALL rows in enriched_leads.csv should be eligible; column exists for downstream audit confidence) |
enrichment_provider |
string |
resolved provider identifier |
confidence_score |
float |
0.0–1.0 from enrichment provider (per ADR-008 output schema) |
catch_all |
boolean |
flattened from record.smtp.catch_all in verified.jsonl (top-level per icp-scoring abc contract) |
Write <output-dir>/list_stats.json with input/output row counts + per-source suppression counts + enrichment-provider used + cost actually spent + any provider failures.
MCP confirmation gates (out-of-scope reminders):
- Email Bison
import_leads_to_campaign, resume_campaign, unsubscribe_lead, blacklist_lead, archive_campaign, enable_warmup, remove_email_from_blocklist, remove_domain_from_blocklist — these all have MCP-level confirmation gates per the Email Bison integration guide. list-building does NOT call these (handed off to launch-campaign / campaign-orchestration).
Operational Runbook
Task A — Build a list from a tam-mapping Labs output (Source 1)
Preconditions:
docs/marketing-context.md exists and identifies entity brite-labs (or another entity if invoking against Nites/Supply tam-mapping output).
- tam-mapping has run and produced output at
--input-dir.
--input-dir points at a tam-mapping output directory containing tier-a.csv (Labs) or enriched.jsonl / net_new_leads.csv (Nites/Supply).
Steps:
- Workflow 1 (Source 1 detection + read; skip Workflow 2).
- Workflow 3 (enrichment, provider-routed, cost-gated).
- Workflow 4 (SMTP verify).
- Workflow 5 (free-email filter +
enriched_leads.csv emission).
- Run § Discoveries title-discovery gate against the emitted
enriched_leads.csv per § When to emit conditions (≥2 distinct-company occurrences of a non-canonical title). Each operator-confirmed emit appends one signal to docs/campaigns/{entity}/{slug}/discoveries.json; on o
…(truncated)
1---2name: list-building3description: ICP-targeted outbound list assembly. Consumes a TAM source (tam-mapping output, dbt audience CSV, or manual CSV), runs cross-workspace EB exclusion + SF Lead suppression where needed, enriches via the resolved provider, SMTP-verifies, and emits enriched_leads.csv for launch-campaign or campaign-orchestration. Triggers "build list", "list building", "outbound list", "enrich list", "suppress dedup", "ICP list", "decision-maker list", "contact discovery".4---56# List Building78A BDR, RevOps operator, or marketing lead with a TAM in hand has two failure modes: hand-stitch the audience-to-campaign handoff (slow, error-prone, ICP drift) or skip suppression and burn EB workspace reputation on already-contacted leads. This skill is the third option — it consumes any of three TAM sources, runs cross-workspace EB exclusion + SF Lead suppression where the source needs it, enriches to verified contact-level via the resolved enrichment provider, SMTP-verifies, applies the free-email filter, and emits a single `enriched_leads.csv` ready for `launch-campaign` or `campaign-orchestration`.910---1112## Before Starting1314**Check for product marketing context first.** If `docs/marketing-context.md` exists, read it before asking questions and use that context for Brite entity selection, voice, and ICP. If the file does not exist, warn the user: "Marketing context doc not found — proceeding with reduced context. Run `/marketing:product-marketing-context` to generate it." Then continue using only user-provided information.1516### Input-source detection1718The skill consumes ONE of three input sources per invocation. Detection logic when source-mode flags are not passed: call `AskUserQuestion` listing the 3 source modes with brief descriptions. NEVER silently default.1920**Source 1 — tam-mapping output.** Detect by directory pattern: `--input-dir docs/campaigns/labs/tam/<slug>/` (Labs path) or `docs/research/tam/<vertical>-<YYYY-MM-DD>/` (Nites/Supply path). Skill reads the appropriate output file in stable order: `tier-a.csv` → `tier-b.csv` → `tier-c.csv` (Labs) OR `enriched.jsonl` → `net_new_leads.csv` (Nites/Supply). **Skips Workflow 2 (EB-exclusion)** — tam-mapping Phase 4.5 already ran exclusion against both EB workspaces + SF; re-running wastes EB API calls and may misclassify leads if EB state shifted between runs. **Staleness gate:** check the validated `--input-dir`'s mtime via `python3 -c 'import os, sys, time; print(time.time() - os.path.getmtime(sys.argv[1]))' "$INPUT_DIR"` (argv-passed; no shell interpolation — `--input-dir` was already path-validated at Workflow 1 step 0). If age > 7 days, fire `AskUserQuestion` with TWO options: (1) **Skip Workflow 2 (default — preserves the per-source routing rule).** Default action; output proceeds with stale-but-honored upstream exclusion. (2) **Override and re-run Workflow 2.** Explicit user opt-in to re-run cross-workspace EB + SF exclusion against fresh state. Default = (1) so the routing-table contract holds; only an explicit user choice activates the override path documented in § Methodology > Per-source EB-exclusion routing. Never silently skip the staleness check itself.2122**Source 2 — dbt audience CSV.** User passes `--audience-csv <path>` + `--audience-view-name <name>`. Skill reads CSV via `Read`. Skill reads the dbt model definition via `Bash` → `gh api repos/Brite-Nites/brite-data-platform/contents/models/marts/<view>.sql` for column-shape reference + audit logging only — does NOT execute the model (no Snowflake MCP exists; the dbt models materialize in Snowflake out-of-band). **Runs Workflow 2 (EB-exclusion)** before enrichment.2324**Source 3 — manual CSV.** User passes `--input-csv <path>`. Skill validates required columns (`domain` + `company_name` minimum). **Runs Workflow 2 (EB-exclusion)** before enrichment.2526### Invocation flags2728| Flag | Default | Notes |29|---|---|---|30| `--input-dir <path>` | (Source 1 mode) | Path to a tam-mapping output directory. Mutually exclusive with `--audience-csv` and `--input-csv`. |31| `--audience-csv <path>` | (Source 2 mode) | Path to a pre-exported dbt audience CSV. Requires `--audience-view-name`. |32| `--audience-view-name <name>` | (required with `--audience-csv`) | dbt model name (e.g., `audience_active_municipalities`). MUST match `^[a-z0-9_]+$` (no slashes, dots, semicolons, query strings) — halt with validation error otherwise; this value flows into a `gh api` URL constructed via Bash interpolation, so unvalidated input is a path-traversal + shell-injection vector. Used to read model definition via `gh api` for column-shape + audit. |33| `--input-csv <path>` | (Source 3 mode) | Path to a user-provided CSV. Required columns: `domain`, `company_name`. Optional columns flow through to enrichment unchanged. |34| `--criteria-file <path>` | (optional) | JSON file with per-vertical ICP override (custom title cascade, max-contacts override, free-text title hints). Schema mirrors tam-mapping's `--criteria-file` for consistency. When absent, the entity-default ICP title cascade in § Methodology applies. |35| `--output-dir <path>` | `docs/research/lists/<entity>-<YYYY-MM-DD>/` (auto-derived) | Path-traversal segments (`..`) and absolute paths outside the worktree root are rejected. |36| `--enrichment-provider <id>` | (read from `${user_config.enrichment_provider}`) | Override per-run. Enum: `blitz_waterfall \| brite_cli \| brite_mcp \| skip` per [ADR-008](../../../../docs/decisions/008-tam-mapping-enrichment-pluggability.md). |37| `--max-contacts-per-company N` | 3 | Per Revgrowth 08 heuristic — beyond 3, marginal contact acquisition cost outweighs list-quality value. |38| `--max-records N` | (unset → cost gate fires) | Cost-gate pre-approval. When set and `N >= record count`, gate is skipped (caller pre-approved cost). When `N < record count`, skill stops and reports overflow — does NOT silently truncate. (Mirrors tam-mapping semantics.) |39| `--sfdx-project-dir <path>` | (auto-detect via `pwd` if it contains `sfdx-project.json`, else error) | Required for SF MCP `directory` parameter. Per `salesforce.md` § Known gotchas → directory parameter trap, calls from `britenites-claude-plugins/` cwd reject without this. |40| `--resume` | (off) | Force resume even when state is ambiguous (e.g., partial JSONL writes — validates the last record line and resumes from the next). Default behavior (no flag) auto-detects from file existence per § Resume detection. |4142### Enrichment-provider selection4344Read in priority order (per [ADR-008](../../../../docs/decisions/008-tam-mapping-enrichment-pluggability.md) § Unset resolution order):45461. `--enrichment-provider <id>` flag if passed.472. `${user_config.enrichment_provider}` from plugin.json `userConfig` if explicitly set.483. **Auto-detect** (when both above are unset):49 1. Check for brite-enrichment MCP registration (`mcp__plugin_marketing_enrichment__bulk_enrich` reachable in-session) → use `brite_mcp`.50 2. Else check for brite-enrichment CLI at `$BRITE_DATA_PLATFORM/services/enrichment/cli.py` → use `brite_cli`; else fall through to `blitz_waterfall`.514. `skip` is never auto-selected; it must be passed explicitly.5253`brite_mcp` is the **auto-detect default** since the ADR-008 default-flip (executed 2026-07-09, BC-16888; opt-in lineage BC-6170 → BC-13165) — auto-detect selects it whenever the brite-enrichment MCP is registered, routing the whole candidate list through the bulk door (`mcp__plugin_marketing_enrichment__bulk_enrich`, granted in this skill's `allowed-tools`). If the brite-enrichment MCP server is unreachable in-session, it falls through to `blitz_waterfall` (logged) — the fail-open safety net is unchanged. **The `allowed-tools` grant and the `.mcp.json` server-SHA pin are a config-invariant pair — keep them in lockstep: rolling the pin back to a SHA without `bulk_enrich` silently disables the default (it degrades to the fall-through, not an error).**5455The resolved provider is logged at skill invocation so the user sees which path ran (e.g., `[list-building] enrichment_provider=brite_mcp (auto-detected; brite-enrichment MCP registered)` or, on fall-through, `[list-building] enrichment_provider=blitz_waterfall (auto-detected; brite-enrichment MCP not registered, $BRITE_DATA_PLATFORM unset → brite_cli unavailable)`). The 4-row enum table is the canonical source — see [tam-mapping § 3 Phase 5](../tam-mapping/SKILL.md) for per-value implementation and fallback messages (single source of truth across both skills per ADR-008).5657### Resume detection5859Per Operational Runbook Task D, if `--output-dir` already exists with partial output, the skill detects the resume point via file existence in this stable order:60610. **First read `<output-dir>/source.json` to determine the active source.** If `source.json` is missing, treat as a fresh run (resume from Workflow 1). The `source` field drives the branching at slot 2 below.621. `source.json` (Workflow 1 — input source manifest)632. `suppression_set.json` (Workflow 2 — **only checked when `source.json.source` ∈ {`dbt-view`, `manual-csv`}**; SKIPPED entirely for `tam-output` source per the per-source EB-exclusion routing rule — checking it would force a Workflow 2 re-run that the routing table forbids)643. `enriched.jsonl` (Workflow 3)654. `verified.jsonl` (Workflow 4)665. `enriched_leads.csv` (Workflow 5 — terminal)6768The skill NEVER restarts from Workflow 1 when resume state exists. Stop the file-existence loop at the first missing file; do not check subsequent entries. **For `tam-output` source, slot 2 is skipped entirely — the loop checks slots 1 → 3 → 4 → 5.**6970---7172## Methodology7374Adapted from [Revgrowth1/ai-gtm-workflows workflow 08 (MIT)](https://github.com/Revgrowth1/ai-gtm-workflows/tree/main/workflows/08-contact-discovery) for the contact-discovery 3-step pipeline + max-contacts heuristic, and from [Revgrowth1/ai-gtm-workflows workflow 02 (MIT)](https://github.com/Revgrowth1/ai-gtm-workflows/tree/main/workflows/02-tam-mapping) for the dual-source suppression pattern.7576### ICP framing7778An Ideal Customer Profile (ICP) is the firmographic + decision-maker shape of the company most likely to convert. Outbound list assembly fails when the ICP is too loose (waste credits enriching the wrong companies) or too narrow (artificially small TAM, overspend on enrichment that returns 0 hits). The cleanest framing is the **decision-maker title cascade**: T1 (most-senior, smallest pool, highest reply weight), T2 (mid-senior, larger pool, balanced reply weight), T3 (operator-level, largest pool, lowest reply weight). Different verticals invert the cascade — for owner-operator categories (residential service, small contractor) T1 is "Owner" and T2/T3 collapse; for enterprise categories T2 is the largest reply-weighted pool. Cite Revgrowth 08 as canonical shape.7980### Contact-discovery 3-step pipeline8182The conceptual pipeline matches Revgrowth 08:8384**Step 1 — Domain → LinkedIn Company URL.** Given a domain, resolve to canonical LinkedIn company URL. Frequently a no-op when input data already includes the URL (most TAM sources and dbt audience views do).8586**Step 2 — Company URL → ICP decision-maker contacts.** Cascade search for prospects matching entity-specific title tiers. Cap at `max-contacts-per-company` (default 3) per Revgrowth 08 heuristic.8788**Step 3 — Contact → verified work email.** Waterfall via the resolved enrichment provider, then SMTP-verify the result. Drop unverified records.8990### Suppression theory9192Two-source suppression catches more than either alone. **Campaign-tool suppression** (the sequencer's lead store — both b2b and personal workspaces in this skill's case) catches "leads we've already touched in any active or recent campaign." **CRM suppression** (the system-of-record's Lead/Contact store) catches "leads tied to existing accounts, opportunities, or sales motions." Either alone leaks: campaign-tool only misses prospects in long-running SF deals; CRM-only misses fresh outbound in flight. Skipping suppression entirely burns sender reputation (replies of "we've talked to your team three times already") and wastes per-record enrichment credits.9394### Contact count heuristic9596`max-contacts-per-company` defaults to 3 per Revgrowth 08. Beyond 3 contacts per company, marginal contact acquisition cost outweighs list-quality value — the additional contacts are typically lower-tier roles with lower reply weight, while enrichment credits scale linearly with contact count.9798### ICP title cascade — entity-specific99100Entity-specific title tier defaults for the 3 Brite entities. Use these as priors; override per-vertical via `--criteria-file` or per-invocation prompts.101102| Tier | Default | Brite Nites adaptation | Brite Labs adaptation | Brite Supply adaptation |103|---|---|---|---|---|104| T1 | CEO, Founder, Owner, President, Co-Founder | Owner, GM (residential — skip VP cascade) | Venue Director, Events Director | Procurement |105| T2 | VP Marketing, VP Sales, VP Growth, CMO, CRO | (skipped — Owner is the decision-maker) | Marketing Manager | VP Ops, Buyer |106| T3 | Marketing Director, Sales Director, Director of Growth | (skipped) | Catering Director, F&B Director | Buyer |107108### Per-source EB-exclusion routing109110Whether Workflow 2 (EB-exclusion) runs depends on whether the upstream source already ran it.111112| Source | Upstream EB-exclusion? | This skill runs EB-exclusion? |113|---|---|---|114| tam-mapping output (`--input-dir`), input-dir mtime ≤ 7 days | Yes (tam-mapping Phase 4.5) | **No — skip** (default routing rule) |115| tam-mapping output (`--input-dir`), input-dir mtime > 7 days | Yes (tam-mapping Phase 4.5) — but stale | **No by default; user-explicit override path runs Workflow 2** (per Source 1 staleness gate at § Before Starting → Input-source detection) |116| dbt audience CSV (`--audience-csv`) | No | **Yes** |117| Manual CSV (`--input-csv`) | No | **Yes** |118119---120121## Brite Implementation122123### Tools this skill calls124125Organized by phase + reason. Every row cites an ADR or a source file.126127| What the skill needs to do | MCP server / tool | Repo or system | Reason (ADR / source) |128|---|---|---|---|129| Read tam-mapping output (Source 1) | `Read` + `Glob` | local worktree | tam-mapping `--output-dir` contract |130| Read dbt audience CSV (Source 2) | `Read` | local filesystem | user-provided path |131| Read dbt model definition for audit (Source 2) | `Bash` → `gh api repos/Brite-Nites/brite-data-platform/contents/models/marts/<view>.sql` | brite-data-platform (cross-repo) | ADR 2d (no local clones); `reference_handbook_access.md` pattern |132| Validate manual CSV columns (Source 3) | `Read` | local filesystem | user-provided path |133| EB-exclusion availability check (Sources 2/3) | `mcp__emailbison-b2b__get_active_workspace_info` + `mcp__emailbison-personal__get_active_workspace_info` + `mcp__plugin_marketing_salesforce__run_soql_query` (`SELECT Id FROM User LIMIT 1`) | EB workspaces 55/13 + brite-salesforce prod | ADR 2a (Salesforce CRM SoR; EB sole sequencer); 3-probe parallel batch mirrors tam-mapping § 3 Phase 4.5 |134| EB-exclusion bulk pagination (Sources 2/3) | `mcp__emailbison-b2b__list_leads` + `mcp__emailbison-personal__list_leads` | both EB workspaces | ADR 2a (two-workspace requirement) |135| SF Lead suppression read (Sources 2/3) | `mcp__plugin_marketing_salesforce__run_soql_query` with `SELECT Id, Email, Status FROM Lead WHERE Email IN (:emails) LIMIT 2000` | brite-salesforce prod | `salesforce.md` § Common workflows → Lead suppression read |136| Contact-discovery enrichment (provider-routed) | `Bash` → `bws run --project-id 627506e6-db07-46b9-8b4d-b498016a0e17 -- "exec python '${CLAUDE_PLUGIN_ROOT}/scripts/tam-map/enrich_waterfall.py'"` (fall-through) OR `mcp__plugin_marketing_enrichment__bulk_enrich` (auto-detect default `brite_mcp`, BC-16888; routes the list through the bulk door) | BlitzAPI + Prospeo (fall-through) OR brite-enrichment bulk door (auto-detect default) | [ADR-008](../../../../docs/decisions/008-tam-mapping-enrichment-pluggability.md) + ADR-017 enrichment pluggability |137| SMTP verify | `Bash` → `bws run --project-id 627506e6-db07-46b9-8b4d-b498016a0e17 -- "exec python '${CLAUDE_PLUGIN_ROOT}/scripts/tam-map/verify_smtp.py'"` | MillionVerifier | Same script as tam-mapping § 3 Phase 6 (single source of truth) |138| Contact-context augmentation (Step 1 fallback when enrichment provider returns no LinkedIn URL) | `mcp__plugin_marketing_spider__*` | Spider.cloud | Crawl homepage + `/about` + `/contact` for an inline LinkedIn link; only invoked when enrichment provider misses Step 1 |139140### Architectural rules that apply141142- **MCP-cap exception ratified.** Marketing plugin runs 4 plugin-level MCPs today (`salesforce`, `spider`, `aiark`, `discolike`) — within the ~5–6 advisory cap. No new MCPs added by this skill. (CLAUDE.md `MCP_cap_advisory.md` + [`docs/research/tam-map-port-policy.md`](../../../../docs/research/tam-map-port-policy.md) § 1.)143- **EB-exclusion is HARD-FAIL when run** (Sources 2 + 3). If either EB workspace OR SF unreachable, skill HALTS — does NOT silent-skip and does NOT proceed to enrichment. Reason: enrichment costs real money. (Mirrors [tam-mapping § 3 Phase 4.5](../tam-mapping/SKILL.md) HARD-FAIL rule.)144- **EB-exclusion is SKIPPED when source is tam-mapping output** (Source 1). tam-mapping Phase 4.5 already ran exclusion; running it again wastes EB API calls and may misclassify net-new leads as suppressed if EB state shifted between runs. (Per-source EB-exclusion routing table in § Methodology > Per-source EB-exclusion routing.)145- **SF MCP `directory` parameter trap.** Skill MUST pass `directory` pointing at a local `brite-salesforce/` checkout OR the skill MUST assert `pwd` contains an `sfdx-project.json` before calling. Calls from `britenites-claude-plugins/` cwd reject with path-not-found. (`salesforce.md` § Known gotchas → directory parameter trap.)146- **`run_soql_query` `usernameOrAlias` must be the literal username**, not the alias or the `DEFAULT_TARGET_ORG` sentinel. Pass the service user's literal username (Bitwarden Notes field). (`salesforce.md` § Known gotchas; `gotcha_sf_mcp_username_not_alias.md`.)147- **Contact-discovery Step 1 is conditional.** Skip when input row already has `linkedin_url`. Most input sources (tam-mapping output, many dbt audience CSVs) include it; unconditional Step-1 calls waste credits.148- **SMTP-verify pattern matches tam-mapping Phase 6 verbatim.** Use the same `verify_smtp.py` script (single source of truth). Filter result codes 1 + 2 (`catch_all` flagged); drop 3+ (forward-compatible against future MillionVerifier code additions).149- **No free-email providers in B2B output.** Filter `gmail.com`/`yahoo.com`/`hotmail.com`/`outlook.com`/`icloud.com` rows to `personal-contacts.csv` before final `enriched_leads.csv` write. (Same rule as tam-mapping Operational rule 1.)150151### Cross-skill boundaries152153- **Owns:** Input-source detection (tam-output / dbt-CSV / manual-CSV). Path-flag validation for all 6 path inputs (Workflow 1 step 0). Per-source EB-exclusion routing. Source-aware resume detection (reads `source.json` first; skips slot 2 for tam-output). Source-1 staleness detection (gates re-run via explicit user override). SF Lead suppression. Contact-discovery enrichment orchestration (provider-routed). SMTP verify. Free-email filter. `enriched_leads.csv` emission.154- **Receives from:**155 - `tam-mapping` (BC-5832) — Source 1 directly via `--input-dir <tam-output-dir>`. EB-exclusion ALREADY done; this skill skips it.156 - User invocation — Sources 2/3 with `--audience-csv` + `--audience-view-name` or `--input-csv`.157 - **Indirect upstream:** if a dbt audience CSV (Source 2) is built from a brite-data-platform model that itself ingests prior outbound state, the audit trail still shows dbt → CSV → list-building, not tam-mapping. The two upstream paths do not collide.158- **Hands off to:**159 - `campaign-orchestration` (BC-2718) when sequence design / multi-touch orchestration is needed. Handoff artifact: single `enriched_leads.csv` (schema in § MCP Tool Reference Workflow 5).160 - `launch-campaign` (BC-5826) for direct CSV → EB campaign activation. Same handoff artifact.161 - `icp-scoring` (BC-5831) **optionally** — if ABC tiering is desired, the caller invokes `icp-scoring --rubric abc` on `enriched_leads.csv`. list-building does NOT delegate this itself; ICP scoring is the caller's choice, not list-building's responsibility.162- **Does not own:**163 - TAM construction → `tam-mapping` (BC-5832).164 - ABC tiering / ICP scoring → `icp-scoring` (BC-5831).165 - Sequence design + EB campaign activation → `campaign-orchestration` (BC-2718) + `launch-campaign` (BC-5826).166 - dbt model design + Snowflake materialization → `brite-data-platform`.167 - Enrichment provider implementation → `services/enrichment/cli.py` in brite-data-platform (or the brite-enrichment `bulk_enrich` door → REST `/enrich/batch`).168169---170171## Discoveries — title-discovery signals172173This skill's contact-discovery pipeline (Workflow 3, Step 2) routinely surfaces decision-maker titles that are not in the entity's ICP title cascade (§ Methodology → ICP title cascade) — a venue with a "Director of Guest Experience" instead of the expected "Events Director", or a hotel chain that puts the buying decision under "Director of F&B Operations" rather than "Catering Director". These observations are signal: they should flow back into the handbook's title cascades + canonicals so the next campaign in the same vertical/persona stops missing the pattern.174175The skill emits these observations as **`title-discovery`** signals to `docs/campaigns/{entity}/{slug}/discoveries.json` so BC-8726 (`/marketing:icp-refinement-review`) and humans can later promote them to `plugins/marketing/data/canonicals/{vertical}.yaml` via PR. See [`plugins/marketing/references/discoveries-promotion.md`](../../references/discoveries-promotion.md) for the full signal → review → handbook PR flow; the schema lives at [`plugins/marketing/data/discoveries-schema.json`](../../data/discoveries-schema.json) and is enforced by `plugins/marketing/scripts/lint_discoveries.py` (wired into `scripts/validate.sh`).176177### When to emit178179Emit a `title-discovery` signal when ALL three conditions hold for a contact-discovery hit:1801811. The returned `contact_title` does NOT appear in the entity's ICP title cascade (default cascade OR the cascade supplied by `--criteria-file`).1822. The contact still ranked highly enough to land in `enriched_leads.csv` (i.e., the title is operationally useful even though it isn't canonical yet — operator already accepted the contact, the title pattern is the value).1833. The pattern recurs at ≥2 distinct companies in the same run (one-off junior-title spelling variants are noise; cross-company repetition is the signal).184185Do NOT emit a signal for: simple alias variations the title-normalization layer should catch (`VP of Marketing` ↔ `VP Marketing`); titles inside an already-active canonical alias chain; or single occurrences that read as one-off labelling quirks.186187### Confirm gate (one `AskUserQuestion` + one `Write`)188189The emit path is operator-confirmed, never automatic. Mirror the gate `campaign-debrief` uses for transferable-insight propagation (§3 Transferable-insight flagging there).1901911. **Surface the candidate.** After Workflow 5 emits `enriched_leads.csv`, scan the output for titles meeting the § When to emit conditions. Group by `{vertical}/{persona}` (operator-confirmed at invocation or inferred from `--criteria-file`). For each grouping with ≥2 distinct-company occurrences, fire `AskUserQuestion`:192 > "The run surfaced N contacts with title `{contact_title}` at {company_1}, {company_2}, … under vertical `{vertical}` + persona `{persona}` — this title is not in the canonical cascade. Log a `title-discovery` signal for human review?"193 With options: `Yes, log it` / `No, skip` / `No — title is alias of existing canonical entry (operator notes which)`.1942. **Append the signal on `Yes`.** Read `docs/campaigns/{entity}/{slug}/discoveries.json` (file-not-found branches to a fresh `{schema_version: 1, signals: []}` shape; do NOT use `Glob` first — a single `Read` is cheaper). Append one signal to `signals[]` with this payload shape:195196 ```jsonc197 {198 "category": "title-discovery",199 "emitted_at": "<ISO-8601 datetime — Z or +00:00 normalized>",200 "emitted_by_skill": "list-building",201 "payload": {202 "vertical": "<vertical-slug>",203 "persona": "<persona-slug>",204 "candidate_title": "<contact_title-as-returned>",205 "occurrences": <integer ≥2>,206 "example_companies": ["company_1", "company_2"],207 "notes": "<optional operator-supplied note from the AskUserQuestion fallback>"208 },209 "promotion_status": "pending"210 }211 ```212213 Write the file back with a single `Write` call. The `payload` keys above are the convention this skill uses; the [`discoveries-schema.json`](../../data/discoveries-schema.json) deliberately holds `payload` open (`type: object`) so downstream `/marketing:icp-refinement-review` (BC-8726) can evolve the shape per category. **No other path is allowed to mutate `discoveries.json`** — neither this skill nor any sibling writes to the file outside the confirmed-emit gate.2142153. **On `No, skip` or `No — alias of canonical`**, do NOT write to `discoveries.json`. The operator's alias note (when supplied) should be surfaced to the operator at the end of the run as a reminder to file a handbook-canonicals follow-up if appropriate — list-building does NOT create that PR itself.216217### Where this lands218219Emitted signals collect in the per-campaign-run `discoveries.json`. Downstream, BC-8726 (`/marketing:icp-refinement-review`) reads all `category: "title-discovery"` signals across runs, presents them as candidates for promotion into `plugins/marketing/data/canonicals/{vertical}.yaml` `personas[].titles[]`, and produces the canonicals PR. The discoveries-promotion reference doc carries the canonical flow.220221---222223## MCP Tool Reference224225Workflows grouped by phase, not by server. Bare semantic tool names; the `allowed-tools` frontmatter establishes the server prefix. See [`plugins/marketing/tools/integrations/`](../../tools/integrations/) for the per-provider integration guides.226227### Workflow 1 — Input source detection + read2282290. **Validate ALL path-bearing flags before any Bash interpolation.** This step is the canonical enforcement point for the path-validation guarantees asserted in the flag table at § Before Starting → Invocation flags. Apply to every set flag in `{--output-dir, --input-dir, --input-csv, --audience-csv, --criteria-file, --sfdx-project-dir}`:230 - **Determine safe-root.** Try `git rev-parse --show-toplevel`. On non-zero exit (cwd is not a git checkout), fall back to `$PWD`. Treat the resolved value as the prefix that all path flags must stay under.231 - **Resolve to absolute + reject escapes.** Use Python (portable across BSD/GNU `realpath`) — `python3 -c 'import os, sys; print(os.path.realpath(sys.argv[1]))' "$FLAG_VALUE"` — passing the flag via `argv` (no shell interpolation). For `--output-dir`, use `os.path.realpath` with `strict=False` so the dir need not exist yet; for input flags, the file/dir MUST exist — refuse if missing. If the resolved path contains `..` literal-segments after resolution, OR does not start with the safe-root prefix, HALT with `<flag> validation failed: <resolved-path> escapes safe-root <safe-root>`.232 - **Symlink defense.** When `<output-dir>` already exists (resume mode or repeat run), refuse to write into pre-existing symlink targets — re-resolve every `Write <output-dir>/<filename>` via Python `os.path.realpath` immediately before write and re-prefix-check. A pre-existing symlink under `<output-dir>` pointing outside safe-root is rejected at the per-write check, not just at step 0.233 - **`$BRITE_DATA_PLATFORM` validation** (only when `enrichment_provider` resolves to `brite_cli`, i.e., per § Before Starting → Enrichment-provider selection): require `$BRITE_DATA_PLATFORM` non-empty AND begins with `/` AND matches `^[A-Za-z0-9_/.-]+$`. On validation failure, fall through to `blitz_waterfall` AND log the validation reason (do NOT silently suppress — see Anti-Slop Bash-validation guardrail).234 - **`--criteria-file` content sanitize** (only when set): after path validation, parse the JSON; reject titles containing `$`, backticks, `;`, `|`, `&`, single quotes, double quotes, or newlines. This is the workflow-step binding for the Anti-Slop title-validation rule.235236 These checks run in one batch before any source detection. Failure of any check HALTs the skill at this step.2371. **If `--input-dir` set:** Source 1 (tam-mapping output). Use `Glob` to find the canonical output file in stable order — `tier-a.csv` → `tier-b.csv` → `tier-c.csv` (Labs path) OR `enriched.jsonl` → `net_new_leads.csv` (Nites/Supply path). Read with `Read`. Write `<output-dir>/source.json` with `{source: "tam-output", input_dir, files_found}`. **Skip Workflow 2.**2382. **If `--audience-csv` set:** Source 2 (dbt audience CSV). Read CSV via `Read`. Validate `--audience-view-name` matches `^[a-z0-9_]+$` (halt with validation error if not — never string-interpolate unvalidated input into Bash). Read dbt model definition via `Bash` → `gh api repos/Brite-Nites/brite-data-platform/contents/models/marts/<view>.sql` for audit log. **Do NOT suffix `2>/dev/null`** — let stderr flow so `gh` auth failures, 404s, and rate-limits surface; on non-zero exit, HALT with the stderr message rather than silently writing an empty `dbt_model_sha`. Write `<output-dir>/source.json` with `{source: "dbt-view", audience_csv, audience_view_name, dbt_model_sha}`. **Continue to Workflow 2.**2393. **If `--input-csv` set:** Source 3 (manual CSV). Read CSV via `Read`. Validate required columns (`domain`, `company_name`); halt with column-validation message if missing. Write `<output-dir>/source.json` with `{source: "manual-csv", input_csv, columns_present}`. **Continue to Workflow 2.**2404. **If no source-mode flag set:** call `AskUserQuestion` listing the 3 modes; never silently default.241242### Workflow 2 — EB-exclusion (Sources 2 + 3 only — SKIP for Source 1)243244> **Procedure mirrors [tam-mapping § 3 Phase 4.5](../tam-mapping/SKILL.md). Keep in sync — when one changes, audit the other.**2452461. **Availability checks — issue all 3 in ONE assistant turn as a single message containing 3 parallel tool calls. Do not serialize. ALL THREE MUST PASS — HARD-FAIL on any failure:**247 - `mcp__emailbison-b2b__get_active_workspace_info`.248 - `mcp__emailbison-personal__get_active_workspace_info`.249 - `mcp__plugin_marketing_salesforce__run_soql_query` with `SELECT Id FROM User LIMIT 1` (per `salesforce.md` — `get_username` is NOT a valid liveness check).2502. Bulk pagination via `mcp__emailbison-b2b__list_leads` AND `mcp__emailbison-personal__list_leads`. Both workspaces.2513. Salesforce Lead suppression query (per `salesforce.md` § Common workflows → Lead suppression read):252 ```sql253 SELECT Id, Email, Status FROM Lead WHERE Email IN (:emails) LIMIT 2000254 ```255 Bind variable (`:emails`); never string-interpolate (SOQL injection prevention). **Chunking:** SOQL `IN ()` supports up to 4000 bind values per query and the `LIMIT 2000` caps result rows (not the bind list). For input lists where > 1500 emails could plausibly match SF Leads, chunk the input to ≤ 1500 emails per query and union the result sets locally — guards against silent suppression-set truncation when match count exceeds the result-row LIMIT.2564. Merge into a domain-level + email-level exclusion set.2575. Filter input rows against exclusion set. Write `<output-dir>/suppression_set.json` recording which rows were excluded + by which source (`eb_b2b` / `eb_personal` / `sf_lead`):258 ```jsonc259 {260 "input_rows": 4200,261 "eb_b2b_excluded": 850,262 "eb_personal_excluded": 120,263 "sf_lead_excluded": 310,264 "total_excluded": 1280,265 "output_rows": 2920,266 "exclusion_rate_pct": 30.5267 }268 ```269270Typical exclusion rate: 20–40% (matches tam-mapping § 3 Phase 4.5 cited average).271272### Workflow 3 — Contact-discovery enrichment (provider-routed via Bash)2732741. Resolve `enrichment_provider` per § Before Starting → Enrichment-provider selection (priority: `--enrichment-provider` flag → `${user_config.enrichment_provider}` → auto-detect cascade).2752. **Cost gate** before invocation: read MillionVerifier balance; compute `estimated enrichment cost: $X.XX for N records (BlitzAPI: $A, Prospeo: $B, MillionVerifier: $C)`. This verbatim string MUST appear in output before any enrichment call (grep test in evals). Then apply the gate per `--max-records` state (defined in § Before Starting → Invocation flags): if `--max-records` is set and `N >= record_count`, gate is skipped (caller pre-approved); if `--max-records` is set and `N < record_count`, HALT with overflow report (no silent truncation); if `--max-records` is unset and `estimated cost > $20`, fire `AskUserQuestion` confirmation; if `--max-records` is unset and `estimated cost <= $20`, proceed.2763. Switch on `enrichment_provider` enum. The 4-row enum table is the canonical source — see [tam-mapping § 3 Phase 5](../tam-mapping/SKILL.md) for per-value invocation, fallback message, and status. Quick reference:277 - `blitz_waterfall` (fall-through): `Bash` → `bws run --project-id 627506e6-db07-46b9-8b4d-b498016a0e17 -- "exec python '${CLAUDE_PLUGIN_ROOT}/scripts/tam-map/enrich_waterfall.py' --in <input.jsonl> --out enriched.jsonl"`. The script accepts only `--in` + `--out` (verify against the script's argparse before invocation). Title-tier filtering and `--max-contacts-per-company` are **skill-layer concerns**: pre-filter the input JSONL to drop non-target titles before invoking the script, and post-filter `enriched.jsonl` to dedup by `domain` keeping the top-N entries by tier rank (T1 > T2 > T3).278 - `brite_cli`: `Bash` → shell to `$BRITE_DATA_PLATFORM/services/enrichment/cli.py` (subcommand and flags TBD per ADR-008 — verify against the actual `cli.py` argparse surface before invocation; falls through to `blitz_waterfall` if `$BRITE_DATA_PLATFORM` unset).279 - `brite_mcp` (auto-detect default, BC-16888): route the ENTIRE candidate list through the `bulk_enrich` bulk door (→ REST `/enrich/batch`, per [ADR-017](https://github.com/Brite-Nites/brite-data-platform/blob/main/docs/decisions/017-bulk-enrichment-door.md)) — **never** a per-company loop of single `enrich_contacts` calls. If `bulk_enrich` is unavailable in-session (the brite-enrichment MCP server failed to register/spawn — e.g. the credentials broker or network is down), log `brite_mcp selected but bulk_enrich unavailable in-session (MCP server not reachable) — using blitz_waterfall` and run `blitz_waterfall`.280 - `skip`: pass-through unenriched (testing only).2814. **Step 1 conditionally** at the skill layer: skip per-record if `linkedin_url` already present in input row. When Step 1 is needed and the enrichment provider misses (returns no LinkedIn URL), fall back to Spider crawl of homepage + `/about` + `/contact` for an inline LinkedIn link.282283### Workflow 4 — SMTP verify2842851. `Bash` → `bws run --project-id 627506e6-db07-46b9-8b4d-b498016a0e17 -- "exec python '${CLAUDE_PLUGIN_ROOT}/scripts/tam-map/verify_smtp.py' --in enriched.jsonl --out verified.jsonl"` (same script tam-mapping Phase 6 uses — single source of truth).2862. Keep result codes 1 + 2 (with `catch_all` flag preserved); drop 3+ (`unknown`, `error`, `disposable`, `invalid`, and any future codes — forward-compatible).287288### Workflow 5 — Free-email filter + final emission2892901. **Filter free-email rows.** Rows whose email domain is `gmail.com` / `yahoo.com` / `hotmail.com` / `outlook.com` / `icloud.com` → write to `<output-dir>/personal-contacts.csv` for separate manual outreach. NEVER include in `enriched_leads.csv`. (Same rule as tam-mapping Operational rule 1.)2912. **Emit `enriched_leads.csv`.** Reshape remaining `verified.jsonl` rows to `<output-dir>/enriched_leads.csv` with these 16 columns (final handoff schema). The `catch_all` column is **flattened from nested `record.smtp.catch_all`** in `verified.jsonl` (mirrors tam-mapping § 3 Phase 7's JSONL→flat-CSV reshape — the column MUST be top-level + literally named `catch_all` so callers can invoke `icp-scoring --rubric abc` on `enriched_leads.csv` without the contract-break that `missing required column 'catch_all' for --rubric abc` produces). The 3 firmographic columns (`industry`, `employees`, `geography`) are pass-through — list-building does NOT enrich firmographics on its own; they exist to feed icp-scoring's pre-filter optimization (per icp-scoring SKILL.md "expects `industry` + `employees` populated where possible"). **Note on the two icp-scoring upstream feeders.** `enriched_leads.csv` (16 cols, this file) and tam-mapping's `verified-flat.csv` (6 cols — `domain`, `company_name`, `industry`, `employees`, `geography`, `catch_all`) are deliberately different shapes. list-building emits the *fully-enriched* feeder (contact-level columns); tam-mapping emits the *tier-classification-only* feeder. Both satisfy icp-scoring's required column set; downstream consumers needing contact-level fields (e.g., `launch-campaign`'s post-icp-scoring step) MUST consume from `enriched_leads.csv`, not from `verified-flat.csv`. Cross-cite tam-mapping § 3 Phase 7 for the symmetric note on its side.292293 | Column | Type | Notes |294 |---|---|---|295 | `domain` | string | normalized lowercase, `www.` stripped |296 | `company_name` | string | from input source |297 | `contact_email` | string | verified, lowercase |298 | `contact_first_name` | string | |299 | `contact_last_name` | string | |300 | `contact_title` | string | actual title returned by provider |301 | `contact_linkedin_url` | string | when available |302 | `industry` | string | optional pass-through from input source or enrichment provider; blank when unavailable; enables icp-scoring `--rubric abc` pre-filter cost optimization |303 | `employees` | integer | optional pass-through; blank when unavailable; same pre-filter optimization |304 | `geography` | string | optional pass-through; blank when unavailable; same pre-filter optimization |305 | `source` | enum | `tam-output` \| `dbt-view` \| `manual-csv` |306 | `source_provenance` | string | tam-mapping output dir, dbt view name, or manual CSV path |307 | `suppression_status` | enum | `eligible` \| `sf_suppressed` \| `eb_b2b_suppressed` \| `eb_personal_suppressed` (ALL rows in `enriched_leads.csv` should be `eligible`; column exists for downstream audit confidence) |308 | `enrichment_provider` | string | resolved provider identifier |309 | `confidence_score` | float | 0.0–1.0 from enrichment provider (per ADR-008 output schema) |310 | `catch_all` | boolean | flattened from `record.smtp.catch_all` in `verified.jsonl` (top-level per icp-scoring `abc` contract) |3113123. **Write `<output-dir>/list_stats.json`** with input/output row counts + per-source suppression counts + enrichment-provider used + cost actually spent + any provider failures.313314**MCP confirmation gates (out-of-scope reminders):**315316- Email Bison `import_leads_to_campaign`, `resume_campaign`, `unsubscribe_lead`, `blacklist_lead`, `archive_campaign`, `enable_warmup`, `remove_email_from_blocklist`, `remove_domain_from_blocklist` — these all have MCP-level confirmation gates per the Email Bison integration guide. **list-building does NOT call these** (handed off to `launch-campaign` / `campaign-orchestration`).317318---319320## Operational Runbook321322### Task A — Build a list from a tam-mapping Labs output (Source 1)323324**Preconditions:**325326- `docs/marketing-context.md` exists and identifies entity `brite-labs` (or another entity if invoking against Nites/Supply tam-mapping output).327- tam-mapping has run and produced output at `--input-dir`.328- `--input-dir` points at a tam-mapping output directory containing `tier-a.csv` (Labs) or `enriched.jsonl` / `net_new_leads.csv` (Nites/Supply).329330**Steps:**3313321. Workflow 1 (Source 1 detection + read; **skip Workflow 2**).3332. Workflow 3 (enrichment, provider-routed, cost-gated).3343. Workflow 4 (SMTP verify).3354. Workflow 5 (free-email filter + `enriched_leads.csv` emission).3365. Run § Discoveries title-discovery gate against the emitted `enriched_leads.csv` per § When to emit conditions (≥2 distinct-company occurrences of a non-canonical title). Each operator-confirmed emit appends one signal to `docs/campaigns/{entity}/{slug}/discoveries.json`; on o337338…(truncated)