By Angel Kurten15 min read

Qué Era Cierto vs. Qué Sabíamos: Datos Bitemporales para Developers

Los hechos tienen dos relojes: cuándo fueron ciertos y cuándo te enteraste. Datos bitemporales con un ejemplo, cuatro consultas y los bugs que me enseñaron las reglas.

El 3 de octubre, el asistente de Toche le dijo a un hogar que debía 4.200.000 pesos de la Visa. El 9 de octubre la respuesta correcta era 3.895.000. Alguien preguntó si el asistente se había equivocado.

La respuesta honesta es "depende de qué estés preguntando". Y si tu base de datos no puede distinguir entre las preguntas posibles, tampoco puede responderla.

Es el mismo hogar y la misma deuda del post de lanzamiento de Keel, con una diferencia: allí el extracto de octubre llegaba a tiempo, y aquí llega tarde y con un error, como pasa con los extractos reales. En orden:

  • 1 de septiembre. Llega la foto de deudas de septiembre: la Visa debe 4.200.000.
  • 1 de octubre. El banco cierra el corte de octubre. El saldo ese día es otro, pero nadie en el sistema lo sabe todavía.
  • 3 de octubre. El hogar le pregunta al asistente cuánto debe. El asistente responde 4.200.000, que es lo único que sabe.
  • 6 de octubre. Llega el extracto de octubre, con cinco días de retraso: 3.870.000 desde el 1 de octubre.
  • 9 de octubre. El banco reexpide el extracto. Había omitido un cargo de seguro de 25.000. El saldo correcto desde el 1 de octubre era 3.895.000.

Ahora pregunta "¿cuánto debíamos el 3 de octubre?". Hay tres respuestas, y las tres son correctas para su pregunta:

  • 3.895.000 es lo que era cierto el 3 de octubre, según todo lo que sabemos hoy.
  • 4.200.000 es lo que el sistema creía el 3 de octubre, y lo que vio el asistente cuando respondió. La cifra era incorrecta, pero el asistente no tuvo la culpa: respondió bien con lo que había.
  • 3.870.000 es lo que habrías creído el 7 de octubre, entre el extracto tardío y la corrección.

Una tabla con una columna balance y un updated_at solo responde una pregunta: qué creemos ahora sobre ahora. Todo lo demás lo sobrescribió el último UPDATE.

Dos relojes

Cada hecho tiene dos tiempos, y casi todos los sistemas guardan uno solo.

Tiempo válido (valid time, o tiempo del mundo): cuándo fue cierto el hecho en el mundo. El saldo de 3.895.000 es válido desde el 1 de octubre, aunque nadie lo supiera hasta el 9.

Tiempo de transacción (transaction time, o tiempo del sistema): cuándo lo supo tu sistema. El 3.895.000 se registró el 9 de octubre.

Un modelo bitemporal guarda los dos. No es una idea nueva: SQL:2011 estandarizó las tablas con application-time period (tiempo válido) y las system-versioned (tiempo de transacción), MariaDB soporta ambas, y bases como XTDB están construidas alrededor de esta idea. Lo que sí es nuevo es la cantidad de software que ahora necesita la segunda dimensión. Un agente de IA toma decisiones, y cuando alguien audita una decisión la pregunta no es "qué era cierto" sino "qué sabía el agente cuando decidió".

En concreto, cada hecho lleva cuatro tiempos:

ColumnaRelojSignificado
valid_frommundoCuándo empezó a ser cierto
valid_tomundoCuándo dejó de serlo (null = sigue siendo cierto)
recorded_atsistemaCuándo nos enteramos de que empezó
closed_atsistemaCuándo nos enteramos de que terminó

Las tablas system-versioned guardan una versión de la fila por cada cambio, cada una con su propio intervalo de transacción. Si lo implementas a mano con una fila por hecho, closed_at es la columna que más se olvida. Sin ella sabes cuándo apareció cada versión, pero no cuándo supiste que la anterior ya no valía, y eso es justo lo que necesitas para reconstruir "lo que creíamos el 3 de octubre". Más abajo vas a ver que una sola columna tampoco alcanza si confías en ella para leer el pasado.

Los tres hechos, guardados

Corrí el escenario contra Keel, la capa de contexto que publiqué la semana pasada, porque su store es bitemporal y así puedo mostrarte salidas reales en lugar de tablas inventadas. Son tres eventos, cada uno con su occurred_at (tiempo válido) y su recorded_at (tiempo de transacción). Esta es la historia del saldo después de ingerirlos:

