WorkDone · Informe técnico

Sincronización móvil ↔ backend
Estado actual y opciones

Fecha 2026-08-20 Fuentes java.zip (workdone_backend, 451 archivos) · srcmobile.zip (workdone_mobile, 177 archivos) Referencia AUDIT-SYNC-01 (SYNC-R01…R11)
Veredicto

La sincronización ya está implementada y es madura (protocolo v2/C2): un solo round-trip POST /api/v1/app/sync que hace push + pull atómico, con outbox idempotente, watermarks por catálogo y disparadores múltiples. De los 11 requisitos del modelo de referencia, 9 están implementados, 1 parcial y 1 no aplica por decisión válida.

Endpoint
POST /api/v1/app/sync
Idempotencia
client_uuid + UNIQUE en DB
Disparadores
15 min · reconexión · post-tap · manual
Push (FCM/ntfy)
No existe — polling alcanza

01 Cómo funciona hoy

El diseño real supera al modelo de dos fases (push, después pull): ambas direcciones viajan en un solo request. El server procesa la cola offline primero y calcula los deltas después, dentro del mismo round-trip — el orden push-antes-que-pull queda garantizado por construcción, no por coordinación del cliente.

Arquitectura

APP MÓVIL Kotlin · Compose · Room · WorkManager OUTBOX (Room) trabajo_pendiente PK client_uuid nfc_evento_pendiente (solo ADMIN) + intentos_sync · ultimo_error RÉPLICA LOCAL 7 catálogos + trabajo_sincronizado sync_state: watermark por tabla SyncWorker · disparadores Periódico 15 min (NetworkType.CONNECTED) NetworkCallback al reconectar → oneshot Botón "Sincronizar ahora" · backoff exp. 1 · PUSH (en el request) cola ordenada + last_sync + telemetría 2 · PULL (en el response) deltas + procesados + config + ruta 1 round-trip BACKEND Spring Boot · JWT + device binding (ADR-038) AppSyncController valida operario_id + device_uuid vs JWT revocación efectiva en cada sync (401) SyncService — orden fijo ① procesarTrabajos (idempot. client_uuid) ② procesarNfcEventos (cola ADMIN) ③ calcularDeltas (updated_at > watermark) + drift de reloj · telemetría · mi_ruta_hoy + config remota anti doble-tap (DT-B4) SQL Server trabajo.client_uuid UNIQUE (constraint DB) soft-delete: deleted_at → deleted_ids
Fig. 1 — Arquitectura actual. El push no es un endpoint aparte: viaja como parte del request. Smiley tiene su propio canal (AppSmileySyncController), separado del móvil.

El ciclo, paso a paso

APP (SyncRepository) SERVER (SyncService) t0 · single-flight Mutex + generaciones: nunca dos sync concurrentes; demanda extra se coalescea SyncRequest cola completa (orden ts_local ASC) · last_sync{7 watermarks} · device_now · batería/red/versión En el server, en orden: ① ¿device activo? no → 401 revocado ② cada trabajo: ¿client_uuid ya existe? sí → DUPLICADO (devuelve el existente) no → procesa (puede reinterpretar: cierre automático, tag desconocido…) ③ deltas: updated_at > watermark bajas → deleted_ids (soft delete) SyncResponse server_ts · deltas ×7 · trabajos_procesados · nfc_procesados · config · mi_ruta_hoy UNA transacción Room (Applier): ① aplica deltas (orden de dependencia) ② snapshot de mi_ruta_hoy ③ ACK → mueve pendiente a sincronizado rechazo terminal → purga sin reintento ④ avanza watermarks a server_ts error de red → intentos_sync++ y queda Deltas ANTES que procesados: un trabajo puede referenciar un sanitario que recién llega en el delta.
Fig. 2 — Secuencia del ciclo. Si el request muere después de que el server persistió, el retry reenvía los mismos client_uuid y el server responde DUPLICADO con el trabajo existente: el retry es inofensivo por diseño.

Vida de una operación offline

TAP NFC / manual UUID.randomUUID() antes de todo I/O ("regla #1") trabajo_pendiente sobrevive kill de app y reboot intentos_sync · ultimo_error error de red / 5xx → retry (backoff) ACK OK / DUPLICADO rechazo terminal trabajo_sincronizado historial read-only, con verdad del server Purgado SANITARIO_INACTIVO, rebote anti doble-tap…
Fig. 3 — Outbox. Nunca se borra al enviar: solo contra respuesta del server. El equivalente del estado CONFIRMADA es el traspaso físico a otra tabla.

Cobertura contra el modelo de referencia (AUDIT-SYNC-01)

