Saltar a contenido

Architecture Decision Records (ADRs)

Registro de decisiones de arquitectura del ecosistema WorkDone. Cada ADR documenta una decisión: el contexto, las opciones, lo que se eligió y por qué.

Una decisión registrada no se borra ni se edita: si cambia, se crea un ADR nuevo que supersede al anterior. Así queda el rastro del razonamiento.

Formato

Un archivo por decisión: NNNN-titulo-corto.md (ej. 0001-repos-separados.md).

Plantilla mínima:

# ADR-NNNN — Título de la decisión

- **Estado:** propuesto | aceptado | supersedido por ADR-XXXX
- **Fecha:** YYYY-MM-DD

## Contexto
Qué problema o fuerza nos llevó a decidir.

## Decisión
Qué elegimos.

## Consecuencias
Qué gana y qué pierde el proyecto por esta decisión.

De dónde sale este registro

Las decisiones viven en el DECISIONS.md de cada repo (workdone_backend y workdone_mobile), consolidado como v2. Esta página sintetiza ese registro para onboarding: el índice completo abajo y, separadas, las decisiones más formativas para entender CÓMO se programa el sistema. Para el texto íntegro y la historia de cada ADR, la fuente de verdad es el DECISIONS.md del repo correspondiente.

Cómo leer los estados

VIGENTE / ACCEPTED rigen el código hoy. PROPUESTO está sobre la mesa pero no implementado. Cuando un ADR dice "supersede" a otro, el viejo se conserva como historia pero ya no manda. Los ADR 001-017 se consolidaron en v1; sus textos completos están en el historial de git, acá quedan en la tabla por referencia.

Las cuatro reglas de oro

Antes de los ADRs conviene tener presentes los cuatro invariantes que atraviesan casi todas las decisiones. Aparecen citados por número a lo largo del registro:

# Regla de oro Dónde pega
1 El tap (NFC) es el único gesto de registro: toggle inicio/fin ADR-001/002, motor de Trabajo
2 En el device solo se persiste el refresh_token, idealmente cifrado (matizada por ADR-028 mobile) Auth móvil
3 El timestamp del teléfono es la verdad Sync, SLA, drift
4 Los operarios son soft-delete: nunca se borran físicamente Catálogos, FKs

Índice de decisiones — Backend

Repo workdone_backend. Núcleo del dominio, sync, seguridad y los cuatro módulos de v2.1/v2.2/v2.3.

