wandres.dev
DETECTAR CONFLICTOS · y por qué LWW pierde datos

El conflicto como decisión de producto

Qué debe ver el usuario ante un conflicto, cuándo es legítimo interrumpirle y cuándo no es más que pereza disfrazada de transparencia, y por qué elegir la política de fusión es diseñar el producto antes que programarlo.

⏱ 18 min

Las cuatro lecciones anteriores han producido un detector exacto, un inventario de daños y un catálogo de estrategias con sus costes. Falta la pregunta que ninguna de ellas responde y que decide si tu aplicación resulta habitable: qué se le cuenta a la persona que está delante. Y esa pregunta no se responde con más ingeniería, porque no tiene forma técnica. Elegir si un alta gana a una baja es decidir qué promete tu producto. Elegir si se interrumpe al usuario es decidir cuánto vale su atención. Elegir qué se muestra de dos versiones enfrentadas es decidir si la decisión que le pides es posible o es un trámite que resolverá al azar. Nada de eso lo deduce un algoritmo, y sin embargo se está decidiendo hoy en tu código, por defecto y sin que nadie lo haya escrito en ningún sitio.

🎯 Al terminar esta lección sabrás
  • Tratar la atención del usuario como el recurso más escaso del sistema y presupuestarla en consecuencia.
  • Distinguir los tres registros de comunicación —silencio, rastro e interrupción— y el criterio que separa a cada uno.
  • Construir una presentación del conflicto que haga la decisión posible: procedencia, diferencia y salida sin arrepentimiento.
  • Reconocer los casos en que la respuesta correcta no es fusionar mejor sino cambiar la interacción.

La atención es el recurso más caro que administras

Hay una asimetría que conviene interiorizar antes de diseñar ninguna pantalla. Los metadatos se compran con disco, el cómputo se compra con tiempo de CPU y ambos se abaratan cada año. La atención de la persona que usa tu aplicación no se compra, no se repone y no escala: cada interrupción que le sirves sale de un presupuesto fijo que ella estaba gastando en otra cosa, normalmente en el trabajo por el que abrió tu producto. Un diálogo de conflicto no es una funcionalidad; es un cargo a esa cuenta.

De ahí se sigue el primer principio, que es contraintuitivo para quien acaba de construir un detector del que está orgulloso: detectar un conflicto no es motivo suficiente para mencionarlo. La detección es obligatoria siempre; la comunicación es una decisión aparte que se toma dato por dato. Un sistema que pregunta cada vez que dos escrituras se cruzan no es más transparente que uno que fusiona en silencio, es simplemente uno que ha externalizado su trabajo de diseño a quien no puede hacerlo, y el resultado empírico está bien establecido: a la tercera pregunta el usuario deja de leer, y a partir de la cuarta pulsa lo que sea para que desaparezca. Un diálogo que se descarta sin leer es peor que no haberlo mostrado, porque produce una decisión falsa que el sistema registrará como consentimiento.

Conviene además reconocer el sesgo profesional que empuja en la dirección contraria. Quien acaba de construir el detector siente, con razón, que ha hecho un trabajo difícil y correcto, y de ahí a pensar que mostrar sus resultados es un acto de honestidad hay un paso muy corto. Pero exhibir el funcionamiento interno de un sistema no es honestidad: es una transferencia de responsabilidad hacia quien no tiene ni el contexto ni el vocabulario para asumirla. La honestidad consiste en no perder nada y en poder responder siempre a la pregunta de qué pasó con una versión; no consiste en narrar en tiempo real cada decisión que el motor toma.

El segundo principio delimita al primero para que no se convierta en excusa. Hay una invariante que no admite negociación y una aspiración que sí. La invariante es no perder en silencio: cualquier valor que una persona escribió y que el sistema descarta debe seguir existiendo y ser recuperable. La aspiración es no interrumpir. Como la invariante es más fuerte que la aspiración, la combinación de ambas produce automáticamente la postura correcta por defecto: conservar todo y mostrar poco. Todo lo demás de esta lección son consecuencias de tomarse en serio ese orden.

Tres registros de comunicación, no dos

