arifabds

App in Handy · Caso de estudio

Una plataforma de confianza para el espacio entreun problema y la app que lo resuelve.

Desplegado: la flota corre en un servidor real, pagos aplazados

Tres repositorios, trece servicios de backend y una regla deliberadamente aburrida: un servicio es dueño de sus datos y habla con el resto del mundo a través de un log de eventos. Ahora corre en un servidor real, desplegado por una tubería que verifica qué build está sirviendo en lugar de darlo por supuesto. Lo que sigue es cómo se construyó, por qué cada decisión fue en su dirección y qué sigue abierto.

13

servicios

26

topics

1524

pruebas de backend

146

casos de uso

106

migraciones

4

idiomas

Resumen

El producto, en sesenta segundos

La arquitectura solo cobra sentido cuando lo hace el producto, así que esta parte va primero y se mantiene breve.

La premisa

Preguntar si una app es buena es la pregunta equivocada. La misma herramienta es excelente para el problema de una persona e inútil para el de otra. Por eso la plataforma no puntúa apps aisladas: puntúa el Match (una app concreta respondiendo a un problema concreto) y es ese emparejamiento el que lleva la nota.

HandyScore

Cada voto puntúa varios ejes contextuales del 1 al 10. Un motor determinista los normaliza, pondera más las opiniones recientes sin descartar las antiguas, atrae las muestras pequeñas hacia un prior neutro y mezcla los ejes en un único número que se publica junto con su desglose. La fórmula está en la sección de decisiones: nada de esto es una caja negra.

«Me Too»

Los problemas se votan aparte y de forma mucho más simple: una señal neta a favor o en contra que responde a «cuánta gente tiene realmente este problema». Demanda y calidad son preguntas distintas, así que se miden con instrumentos distintos.

Tres repositorios

Repositorios separados, despliegues separados, historiales de commits separados, a propósito. El sitio de marketing nunca debería poder romper el producto, y ninguno de los dos debería poder romper la flota.

RepositorioStack
platform13 servicios, un gateway de borde, 9 bibliotecas compartidasJava 21 · Spring Boot 3.5 · PostgreSQL · Kafka
web-uiLa interfaz del productoNext.js 16 · React 19 · RSC-first
landingSuperficie pública de marketingNext.js 16 · next-intl

El viaje de una petición

Esto es además el mapa del resto de la página: cada paso se desarrolla más abajo.

  • 01

    El borde

    El gateway verifica el token de identidad una vez, emite uno interno de vida corta, aplica el límite de peticiones y estampa un identificador de correlación.

  • 02

    Un único servicio dueño

    La petición llega exactamente a un servicio, internamente hexagonal: la infraestructura llama a la aplicación y la aplicación a un dominio puro.

  • 03

    Una transacción local

    El cambio de estado y el evento saliente se escriben juntos. O confirman ambos o ninguno.

  • 04

    El relay

    Un relay programado reclama las filas pendientes con SKIP LOCKED, estampa un identificador de evento estable y publica con la clave del agregado.

  • 05

    Consumidores idempotentes

    Cada servicio interesado aplica el evento a su propio read model y deduplica por ese identificador en la misma transacción que el efecto.

Arquitectura

Un borde, trece dueños, un log

Selecciona un servicio para ver de qué es dueño y con quién habla. Ninguna flecha de aquí es el dibujo de una intención: cada una es un topic que existe en el repositorio.

api-gateway · 8080
Kafka / Redpanda · event log

Por debajo

  • PostgreSQL 16
  • Kafka / Redpanda
  • Redis
  • pgvector
  • OpenTelemetry Collector
  • Tempo
  • Prometheus
  • Grafana
  • Alertmanager
ProduceConsumeSelecciona un servicio
  • identity-service: Dueño exclusivo de los usuarios y único servicio que habla con el proveedor de identidad: webhook, aprovisionamiento en el primer acceso y el snapshot de usuario que proyectan los demás.
  • catalog-service: Dueño de las apps y de la dimensión de categorías. Aquí vive el «orphan killer»: cuando se crea un match, la app emparejada deja de ser huérfana.
  • problem-service: Dueño de los problemas y de su puntuación de demanda. Solo consumidor: cada campo ajeno que lee es una proyección local alimentada por eventos de otros.
  • scoring-service: El motor determinista de HandyScore. Un voto recalcula una puntuación y la publica; tres contextos distintos cachean el resultado.
  • social-service: El núcleo: matches, sus hilos de mensajes y los votos de mensajes, más las insignias de autor recalculadas en cada lectura, de modo que levantar un veto se revierte solo.
  • moderation-service: Denuncias, tickets de soporte y la puerta de publicación de toda la flota: la cola donde cada app, problema y match nuevo espera aprobación.
  • search-service: Lado de lectura CQRS puro. No es dueño de ningún dato origen: el índice se construye íntegramente a partir de eventos y reconstruirlo significa reproducir el log.
  • media-service: Una hoja sin estado y el único poseedor de las credenciales de almacenamiento de objetos. Valida tipo y tamaño, devuelve URLs y nunca hace pasar los bytes por la aplicación.
  • notification-service: El canal de correos masivos. No almacena ninguna dirección: los destinatarios se piden a identity en el momento del envío y se usan de forma transitoria.
  • admin-service: Superficies transversales de operación: estadísticas de panel repartidas entre servicios y una prueba de caja negra del limitador del borde.
  • ai-service: Chat con recuperación anclado estrictamente en el corpus de la propia plataforma, tarjetas de insight de match, un banco de evaluación y un libro mayor de costes completo.
  • seed-service: Recolecta datos públicos, los neutraliza y los escribe a través de la API de escritura normal del servicio dueño. Nunca fabrica un voto.
  • billing-service: Ciclo de vida de las suscripciones y el canal de premium gratuito. Sale con su superficie de pago apagada por defecto.

Ocho reglas que impone la compilación

No son recomendaciones. Cada una la comprueba algo que rompe una compilación, o es imposible por topología.

#Regla
01Un servicio es dueño de sus datos. Ningún otro toca su base de datos: sin esquema compartido, sin SQL entre servicios, sin claves foráneas entre servicios.Topología de base de datos por servicio más una regla de ArchUnit sobre importaciones de persistencia ajenas
02Entre servicios significa una llamada HTTP o un evento de Kafka. Nunca una llamada en proceso.Sin jars de negocio compartidos; las bibliotecas solo llevan el kernel y los contratos de eventos
03Sin transacciones distribuidas. Una unidad de trabajo abarca exactamente una base de datos.Transacciones solo dentro de los interactores de un servicio; la consistencia entre servicios es eventual
04Cada servicio es internamente hexagonal, con un dominio sin framework dentro.Un conjunto de reglas de ArchUnit por servicio, heredado del monolito
05Los eventos son fiables gracias al outbox y los consumidores son idempotentes.Tabla de outbox y relay por productor; deduplicación por identificador dentro de la transacción del consumidor
06La autenticación se verifica una vez, en el borde.Filtro JWT en el gateway; los servicios solo confían en el token interno que él emite
07Contract-first: la API síncrona es OpenAPI, la asíncrona es un esquema de eventos versionado.Una prueba de contrato rompe la compilación raíz si un registro de evento falta en el catálogo
08Todo es observable: traza, métricas e identificador de correlación viajan tanto por HTTP como por Kafka.Un starter de observabilidad autoconfigurado; nada que olvidar servicio a servicio

Cómo se llegó hasta aquí

Escribir microservicios desde cero y tallarlos de un monolito en funcionamiento son trabajos distintos. Este fue el segundo, y el monolito permaneció congelado byte a byte todo el camino: legible, ejecutable, nunca editado.

  1. Preparación

    Infraestructura antes de extraer

    Broker, base de datos, caché y la pila de observabilidad se levantaron primero en local. El monolito se declaró congelado: leerlo, copiar de él, nunca cambiarlo, de modo que la vuelta atrás siguiera siendo real.

  2. Strangler

    El gateway toma la puerta de entrada

    Todo se enrutaba al monolito. A partir de ahí, cada extracción añadía delante una ruta de mayor prioridad. Ese era el mecanismo de corte: un camino cada vez y siempre reversible.

  3. Plataforma

    Los raíles compartidos

    Identificador de correlación, verificar-una-vez más emisión del token interno, límite de peticiones en Redis, la biblioteca de outbox/idempotencia/dead-letter y el starter de OpenTelemetry. Escritos deliberadamente como bibliotecas autoconfiguradas para que ningún servicio pudiera olvidarse de participar.

  4. Desacoplamiento

    Cada lectura entre servicios se vuelve una proyección

    El trabajo de verdad. Una a una, las lecturas que entraban en las tablas de otro módulo se convirtieron en proyecciones locales alimentadas por el log de eventos. El primer cliente síncrono entre servicios se construyó con reintentos y cortacircuitos, y luego se volvió innecesario y se retiró.

  5. Limpieza

    Las últimas lecturas compartidas

    Las siete lecturas entre contextos que quedaban se cerraron una a una, cada una sustituida por una proyección propiedad del lector. La parte honesta: un informe anterior afirmaba que esto ya estaba terminado. Era una exageración, y la retrospectiva lo dice.

  6. Separación

    Separación física y adiós al catch-all

    Cada servicio con estado pasó a su propia base de datos con su propia cadena de migraciones. Después se eliminó la ruta comodín del monolito y este salió por completo del camino de las peticiones. El binario quedó ejecutable como referencia.

  7. Después

    Paridad, pipeline, endurecimiento

    Se recuperaron unas cincuenta pruebas de caso de uso y de dominio servicio a servicio, se construyeron la matriz de CI y el pipeline de publicación, y la flota se arrancó desde cero con la observabilidad verificada en vivo.