ADR Título Estado Qué decide en una línea
001 NFC sobre QR VIGENTE El registro se hace por chip NFC, no por QR escaneado
002 Tap = toggle, tag único por sanitario VIGENTE (ref. 019) Un tap abre o cierra el Trabajo; un tag por sanitario
003 Cierre automático con duración truncada VIGENTE (ampl. 022) Trabajo olvidado abierto se cierra solo
004 Concurrencia de operarios permitida VIGENTE Varios operarios pueden trabajar en paralelo
005 Offline-first, sync bidireccional VIGENTE La app opera sin red; sincroniza en ambos sentidos
006 Device compartido, usuario+PIN, sesión 30 min VIGENTE Un teléfono lo comparten operarios del turno
007 Tag como URL opaca VIGENTE El tag codifica una URL no adivinable
008 Single-tenant VIGENTE (prec. 018) Una sola instancia, de Corpal
009 Stack (Java 25 · SB 4.0.6 · JHipster 9.1 · MSSQL · Kotlin/Compose/Room) VIGENTE Elección de tecnologías base
010 Soft delete en catálogos VIGENTE Las bajas no borran filas
011 Auditoría JHipster en entidades VIGENTE created_by/last_modified_by automáticos
012 Timestamp del teléfono = verdad VIGENTE El reloj del device manda sobre el del server
013 Idempotencia por client_uuid VIGENTE (ext. 020) Reintentos no duplican registros
014 Operario separado de jhi_user VIGENTE (ext. 021) El operario de campo no es el usuario web
015 Ícono NFC en placa física VIGENTE Señalización física del punto de tap
016 Testing NFC sin emulador VIGENTE Mocks + panel dev + device físico
017 Virtual threads para /sync PROPUESTO Aún no implementado
018 Jerarquía Empresa › Sucursal › Sector › Sanitario ACCEPTED Empresa = cliente del servicio, no la limpieza
019 Máximo 1 tag NFC activo por sanitario ACCEPTED Constraint DB UNIQUE WHERE estado='ASIGNADO'
020 Ciclo de vida del tag (stock, whitelist, auditoría) ACCEPTED Máquina de estados + alta solo desde la app
021 Tres roles de campo en Operario ACCEPTED ADMIN / SUPERVISOR / OPERADOR_LIMPIEZA
022 Supervisión = mismo motor que limpieza ACCEPTED Trabajo.tipo, sin entidad separada
023 Catálogo de eventos configurable con flags ACCEPTED Eventos como filas, no enum
024 "Limpieza válida" como única fuente del SLA ACCEPTED Define qué resetea el reloj de vencimiento
025 Deudas v1 saldadas en la migración ACCEPTED Normalización de UUID, alertas, auditoría
026 Endurecimiento de seguridad post-auditoría ACCEPTED CRUD scaffold → ROLE_ADMIN, 409 en conflicto
027 Duración mínima como property global ACCEPTED app.tap.duracion-minima-seg, detector de doble-tap
028 Portal de transparencia del cliente ACCEPTED Solo lectura, ROLE_CLIENTE, scoping server-side
029 Robustez operativa (V21_ROBUSTEZ) ACCEPTED Días sin exigencia, contingencia de placa, mail, revocación
030 Smiley — opinión del público ACCEPTED Tablet kiosko, terminal como Dispositivo tipado
031 Planificación por rutas (V21_RUTAS) ACCEPTED La ruta planifica, nunca bloquea el tap
032 Flecos del code review v21 (SLA y rate-limit) ACCEPTED Decisiones de owner sobre semántica del SLA
033 updatedAt opcional en entrada de catálogos ACCEPTED Lo completa el server si viene null
034 Relaciones anidadas de DTOs incluyen nombre ACCEPTED El BackOffice muestra nombre, no id
035 Backstop de unicidad para alerta SLA_VENCIDO ACCEPTED Índice único filtrado contra race del scheduler
036 Parity dev↔MSSQL + gate de CI ACCEPTED Migrations validadas sobre SQL Server real
037 Sacar credenciales scaffold de JHipster en prod ACCEPTED Elimina user, resetea admin, fail-closed
038 /sync valida device_uuid del body contra el JWT ACCEPTED No se puede sincronizar como otro device
039 Throttling de login también por usuario ACCEPTED Cierra el bypass rotando device_uuid
040 Authority separada del operario móvil (ROLE_OPERARIO) ACCEPTED Separa la identidad móvil de la web
041 Opinión por QR público NO alimenta tendencias ACCEPTED El QR mide, no dispara correctivas
042 Estado público con umbral, anonimato, slug opaco ACCEPTED Página /p/{slug} sin tracking
043 Planos y mapeo: capa georreferenciada opcional ACCEPTED Módulo desprendible, plano → pin → sanitario
R1 Anti-fraude de ubicación RIESGO ASUMIDO Sin GPS en el tap para el piloto
R2 PIN prestado entre operarios RIESGO ASUMIDO Problema organizacional, no técnico
R3 Pérdida de cola offline RIESGO ASUMIDO Inherente al offline-first; sync inmediato lo acota

Backlog del backend (disparador definido)

Quedan registrados con su gatillo: B1 SLA por franja horaria · B2 Push FCM · B3 Multi-idioma del kiosk · B4 Auditoría de acciones admin · B5 Retención y archivado · B6 QR público (ya promovido a V22) · B7 Subdominio/theming del portal · B8 Visual-regression del BackOffice · B9 Contraste a11y (WCAG AA) · B10 N+1 en dashboard/portal.

Índice de decisiones — Mobile

Repo workdone_mobile (Kotlin/Compose/Room). Su DECISIONS.md comparte los ADR 001-025 con el backend, pero a partir del 026 numera decisiones PROPIAS del cliente Android (telemetría, purga, tokens, backup). Cuidado: los números 026-029 NO son los mismos que en el backend.

ADR Título Estado Qué decide en una línea
001-025 (compartidos con backend) VIGENTE / ACCEPTED Ver índice del backend
026 Telemetría del device en el sync ACCEPTED red_tipo WIFI/CELULAR/…, sin distinguir 4G/5G
027 Cliente Android NO purga por deleted_ids ACCEPTED Traduce la baja a activo=false
028 Tokens en claro en el device (riesgo aceptado) ACCEPTED DataStore sin cifrar durante el piloto
029 Backup de Android deshabilitado ACCEPTED allowBackup=false para evitar restores corruptos
R1-R3 (riesgos compartidos) RIESGO ASUMIDO Mismos que el backend

Backlog del mobile

B1 SLA por franja horaria · B2 Push FCM · B3 Multi-idioma del kiosk · B4 Auditoría admin · B5 Retención · B6 QR como segunda vía de opinión · B7 Subdominio/theming del portal.


A continuación, las decisiones que más afectan CÓMO se programa en este ecosistema. Si sos dev nuevo, leé estas primero: explican por qué el código tiene la forma que tiene.

ADR-005 (backend) · Offline-first con sync bidireccional

