Saltar a contenido

ADR-0013 · Webchat sync handler — excepción al modelo async

  • Status: Accepted
  • Date: 2026-05-15
  • Deciders: Cristian Fernández (Zerviz Group)
  • Related: ADR-0004 (eventing async), ADR-0007 (strategy pattern), ADR-0011 (service decomposition).

Context

apps/widget-spa (widget público) habla con la plataforma vía POST {apiBaseUrl}/v1/webchat/messages y espera replies en el response body de forma síncrona:

// request
{ "tenantId": "...", "conversationId": "...|null", "channel": "webchat",
  "from": { "id": "visitor-uuid" }, "content": { "text": "hola" } }

// response
{ "conversationId": "...", "status": "active|completed|handoff",
  "replies": [ { "text": "...", "choices": [{label,value}]? } ] }

Este contrato (ya fijado del lado del widget) choca con el modelo async de inbound-router → EventBridge → flow-engine usado para WhatsApp Meta Cloud (webhook → publicar Conversation.MessageReceived → 200 vacío; replies salen después vía Channel.OutboundRequested → connector-orchestrator).

Opciones evaluadas

Opción 1 — Handler webchat dentro de inbound-router que reusa processInbound in-process (ELEGIDA)

services/inbound-router/src/handlers/webchatMessages.ts importa la función pura processInbound(inbound, deps) de @zengine/flow-engine (publicada en services/flow-engine/src/index.ts) y la ejecuta inline. Inyecta un TeePublisher que envuelve al EventBridgePublisher real y captura los Channel.OutboundRequested para mapearlos a replies[] del response.

  • Reuso máximo: un único FSM, una única implementación; los executors (text, condition, handoff, etc.) sirven tanto el flow webchat como el flow whatsapp sin divergencias.
  • Eventos async siguen vivos: el TeePublisher delega al EventBridgePublisher, por lo que Channel.OutboundRequested, Conversation.HandoffRequested, Conversation.FlowCompleted se siguen publicando al bus → reporting, surveys, escalamiento, etc., funcionan.
  • Mismo Lambda image que inbound-router, función Lambda separada con image_config.command = ["dist/handlers/webchatMessages.handler"] para aislar cold-start y métricas.

Opción 2 — Nuevo Lambda webchat-bff que invoca flow-engine via lambda.invoke sync

Rechazada: - Sync cross-Lambda invoke = antipatrón (acopla latencias, suma cold-start, paga IAM/STS extra, hace el flow-engine un servicio bloqueante para webchat pero async para whatsapp — dos personalidades para el mismo proceso). - Duplicaría infra (función adicional, role adicional, métricas adicionales) sin ganancia de aislamiento real: si flow-engine falla, webchat falla igual. - No mejora reuso vs Opción 1 (ya tenemos la función pura exportable).

Decision

Se adopta Opción 1.

Detalles de implementación

  1. services/flow-engine/src/index.ts expone processInbound, InboundMessage, DdbFlowStateRepository, S3FlowCatalog, FlowStateRepository, FlowCatalogReader. NO re-exporta los handlers Lambda (evita inicializar AWS clients al importar el package).
  2. services/inbound-router/src/strategies/webchatHttp.ts valida el body con Zod (WebchatRequestSchema), normaliza a InboundMessage, ejecuta processInbound con el TeePublisher, mapea replies, soporta header Idempotency-Key (caché opcional).
  3. services/inbound-router/src/handlers/webchatMessages.ts es el adaptador Lambda HTTP: lazy-init de DDB/S3/EB, parseo del event, llamada al strategy, errores estructurados (BaseErrorhttpStatus + JSON).
  4. API Gateway: route adicional POST /v1/webchat/messages (auth NONE, widget público) sobre el HTTP API existente del inbound-router, apuntando a la Lambda separada con CMD override.
  5. No se modifica whatsappMetaCloud.ts ni webhook.ts ni onMessageReceived.ts. El path async sigue intacto.

Consequences

Positive

  • Reuso completo del FSM. Bug-fixes en steps benefician ambos canales.
  • Latencia perceived por el usuario del widget: una sola Lambda invocation (~200-400ms cold, ~50ms warm) vs dos saltos asincrónicos.
  • Observabilidad: el response status code refleja el éxito de la conversación.

Negative

  • El timeout de API Gateway (29s) y el de Lambda (10s) bound el tiempo total del flow webchat. Si un flow webchat hace HTTP step lento, el widget ve 502. Mitigación: nuestros executors http y llm ya tienen timeouts internos ≤ 5s.
  • Si processInbound tira, la conversación NO queda en estado consistente (el caller verá 500). Mitigación: processInbound ya persiste estado al inicio de cada step; en V1.1 agregamos outbox para garantizar eventos at-least-once cuando el handler salga 5xx.
  • Acoplamiento de package: inbound-router ahora depende de @zengine/flow-engine. Aceptable: ambos son del mismo equipo y la API pública (processInbound + interfaces) es estable.

Tenant-isolation impact

Ninguno nuevo. La función processInbound ya es tenant-scoped via InboundMessage.tenantId y todos los reads/writes a DDB/S3 usan la clave TENANT#<id>.

Blast radius

  • Bug en webchat handler ⇒ canal webchat caído. WhatsApp y EB-async sin impacto (Lambda separada, distinto CMD).
  • Bug en processInbound ⇒ ambos canales caídos (lo mismo que hoy: es la misma función ejecutada in-process o desde el SQS consumer).

Cost note

  • 1 Lambda adicional (mismo image, distinto CMD). Costo marginal.
  • 1 log group adicional (retention 14d, dev). $0.

ISO 27001 controls touched

  • A.14.2.5 (secure system engineering): contrato sync explícito y testeado.
  • A.14.2.8 (system security testing): unit tests ≥ 7 casos + handler tests + reuso de fixtures de flow-engine.

Sources

  • apps/widget-spa/src/transport/HttpTransport.ts (contrato fijado).
  • services/flow-engine/src/domain/processInbound.ts (función pura reusada).
  • services/inbound-router/src/handlers/webhook.ts (patrón async existente, no modificado).