Tres cosas que solo aparecen cuando lo haces de verdad

Partir un monolito mata en silencio a los listeners en proceso

Dos comportamientos corrían sobre listeners de eventos en proceso: que una app saliera de la lista de «huérfanas» al emparejarse, y que la reputación de un autor cambiara al votarse su mensaje. En cuanto esos emisores y esos oyentes quedaron en procesos distintos, ambos dejaron de dispararse: sin error, sin excepción, sin nada en un log. Los dos se encontraron y se recablearon como consumidores de Kafka. Que nada explote no es prueba de que nada se haya roto.

Una bandera derivada tiene que reemitirse o aguas abajo nunca se entera

Cuando una app dejaba de ser huérfana, la bandera cambiaba en el servicio dueño pero no se publicaba ningún evento, así que la lista de huérfanas de búsqueda quedaba obsoleta para siempre. El arreglo fue una línea en el sitio correcto; encontrarlo exigió razonar sobre qué transiciones de estado producen eventos y cuáles no.

Auditar tu propio informe de migración es parte de la migración

Un informe de fase afirmaba que el último lector de la base de datos compartida se había retirado. Quedaban siete más. Esa corrección se escribió dentro de la retrospectiva en lugar de borrarse de ella, porque una afirmación falsa en un documento es un fallo del documento.

Columna de eventos

Cómo un cambio de estado se convierte en la verdad de los demás

Cada flecha de la sección anterior es uno de estos topics. La cadena de abajo es lo que los transporta, y cada paso existe por una forma concreta en que el diseño anterior podía perder datos.

La cadena

  1. 01

    El interactor cambia el estado

    Un caso de uso muta su propio agregado y publica un evento de dominio en proceso. No sabe nada de Kafka.

  2. 02

    Appender del outbox, antes de confirmar

    Un listener escribe el evento en una tabla de outbox dentro de la misma transacción. Un rollback no publica nada; una confirmación no puede perder el evento.

    El diseño anterior publicaba justo después de confirmar. Si ese envío fallaba, el evento desaparecía sin rastro: el estado había cambiado y nadie aguas abajo llegaba a enterarse.

  3. 03

    El relay publica

    Un relay programado reclama filas pendientes con SKIP LOCKED (seguro con varias instancias), estampa un identificador estable y publica con la clave del agregado.

    Usar la clave del agregado es lo que da orden por agregado. Hay una excepción deliberada y está en las notas de abajo.

  4. 04

    El log

    Los topics llevan el nombre del contexto que los posee. El log es la razón por la que un read model puede reconstruirse: la reproducción es un camino de recuperación de primera clase, no una fantasía.

  5. 05

    Los consumidores lo aplican

    Cada servicio interesado proyecta el evento en su propio esquema. La deserialización está protegida, así que una carga malformada no puede envenenar la partición.

  6. 06

    Deduplicación en la misma transacción

    La marca de evento procesado y el efecto de negocio confirman juntos. Eso es lo que convierte la entrega «al menos una vez» en un efecto «exactamente una vez».

  7. 07

    Reintentos acotados y luego dead-letter

    Tres intentos con espera creciente y después el registro pasa a la cola de mensajes muertos del topic. Un fallo de deserialización se salta los reintentos: reintentar una carga que nunca va a parsearse solo es un fallo más lento.

  8. 08

    Reproducción bajo demanda

    Un endpoint de operación reproduce los registros muertos con fidelidad de bytes, cabeceras incluidas, de modo que el identificador original sobrevive y la deduplicación sigue protegiendo un lote aplicado a medias.

El catálogo

Este registro no es documentación. Una prueba de contrato descubre cada registro de evento de la biblioteca compartida y rompe la compilación raíz si no tiene una fila aquí, de modo que la API asíncrona no puede alejarse de su descripción.

  • scoring.app-score-updatedSnapshot

    Refresca la puntuación de calidad cacheada de la app.

    Productor
    scoring
    Consumidores
    catalog, ai
    Clave
    appId
  • scoring.problem-score-updatedSnapshot

    Refresca la puntuación de demanda cacheada del problema.

    Productor
    scoring
    Consumidores
    problem, ai
    Clave
    problemId
  • scoring.handy-score-updatedSnapshot

    Refresca la puntuación del match allí donde esté cacheada.

    Productor
    scoring
    Consumidores
    social, problem, search, catalog, ai
    Clave
    matchId
  • scoring.problem-vote-castSnapshot

    Precarga el propio veredicto «Me Too» de quien llama.

    Productor
    scoring
    Consumidores
    problem
    Clave
    problemId:userId
  • scoring.problem-vote-clearedTombstone

    Elimina un voto retirado para que deje de precargarse.

    Productor
    scoring
    Consumidores
    problem
    Clave
    problemId:userId
  • scoring.app-vote-castSnapshot

    Precarga la valoración por ejes de la app de quien llama.

    Productor
    scoring
    Consumidores
    catalog
    Clave
    appId:userId
  • scoring.match-vote-castSnapshot

    Precarga la valoración por ejes del match de quien llama.

    Productor
    scoring
    Consumidores
    social
    Clave
    matchId:userId
  • catalog.category-upsertedSnapshot

    Actualiza la etiqueta de categoría que los consumidores pintan en local.

    Productor
    catalog
    Consumidores
    problem, social, search
    Clave
    categoryId
  • catalog.app-upsertedSnapshot

    Actualiza la proyección local de la app: etiqueta, texto de búsqueda, bandera de huérfana.

    Productor
    catalog
    Consumidores
    social, scoring, problem, search, ai
    Clave
    appId
  • catalog.app-pending-reviewPuerta

    Añade la app a la cola de aprobación.

    Productor
    catalog
    Consumidores
    moderation
    Clave
    appId
  • catalog.app-deletedTombstone

    Elimina la app de todas las proyecciones.

    Productor
    catalog
    Consumidores
    social, scoring, problem, search, ai
    Clave
    appId
  • catalog.category-deletedTombstone

    Retira la categoría; las etiquetas pasan a «sin categoría».

    Productor
    catalog
    Consumidores
    problem, social, search
    Clave
    categoryId
  • identity.user-upsertedSnapshot

    Actualiza la proyección local del usuario: insignia, reputación, veto.

    Productor
    identity
    Consumidores
    social, moderation
    Clave
    userId
  • identity.user-deletedTombstone

    Elimina al usuario de todas las proyecciones.

    Productor
    identity
    Consumidores
    social, moderation
    Clave
    userId
  • social.match-message-postedSnapshot

    Guarda un snapshot del mensaje para que siga siendo denunciable.

    Productor
    social
    Consumidores
    moderation
    Clave
    messageId
  • social.match-message-deletedTombstone

    Marca el mensaje como borrado pero conserva la fila como prueba.

    Productor
    social
    Consumidores
    moderation
    Clave
    messageId
  • social.match-createdSnapshot

    Actualiza la proyección del match y deja de considerar huérfana a la app.

    Productor
    social
    Consumidores
    problem, catalog, search, scoring, ai
    Clave
    matchId
  • social.match-deletedTombstone

    Elimina el match de todas las proyecciones.

    Productor
    social
    Consumidores
    problem, search, scoring, catalog, ai
    Clave
    matchId
  • social.match-pending-reviewPuerta

    Añade el match a la cola de aprobación.

    Productor
    social
    Consumidores
    moderation
    Clave
    matchId
  • social.user-reputation-changedDelta

    Aplica un delta de reputación al autor del mensaje.

    Productor
    social
    Consumidores
    identity
    Clave
    userId
  • problem.problem-upsertedSnapshot

    Actualiza la proyección del problema: texto buscable, estado, autor.

    Productor
    problem
    Consumidores
    search, scoring, social, catalog, ai
    Clave
    problemId
  • problem.problem-deletedTombstone

    Elimina el problema de todas las proyecciones.

    Productor
    problem
    Consumidores
    search, scoring, social, catalog, ai
    Clave
    problemId
  • problem.problem-pending-reviewPuerta

    Añade el problema a la cola de aprobación.

    Productor
    problem
    Consumidores
    moderation
    Clave
    problemId
  • catalog.contribution-rewardedDelta

    Concede karma una sola vez por una app aceptada.

    Productor
    catalog
    Consumidores
    identity
    Clave
    userId
  • problem.contribution-rewardedDelta

    Concede karma una sola vez por un problema aceptado.

    Productor
    problem
    Consumidores
    identity
    Clave
    userId

Cuatro sutilezas que merecen su párrafo

Los snapshots perdonan; los deltas no

Casi todos los topics llevan el estado actual completo, así que el consumidor fija un valor y una reentrega simplemente converge. Dos topics llevan en cambio un cambio: un ajuste de reputación y una recompensa por contribución. Para ellos la deduplicación no es una comodidad: es lo único que separa la entrega «al menos una vez» del recuento doble. La vía de recompensa va más allá y añade una segunda protección independiente en el productor: una marca de tiempo de una sola vez sobre el propio contenido, de modo que publicar → despublicar → volver a publicar recompensa exactamente una vez.

La misma palabra, dos comportamientos correctos

Una app, un match o un problema borrados se eliminan de todas las proyecciones: algo borrado no debe aparecer en una lista. Un mensaje de hilo borrado no: su fila permanece con una marca de borrado, porque un mensaje eliminado después debe seguir siendo denunciable y la prueba no puede desaparecer. A ambos se les llama tombstones; tratarlos igual rompería uno de los dos.

Un grupo de consumidores es una decisión de corrección

Un servicio reacciona a la creación de un match por dos motivos sin relación: muta un agregado y mantiene una proyección. Si pones ambos listeners en el mismo grupo, se reparten las particiones: cada reacción ve solo parte de los eventos y ninguna parece rota de forma evidente. Grupos separados lo convierten en un fan-out limpio en el que ambas lo ven todo.