R01R02R03R04R05R06R07R08R09R10R11
verde = implementado · ámbar = parcial · gris = N/A por decisión válida
ReqQué exigeEstadoEvidencia
R01Registro local antes de redIMPLEMENTADORoom como fuente de verdad; el tap escribe el outbox sin tocar redmobile: entity/TrabajoPendiente.kt · usecase/EvaluarTapUseCase.kt:86
R02Push antes que pullIMPLEMENTADOGarantizado por construcción: un solo request; el server procesa pendientes (①②) antes de calcular deltas (③)backend: SyncService.java:180-182 · mobile: SyncRepository.kt
R03UUID al crear, en el deviceIMPLEMENTADOUUID v4 generado antes de cualquier I/O, es la PK del outboxmobile: EvaluarTapUseCase.kt:86 · NfcGestionRepository.kt:152
R04Idempotencia server por UUIDIMPLEMENTADOLookup por client_uuid → DUPLICADO devuelve el existente; respaldado por constraint UNIQUE en DB (no solo check-then-insert)backend: TrabajoTapService.java:179-185 · Trabajo.java:40 (unique=true)
R05Confirmar solo contra ACKIMPLEMENTADOEl pendiente se mueve a trabajo_sincronizado únicamente al procesar la respuesta; error de red deja la fila e incrementa intentosmobile: SyncResponseApplier.kt (procesarTrabajo) · SyncRepository.kt (markRetry)
R06Cursor monotónico del serverIMPLEMENTADOWatermark por catálogo = server_ts del último sync; delta = updated_at > watermark. El reloj del device no participabackend: SyncService.calcularDeltas · mobile: SyncState.kt + advanceIfNewer
R07Operación offline plenaIMPLEMENTADOLa app resuelve taps contra la réplica local de catálogos; el registro manual (sin placa) también funciona offlinemobile: réplica 7 catálogos + trabajo_pendiente con manual/motivo
R08Push solo como timbreN/A · SIN PUSHNo hay FCM ni ningún push en el código. El propio modelo lo declara aceptable: polling 15 min + reconexión cubren la latencia requerida hoy
R09Batch con reintentos segurosIMPLEMENTADOCola completa en un batch, orden ts_local ASC (server re-ordena defensivo); backoff exponencial WorkManager; retry cubierto por R04. Sin límite de tamaño (ver S-03)backend: SyncService.java:248-249 · mobile: SyncWorker.kt
R10Purga por retenciónPARCIALLos rechazos terminales se purgan bien; pero trabajo_sincronizado no tiene purga por antigüedad (solo deleteAll de logout). Ver S-01
R11Orden canónico server-sideIMPLEMENTADOEl server fija inicio_ts/fin_ts como verdad (clave en cierre automático); ts_local_device es informativo; drift de reloj medido aparte contra device_nowbackend: TrabajoProcesado (inicio_ts "verdad del server") · procesarDriftReloj

MÁS ALLÁ DEL MODELO  La implementación incluye cosas que el modelo de referencia ni pedía: medición de drift de reloj con alerta RELOJ_DESVIADO, telemetría de flota (batería, red, versiones), config remota bajada en cada sync (anti doble-tap sin release), revocación de device efectiva al próximo request con cancelación durable del worker (ADR-044), reinterpretación server-side del tap (accion_real: cierre automático, tag desconocido), resolución de conflictos de la cola NFC con reversión del optimismo local, y single-flight con coalescing de demanda en el cliente.

Los hallazgos reales (pocos)

S-01 · GAP Sin purga por retención de trabajo_sincronizado R10

El historial confirmado crece sin límite hasta el logout (deleteAll). Las queries están acotadas (LIMIT 50), así que no afecta la UI, pero la DB local crece indefinidamente. Fix chico: un DELETE por antigüedad (ej. >60 días) al final de cada sync exitoso. Esfuerzo S.

S-02 · RIESGO TEÓRICO Cursor por timestamp updated_at

Ventana de carrera clásica: una fila commiteada con updated_at menor al server_ts ya entregado (transacción concurrente larga) se saltearía hasta su próximo cambio. Con el volumen actual de escritura de catálogos es prácticamente irrelevante; si algún día molesta, el fix es restar un margen de solapamiento al watermark (los upserts son idempotentes, re-recibir filas no daña). Esfuerzo S. No actuar hoy.

S-03 · RIESGO TEÓRICO Batch sin límite de tamaño

La app envía toda la cola en un request. Para el dominio (taps de limpieza) el volumen es chico incluso tras días offline; solo se vuelve tema con semanas de cola acumulada. Si se quisiera blindar: paginar de a N con el mismo contrato (el server ya tolera batches parciales gracias a R04). No actuar hoy.

02 Opciones de evolución

Cuatro caminos, del más simple al más pesado. La pregunta que los ordena es una sola: ¿cuánta latencia de bajada tolerás? (cuánto puede tardar un cambio de config/ruta del server en llegar al device). Todo lo demás — outbox, idempotencia, deltas — ya está resuelto y no cambia en ninguna opción.

A

Quedarse como está: polling + reconexión

ES LO IMPLEMENTADO HOY · latencia de bajada ≤ 15 min
App reloj 15' · reconexión · manual POST /sync (push+pull) cuando el device decide Server pasivo: responde, no inicia

El server nunca inicia contacto. La iniciativa es 100% del device, con tres disparadores. Un cambio de ruta o config tarda como máximo un ciclo de polling en llegar (o segundos, si el operario abre la app o toca sincronizar).

