01 · Resumen ejecutivo

Motor TS que convierte datos en direccion accionable

El motor evaluador es el corazon operativo del MVP. Toma kpi_snapshots + rule_definitions + contexto de empresa/periodo y produce una cadena auditable: rule_evaluationstensionsactions → evidence requirements → notifications → payload de impacto Score.

Sin este motor, FARO tiene datos, tablas y reglas, pero no decide nada. Con este motor, FARO puede decir: esto esta pasando, esta tension se activo, esta es la causa, este es el responsable, esta es la accion sugerida, esta es la evidencia requerida, este es el impacto sobre el Score. Eso ya no es dashboard. Es direccion accionable.

Esta pieza consolida dos especificaciones que vivian separadas: FARO-ENG-003 (base original del motor) + FARO-ENG-003.1 (patch que reemplaza el ACTION_CATALOG hardcodeado por consultas a las tres tablas *_definitions). El patch ya esta aplicado en cada bloque de codigo de esta pagina; queda como changelog visible en la seccion 14, no como spec separada.

Fuente de verdad despues de la consolidacion. El motor lee siempre PostgreSQL (faro.tension_definitions, faro.action_definitions, faro.evidence_definitions), bloquea ejecucion de reglas con codigos fuera de catalogo y aplica jerarquia de fuentes: YAML cliente > catalogo canonico > fallback tecnico. Cero catalogos hardcodeados en codigo de aplicacion.

Empresa Demo de referencia: Empresa Demo Cuyo S.A. (UUID 10000000-0000-0000-0000-000000000001). Periodo demo: 2026-05-01 a 2026-05-31. Set esperado de tensiones que dispara: TNS-001, TNS-004, TNS-006, TNS-007, TNS-009, TNS-010 (+ variaciones segun severidad).

02 · Tesis tecnica y 10 principios

El catalogo gobierna, el codigo ejecuta

La logica de direccion no debe estar escondida en el codigo. El codigo debe ejecutar. El catalogo debe gobernar. Esa es la tesis tecnica del motor en su version consolidada con catalogos canonicos.

Antes (ENG-003 base): el motor podia evaluar reglas y crear tensiones, pero parte de la logica vivia en objetos como const ACTION_CATALOG = {...}. Sirve para arrancar; no sirve para producto.

Despues (ENG-003.1 aplicado): el motor consulta action_definitions y actua segun el catalogo. Permite agregar acciones sin tocar codigo, cambiar SLAs desde base, cambiar evidencia requerida desde catalogo, enriquecer UI sin duplicar logica y evitar diferencias entre YAML, SQL, UI y reportes.

Principio 01

Deterministico

Misma regla con mismos datos debe dar el mismo resultado. Sin aleatoriedad, sin IA decisora, sin temporalidad oculta.

Principio 02

Auditable

Toda evaluacion queda registrada en rule_evaluations con input_payload y output_payload completos. Trazabilidad total.

Principio 03

Multiempresa

Nunca mezcla empresas. Setea app.company_id en cada transaccion. RLS de PostgreSQL bloquea cualquier fuga.

Principio 04

Seguro

Respeta RLS y contexto company_id. Roles minimos: integration_service, faro_owner, company_admin.

Principio 05

Idempotente

Correr dos veces no duplica tensiones abiertas. Clave: company_id + tension_code + periodo + estado_abierto.

Principio 06

Explicable

Registra por que disparo o no disparo. ConditionDiagnostic por cada condicion: actual, expected, operator, passed.

Principio 07

Configurable

Lee reglas desde faro.rule_definitions. Enriquece tensiones/acciones/evidencias desde los tres catalogos canonicos.

Principio 08

Extensible

Nuevas reglas sin cambiar codigo. Nuevas acciones sin tocar codigo. Nueva evidencia desde catalogo.

Principio 09

Prudente

Si falta dato o baja confianza, respeta missing_data_policy. Por defecto: no inventar, no disparar.

Principio 10

Accionable

Toda tension tiene accion (catalogo), responsable (rol + RLS) y evidencia (catalogo). Sin esos tres, no se crea.

Jerarquia de fuentes obligatoria. Cuando el motor decide que valor usar: 1) Regla YAML especifica de cliente > 2) Catalogo canonico PostgreSQL > 3) Fallback tecnico seguro. Ejemplo: si YAML trae default_sla_days: 7 y catalogo dice 10, gana YAML pero se guarda en payload que se uso override. Si YAML no trae nada, gana catalogo. Si catalogo no tiene, gana fallback.

03 · Que hace y que NO hace el motor

Alcance explicito para no inflar el MVP

Lista dura de responsabilidades del motor evaluador. Cualquier cosa que aparezca en la columna izquierda es codigo del motor. Cualquier cosa en la columna derecha corresponde a otro modulo o a una fase posterior.

✓ HACE
  • Lee reglas activas desde faro.rule_definitions con jerarquia company > global.
  • Lee KPIs desde faro.kpi_snapshots para el periodo solicitado.
  • Valida datos requeridos: KPIs presentes, confianza minima, dimension/periodo correcto.
  • Evalua condiciones all, any, none con 13 operadores.
  • Calcula severidad default + escalations declarativas.
  • Calcula prioridad via severity + confidence + scoreImpact normalizado 0-100.
  • Consulta catalogos canonicos antes de crear tension/accion (patch 003.1).
  • Bloquea reglas con codigo fuera de catalogo con error UNKNOWN_TENSION_CODE.
  • Crea tension enriquecida con metadata de tension_definitions.
  • Crea acciones desde action_definitions (no desde hardcoded).
  • Adjunta evidencia enriquecida desde evidence_definitions.
  • Calcula impacto Score y prepara payload.
  • Registra auditoria en audit.audit_log.
  • Soporta dry-run y explain via CLI.
× NO HACE
  • No calcula KPIs base → eso corresponde a FARO-TEST-001 / motor KPI.
  • No importa YAML → eso corresponde a FARO-ENG-002 (DSL Parser).
  • No cierra acciones → requiere evidencia + aprobacion humana.
  • No aprueba evidencia → lo hace responsable/aprobador asignado.
  • No modifica RAW → RAW es inmutable por contrato.
  • No usa IA para decidir → IA solo explica/redacta, no dispara tensiones.
  • No recalibra reglas automaticamente → fase posterior con historico.
  • No hace simulaciones complejas (what-if) → fase posterior.
  • No reemplaza criterio directivo → recomienda y ordena, no gobierna solo.
  • No administra catalogos → UI de admin queda fuera del motor.
  • No versiona catalogos avanzado → usa version DESC LIMIT 1 simple.
  • No envia emails reales → produce payload de notificacion (TPL-001 entrega).
  • No reabre tensiones cerradas → politica MVP, queda para fase posterior.
  • No corre en tiempo real → batch diario/semanal alcanza para MVP.
04 · Flujo de 17 pasos del motor

De company_id + periodo a tensiones + acciones + evidencia

Secuencia oficial de la corrida del motor. Cada paso es transaccional dentro del wrapper withFaroDbContext. Los pasos 11, 12 y 13 son los que el patch 003.1 enriquecio con consultas a catalogos canonicos.

kpi_snapshots
rule_definitions
evaluator
catalog
validation
tensions
actions
score
payload
  1. Recibir contexto: company_id, periodo, modo (normal/dry-run), opciones de creacion.
  2. Setear contexto RLS en PostgreSQL via set_config('app.company_id', ...) + app.user_id + app.role_codes. BEGIN transaction.
  3. Cargar reglas activas desde faro.rule_definitions filtradas por estado y empresa (orden: company > global, version DESC).
  4. Cargar kpi_snapshots requeridos para los required_kpis de cada regla en el periodo y dimension.
  5. Validar disponibilidad de datos contra missing_data_policy (do_not_trigger / warn / continue).
  6. Validar confianza minima via ConfidenceValidator: promedio de confidence_score vs minimum_confidence_score.
  7. Evaluar condiciones all/any/none via ConditionEvaluator y producir ConditionDiagnostic[].
  8. Calcular severidad via SeverityCalculator: default + escalation rules declarativas.
  9. Calcular prioridad 0-100 via PriorityCalculator: severity base + confidence adj + impact adj.
  10. Registrar rule_evaluation en faro.rule_evaluations con input_payload + output_payload completos.
  11. Validar codigos contra catalogos (patch 003.1): assertTensionCodeExists + assertActionCodesExist + assertEvidenceCodesExist. Si falla → error UNKNOWN_*_CODE.
  12. Cargar TensionDefinition canonica via getActiveTensionDefinition. Si la regla disparo, crear o actualizar tension enriquecida con payload canonico (canonical: true, catalog_source, tension_definition_id, business_question, executive_diagnosis, score_dimension).
  13. Crear acciones sugeridas desde catalogo (patch 003.1): merge recommended_actions YAML + catalogo. Para cada ACT-* consultar action_definitions, resolver responsable, calcular due_date desde defaultSlaDays, adjuntar evidence_requirements enriquecidas.
  14. Registrar evidencia requerida en payload de tension y de cada accion con metadata canonica (evidence_type, trust_level, requires_review, can_close_action, confidence_weight).
  15. Crear notificaciones opcionales (payload, no envio real). MVP: critical_tension + critical_action.
  16. Preparar payload de impacto Score: base + multiplier por severidad, clamp por max, guardado en tensions.score_impact.
  17. Registrar auditoria en audit.audit_log, COMMIT transaction, devolver EvaluationRunSummary con metricas operativas.

Politica de idempotencia. Si la corrida se repite para el mismo periodo: crea nueva rule_evaluation, pero NO duplica tensions abiertas ni actions abiertas. Actualiza severity, priority, confidence, payload y description de la tension existente. Tension cerrada no se reabre automaticamente en MVP.

05 · Arquitectura del modulo faro-evaluator

Estructura consolidada base + patch

Modulo Node.js + CLI con TypeScript estricto. La estructura abajo refleja ENG-003 (base) con los archivos nuevos del patch ENG-003.1 marcados como NEW y los modificados como PATCH.

faro-evaluator/
  package.json
  tsconfig.json
  .env.example
  README.md

  src/
    index.ts
    cli.ts

    config/
      evaluator.config.ts

    types/
      rule.types.ts
      kpi.types.ts
      evaluation.types.ts          PATCH  # +catalogWarnings, +catalogStatus
      action.types.ts              PATCH
      catalog.types.ts             NEW    # TensionDefinition, ActionDefinition, EvidenceDefinition

    db/
      db.ts                        # withFaroDbContext + RLS
      ruleRepository.ts
      kpiRepository.ts
      evaluationRepository.ts
      tensionRepository.ts         PATCH
      actionRepository.ts          PATCH  # createAction acepta enriquecido
      notificationRepository.ts
      auditRepository.ts
      tensionCatalogRepository.ts  NEW    # lee faro.tension_definitions
      actionCatalogRepository.ts   NEW    # lee faro.action_definitions
      evidenceCatalogRepository.ts NEW    # lee faro.evidence_definitions

    engine/
      evaluator.ts                 PATCH
      conditionEvaluator.ts        # all/any/none, 13 operadores
      severityCalculator.ts
      priorityCalculator.ts
      confidenceValidator.ts
      idempotency.ts
      diagnosticsBuilder.ts        PATCH

    services/
      evaluationService.ts         PATCH  # bloque central reescrito
      tensionService.ts            PATCH
      actionService.ts             PATCH  # elimina ACTION_CATALOG hardcoded
      scoreImpactService.ts
      notificationService.ts
      catalogValidationService.ts  NEW    # assertTension/Action/EvidenceCodes
      evidenceRequirementService.ts NEW   # buildEvidenceRequirementsPayload

    commands/
      evaluateCommand.ts
      dryRunCommand.ts
      explainCommand.ts            PATCH  # muestra catalogo canonico

    utils/
      logger.ts
      dates.ts
      errors.ts

  tests/
    evaluator.test.ts
    catalog_integration.test.ts    NEW
    fixtures/
      rules.ts
      kpis.ts

3 archivos nuevos en db/ (repositorios de catalogos), 2 en services/ (validacion + evidencia), 1 en types/ y 1 en tests/. 7 archivos modificados (patch). La separacion repositorio/servicio/engine se mantiene; el patch suma una capa de catalogos sin romper la estructura original.

06 · Stack tecnico + package.json + tsconfig

TypeScript ESM, pg driver, vitest, commander CLI

Stack minimo, dependencias acotadas. Cero ORM (consultas SQL explicitas), cero IA en MVP (motor deterministico), cero framework HTTP (es libreria + CLI). Cobertura de tests con vitest.

▸ package.json
{
  "name": "@faro/evaluator",
  "version": "1.1.0",
  "description": "FARO Connect MVP Evaluation Engine (ENG-003 + ENG-003.1)",
  "type": "module",
  "scripts": {
    "dev": "tsx src/cli.ts",
    "evaluate": "tsx src/cli.ts evaluate",
    "dry-run": "tsx src/cli.ts dry-run",
    "explain": "tsx src/cli.ts explain",
    "typecheck": "tsc --noEmit",
    "test": "vitest run",
    "test:unit": "vitest run tests/unit",
    "test:integration": "vitest run tests/integration"
  },
  "dependencies": {
    "commander": "^12.1.0",
    "dotenv": "^16.4.5",
    "pg": "^8.13.1",
    "zod": "^3.23.8"
  },
  "devDependencies": {
    "@types/node": "^22.10.0",
    "@types/pg": "^8.11.10",
    "tsx": "^4.19.2",
    "typescript": "^5.7.2",
    "vitest": "^2.1.8"
  }
}
▸ tsconfig.json
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "ESNext",
    "moduleResolution": "Bundler",
    "strict": true,
    "strictNullChecks": true,
    "esModuleInterop": true,
    "resolveJsonModule": true,
    "outDir": "dist",
    "rootDir": ".",
    "types": ["node"]
  },
  "include": ["src/**/*.ts", "tests/**/*.ts"]
}
▸ .env.example
# Conexion PostgreSQL (RLS habilitado, schema faro)
DATABASE_URL=postgresql://faro_app:password@localhost:5432/faro

# Empresa Demo Cuyo S.A. + usuario service
FARO_DEFAULT_COMPANY_ID=10000000-0000-0000-0000-000000000001
FARO_DEFAULT_USER_ID=12000000-0000-0000-0000-000000000001
FARO_DEFAULT_ROLE_CODES=integration_service,faro_owner,company_admin

# Modo de evaluacion y flags
FARO_EVALUATION_MODE=normal
FARO_CREATE_ACTIONS=true
FARO_CREATE_NOTIFICATIONS=false
FARO_STRICT_CONFIDENCE=true
▸ src/db/db.ts · conexion + contexto RLS
import pg from "pg";
import dotenv from "dotenv";

dotenv.config();
const { Pool } = pg;

if (!process.env.DATABASE_URL) {
  throw new Error("DATABASE_URL is required");
}

export const pool = new Pool({ connectionString: process.env.DATABASE_URL });

export async function withFaroDbContext<T>(
  context: { companyId: string; userId: string | null; roleCodes: string[] },
  fn: (client: pg.PoolClient) => Promise<T>
): Promise<T> {
  const client = await pool.connect();
  try {
    await client.query("BEGIN");
    await client.query(`SELECT set_config('app.company_id', $1, true)`, [context.companyId]);
    if (context.userId) {
      await client.query(`SELECT set_config('app.user_id', $1, true)`, [context.userId]);
    }
    await client.query(`SELECT set_config('app.role_codes', $1, true)`, [context.roleCodes.join(",")]);
    const result = await fn(client);
    await client.query("COMMIT");
    return result;
  } catch (error) {
    await client.query("ROLLBACK");
    throw error;
  } finally {
    client.release();
  }
}
07 · Repositorios (7 base + 3 catalogos NEW)

Capa de acceso a datos con SQL explicito

El motor accede a PostgreSQL via repositorios delgados (sin ORM). Cada repositorio expone funciones puras que reciben pg.PoolClient y mapean filas SQL a tipos TypeScript. El patch 003.1 suma tres repositorios nuevos: uno por cada catalogo canonico.

Base
db/ruleRepository.ts

getActiveRulesForCompany lee faro.rule_definitions con jerarquia company > global, version DESC, deduplicacion por rule_code.

Base
db/kpiRepository.ts

getKpiSnapshotsForRule trae kpi_snapshots filtrados por periodo, dimension y array de KPI codes. Devuelve KpiSnapshotMap.

Base
db/evaluationRepository.ts

insertRuleEvaluation guarda rule_evaluations con input_payload + output_payload completos como JSONB.

Patch
db/tensionRepository.ts

findOpenTension + createTension + updateExistingTension. Patch suma payload canonico al INSERT/UPDATE.

Patch
db/actionRepository.ts

findExistingActionForTension + createAction. Patch agrega action_type, expected_impact y expected_impact_amount.

Base
db/notificationRepository.ts

Inserta payload de notificacion (critical_tension, critical_action). MVP: no envia; produce row para que TPL-001 entregue.

Base
db/auditRepository.ts

insertAuditLog graba audit.audit_log con company_id, actor, entity, action, metadata JSONB.

NEW · Patch 003.1
db/tensionCatalogRepository.ts

Consulta faro.tension_definitions. Expone getActiveTensionDefinition, getActiveTensionDefinitionsByCodes y assertTensionCodeExists (lanza UNKNOWN_TENSION_CODE).

NEW · Patch 003.1
db/actionCatalogRepository.ts

Consulta faro.action_definitions. Expone getActiveActionDefinition, getActiveActionDefinitionsByCodes y assertActionCodesExist.

NEW · Patch 003.1
db/evidenceCatalogRepository.ts