value      valid_from   valid_to     recorded_at        closed_at
4.200.000  09-01 08:00  10-01 00:00  09-01 08:00        10-06 10:00
3.870.000  10-01 00:00  10-01 00:00  10-06 10:00        10-09 16:00
3.895.000  10-01 00:00  (abierto)    10-09 16:00        (abierto)

Hay tres cosas que mirar.

Nada se sobrescribió. El saldo de septiembre sigue ahí. Se cerró en valid_to = 1 de octubre porque ese día dejó de ser cierto, y su closed_at es el 6 de octubre porque ese día nos enteramos.

La corrección es un intervalo vacío. El 3.870.000 va del 1 al 1 de octubre. En el tiempo del mundo nunca fue cierto. Pero existió en el tiempo del sistema, entre el 6 y el 9 de octubre, y alguien pudo haber decidido algo con él. Esta es una decisión de Keel: un cambio y una corrección se guardan igual, como una fila nueva que cierra la anterior, y lo que los distingue son los tiempos. Una corrección empieza en el mismo instante que el valor que corrige. El modelo clásico de SQL:2011 lo hace al revés: conserva el intervalo válido de la fila errónea y le cierra el intervalo de transacción. Las dos formas responden las mismas preguntas.

El orden de llegada no importa. Probé los seis órdenes posibles de los tres eventos y todos producen los mismos valores, los mismos valid_to y los mismos closed_at. Más abajo explico por qué eso es más difícil de lo que parece.

Las cuatro consultas

Con los cuatro tiempos, cada pregunta es un filtro distinto:

tools.context("debt:visa", as_of="2026-10-03 12:00 UTC")
# 3.895.000: lo que era cierto el 3 de octubre, sabiendo lo que sabemos hoy
 
tools.context("debt:visa", as_of="2026-10-03 12:00 UTC", as_known_at="2026-10-03 12:00 UTC")
# 4.200.000: lo que creíamos el 3 de octubre. Lo que vio el asistente
 
tools.context("debt:visa", as_of="2026-10-03 12:00 UTC", as_known_at="2026-10-07 12:00 UTC")
# 3.870.000: lo que creíamos el 7 de octubre sobre el 3
 
tools.context("debt:visa", as_of="2026-09-15 12:00 UTC")
# 4.200.000: lo que era cierto el 15 de septiembre

La consulta de tiempo válido es la conocida: valid_from <= t < valid_to. La de tiempo de transacción es la interesante, y funciona en dos pasos. Primero reconstruyes el mundo como lo veías en known_at: solo los hechos registrados hasta entonces, y cada uno cerrado solo con lo que ya sabías en ese momento. Después le aplicas el filtro de tiempo válido. El 3 de octubre, el hecho de 4.200.000 ya estaba registrado y ningún sucesor ni cierre era conocido todavía (llegaron el 6), así que para ese momento seguía abierto, y el filtro as_of lo devuelve.

La frase clave es "cerrado solo con lo que ya sabías". La primera versión de Keel la implementaba de la forma obvia, y estaba mal. Esa historia es la regla 5.

Cinco reglas que aprendí rompiéndolas

1. Cierra, no sobrescribas

Un UPDATE destruye exactamente la información que hace falta para auditar. Cada cambio de valor es una fila nueva y cierra la anterior. Si te preocupa el espacio, guarda valores cortos y punteros al sistema de registro, no documentos.

2. El tiempo de transacción viene de la fuente, no de tu reloj de ingesta

Keel ponía recorded_at con la hora en que procesaba el evento. Funciona, hasta que reconstruyes el grafo reproduciendo el log, que es la promesa de cualquier sistema basado en eventos. Al reproducir, todos los hechos quedan "registrados" hoy, y la pregunta "qué sabíamos el 3 de octubre" deja de tener respuesta. En el benchmark del paper de Keel, antes de este arreglo, las 900 ventanas de llegada tardía inyectadas en uno de los proyectos daban una respuesta incorrecta.

La solución es que el evento declare su recorded_at cuando la fuente lo sabe: el extracto llegó el 6, aunque lo procesemos el 22. Si el evento no lo declara, se usa la hora de ingesta, y eso queda documentado como una pérdida de información, no como un buen valor por defecto.

Ojo: necesitas un tercer reloj, aunque nunca lo consultes. Keel guarda changed_at, la hora real de cada escritura, porque un agente que pregunta "qué cambió desde mi última corrida" necesita un cursor que siempre avance, incluso cuando llega un evento con un recorded_at antiguo. Ese reloj es pura contabilidad. Si lo dejas entrar en las consultas de negocio, vuelves al problema anterior.