La clave de partición no siempre es el identificador del agregado

Los eventos de voto se clavean por el par problema-y-votante, no por la fila del voto. La identidad del read model es ese par, y la secuencia votar → cambiar → retirar de una persona tiene que mantener el orden. El identificador de la fila cambia al retirar y volver a votar, así que usarlo dejaría que un «voto emitido» obsoleto aterrizara después del «voto retirado» que debía eliminarlo.

Decisiones de ingeniería

Por qué cada pieza es como es

Cada tarjeta enuncia la decisión y el motivo en unas pocas líneas. Abre una para el argumento completo, la concesión que aceptó y dónde vive en el repositorio.

DatosUn servicio es dueño de sus datos: sin esquema compartido, sin joins entre servicios, sin claves foráneas entre serviciosLos datos ajenos son un identificador blando más una proyección que el lector posee y mantiene al día desde el log de eventos.Una tabla compartida es un despliegue compartido. En cuanto dos servicios leen las mismas filas, ninguno puede cambiar su esquema solo y la frontera entre microservicios se vuelve decorativa.

Cada servicio con estado tiene su propia base de datos y su propia cadena de migraciones, desde su primera versión. Cuando un servicio necesita datos de otro, guarda el identificador y un read model local detrás de un puerto que define él mismo, de modo que la forma que almacena es la que necesita, no la que casualmente tiene el dueño.

La regla no se mantiene por disciplina. ArchUnit rompe la compilación si un servicio importa los tipos de persistencia de otro y, tras la separación física, ya no queda un esquema compartido al que llegar ni por accidente.

ConcesiónToda lectura entre servicios es eventualmente consistente. Comprobaciones de escritura como «¿existe esta app?» dejan de ser certezas y pasan a converger, algo aceptable aquí y anotado donde aplica en lugar de descubrirse más tarde.

En el repositorio

  • 13 × <svc>_db
  • ArchUnit: SERVICES_DO_NOT_DEPEND_ON_EACH_OTHER
  • *LookupPort → EventFed*Adapter
DatosEl lado de lectura no posee ningún dato origenLa búsqueda tiene base de datos pero ni una sola tabla de negocio: cuatro proyecciones construidas puramente desde eventos y un ayudante de búsqueda de texto.La búsqueda lee a través de todos los contextos, que es justo la consulta que tienta a hacer un join más allá de las fronteras. Convertirla en una proyección pura elimina la tentación y hace que reconstruir un índice sea una reproducción y no una migración.

Las consultas van contra proyecciones locales con SQL plano y búsqueda de texto de Postgres sobre un índice GIN. Aquí no hay entidades ORM a propósito: el lado de lectura no tiene un dominio que proteger, así que un mapeador de objetos sería puro coste.

Dos reglas mantienen honesta la paginación. La ordenación es un enum y el enum es la lista blanca: un valor desconocido se rechaza antes de que corra nuestro código, porque la cláusula tiene que interpolarse en una consulta nativa y una cadena de cliente jamás debe llegar ahí. Y toda ordenación termina con el identificador de fila, porque sin un orden total estricto las filas empatadas se repiten o desaparecen en silencio entre páginas.

ConcesiónTodo lo que quieras buscar tiene que viajar primero en un evento. No hay un join cómodo al que recurrir cuando hace falta un campo nuevo, que es justo el objetivo, pero también significa que a veces una función de búsqueda empieza en otro servicio.

-- Every ORDER BY on the paged union ends with the id.
-- Without a strict total order, rows tying on the sort column
-- silently repeat or vanish between pages.
ORDER BY ts_rank(search_vector, query) DESC, created_at DESC, id

En el repositorio

  • search_db
  • app/problem/category/match_projection
  • SearchRepositoryAdapter
  • SearchSort
DatosUn listado con varios ejes es una única consulta compuesta, no una cadena de prioridadesCada filtro es un predicado tolerante a nulos dentro de la misma consulta. Añadir uno es añadir una línea, no una rama.El listado era una escalera de if/else sobre los ejes de filtrado: ganaba el primero no nulo y el resto se descartaba en silencio. Una petición que filtraba por nombre y por estado de huérfana solo respondía la mitad del nombre, y quien llamaba no tenía forma de saberlo.

Reducirlo a un único objeto de criterios y una única consulta nativa eliminó nueve métodos de repositorio ya muertos e hizo que el endpoint se comportara como su propia documentación siempre afirmó.

Los atributos JSON se consultan por contención del documento completo y no por extracción de campos (la extracción ni siquiera puede responder a la pertenencia a un array), lo que además hace que un solo índice GIN sirva a todos los ejes JSON, presentes y futuros.

ConcesiónUna consulta grande se lee de un vistazo peor que cinco métodos pequeños. A cambio, es imposible que dos filtros discrepen sobre cuál manda.

-- One query, every axis null-tolerant. A new filter is a predicate here,
-- never a new repository method and never a new branch.
WHERE (CAST(:categoryId AS uuid) IS NULL OR a.category_id = CAST(:categoryId AS uuid))
  AND (CAST(:platform   AS text) IS NULL OR a.metadata @> :platformJson::jsonb)
  AND (CAST(:name       AS text) IS NULL OR a.search_vector @@ plainto_tsquery(:name))

En el repositorio

  • AppSearchCriteria
  • AppSearchNativeQuery
  • GIN jsonb_path_ops (V6)
DatosUn valor por defecto de mapeo hizo que un campo no pudiera vaciarseIgnorar los nulos es correcto para «no borres lo que no te enviaron» y equivocado para un campo cuya ausencia es el nuevo valor.Una vez puesta una captura en un problema, ya no podía quitarse. El dominio decía nulo, el mapeador ignoraba el nulo, la URL antigua sobrevivía a cada guardado, y nada fallaba.

El arreglo es una excepción por propiedad en ese único campo, no un cambio del valor por defecto global, que es correcto para todo lo demás. La ausencia también obtuvo una sola representación: la entrada vacía se normaliza a nulo en el propio comando, de modo que ningún lector tiene que comprobar dos clases de «sin imagen».

Lo que merece recordarse es cómo se detectó. Las pruebas con mocks pasaban en ambos casos: verificaban un mapeador que hacía exactamente lo que se le decía. Hizo falta una prueba de integración contra una base de datos real para ver que la fila nunca cambiaba.

ConcesiónCada futuro campo opcional y borrable necesita la misma excepción explícita y una prueba que lo vacíe de verdad. Un coste pequeño y recurrente, aceptado porque la alternativa es toda una clase de fallo silencioso de datos.

// IGNORE is right for "don't blank what the caller didn't send" and
// silently wrong for a field whose absence IS the new value.
@Mapping(target = "screenshotUrl", source = "screenshotUrl",
         nullValuePropertyMappingStrategy = SET_TO_NULL)
void updateEntity(Problem domain, @MappingTarget ProblemEntity entity);

En el repositorio

  • MapStructGlobalConfig
  • problems.screenshot_url (V5)
  • ProblemPersistenceIT
MensajeríaEl estado y el evento confirman en la misma transacciónEl evento saliente se escribe en una tabla de outbox dentro de la transacción de negocio. Publicar lo hace después un relay.El diseño anterior publicaba justo después de confirmar. Si esa publicación fallaba (broker caído, corte de red, proceso muerto), el cambio de estado sobrevivía y el evento no. Nadie aguas abajo se enteraba y nada registraba que se hubiera perdido algo.

Un listener que corre antes de confirmar añade el evento al outbox del servicio dueño en la misma conexión. Un rollback no publica nada. Una confirmación hace el evento tan duradero como los datos que describe.

Un relay programado reclama las filas pendientes con SKIP LOCKED (lo que hace seguro correr varias instancias), estampa un identificador estable que después se usa para deduplicar y publica con la clave del agregado, de modo que los eventos de un agregado mantienen el orden.

ConcesiónLa publicación es ahora asíncrona y algo diferida, y el intervalo del relay se convierte en una palanca real de latencia. La captura de cambios eliminaría el sondeo; es un aplazamiento registrado y deliberado, no un descuido.

// BEFORE_COMMIT: the event row joins the business transaction, so a
// rollback publishes nothing and a commit can never lose the event.
@TransactionalEventListener(phase = TransactionPhase.BEFORE_COMMIT)
void on(AppScoreUpdatedEvent event) {
    outbox.append(ScoringTopics.APP_SCORE_UPDATED, event.appId(), event);
}

En el repositorio

  • OutboxWriter
  • OutboxRelay
  • <svc>.outbox_events
  • ScoreOutboxAppender
MensajeríaEntrega al menos una vez, efecto exactamente una vezLa marca de deduplicación se escribe en la misma transacción que el efecto de negocio. Ni antes ni después.Cualquier broker reentrega. Si la marca confirma por separado, hay una ventana en la que el efecto se aplicó y la marca no, y la siguiente entrega vuelve a aplicarlo.

Para los eventos de snapshot esto es una comodidad: el consumidor fija un valor, así que una reproducción converge igualmente. Para los dos topics que llevan un cambio en lugar de un estado (un ajuste de reputación y una recompensa) es lo único que evita el recuento doble.

La vía de recompensa lleva una segunda protección independiente en el productor: una marca de tiempo de una sola vez sobre el propio contenido. La deduplicación del consumidor frena una reentrega; la marca del productor frena que un ciclo de publicar → despublicar → volver a publicar recompense dos veces. Dos capas porque un delta necesita realmente ambas.

ConcesiónCada consumidor paga una escritura pequeña por evento y el registro hay que podarlo en algún momento. Barato al lado de la clase de fallo que elimina.