Consulta faro.evidence_definitions. Expone getActiveEvidenceDefinitionsByCodes, assertEvidenceCodesExist y toEvidenceRequirementPayload (proyeccion enriquecida para payload de tensiones/acciones).

▸ src/db/ruleRepository.ts
import type pg from "pg";
import type { RuleDefinition } from "../types/rule.types.js";

function mapRule(row: any): RuleDefinition {
  return {
    ruleId: row.rule_id,
    companyId: row.company_id,
    ruleCode: row.rule_code,
    name: row.name,
    description: row.description,
    ruleType: row.rule_type,
    ruleFormat: row.rule_format,
    ruleBody: row.rule_body,
    severityDefault: row.severity_default,
    isMvp: row.is_mvp,
    status: row.status,
    version: row.version
  };
}

export async function getActiveRulesForCompany(
  client: pg.PoolClient,
  companyId: string
): Promise<RuleDefinition[]> {
  const result = await client.query(
    `
    SELECT *
    FROM faro.rule_definitions
    WHERE status = 'active'
      AND rule_type = 'tension'
      AND (company_id IS NULL OR company_id = $1)
    ORDER BY
      CASE WHEN company_id = $1 THEN 0 ELSE 1 END,
      rule_code,
      version DESC
    `,
    [companyId]
  );

  const latestByRuleCode = new Map<string, RuleDefinition>();
  for (const row of result.rows) {
    const rule = mapRule(row);
    if (!latestByRuleCode.has(rule.ruleCode)) {
      latestByRuleCode.set(rule.ruleCode, rule);
    }
  }
  return Array.from(latestByRuleCode.values());
}
▸ src/db/kpiRepository.ts
export async function getKpiSnapshotsForRule(
  client: pg.PoolClient,
  params: {
    companyId: string;
    periodStart: string;
    periodEnd: string;
    kpiCodes: string[];
    dimensionType?: string;
    dimensionId?: string | null;
  }
): Promise<KpiSnapshotMap> {
  const result = await client.query(
    `
    SELECT *
    FROM faro.kpi_snapshots
    WHERE company_id = $1
      AND period_start = $2
      AND period_end = $3
      AND kpi_code = ANY($4)
      AND dimension_type = COALESCE($5, dimension_type)
      AND ($6::uuid IS NULL OR dimension_id = $6::uuid)
    `,
    [params.companyId, params.periodStart, params.periodEnd, params.kpiCodes,
     params.dimensionType ?? null, params.dimensionId ?? null]
  );

  const map: KpiSnapshotMap = {};
  for (const row of result.rows) {
    const snapshot = mapKpi(row);
    map[snapshot.kpiCode] = snapshot;
  }
  return map;
}
▸ NEW · src/db/tensionCatalogRepository.ts (patch 003.1)
import type { TensionDefinition } from "../types/catalog.types.js";

function mapTensionDefinition(row: any): TensionDefinition {
  return {
    tensionDefinitionId: row.tension_definition_id,
    tensionCode: row.tension_code,
    name: row.name,
    shortName: row.short_name,
    areaCode: row.area_code,
    moduleCode: row.module_code,
    businessQuestion: row.business_question,
    description: row.description,
    executiveDiagnosis: row.executive_diagnosis,
    triggerLogic: row.trigger_logic,
    requiredKpis: row.required_kpis ?? [],
    recommendedActions: row.recommended_actions ?? [],
    evidenceRequired: row.evidence_required ?? [],
    defaultSeverity: row.default_severity,
    severityLogic: row.severity_logic,
    defaultOwnerRole: row.default_owner_role,
    approverRole: row.approver_role,
    scoreDimension: row.score_dimension,
    scoreImpactMin: Number(row.score_impact_min),
    scoreImpactMax: Number(row.score_impact_max),
    mvpPriority: row.mvp_priority,
    demoRelevance: row.demo_relevance,
    industryScope: row.industry_scope ?? [],
    moduleScope: row.module_scope ?? [],
    status: row.status,
    version: Number(row.version),
    metadata: row.metadata ?? {}
  };
}

export async function getActiveTensionDefinition(
  client: pg.PoolClient,
  tensionCode: string
): Promise<TensionDefinition | null> {
  const result = await client.query(
    `
    SELECT *
    FROM faro.tension_definitions
    WHERE tension_code = $1
      AND status = 'active'
    ORDER BY version DESC
    LIMIT 1
    `,
    [tensionCode]
  );
  return result.rows[0] ? mapTensionDefinition(result.rows[0]) : null;
}

export async function assertTensionCodeExists(
  client: pg.PoolClient,
  tensionCode: string
): Promise<void> {
  const definition = await getActiveTensionDefinition(client, tensionCode);
  if (!definition) {
    throw new Error(
      `UNKNOWN_TENSION_CODE: ${tensionCode} does not exist in faro.tension_definitions`
    );
  }
}
▸ NEW · src/db/actionCatalogRepository.ts (patch 003.1)
export async function getActiveActionDefinitionsByCodes(
  client: pg.PoolClient,
  actionCodes: string[]
): Promise<Map<string, ActionDefinition>> {
  if (actionCodes.length === 0) return new Map();
  const result = await client.query(
    `
    SELECT *
    FROM faro.action_definitions
    WHERE action_code = ANY($1)
      AND status = 'active'
    ORDER BY action_code, version DESC
    `,
    [actionCodes]
  );
  const map = new Map<string, ActionDefinition>();
  for (const row of result.rows) {
    const def = mapActionDefinition(row);
    if (!map.has(def.actionCode)) map.set(def.actionCode, def);
  }
  return map;
}

export async function assertActionCodesExist(
  client: pg.PoolClient,
  actionCodes: string[]
): Promise<void> {
  const defs = await getActiveActionDefinitionsByCodes(client, actionCodes);
  const missing = actionCodes.filter((c) => !defs.has(c));
  if (missing.length > 0) {
    throw new Error(`UNKNOWN_ACTION_CODE: ${missing.join(", ")}`);
  }
}
▸ NEW · src/db/evidenceCatalogRepository.ts (patch 003.1)
export async function getActiveEvidenceDefinitionsByCodes(
  client: pg.PoolClient,
  evidenceCodes: string[]
): Promise<Map<string, EvidenceDefinition>> {
  if (evidenceCodes.length === 0) return new Map();
  const result = await client.query(
    `
    SELECT *
    FROM faro.evidence_definitions
    WHERE evidence_code = ANY($1)
      AND status = 'active'
    ORDER BY evidence_code, version DESC
    `,
    [evidenceCodes]
  );
  const map = new Map<string, EvidenceDefinition>();
  for (const row of result.rows) {
    const def = mapEvidenceDefinition(row);
    if (!map.has(def.evidenceCode)) map.set(def.evidenceCode, def);
  }
  return map;
}

export function toEvidenceRequirementPayload(
  d: EvidenceDefinition
): EvidenceRequirementPayload {
  return {
    evidence_code: d.evidenceCode,
    name: d.name,
    evidence_type: d.evidenceType,
    trust_level: d.trustLevel,
    requires_review: d.requiresReview,
    can_close_action: d.canCloseAction,
    confidence_weight: d.confidenceWeight,
    allowed_submission_modes: d.allowedSubmissionModes,
    allowed_file_types: d.allowedFileTypes,
    required_metadata_keys: d.requiredMetadataKeys
  };
}
08 · ConditionEvaluator (all / any / none)

13 operadores deterministicos con diagnostic por condicion

Corazon de la evaluacion. Recibe un ConditionGroup con tres arrays opcionales (all, any, none) y un KpiSnapshotMap. Produce un booleano de paso global y un ConditionDiagnostic[] con actual/expected/operator/passed por cada condicion. Sin esto, no hay explicabilidad.

Operadores soportados (13). Comparacion numerica: >, >=, <, <=, ==, !=. Rangos: between. Conjuntos: in, not_in. Presencia: exists, missing. Especiales FARO: changed_by_pct (delta absoluto vs umbral), older_than_days (antiguedad en dias). Las metricas leibles del snapshot: value, reference_value, delta_value, delta_pct, confidence_score, status.

▸ src/types/rule.types.ts · tipos base
export type Severity = "low" | "medium" | "high" | "critical";

export type RuleOperator =
  | ">" | ">=" | "<" | "<=" | "==" | "!="
  | "between" | "in" | "not_in"
  | "exists" | "missing"
  | "changed_by_pct" | "older_than_days";

export type Condition = {
  kpi: string;
  metric: string;
  operator: RuleOperator;
  value?: number | string | boolean | Array<number | string>;
};

export type ConditionGroup = {
  all?: Condition[];
  any?: Condition[];
  none?: Condition[];
};
▸ src/engine/conditionEvaluator.ts
import type { Condition, ConditionGroup } from "../types/rule.types.js";
import type { KpiSnapshotMap } from "../types/kpi.types.js";
import type { ConditionDiagnostic } from "../types/evaluation.types.js";