A favor
  • Cero dependencias nuevas, cero infra nueva
  • Ya está probado y con tests (SyncWorkerTest, SyncContractJsonTest)
  • Batería y red: un request cada 15 min es despreciable
En contra
  • Latencia de bajada de hasta 15 min si la app está en background
  • Si un día hay "reasignación urgente de ruta en vivo", no alcanza
A′

A + polling fino en foreground (1–3 min)

LA RESPUESTA PARA FIELD OPERATORS · latencia 1–3 min con la app abierta · esfuerzo S

Restricción de plataforma primero: WorkManager tiene un piso duro de 15 minutos para trabajo periódico — no existe configuración que lo baje, y encadenar oneshots con delay para simular menos es frágil bajo Doze. Pero WorkManager es la herramienta para background; para un operario en turno, con el device de trabajo en la mano y la app abierta, la herramienta es otra: un timer del ciclo de vida de la UI.

APP VISIBLE (el turno del operario) repeatOnLifecycle(STARTED) { while(true) { delay(pollSeg) ; sincronizar() } } + sync tras cada cierre de trabajo (ya existe) · el single-flight existente absorbe cualquier solapamiento latencia 1–3 min APP EN BACKGROUND / PANTALLA APAGADA Queda lo de hoy, intacto: SyncWorker cada 15 min + oneshot al reconectar El timer de arriba muere solo al pasar a background (lifecycle-aware) — no pelea contra Doze ni gasta batería en el bolsillo red de seguridad 15 min

Son ~20–30 líneas en MainScreen/MainViewModel: un loop con delay atado al lifecycle (arranca al mostrarse la pantalla, muere al pasar a background). No toca el protocolo, no toca el backend, no toca WorkManager. Y hay un detalle elegante gratis: el canal de config remota ya existe (DT-B4 baja silencio_cliente_seg en cada sync) — agregando un campo poll_foreground_seg al SyncConfig, el intervalo se ajusta desde el server sin release de la app: hoy 120 seg, mañana 60, pasado se apaga.

¿Y el costo? Un sync sin novedades es un request de ~1 KB con respuesta casi vacía (deltas vacíos). A 2 minutos son 30 requests/hora por device; con 50 operarios activos, ~0,4 req/seg para el backend — ruido. En batería, un HTTP corto por minuto en un device que está en uso activo y se carga a diario es despreciable. El único cuidado: mantener el polling fino solo en foreground; intentar sostener 1–3 min con pantalla apagada es pelear contra Android (eso ya es territorio de la Opción B o de un foreground service con notificación permanente).

A favor
  • 1–3 min reales durante el turno, que es cuando importa
  • Esfuerzo S, solo cliente, cero infra, reversible
  • Intervalo ajustable desde el server por el canal de config existente
En contra
  • Con la app en background sigue rigiendo el techo de 15 min
  • Si el requisito fuera "pantalla apagada y se entera igual", esto no alcanza → B
B

A + timbre push (FCM o ntfy)

EVOLUCIÓN NATURAL SI APARECE REQUISITO DE LATENCIA · latencia segundos
Server cambia ruta/config → emite timbre FCM ó ntfy self-hosted payload = "hay novedades" (sin datos) App handler → triggerOneshot() …y de acá en adelante es EXACTAMENTE el ciclo de la Fig. 2 — el push no toca la arquitectura de datos, solo agrega un cuarto disparador

El push jamás lleva datos: es un timbre que llama a SyncWorker.triggerOneshot(), que ya existe. Por eso la elección FCM vs ntfy es intercambiable: cambia la "salida", no el diseño. FCM: gratis, llave en mano, exige Google Play Services en el fleet. ntfy/UnifiedPush: self-hosted (encajaría en la Lightsail junto a NetBird, misma filosofía de infra propia), sin Google, un contenedor más que operar. El costo total ronda 50–100 líneas en la app + un emisor en el backend cuando cambia un catálogo o una ruta.

A favor
  • Latencia de bajada pasa de minutos a segundos
  • Todo lo construido queda intacto; es aditivo y reversible
  • Permite bajar el polling a 1–2 hs (el push hace el trabajo fino)
En contra
  • Una dependencia nueva (Google o un servicio propio a operar)
  • Los push se pierden/colapsan: el polling queda como red de seguridad obligatoria
  • Hoy no hay requisito que lo justifique
C

Separar en dos endpoints (push, después pull)

EL MODELO "DE LIBRO" · acá sería un retroceso
App 1º POST /operaciones (batch + ACK) 2º GET /config?desde=cursor Server ! ventana entre llamadas: el orden push→pull pasa a depender del cliente, no de la construcción

Es el diseño de dos fases que uno esboza en el pizarrón (y el que planteaba el AUDIT original). Con la implementación actual sobre la mesa, sería un downgrade: duplica auth, telemetría y manejo de errores, y convierte una garantía estructural (push-antes-que-pull dentro de un request) en una responsabilidad de coordinación del cliente, con dos ventanas de fallo en vez de una. El único escenario donde ganaría sentido es si los deltas crecieran tanto que hiciera falta paginarlos por separado del push — muy lejos del volumen de WorkDone.