-- The marker is written in the same transaction as the business effect,
-- which is what turns at-least-once delivery into exactly-once effect.
INSERT INTO processed_events (event_id, consumer)
VALUES (:eventId, :consumer)
ON CONFLICT (event_id, consumer) DO NOTHING;

En el repositorio

  • ProcessedEventStore
  • <svc>.processed_events
  • apps.contribution_rewarded_at
MensajeríaUn mensaje envenenado detiene un registro, no el sistemaReintentos acotados, después un topic de mensajes muertos, y un endpoint de operación que los reproduce con fidelidad de bytes.Sin esto, una sola carga malformada bloquea su partición para siempre y deja fuera de servicio toda una proyección. Con reintentos infinitos ingenuos, lo hace de forma ruidosa y cara.

Un único manejador de errores sirve a toda la flota: tres intentos con espera creciente y luego el topic de mensajes muertos. Los fallos de deserialización se saltan los reintentos por completo: reintentar una carga que nunca va a parsearse solo es un fallo más lento.

La recuperación importa tanto como la contención. El endpoint de reproducción conserva las cabeceras, lo que significa que el identificador original sobrevive, así que reproducir un lote aplicado a medias es seguro porque la deduplicación sigue reconociendo lo que ya aterrizó.

ConcesiónLos reintentos son bloqueantes en lugar de enrutarse por topics de reintento. Para consumidores idempotentes de bajo volumen es más simple y suficiente; la vía de mejora está escrita para el día en que el volumen diga lo contrario.

En el repositorio

  • ConsumerErrorHandlingAutoConfiguration
  • DltReplayer
  • POST /actuator/dltreplay
MensajeríaConstruye el cliente síncrono resistente y luego vuélvelo innecesarioLa primera llamada entre servicios de la flota se endureció como es debido, después se sustituyó por una proyección alimentada por eventos y se retiró el último cliente síncrono.Una llamada síncrona acopla la disponibilidad: si el llamado se cae, el llamante se degrada. A veces esa es la concesión correcta, pero para una lectura que puede proyectarse es un impuesto permanente.

La primera llamada se construyó con cuidado (interfaz HTTP tipada, tiempos de espera de transporte, reintentos, cortacircuitos, bulkhead, un mapeador anticorrupción y una degradación elegante) y su ciclo completo de cortacircuitos se demostró bajo una caída real de la dependencia.

Después el dueño empezó a publicar esos mismos datos como evento, dos consumidores los proyectaron en local y el cliente se borró. La biblioteca de transporte y el endpoint interno se conservaron en vez de eliminarse: hoy no tienen consumidores y son exactamente lo que usará la próxima necesidad síncrona real. Esa misma pila lleva hoy las llamadas de publicación del servicio de moderación.

ConcesiónLa lectura proyectada es eventualmente consistente, así que una comprobación de existencia en escritura puede discrepar brevemente de la realidad. Aceptado a sabiendas y con las rutas afectadas documentadas.

En el repositorio

  • libs/platform-http
  • PlatformHttpClientFactory
  • AppUpsertedEvent
  • social/scoring app_projection
SeguridadLa autenticación ocurre una vez, en el bordeEl gateway verifica el token externo y luego lo sustituye por uno interno de vida corta en el que confían los servicios.Trece servicios validando por su cuenta un proveedor de identidad externo son trece sitios donde configurar mal algo, trece dependencias salientes y trece respuestas distintas a «qué puede hacer este usuario».

Los servicios nunca ven el proveedor externo. Un único bean decodificador en un starter compartido los cambió todos a la vez, sin código por servicio. El token interno vive dos minutos, lo bastante poco como para que uno filtrado no valga casi nada.

La parte sutil es el rol. No se copia del token externo: el gateway lo resuelve desde la propia base de datos del servicio de identidad, con una caché breve, de modo que un permiso concedido dentro del producto surte efecto en toda la flota dentro de esa ventana y no en el siguiente inicio de sesión del usuario.

ConcesiónEl coste es la disponibilidad. Cuando identity no puede responder, el borde rechaza la petición con un 503 en lugar de confiar en el rol que declara el token externo; una versión anterior caía abierta ahí y una campaña de resiliencia lo cerró. El tráfico anónimo no se ve afectado y una caché de treinta segundos sostiene a los usuarios ya vistos durante un corte breve, así que lo único que se rechaza es una petición autenticada de alguien a quien nadie ha visto hace poco.

platform:
  security:
    internal-jwt:
      issuer: api-gateway
      ttl-seconds: 120          # short enough that a stolen token is worthless
    role-cache:
      ttl-seconds: 30           # role comes from identity's DB, not from Clerk

En el repositorio

  • GatewaySecurityConfig
  • InternalIdentityMintGlobalFilter
  • GatewayRoleResolver
  • platform-security-starter
SeguridadPremium tiene exactamente una derivaciónUn solo método lo decide y un administrador siempre es premium. No hay una segunda bandera que mantener sincronizada.La lógica de derechos duplicada en tres servicios acaba siendo tres respuestas sutilmente distintas, y el fallo aparece como «esta pantalla cree que no estoy suscrito».

La propagación es solo derivación: sin evento nuevo, sin read model, sin llamada síncrona. El servicio de identidad expone el valor derivado, el gateway lo estampa como claim al emitir el token interno y cada servicio lee el claim. Añadir una función restringida a premium es leer un booleano.

El derecho en sí es la unión de las concesiones activas de un usuario: una suscripción de pago, un código promocional, una cortesía concedida por un administrador. Por eso una suscripción que caduca nunca degrada a alguien que aún tiene una promoción válida: la pregunta «¿tiene derecho este usuario?» se hace en un solo sitio y se responde con todos los motivos a la vez.

ConcesiónEl claim es tan fresco como la ventana de caché. Es una elección deliberada: ir y volver a identity en cada petición del borde sería un trato mucho peor.

// The one and only derivation of "premium" in the fleet.
// An ADMIN is always premium: no second flag to keep in sync.
public boolean isPremium() {
    return accountType == AccountType.PREMIUM || role == Role.ADMIN;
}

En el repositorio

  • User.isPremium()
  • InternalPrincipalView
  • EntitlementPolicy.isEntitled
SeguridadLa superficie de dinero sale apagadaUna sola bandera, apagada por defecto, cierra toda la superficie de pago en la capa de seguridad y otra vez dentro del caso de uso.Los pagos se aplazaron por un motivo ajeno al código: el proveedor solo da de alta a una empresa registrada y todavía no hay ninguna. Borrar código que funciona habría sido la respuesta equivocada; dejarlo accesible habría sido peor.

Con la bandera apagada, el checkout, el ciclo de vida de la suscripción, el libro de pagos, la lista de planes, el webhook del proveedor y la superficie administrativa de reembolsos son inaccesibles para todo el mundo: usuario, administrador o anónimo. Dos capas sobre una bandera: la configuración de seguridad deniega las rutas y el caso de uso del checkout se niega dentro del proceso, así que no puede iniciarse ningún cargo ni desde dentro.

El resto del diseño asume que algún día se encenderá. Nunca se almacena ningún dato de tarjeta: los guarda el formulario alojado del proveedor. El importe siempre se resuelve en el servidor a partir de un identificador de plan, nunca se acepta del cliente. El acceso solo se concede desde un estado confirmado por webhook, porque un retorno del navegador puede abandonarse o falsificarse y no es una autoridad.

ConcesiónEl código que no se ejecuta no se ejercita. Por eso el orden del interruptor tiene su propia prueba, para que la puerta más externa no se pudra mientras espera.

En el repositorio

  • BillingSecurityConfig.PAID_SURFACE
  • StartCheckoutInteractor
  • BillingKillSwitchSecurityIT
SeguridadUna comprobación escrita donde se usa un secreto solo protege ese secretoUn único registro nombra todos los secretos sin los que la flota no debe arrancar, y un solo gancho de configuración rechaza un arranque en producción antes de que exista el contexto de aplicación.La base de código ya conocía este patrón y lo había escrito a mano en cuatro beans. Aun así tenía ciento cuarenta y cuatro claves de configuración sin ninguna protección, porque una comprobación colocada dentro del bean que consume un secreto protege exactamente una clave. Qué claves estaban protegidas se había convertido en el registro de qué bean editó alguien ese día.

Dónde vive una regla así decide hasta dónde llega. La única biblioteca que compartían los catorce módulos arrancables era la de observabilidad, y el starter de seguridad no puede añadirse al gateway en absoluto: arrastra la pila de servlets, y un gateway reactivo se niega a arrancar cuando la encuentra en el classpath. Abrir un módulo nuevo era mejor que esconder una regla de seguridad dentro de un jar que lleva el nombre de otra cosa.

El hallazgo más afilado fue un valor por defecto versionado que funcionaba. El endpoint de claves del proveedor de identidad apuntaba por defecto a un tenant de desarrollo en vivo, así que olvidarlo en producción no fallaba: aceptaba en silencio las firmas de ese tenant, convirtiendo a cualquiera que se hubiese registrado allí en un usuario válido aquí. Cuando un valor por defecto es inevitable, elige uno que no pueda funcionar —un host reservado e inválido— antes que uno que funcione contra la cosa equivocada.

ConcesiónCatorce módulos ahora se niegan a arrancar si falta una clave: un primer despliegue peor y un segundo año mejor. El fallo es ruidoso, inmediato y dice el nombre de la clave.

// The secret is registered once, centrally, with the consequence of
// forgetting it. Not a check inside the bean that happens to use it:
// that version only ever guards the one key someone remembered.
GuardedSecret.fleetWide(
    "SENTRY_DSN",
    List.of("sentry.dsn", "SENTRY_DSN"),
    Set.of(),
    "production exceptions reach nobody — they go to container stdout"
        + " and are lost on the next restart, with no symptom anywhere"),

