Skip to content

feat(leads): actually record who a lead is, once, across every campaign - #147

Merged
ralyodio merged 1 commit into
masterfrom
feat/wire-contacts
Jul 28, 2026
Merged

ralyodio merged 1 commit into
masterfrom
feat/wire-contacts

Conversation

@ralyodio

Copy link
Copy Markdown
Contributor

The contacts table from #140 shipped empty and stayed empty — 0 rows. Discovery found people and discarded them every run, leaving the source of truth as a schema with nothing in it.

Two blockers, both in the schema I wrote

Email was required, and was the key. That assumed every person arrives with an address. Person discovery does the reverse — a directory gives a name, a title, and a LinkedIn profile, and the address is what the pipeline then goes looking for. Those people couldn't be stored at all.

Identity is now:

  • email:… when there's an address — two records with the same address are the same person by definition
  • name:…@company otherwise — weaker, but the alternative is a fresh row for the same human every run

Maintained by trigger, not generated. Finding an address for a known name has to move that row from the name form to the email form — exactly the transition a generated column can't make.

Merge fills gaps, never overwrites

A scraped company name must not replace one a human typed. Every field records its source; a better-sourced value wins, and the loser is kept in alternates rather than dropped — "we saw something else" is information, and a wrong overwrite is otherwise unrecoverable.

Socials merge per network, so a run finding only GitHub doesn't discard a known LinkedIn.

People without addresses are recorded

The case that motivated the whole thing: a directory publishes the name and withholds the email. Waiting for an address means rediscovering the same person forever.

Checks

  • tsc --noEmit clean
  • 933/933 tests pass, 9 new covering the identity key — including that it changes form when an address is finally found
  • production build compiles
  • migration applied to prod

🤖 Generated with Claude Code

The contacts table shipped empty and stayed empty. Discovery found people
and threw them away on every run, which left the source of truth it was
built to be as a schema with nothing in it.

Two things had to change before anything could be written.

Email was required and was the key, which assumed every person arrives
with an address. Person discovery does the reverse: a directory gives a
name, a title and a LinkedIn profile, and the address is what the pipeline
then goes looking for. Those people could not be stored at all. Identity
is now the email when there is one — two records with the same address are
the same person by definition — and the normalised name and employer when
there is not. Weaker, but the alternative is a fresh row for the same human
every run.

The key is maintained by trigger rather than generated, because finding an
address for a known name has to move that row from the name form to the
email form, which is exactly the transition a generated column cannot make.

Merging fills gaps and does not overwrite. A scraped company name must not
replace one a human typed, so every field records where it came from and a
better-sourced value wins; the loser is kept in `alternates` rather than
dropped, because "we saw something else" is information and a wrong
overwrite is otherwise unrecoverable. Socials merge per network, so a run
that finds only GitHub does not discard a known LinkedIn.

People are recorded even when no address was found, which is the case that
motivated all of it: a directory publishes the name and withholds the
email, and waiting for an address means rediscovering the same person
forever.

isContactBlocked returns false when the lookup itself fails. A failed read
is not permission to send, but neither is it grounds to silently block a
campaign — the caller's own suppression checks still run.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown

vu1nz Security Review

0 finding(s) in PR #?

No security issues found.

@ralyodio
ralyodio merged commit 42d8973 into master Jul 28, 2026
8 checks passed
@ralyodio
ralyodio deleted the feat/wire-contacts branch July 28, 2026 09:42
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant