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 queChannel.OutboundRequested,Conversation.HandoffRequested,Conversation.FlowCompletedse siguen publicando al bus → reporting, surveys, escalamiento, etc., funcionan. - Mismo Lambda image que
inbound-router, función Lambda separada conimage_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¶
services/flow-engine/src/index.tsexponeprocessInbound,InboundMessage,DdbFlowStateRepository,S3FlowCatalog,FlowStateRepository,FlowCatalogReader. NO re-exporta los handlers Lambda (evita inicializar AWS clients al importar el package).services/inbound-router/src/strategies/webchatHttp.tsvalida el body con Zod (WebchatRequestSchema), normaliza aInboundMessage, ejecutaprocessInboundcon elTeePublisher, mapea replies, soporta headerIdempotency-Key(caché opcional).services/inbound-router/src/handlers/webchatMessages.tses el adaptador Lambda HTTP: lazy-init de DDB/S3/EB, parseo del event, llamada al strategy, errores estructurados (BaseError→httpStatus+ JSON).- API Gateway: route adicional
POST /v1/webchat/messages(auth NONE, widget público) sobre el HTTP API existente delinbound-router, apuntando a la Lambda separada con CMD override. - No se modifica
whatsappMetaCloud.tsniwebhook.tsnionMessageReceived.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
httpyllmya tienen timeouts internos ≤ 5s. - Si
processInboundtira, la conversación NO queda en estado consistente (el caller verá 500). Mitigación:processInboundya persiste estado al inicio de cada step; en V1.1 agregamosoutboxpara garantizar eventos at-least-once cuando el handler salga 5xx. - Acoplamiento de package:
inbound-routerahora 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).