function getMetricValue(c: Condition, snapshots: KpiSnapshotMap): unknown {
  const s = snapshots[c.kpi];
  if (!s) return undefined;
  switch (c.metric) {
    case "value": return s.value;
    case "reference_value": return s.referenceValue;
    case "delta_value": return s.deltaValue;
    case "delta_pct": return s.deltaPct;
    case "confidence_score": return s.confidenceScore;
    case "status": return s.status;
    default: return (s as any)[c.metric];
  }
}

function compare(actual: unknown, op: string, expected: unknown): boolean {
  if (op === "exists") return actual !== undefined && actual !== null;
  if (op === "missing") return actual === undefined || actual === null;
  if (op === "in") return Array.isArray(expected) && expected.includes(actual as any);
  if (op === "not_in") return Array.isArray(expected) && !expected.includes(actual as any);
  if (op === "between") {
    if (!Array.isArray(expected) || expected.length !== 2) return false;
    if (typeof actual !== "number") return false;
    return actual >= Number(expected[0]) && actual <= Number(expected[1]);
  }
  if (typeof actual !== "number" || typeof expected !== "number") {
    if (op === "==") return actual === expected;
    if (op === "!=") return actual !== expected;
    return false;
  }
  switch (op) {
    case ">": return actual > expected;
    case ">=": return actual >= expected;
    case "<": return actual < expected;
    case "<=": return actual <= expected;
    case "==": return actual === expected;
    case "!=": return actual !== expected;
    case "changed_by_pct": return Math.abs(actual) >= expected;
    case "older_than_days": return actual > expected;
    default: return false;
  }
}

function evaluateCondition(c: Condition, snapshots: KpiSnapshotMap): ConditionDiagnostic {
  const actual = getMetricValue(c, snapshots);
  const passed = compare(actual, c.operator, c.value);
  return {
    kpi: c.kpi, metric: c.metric, operator: c.operator,
    expected: c.value, actual, passed,
    message: passed
      ? `Condition passed: ${c.kpi}.${c.metric} ${c.operator} ${c.value}`
      : `Condition failed: ${c.kpi}.${c.metric} ${c.operator} ${c.value}; actual=${actual}`
  };
}

export function evaluateConditionGroup(
  group: ConditionGroup,
  snapshots: KpiSnapshotMap
): { passed: boolean; diagnostics: ConditionDiagnostic[] } {
  const diagnostics: ConditionDiagnostic[] = [];
  let allPassed = true, anyPassed = true, nonePassed = true;

  if (group.all?.length) {
    const r = group.all.map((c) => evaluateCondition(c, snapshots));
    diagnostics.push(...r);
    allPassed = r.every((x) => x.passed);
  }
  if (group.any?.length) {
    const r = group.any.map((c) => evaluateCondition(c, snapshots));
    diagnostics.push(...r);
    anyPassed = r.some((x) => x.passed);
  }
  if (group.none?.length) {
    const r = group.none.map((c) => evaluateCondition(c, snapshots));
    diagnostics.push(...r);
    nonePassed = r.every((x) => !x.passed);
  }
  return { passed: allPassed && anyPassed && nonePassed, diagnostics };
}
09 · Severidad + Prioridad + Confianza + Idempotencia

Cuatro engines auxiliares que calculan el peso de cada tension

Despues de que ConditionEvaluator dice si la regla pasa, el motor calcula severidad (default + escalaciones declarativas), prioridad 0-100 (severity + confidence + impact), confianza minima (promedio vs umbral) y resuelve idempotencia por clave compuesta. Cada uno es una funcion pura testeable.

▸ src/engine/severityCalculator.ts
import type { RuleDefinition, Severity } from "../types/rule.types.js";
import type { KpiSnapshotMap } from "../types/kpi.types.js";
import { evaluateConditionGroup } from "./conditionEvaluator.js";

export function calculateSeverity(
  rule: RuleDefinition,
  snapshots: KpiSnapshotMap
): Severity {
  const severity = rule.ruleBody.severity;
  for (const escalation of severity.escalation ?? []) {
    const result = evaluateConditionGroup(escalation.when, snapshots);
    if (result.passed) return escalation.set;
  }
  return severity.default;
}
▸ src/engine/priorityCalculator.ts
import type { Severity } from "../types/rule.types.js";
import { evaluatorConfig } from "../config/evaluator.config.js";

export function calculatePriorityScore(params: {
  severity: Severity;
  confidenceScore: number | null;
  scoreImpact: number | null;
}): number {
  const base = evaluatorConfig.priority[params.severity];

  // confianza alta → bonus; baja → penalty
  const confidenceAdjustment =
    params.confidenceScore === null
      ? -10
      : params.confidenceScore >= 85
        ? 5
        : params.confidenceScore < 70
          ? -10
          : 0;

  // impacto absoluto en Score, clamp a 10
  const impactAdjustment =
    params.scoreImpact === null
      ? 0
      : Math.min(10, Math.abs(params.scoreImpact));

  const result = base + confidenceAdjustment + impactAdjustment;
  return Math.max(0, Math.min(100, Number(result.toFixed(2))));
}
▸ src/engine/confidenceValidator.ts
export function validateConfidence(
  rule: RuleDefinition,
  snapshots: KpiSnapshotMap
): { passed: boolean; confidenceScore: number | null; warnings: string[] } {
  const requiredKpis = rule.ruleBody.data_requirements.required_kpis;
  const minimum = rule.ruleBody.data_requirements.minimum_confidence_score;
  const confidenceValues: number[] = [];
  const warnings: string[] = [];

  for (const kpiCode of requiredKpis) {
    const s = snapshots[kpiCode];
    if (!s) continue;
    if (s.confidenceScore === null) {
      warnings.push(`KPI ${kpiCode} has no confidence_score`);
      continue;
    }
    confidenceValues.push(s.confidenceScore);
    if (s.confidenceScore < minimum) {
      warnings.push(`KPI ${kpiCode} confidence ${s.confidenceScore} is below minimum ${minimum}`);
    }
  }

  if (confidenceValues.length === 0) {
    return { passed: false, confidenceScore: null, warnings: ["No confidence scores available"] };
  }
  const avg = confidenceValues.reduce((sum, v) => sum + v, 0) / confidenceValues.length;
  return { passed: avg >= minimum, confidenceScore: Number(avg.toFixed(2)), warnings };
}
▸ src/engine/idempotency.ts
// Estados abiertos (tensiones que cuentan para deduplicacion).
// 'escalated' es evento, no estado terminal: la tension sigue abierta.
export const OPEN_TENSION_STATUSES = [
  "new",
  "in_analysis",
  "in_execution",
  "in_verification",
  "expired",
  "escalated"
];

export function buildEvaluationKey(params: {
  companyId: string;
  tensionCode: string;
  periodStart: string;
  periodEnd: string;
  dimensionType?: string;
  dimensionId?: string | null;
}): string {
  return [
    params.companyId,
    params.tensionCode,
    params.periodStart,
    params.periodEnd,
    params.dimensionType ?? "company",
    params.dimensionId ?? "null"
  ].join(":");
}
▸ src/services/scoreImpactService.ts
export function calculateScoreImpact(params: {
  base: number;
  max?: number;
  severity: Severity;
}): number {
  const severityMultiplier: Record<Severity, number> = {
    low: 0.5, medium: 0.75, high: 1, critical: 1.25
  };
  const raw = params.base * severityMultiplier[params.severity];
  if (params.max === undefined) return Number(raw.toFixed(2));
  const maxAbs = Math.abs(params.max);
  if (raw < 0) return Number(Math.max(raw, -maxAbs).toFixed(2));
  return Number(Math.min(raw, maxAbs).toFixed(2));
}

Nota sobre escalated (decision D4 aplicada). En MVP el patch trata escalated como evento de auditoria (entry en audit.audit_log) y NO como estado terminal de la tension. La tension permanece en estado abierto (queda incluida en OPEN_TENSION_STATUSES) y el escalamiento se refleja en payload.escalated_at + payload.escalated_to. Esto evita confusion entre estado del flujo y eventos del flujo.

10 · Flujo evaluate consolidado (bloque central)

Codigo TS completo del runEvaluation con catalogos canonicos

Este es el bloque central del motor reescrito por el patch 003.1. Reemplaza la version base que creaba tensiones/acciones desde objetos hardcodeados. Ahora cada decision pasa por: validar contra catalogo → cargar definicion canonica → mergear con YAML → crear con payload enriquecido. Este es el pedazo que mas cambio entre ENG-003 y ENG-003.1.

Que cambio respecto a la version base. 1) Se valida el codigo de tension contra catalogo antes de hacer nada. 2) Se carga TensionDefinition via getActiveTensionDefinition. 3) Las acciones recomendadas resultan de mergear YAML + catalogo. 4) Las evidencias se cargan enriquecidas via buildEvidenceRequirementsPayload (si falta una, se bloquea). 5) El payload de la tension lleva canonical: true, catalog_source, tension_definition_id, business_question, executive_diagnosis, trigger_logic, score_dimension.

