Blog / Ingeniería / No.007

Una lista de exclusión que funciona entre cuentas

Una baja global es una frase en una política y una semana de ingeniería. Claves por dominio, una verificación en cada etapa, un compare-and-set al entregar, y filas que nadie borra.

Mohamad · Knockwire9 de junio de 20269 min de lecturaIngeniería

Este texto es una traducción del original en inglés.

Una lista de exclusión es fácil de prometer. "Dígannos que paremos y nunca volveremos a contactarlo" es una frase en una página de políticas y más o menos una semana de ingeniería, casi toda en lugares que usted no adivinaría. El esquema es una tarde. La dificultad es que una lista de exclusión tiene que sostenerse entre cuentas, tiene que alcanzar hacia atrás el trabajo que ya está en curso, y tiene que poder demostrar después qué fue lo que detuvo.

01El esquema, y el índice que hace el trabajo

La exclusión se indexa por el dominio registrable. No por el nombre de host, ni por la dirección de correo individual. Quien nos pide que paremos en acme.com no aceptó saber de nosotros en www.acme.com ni en careers.acme.com. Normalizamos con la lista de sufijos públicos, pasamos a minúsculas, quitamos un www inicial y convertimos los nombres internacionalizados a su forma punycode antes de guardar, porque dos grafías de una empresa en la tabla es funcionalmente lo mismo que ninguna entrada.

  • domain: el dominio registrable normalizado, y la única columna contra la que se compara alguna vez.
  • tenant_id: la cuenta a la que pertenece la exclusión, con un valor centinela para toda la plataforma.
  • reason: texto libre, obligatorio, sin valor por defecto. Si nadie puede decir por qué, no entra.
  • source: cómo llegó. Una respuesta, una solicitud por formulario, una acción de un operador, una carga masiva, un requerimiento legal. Estos se comportan distinto después, y un solo booleano lo tira todo.
  • evidence: un enlace al mensaje o al ticket que lo causó.
  • created_by y created_at: quién, y cuándo.

El índice único sobre cliente y dominio es lo que vuelve seguro volver a agregar. Las escrituras pasan por un insert que actualiza motivo, origen y marca de tiempo ante una clave duplicada en lugar de fallar, así que un operador puede pegar la misma lista de cuarenta dominios dos veces y obtener cuarenta filas y ningún error. Que volver a agregar sea idempotente importa más de lo que suena. La alternativa es que la gente lea antes de escribir, y leer y luego escribir es justamente la carrera que el índice existe para eliminar.

TrampaUna exclusión para toda la plataforma es tentadora de modelar como un tenant_id en NULL. En MySQL un índice único trata cada NULL como distinto de todos los demás NULL, así que ese índice aceptará con gusto el mismo dominio global cien veces. Use un identificador de cliente centinela, o una columna de alcance separada que nunca sea nula. Un índice solo protege lo que puede comparar.

02Verifique en cada etapa, no en la entrada

El proceso es una secuencia con tiempo real entre los pasos. Una lectura llega de una fuente, supera los filtros y pasa a calificada, se investiga, se le escribe un borrador, espera a una persona, y solo entonces se entrega un envío. Pasan días entre el primer paso y el último. Una empresa puede quedar excluida en cualquier punto dentro de esa ventana.

Lo que hace que una verificación de exclusión en el descubrimiento sea casi inútil por sí sola. Si la entrada es la única verificación, una empresa que se da de baja el martes igual tiene un borrador esperando en la fila de aprobación desde el lunes, y ese borrador se va a enviar. La lista era correcta, la entrada estaba ahí, y el mensaje salió igual. La falla no está en los datos.

  • En la entrada, para que un dominio excluido no llegue siquiera a ser una lectura.
  • Antes de investigar, porque investigar gasta un presupuesto y no tiene sentido gastarlo en una empresa que no se puede contactar.
  • Antes de redactar, por la misma razón y con un costo mayor asociado.
  • Cuando se dibuja la fila de aprobación, mostrado como bloqueado y no oculto, para que la persona vea que algo se detuvo en lugar de preguntarse a dónde se fue.
  • Inmediatamente antes de la entrega, como la última instrucción que se ejecuta antes de que salga la petición.

Solo la última de esas sostiene la corrección. Las otras cuatro ahorran trabajo y evitan que una persona gaste atención aprobando algo que nunca podrá salir. La verificación final tiene que leer la base de datos y no una caché por ronda, porque una caché queda desactualizada exactamente por el intervalo en que vive el error. Es una búsqueda indexada contra una tabla de unos pocos miles de filas, y no vale la pena optimizarla.

03Dos procesos, una empresa