A favor
  • Endpoints más chicos y ortogonales
  • Permitiría paginar deltas gigantes de forma independiente
En contra
  • Migración con costo real y riesgo, para ganar nada hoy
  • Pierde la atomicidad del round-trip único
  • Dos requests = más batería, más ventanas de corte
D

Canal persistente (WebSocket / SSE)

TIEMPO REAL CONTINUO · sobredimensionado para este dominio
App Server conexión viva permanente, bidireccional × N devices del fleet · reconexión · keep-alive · batería · Doze mode de Android en contra

Tiene sentido en dashboards en vivo, chats, cotizaciones. Para taps de limpieza que se registran offline y se concilian por lotes, el canal persistente paga todos los costos (conexiones vivas por device, lucha contra el Doze de Android, keep-alives, reconexión) sin comprar ningún beneficio que B no dé más barato. Descartada salvo que WorkDone mute a un producto de monitoreo en vivo — y aun ahí, el vivo sería para el panel web, no para el móvil.

Comparación

OpciónLatencia de bajadaCosto de implementarDependencias nuevasBatería / redCuándo elegirla
A · Hoy≤ 15 min (seg. si abre la app)Cero — ya estáNingunaMínimoMientras nadie necesite bajadas en segundos. Es el caso actual.
A′ · + polling foreground1–3 min en turno (15 min en background)S (~20–30 líneas, solo cliente)NingunaDespreciable en uso activoField operators con la app abierta durante el turno. Primer paso natural.
B · + timbreSegundosBajo (50–100 líneas + emisor)FCM o ntfy self-hostedBajoCuando aparezca un requisito real: reasignación de ruta en vivo, config urgente
C · 2 endpointsIgual que AMedio-alto (migración)NingunaPeor (2 requests)Solo si los deltas se volvieran tan grandes que exijan paginación propia
D · Canal vivoSub-segundoAlto (infra + cliente)Broker WS/SSE + operaciónAltoPrácticamente nunca para este dominio

Recomendación

Opción A′: quedarse con el protocolo tal como está y agregar el polling fino en foreground (1–3 min). Es la respuesta proporcionada al requisito real de field operators — 15 minutos es demasiado para alguien en turno, y A′ lo resuelve con ~20–30 líneas del lado cliente, sin infra nueva y con el intervalo gobernable desde el server por el canal de config que ya existe. En el mismo trabajo, cerrar el hallazgo S-01 (purga por retención de trabajo_sincronizado, esfuerzo S). S-02 y S-03 quedan documentados sin acción. El día que el requisito sea "se entera aunque el teléfono esté en el bolsillo con la pantalla apagada", el camino es B: el push como timbre que dispara triggerOneshot() — la elección FCM vs ntfy se toma recién ahí, mirando si el fleet tiene Google Play Services. C y D se descartan con argumento, no por pereza.

03 Casos de uso reales de campo

Veinte situaciones que van a pasar (o ya pasaron) en un shopping, un aeropuerto o un hospital, y cómo sale adelante el sistema con lo que hay implementado hoy. Tres familias: contratiempos del entorno (UC-01 a 09), rechazos del propio server (UC-10 a 15), y sesión, identidad y ciclo de vida (UC-16 a 20). Cada caso cita el mecanismo concreto que lo resuelve.

UC-01

Le cambian la ruta al operario a mitad de turno

Pasa esto

El supervisor detecta un reclamo en el sanitario del patio de comidas y, desde el panel, le agrega esa parada a la ruta del operario (o le reasigna un sanitario). El operario está limpiando en otro piso, con la app abierta.

Qué hace el sistema

El próximo sync (post-tap al cerrar el trabajo actual, polling foreground con A′, o el periódico) trae mi_ruta_hoy y los deltas. El Applier hace replaceSnapshot en Room y la pantalla de ruta, que observa via Flow, se recompone sola.

Cómo termina

La parada nueva aparece en la lista sin que el operario toque nada. Latencia: segundos si acaba de cerrar un trabajo; 1–3 min con A′; techo de 15 min solo si dejó la app en background.

Mecanismo: mi_ruta_hoy snapshot · deltas de catálogos · Room Flow → Compose
UC-02

Dos horas sin señal en el subsuelo

Pasa esto

El operario baja a los sanitarios del subsuelo/cochera donde no hay cobertura. Limpia seis sanitarios: doce taps (ingreso + egreso con eventos) sin red.

Qué hace el sistema

Cada tap se resuelve contra la réplica local de catálogos y se encola en trabajo_pendiente con su UUID. La app funciona idéntica a online — el operario ni nota la diferencia. Al volver a superficie, el NetworkCallback dispara el oneshot: sube los doce en orden cronológico y baja las novedades, en un solo request.

Cómo termina