▸ NEW · src/services/catalogValidationService.ts (patch 003.1)
import type pg from "pg";
import type { RuleDefinition } from "../types/rule.types.js";
import { assertTensionCodeExists } from "../db/tensionCatalogRepository.js";
import { assertActionCodesExist } from "../db/actionCatalogRepository.js";
import { assertEvidenceCodesExist } from "../db/evidenceCatalogRepository.js";

export async function validateRuleAgainstCatalogs(
  client: pg.PoolClient,
  rule: RuleDefinition
): Promise<{ valid: boolean; warnings: string[] }> {
  const warnings: string[] = [];

  // 1. Tension code DEBE existir en catalogo
  await assertTensionCodeExists(client, rule.ruleBody.tension_code);

  // 2. Action codes DEBEN existir todos
  const actionCodes = rule.ruleBody.output.recommended_actions ?? [];
  await assertActionCodesExist(client, actionCodes);

  // 3. Evidence codes DEBEN existir todos
  const evidenceCodes = rule.ruleBody.output.evidence_required ?? [];
  await assertEvidenceCodesExist(client, evidenceCodes);

  // 4. Warning si RULE-TNS-XXX no coincide con TNS-XXX
  const ruleNumber = rule.ruleCode.replace("RULE-TNS-", "");
  const tensionNumber = rule.ruleBody.tension_code.replace("TNS-", "");
  if (ruleNumber !== tensionNumber) {
    warnings.push(
      `RULE_TENSION_CODE_MISMATCH: ${rule.ruleCode} points to ${rule.ruleBody.tension_code}`
    );
  }

  return { valid: true, warnings };
}
▸ NEW · src/services/evidenceRequirementService.ts (patch 003.1)
export async function buildEvidenceRequirementsPayload(
  client: pg.PoolClient,
  evidenceCodes: string[]
): Promise<{
  evidenceCodes: string[];
  requirements: EvidenceRequirementPayload[];
  missingEvidenceCodes: string[];
}> {
  const uniqueCodes = Array.from(new Set(evidenceCodes));
  const definitions = await getActiveEvidenceDefinitionsByCodes(client, uniqueCodes);

  const requirements: EvidenceRequirementPayload[] = [];
  const missingEvidenceCodes: string[] = [];

  for (const code of uniqueCodes) {
    const definition = definitions.get(code);
    if (!definition) {
      missingEvidenceCodes.push(code);
      continue;
    }
    requirements.push(toEvidenceRequirementPayload(definition));
  }
  return { evidenceCodes: uniqueCodes, requirements, missingEvidenceCodes };
}
▸ PATCH · bloque central de runEvaluation (reemplaza if result.triggered)
// Helpers de merge (declarados al inicio del archivo)
function uniqueArray(values: string[]): string[] {
  return Array.from(new Set(values.filter(Boolean)));
}

// ====== Bloque que reemplaza la creacion vieja de tension ======

if (!result.triggered) {
  continue;
}

summary.rulesTriggered++;

if (!rule.ruleBody.output.create_tension) {
  continue;
}

// 1. Validar la regla contra los tres catalogos (bloquea si falta algo)
const catalogValidation = await validateRuleAgainstCatalogs(client, rule);

// 2. Cargar TensionDefinition canonica
const tensionDefinition = await getActiveTensionDefinition(
  client,
  rule.ruleBody.tension_code
);

if (!tensionDefinition) {
  summary.errors++;
  console.error(`Missing canonical tension definition: ${rule.ruleBody.tension_code}`);
  continue;
}

const yamlOutput = rule.ruleBody.output;

// 3. Merge: YAML + catalogo → lista canonica de acciones recomendadas
const recommendedActionCodes = uniqueArray([
  ...(yamlOutput.recommended_actions ?? []),
  ...(tensionDefinition.recommendedActions ?? [])
]);

// 4. Merge: YAML + catalogo → lista canonica de evidencia requerida
const evidenceCodes = uniqueArray([
  ...(yamlOutput.evidence_required ?? []),
  ...(tensionDefinition.evidenceRequired ?? [])
]);

// 5. Cargar payload enriquecido de cada evidencia (bloquea si falta una)
const evidencePayload = await buildEvidenceRequirementsPayload(client, evidenceCodes);

if (evidencePayload.missingEvidenceCodes.length > 0) {
  throw new Error(
    `UNKNOWN_EVIDENCE_CODE for ${tensionDefinition.tensionCode}: ${evidencePayload.missingEvidenceCodes.join(", ")}`
  );
}

// 6. Resolver responsable: YAML > catalogo > fallback "general_manager"
const ownerRole =
  yamlOutput.assign_to_role ||
  tensionDefinition.defaultOwnerRole ||
  "general_manager";

const approverRole =
  yamlOutput.approver_role ||
  tensionDefinition.approverRole ||
  "director";

const responsibleUserId = await resolveResponsibleUser(client, {
  companyId: context.companyId,
  logicalRole: ownerRole
});

const approverUserId = approverRole
  ? await resolveResponsibleUser(client, {
      companyId: context.companyId,
      logicalRole: approverRole
    })
  : null;

// 7. Diagnostico: template YAML > executive_diagnosis catalogo > description catalogo
const diagnosis = buildDiagnosisText({
  template:
    yamlOutput.diagnosis_template ||
    tensionDefinition.executiveDiagnosis ||
    tensionDefinition.description,
  diagnostics: result.diagnostics
});

// 8. Idempotencia: buscar tension abierta del mismo periodo/dimension
const existing = await findOpenTension(client, {
  companyId: context.companyId,
  tensionCode: tensionDefinition.tensionCode,
  periodStart: context.periodStart,
  periodEnd: context.periodEnd,
  dimensionType: context.dimensionType ?? "company",
  dimensionId: context.dimensionId ?? null
});

let tensionId: string;

// 9. Payload canonico enriquecido para la tension
const tensionPayload = {
  canonical: true,
  catalog_source: "faro.tension_definitions",
  tension_definition_id: tensionDefinition.tensionDefinitionId,
  tension_code: tensionDefinition.tensionCode,
  tension_name: tensionDefinition.name,
  business_question: tensionDefinition.businessQuestion,
  executive_diagnosis: tensionDefinition.executiveDiagnosis,
  trigger_logic: tensionDefinition.triggerLogic,
  score_dimension: tensionDefinition.scoreDimension,
  rule_code: rule.ruleCode,
  rule_version: rule.version,
  period_start: context.periodStart,
  period_end: context.periodEnd,
  dimension_type: context.dimensionType ?? "company",
  dimension_id: context.dimensionId ?? "null",
  diagnostics: result.diagnostics,
  warnings: [
    ...result.warnings,
    ...(catalogValidation.warnings ?? [])
  ],
  recommended_actions: recommendedActionCodes,
  evidence_required: evidencePayload.evidenceCodes,
  evidence_requirements: evidencePayload.requirements,
  missing_evidence_codes: evidencePayload.missingEvidenceCodes
};

const title =
  yamlOutput.title ||
  tensionDefinition.name ||
  tensionDefinition.tensionCode;

const description = [
  diagnosis,
  tensionDefinition.executiveDiagnosis
    ? `Lectura ejecutiva:\n${tensionDefinition.executiveDiagnosis}`
    : null
].filter(Boolean).join("\n\n");

// 10. Crear o actualizar tension (respeta idempotencia)
if (context.dryRun) {
  tensionId = "dry-run";
} else if (existing) {
  tensionId = existing.tension_id;
  await updateExistingTension(client, {
    tensionId,
    ruleEvaluationId,
    severity: result.severity ?? tensionDefinition.defaultSeverity,
    priorityScore: result.priorityScore ?? 0,
    confidenceScore: result.confidenceScore,
    description,
    scoreImpact: result.scoreImpact,
    payload: tensionPayload
  });
  summary.tensionsUpdated++;
} else {
  tensionId = await createTension(client, {
    companyId: context.companyId,
    ruleEvaluationId,
    tensionCode: tensionDefinition.tensionCode,
    title,
    description,
    severity: result.severity ?? tensionDefinition.defaultSeverity,
    priorityScore: result.priorityScore ?? 0,
    confidenceScore: result.confidenceScore,
    responsibleUserId,
    dueAt: null,
    scoreImpact: result.scoreImpact,
    payload: tensionPayload
  });
  summary.tensionsCreated++;
}

// 11. Crear acciones desde catalogo (action_definitions, no hardcoded)
const shouldCreateActions =
  context.createActions ?? evaluatorConfig.defaultCreateActions;