3. Ordena por lo que el hecho contiene, nunca por el orden de llegada

Cuando llega un hecho, hay que meterlo en su lugar en la historia: cerrar el que estaba vigente en su valid_from y, si ya existe uno posterior (porque llegó antes), cerrarse a sí mismo donde empieza ese. La versión ingenua ordena por valid_from y deshace los empates por orden de llegada. Y los empates existen: dos cambios en el mismo segundo, o una corrección con el mismo valid_from que el valor que corrige.

En uno de los proyectos reales de Jira del benchmark, un issue pasó dos veces al mismo estado en el mismo segundo. Con el log en orden, Keel dejaba dos estados abiertos a la vez, y con el log barajado construía una historia distinta. La solución fue un orden total sobre lo que el hecho contiene:

def succession(f):
    # por valid_from; los empates por cuándo se registraron, luego por evento y valor.
    # Nunca por llegada, así que cualquier orden de llegada construye la misma historia
    return (f.valid_from, f.recorded_at or valid_never, f.evidence or "", json.dumps(f.value, sort_keys=True))

4. Un cierre se conoce cuando conoces los dos hechos

¿Cuál es el closed_at del saldo de septiembre? La respuesta tentadora es "el recorded_at del hecho que lo cierra", y es la que Keel implementó al principio. Pero si el sucesor se registró antes que el predecesor (llegó un dato tardío del pasado), no podías saber que el predecesor terminaba antes de conocerlo. El cierre se conoce en el más tardío de los dos recorded_at. El test de reproducción lo encontró durante el desarrollo: un sucesor que llegaba primero daba otro closed_at.

La variante que más me gusta solo aparece con tres eventos: una corrección que llega antes que el valor que corrige. El sistema cerraba bien el primero, pero cuando aparecía después un sucesor registrado más temprano en el mismo instante, nunca revisaba ese cierre. Con el log en orden no pasaba nunca. Con los logs del benchmark barajados, que tienen llegadas tardías y correcciones inyectadas, afectaba al 0,3 % de los hechos. Ahora una prueba corre esos tres eventos en los seis órdenes posibles.

5. El cierre guardado es del presente; el del pasado se reconstruye

Este bug lo encontró la revisión de este mismo post, y no un test, porque ninguno de los tests lo cubría.

Tres saldos de la misma deuda, que llegan en este orden:

HechoSaldoVálido desdeRegistrado
A4.200.0001 sep1 sep
B3.500.00015 oct16 oct
C3.870.0001 oct20 oct

C llega al final, pero en el tiempo del mundo cae entre A y B. Cuando llega B, A se cierra el 15 de octubre, y lo sabemos desde el 16. Cuando llega C, el cierre de A se mueve al 1 de octubre, y lo sabemos desde el 20. La fila de A solo tiene un valid_to y un closed_at, así que el primer cierre se sobrescribe. La historia actual queda bien. El problema está en leer el pasado.

Pregunta "¿qué creíamos el 18 de octubre sobre el 17?". El 18 conocíamos A y B, y sabíamos que A terminaba el 15: la respuesta es 3.500.000. La primera versión de as_known_at hacía lo obvio:

# la versión con el bug
if f.closed_at is not None and f.closed_at > known_at:
    f = replace(f, valid_to=None)  # el cierre es del futuro: para ese momento seguía abierto

El cierre guardado de A se conoció el 20, después del 18, así que la función reabría A. Respuesta: [4200000, 3500000], dos saldos vigentes a la vez, un estado que el sistema nunca tuvo. El cierre correcto para el 18 (el del 15 de octubre) ya no existía en ninguna parte.

La primera solución que se te ocurre es guardar el historial de cierres de cada fila. Tampoco sirve: qué hecho cierra a cuál depende del orden de llegada, y entonces ese historial también dependería del orden de llegada. La solución fue dejar de leer el cierre del pasado de la fila y derivarlo: con los hechos registrados hasta known_at, cada uno termina donde empieza el siguiente que ya conocías, o en un cierre explícito que ya conocías, lo que ocurra antes.

