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/adminpor 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).
- ADR-014:
Operarioes una tabla separada dejhi_user(linkuser_idopcional). El usuario web no es el operario. - 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. - ADR-040: se crea la authority
ROLE_OPERARIOpara el JWT móvil, distinta deROLE_USER(web). AntesROLE_USERhacía dos trabajos y un token web podía colarse en endpoints de app interpretando sujhi_user.idcomooperario.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.