if (shouldCreateActions && !context.dryRun) {
  const actionsResult = await createRecommendedActions({
    client,
    companyId: context.companyId,
    tensionId,
    tensionCode: tensionDefinition.tensionCode,
    actionCodes: recommendedActionCodes,
    responsibleUserId,
    approverUserId,
    fallbackPriority: mapSeverityToPriority(result.severity),
    fallbackSlaDays: yamlOutput.default_sla_days,
    createdBy: context.userId,
    evidenceRequiredCodesFromRule: evidencePayload.evidenceCodes
  });

  summary.actionsCreated += actionsResult.createdActionIds.length;

  if (actionsResult.missingActionCodes.length > 0) {
    summary.warnings += actionsResult.missingActionCodes.length;
    console.warn(
      `Missing action definitions for ${tensionDefinition.tensionCode}: ${actionsResult.missingActionCodes.join(", ")}`
    );
  }
}
▸ PATCH FUERTE · src/services/actionService.ts (elimina ACTION_CATALOG)
// ANTES (ENG-003 base): objeto hardcodeado dentro del archivo
// const ACTION_CATALOG = { "ACT-COM-001": { title, description, ... } };
// DESPUES (ENG-003.1 aplicado): consulta action_definitions, sin hardcoded.

import { getActiveActionDefinitionsByCodes } from "../db/actionCatalogRepository.js";
import { buildEvidenceRequirementsPayload } from "./evidenceRequirementService.js";

export async function createRecommendedActions(params: {
  client: pg.PoolClient;
  companyId: string;
  tensionId: string;
  tensionCode: string;
  actionCodes: string[];
  responsibleUserId: string | null;
  approverUserId: string | null;
  fallbackPriority: string;
  fallbackSlaDays: number;
  createdBy: string | null;
  evidenceRequiredCodesFromRule: string[];
}): Promise<{
  createdActionIds: string[];
  skippedActionCodes: string[];
  missingActionCodes: string[];
}> {
  const createdActionIds: string[] = [];
  const skippedActionCodes: string[] = [];
  const missingActionCodes: string[] = [];

  const uniqueActionCodes = Array.from(new Set(params.actionCodes));
  const definitions = await getActiveActionDefinitionsByCodes(params.client, uniqueActionCodes);

  for (const actionCode of uniqueActionCodes) {
    const definition = definitions.get(actionCode);

    if (!definition) {
      missingActionCodes.push(actionCode);
      continue; // warning, no error (la accion no esta en catalogo)
    }

    const existing = await findExistingActionForTension(params.client, {
      companyId: params.companyId,
      tensionId: params.tensionId,
      actionCode
    });
    if (existing) {
      skippedActionCodes.push(actionCode);
      continue; // idempotencia: ya existe abierta
    }

    // Responsable: explicito > catalogo > fallback
    const responsibleUserId =
      params.responsibleUserId ??
      (await resolveResponsibleUser(params.client, {
        companyId: params.companyId,
        logicalRole: definition.defaultOwnerRole
      }));

    const approverUserId =
      params.approverUserId ??
      (definition.approverRole
        ? await resolveResponsibleUser(params.client, {
            companyId: params.companyId,
            logicalRole: definition.approverRole
          })
        : null);

    // Evidencia: catalogo de la accion > evidencia heredada de la regla
    const evidenceCodes =
      definition.evidenceRequiredCodes.length > 0
        ? definition.evidenceRequiredCodes
        : params.evidenceRequiredCodesFromRule;

    const evidencePayload = await buildEvidenceRequirementsPayload(
      params.client, evidenceCodes
    );

    if (evidencePayload.missingEvidenceCodes.length > 0) {
      throw new Error(
        `UNKNOWN_EVIDENCE_CODE for action ${actionCode}: ${evidencePayload.missingEvidenceCodes.join(", ")}`
      );
    }

    const dueDate = addDays(new Date(), definition.defaultSlaDays || params.fallbackSlaDays);

    const expectedRecovery =
      definition.expectedScoreRecoveryMax > 0
        ? definition.expectedScoreRecoveryMax
        : null;

    const actionId = await createAction(params.client, {
      companyId: params.companyId,
      tensionId: params.tensionId,
      actionCode: definition.actionCode,
      title: definition.name,
      description: definition.description,
      actionType: definition.actionType,
      responsibleUserId,
      approverUserId,
      priority: definition.defaultPriority || params.fallbackPriority,
      dueDate,
      evidenceRequired: definition.evidenceRequired,
      closureCriteria: definition.closureCriteria,
      createdBy: params.createdBy,
      expectedImpact: definition.expectedBusinessImpact,
      expectedImpactAmount: expectedRecovery,
      payload: {
        created_by_engine: true,
        catalog_source: "faro.action_definitions",
        action_definition_id: definition.actionDefinitionId,
        action_family: definition.actionFamily,
        executive_purpose: definition.executivePurpose,
        success_metric: definition.successMetric,
        score_dimension: definition.scoreDimension,
        expected_score_recovery_min: definition.expectedScoreRecoveryMin,
        expected_score_recovery_max: definition.expectedScoreRecoveryMax,
        triggered_by_tension_code: params.tensionCode,
        evidence_required_codes: evidencePayload.evidenceCodes,
        evidence_requirements: evidencePayload.requirements
      }
    });

    createdActionIds.push(actionId);
  }

  return { createdActionIds, skippedActionCodes, missingActionCodes };
}

Reglas de fallback (politica del patch). Si falta tension_definition → el motor falla la regla y NO crea tension (UNKNOWN_TENSION_CODE bloquea ejecucion). Si falta action_definition → el motor crea la tension pero NO esa accion (warning UNKNOWN_ACTION_CODE). Si falta evidence_definition → el motor bloquea la creacion de la accion (acepta tension, rechaza accion). Si falta responsable → jerarquia: rol YAML → rol action_definition → rol tension_definition → general_manager → director. Si falta criterio de cierre → no crear la accion (accion sin closure es decoracion operativa).

11 · explainCommand patch

CLI explain ahora muestra catalogo canonico, no solo regla

El comando explain sirve para entender que va a hacer el motor antes de correrlo (debugging operativo). El patch 003.1 lo enriquece: ya no muestra solo RULE-TNS-001 · KPIs..., ahora resuelve el catalogo y muestra nombre canonico, dimension Score, owner, acciones y evidencias enriquecidas.

Antes (ENG-003 base).

RULE-TNS-001 · KPIs: KPI-SAL-001, KPI-SAL-002, KPI-SAL-003

Despues (ENG-003.1 aplicado).

RULE-TNS-001 → TNS-001 · Crecimiento no rentable
  KPIs: KPI-SAL-001, KPI-SAL-002, KPI-SAL-003
  Score dimension: commercial_health
  Owner role: commercial_manager
  Actions: ACT-COM-001, ACT-COM-002, ACT-COM-003
  Evidence: EVD-007, EVD-012

▸ PATCH · src/commands/explainCommand.ts
import { withFaroDbContext } from "../db/db.js";
import { getActiveRulesForCompany } from "../db/ruleRepository.js";
import { getActiveTensionDefinition } from "../db/tensionCatalogRepository.js";

export async function explainCommand(params: {
  companyId: string;
  periodStart: string;
  periodEnd: string;
}) {
  await withFaroDbContext(
    {
      companyId: params.companyId,
      userId: null,
      roleCodes: ["integration_service", "faro_owner", "company_admin"]
    },
    async (client) => {
      const rules = await getActiveRulesForCompany(client, params.companyId);

      console.log("FARO Evaluation Context");
      console.log("-----------------------");
      console.log(`Company: ${params.companyId}`);
      console.log(`Period:  ${params.periodStart} → ${params.periodEnd}`);
      console.log(`Active rules: ${rules.length}`);
      console.log("");

      for (const rule of rules) {
        // NEW: resolver definicion canonica de la tension
        const definition = await getActiveTensionDefinition(
          client,
          rule.ruleBody.tension_code
        );

        console.log(
          `${rule.ruleCode} → ${rule.ruleBody.tension_code} · ${definition?.name ?? rule.name}`
        );
        console.log(`  KPIs: ${rule.ruleBody.data_requirements.required_kpis.join(", ")}`);
        console.log(`  Score dimension: ${definition?.scoreDimension ?? "unknown"}`);
        console.log(
          `  Owner role: ${definition?.defaultOwnerRole ?? rule.ruleBody.output.assign_to_role}`
        );
        console.log(
          `  Actions: ${(definition?.recommendedActions ?? rule.ruleBody.output.recommended_actions).join(", ")}`
        );
        console.log(
          `  Evidence: ${(definition?.evidenceRequired ?? rule.ruleBody.output.evidence_required).join(", ")}`
        );
        console.log("");
      }
    }
  );
}
▸ comandos esperados desde CLI
# Dry-run (no escribe, solo evalua)
tsx src/cli.ts dry-run \
  --company-id 10000000-0000-0000-0000-000000000001 \
  --period-start 2026-05-01 \
  --period-end 2026-05-31 \
  --user-id 12000000-0000-0000-0000-000000000001 \
  --roles integration_service,faro_owner,company_admin