Presentado como una elección entre fusionar callando y preguntar, el problema no tiene solución buena. Presentado con un registro intermedio, casi todos los casos se colocan solos. El silencio aplica una fusión y no deja constancia visible; es adecuado únicamente cuando el valor descartado no existe, porque la fusión fue semántica y no descartó nada. El rastro aplica una fusión razonable y deja constancia consultable: una marca discreta, una entrada en el historial, un botón de ver la otra versión. Y la interrupción detiene lo que la persona estaba haciendo y le pide que decida ahora.

flowchart TD
C[conflicto detectado] --> Q1[la fusion descarta algo que alguien escribio]
Q1 --> SIL[silencio: no hay nada que contar]
Q1 --> Q2[el descarte es reversible mas tarde sin coste]
Q2 --> RAS[rastro: fusiona y deja constancia consultable]
Q2 --> Q3[la persona tiene delante el contexto para decidir ahora]
Q3 --> RAS
Q3 --> INT[interrupcion: pide la decision en este momento]
style SIL fill:#a6e3a1,color:#11111b
style RAS fill:#89b4fa,color:#11111b
style INT fill:#f38ba8,color:#11111b

El registro intermedio es el que casi nadie construye y el que resuelve la inmensa mayoría de los casos reales. No es un compromiso tibio entre los otros dos: es la única postura que cumple la invariante sin gastar atención, porque separa el momento en que la información está disponible del momento en que alguien la consulta. Una marca discreta junto a un campo, una entrada fechada en un panel de historial y un enlace para recuperar la versión desplazada cubren juntos todo lo que la honestidad exige, y ninguno de los tres interrumpe a nadie. Construirlos cuesta menos que un diálogo modal bien hecho y sirve además para otras cosas —auditoría, soporte, depuración— que el diálogo no cubre.

Las tres condiciones que llevan a la interrupción deben cumplirse a la vez, y por eso la interrupción es tan rara en un sistema bien diseñado. Si el descarte no destruye nada, sobra. Si es reversible sin coste, sobra también, porque la decisión puede esperar a que alguien la tome con calma. Y si la persona no tiene delante el contexto —está sincronizando en segundo plano, acaba de abrir la aplicación, está en mitad de otra tarea—, la interrupción no obtiene una decisión sino un descarte reflejo. Ese tercer filtro elimina de un golpe el momento en que la mayoría de los sistemas preguntan, que es el instante de la sincronización, precisamente el peor de todos.

⚠️
El momento de la sincronización es casi siempre el peor momento para preguntar

Es tentador resolver el conflicto donde se detecta, porque ahí está el código y ahí están los datos. Pero la sincronización ocurre cuando vuelve la red, no cuando el usuario está pensando en ese documento: puede estar escribiendo otra cosa, puede tener la aplicación en segundo plano, puede haber abierto el portátil solo para consultar algo. Preguntar entonces por un texto que editó hace tres días en otro dispositivo es pedir una decisión a alguien que ya no tiene el contexto que la haría posible. Lo correcto es desacoplar los dos instantes: detectar y conservar al sincronizar, presentar al abrir el documento afectado. La información viaja cuando puede; la pregunta se formula cuando sirve de algo.

Qué hay que enseñar para que la decisión sea posible

Suponiendo que la interrupción esté justificada, queda la parte que más se descuida. Mostrar dos bloques de texto uno al lado del otro y pedir que se elija uno no es plantear una decisión: es plantear una adivinanza. Para decidir hacen falta tres cosas, y si falta cualquiera de ellas la persona elegirá al azar y con razón.

Conviene notar que las tres son información que el sistema ya posee y que la mayoría de las implementaciones tira por el camino. La procedencia está en el identificador de réplica que el detector usó para desempatar; el instante está en la marca que se comparó; la diferencia se calcula con las mismas dos cadenas que se pasaron a la política. Nada de esto requiere infraestructura nueva: requiere no descartar, en el momento de fusionar, los datos que hacen falta doce pantallas más adelante. Es el mismo error de la lección segunda visto desde el otro extremo del sistema.

