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:
- Un transpiler determinístico legacy → v2.
- 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. - Un gate bloqueante en CI para impedir regresiones.
Decision¶
1. Transpiler @zengine/legacy-transpiler (packages/legacy-transpiler/)¶
Convierte LegacyFlow → Flow v2 y produce un TranspileWarning[]. Reglas de mapeo:
Legacy kind |
v2 step | Notas |
|---|---|---|
message |
text |
nextNodeId → next |
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/apiEndpoint → method/url; valida URL/método |
ai |
llm |
aiPrompt → promptTemplate, 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/ sinido sinkind→ skip + warningMALFORMED_NODE. - Refs a nodos inexistentes →
next: null+ warningDANGLING_REFERENCE. startNodeIdno resuelve → fallback al primer nodo válido + warningINVALID_START.- Flow sin nodos válidos → genera placeholder de un solo step (
type: text).
2. Parity Gate (services/flow-engine/tests/parity/)¶
buildFixtures.tscorre el transpiler sobre los 21 flows y persistefixtures/<flowId>.json+<flowId>.warnings.json+_coverage-report.json._build.test.tsregenera fixtures al inicio de la suite (idempotente).inputs/iplacex-style.fixtures.jsondefine 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(legacyif). - Fallbacks (
defaultrama de condition). - Handoff paths (
transferlegacy →HandoffRequestedv2). - Edge cases: input vacío, sensibilidad a mayúsculas, repeticiones.
parity.test.tsitera escenarios, ejecutarunFlowin-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_KINDoMALFORMED_NODE. - Cobertura de escenarios: ≥60 escenarios input→expected pasan al 100%. (Actual: 64/64.)
- Cero
faileden_coverage-report.json. - Los flows que dependan estructuralmente de
buttons/optionsinteractivos quedan en known-gap hasta V1.1.x (ver §Gaps).
Consequences¶
- Positivas
- Cualquier cambio al
flow-enginev2 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
kinden 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)¶
buttons/optionsinteractivos — flow-schema v2 no tiene tiposbuttons/listaún. Pendiente: V1.1.x agregar tipos interactivos al schema + executor + UI builder. Hasta entonces, transpiler emiteLOSSY_INTERACTIVEy renderiza markdown. 15 ocurrencias en el corpus legacy.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 typelambda-invokecon whitelist de funciones registradas. Hasta entonces, placeholder text.flow_transfer(sub-flow nesting) — 1 ocurrencia. Pendiente diseño en V1.2 (sub-flow inlining vs. step typeflow-link).- Operador
containslegacy — el transpiler lo degrada a==(best-effort). Pendiente extenderevaluateExpressionv2 con~=(substring). - Variables
{{user.name}}/{{user.phone}}— el transpiler las mapea acontext.user_name, pero el flow-engine no las inyecta hoy. Pendiente: handler upstream debe poblarcontext.user_*desdeinbound-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-engine↔connector-orchestrator. - C) Snapshot tests sobre el output completo de
processInbound. Descartada: snapshots son frágiles y no expresan intención. Las asercionesexpectedReplyContains/expectedHandoff/expectedFinalson legibles y permiten evolucionar el motor sin reescribir fixtures.