por Equipo de Cierre

Cómo construimos una bitácora de IA que un colegio profesional puede aceptar

La pregunta que más nos hicieron en los primeros 30 días después del lanzamiento no fue de un cliente. Fue del abogado de un cliente.

"¿Cómo sé que la bitácora que me muestras no fue editada después de los hechos?"

La respuesta corta: no sabes. Hasta ahora.

Esta es una nota técnica sobre cómo Cierre pasó de "guardamos un log de cada acción de IA" a "podemos demostrarle a un colegio profesional, semanas después, que nadie modificó ese log" — y por qué esa diferencia importa para cualquier firma regulada que esté pensando en automatizar su prospección.

La trampa del log "auditable"

Casi todas las herramientas de SaaS tienen un activity log. Algunas lo llaman "audit log" para sonar más serias. La diferencia operativa, en la mayoría de los casos, es de cero. Son filas en una tabla de Postgres a las que cualquier persona con acceso UPDATE puede ajustar.

En productos no regulados eso es suficiente. Si un usuario sospecha de una entrada y la herramienta dice "no la modificamos", normalmente le creemos. El costo de auditarlo más a fondo no compensa.

En sectores regulados — derecho, medicina, finanzas — la calculadora cambia. Si el Colegio de Abogados de Florida recibe una queja sobre un mensaje que envió tu firma, la pregunta no es "¿qué dice tu bitácora?". La pregunta es "¿cómo sabemos que tu bitácora no fue editada después de la queja?"

Si la respuesta es "confíen en nuestra base de datos", la auditoría termina.

Cuatro decisiones, una garantía

Diseñamos la bitácora de Cierre para que la respuesta a esa pregunta sea, literalmente, "si alguien la editó, la cadena se rompe y la próxima raíz Merkle del día expone la inconsistencia". Cuatro decisiones de arquitectura sostienen esa frase.

1. La escritura es sincrónica. Si la bitácora falla, la acción falla.

La regla más simple y la más difícil de mantener cuando aumenta el tráfico: ninguna acción de IA devuelve un resultado al usuario antes de que la fila correspondiente esté grabada en la base de datos.

// Pseudocódigo, simplificado
export async function draftOutreach(...) {
  const result = await callClaude(...);
  await logComplianceEvent({ ... }); // ← await es la garantía
  return result;
}

La tentación de hacer este await un fire-and-forget es enorme. Una llamada DB extra por cada acción es latencia visible. Pero si el log es la diferencia entre cumplimiento y queja, no es opcional. Lo modelamos como parte de la transacción, no como un side effect.

2. Cada fila apunta a la fila anterior

Cada fila de compliance_logs incluye dos columnas adicionales: prev_row_hash y row_hash.

  • prev_row_hash: el row_hash de la última fila escrita por el mismo tenant.
  • row_hash: sha256(prev_row_hash | event_type | lead_id | model | prompt_hash | output_preview | timestamp | metadata_canonico).

La consecuencia: alterar cualquier campo de cualquier fila histórica cambia su row_hash. Como el prev_row_hash de la fila siguiente fue calculado con el valor original, la cadena queda rota desde el punto de la alteración hasta el final.

Una fila no se puede "reescribir" sin reescribir todas las filas posteriores. Y como cada fila incluye un timestamp, reescribir el pasado en orden tampoco es trivial: hay que coordinar la reescritura con un downtime que las copias de seguridad y los logs de aplicación van a delatar.

3. Postgres advisory lock por tenant

Si dos acciones simultáneas leen el mismo prev_row_hash y luego ambas escriben con ese valor como predecesor, la cadena se bifurca. Eso es estructuralmente equivalente a una reescritura — ambas filas son válidas individualmente pero ninguna puede demostrar quién es el verdadero "sucesor".

La solución es serializar las escrituras dentro de cada tenant. Postgres tiene pg_advisory_xact_lock(bigint) exactamente para esto: un lock cooperativo, atado a la transacción, sin tablas de lock dedicadas.

await db.$transaction(async tx => {
  await tx.$executeRaw`SELECT pg_advisory_xact_lock(${tenantLockKey})`;
  const prev = await tx.complianceLog.findFirst({
    orderBy: [{ createdAt: 'desc' }, { id: 'desc' }],
    where: { tenantId },
  });
  // … computar row_hash, insertar
});

El id desc como tie-breaker no es decorativo: a resolución de milisegundo, dos eventos pueden compartir createdAt, y la lectura del predecesor tiene que ser determinística aun en ese caso. Lo aprendimos con un test que ejecutaba 200 escrituras en paralelo en el mismo tenant.

4. Una raíz Merkle diaria

La cadena por fila resuelve la integridad interna: si tienes la base de datos completa, puedes verificar que ninguna fila fue alterada porque la cadena se rompería.

La raíz Merkle resuelve la integridad externa: cada noche a las 00:30 UTC, un cron computa el árbol Merkle binario de todos los row_hash del día anterior y persiste la raíz en una tabla aparte (merkle_roots). El tenant puede publicar esa raíz donde quiera — un correo a sí mismo, un commit en un repo, una notaría — y semanas después demostrar que cualquier row_hash específico participó en esa raíz, sin compartir la cadena entera.

Es la misma técnica que usa Bitcoin para los headers de bloque y que usan los certificate transparency logs. No la inventamos; la aplicamos a un dominio donde nadie la había aplicado todavía.

Las cosas que no hicimos

Por cada decisión que tomamos, hubo una alternativa "más fancy" que rechazamos. Vale la pena nombrarlas:

  • Firma asimétrica por fila. Tentadora — daría no-repudio por evento. Pero la gestión de claves es un negocio aparte, y para el caso de uso (mostrar la cadena a un revisor en una audiencia) la verificación con clave pública es overkill. La cadena + Merkle es suficiente y no agrega operaciones manuales.
  • Append-only desde Postgres mismo (con triggers). Posible pero frágil: cualquier superuser puede deshabilitar el trigger. Preferimos que la inmutabilidad esté en la lógica de aplicación + en la cadena, donde un editor manual rompe la verificación pública en lugar de hacerla en silencio.
  • Blockchain. No. La latencia, el costo y la complejidad operativa no se justifican para un sector donde la confianza viene del registro local + un colegio profesional, no de un consenso global.

¿Qué cambia en la práctica?

Para un solo abogado de inmigración prospectando en español, la diferencia es invisible. La app se ve igual; los mensajes se redactan igual; el registro se ve igual.

La diferencia es la respuesta a una pregunta que aparece después: cuando un cliente, un colegio profesional o un auditor pide la bitácora.

"Aquí está la bitácora. Aquí están las raíces Merkle diarias publicadas en mi propio dominio. Si crees que algo fue alterado, computamos la cadena en vivo y la verificamos."

Si la respuesta de la herramienta de la competencia a esa misma pregunta es "confía en nuestro Postgres", la conversación se acaba antes de empezar.


Si trabajas en un sector regulado y estás pensando en automatizar tu prospección, conversemos. La auditoría no debería ser la razón por la que decides no automatizar — debería ser la razón por la que sí.

Crea una cuenta o revisa los planes. Si tienes preguntas técnicas específicas, escríbenos.