La primera es la procedencia. No versión A y versión B, sino de qué dispositivo viene cada una y de cuándo, en el lenguaje con el que la persona recuerda su propia semana: lo que escribiste en el móvil el martes por la tarde frente a lo que escribiste en el portátil ayer. La procedencia es lo que permite reconstruir la intención propia, y es información que el sistema ya tiene y casi nunca muestra. La segunda es la diferencia, no el contenido completo: lo que hay que resaltar es en qué se apartan las dos versiones, porque es lo único sobre lo que se decide. Y la tercera es una salida sin arrepentimiento, que casi siempre es la opción de conservar ambas: la única alternativa que nadie lamenta después.

// El objeto que la interfaz necesita: procedencia, diferencia y salidas
function prepararDecision(hermanos, campo) {
  return {
    campo,
    opciones: hermanos.map((h) => ({
      valor: h.valor,
      origen: nombreLegible(h.replica),        // "tu movil", no "replica a2f9"
      cuando: fechaRelativa(h.escritoEn),      // "el martes por la tarde"
      cambios: diferenciaResaltada(h.valor, hermanos),
    })),
    salidas: ["conservar ambas", "elegir una", "decidir mas tarde"],
    reversible: true, // sin esto, no formules la pregunta
  };
}

El campo del final no es decorativo. Una resolución reversible cambia por completo la economía de todo lo anterior: si equivocarse cuesta un clic de deshacer, la fusión automática deja de ser una apuesta y la interrupción deja de ser necesaria en la mayoría de los casos. Vale la pena enunciarlo sin rodeos porque contradice la intuición de mucha gente que llega aquí desde la ingeniería: un buen deshacer vale más que una buena pantalla de resolución, cuesta menos construirlo, sirve para docenas de situaciones que no son conflictos y no consume ni un segundo de atención de nadie.

🏷️

Nombra dispositivos, no réplicas

La persona recuerda dónde estaba y con qué aparato. Los identificadores internos no le dicen nada y convierten la decisión en una lotería.

🔍

Resalta la diferencia

Dos textos completos enfrentados no son una ayuda. Lo que se decide es lo que difiere, y es lo único que debe destacar.

🤝

Ofrece conservar ambas

Es la opción sin arrepentimiento y debe estar siempre. Cuando falta, cualquier elección implica destruir algo, y eso paraliza.

↩️

Haz reversible la resolución

Si deshacer es barato, decidir automáticamente deja de ser peligroso. El deshacer es la infraestructura que abarata todo lo demás.

Hay un cuarto elemento que no cabe en la pantalla y que sostiene a los otros tres: el archivo. Aunque la persona elija una versión, la descartada debe quedar guardada y localizable, y no por escrúpulo sino porque el arrepentimiento llega tarde y con contexto nuevo —al abrir el documento la semana siguiente y notar que falta un párrafo que sí recuerda haber escrito—. Un sistema que conserva las versiones descartadas convierte cualquier error de resolución, propio o del algoritmo, en una molestia recuperable. Uno que no las conserva convierte cada decisión apresurada en definitiva, y por tanto obliga a que todas las decisiones sean cuidadosas, que es una exigencia que ningún usuario puede sostener.

// Aplicar es reversible: nada se destruye, solo se decide que se muestra
function aplicarResolucion(campo, elegido, descartados, historial) {
  historial.registrar({
    campo, elegido, descartados,
    en: Date.now(),
    deshacer: () => aplicarResolucion(campo, descartados[0], [elegido], historial),
  });
  return elegido;
}

Queda una decisión de lenguaje que parece menor y no lo es. Las palabras conflicto, fusión, versión y sincronización pertenecen a tu modelo mental, no al de quien usa la aplicación, y usarlas traslada al usuario la carga de entender tu arquitectura para poder responderte. La formulación que funciona describe lo que ocurrió en sus términos —escribiste esto en dos sitios distintos— y ofrece exactamente las salidas que existen. Cuando un texto de interfaz necesita explicar cómo funciona el sistema para que la pregunta tenga sentido, la pregunta estaba mal planteada.

Cuando la respuesta correcta es cambiar el producto