En el repositorio

  • libs/platform-config-guard
  • PlatformSecretRegistry
  • EnvironmentPostProcessor
  • security-gates.sh
SeguridadEstar al día no es lo mismo que estar escaneadoUn inventario de componentes se genera y escanea de forma periódica, las imágenes base están fijadas, y cada supresión lleva una caducidad que el propio escáner aplica.Esta fase se planificó esperando no encontrar nada: cada framework estaba en su última versión. El primer escaneo devolvió cuarenta y seis avisos altos o críticos, seis de ellos críticos y tres directamente en la ruta de petición, incluido un fallo de máxima severidad en el lenguaje de expresiones del gateway, que es la única puerta de entrada de la flota. Una versión está limpia el día en que se elige. El aviso se publica después, y en el repositorio no cambia nada cuando ocurre.

Sobreviven dos sobreescrituras de dependencia, y cada una lleva su condición de retirada en un comentario en lugar de en la memoria de alguien: bórrala el día en que un escaneo salga verde sin ella. Las supresiones cumplen el mismo estándar. El fichero exige una justificación y una fecha de caducidad, y el escáner aplica la fecha, así que una entrada deja de silenciar nada cuando vence y la puerta se pone roja por sí sola.

Las entradas de la compilación también son dependencias. La etiqueta de una acción de workflow es un puntero móvil: si se mueve, se ejecuta otro código contra nuestro checkout con nuestro token, y en nuestro diff no cambia nada, así que las acciones se fijan por commit. El wrapper de compilación verifica por hash el archivo que descarga, porque la seguridad del transporte autentica al servidor, no al artefacto. Las imágenes base también están fijadas, y sin eso escanearlas no significa nada: una sola máquina había acumulado en silencio siete imágenes de builder y cinco de ejecución, y nada registraba cuál había usado cada compilación.

ConcesiónUn escáner en lugar de dos. Dos habrían significado dos bases de datos de vulnerabilidades, dos umbrales y dos ficheros de supresión que mantener de acuerdo: exactamente la divergencia que esta base de código sigue pagando en otros sitios, importada a la cadena de suministro.

# An empty bill of materials scans perfectly clean, and a clean scan is
# indistinguishable from a good one by exit code alone. Anything that
# narrows the reactor lands here, so the floor is asserted first.
components=$(jq '.components | length' "$SBOM")
if [ "$components" -lt "$MIN_COMPONENTS" ]; then
  fail "SBOM has $components components (< $MIN_COMPONENTS) — scan not trustworthy"
fi

En el repositorio

  • dependency-scan.sh
  • CycloneDX SBOM → Trivy
  • .trivyignore.yaml (expired_at)
  • supply-chain.yml (weekly)
SeguridadQuién puede leer esto estaba escrito; cuándo desaparece, noCada columna asociada a un usuario se clasifica en una de cuatro categorías de retención, y el borrado alcanza tanto a la segunda copia como a la primera.El control de acceso responde a quién puede ver algo. Nada de eso responde a cuándo esa cosa deja de existir, y esa laguna no parece un fallo de permisos, así que nunca aflora en una revisión de permisos.

El evento de borrado de cuenta tenía dos consumidores y ambos hacían lo mismo: eliminar un nombre proyectado. El nombre desaparecía y el contenido no, así que cada conversación con el asistente, con su prompt y su respuesta completos, seguía asociada indefinidamente al usuario borrado. Ahora el borrado alcanza el contenido, y alcanza primero al duplicado: un registro de evaluación incrusta el mismo material literalmente y no tiene columna de usuario propia, así que debe irse dentro de la misma transacción y antes de la fila que lo une.

Borrar no es la respuesta correcta para cada categoría, y decir cuál lo es constituye el trabajo. Una conversación privada se borra. Una contribución pública permanece y se anonimiza al leerla, porque eliminarla reescribe las páginas de otras personas. Un voto permanece, porque es una entrada de un número ya publicado. Los registros de pago permanecen porque la ley lo exige. Aparte, una línea de log es una segunda copia sin dueño, sin retención y sin forma de atender una solicitud de borrado, así que lo que viaja allí es el identificador seudónimo; y el peor infractor era el valor por defecto: un emisor de correo de relleno escribía una línea por destinatario, de modo que cada difusión volcaba el directorio de usuarios en el log.

ConcesiónAnonimizar en el momento de la lectura cuesta una consulta en rutas que antes unían un nombre directamente. Es la única versión que deja intacto el historial de todos los demás.

En el repositorio

  • identity.user-deleted
  • chat_turn_log
  • judge_evaluation.full_prompt
  • SECURITY_RUNBOOK §6
ArquitecturaUna comprobación que no se ejecuta no es una comprobaciónLa última fase de la campaña de seguridad auditó el sistema en ejecución en lugar del código fuente, y encontró cuatro defectos más, entre ellos que la flota iba cinco fases por detrás del código.Nueve fases habían verificado el repositorio. Ninguna le había preguntado nada a la flota en ejecución. La distinción suena quisquillosa justo hasta que cuesta algo.

La primera medición en vivo parecía una regresión: una petición anónima de salud devolvía el desglose completo de componentes, una fuga cerrada días antes. No había regresado. Las imágenes se etiquetaron dos horas antes de que aterrizara esa corrección, y ninguna de las cinco fases posteriores existía en ningún contenedor, mientras todas las comprobaciones del árbol de fuentes estaban en verde. Un repositorio de imágenes caduca en silencio bajo una etiqueta móvil, así que ahora cada servicio sella su compilación y una comprobación rechaza una flota más antigua que el código que dice ejecutar.

Dos suites estaban en rojo o ausentes y nadie podría haberlo sabido, porque nada las ejecutaba. La suite de cabeceras del frontend no tenía ni script ni workflow. El nivel profundo de extremo a extremo solo se dispara con una etiqueta de versión, y este repositorio nunca ha tenido ninguna; su primera ejecución real contra una flota viva encontró dos defectos nuevos que leer el código no habría encontrado, porque en ambos casos el código parece correcto. Un manejador general convertía excepciones de petición inválida del framework en errores de servidor desde un endpoint anónimo, y el único módulo que no puede heredar el contrato de error compartido resultó ser el que da a internet.

ConcesiónProbar el sistema en ejecución es lento, necesita contenedores y no puede correr en cada commit. Por eso es un nivel con su propio disparador, y la lección anotada al lado es que un disparador que nadie acciona equivale a no tener prueba.

# Ask the running fleet what it is, rather than assuming it is the repo.
# The first time this was asked, the answer was five phases old: every
# check in the source tree was green and none of it was deployed.
built=$(curl -fsS "$svc/actuator/info" | jq -r '.build.time')
[ "$built" \> "$LAST_FIX" ] || fail "$svc image predates the fix ($built)"

En el repositorio

  • fleet-build-check.sh
  • /actuator/info build stamp
  • fleet-e2e.sh negative tier
  • ActuatorExposureIT
ArquitecturaTrece servicios con el mismo interiorLa infraestructura depende de la aplicación y la aplicación de un dominio puro, nunca al revés. Un caso de uso es un interactor y un comando.Los sistemas distribuidos deben su fama al espacio entre servicios, pero la mayor parte de la confusión vive en realidad dentro de ellos. Que los trece interiores sean idénticos significa que aprender un servicio te enseña todos.

El dominio es Java plano sin framework dentro, que es lo que permite probar el motor de puntuación como aritmética y no como un contexto de Spring. El modelo de persistencia está separado del modelo de dominio a propósito: una anotación de ORM es un asunto de almacenamiento y no tiene por qué dar forma a una regla de negocio.

Los límites de transacción viven solo en los interactores, los errores son documentos de problema RFC 7807 y cada servicio es dueño de su cadena de migraciones. La estratificación la comprueba ArchUnit y no una revisión, así que sigue siendo cierta un viernes por la tarde.

ConcesiónMás ficheros por funcionalidad que en un servicio pragmático de tres capas. Con trece servicios compensa; con uno probablemente sería excesivo.

En el repositorio

  • *Interactor per use case
  • ArchUnit rule sets
  • platform-kernel
  • RFC 7807
ArquitecturaTodo nace invisibleLas apps, los problemas y los matches nuevos empiezan pendientes de revisión y llegan al público solo a través de una cola de aprobación.Una plataforma de confianza donde todo aparece al instante no tiene confianza dentro. Pero añadir una segunda bandera de «publicado» junto a un ciclo de estados existente crea dos fuentes de verdad sobre la visibilidad, y acaban divergiendo.

Por eso solo hay una puerta: el ciclo de estados existente responde a «¿esto es visible públicamente?». Lo que cambió es cuándo se disparan los eventos. Los flujos públicos ya no publican al crear (la entidad todavía no es pública) sino al aprobar, y despublicar emite el tombstone de eliminación. Los consumidores no añadieron ningún filtro: simplemente ahora solo reciben eventos de cosas que el público puede ver.

Ese cambio dejó al descubierto toda una clase de fallo. Cada ruta de escritura que cambia la visibilidad tiene que emitir el evento, y un endpoint de administración no lo hacía, así que un elemento rechazado por ahí seguía vivo en el índice de búsqueda y en el corpus del asistente. El arreglo emite sobre la propia transición de visibilidad en lugar de confiar en que cada llamante se acuerde.

ConcesiónLa aprobación es un cuello de botella humano y hubo que construir acciones en lote, con informe honesto de éxito parcial, porque un lote que dice «hecho» cuando salieron bien once de doce es peor que no informar.

En el repositorio

  • AppStatus.isPubliclyVisible()
  • PendingItem
  • *-pending-review topics
  • BulkProgressStream
