feat(leads): price a run, cap its variable cost, and give it a real funnel - #140
Merged
Merged
Conversation
…unnel Four pieces of the same thing: making lead generation a feature that can be sold, measured, and deduplicated. Pricing is measured rather than guessed. At the per-tick caps a run costs about 4.9c at the ceiling and nearer 2c in ordinary use, of which search is roughly four fifths and the model under a fifth — pricing off AI cost alone would have undercharged by about five times. Three credits is 15c at rack and 7.5c on the deepest pack, so it keeps a margin at every tier; two would have been exactly break-even for anyone on the largest pack. It is charged when a tick spends, not per tick. The cron fires every fifteen minutes, so a flat per-tick charge would bill an idle campaign 288 credits a day for doing nothing. The search-based contact fallback now has a per-run ceiling like every other stage. It is the most variable cost in a tick, because it scales with how many prospects publish no address. The funnel reports what happened rather than what a benchmark predicts, at project, campaign and run level. Rates are withheld below twenty sends: one reply in three is not a thirty-three percent reply rate, and drawing it as one invites a decision the sample cannot support. Close rate divides by replies rather than by everyone contacted, because a deal comes out of a conversation. Nothing could previously record an outcome. The statuses existed and the UI rendered them, but no code ever moved a prospect to replied or won, so a measured reply rate would have been structurally zero forever. Marking one is now possible, which is what makes the funnel worth showing. outreach_contacts is the source of truth for who someone is. Prospects are unique per project, so the same person became a separate row in every project that found them, and nothing noticed when two campaigns were about to email them in the same week. Discovery merges into a contact and never overwrites: a scraped company name must not replace one a human typed, and a later conflicting value is kept in `alternates` rather than discarded. The read policy qualifies the outer column. Written as `m.organization_id = organization_id` the bare name resolves to the subquery's own table, the predicate is a tautology, and every member of any organization can read every contact in the table. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
vu1nz Security Review0 finding(s) in PR #? No security issues found. |
This was referenced Jul 28, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Four pieces of the same thing: making lead generation sellable, measurable, and deduplicated.
Pricing — measured, not guessed
At the per-tick caps (5 SERP queries, 8 researched, 5 drafts):
Search is ~4/5 of the cost; the model is under 1/5 — pricing off AI cost alone would have undercharged ~5×.
LEAD_RUN_CREDITS = 3→ 15¢ at rack, 7.5¢ on the deepest pack, so margin holds at every tier. Two credits would have been exactly break-even for anyone on the 100-scan pack.Charged when a tick spends, not per tick. The cron fires every 15 minutes — a flat per-tick charge would bill an idle campaign 288 credits/day for doing nothing.
Cost ceiling on the contact fallback
The search-based fallback (#133) scales with how many prospects publish no address, making it the most variable cost in a run. Now capped at 10 lookups per tick, shared across every prospect the tick researches.
Funnel: project, campaign, and run level
Reports what happened, not what a benchmark predicts.
Outcomes were unrecordable
The
replied/wonstatuses existed in the schema and rendered in the UI, but no code ever set them — so a measured reply rate would have read 0% forever.markLeadOutcomeActionfixes that, which is what makes the funnel worth showing at all.outreach_contacts — one record per person
outreach_prospectsis unique per(project_id, channel, target_key), so the same person became a separate row in every project that found them, with separate details and nothing noticing when two campaigns were about to email them the same week.Discovery merges, never overwrites: a scraped company name must not replace one a human typed, and a later conflicting value lands in
alternatesrather than being discarded. Carriesniche,industry,company_site,source_url, andcountryfor segmentation and provenance, plus an org-widedo_not_contact.RLS bug caught pre-apply: written as
m.organization_id = organization_id, the bare name resolves to the subquery's own table — the predicate becomes a tautology and every member of any org can read every contact. Now qualified.Checks
tsc --noEmitclean🤖 Generated with Claude Code