The problem
Building a prospect list was manual, slow, and produced data nobody could vouch for.
Discovery was a person with a browser. Searching, opening pages, copying addresses into a spreadsheet, checking whether the domain even accepts mail. Hours of work producing a list whose provenance existed only in that person's memory.
Quality problems surfaced after sending, which is the worst possible time. Addresses that do not resolve, role addresses that should have been filtered, records already on the suppression list. Each one costs sender reputation, and you find out afterwards.
Enrichment had a dangerous shape. The natural implementation — look up enrichment data while generating messages — makes an external data source a dependency of sending. When that source slows down, campaigns stop. The failure is remote, external, and takes the revenue path with it.
Paid search APIs made the cost scale with curiosity. Every exploratory query cost money, which meant people ran fewer of them and worked from worse data.
Nothing was reproducible. Re-running the same discovery produced a different result set, because query generation was ad hoc. There was no way to audit where a list came from.
Previous workflow
Before: manual prospect research
The end state was a spreadsheet whose provenance lived in one person's head.
- Human action
Researcher decides what to search for
- Problem: Query choice varied by person and by day
- Problem: No record of what had already been searched
- Human action
Manual web searching
- Problem: Hours per list
- Problem: Coverage depended on patience
- Problem: Paid search API cost per query discouraged exploration
- Human action
Open pages and copy addresses
- Problem: Transcription errors
- Problem: Duplicate work across researchers
- Problem: No deduplication until the spreadsheet stage
- Human action
Spot-check validity
- Problem: Rarely done thoroughly
- Problem: Role addresses and non-accepting domains slipped through
- Automated software
Import to a sending list
- Problem: Quality problems only surfaced after the campaign sent
- Problem: No provenance record for compliance review
- Human action
Enrichment looked up by hand when needed
- Problem: Inconsistent coverage
- Problem: Segmentation limited to what someone had time to fill in
- Human action
- Automated software
Goals and non-goals
Goals
- Discovery runs as a repeatable job with recorded inputs and generated queries.
- Results are filtered and reviewed before anything can become a sending list.
- Retrying a job reproduces the same query set rather than a new random one.
- Enrichment never sits in the sending path, under any configuration.
- Discovery cost is not proportional to how many questions someone asks.
- Repeated work is cached rather than repaid.
Non-goals
- No automatic import. Explicitly out of scope. A human reviews and creates the list.
- Not a contact database product. It supports the platform's own list building.
- No scraping behind authentication. Publicly published business contact information only.
- No enrichment guarantee. Records may have no enrichment data, and every consumer must handle that.
My role
- Discovery — established that the expensive part was not searching but reviewing, which is why the review screen got the design attention rather than the crawler.
- Requirements — wrote the enrichment service specification, including its hard constraints: background-only, additive schema, no changes to the sending core, and tolerance of missing data everywhere.
- Safety design — made the no-auto-import rule a structural property rather than a policy: the service has no path into the sending pipeline at all.
- Pipeline architecture — the database-driven queue, its claim semantics, idempotent expansion and stale-lock reclamation.
- Cost architecture — moved discovery from a paid search API to self-hosted metasearch with an open-source fallback, keeping the paid backend selectable for comparison.
- Reproducibility — required query generation to be seeded by job identity, so a retry produces the same queries.
- Test planning and UAT — with the lead operations staff who use the review screen daily.
- Operational tuning — bandwidth budgeting across competing crawl consumers.
Product solution
Discovery. An operator creates a job from keywords, optional manual search queries, seed domains, or direct URLs. For keywords, the service expands contact-focused query templates, optionally scoping some of the budget to specific public platforms. It runs those searches, fetches page content, extracts candidate addresses, and validates and classifies each one — does the domain accept mail, is this a role address, is it already suppressed.
The operator then reviews. The results screen filters by mail-exchange validity, confidence, role type and suppression status. From there they either create a list or export the data. There is no third option, and no automatic path.
Enrichment. A separate background service that owns three tables and nothing else. Jobs expand into a queue with set-based SQL and conflict handling that makes re-running a job a no-op rather than a duplicate. Workers claim rows with skip-locked semantics, heartbeat while working, and a reaper reclaims locks left behind by a worker that died. The service populates segmentation data and is architecturally incapable of delaying a send.
Workflow
Discovery — job to reviewed list
- Human action
Operator creates a job
Keywords, manual queries, seed domains or direct URLs.
- Automated software
Query expansion
Contact-focused templates expanded and shuffled, seeded by job identity so a retry reproduces the same set.
- Data store
Repeat-query cache checked
Recently seen queries are served from cache with a bounded lifetime.
- External service
Self-hosted metasearch
Decision point: Falls back to an open-source harvester when metasearch returns nothing usable.
- Automated software
Page fetch through a metered proxy pool
Bandwidth drawn from a shared monthly budget split across competing consumers.
- Automated software
Address extraction and classification
Mail-exchange validation, role-address detection and suppression cross-check.
- Approval gate
Operator review
Decision point: Filter by validity, confidence, role type and suppression. Create a list, or export — there is no automatic import.
Enrichment — background only
- Automated software
Job created with a scope
A whole list, or an explicit set of records.
- Automated software
Set-based expansion into the queue
Conflict handling makes re-running the same job idempotent rather than duplicative.
- Automated software
Worker claims a batch
Skip-locked claim semantics so parallel workers never contend for the same rows.
- External service
External enrichment sources
Decision point: Failure marks the row for retry and never propagates toward sending.
- Data store
Enrichment record written
Provider, geography, risk signals and extensible attributes.
- Monitoring / feedback
Heartbeat and stale-lock reaper
Locks held by a dead worker are reclaimed on a fixed cycle.
- Automated software
Available for segmentation
Consumers must tolerate a missing record. Absence is normal, not an error.
Architecture
Discovery and enrichment — sanitized view
Two independent services. Neither has a path into the sending pipeline.
Operator surface
Job creation
Human
Keywords, manual queries, seed domains, direct URLs, platform scoping.
Results review screen
Approval gate
Filter by mail-exchange validity, confidence, role type and suppression. The only route from discovery to a list.
Discovery service
Query expander
Automated
Template-based generation seeded by job identity, so retries reproduce the query set.
Query cache
Data store
Bounded-lifetime cache so repeated research does not repay the same cost.
Self-hosted metasearch
External service
Primary backend, bound to the local interface only. Open-source harvester as fallback; the legacy paid backend remains selectable.
Fetch and extract
Automated
Page retrieval through a metered proxy pool, then address extraction, validation and classification.
Enrichment service
Owns exactly three tables and touches nothing in the sending core.
Job expander
Automated
Set-based SQL expansion with conflict handling for idempotency.
Queue table
Data store
Queued, processing, done, error and dead states with claim ownership and timestamps.
Batch worker
Automated
Skip-locked claiming, batched record fetches, explicit timestamp updates — no database triggers.
Heartbeat and reaper
Monitoring
Stale locks reclaimed on a fixed cycle so a dead worker cannot strand its batch.
Enrichment records
Data store
Provider, geography, risk signals and extensible attributes for segmentation.
Supporting services
Each replaced a paid dependency or split a concern out of the pipeline.
AdFetch
Automated
A self-hosted fetch and scrape service this pipeline calls for every page fetch, implementing the same request and response shapes as the paid API it replaced so callers did not change. A separately run service also used by the AdSpy pipeline. See the separate case study.
Layered verification
Automated
Syntax, then mail exchange, then an SMTP handshake, then catch-all detection, then disposable-provider and role-address detection. One concern per stage, so a rejection names the layer that produced it.
Domain trap scoring
Automated
A separate service scoring trap and parked domains, consumed by the enrichment stages rather than embedded in them.
Boundary
No automatic path to sending
Approval gate
Discovery results require explicit operator list creation. Enrichment is read-only from the sending path's perspective and may be entirely absent.
Diagram is sanitized for publication: it shows responsibilities and data flow, not hostnames, addresses or credentials.
Where AI is used — and where it is not
AI / LLM responsibilities
- Suggesting keyword and niche themes for an operator to consider before a job is created.
- Summarizing a result set so a reviewer knows what they are about to look at.
- Implementation assistance and code review under the engineering workflow.
- Drafting the service specification, reviewed and corrected by me before it became the contract.
Deterministic responsibilities
- Query generation — template-based and seeded, so it is reproducible.
- Address extraction from page content.
- Mail-exchange validation and role-address classification.
- Suppression cross-checking.
- Queue claim semantics, idempotent expansion and stale-lock reclamation.
- Bandwidth budgeting and crawl throttling.
- Every count and confidence value shown on the review screen.
Decisions and tradeoffs
Mandatory human review before anything becomes a sending list
- Options considered
- Auto-import above a confidence threshold · Auto-import with post-hoc audit · Mandatory review, no automatic path
- Chosen
- Mandatory review, with no code path from discovery into sending
- Reason
- A confidence threshold is a number someone will raise under deadline pressure. Sending to a badly sourced list damages reputation for weeks and is not undoable.
- Cost and risk accepted
- Discovery cannot scale beyond the review capacity of the people doing it. That is a real throughput ceiling.
- Result
- Accepted deliberately. The review screen got the design investment instead, because review is the actual bottleneck.
Reimplement the paid scraping API rather than wrap or replace it
- Options considered
- Keep paying per request · Wrap a different vendor · Build a service exposing the same API shape
- Chosen
- Build AdFetch — a self-hosted service implementing the original API's request and response shapes
- Reason
- Callers already spoke that API. Keeping the contract identical meant the swap touched no calling code and could be reverted by changing one address.
- Cost and risk accepted
- A scraping service to operate — browser rendering, queuing, proxy egress and storage are all now ours to run.
- Result
- The paid dependency was removed without a migration. The same service now backs page fetching for discovery.
Self-hosted metasearch instead of a paid search API
- Options considered
- Keep the paid API · Self-host metasearch · Open-source harvester only
- Chosen
- Self-hosted metasearch primary, harvester fallback, paid backend still selectable
- Reason
- Per-query cost was suppressing exploration. People ran fewer searches and worked from worse data because each query had a price.
- Cost and risk accepted
- A service to run and keep working, and result quality that varies more than a commercial API's.
- Result
- Query cost stopped constraining research. The fallback chain covers the quality variance.
Enrichment background-only, never in the sending path
- Options considered
- Inline during message generation · On demand with a timeout · Background-only with tolerated absence
- Chosen
- Background-only, with every consumer required to handle a missing record
- Reason
- Inline enrichment makes an external data source a dependency of revenue. A remote slowdown becomes a sending outage.
- Cost and risk accepted
- Segmentation may run on incomplete data, and null-handling is spread across every consumer.
- Result
- An external provider cannot take sending down. Written as a hard constraint in the specification, not a preference.
Seed query generation by job identity
- Options considered
- Fresh randomization each run · Fixed query order · Randomized but seeded by job identity
- Chosen
- Seeded by job identity
- Reason
- Randomization improves coverage; unrepeatable randomization makes a retry a different job and makes provenance unauditable.
- Cost and risk accepted
- A retried job cannot discover anything the original could not.
- Result
- Retries are genuine retries. Generated queries are stored on the job for audit.
A database-driven queue rather than a message broker
- Options considered
- Dedicated message broker · In-memory queue · Database table with skip-locked claiming
- Chosen
- Database table with skip-locked claiming and a stale-lock reaper
- Reason
- The workload is low-rate and batch-shaped. A broker adds a component to operate and a second place for state to disagree with the database.
- Cost and risk accepted
- Claim polling load on the database, and locking semantics that have to be got right rather than inherited.
- Result
- One fewer system to run. Job state and queue state cannot diverge because they are the same store.
Challenges
The first queue implementation could strand work. A worker that died while holding claimed rows left them marked as processing forever. Nothing errored — the job simply never completed, and its counters stopped moving. Fixed with heartbeats and a reaper that reclaims locks older than a threshold, but the lesson generalizes: any claim mechanism needs an answer for the claimant dying, and that answer has to be designed rather than assumed.
Idempotency was harder than expected. Re-running a job initially duplicated queue rows. The correct fix was conflict handling scoped to rows already queued or processing — which required being precise about what "already in flight" means, rather than deduplicating on identity alone.
Bandwidth is a shared, finite budget. Discovery competes with other crawling consumers for a metered monthly allowance. Raising concurrency to make discovery faster silently starves the others. This became an explicit split with a safe burn rate rather than a per-service tuning decision.
Result quality varies more without a commercial API. Self-hosted metasearch sometimes returns little for a query the paid backend handled well. The fallback chain covers most of it, and the honest position is that we traded some consistency for the ability to explore freely — which was the right trade for how the tool is actually used.
Review is still the bottleneck, by design. Discovery can produce candidates faster than people can review them. Every proposal to relax that has been a proposal to auto-import under a different name.
Testing and validation
| Layer | What it covers | Evidence |
|---|---|---|
| Acceptance criteria | Reproducible query generation, idempotent expansion, no path from discovery to sending. | Written before implementation; each maps to a test. |
| Unit | Query expansion determinism, address extraction, role classification, validation. | Package test suites. |
| Integration | Job creation through to a reviewable result set against fixture pages. | Fixture-backed integration tests. |
| Concurrency | Parallel workers claiming from the same queue without overlap; stale-lock reclamation. | Tests that kill a worker mid-claim and assert reclamation. |
| Idempotency | Re-running a job produces no duplicate queue rows. | Regression test written when the duplication defect was found. |
| UAT | Lead operations staff running real research on the review screen. | Operator feedback drove the filter set. |
| Monitoring | Job progress, queue depth, error and dead-row counts, bandwidth burn rate. | Counters exported; bandwidth tracked against the monthly split. |
| Safety check | Feature flag defaults off, and there is no code path from discovery into the sending pipeline. | Verified by inspection at review. |
Results
| Measure | Before | After | How it is measured |
|---|---|---|---|
| Discovery effort | Hours of manual searching and transcription per list. | A job definition, then review of a filtered, validated result set. | The manual work that remains is review, which is the part that requires judgment. |
| Provenance | Existed only in the researcher's memory. | Job inputs and generated queries stored on the job record. | A list can be traced to the queries that produced it. |
| Reproducibility | Re-running produced a different result set. | Query generation seeded by job identity; retries reproduce the query set. | Verified by determinism tests. |
| Discovery cost | Per-query charges on a paid search API discouraged exploration. | Self-hosted metasearch primary, with fallback and the paid backend still selectable. | Cost no longer scales with how many questions someone asks. |
| Sending-path risk | Inline enrichment would have made an external source a sending dependency. | Background-only, with absence tolerated by every consumer. | Written as a hard constraint in the service specification. |
No conversion or list-performance figures are published here. Those belong to specific campaigns and clients, and I do not have permission to share them.
Evidence
Discovery job creation
Keywords, manual queries, seed domains and platform scoping options.
Placeholder — evidence pending sanitization review before publication. Results review screen
Filtering by validity, confidence, role type and suppression before list creation.
Placeholder — evidence pending sanitization review before publication. Enrichment queue state
Job progress with claimed, done, error and dead row counts.
Placeholder — evidence pending sanitization review before publication. Service specification excerpt
The hard constraints section, including background-only and tolerated absence.
Placeholder — evidence pending sanitization review before publication.
Lessons
What worked. Making the safety property structural. "No auto-import" as a policy is a sentence someone can decide to make an exception to. "There is no code path from discovery into sending" is not.
What failed. My first queue design had no answer for a worker dying mid-claim. It failed silently — the job simply never finished. Any claim mechanism needs its failure case designed, not assumed.
What changed. The review screen became the product. I started out thinking discovery was the hard part; the operators made it clear within a week that finding candidates was easy and deciding about them was the work.
What I would do differently. I would have made query generation seeded from the start. Retrofitting reproducibility meant reworking the expander and accepting that earlier jobs are not auditable.
What this demonstrates about AI-product leadership. This is a system where the obvious AI application — "use a model to find and qualify leads" — is worse than the deterministic implementation on accuracy, cost and speed. The genuinely valuable judgment was recognizing that, keeping the model on the assistive side, and spending the design effort on the human review step that actually gates quality.
Next milestone
- Scheduled refresh of stale enrichment records. The job type is specified but not implemented; records currently age without a refresh cycle.
- Segment-scoped enrichment jobs, so a job can target a segment rather than a whole list or an explicit record set.
- Review-assist ranking. Ordering the review queue so the most likely decisions surface first — assisting the reviewer, explicitly not replacing the review gate.