Los seis trabajos quedan en el server con la secuencia correcta y pasan al historial local como confirmados. Si mientras tanto cambió la ruta, la baja en ese mismo round-trip.

Mecanismo: outbox Room · orden ts_local ASC · oneshot al reconectar · push+pull atómico
UC-03

La señal se corta justo después de que el server grabó

Pasa esto

El sync sube tres trabajos, el server los persiste… y la respuesta nunca llega (túnel, ascensor, antena saturada). Para la app, ese sync falló.

Qué hace el sistema

La app no borró nada (solo borra contra respuesta): marca intentos_sync++ y reintenta con backoff. El retry reenvía los mismos client_uuid; el server los encuentra por el UNIQUE y responde DUPLICADO con los trabajos ya existentes, sin crear nada de nuevo.

Cómo termina

Cero duplicados en el server, cero pérdida en el device. Este es el caso que justifica toda la idempotencia — y es invisible para el operario.

Mecanismo: confirmación solo contra ACK · client_uuid UNIQUE · estado DUPLICADO
UC-04

Doble tap por rebote de la placa

Pasa esto

El operario apoya el teléfono y la placa NFC lee dos veces en un segundo, o hace egreso y sin querer vuelve a apoyar: se generaría un ingreso fantasma de 3 segundos.

Qué hace el sistema

Doble defensa: la app ignora lecturas del mismo tag durante silencio_cliente_seg (config que baja del server, ajustable sin release), y si algo pasa igual, el server descarta el ingreso rebote post-egreso y lo registra en tap_descartado — que también es idempotente por client_uuid.

Cómo termina

El registro queda limpio. Si el egreso resuelve con duración sospechosamente corta, la app además pide confirmación al operario (confirmar_egreso_min) antes de cerrar.

Mecanismo: SyncConfig remota (DT-B4) · descarte server-side de rebotes · tap_descartado
UC-05

La placa NFC no responde (rota o vandalizada)

Pasa esto

El operario llega al sanitario y el tag no lee: lo arrancaron, se mojó, o el adhesivo cedió. Igual tiene que limpiar y dejar constancia.

Qué hace el sistema

Registro manual: elige el sanitario del catálogo local (funciona offline) y un motivo de contingencia obligatorio. El trabajo viaja con manual=true + sanitario_id + motivo_contingencia_codigo; el server lo valida en forma cruzada (manual exige sanitario+motivo; NFC exige tag).

Cómo termina

El trabajo queda registrado y marcado como manual — trazable para el supervisor, que además se entera de que hay una placa para reponer. La operación no se frena por hardware roto.

Mecanismo: registro sin placa (V21_ROBUSTEZ C2) · validación cruzada en TrabajoTapService
UC-06

Se queda sin batería con un trabajo abierto

Pasa esto

Hizo el ingreso, limpió… y el teléfono se apagó (o Android mató la app) antes del egreso. Al otro día prende el device y sigue trabajando.

Qué hace el sistema

La cola sobrevive al reboot (es Room, no memoria). El ingreso pendiente sube en el próximo sync. Cuando el operario hace su siguiente ingreso en otro sanitario, el server detecta el trabajo abierto huérfano y ejecuta el cierre automático: lo cierra fijando él los timestamps (verdad del server) y devuelve accion_real = EGRESO_CIERRE_AUTOMATICO.

Cómo termina

Ningún trabajo queda abierto para siempre y el historial refleja lo que pasó, con el warning correspondiente para que el supervisor lo vea.

Mecanismo: outbox persistente · reinterpretación server-side (accion_real) · cierre automático
UC-07

Roban o se pierde un device

Pasa esto

Un teléfono de la flota desaparece con una sesión activa. Riesgo: que alguien registre trabajos falsos o consuma la API con credenciales válidas.

Qué hace el sistema

El admin revoca el device en el panel. La revocación se valida en cada sync, sin cache: el próximo request recibe 401 DEVICE_REVOCADO (código tipado, nunca texto libre). La app persiste el bloqueo, bloquea la UI y el worker se autocancela en forma durable — ni tras reboot vuelve a intentar. Además el JWT tiene device binding: no se puede sincronizar "como otro device".

Cómo termina

El device queda inerte al siguiente contacto. Si aparece, el admin lo re-habilita y la cola offline preservada se sube recién entonces — no se pierde ni se acepta mientras estuvo revocado.

Mecanismo: revocación efectiva por sync (V21 E1) · ADR-038 device binding · ADR-044 cancelación durable
UC-08

Dos admins asignan el mismo tag offline, a sanitarios distintos

Pasa esto

Dos supervisores con rol ADMIN, cada uno sin señal, asignan la misma placa nueva a dos sanitarios diferentes desde sus devices. Los dos ven su asignación aplicada localmente (optimismo).

Qué hace el sistema

El primero que sincroniza gana. El segundo recibe CONFLICTO_ASIGNACION: la app purga su acción de la cola, revierte el optimismo local a la verdad que llegó en el delta de tags, y emite el conflicto para que la pantalla de gestión se lo muestre al admin.

Cómo termina