ArquitecturaUna capa de pruebas que rompe la infraestructura a propósitoContenedores reales, broker real y un proxy de red que la prueba puede cortar, con el sistema bajo prueba de un lado y el cliente de la propia prueba del otro.Toda garantía de este sistema (durabilidad del outbox, deduplicación, mensajes muertos) es una afirmación sobre lo que pasa durante un fallo. Las pruebas unitarias comprueban el camino feliz de cada pieza; no pueden decirte que la cadena aguanta.

La asimetría es todo el diseño. La aplicación llega al broker y a la base de datos a través del proxy; el consumidor de la propia prueba se conecta directamente. Así una prueba puede quitarle Kafka a la aplicación, ver cómo se acumula el outbox con sus contadores de reintentos, restaurar el broker y comprobar que se vacía, todo ello con su vía de verificación viva.

Un detalle nada obvio lo hace posible. Un cliente de Kafka arranca contra una dirección y después recibe la dirección anunciada del broker, a la que se conecta directamente, así que hacer proxy solo de la dirección de arranque no hace proxy de nada. Por eso el broker se levanta anunciando el puerto del propio proxy, con lo que tanto el arranque como los datos pasan por él. Sin eso, cortar el proxy no corta nada y la prueba pasa por el motivo equivocado.

ConcesiónEstas pruebas gastan segundos reales dentro de las caídas a propósito, así que la capa está etiquetada y excluida de la compilación por defecto. El ciclo rápido sigue siendo rápido; el caos corre con su propio perfil.

// The system under test reaches Kafka through the proxy; the test's own
// consumer does not. Without that asymmetry, cutting the broker would
// blind the assertions as well as the application.
registry.add("spring.kafka.bootstrap-servers", ChaosIntegrationTest::proxiedBootstrapServers);
// …and the broker advertises Toxiproxy's host port, or only bootstrap
// would be proxied and cutBroker() would cut nothing.

En el repositorio

  • ChaosIntegrationTest
  • Toxiproxy 2.12
  • OutboxOutageChaosIT
  • mvn -Pchaos verify
IA aplicadaUn asistente anclado solo en el corpus de la propia plataformaBúsqueda vectorial sobre el contenido que el servicio proyecta desde el log de eventos, detrás de un puerto neutral respecto al proveedor y con un cambio de modelo de una línea.Un asistente que en una plataforma de confianza responde con conocimiento general del mundo es peor que no tener asistente: suena autorizado sobre cosas que la plataforma no puede respaldar.

El corpus es el triángulo app–problema–match, proyectado hacia el servicio de IA desde los mismos eventos que consumen los demás. La recuperación es una búsqueda de vecinos aproximados sobre embeddings multilingües, con el índice ajustado para que la cobertura no se hunda en silencio cuando los filtros estrechan el conjunto de candidatos.

La capa de aplicación nunca sabe qué proveedor responde. En esa frontera hay dos lecciones grabadas. Pedirle a un modelo que «devuelva JSON» no es un contrato: envuelve la salida en un bloque de código y el parseo falla; prefijar la llave de apertura pasa la primera prueba y luego muere con una comilla sin escapar dentro de una cadena, una clase de fallo que el prefijo no puede evitar por construcción. El arreglo definitivo es decodificación restringida contra un esquema, que hace la salida inválida irrepresentable y no meramente desaconsejada.

ConcesiónAnclarse estrictamente en nuestro propio contenido significa que el asistente dirá que no lo sabe en vez de adivinar. Aquí es el comportamiento correcto y aun así, de vez en cuando, una demo peor.

En el repositorio

  • pgvector vector(1024) · BGE-M3
  • HNSW vector_cosine_ops
  • LlmPort
  • AnthropicChatClient
IA aplicadaEl contador es el libro mayorCuatro límites de gasto corren antes de cualquier llamada al modelo, y el uso se suma desde el registro de lo que realmente se facturó, nunca desde un contador aparte.Un segundo contador junto al libro mayor es una segunda cosa que puede estar mal, y las dos acabarán divergiendo. El único número que no puede mentir sobre el gasto es el que se deriva de los propios registros de gasto.

Cuatro capas corren en orden en lo alto de cada caso de uso que gasta: una ventana corta deslizante, un techo semanal, un límite diario global y un fusible acumulado. Las lecturas fallan cerrando (la cartera no se abre porque una base de datos estuviera un momento inaccesible) y una ventana solo se abre con una petición que pasó todas las capas, así que un rechazo nunca arranca el reloj de nadie.

El coste se calcula exactamente en un sitio, el adaptador del proveedor, y un modelo sin precio registrado se niega a arrancar: algo cuyo coste no se puede calcular nunca debe atender tráfico. Además, una vía automática cara no se limitó sino que se eliminó: el guardián de presupuesto más seguro es no tener un gastador sin supervisión.

ConcesiónSumar un libro mayor en cada petición cuesta una consulta. Es barato al lado de la llamada al modelo que protege, y es la única versión verdadera del número.

En el repositorio

  • BudgetGuard
  • chat_turn_log
  • ai.usage_window
  • insight_run_log
IA aplicadaLa automatización puede crear contenido, nunca una señal de confianzaEl servicio de siembra escribe apps y problemas a través de la API normal del servicio dueño, y nunca emite un voto.En una plataforma cuyo producto entero es una puntuación, una puntuación fabricada no es un atajo: es una mentira sobre lo único que el producto vende.

Los datos públicos recolectados se normalizan a contenido neutral de plataforma y se escriben por la API de escritura habitual del dueño, donde aterrizan pendientes de revisión como todo lo demás. El sembrador no reimplementa la moderación: la alimenta. La seguridad ante repeticiones viene de un registro de procedencia por fuente e identificador externo, no de deduplicación de eventos: no consume ni produce ninguno.

Tampoco tiene superficie pública alguna: sin ruta en el gateway, sin API externa, solo un endpoint de operación aislado de la red. A un componente que escribe en nombre de todos los demás debería poder llegar la menor cantidad de gente posible.

ConcesiónUn catálogo sembrado sin puntuaciones parece más vacío que uno con puntuaciones inventadas. Ese es el estado honesto de una plataforma antes de tener usuarios, y mostrarlo es justamente el objetivo.

En el repositorio

  • seed-service (8093)
  • provenance (source, external_id)
  • no /api/v1 surface
ProductoLa puntuación es una fórmula, no una sensaciónAritmética pura en la capa de dominio: normalizar, decaer con la edad, regularizar hacia un prior, mezclar por peso de eje.Una nota en la que se pide confiar tiene que poder explicarse. Si nadie puede decir por qué un match saca un 7,4, ese número es decoración.

Tres elecciones la sostienen. Un prior bayesiano atrae las muestras pequeñas hacia el centro, de modo que tres votos entusiastas no producen un diez perfecto. El decaimiento por antigüedad pesa menos las opiniones viejas (el software envejece) pero tiene un suelo, porque una valoración antigua no es una valoración inútil. Y el desglose por ejes se publica junto al titular, porque un solo número nunca responde al «por qué».

El motor toma votos y una marca de tiempo y devuelve un resultado. Sin repositorio, sin reloj, sin lectura de configuración, lo que significa que se prueba como aritmética y que las mismas entradas siempre dan la misma salida. Las puntuaciones que cachean otros servicios llegan como eventos que llevan el valor calculado, así que una reentrega converge en lugar de acumularse.

ConcesiónLa regularización hace que los matches nuevos parezcan poco llamativos durante un tiempo. Es honesto: una nota de tres votos no debería parecerse a una de trescientos.

q        = (rating − 1) / 9                            // 1–10 ballot → [0,1]
decay(d) = 0.75 + 0.25 · σ(0.005 · (730 − d))         // sigmoid recency, 75% floor
axisQ    = (Σ decay·q + k·0.5) / (Σ decay + k)        // Bayesian prior, k = 3
score    = 10 · Σ(wᵢ · axisQᵢ) / Σ wᵢ                 // weighted blend → 0–10
confidence = LOW (<5) · MEDIUM (<30) · HIGH (≥30) ballots

En el repositorio

  • HandyScoreCalculator
  • pure domain, no I/O
  • HandyScoreCalculatorTest

Frontend

La otra mitad del sistema

Trece servicios de backend solo sirven si lo que hay delante se mantiene coherente. La interfaz se construyó con el mismo instinto: reglas que rompen una compilación, no reglas que se pide recordar.

Servidor primero, con una capa de rutas fina

Todo es componente de servidor por defecto; la directiva de cliente solo aparece donde la interactividad realmente lo exige. La capa de rutas se mantiene deliberadamente fina: lee parámetros y metadatos y delega en una vista que orquesta la página. Esa separación se impone: si la capa de rutas importa un servicio, la compilación falla.

Una capa de datos de cuatro niveles

Con trece servicios detrás, un cambio de contrato debe tocar exactamente un fichero. Cada nivel solo puede hablar con el siguiente hacia abajo, y saltarse uno rompe la compilación en vez de una revisión.

  1. 1. Endpoints

    URLs en crudo, declaradas una vez por dominio.

  2. 2. API

    El único nivel autorizado a tocar un cliente de red.

  3. 3. Services

    Anticorrupción: las formas externas se vuelven modelos internos y los errores se normalizan.

  4. 4. Consumers

    Server actions y hooks de consulta. Nunca ven una URL.

Cinco reglas de arquitectura, comprobadas en cada ejecución

  • 01

    Ningún módulo entra en el interior de otro: solo pasa por su punto de entrada público

  • 02

    Ningún consumidor se salta la capa de servicios

  • 03

    Ningún componente se salta la capa de API para pedir datos directamente

  • 04

    Sin dependencias circulares entre módulos

  • 05

    La capa de rutas no importa ningún módulo de servicio ni de servidor