Hay un movimiento final que rara vez se considera y que resuelve más casos que cualquier refinamiento de la fusión. Si la tasa de conflictos de un dato es alta, eso no es solo un problema de convergencia: es una señal de que el producto está permitiendo una forma de trabajo que no encaja con ese dato. Y ante esa señal existen respuestas que no pasan por fusionar mejor.

La primera es la presencia: mostrar quién más tiene el documento abierto y dónde está editando. Es sorprendentemente eficaz, porque la mayoría de los conflictos entre personas que se conocen se evitan solos en cuanto ambas saben que la otra está ahí. La segunda es la reserva explícita para recursos genuinamente exclusivos: hay datos —un número de factura, una asignación de turno, un estado de aprobación— cuya semántica es que una sola persona puede fijarlos, y para ellos la fusión no es un reto técnico sino un error conceptual; lo correcto es pedir el recurso, y que el sistema diga con honestidad cuándo no está disponible. La tercera es rediseñar el dato para que la operación conmute, que es lo que la lección anterior desarrolló y sigue siendo la respuesta más limpia cuando el dominio lo permite.

La segunda merece una defensa explícita porque incomoda en un track dedicado a eliminar la autoridad central. Reconocer que un dato requiere exclusión no es una recaída ni una derrota: es una lectura correcta del dominio. Local-first no promete que toda operación funcione sin coordinación —eso sería falso y ningún sistema lo cumple—, promete que la coordinación sea la excepción explícita y no el modo por defecto de todas las lecturas y escrituras. Una aplicación en la que el noventa y ocho por ciento del trabajo es local y convergente y el dos por ciento restante requiere reservar un recurso es plenamente local-first; una que finge fusionar lo que semánticamente no se puede fusionar no lo es más, solo es menos honesta.

Y hay una cuarta respuesta, la más incómoda y a veces la más acertada: eliminar la concurrencia del flujo. Si dos roles editan el mismo campo porque el proceso los obliga a pasar por la misma pantalla, el conflicto no es un accidente de la sincronización sino el reflejo fiel de una ambigüedad organizativa que el software estaba disimulando. Arreglarlo separando responsabilidades en la interfaz resuelve el conflicto técnico y, de paso, el problema humano que lo generaba, que es un rendimiento que ninguna estructura de datos puede ofrecer.

Ninguna de estas cuatro respuestas es una renuncia a la fusión: son decisiones que se toman antes de que la fusión entre en juego y que reducen el material sobre el que tendrá que operar. Y todas comparten un rasgo que las hace atractivas frente a la alternativa: se implementan en la capa donde el equipo de producto ya sabe trabajar, se prueban con usuarios y se revierten sin tocar el núcleo de sincronización. Comparadas con reescribir un tipo de dato convergente, son intervenciones baratas cuyo efecto se mide en semanas.

👀

Presencia

Mostrar quién más está dentro evita solo la mayoría de los cruces entre personas que se conocen. Es la intervención con mejor relación coste-efecto.

🔒

Reserva explícita

Para lo que semánticamente admite un solo dueño, pedir el recurso y negarlo con honestidad es más correcto que fingir una fusión.

🧬

Rediseñar el dato

Si la operación puede conmutar, el conflicto deja de existir en tiempo de diseño y no vuelve a costar nada nunca más.

🚪

Separar el flujo

Un campo que dos roles disputan a diario está señalando una ambigüedad organizativa que el software estaba disimulando.

ℹ️
La tasa de conflictos es una métrica de producto, no de infraestructura

Instrumenta el detector y mira los números por campo, no en agregado. Un campo que concentra la mayoría de los conflictos te está señalando un problema de diseño de la interacción mucho antes que uno de convergencia: quizá dos roles distintos están editando lo mismo porque el flujo no separa sus tareas, quizá el formulario invita a corregir un valor que debería ser derivado, quizá falta un indicador de que otra persona está trabajando ahí. Llevar esa cifra a la revisión de producto, y no solo a la de arquitectura, cambia la clase de soluciones que se ponen sobre la mesa, y casi siempre aparece alguna más barata que la fusión sofisticada que ibas a construir.