Los dos devices convergen al mismo estado (el del server) y el admin perdedor se entera explícitamente, en vez de quedarse creyendo su versión. Es el único punto del sistema con conflicto real posible — y está resuelto.

Mecanismo: cola NFC ADMIN · primero-gana server-side · reversión de optimismo + feedback D4
UC-09

El reloj del device está desconfigurado

Pasa esto

Un device de la flota quedó con la hora corrida 40 minutos (sin auto-hora, zona mal seteada). Sus taps llevan timestamps locales incorrectos.

Qué hace el sistema

Cada sync manda device_now; el server mide el drift contra su propio reloj — nunca contra los timestamps de la cola, que legítimamente pueden ser viejos — y si excede el umbral levanta la alerta RELOJ_DESVIADO (una por device, se auto-atiende al normalizarse). Los timestamps canónicos de los trabajos los fija el server; el ts local queda como dato informativo.

Cómo termina

El histórico no se contamina con horas falsas, y mantenimiento ve en el panel qué device hay que corregir. El cursor de deltas tampoco se ve afectado: usa server_ts, jamás el reloj del device.

Cuando el server dice que no — casos de no éxito

Los anteriores son contratiempos del entorno (señal, hardware, batería). Estos son distintos: el trabajo llega al server y el server lo rechaza o lo reinterpreta. Acá se ve la decisión de diseño central del protocolo: los errores de negocio son por ítem (un rechazo nunca voltea el batch ni el sync completo), y cada estado de rechazo tiene definida su semántica de reintento — o de no-reintento.

UC-10

Tapea una placa que el sistema no conoce

Pasa esto

Mantenimiento pegó una placa nueva que nadie dio de alta todavía, o quedó una placa de una instalación anterior. El operario la tapea de buena fe y limpia.

Qué hace el sistema

Whitelist estricta: solo un tag en estado ASIGNADO habilita el trabajo normal. Pero el trabajo no se pierde: el server lo persiste igual en modo OFFLINE_PENDIENTE_RESOLUCION (sin sanitario asociado) y genera la alerta TAG_DESCONOCIDO en el panel del supervisor.

Cómo termina

La limpieza hecha queda registrada y esperando que un humano la resuelva (asociarla al sanitario correcto, dar de alta la placa). El esfuerzo del operario nunca se descarta por un problema administrativo.

Mecanismo: whitelist ASIGNADO · OFFLINE_PENDIENTE_RESOLUCION · alerta TAG_DESCONOCIDO al panel
UC-11

Limpió un sanitario que dieron de baja mientras estaba offline

Pasa esto

El operario, sin señal, registra trabajos en un sanitario que el admin desactivó hace una hora (clausurado por obra). Su catálogo local todavía no se enteró de la baja.

Qué hace el sistema

El server responde SANITARIO_INACTIVO: rechazo terminal. La app purga el ítem de la cola sin reintentar — reintentar daría siempre lo mismo, y dejar el ítem trabaría la cola para siempre. En el mismo response llega el delta que marca el sanitario como inactivo, así que el device queda al día.

Cómo termina

La cola queda limpia y el catálogo corregido. Punto flojo honesto: hoy el rechazo se registra solo en el log del device — el operario no ve nada. Ver S-04 abajo.

Mecanismo: rechazo terminal por ítem · purga sin reintento · delta correctivo en el mismo round-trip
UC-12

La app cree que es un egreso, el server no tiene nada abierto

Pasa esto

Por una secuencia rara (auto-cierre previo que la app no llegó a aplicar, cola parcialmente subida en un sync cortado), el operario tapea "para salir" pero del lado del server no existe ningún trabajo abierto que cerrar.

Qué hace el sistema

El server no inventa un cierre imposible ni tira el tap: lo reinterpreta como INGRESO (con warning; los eventos de limpieza seleccionados se descartan porque no hay cierre al cual asociarlos). El campo accion_real del response le cuenta a la app qué pasó de verdad.

Cómo termina

El estado converge: la app aplica la verdad del server y el operario sigue con un trabajo abierto legítimo. El principio de fondo: ante ambigüedad, el server decide y el cliente obedece — nunca al revés.

Mecanismo: tipo_tentativo vs accion_real · reinterpretación server-side · warnings diagnósticos
UC-13

Un admin pierde el rol con acciones NFC en la cola

Pasa esto

Un supervisor encoló offline altas y asignaciones de placas. Antes de que sincronice, RRHH le quita el rol ADMIN (o rotó de puesto). Su cola NFC llega al server sin permisos.

Qué hace el sistema

Cada ítem vuelve como ROL_INSUFICIENTE — y acá importa el detalle: se rechazan los ítems sin abortar el sync. Los trabajos de limpieza del mismo request se procesan normal, los deltas bajan normal.

Cómo termina

La parte legítima del sync sale adelante; solo lo que ya no está autorizado se descarta. Un cambio de permisos nunca deja un device incomunicado.

Mecanismo: validación de rol por ítem · fallo parcial sin aborto del batch
UC-14