# Evaluacion real (crea tensions + actions enriquecidas)
tsx src/cli.ts evaluate \
  --company-id 10000000-0000-0000-0000-000000000001 \
  --period-start 2026-05-01 \
  --period-end 2026-05-31

# Explain con catalogo canonico (patch 003.1)
tsx src/cli.ts explain \
  --company-id 10000000-0000-0000-0000-000000000001 \
  --period-start 2026-05-01 \
  --period-end 2026-05-31
12 · 5 validaciones SQL del patch

Checks de coherencia entre motor, reglas y catalogos

Estas cinco consultas se corren despues de cada deploy del motor para verificar que el patch 003.1 quedo bien aplicado. Cada una debe devolver 0 filas (excepto la 5 que se aplica solo a acciones creadas por el motor nuevo). Si alguna devuelve filas, hay un drift entre catalogo y motor que rompe el gobierno.

12.1 · Tensiones Esperado: 0 filas

Reglas apuntan a tensiones existentes en catalogo

Toda regla activa debe apuntar a un tension_code que exista en tension_definitions activas. Si una regla apunta a un codigo fantasma, el motor falla al ejecutarla; este check lo detecta antes.

▸ SQL
SELECT
  rd.rule_code,
  rd.rule_body->>'tension_code' AS tension_code
FROM faro.rule_definitions rd
LEFT JOIN faro.tension_definitions td
  ON td.tension_code = rd.rule_body->>'tension_code'
 AND td.status = 'active'
WHERE rd.status = 'active'
  AND td.tension_code IS NULL;
12.2 · Acciones Esperado: 0 filas

Acciones recomendadas existen en catalogo de acciones

Toda recommended_action de cada regla debe existir en action_definitions. Lateral JOIN explota el array JSONB y valida una a una.

▸ SQL
SELECT
  rd.rule_code,
  action_code
FROM faro.rule_definitions rd
CROSS JOIN LATERAL jsonb_array_elements_text(
  rd.rule_body->'output'->'recommended_actions'
) AS action_code
LEFT JOIN faro.action_definitions ad
  ON ad.action_code = action_code
 AND ad.status = 'active'
WHERE rd.status = 'active'
  AND ad.action_code IS NULL;
12.3 · Evidencias Esperado: 0 filas

Evidencias requeridas existen en catalogo de evidencias

Toda evidence_required de cada regla debe existir en evidence_definitions. Sin este check, el motor falla en runtime al construir el payload de evidencia.

▸ SQL
SELECT
  rd.rule_code,
  evidence_code
FROM faro.rule_definitions rd
CROSS JOIN LATERAL jsonb_array_elements_text(
  rd.rule_body->'output'->'evidence_required'
) AS evidence_code
LEFT JOIN faro.evidence_definitions ed
  ON ed.evidence_code = evidence_code
 AND ed.status = 'active'
WHERE rd.status = 'active'
  AND ed.evidence_code IS NULL;
12.4 · Trazabilidad Esperado: 0 filas

Acciones creadas tienen definicion canonica

Toda accion en faro.actions debe resolver contra action_definitions. Si aparece una accion con action_code huerfano, alguien la creo fuera del motor o el catalogo retrocedio (regresion).

▸ SQL
SELECT DISTINCT
  a.action_code
FROM faro.actions a
LEFT JOIN faro.action_definitions ad
  ON ad.action_code = a.action_code
 AND ad.status = 'active'
WHERE ad.action_code IS NULL;
12.5 · Payload enriquecido Esperado: 0 filas para motor nuevo

Acciones del motor nuevo tienen evidence_requirements enriquecidas

Toda accion creada por el patch 003.1 lleva en payload.evidence_requirements el array proyectado desde evidence_definitions. Si una accion abierta no lo tiene, fue creada antes del patch o el motor regreso a logica vieja.

▸ SQL
SELECT
  action_code,
  title
FROM faro.actions
WHERE payload->'evidence_requirements' IS NULL
  AND payload->>'created_by_engine' = 'true'
  AND status NOT IN ('closed', 'cancelled', 'rejected');
13 · Tests, payload esperado e impacto Score

Que prueba el motor, que devuelve y como suma al Score

Tres dimensiones de verificacion del motor: tests unitarios (engine puro), tests de integracion (motor + catalogos + RLS) y forma del payload que produce. Cierra con el calculo de impacto Score canonico (input de FARO-SCORE).

Unit · engine puro

ConditionEvaluator: all/any/none

Verifica que el evaluador devuelve passed=true cuando se cumplen condiciones del grupo. Snapshot inline, sin DB.

expect(passed).toBe(true)
Unit · missing data policy

No dispara si falta KPI y policy=do_not_trigger

Si el KPI requerido no esta en snapshots y la policy es estricta, triggered debe ser false y missingKpis debe listar el faltante.

expect(triggered).toBe(false)
Integration · catalogos

getActiveTensionDefinition carga TNS-001

Lee canonica desde tension_definitions: nombre, acciones recomendadas, evidencia requerida coinciden con seed.

name === "Crecimiento no rentable"
Integration · catalogos

getActiveActionDefinition carga ACT-COM-001

Verifica que la accion canonica tiene closureCriteria y referencias a evidencias requeridas.

evidenceRequiredCodes.includes("EVD-007")
Integration · demo

runEvaluation con Empresa Demo

Corrida real sobre periodo demo. Espera rulesEvaluated > 0, errors === 0, y que cada accion creada tenga evidence_requirements en payload.

errors === 0 && actions.length > 0
Integration · idempotencia

Doble corrida no duplica tensiones abiertas

Corre evaluate dos veces seguidas. Primera: tensiones +N. Segunda: 0 tensiones nuevas, actualiza severity/priority en las abiertas.

tensionsCreated_run2 === 0
▸ tests/evaluator.test.ts · ConditionEvaluator
import { describe, expect, it } from "vitest";
import { evaluateConditionGroup } from "../src/engine/conditionEvaluator.js";
import { evaluateRule } from "../src/engine/evaluator.js";

describe("FARO evaluator", () => {
  it("evaluates all conditions as true", () => {
    const result = evaluateConditionGroup(
      { all: [{ kpi: "KPI-SAL-001", metric: "delta_pct", operator: ">=", value: 0.1 }] },
      {
        "KPI-SAL-001": {
          kpiSnapshotId: "1", companyId: "company", kpiCode: "KPI-SAL-001",
          periodStart: "2026-05-01", periodEnd: "2026-05-31",
          dimensionType: "company", dimensionId: null,
          value: 100, referenceValue: 80, deltaValue: 20, deltaPct: 0.25,
          status: "ok", confidenceScore: 90,
          sourceSnapshot: {}, calculatedAt: new Date().toISOString()
        }
      }
    );
    expect(result.passed).toBe(true);
  });
});
▸ tests/catalog_integration.test.ts (patch 003.1)
describe("Catalog integration", () => {
  it("loads canonical tension definition", async () => {
    await withTestDbContext({ companyId: COMPANY_ID }, async (client) => {
      const def = await getActiveTensionDefinition(client, "TNS-001");
      expect(def).toBeTruthy();
      expect(def?.name).toBe("Crecimiento no rentable");
      expect(def?.recommendedActions).toContain("ACT-COM-001");
      expect(def?.evidenceRequired).toContain("EVD-007");
    });
  });

  it("creates actions enriched with evidence requirements", async () => {
    await withTestDbContext({ companyId: COMPANY_ID, userId: USER_ID }, async (client) => {
      const summary = await runEvaluation(client, {
        companyId: COMPANY_ID, userId: USER_ID,
        roleCodes: ["integration_service", "faro_owner", "company_admin"],
        periodStart: "2026-05-01", periodEnd: "2026-05-31",
        dryRun: false, createActions: true
      });
      expect(summary.rulesEvaluated).toBeGreaterThan(0);
      expect(summary.errors).toBe(0);

      const result = await client.query(`
        SELECT a.action_code, a.payload->'evidence_requirements' AS er
        FROM faro.actions a
        WHERE a.company_id = $1
          AND a.payload->>'created_by_engine' = 'true'
      `, [COMPANY_ID]);

      expect(result.rows.length).toBeGreaterThan(0);
      for (const row of result.rows) {
        expect(row.er).toBeTruthy();
        expect(row.er.length).toBeGreaterThan(0);
      }
    });
  });
});
▸ Payload esperado de tension creada (post-patch)
{
  "canonical": true,
  "catalog_source": "faro.tension_definitions",
  "tension_definition_id": "...",
  "tension_code": "TNS-001",
  "tension_name": "Crecimiento no rentable",
  "business_question": "¿Estamos vendiendo mas pero ganando menos?",
  "executive_diagnosis": "La empresa crece en volumen pero sacrifica rentabilidad.",
  "trigger_logic": "Ventas netas suben + margen bruto cae + descuento promedio sube.",
  "score_dimension": "commercial_health",
  "rule_code": "RULE-TNS-001",
  "rule_version": 1,
  "period_start": "2026-05-01",
  "period_end": "2026-05-31",
  "recommended_actions": ["ACT-COM-001", "ACT-COM-002", "ACT-COM-003"],
  "evidence_required": ["EVD-007", "EVD-012"],
  "evidence_requirements": [
    {
      "evidence_code": "EVD-007",
      "name": "Cambio de politica",
      "evidence_type": "policy_change",
      "trust_level": "critical",
      "requires_review": true,
      "can_close_action": true,
      "confidence_weight": 0.95
    },
    {
      "evidence_code": "EVD-012",
      "name": "Validacion de direccion",
      "evidence_type": "executive_validation",
      "trust_level": "critical",
      "requires_review": true,
      "can_close_action": true,
      "confidence_weight": 1.0
    }
  ]
}
▸ Payload esperado de accion creada (post-patch)
{
  "created_by_engine": true,
  "catalog_source": "faro.action_definitions",
  "action_definition_id": "...",
  "action_family": "commercial",
  "executive_purpose": "Recuperar margen y evitar crecimiento comprado con descuentos excesivos.",
  "success_metric": "Reduccion del descuento promedio y recuperacion de margen bruto.",
  "score_dimension": "commercial_health",
  "expected_score_recovery_min": 3,
  "expected_score_recovery_max": 8,
  "triggered_by_tension_code": "TNS-001",
  "evidence_required_codes": ["EVD-007", "EVD-012"],
  "evidence_requirements": [/* mismo formato canonico */]
}