El caso de concurrencia sobrevive a la revisión porque cuesta imaginarlo. Dos procesos toman la misma empresa: un arrendamiento que expiró sin que el primero se diera cuenta, o un mismo negocio llegando de dos fuentes de descubrimiento con identificadores distintos que normalizan al mismo dominio. Los dos verifican la exclusión. Los dos pasan. Los dos escriben un borrador. La fila de aprobación ahora tiene dos mensajes para una empresa, y rechazar uno no le hace nada al otro.

Una transacción más larga no arregla esto, y verificar dos veces tampoco. Una restricción de unicidad sí. La fila del borrador carga un índice único sobre cliente, dominio y campaña, así que el segundo insert pierde en la base de datos y no en la lógica de la aplicación, y el proceso perdedor trata la clave duplicada como un resultado ordinario en lugar de un error. El arrendamiento carga un claimed_at y un identificador de proceso, así que un trabajo atorado es visible en lugar de simplemente tardío.

La entrega usa la misma idea desde el otro extremo. El paso de envío mueve el borrador de aprobado a enviando con el estado actual nombrado en la cláusula WHERE, y continúa solo si esa actualización afectó exactamente una fila. Si un evento de exclusión pasó la fila a detenida un segundo antes, la actualización no coincide con nada, el envío no ocurre, y ningún código de aplicación tuvo que razonar sobre el orden. Ese compare-and-set es lo que hace que la exclusión esté libre de carreras en vez de ser correcta casi siempre.

Si una exclusión solo filtra lo que viene después, no ha hecho lo que prometió.

04La exclusión tiene que alcanzar hacia atrás

Marcar un dominio como excluido es un evento, no un filtro. Cuando aterriza cancela la investigación en cola para ese dominio, mueve cada borrador de ese dominio a detenido con el identificador de la exclusión anotado como motivo, y saca esas filas de la fila de aprobación. El trabajo ya hecho es el problema entero. Un filtro aplicado solo a lecturas futuras deja intactas las terminadas, y las terminadas son las únicas lo bastante cerca de enviarse.

Las filas detenidas después se quedan. No las borramos, y esta es la parte que se discute, porque borrar se siente como la opción respetuosa. Destruye la única evidencia de que algo se detuvo. Cuando una empresa escribe para preguntar si la hemos estado contactando, una respuesta honesta es específica: se escribió un borrador el día nueve, se detuvo el día once cuando el dominio quedó excluido, nunca se entregó, y aquí está el texto. Esa respuesta necesita que la fila exista.

Así que la regla se sostiene en las dos direcciones: nunca borramos un registro de rechazo. Cada lectura rechazada, cada borrador detenido y cada entrada de exclusión se conservan, y el campo de motivo es obligatorio en los tres. Un filtro que no se puede auditar es apenas una lista, y una lista es lo que ya dice tener todo el mundo.

05Una lista de clientes no es una lista de exclusión

El último error parece un acto de orden. Un cliente ya tiene una lista de sus clientes actuales y no quiere que se les haga prospección, así que alguien carga esa lista en la tabla de exclusión. Funciona una semana, y luego la información se pierde.

Son hechos distintos sobre una empresa. Excluida significa que una persona nos pidió parar. Manda sobre todo lo demás, no expira y aplica sea cual sea el mensaje. Ya es cliente significa no trate esto como un prospecto nuevo, que no es la misma instrucción: el equipo de cuenta bien puede querer una conversación de expansión, y si la quiere, la tabla de exclusión ahora está rechazando en nombre de alguien que nunca pidió eso. Una exclusión de competidores es una tercera cosa. Una pausa pedida por ventas mientras un trato está en curso es una cuarta, y a diferencia de las demás tiene fecha de término.

Separadas, la pregunta "por qué nunca se contactó a esta empresa" tiene una respuesta de una palabra que puede producir una consulta. Fundidas en una sola tabla detrás de un motivo de texto libre, la respuesta exige que alguien lea prosa e infiera, y en menos de un mes la columna de motivo está juntando valores como "cliente, no quitar". Tablas separadas, semánticas separadas, y una sola verificación compartida en el punto de entrega que las consulta todas.

La entrega, por ahora, significa el propio formulario de contacto público de la empresa objetivo. El envío de correo está planeado para el cuarto trimestre de 2026 y no está construido, lo que simplifica discretamente todo lo anterior: hay un solo canal que excluir. Cuando haya dos, cada verificación descrita aquí corre en los dos, y alcanzar hacia atrás es la parte que necesitará más cuidado.

Knockwire lee internet, descarta las empresas que nunca le van a comprar y toca la puerta de las que sí. Ejecútelo en su propio dominio y lea sus propios rechazos.

Probarlo en mi sitioTodos los textos