Se perdió la respuesta de un auto-cierre y el espejo local quedó chueco

Pasa esto

El server auto-cerró un trabajo, pero esa respuesta nunca se aplicó en el device (app vieja, crash a mitad de apply de una versión anterior). El historial local muestra dos trabajos "abiertos" del mismo operario — algo que el dominio prohíbe.

Qué hace el sistema

Cada sync ejecuta reconciliarUnicoAbierto: si el espejo local viola el invariante "a lo sumo un trabajo abierto por operario" (ADR-030), el más nuevo gana y los anteriores se cierran localmente como EGRESO_CIERRE_AUTOMATICO, con la misma semántica de timestamps que usaría el server. Es idempotente: si está todo bien, no hace nada.

Cómo termina

El sistema se auto-repara en el siguiente sync, sin intervención. Los estados ilegales locales tienen fecha de vencimiento: duran hasta el próximo round-trip.

Mecanismo: reconciliación de invariantes por sync · self-healing idempotente (ADR-030)
UC-15

Un ítem malformado en el batch (bug de cliente)

Pasa esto

Un bug de una versión de la app produce un ítem estructuralmente inválido (campo obligatorio nulo, string excedido). No es un error de negocio — es un payload que ni pasa la validación de entrada.

Qué hace el sistema

Acá está el límite del diseño actual: la validación estructural (@Valid) es del request completo → responde 400 al batch entero. El worker no reintenta los 4xx (correcto: reintentar lo mismo da lo mismo), pero la cola queda en el device con intentos_sync creciendo — el ítem venenoso bloquea también a los ítems sanos que viajan con él.

Cómo termina

Hoy: la cola queda trabada hasta que un fix de la app corrija el ítem. El riesgo real es bajo — los payloads los genera la propia app, no un tercero, y el versionado v1/v2 ya cubre el skew de versiones — pero es el único modo conocido de que la cola no salga adelante sola. Ver S-05.

Mecanismo (y límite): validación estructural = todo-o-nada · errores de negocio = por ítem

Observaciones que dejaron estos casos

S-04 · MEJORA UX Rechazos terminales silenciosos para el operario

Cuando un trabajo se purga por SANITARIO_INACTIVO, ROL_NO_PERMITIDO o MANUAL_INVALIDO (UC-11), el único rastro es un Log.w en el device: el operario no recibe feedback de que esa limpieza no quedó registrada. Para la cola NFC el feedback sí existe (conflictos D4); para trabajos, no. Mejora candidata: un contador/aviso "N registros rechazados" en la pantalla principal o el historial, reutilizando el mismo patrón de SharedFlow que ya usan los conflictos NFC. Esfuerzo S-M.

S-05 · RIESGO BAJO Ítem venenoso traba el batch (UC-15)

Un ítem estructuralmente inválido produce 400 del request completo y deja la cola sin salida. Probabilidad baja (payloads autogenerados + compatibilidad v1/v2 diseñada), pero si se quisiera blindar: (a) del lado app, cuarentena del ítem cuyo ultimo_error sea un 400 tras N intentos, para liberar al resto; o (b) del lado server, mover la validación estructural a por-ítem devolviendo un estado PAYLOAD_INVALIDO. La opción (a) no toca el contrato. Documentado para decidir si alguna vez ocurre en producción; no actuar preventivamente.

Sesión, identidad y ciclo de vida — la tercera familia

Los dos bloques anteriores cubren el entorno y el rechazo de negocio. Queda una familia distinta: lo que pasa cuando cambia quién está usando el device, o cuando cambia el device mismo. Son los casos donde la cola offline y la sesión se cruzan — históricamente el terreno más resbaladizo de cualquier app offline-first.

UC-16

Rotación de operarios en el mismo teléfono con cola pendiente

Pasa esto

Turno tarde: el operario A trabaja sin señal y deja ocho taps encolados. Termina, cierra sesión, y el operario B toma el mismo teléfono, se loguea y empieza su turno. Cuando vuelve la red, la cola tiene trabajos de A viajando bajo la sesión de B.

Qué hace el sistema

Está previsto explícitamente en el protocolo (caso 2). El controller exige que el operario_id del sobre coincida con el del token — eso frena la suplantación — pero los operario_id de cada ítem pueden diferir legítimamente: cada trabajo lleva el suyo, el de quien realmente lo hizo.

Cómo termina

Los ocho trabajos de A se acreditan a A, no a B. Sin esta distinción, la métrica de productividad quedaría corrupta cada vez que rota un turno — que en un shopping es todos los días.

Mecanismo: operario_id del sobre validado vs JWT · operario_id por ítem preservado (SYNC_PROTOCOL caso 2)
UC-17

Dan de baja al operario mientras tiene trabajos sin subir

Pasa esto

Un operario renuncia o lo desvinculan a media tarde. RRHH lo desactiva en el sistema. Pero él ya limpió seis sanitarios esa mañana y esos taps están encolados en el device, sin subir.

Qué hace el sistema