Como suma al FARO Score (input para FARO-SCORE)

Con catalogos canonicos, el calculo de Score puede distinguir estados que antes eran un solo bucket. Cada tension activa penaliza, cada accion recupera segun evidencia validada:

  1. Tension activa → penaliza por score_impact (negativo, calculado en motor).
  2. Accion asignada → no recupera todavia (cuenta como compromiso, no como resultado).
  3. Accion en ejecucion → recupera parcialmente segun progreso registrado.
  4. Accion cerrada sin evidencia → no recupera (politica anti-decoracion).
  5. Accion cerrada con evidencia trust_level=medium → recupera parcialmente (confidence_weight < 1).
  6. Accion cerrada con evidencia trust_level=critical → recupera dentro del rango expected_score_recovery_min..max.

El motor evaluador NO calcula el Score (es responsabilidad de FARO-SCORE), pero deja en cada accion los inputs canonicos: expected_score_recovery_min/max, evidence_requirements[], trust_level, confidence_weight. Sin esos campos guardados, el Score no puede distinguir compromiso de resultado.

14 · Changelog ENG-003 → ENG-003.1

Que cambio, por que, y que quedo removido del hardcoded

El patch 003.1 es de tipo "eleva al nivel producto", no de bug fix. La base 003 servia para arrancar; la 003.1 deja al motor listo para gobierno multiempresa con catalogos versionados. Esta seccion es el log oficial de cambios entre versiones.

v1.0 ENG-003 (base) → v1.1 ENG-003.1 (patch aplicado) 17 cambios · 6 nuevos archivos · 7 patch · 0 hardcoded restantes
REMOVED

ACTION_CATALOG hardcoded en actionService.ts. El objeto que tenia { "ACT-COM-001": { title, description, closureCriteria } } con 8 entradas hardcoded queda eliminado. Cada accion ahora se consulta a faro.action_definitions y se enriquece en runtime. Cambiar una accion ya no requiere modificar codigo.

NEW

3 repositorios de catalogos canonicos. tensionCatalogRepository.ts, actionCatalogRepository.ts, evidenceCatalogRepository.ts. Cada uno expone get + getByCodes + assertCodesExist. Single source of truth para validacion y enriquecimiento.

NEW

2 servicios de catalogos. catalogValidationService.ts (assertTension/Action/EvidenceCodesExist combinado) y evidenceRequirementService.ts (build payload enriquecido para tensiones y acciones).

NEW

Tipos canonicos en catalog.types.ts. TensionDefinition (26 campos), ActionDefinition (24 campos), EvidenceDefinition (28 campos), EvidenceRequirementPayload (10 campos proyeccion). Reemplazan los stubs any que tenia la version base.

PATCH

Bloque central de runEvaluation (evaluationService.ts). La parte despues de if (result.triggered) se reescribio para: validar contra catalogos → cargar TensionDefinition → mergear acciones YAML+catalogo → mergear evidencia YAML+catalogo → crear tension/accion con payload canonico. Es el cambio mas grande del patch (codigo completo en seccion 10).

PATCH

createRecommendedActions en actionService.ts ahora recibe el contexto canonico (tensionCode, fallbackPriority, fallbackSlaDays, evidenceRequiredCodesFromRule) y devuelve { createdActionIds, skippedActionCodes, missingActionCodes }. Antes devolvia solo string[].

PATCH

createAction en actionRepository.ts agrega 3 columnas en INSERT: action_type, expected_impact, expected_impact_amount. Permiten al Score distinguir progreso esperado vs realizado por accion.

PATCH

explainCommand.ts ahora resuelve TensionDefinition y muestra nombre canonico, dimension Score, owner role y arrays canonicos de acciones/evidencias. Es el CLI de debug operativo enriquecido.

PATCH

RuleEvaluationResult en evaluation.types.ts suma dos campos opcionales: catalogWarnings?: string[] y catalogStatus?: "validated" | "missing" | "not_checked". Permiten trazar estado de validacion de catalogos por evaluacion.

NEW

Tests de integracion catalog_integration.test.ts que verifican: 1) carga de definiciones canonicas, 2) runEvaluation real con catalogos, 3) acciones creadas tienen evidence_requirements en payload.

NEW

Politica de fallback documentada. Si falta tension_definition: bloquea regla con UNKNOWN_TENSION_CODE. Si falta action_definition: warning, no crea esa accion. Si falta evidence_definition: bloquea creacion de accion. Si falta responsable: cadena 5 niveles (rol YAML → action_def → tension_def → general_manager → director).

NEW

5 validaciones SQL post-deploy (seccion 12) que verifican coherencia motor ↔ reglas ↔ catalogos. Se corren despues de cada deploy del motor para detectar drift.

KEEP

ConditionEvaluator (engine puro) queda intacto. Logica de all/any/none + 13 operadores no necesita cambios; el patch trabaja al nivel de servicio, no de engine.

KEEP

Idempotencia y RLS quedan igual. La clave de deduplicacion (company_id + tension_code + periodo) y el contexto app.company_id seguian funcionando bien.

KEEP

SeverityCalculator, PriorityCalculator, ConfidenceValidator, ScoreImpactService quedan sin cambios. Operan sobre RuleDefinition.ruleBody; no necesitan saber del catalogo.

KEEP

CLI evaluate y dry-run mantienen flags. Solo cambia que ahora pueden fallar con UNKNOWN_TENSION_CODE si una regla apunta fuera de catalogo (antes silenciaba el problema).

REMOVED

Catalogo de evidencia inline en payload de accion. En la base, evidencia era un array suelto en payload (evidence_required_codes: ["EVD-007"]). En el patch, ademas del array se incluye evidence_requirements con la proyeccion canonica completa de evidence_definitions.

Por que se hizo la consolidacion en una sola pieza. Originalmente eran dos documentos separados (ENG-003 base + ENG-003.1 patch). Mantenerlos divididos forzaba a leer dos archivos en orden para entender el motor real. La consolidacion hace que esta pieza sea la fuente de verdad: cualquier referencia futura a "motor evaluador FARO MVP" apunta aca. El patch queda como changelog visible (esta seccion 14), no como spec separada.

  1. FARO-TEST-002.1 · Tests de integracion con catalogos canonicos. Agregar test que valide rule_definitions.rule_body.tension_code contra tension_definitions activas. Bloquear cualquier codigo fuera de catalogo en CI.
  2. FARO-UI-001 · Bandeja de Tensiones. La UI debe leer tension_name, business_question, executive_diagnosis y score_dimension desde payload de la tension (ya estan enriquecidos por el motor). Cero strings hardcodeados en frontend.
  3. FARO-TPL-001 · Templates email alertas. Render de notificacion usa tension_name + recommended_actions (con nombres canonicos resueltos via action_definitions) + evidence_required.
  4. FARO-SCORE · Modelo de calculo. Consume expected_score_recovery_min/max, trust_level, confidence_weight que el motor guarda en cada accion. Permite distinguir compromiso de resultado.
  5. Gap KPI catalog (D6 marcado). Falta el catalogo canonico de KPIs (faro.kpi_definitions) paralelo a los tres existentes. Hoy las reglas referencian KPI-* sin validacion contra catalogo. Queda en backlog inmediato post-MVP.
+ · Cross-references

Donde se cruza el motor con el resto del pack

El motor evaluador no vive solo. Estos son los puntos del pack donde produce, consume o se valida contra otras piezas. Las PENDIENTE son piezas en construccion.