A suppression list is easy to promise. "Tell us to stop and we will never contact you again" is one sentence on a policy page and roughly a week of engineering, most of it in places you would not guess. The schema is an afternoon. The difficulty is that a suppression list has to hold across accounts, has to reach backwards into work already in progress, and has to be able to prove afterwards what it stopped.
01The schema, and the index that does the work
Suppression is keyed on the registrable domain. Not the hostname, not the individual email address. Somebody who asks us to stop at acme.com has not agreed to hear from us at www.acme.com or at careers.acme.com. We normalise through the public suffix list, lowercase, drop a leading www, and convert internationalised names to their punycode form before storing, because two spellings of one company in the table is functionally the same as no entry at all.
- domain: the normalised registrable domain, and the only column ever matched against.
- tenant_id: the account the suppression belongs to, with a sentinel value for platform wide.
- reason: free text, required, no default. If nobody can say why, it does not go in.
- source: how it arrived. A reply, a request through a form, an operator action, a bulk import, a legal request. These behave differently later, and a single boolean throws all of it away.
- evidence: a link to the message or the ticket that caused it.
- created_by and created_at: who, and when.
The unique index on tenant and domain is what makes re-adding safe. Writes go through an insert that updates reason, source and timestamp on a duplicate key rather than failing, so an operator can paste the same list of forty domains twice and get forty rows and no error. Idempotent re-adding matters more than it sounds. The alternative is that people read before they write, and read-then-write is exactly the race the index exists to remove.
02Check at every stage, not at intake
The pipeline is a sequence with real time between the steps. A Read arrives from a source, clears the gates and becomes Qualified, gets researched, gets a Draft written, waits for a human, and only then is a Knock delivered. Days pass between the first step and the last. A company can become suppressed at any point inside that window.
Which makes a suppression check at discovery close to worthless on its own. If intake is the only check, a company that opts out on Tuesday still has a Draft sitting in the approval queue from Monday, and that Draft will send. The list was correct, the entry was there, and the message went anyway. The failure is not in the data.
- At intake, so a suppressed domain never becomes a Read at all.
- Before research, because research spends a budget and there is no sense spending it on a company that cannot be contacted.
- Before drafting, for the same reason and with a larger cost attached.
- When the approval queue renders, shown as blocked rather than hidden, so the person sees that something was stopped instead of wondering where it went.
- Immediately before delivery, as the last statement executed before the request leaves.
Only the last of those is load-bearing for correctness. The other four save work and stop a person spending attention on approving something that can never go. The final check has to read the database rather than any per-pass cache, because a cache is stale by exactly the interval the bug lives in. It is one indexed lookup against a table with a few thousand rows, and it is not worth optimising.
03Two workers, one company
The concurrency case survives review because it is hard to picture. Two workers pick up the same company: a lease that expired without the first worker noticing, or one business arriving from two discovery sources under different ids that normalise to the same domain. Both check suppression. Both pass. Both write a Draft. The approval queue now holds two messages for one company, and rejecting one of them does nothing to the other.
A longer transaction does not fix this, and checking twice does not either. A unique constraint does. The Draft row carries a unique index on tenant, domain and campaign, so the second insert loses in the database rather than in application logic, and the losing worker treats a duplicate key as an ordinary outcome instead of an error. The lease carries a claimed_at and a worker id, so a stuck job is visible rather than merely late.
Delivery uses the same idea from the other end. The send step moves the Draft from approved to sending with the current state named in the WHERE clause, and continues only if that update affected exactly one row. If a suppression event flipped the row to halted a second earlier, the update matches nothing, the send does not happen, and no application code had to reason about the ordering. That compare-and-set is what makes suppression race-free rather than usually correct.
04Suppression has to reach backwards
Marking a domain suppressed is an event, not a filter. When it lands it cancels queued research for that domain, moves every Draft for that domain to halted with the suppression id recorded as the reason, and pulls those rows out of the approval queue. The work already done is the entire problem. A filter applied only to future reads leaves the finished ones intact, and the finished ones are the only ones close enough to send.
The halted rows then stay. We do not delete them, and this is the part that gets argued about, because deleting feels like the respectful option. It destroys the only evidence that anything was stopped. When a company writes to ask whether we have been contacting them, an honest answer is specific: a Draft was written on the ninth, it was halted on the eleventh when the domain was suppressed, it was never delivered, and here is the text of it. That answer needs the row to exist.
So the rule holds in both directions: we never delete a refusal record. Every turned down read, every halted Draft and every suppression entry is kept, and the reason field is required on all three. A filter you cannot audit is just a list, and a list is what everybody already claims to have.
05A customer list is not a suppression list
The last mistake looks like tidying up. A tenant already holds a list of its existing customers and does not want them prospected, so somebody loads that list into the suppression table. It works for a week, and then the information is gone.
These are different facts about a company. Suppressed means a person asked us to stop. It outranks everything, it does not expire, and it applies whatever the message is. Already a customer means do not treat this as a new prospect, which is not the same instruction: the account team may well want an expansion conversation, and if they do, the suppression table is now refusing on behalf of somebody who never asked for that. A competitor exclusion is a third thing. A hold from sales while a deal is in progress is a fourth, and unlike the others it has an end date.
Kept apart, the question "why was this company never contacted" has a one word answer that a query can produce. Merged into one table behind a free text reason, the answer requires somebody to read prose and infer, and within a month the reason column is collecting values like "customer, do not remove". Separate tables, separate semantics, and one shared check at the point of delivery that consults all of them.
Delivery, for now, means the target's own public contact form. Email sending is planned for Q4 2026 and is not built, which quietly simplifies all of the above: there is one channel to suppress. When there are two, every check described here runs on both, and the reaching backwards is the part that will need the most care.