Decisión de diseño interesante: el trabajo se acepta igual — no se descarta el trabajo real de una persona por un cambio administrativo posterior. Se persiste con el warning operario_inactivo_offline y se genera la alerta OPERARIO_INACTIVO_OFFLINE para el supervisor.

Cómo termina

El servicio prestado queda registrado y facturable; la anomalía queda marcada para revisión humana. La regla implícita: el sistema no arbitra sobre situaciones laborales, las señaliza.

Mecanismo: aceptación con warning · alerta OPERARIO_INACTIVO_OFFLINE · el dato no se pierde
UC-18

El token expira durante el turno (o tras días sin red)

Pasa esto

El access token vive 15 minutos (ADR-006), así que expira constantemente. Caso extremo: un device queda en un locker una semana y vuelve con todo vencido.

Qué hace el sistema

El TokenAuthenticator intercepta el 401, refresca contra /auth/refresh y reintenta la request original — transparente para el sync. Con varias requests fallando en paralelo, solo una refresca. Y la clasificación es fina: solo un 401 con code=REFRESH_INVALIDO limpia la sesión; timeouts, 5xx, 4xx no tipificados o un body ilegible se tratan como transitorios y preservan las credenciales.

Cómo termina

La expiración normal es invisible. Y lo importante: un backend caído no desloguea a toda la flota — el error que más duele en campo (operarios pidiendo credenciales a la vez) está explícitamente evitado por diseño.

Mecanismo: refresh transparente con lock · clasificación por code tipado, nunca texto libre (ADR-044)
UC-19

Teléfono nuevo que nadie dio de alta

Pasa esto

Se suma un device a la flota (reemplazo de uno roto). Le instalan la app, el operario intenta loguearse — pero el admin todavía no lo registró en el backend.

Qué hace el sistema

401 DEVICE_NO_REGISTRADO, tratado como terminal igual que la revocación (ADR-044): no se reintenta, se persiste el bloqueo, se cancela el worker en forma durable. Pero se distingue del robo en la UI: la app emite un aviso específico y reabre el enrolamiento, en vez de fallar en silencio.

Cómo termina

El operario ve un mensaje accionable ("falta registrar este equipo") en lugar de una app rota. La recuperación es un login exitoso después del alta — no hace falta reinstalar ni limpiar datos.

Mecanismo: DEVICE_NO_REGISTRADO terminal pero distinguible · reapertura del enrolamiento
UC-20

Actualización de la app con la cola llena

Pasa esto

Sale una versión nueva y se despliega a la flota. Algunos devices actualizan con trabajos sin subir en la cola. Si la DB local se recreara, esos trabajos se perderían — silenciosamente.

Qué hace el sistema

La base va por la versión 5 con migraciones explícitas y exportSchema = true — sin fallbackToDestructiveMigration, que es justamente el atajo que borra datos del usuario ante un cambio de esquema. Del lado del protocolo, los campos nuevos siempre entraron como opcionales (v1 manda null, v2 los completa), así que una app vieja sincronizando contra un backend nuevo — y viceversa — no rompe.

Cómo termina

La cola sobrevive al update y se sube con la versión nueva. La compatibilidad hacia atrás no es accidental: está escrita en cada DTO del protocolo.

Mecanismo: migraciones Room explícitas (v5) · campos opcionales v1/v2 · sin fallback destructivo

S-06 · A VERIFICAR Logout con cola pendiente

El logout hace clearSession() (credenciales) pero, por lo que se ve en los fuentes, no borra trabajo_pendiente — que es lo correcto para UC-16: la cola de A tiene que sobrevivir a su logout para subirse bajo la sesión de B. Vale confirmarlo con un test explícito si no existe: "logout de A → login de B → sync → los trabajos de A llegan con operario_id de A". Es el invariante que sostiene todo UC-16 y conviene que esté clavado con un test, no solo con la ausencia de un deleteAll. Esfuerzo S.

Dónde para la lista

Con veinte casos el mapa está cubierto en sus tres familias: entorno (01–09), rechazo de negocio (10–15) e identidad y ciclo de vida (16–20). Lo que queda afuera es deliberado, no un olvido:

  • Backend caído por mantenimiento — es UC-03 con otra ropa: 5xx → retry con backoff, cola intacta. No agrega mecanismo nuevo.
  • Device en un locker una semana — combinación de UC-02 (cola larga) + UC-18 (token vencido) + S-03 (batch sin límite). Ya está descripto por partes.
  • Cambio de horario de verano / zona horaria — cubierto por UC-09: los timestamps canónicos son del server y el cursor de deltas nunca usa el reloj del device.
  • Corrupción de la base local — fuera del alcance del protocolo; es reinstalar. Si se volviera un problema real, ahí sí hay que diseñar algo (y hoy no lo hay).

Agregar más casos a esta altura sería listar variantes de mecanismos ya descriptos, y eso resta claridad en vez de sumar cobertura. Los tres hallazgos que salieron del ejercicio (S-04 feedback de rechazos, S-05 ítem venenoso, S-06 test de logout con cola) son el rendimiento real de haberlo hecho.