Cuatro idiomas, cero cadenas incrustadas

Cuatro idiomas en paridad total, con más de mil doscientas claves cada uno. La paridad no es una promesa: dos scripts de auditoría comparan los diccionarios y buscan texto incrustado, y ambos corren dentro del comando de validación. Añadir una cadena sin traducir rompe la compilación.

Pruebas y accesibilidad

Las pruebas viven junto a lo que prueban y corren contra manejadores de red simulados en lugar de un backend real, así que son deterministas sin ser ficticias. La accesibilidad se verifica dentro de la misma suite en vez de auditarse después, y la suite entera corre con el compilador de React activado, tal y como la aplicación se publica de verdad.

Tres cosas que me costaron un día cada una

Un formulario que muere en silencio bajo el compilador

Reiniciar un formulario dentro de un efecto deja de funcionar en cuanto se activa el compilador: sin error, el formulario simplemente se queda inerte. Sembrar valores por defecto y revalidar es el patrón correcto, y ahora está escrito donde lo buscará la siguiente persona.

Un proveedor de identidad asíncrono produce una respuesta incorrecta permanente

La autenticación se carga de forma asíncrona. Una lectura por usuario lanzada antes de que esté lista vuelve vacía y se cachea como si fuera la verdad: la pantalla no está rota, está equivocada con total seguridad. Condicionar la lectura al estado de carga es una línea; darse cuenta del fallo es lo difícil.

Mide antes de culpar a la herramienta

La memoria del servidor de desarrollo crecía hasta morir, y parecía exactamente una fuga en nuestro código. Era una caché de compilación sin límite aguas arriba, que solo afectaba al desarrollo. Documentarlo con la evidencia, y con la solución provisional, valió más que una suposición.

Calidad y pipeline

Qué tiene que pasar antes de que algo se integre

Cada regla descrita en esta página solo es real porque hay algo que se niega a compilar cuando se incumple. Esto es esa maquinaria.

Cuatro capas de pruebas

CapaQué cubre
Guardas sin redReglas de arquitectura, pruebas de dominio y de caso de uso, comprobaciones de contratoLa fase de pruebas por defecto
IntegraciónBase de datos y broker reales: idas y vueltas del outbox, persistencia, consultas nativas, reproducción de mensajes muertosLa fase de verificación
CaosFallo inyectado: caída del broker, caída de la base de datos, latenciaSu propio perfil, excluido de la compilación por defecto
Extremo a extremoLa flota arrancada, comprobada por salud y luego ejercitada a través del gatewayBajo demanda y en las etiquetas de versión

Integración continua

Una matriz dinámica filtrada por rutas

Un cambio en un servicio compila ese servicio y sus bibliotecas. Un cambio en una biblioteca compartida compila todo. Un cambio solo de documentación no compila nada. Los fallos no cancelan a sus hermanos, así que un servicio roto no oculta a otro.

Etiquetas de versión

Una etiqueta construye diez imágenes de contenedor con buildpacks (no hay ni un Dockerfile escrito a mano en el repositorio) y ejecuta la capa de extremo a extremo contra ellas.

Un solo comando local para todo

Un pipeline de cuatro etapas: comprobación de formato, después las pruebas de todo el reactor, después diez imágenes de contenedor y después la flota levantada con esas imágenes frescas y comprobada hasta que cada servicio se declara listo. No se limita a informar de éxito: deja la flota en marcha y al día.

La puerta del frontend

Formato, lint, reglas de arquitectura, auditoría de consistencia de la interfaz, dos auditorías de internacionalización, comprobación de tipos y la suite de pruebas: un comando, el mismo que ejecuta el pipeline.

La documentación es una prueba

Una prueba de contrato descubre cada registro de evento de la biblioteca compartida y rompe la compilación raíz si falta en el catálogo de eventos. Junto a ella hay ficheros de referencia de serialización, comprobaciones de ida y vuelta y una comprobación de huérfanos. Así, la API asíncrona no puede alejarse del documento que la describe, que es la única versión de «la documentación se mantiene al día» que ha funcionado alguna vez.

Observabilidad y dos fallos que detectó

Trazas, métricas y logs salen de cada servicio por un único starter autoconfigurado, y el identificador de correlación viaja tanto por cabeceras HTTP como de Kafka, de modo que una petición puede seguirse a través de un salto por el broker. Los objetivos de servicio conviven con las reglas de mensajería y de seguridad, las alertas llegan a un destino real, y un sumidero aparte recibe los fallos: está atado al nivel de log de error, así que la decisión sobre qué cuenta como fallo se toma una vez, en el manejador de excepciones, y no una segunda vez en una biblioteca de terceros.

Se ganó el sueldo dos veces. Las latencias percentiles eran inservibles en toda la flota porque los histogramas no emitían sus buckets; el arreglo salió a las diez imágenes y, tras eso, cada servicio reportó un p99 real en lugar de nada, y una prueba de carga acotada confirmó que el limitador del borde descarta el exceso sin ni un solo error de servidor. Más tarde resultó que la tubería de alertas estaba sana de extremo a extremo y no entregaba a nadie: un receptor nulo acepta cada notificación y la descarta, lo cual es indistinguible de no tener nada que informar. Ya no está documentado como prohibido: está borrado del fichero, así que una ruta que lo apunte falla ahora la validación de configuración.

Entrega

De un push a una flota que demuestra qué build es

Lo interesante de un despliegue no es que tenga éxito. Lo interesante es si una ejecución exitosa y una que no hizo nada se distinguen. Todo lo que sigue existe porque, al menos una vez, no se distinguían.

Qué hace un push a la rama de desarrollo

  1. 01

    Un mapa de cambios decide qué se reconstruye

    Un solo archivo posee la respuesta a “cuál de los catorce módulos tocó este commit”, y lo leen tanto el flujo de integración como el de despliegue. Un módulo ausente de ese mapa no se rechaza: se omite en silencio, y un job omitido se informa en verde, así que el mapa se trata como una regla y no como una comodidad.

  2. 02

    Solo los módulos que cambiaron se convierten en imágenes

    Las imágenes se construyen con buildpacks; no hay ningún archivo de contenedor escrito a mano en ningún módulo de backend. Cada una se etiqueta con el commit y no solo con una etiqueta móvil, porque una etiqueta de registro que se mueve es una flota que envejece sin que cambie nada en el repositorio.

  3. 03

    Se aplica la composición, no la diferencia

    El servidor descarga y recrea solo los servicios que cambiaron, y después recorre la composición para levantar lo que falte sin tocar lo que ya corre. El primer despliegue demostró por qué: un delta no puede responder “qué falta en el destino”, solo “qué cambió desde la última vez”.

  4. 04

    Una puerta de salud, no una esperanza

    Cada servicio tiene que informarse listo antes de que la ejecución continúe. El techo se dimensiona para el caso vinculante — catorce servicios y once migraciones de esquema arrancando en frío sobre cuatro núcleos — porque equivocarse por exceso solo avisa tarde, mientras que equivocarse por defecto declara rota una flota sana.

  5. 05

    Se le pregunta a la flota qué build es

    Cada módulo publica un sello de build y el despliegue lo compara con lo que la tubería acaba de producir. Este paso existe por el peor hallazgo de todo el proyecto: durante cinco fases la flota en ejecución era más antigua que el código, y todas las comprobaciones del lado del código estaban en verde. Un despliegue que no se puede fechar es un despliegue que no se ha verificado.

  6. 06

    Y si algo quedó atascado

    Por último se revisa el outbox en busca de filas que nunca se publicaron. Una tubería de mensajes rota se ve exactamente igual que una ociosa — la salud sigue verde, no aparecen errores y la cola simplemente deja de avanzar — así que la única señal honesta es el recuento de filas sin publicar.

La máquina, y la puerta de entrada

Un único servidor pequeño ejecuta toda la flota. Nada la alcanza desde internet: un túnel exclusivamente saliente conecta con la red de borde, el gateway escucha en loopback y el cortafuegos permanece cerrado salvo para administración. La clave de despliegue sigue el mismo principio: de solo lectura y limitada a un repositorio en vez de un token de toda la cuenta, porque una credencial debe poder hacer su trabajo y nada más.

Dos de estas decisiones fueron mediciones, no preferencias. El subdominio tuvo que perder una etiqueta porque un certificado comodín cubre exactamente una, de modo que el nombre original no completaba el handshake en absoluto: un fallo que parece una configuración de protocolo obsoleta y no lo es. Y el número de proxies en los que confiar se leyó de un contenedor de eco desechable antes de configurar nada, porque esa constante decide si el límite de peticiones anónimas cuenta a un cliente o cuenta a todo internet como uno solo.

Lo que encontraron las primeras ejecuciones reales

Una tubería que nunca se ha ejecutado nunca se ha verificado. Tres hallazgos de las primeras ejecuciones, ninguno de los cuales podría haber producido una revisión:

Dos meses de builds en verde que nunca compilaron

El wrapper de Maven se había commiteado sin su bit de ejecución desde el primer commit, así que cinco flujos habrían muerto en su primer comando. Nada lo mostraba, porque los jobs que corrían no lo necesitaban y los que sí lo necesitaban los omitía el mapa de cambios. El primer job que realmente requirió un compilador fue el primer despliegue real.

Un informe de seguridad que era mentira

El escaneo de imágenes informó de catorce imágenes con hallazgos críticos. No había ninguno: al runner se le había acabado el disco y el escáner no había abierto ni una sola imagen. Su caída y sus hallazgos compartían un mismo código de salida, así que el fallo que pedía más disco se informó como catorce vulnerabilidades. Ahora son códigos distintos, y el consejo final se considera parte del informe.

Un verificador que informó de su propia ceguera como veredicto

