Deterministico
Misma regla con mismos datos debe dar el mismo resultado. Sin aleatoriedad, sin IA decisora, sin temporalidad oculta.
FARO-ENG-003 (base) + FARO-ENG-003.1 (patch catalogos canonicos) ya aplicados. Motor TypeScript que evalua reglas FARO contra kpi_snapshots, consulta tres catalogos canonicos en PostgreSQL y produce tensions, actions, evidence requirements y payload de impacto Score.
El motor evaluador es el corazon operativo del MVP. Toma kpi_snapshots + rule_definitions + contexto de empresa/periodo y produce una cadena auditable: rule_evaluations → tensions → actions → 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).
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.
Misma regla con mismos datos debe dar el mismo resultado. Sin aleatoriedad, sin IA decisora, sin temporalidad oculta.
Toda evaluacion queda registrada en rule_evaluations con input_payload y output_payload completos. Trazabilidad total.
Nunca mezcla empresas. Setea app.company_id en cada transaccion. RLS de PostgreSQL bloquea cualquier fuga.
Respeta RLS y contexto company_id. Roles minimos: integration_service, faro_owner, company_admin.
Correr dos veces no duplica tensiones abiertas. Clave: company_id + tension_code + periodo + estado_abierto.
Registra por que disparo o no disparo. ConditionDiagnostic por cada condicion: actual, expected, operator, passed.
Lee reglas desde faro.rule_definitions. Enriquece tensiones/acciones/evidencias desde los tres catalogos canonicos.
Nuevas reglas sin cambiar codigo. Nuevas acciones sin tocar codigo. Nueva evidencia desde catalogo.
Si falta dato o baja confianza, respeta missing_data_policy. Por defecto: no inventar, no disparar.
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.
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.
faro.rule_definitions con jerarquia company > global.faro.kpi_snapshots para el periodo solicitado.all, any, none con 13 operadores.severity + confidence + scoreImpact normalizado 0-100.UNKNOWN_TENSION_CODE.tension_definitions.action_definitions (no desde hardcoded).evidence_definitions.audit.audit_log.version DESC LIMIT 1 simple.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.
company_id, periodo, modo (normal/dry-run), opciones de creacion.set_config('app.company_id', ...) + app.user_id + app.role_codes. BEGIN transaction.faro.rule_definitions filtradas por estado y empresa (orden: company > global, version DESC).required_kpis de cada regla en el periodo y dimension.missing_data_policy (do_not_trigger / warn / continue).ConfidenceValidator: promedio de confidence_score vs minimum_confidence_score.all/any/none via ConditionEvaluator y producir ConditionDiagnostic[].SeverityCalculator: default + escalation rules declarativas.PriorityCalculator: severity base + confidence adj + impact adj.faro.rule_evaluations con input_payload + output_payload completos.assertTensionCodeExists + assertActionCodesExist + assertEvidenceCodesExist. Si falla → error UNKNOWN_*_CODE.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).recommended_actions YAML + catalogo. Para cada ACT-* consultar action_definitions, resolver responsable, calcular due_date desde defaultSlaDays, adjuntar evidence_requirements enriquecidas.evidence_type, trust_level, requires_review, can_close_action, confidence_weight).tensions.score_impact.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.
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.
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.
{
"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"
}
}
{
"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"]
}
# 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
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(); } }
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.
getActiveRulesForCompany lee faro.rule_definitions con jerarquia company > global, version DESC, deduplicacion por rule_code.
getKpiSnapshotsForRule trae kpi_snapshots filtrados por periodo, dimension y array de KPI codes. Devuelve KpiSnapshotMap.
insertRuleEvaluation guarda rule_evaluations con input_payload + output_payload completos como JSONB.
findOpenTension + createTension + updateExistingTension. Patch suma payload canonico al INSERT/UPDATE.
findExistingActionForTension + createAction. Patch agrega action_type, expected_impact y expected_impact_amount.
Inserta payload de notificacion (critical_tension, critical_action). MVP: no envia; produce row para que TPL-001 entregue.
insertAuditLog graba audit.audit_log con company_id, actor, entity, action, metadata JSONB.
Consulta faro.tension_definitions. Expone getActiveTensionDefinition, getActiveTensionDefinitionsByCodes y assertTensionCodeExists (lanza UNKNOWN_TENSION_CODE).
Consulta faro.action_definitions. Expone getActiveActionDefinition, getActiveActionDefinitionsByCodes y assertActionCodesExist.
Consulta faro.evidence_definitions. Expone getActiveEvidenceDefinitionsByCodes, assertEvidenceCodesExist y toEvidenceRequirementPayload (proyeccion enriquecida para payload de tensiones/acciones).
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()); }
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; }
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` ); } }
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(", ")}`); } }
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 }; }
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.
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[]; };
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 }; }
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.
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; }
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)))); }
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 }; }
// 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(":"); }
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.
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.
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 }; }
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 }; }
// 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(", ")}` ); } }
// 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).
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
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(""); } } ); }
# 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
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.
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.
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;
Toda recommended_action de cada regla debe existir en action_definitions. Lateral JOIN explota el array JSONB y valida una a una.
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;
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.
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;
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).
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;
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.
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');
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).
Verifica que el evaluador devuelve passed=true cuando se cumplen condiciones del grupo. Snapshot inline, sin DB.
expect(passed).toBe(true)Si el KPI requerido no esta en snapshots y la policy es estricta, triggered debe ser false y missingKpis debe listar el faltante.
Lee canonica desde tension_definitions: nombre, acciones recomendadas, evidencia requerida coinciden con seed.
Verifica que la accion canonica tiene closureCriteria y referencias a evidencias requeridas.
Corrida real sobre periodo demo. Espera rulesEvaluated > 0, errors === 0, y que cada accion creada tenga evidence_requirements en payload.
Corre evaluate dos veces seguidas. Primera: tensiones +N. Segunda: 0 tensiones nuevas, actualiza severity/priority en las abiertas.
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); }); });
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); } }); }); });
{
"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
}
]
}
{
"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 */]
}
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:
score_impact (negativo, calculado en motor).trust_level=medium → recupera parcialmente (confidence_weight < 1).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.
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.
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.
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.
2 servicios de catalogos. catalogValidationService.ts (assertTension/Action/EvidenceCodesExist combinado) y evidenceRequirementService.ts (build payload enriquecido para tensiones y acciones).
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.
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).
createRecommendedActions en actionService.ts ahora recibe el contexto canonico (tensionCode, fallbackPriority, fallbackSlaDays, evidenceRequiredCodesFromRule) y devuelve { createdActionIds, skippedActionCodes, missingActionCodes }. Antes devolvia solo string[].
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.
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.
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.
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.
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).
5 validaciones SQL post-deploy (seccion 12) que verifican coherencia motor ↔ reglas ↔ catalogos. Se corren despues de cada deploy del motor para detectar drift.
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.
Idempotencia y RLS quedan igual. La clave de deduplicacion (company_id + tension_code + periodo) y el contexto app.company_id seguian funcionando bien.
SeverityCalculator, PriorityCalculator, ConfidenceValidator, ScoreImpactService quedan sin cambios. Operan sobre RuleDefinition.ruleBody; no necesitan saber del catalogo.
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).
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.
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.
FARO-ENG-002 · DSL Parser Reglas YAML. Importa YAML y produce las filas en rule_definitions que el motor consume.
30 reglas YAML del MVP (RULE-TNS-001..030). Fuente operativa de lo que el motor evalua periodicamente.
30 tensiones canonicas TNS-001..030. El motor valida cada tension_code contra esta tabla via assertTensionCodeExists.
Catalogo canonico de acciones ACT-*. El motor lee SLA, owner role, evidence required y closure criteria desde aca.
Catalogo canonico de evidencias EVD-*. El motor proyecta cada evidencia con trust_level, requires_review y can_close_action.
Modelo RLS multiempresa. El motor setea app.company_id + app.user_id + app.role_codes en cada transaccion.
DDL completo del sistema FARO Connect. Incluye rule_definitions, rule_evaluations, tensions, actions, y las tres *_definitions.
Tests de integracion del motor con los tres catalogos canonicos. Espec completo de fixtures y casos de borde.
Esta spec consolida ENG-003 + ENG-003.1 en un solo documento ejecutable. El proximo paso es FARO-TEST-002.1 (tests de integracion con catalogos) y luego FARO-UI-001 (bandeja de tensiones leyendo payload canonico). Volve al hub para ver el resto del pack o seguir con la pieza de reglas YAML.
→ Volver al hub modelos NDA