def as_known_at(facts, known_at):
    slots = defaultdict(list)  # los hechos que se cierran entre sí: sujeto, predicado, scope...
    for f in facts:
        if f.recorded_at is None or f.recorded_at <= known_at:
            slots[slot(f)].append(f)
    out = []
    for group in slots.values():
        group.sort(key=succession)
        for f, nxt in zip(group, [*group[1:], None]):
            # termina donde empieza el siguiente que ya conocíamos...
            ends = [(nxt.valid_from, later(f.recorded_at, nxt.recorded_at))] if nxt else []
            # ...o en un cierre explícito que ya conocíamos, lo que ocurra antes
            if f.valid_to is not None and (f.closed_at is None or f.closed_at <= known_at):
                ends.append((f.valid_to, f.closed_at))
            valid_to, closed_at = min(ends, key=lambda e: e[0], default=(None, None))
            out.append(replace(f, valid_to=valid_to, closed_at=closed_at))
    return out

(Recortada; la de Keel también agrupa los predicados con varios valores por autor.) Es una función que depende solo de los hechos conocidos hasta ese momento, así que cualquier orden de llegada da la misma respuesta, y no hizo falta cambiar el schema. La lección es que el closed_at guardado es un caché del presente. Para preguntas sobre el pasado, el cierre se reconstruye con lo que se sabía entonces.

El test que tienes que escribir

Si implementas algo bitemporal, este es el test que vale más que todos los demás juntos:

  1. Toma un log de eventos con llegadas tardías y correcciones. Si no los tienes, inyéctalos con una semilla fija, e incluye uno que caiga entre dos hechos que ya existen.
  2. Constrúyelo en orden, al revés y barajado.
  3. Compara las historias completas, campo por campo, sin ignorar nada excepto el reloj de contabilidad. Ordénalas por (valid_from, recorded_at) antes de comparar, porque dos filas con el mismo valid_from pueden salir en distinto orden aunque sean iguales.
  4. Compara también las lecturas, no solo lo guardado: consulta as_known_at en cada instante en que se registró algo y verifica que ningún predicado tenga dos valores abiertos a la vez.

El paso 4 es el que me faltaba. Los bugs de las reglas 3 y 4 vivían en lo que se guardaba, y los encontró el paso 3. El de la regla 5 vivía en una lectura, con una historia guardada perfecta. Ahora una prueba de conformidad corre ese caso en los seis órdenes, en cada store, leyendo en tres fechas distintas.

Cuándo lo necesitas y cuándo no

Lo necesitas si:

  • Los datos llegan tarde o se corrigen después: extractos bancarios, conciliaciones, integraciones con sistemas lentos, cualquier cosa que venga de un tercero.
  • Alguien va a auditar decisiones. "¿Por qué se aprobó este crédito?" se responde con lo que se sabía cuando se aprobó, no con lo que se sabe hoy.
  • Tienes agentes que deciden. Un agente es un tomador de decisiones a gran escala, y cuando uno se equivoca, la primera pregunta es si la culpa fue suya o de los datos que vio. Sin tiempo de transacción no puedes contestarla.
  • Necesitas reproducir el pasado para un eval: correr un agente "como si fuera el 3 de octubre" sin que vea lo que pasó el 9.

No lo necesitas si:

  • Solo te importa el estado actual y nadie va a preguntar por el pasado. La mayoría de los CRUD caben aquí, y está bien.
  • Te basta con el tiempo válido. Si los datos nunca llegan tarde y nunca se corrigen, una historia con valid_from y valid_to es suficiente. El segundo reloj solo es útil cuando lo que sabías y lo que era cierto pueden diferir.
  • Ya tienes un log de eventos inmutable y puedes permitirte reproducirlo para cada consulta histórica. El modelo bitemporal es, en el fondo, ese log ya indexado para consultar.

Lo que me llevo

El asistente de Toche no tuvo la culpa el 3 de octubre. Respondió con lo único que sabía, y el sistema debería poder demostrarlo. "Qué era cierto" y "qué sabíamos" son preguntas distintas. Un modelo que solo responde la primera convierte cada dato tardío en una acusación sin defensa.

La idea cabe en cuatro columnas. Hacerla bien cuesta más: un orden total, cierres que se derivan en lugar de leerse, y un test que baraja el log y compara también lo que lees. Yo necesité cinco bugs para aprenderlo, y el último lo encontró la revisión de este post. Si tu producto tiene datos que llegan tarde y alguien que toma decisiones con ellos, humano o agente, probablemente los necesitas desde antes de lo que crees.

Si quieres ver el modelo funcionando, Keel es open source y el store bitemporal está en keel/store/. Y si quieres la especificación formal, con las consultas que un store debe poder responder, está en el SPEC.

Publicaciones Relacionadas