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.
POST /api/v1/app/sync01 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
El ciclo, paso a paso
Vida de una operación offline
Cobertura contra el modelo de referencia (AUDIT-SYNC-01)
| Req | Qué exige | Estado | Evidencia |
|---|---|---|---|
| R01 | Registro local antes de red | IMPLEMENTADO | Room como fuente de verdad; el tap escribe el outbox sin tocar redmobile: entity/TrabajoPendiente.kt · usecase/EvaluarTapUseCase.kt:86 |
| R02 | Push antes que pull | IMPLEMENTADO | Garantizado por construcción: un solo request; el server procesa pendientes (①②) antes de calcular deltas (③)backend: SyncService.java:180-182 · mobile: SyncRepository.kt |
| R03 | UUID al crear, en el device | IMPLEMENTADO | UUID v4 generado antes de cualquier I/O, es la PK del outboxmobile: EvaluarTapUseCase.kt:86 · NfcGestionRepository.kt:152 |
| R04 | Idempotencia server por UUID | IMPLEMENTADO | Lookup 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) |
| R05 | Confirmar solo contra ACK | IMPLEMENTADO | El 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) |
| R06 | Cursor monotónico del server | IMPLEMENTADO | Watermark por catálogo = server_ts del último sync; delta = updated_at > watermark. El reloj del device no participabackend: SyncService.calcularDeltas · mobile: SyncState.kt + advanceIfNewer |
| R07 | Operación offline plena | IMPLEMENTADO | La 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 |
| R08 | Push solo como timbre | N/A · SIN PUSH | No 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 |
| R09 | Batch con reintentos seguros | IMPLEMENTADO | Cola 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 |
| R10 | Purga por retención | PARCIAL | Los rechazos terminales se purgan bien; pero trabajo_sincronizado no tiene purga por antigüedad (solo deleteAll de logout). Ver S-01 |
| R11 | Orden canónico server-side | IMPLEMENTADO | El 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.
Quedarse como está: polling + reconexión
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).
- 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
- 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 + polling fino en foreground (1–3 min)
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.
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).
- 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
- 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
A + timbre push (FCM o ntfy)
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.
- 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)
- 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
Separar en dos endpoints (push, después pull)
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.
- Endpoints más chicos y ortogonales
- Permitiría paginar deltas gigantes de forma independiente
- 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
Canal persistente (WebSocket / SSE)
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ón | Latencia de bajada | Costo de implementar | Dependencias nuevas | Batería / red | Cuándo elegirla |
|---|---|---|---|---|---|
| A · Hoy | ≤ 15 min (seg. si abre la app) | Cero — ya está | Ninguna | Mínimo | Mientras nadie necesite bajadas en segundos. Es el caso actual. |
| A′ · + polling foreground | 1–3 min en turno (15 min en background) | S (~20–30 líneas, solo cliente) | Ninguna | Despreciable en uso activo | Field operators con la app abierta durante el turno. Primer paso natural. |
| B · + timbre | Segundos | Bajo (50–100 líneas + emisor) | FCM o ntfy self-hosted | Bajo | Cuando aparezca un requisito real: reasignación de ruta en vivo, config urgente |
| C · 2 endpoints | Igual que A | Medio-alto (migración) | Ninguna | Peor (2 requests) | Solo si los deltas se volvieran tan grandes que exijan paginación propia |
| D · Canal vivo | Sub-segundo | Alto (infra + cliente) | Broker WS/SSE + operación | Alto | Prá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.
Le cambian la ruta al operario a mitad de turno
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.
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.
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.
Dos horas sin señal en el subsuelo
El operario baja a los sanitarios del subsuelo/cochera donde no hay cobertura. Limpia seis sanitarios: doce taps (ingreso + egreso con eventos) sin red.
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.
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.
La señal se corta justo después de que el server grabó
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ó.
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.
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.
Doble tap por rebote de la placa
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.
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.
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.
La placa NFC no responde (rota o vandalizada)
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.
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).
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.
Se queda sin batería con un trabajo abierto
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.
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.
Ningún trabajo queda abierto para siempre y el historial refleja lo que pasó, con el warning correspondiente para que el supervisor lo vea.
Roban o se pierde un device
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.
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".
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.
Dos admins asignan el mismo tag offline, a sanitarios distintos
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).
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.
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.
El reloj del device está desconfigurado
Un device de la flota quedó con la hora corrida 40 minutos (sin auto-hora, zona mal seteada). Sus taps llevan timestamps locales incorrectos.
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.
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.
Tapea una placa que el sistema no conoce
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.
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.
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.
Limpió un sanitario que dieron de baja mientras estaba offline
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.
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.
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.
La app cree que es un egreso, el server no tiene nada abierto
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.
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.
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.
Un admin pierde el rol con acciones NFC en la cola
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.
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.
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.
Se perdió la respuesta de un auto-cierre y el espejo local quedó chueco
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.
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.
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.
Un ítem malformado en el batch (bug de cliente)
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.
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.
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.
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.
Rotación de operarios en el mismo teléfono con cola pendiente
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.
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.
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.
Dan de baja al operario mientras tiene trabajos sin subir
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.
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.
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.
El token expira durante el turno (o tras días sin red)
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.
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.
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.
Teléfono nuevo que nadie dio de alta
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.
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.
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.
Actualización de la app con la cola llena
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.
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.
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.
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.