Saltar a contenido

ADR-0012 · Parity Gate del flow-engine v2 (strangler-fig)

  • Status: Accepted
  • Date: 2026-05-15
  • Deciders: Cristian Fernández (Zerviz Group), Flow Engine Engineer
  • Related: ADR-0009 (migración cutover), ADR-0011 (descomposición de servicios — §flow-engine), V1.1 plan item #6.

Context

El motor de flujos v2 corre en patrón strangler-fig: ejecuta en sombra contra los outputs del motor legacy hasta probar paridad. La fuente de verdad legacy son 21 flows reales capturados desde s3://ze-engine/flujos/{org-default,zerviz}/chat-flow.json (2026-05-15) y persistidos en legacy-references/ze-engine-flujos/.

Cada nodo legacy tiene un discriminador kind (10 valores observados: message, end, if, transfer, api, ai, buttons, options, js, flow_transfer) y ~50 campos opcionales. El schema target v2 (packages/flow-schema) es una discriminated union de 7 step types: text, condition, set-context, handoff, encuesta, http, llm.

Necesitamos:

  1. Un transpiler determinístico legacy → v2.
  2. Una batería ejecutable de escenarios input→expected que prueban que processInbound (v2) produce los mismos outcomes canónicos que el legacy en golden paths, branches, fallbacks y handoffs.
  3. Un gate bloqueante en CI para impedir regresiones.

Decision

1. Transpiler @zengine/legacy-transpiler (packages/legacy-transpiler/)

Convierte LegacyFlowFlow v2 y produce un TranspileWarning[]. Reglas de mapeo:

Legacy kind v2 step Notas
message text nextNodeIdnext
end text next: null
if condition una rama (left/op/right → when expr) + default
transfer handoff target default five9-digital, reason = campaignName
api http apiMethod/apiEndpointmethod/url; valida URL/método
ai llm aiPromptpromptTemplate, model default gpt-4o-mini
buttons text (LOSSY) options serializadas como markdown enumerado; next = primer nextNodeId
options text (LOSSY) idem buttons
js text placeholder warning UNSUPPORTED_KIND
flow_transfer text placeholder warning UNSUPPORTED_KIND

Sanitización de IDs: legacy node_1773988151311_k3hnv → v2 node-1773988151311-k3hnv (regex ^[a-z][a-z0-9-]{0,63}$). Cobertura: _-, lowercase, char inválido → -, colapso de - repetidos, prefix n- si empieza con dígito, truncado a 64.

Resiliencia (no rompe la suite cuando el legacy es ruidoso):

  • Nodos null / sin id o sin kind → skip + warning MALFORMED_NODE.
  • Refs a nodos inexistentes → next: null + warning DANGLING_REFERENCE.
  • startNodeId no resuelve → fallback al primer nodo válido + warning INVALID_START.
  • Flow sin nodos válidos → genera placeholder de un solo step (type: text).

2. Parity Gate (services/flow-engine/tests/parity/)

  • buildFixtures.ts corre el transpiler sobre los 21 flows y persiste fixtures/<flowId>.json + <flowId>.warnings.json + _coverage-report.json.
  • _build.test.ts regenera fixtures al inicio de la suite (idempotente).
  • inputs/iplacex-style.fixtures.json define 64 escenarios input→expected estructurados como { flowId, sequence: [{ userInput, expectedReplyContains?, expectedHandoff?, expectedFinal? }] }, cubriendo:
  • Golden paths (chains 1-3 mensajes → completion).
  • Branches izquierda/derecha de cada condition (legacy if).
  • Fallbacks (default rama de condition).
  • Handoff paths (transfer legacy → HandoffRequested v2).
  • Edge cases: input vacío, sensibilidad a mayúsculas, repeticiones.
  • parity.test.ts itera escenarios, ejecuta runFlow in-memory contra el flow transpilado y asserta outcomes. Sin LocalStack — el FSM es puro.

3. CI

Job parity-gate en .github/workflows/ci.yml. Corre npm run test:parity --workspace=@zengine/flow-engine. Bloqueante en PRs a main.

4. Criterios de salida del Parity Gate

Para declarar paridad alcanzada con el legacy:

  • Cobertura de mapeo: ≥95% de nodos legacy productivos transpilan a un step v2 sin warning UNSUPPORTED_KIND o MALFORMED_NODE.
  • Cobertura de escenarios: ≥60 escenarios input→expected pasan al 100%. (Actual: 64/64.)
  • Cero failed en _coverage-report.json.
  • Los flows que dependan estructuralmente de buttons/options interactivos quedan en known-gap hasta V1.1.x (ver §Gaps).

Consequences

  • Positivas
  • Cualquier cambio al flow-engine v2 que rompa comportamiento legacy se detecta en CI antes de merge.
  • La definición legacy queda codificada como contrato verificable y no como conocimiento tribal.
  • El transpiler es reutilizable para una migración bulk en cutover (ADR-0009).
  • Negativas / costos
  • Transpiler debe mantenerse en sync si aparecen nuevos kind en producción legacy.
  • Las interactividades (buttons/options) quedan flatteneadas a texto plano: pérdida de UX pero comportamiento de routing preservado (best-effort: next = primer option).

Gaps conocidos (no bloquean V1.1 #6)

  1. buttons / options interactivos — flow-schema v2 no tiene tipos buttons/list aún. Pendiente: V1.1.x agregar tipos interactivos al schema + executor + UI builder. Hasta entonces, transpiler emite LOSSY_INTERACTIVE y renderiza markdown. 15 ocurrencias en el corpus legacy.
  2. js (legacy JS sandbox) — 6 ocurrencias. v2 no soporta ejecución arbitraria de JS por superficie de ataque. Mitigación V1.1.x: nuevo step type lambda-invoke con whitelist de funciones registradas. Hasta entonces, placeholder text.
  3. flow_transfer (sub-flow nesting) — 1 ocurrencia. Pendiente diseño en V1.2 (sub-flow inlining vs. step type flow-link).
  4. Operador contains legacy — el transpiler lo degrada a == (best-effort). Pendiente extender evaluateExpression v2 con ~= (substring).
  5. Variables {{user.name}} / {{user.phone}} — el transpiler las mapea a context.user_name, pero el flow-engine no las inyecta hoy. Pendiente: handler upstream debe poblar context.user_* desde inbound-router.

Resultados de la run inicial (2026-05-15)

totalFlows: 21
clean: 9          (sin warnings)
withWarnings: 12  (LOSSY_INTERACTIVE o UNSUPPORTED_KIND)
failed: 0
warningCodes:
  UNSUPPORTED_KIND: 7  (js + flow_transfer)
  LOSSY_INTERACTIVE: 15 (buttons + options)

scenarios: 64/64 passing
flow-engine total tests: 92/92 passing (no regressions)

Alternativas consideradas

  • A) Diff binario de outputs legacy vs v2 sin transpiler. Rechazada: requiere ejecutar el motor legacy en CI (no podemos — vive en una Lambda productiva con dependencias acopladas a Five9 real).
  • B) Pact contracts. Rechazada para este caso: Pact verifica integración entre productor y consumidor de eventos, no comportamiento del FSM frente a inputs de usuario. Sí lo usaremos para flow-engineconnector-orchestrator.
  • C) Snapshot tests sobre el output completo de processInbound. Descartada: snapshots son frágiles y no expresan intención. Las aserciones expectedReplyContains/expectedHandoff/expectedFinal son legibles y permiten evolucionar el motor sin reescribir fixtures.