El conflicto es el punto donde tu arquitectura se hace visible, y lo que muestras ahí es lo que prometes

Conviene cerrar el nivel con la observación que reordena todo lo anterior, porque es la que separa a quien implementa sincronización de quien diseña un sistema local-first. Durante el noventa y nueve por ciento del tiempo, la arquitectura que has elegido es invisible: la aplicación abre rápido, escribe en local, funciona en el metro y nadie se pregunta dónde vive la verdad. El conflicto es el uno por ciento restante, el único instante en que la elección arquitectónica emerge a la superficie y se le presenta a una persona. Y lo que esa persona vea en ese instante no es un detalle de interfaz: es la única declaración explícita que tu producto hace jamás sobre qué relación tiene con su trabajo. Si ve un diálogo que le pregunta por réplicas y versiones, la declaración es que le has trasladado tu problema. Si no ve nada y días después descubre que faltaba un párrafo, la declaración es que su trabajo era prescindible. Si ve una nota que dice que escribió en dos sitios, con ambas versiones conservadas y un botón para quedarse con las dos, la declaración es que el sistema estuvo cuidando lo que ella hizo incluso cuando nadie miraba. Las tres cosas son el mismo evento técnico y son tres productos distintos. De ahí se sigue lo que da título a la lección y que merece defenderse con firmeza en cualquier organización: la política de fusión es una decisión de diseño que se está tomando en el código, y el hecho de que se tome ahí no la convierte en una decisión de ingeniería. Cuando alguien elige que en un conjunto compartido gane el alta sobre la baja, está decidiendo que en tu producto es peor perder algo que llevar algo de más, y esa frase es una declaración sobre el dominio que ningún argumento algebraico justifica: para una lista de la compra es evidentemente cierta y para una lista de permisos de acceso es exactamente al revés. Cuando alguien elige gana-el-último para un campo de texto, está decidiendo que el trabajo del usuario con el reloj más lento es aceptable como pérdida. Esas decisiones se están tomando hoy en tu sistema, se tomaron probablemente al aceptar el valor por defecto de una librería, y nadie del equipo de producto estuvo delante. La corrección no es organizativa sino intelectual: consiste en reconocer que el mapa de políticas de fusión de una aplicación local-first es un documento de producto que resulta estar escrito en un lenguaje de programación, y que debe revisarse con los mismos ojos con que se revisa un flujo de alta o una pantalla de pago. Fíjate por último en la simetría con la que empezó el nivel. Abrimos definiendo el conflicto como la consecuencia inevitable de haber retirado al árbitro central, un hecho estructural sobre la información que ninguna ingeniería puede derogar. Cerramos observando que ese hecho estructural, cuando llega a la superficie, se convierte en una conversación entre tu sistema y una persona. Todo el trabajo técnico que viene después —las dos escuelas enfrentadas del próximo nivel, los quince niveles de estructuras convergentes, la compactación y la historia— tiene un único propósito, y es que esa conversación haga falta lo menos posible y sea honesta las veces que haga falta. La convergencia se demuestra; la confianza se diseña.

⚔️ Diseña la conversación, no solo la fusión
  1. Recorre cada conflicto que tu sistema puede producir y asígnale uno de los tres registros —silencio, rastro o interrupción— justificando por escrito por qué se cumplen o no las tres condiciones.
  2. Elimina toda interrupción que ocurra en el momento de la sincronización y traslada su presentación al instante en que el usuario abre el dato afectado.
  3. Reescribe los textos de tus diálogos de conflicto sin las palabras conflicto, versión, fusión ni sincronizar, y comprueba que siguen siendo comprensibles.
  4. Añade procedencia legible por humanos a cada valor en litigio: qué dispositivo y cuándo, en lenguaje cotidiano y no en identificadores.
  5. Implementa el deshacer de una resolución y mide cuántas de tus interrupciones dejan de ser necesarias en cuanto equivocarse sale barato.
  6. Lleva la tasa de conflictos por campo a una revisión de producto y busca, para el campo que encabece la lista, una solución de interacción antes que una de fusión.