Contexto. El personal de campo limpia baños en lugares con conectividad inestable (subsuelos, depósitos, shoppings). No se puede depender de la red en el momento del tap.

Qué se decidió. La app opera 100% offline y sincroniza en ambos sentidos cuando hay red. Lo que el operario registra (taps, eventos, gestión NFC del admin) se encola localmente y se envía después; el server devuelve catálogos, rutas y deltas hacia el device.

Consecuencias. Esta es la decisión más estructurante de todo el sistema. Arrastra el resto:

  • Toda escritura del cliente necesita idempotencia por client_uuid (ADR-013): un reintento no puede duplicar un Trabajo.
  • El timestamp del teléfono es la verdad (ADR-012/regla de oro #3), porque el evento ocurrió offline y el server lo recibe minutos después.
  • Hay colas locales por dominio (trabajo_pendiente, nfc_evento_pendiente) y resolución de conflictos al sincronizar (primero gana, CONFLICTO_ASIGNACION).
  • El riesgo R3 (pérdida de cola si el device se destruye antes de sincronizar) se asume conscientemente, acotado por el sync inmediato tras cada tap.

Implicancia para el dev

Nunca asumas que el server vio un registro en el momento en que ocurrió. Programá pensando en "esto llega tarde, puede reintentarse, y el reloj de origen es el del device".

ADR-009 (backend) · Stack tecnológico

Contexto. Sistema nuevo que necesita backend robusto con auditoría y un cliente móvil de campo.

Qué se decidió. Backend Java 25 · Spring Boot 4.0.6 · JHipster 9.1 · MSSQL (SQL Server). Cliente móvil Kotlin · Jetpack Compose · Room.

Consecuencias. Varias decisiones posteriores son consecuencia directa del stack:

  • JHipster genera CRUD scaffold y entidades con auditoría (ADR-011) — pero también arrastra deuda: credenciales admin/admin por defecto (ADR-037), DTOs con @NotNull updatedAt (ADR-033), endpoints abiertos (ADR-026).
  • MSSQL tiene semánticas propias (un solo NULL en UNIQUE, cascade paths, ALTER con índice) que el dev H2 oculta — ver ADR-036.
  • Room en el cliente impone que las colas offline sean device-local.

ADR-014 + ADR-021 + ADR-040 · Identidad: operario de campo ≠ usuario web

Contexto. JHipster trae jhi_user para el BackOffice. Pero el operario de campo es otra cosa: comparte device, se loguea con PIN, tiene rol operativo.

Qué se decidió (evolución en tres pasos).

  1. ADR-014: Operario es una tabla separada de jhi_user (link user_id opcional). El usuario web no es el operario.
  2. ADR-021: el operario tiene un campo rol (ADMIN | SUPERVISOR | OPERADOR_LIMPIEZA, uno solo). El rol define la experiencia en la app: operador limpia, supervisor controla, admin gestiona NFC.
  3. ADR-040: se crea la authority ROLE_OPERARIO para el JWT móvil, distinta de ROLE_USER (web). Antes ROLE_USER hacía dos trabajos y un token web podía colarse en endpoints de app interpretando su jhi_user.id como operario.id.

Consecuencias. La autorización es siempre server-side y por rol. Los endpoints /api/v1/app/** exigen ROLE_OPERARIO; el CRUD /api/* exige ROLE_ADMIN (ADR-026). Si tocás un endpoint, sabé qué authority lo protege y desde qué cliente viene.

ADR-022 + ADR-023 + ADR-024 · El motor de Trabajo: un solo flujo, eventos como datos, SLA derivado

Estas tres juntas explican el corazón del dominio.

ADR-022 — Supervisión usa el mismo motor que la limpieza. No hay entidad Supervision. Es un Trabajo con tipo = LIMPIEZA | SUPERVISION (lo define el rol del autenticado). Mismo flujo de tap inicio/fin, timer y eventos. Se descartó un checklist con severidad: el multiselect de eventos es más operable en campo y reutiliza todo el motor.

ADR-023 — Los eventos al cierre son datos, no código. Son filas de evento_limpieza (catálogo sincronizado) con cuatro flags de comportamiento: roles_permitidos, es_exclusivo, genera_alerta, invalida_limpieza. Al cerrar, la selección se persiste con snapshot de código+nombre, así el histórico es inmune a cambios futuros del catálogo. Corpal agrega eventos sin release de la app.

ADR-024 — El SLA tiene una única fuente: la "limpieza válida". El reloj de vencimiento se resetea solo con un Trabajo tipo=LIMPIEZA cerrado sin eventos invalida_limpieza. Por lo tanto: "no se pudo limpiar" NO resetea y alerta; la supervisión nunca resetea; el cierre automático sí.

Por qué importa para el dev

Si vas a tocar cierre de trabajos, eventos o el SLA, partí de estas tres. La definición de "limpieza válida" es transversal: la usan el scheduler, el dashboard, el portal cliente y el módulo de rutas. Cambiarla sin entenderla rompe métricas en cascada.

ADR-026 (backend) · Endurecimiento de seguridad post-auditoría

Contexto. Una auditoría adversarial sobre el diff de v2 encontró agujeros reales de autorización antes del piloto.

Qué se decidió. El más importante: todo CRUD scaffold /api/* pasa a ROLE_ADMIN. Antes, el JWT del operario (USER) atravesaba el .authenticated() genérico y leía datos de todos los operarios y hacía DELETE físico. El operario móvil usa SOLO /api/v1/app/*. Además: tap de ADMIN no persiste nada (check de rol antes de resolver el tag), conflicto NFC concurrente devuelve 409, y varias defensas en profundidad.

Regla operativa derivada. Todo endpoint nuevo bajo /api/* que no sea /api/v1/app/* nace cerrado a ADMIN salvo decisión explícita. Internalizá esto antes de agregar un controller.

ADR-031 (backend) · Rutas: planifican, nunca bloquean

Contexto. Se suma planificación de trabajo por rutas (qué recorrer hoy, en qué orden).

Qué se decidió (principio rector que gobierna todo el módulo). La ruta es una capa de planificación ARRIBA del sistema de taps, nunca un bloqueo. El tap libre sigue idéntico: cualquier operario registra cualquier sanitario. Un trabajo fuera de ruta no es un error, es información. Ninguna validación de ruta puede impedir un tap.

Cómo se implementó sin tocar el tap. Los cierres de Trabajo estaban dispersos en 3 puntos. En vez de tocarlos, los 3 publican TrabajoCerradoEvent y un @TransactionalEventListener(AFTER_COMMIT) aparte hace la vinculación trabajo↔parada. Un fallo de vinculación nunca hace rollback del cierre.

Patrón recurrente: efectos secundarios post-commit

Este mismo patrón (publicar evento + listener AFTER_COMMIT @Async, fallo aislado del flujo principal) aparece en el mail de alertas (ADR-029) y en la auto-atención de Smiley (ADR-030). Es la forma estándar de colgar comportamiento sin contaminar el motor de taps.

ADR-036 (backend) · Parity dev↔MSSQL + gate de CI

Contexto. Correr dev contra un SQL Server real destapó que ninguna migration se había validado nunca sobre MSSQL: el job "Testcontainers MSSQL" en realidad corría H2. Tres divergencias MSSQL-vs-H2 quedaron como deuda latente de prod.

Qué se decidió. Se corrigieron las tres divergencias (UNIQUE sobre columna nullable, ALTER con índice dependiente, múltiples cascade paths) y se agregó un gate permanente de CI (schema-mssql) que aplica el changelog entero sobre SQL Server real en cada push.

Acción concreta si trabajás con migrations

El dev usa H2 file-based (target/h2db/). Editar una migration cambia su checksum y rompe el arranque con ValidationFailedException. Antes de actualizar tras un cambio de migration: mvn clean (o borrar target/h2db). Y recordá: si tu migration usa una operación MSSQL-incompatible, H2 no la atrapa — el gate de CI sí, antes del merge.

ADR-028 (mobile) · Tokens en claro en el device durante el piloto

Contexto. El SessionStore del cliente persiste access_token, refresh_token y device_uuid en DataStore Preferences sin cifrar, contradiciendo la regla de oro #2 ("solo refresh_token, cifrado"). androidx.security.crypto está declarada pero sigue en alpha con problemas en Android 14+.

Qué se decidió. Para el piloto, los tokens quedan en claro. NO se adopta androidx.security.crypto mientras esté en alpha: cambiaría un riesgo bajo y entendido (texto plano en device compartido) por uno mayor (fallas de descifrado de una lib alpha que dejarían la sesión irrecuperable). El threat model lo habilita: el device es compartido entre operarios del turno, no es target de robo, y nunca se persiste password ni PIN.

Consecuencias. El vector por backup ya está cerrado (ADR-029, allowBackup=false). Disparadores de revisión: hardening pre-release, exigencia del cliente, o versión estable de la lib.

Nota de evolución (2026-06-15)

El disparador (c) se cumplió: ya hay security-crypto:1.1.0 estable. Pero trae señales de deprecación de Google, así que queda pendiente un spike (¿Keystore directo? ¿Tink?) y una decisión explícita. No bloquea el piloto. Es un buen ejemplo de cómo el registro de ADRs mantiene honesta la deuda: el código y la regla de oro dejaron de contradecirse en silencio.