La comprobación del sello de build declaró trece módulos no verificables y aconsejó reconstruirlos todos. Los sellos estaban ahí; la comprobación había perdido sus propias credenciales y leía un error de autenticación como un sello ausente. Un control que no puede alcanzar su objetivo tiene que decirlo: “lo que audito está mal” y “no pude ejecutarme” son frases distintas.

Una regla correcta en el repositorio y ausente en internet

La política de rastreo declara once rutas cerradas, una de ellas una decisión deliberada de privacidad sobre indexar perfiles de miembros por su nombre. El archivo que se sirve a internet no contiene ninguna y dice lo contrario. La red de borde genera su propia versión por una función que nadie activó, así que la fuente es correcta, la revisión fue correcta y la regla sencillamente no está en vigor. Elegir un proxy inverso es elegir algo con derecho a responder en lugar del origen — y lo que ha asumido solo se averigua preguntándole al sistema en vivo, nunca leyendo el código.

Lo que viene: dos entornos, elegidos por una rama

Hoy existe un solo entorno y es el que está en vivo. La forma de abajo se escribe antes de construirla porque tres de sus restricciones son lo bastante contraintuitivas como para aprenderlas, si no, por la vía cara.

La rama es lo único que elige una persona

La rama de desarrollo despliega el entorno de pruebas permanente; la rama principal despliega el real. Todo lo demás —nombres de host, instancia de identidad, etiquetas de imagen, secretos— se deriva de esa única elección. Las definiciones de despliegue no se bifurcan en dos copias a propósito: dos copias divergen, y la que se edita es la que alguien abrió ese día, así que la rama selecciona valores y no archivos.

La mitad no se puede promover y la otra mitad sí

La práctica habitual es construir un artefacto una vez y moverlo entre entornos. El paquete del navegador compila dentro su host de API en tiempo de construcción, así que la imagen creada para el host de pruebas lleva ese host dentro y producción tiene que reconstruir en lugar de reetiquetar. Las imágenes del backend no tienen entorno en tiempo de construcción y se promueven sin problema. Que las dos mitades de un mismo despliegue tengan reglas realmente distintas merece escribirse antes de producir una interfaz de producción hablando con una API de pruebas.

Dos entornos son dos máquinas

Esto es una medición, no una preferencia. La flota ya está al límite de su máquina y el único clúster de base de datos está atado a su techo de conexiones — por eso cada tamaño de pool en la composición está explícitamente acotado. Dos proyectos en una máquina no son una separación de entornos; son un entorno con dos nombres y una forma compartida de fallar.

El entorno de pruebas tendrá una puerta, y no es código de la aplicación

Una puerta escrita dentro de la aplicación solo se ejecuta en un entorno: no puede ser ejercitada por el entorno que protege y llega a producción como código muerto tras un flag. Su lugar es el borde, donde la petición se rechaza antes de que el origen la vea. Y conviene decir su límite honesto: no puede cubrir el host de la API, porque el navegador llama ahí directamente con un token bearer y no con una cookie, y un secreto compilado dentro de un paquete de navegador no es un secreto. Así que lo que de verdad merece cerrarse no es una URL, es la creación de cuentas — y eso es un ajuste en el proveedor de identidad, no una rama dentro del producto.

La entrega en cifras

14

módulos desplegados

9

flujos de CI/CD

32

puertas de configuración por pull request

49

trampas documentadas

Nada de esto hace correcto al sistema. Hace comprobables sus afirmaciones, que es la única propiedad que sobrevive a equivocarse.

En cifras

Contado, no estimado

Cada cifra de esta página sale de esta tabla, y cada fila dice cómo se contó para que pueda comprobarse. Los recuentos de líneas incluyen comentarios: en esta base de código el razonamiento vive junto al código, a propósito.

MétricaValor
Módulos Maven25
Servicios13
Módulos desplegados14
Ficheros fuente de backend1516
Líneas fuente de backend70.869
Ficheros de prueba de backend331
Casos de prueba de backend1524
Clases de prueba de integración27
Interactores de caso de uso146
Controladores REST55
Endpoints HTTP191
Métodos listener de Kafka73
Topics de evento registrados26
Migraciones de base de datos106
Hallazgos de la auditoría de seguridad54
Puertas de configuración por pull request32
Reglas de alerta18
Flujos de CI/CD9
Trampas documentadas49
Ficheros fuente de frontend616
Líneas fuente de frontend55.022
Casos de prueba de frontend436
Módulos de dominio de frontend12
Claves de traducción por idioma1298
Commits en los repositorios270

Contado en los repositorios el 2026-08-09. Periodo de desarrollo: 2026-02-28 → 2026-08-09.

Estado

Qué está hecho, qué aplazado y qué abierto

El sistema está desplegado; el producto no está lanzado. Son frases distintas, y escribir cuál es la verdadera — junto con qué falta y por qué — es más útil que una página que dé a entender que todo se publicó.

Hecho

  • La migración de monolito modular a una flota de trece servicios, completa en código y funcionando de extremo a extremo en contenedores.
  • Base de datos física por servicio, con cada lectura entre servicios servida por una proyección local.
  • Transactional outbox, consumidores idempotentes, mensajes muertos y reproducción bajo demanda en toda la flota.
  • Una única frontera de autenticación en el borde, con los roles resueltos desde el servicio de identidad y no desde el token.
  • Observabilidad verificada en vivo —trazas, métricas, alertas con un destino real y un sumidero de errores que recibe fallos y nada más— y una prueba de carga acotada superada sin ningún error de servidor.
  • Una interfaz en cuatro idiomas repartida en doce módulos de dominio, con reglas de arquitectura impuestas en integración continua.
  • Una campaña de resiliencia que inyectó fallos reales en la flota en marcha. Encontró siete defectos genuinos: entre ellos un edge que quedaba abierto cuando el servicio de identidad era inalcanzable, un procedimiento de reconstrucción documentado que borraba el modelo de lectura que debía restaurar, un registro de auditoría de difusión perdido y cuatro servicios que la integración continua nunca había compilado. Los siete quedaron cerrados, junto con las tres preguntas de consistencia que la campaña había dejado abiertas a propósito.
  • Una auditoría de seguridad completa en once fases, ejecutada la última para que corriera contra la superficie de ataque definitiva. Cincuenta y cuatro hallazgos, ningún falso positivo, nueve de ellos críticos, y cada uno cerrado o registrado con su motivo. Dejó maquinaria en lugar de un informe: puertas de configuración en cada pull request, un inventario de componentes escaneado, un nivel negativo de extremo a extremo y una comprobación que rechaza una flota más antigua que su propio código.
  • Despliegue de extremo a extremo y que se verifica a sí mismo: un push construye solo lo que cambió, lo publica, aplica la composición en el servidor, espera a que cada módulo se informe listo y después le pregunta a la flota en ejecución qué build es y si algún mensaje quedó atascado. El monolito congelado se retiró una vez que la flota llevaba un día entero en marcha: lo último, y solo con aprobación explícita, exactamente como decía el plan.
  • La interfaz está en vivo en la misma máquina, en cuatro idiomas y tras el mismo túnel, así que el producto es alcanzable de extremo a extremo y no solo la API. El primer sondeo del sistema en ejecución hizo trece preguntas y diez volvieron limpias, incluidas las que un repositorio no puede responder: el endpoint de salud revela un estado y nada más, diez endpoints de gestión rechazan a un llamante anónimo, una petición sin ruta devuelve un documento de problema que no refleja lo que se pidió, y sesenta peticiones en paralelo se convierten en cuarenta respuestas y veinte rechazos.

Aplazado, con motivos

Pagos

Aplazados por un motivo ajeno al código: el proveedor de pago solo intermedia para una empresa registrada y todavía no hay ninguna, así que el cobro en vivo no puede activarse legalmente. El servicio de facturación no se borró: sale con su superficie de pago apagada por defecto mientras el canal de premium gratuito sigue funcionando. El disparador es una empresa, no un commit.

No-objetivos registrados

Topics de reintento no bloqueantes, captura de cambios para el outbox y una topología de Kubernetes con autoescalado. Cada uno se aplazó con su razonamiento escrito, de forma que una decisión futura parta del argumento y no de cero.

Abierto

  • Cada umbral del sistema —objetivos de servicio, límites de tasa, la cuota diaria de subida— se eligió sin ningún tráfico con el que elegirlo. Son conjeturas deliberadas con su razonamiento anotado al lado, y seguirán siendo conjeturas hasta que una hora de carga real diga lo contrario.
  • La política de seguridad de contenido corre en modo de solo informe. Pasarla a aplicación no es un cambio de código sino una medición: una hora de tráfico real y un informe de violaciones vacío. Aplicar una política sobre una suposición significó una vez que nadie pudiera iniciar sesión.
  • Dos cosas que la auditoría no pudo cerrar en código, y no fingió lo contrario. La mitad semántica de la defensa contra inyección de prompt necesita una ejecución en vivo contra un corpus envenenado a propósito y una llamada de pago al modelo. La resistencia a un conjunto coordinado de cuentas nuevas es una decisión de producto sobre la antigüedad de la cuenta, no un límite de tasa: ningún cubo se llena si cada cuenta vota una vez.
  • La propia lista de comprobación de la salida a producción: una revisión visual en vivo en cinco anchos de pantalla, dos temas y cuatro idiomas, una alerta vista en su canal y no solo aceptada por la tubería, y el flujo de inicio de sesión ejercitado contra la instancia de identidad de producción. Todo ello necesita un sistema en marcha y un par de ojos, y por eso nada de ello se afirma aquí.

Está desplegado, es medible y es comprobable — y sigue siendo honesto sobre lo que no ha terminado. No hay contradicción: lo segundo es lo que hace que valga la pena decir lo primero.