01 · Resumen ejecutivo

Un MVP que no se puede operar no es MVP, es maqueta con ambición

FARO-OPS-001 define cómo FARO Connect se mantiene funcionando de forma controlada, monitoreada y recuperable. Cubre jobs programados, colas, workers, reintentos, idempotencia, errores, dead letter queue, logs estructurados, métricas, traces, health checks, backups, migraciones, alertas técnicas, runbooks e incidentes.

FARO Connect tiene muchos procesos asincrónicos corriendo en paralelo por empresa: importar datos, normalizar, validar calidad, calcular KPIs, detectar tensiones, crear acciones, evaluar workflow, recalcular Score, enviar notificaciones, generar reportes, limpiar cache IA, aplicar retención y monitorear integraciones. Si esos procesos no están gobernados, el sistema se vuelve inestable.

El problema no es que un job falle. El problema es que falle y nadie se entere. O peor: falla, se reintenta mal, duplica acciones, manda alertas repetidas, cambia Score dos veces y después todos miran al programador como si fuera un chamán. FARO-OPS-001 evita ese circo con un set acotado de primitivas operativas que se aplican a todos los jobs por igual.

El alcance MVP incluye: 13 jobs canónicos (códigos JOB-XXX-NNN), schema ops con 8 tablas (jobs, job_runs, job_locks, job_schedules, error_events, health_snapshots, dead_letter_queue, alerts), worker idempotente en TypeScript, scheduler básico con cron por job, error taxonomy de 13 categorías con política de backoff exponencial, health checks (simple + deep), observabilidad mínima (logs estructurados + métricas Prometheus + traces con request_id), 5 runbooks operativos expandibles, backups verificables con restore drills, incident management SEV-1 a SEV-4 con postmortems y feature flags por release.

Lo que queda fuera del MVP: Kubernetes complejo, auto-scaling avanzado, multi-región, DRP completo con RTO/RPO contractual, SIEM corporativo, SRE dedicado, chaos engineering, blue/green avanzado y service mesh. Esto se aborda en releases posteriores cuando el volumen lo justifique.

Tenant ejemplo en todas las queries del documento: Empresa Demo Cuyo S.A.. Timezone por defecto: America/Argentina/Mendoza. Toda timestamp en la base se guarda en timestamptz y se presenta en la zona de la empresa.

02 · Principios + arquitectura general

7 principios rectores y la pila operativa MVP

Todo proceso automático debe ser observable, reintentable, idempotente y auditable. Las 7 reglas que siguen no son adornos: son lo que separa un sistema operable de un sistema que solo funciona en el demo.

Principio 01

Observable

Se sabe si corrió, cuánto tardó y si falló. Todo job genera un job_run con métricas básicas y traza estructurada.

Principio 02

Reintentable

Puede volver a ejecutarse sin intervención manual destructiva. Backoff exponencial con tope. Si agota, va a DLQ.

Principio 03

Idempotente

Ejecutarlo dos veces no duplica efectos. Todo job que escriba datos tiene dedupe_key o constraint equivalente.

Principio 04

Runbooks

Cada falla esperada tiene un procedimiento documentado. La improvisación también es una metodología, solo que mala.

Principio 05

Rollback

Migraciones reversibles cuando es posible. Feature flags para apagar funcionalidad rota sin redeploy.

Principio 06

Auditable

Deja registro técnico y funcional. Error events, audit log y health snapshots quedan en la base 365 días.

Principio 07

Seguro por defecto

Respeta company_id, permisos y RLS. El worker setea contexto antes de cada job. Sin contexto, no corre.

Arquitectura operativa MVP

11 componentes que componen la operación. Cada uno con responsabilidad acotada y observabilidad propia.

Componente Responsabilidad Tecnología sugerida
Web AppUI, APIs, SSR para tableros operativosNext.js / React
API BackendEndpoints, validaciones, comandos, withFaroSecurityNode.js / TypeScript
PostgreSQLDatos, RLS, snapshots, auditoría, schema opsPostgreSQL 15+
RedisQueue, locks temporales, cache de sesiónRedis 7
WorkersProcesan jobs asincrónicos con runJobBullMQ o Celery/RQ
SchedulerDispara jobs recurrentes según cron por jobnode-cron o equivalente
Storage privadoEvidencias, PDFs, archivos, backupsS3-compatible
AI GatewayExplicaciones IA controladas con budgetAnthropic / OpenAI compat
Observability stackLogs, métricas, traces, alertas técnicasOpenTelemetry + Grafana
Email providerNotificaciones y reportes ejecutivosResend / SES
Error trackerErrores frontend/backend con stackSentry

Flujo operativo canónico. Evento o schedule → crea job_run → entra en queue → worker toma job → setea company_id y contexto → ejecuta proceso → guarda resultado → genera logs y métricas → si falla, reintenta con backoff → si agota reintentos, va a dead_letter → si es crítico, dispara alerta técnica. Sin pasos optativos, sin saltos.

03 · 13 jobs MVP

JOB-ING a JOB-HEALTH · catálogo canónico operable

13 jobs cubren el pipeline completo: ingesta, validación, normalización, KPIs, tensiones, workflow, score, notificaciones, reportes, IA, retención, backups y health snapshot. Cada uno con código JOB-XXX-NNN, frecuencia, timeout, max_attempts y dedupe_key. Las marcadas EVENT corren además on-demand al ocurrir su evento disparador.

JOB-ING-001 EVENT
Alta

Ingesta de archivos pendientes

Datos batch P1

Procesa archivos subidos a raw_imports según plantilla FARO-PL y los baja a staging.

Trigger
scheduled + upload event
Timeout
600 s
Max attempts
3
Dedupe key
file_hash + source_id
Queue: ingest Company-scoped
JOB-DQ-001
Alta

Validación calidad de datos

Calidad scheduled P1

Aplica las 120 reglas de calidad-datos.html sobre staging. Marca registros con quality_issue.

Trigger
scheduled
Frecuencia
cada 30 min
Timeout
300 s
Max attempts
3
Dedupe key
company_id + period
Queue: quality Company-scoped
JOB-NORM-001
Alta

Normalización staging

Datos batch P1

Transforma staging en tablas normalizadas por área. Resuelve dimensiones (cliente, vendedor, sucursal, producto).

Trigger
scheduled
Frecuencia
cada 30 min
Timeout
600 s
Max attempts
3
Dedupe key
company_id + period + table
Queue: etl Company-scoped
JOB-KPI-001
Alta

Cálculo KPIs MVP

Métrica batch P1

Calcula los KPIs del MVP por período definido (day/week) según biblioteca de 400 KPIs.

Trigger
scheduled
Frecuencia
cada 30-60 min
Timeout
900 s
Max attempts
3
Dedupe key
company_id + kpi_code + period
Queue: compute Company-scoped
JOB-RULE-001 EVENT
Alta

Evaluación reglas y tensiones

Motor event_driven P1

Corre el motor evaluador sobre las reglas YAML activas. Crea tensiones nuevas o actualiza existentes.

Trigger
scheduled + KPI ready
Frecuencia
cada 30-60 min
Timeout
600 s
Max attempts
3
Dedupe key
company_id + rule_set_version + period
Queue: engine Company-scoped
JOB-WF-001
Alta

Workflow checker

Workflow scheduled P1

Avanza acciones según workflow-escalamiento-mvp.html. Marca vencimientos, escala niveles.

Trigger
scheduled
Frecuencia
cada 15-60 min
Timeout
300 s
Max attempts
3
Dedupe key
company_id + tick_at
Queue: workflow Company-scoped
JOB-SCORE-001 EVENT
Alta

Recalcular FARO Score

Score event_driven P1

Recalcula Score por empresa y dimensión. Snapshot en faro.score_snapshots.

Trigger
scheduled + tensión cerrada
Frecuencia
cada 30-60 min / evento
Timeout
300 s
Max attempts
3
Dedupe key
company_id + dimension + period
Queue: score Company-scoped · usa lock
JOB-NOTIF-001
Alta

Dispatcher notificaciones

Email scheduled P1

Procesa la cola de notificaciones pendientes. Llama al email provider, registra provider_response.

Trigger
scheduled
Frecuencia
cada 1-5 min
Timeout
120 s
Max attempts
5
Dedupe key
notification_id
Queue: notifications Company-scoped
JOB-REPORT-001
Alta

Generar reporte semanal

Reportes scheduled P1

Genera reporte ejecutivo semanal por empresa (HTML + PDF). Lo guarda en storage privado.

Trigger
cron semanal
Frecuencia
Lunes 07:00 (timezone empresa)
Timeout
900 s
Max attempts
3
Dedupe key
company_id + report_code + period
Queue: reports Company-scoped
JOB-AI-001
Media

Limpieza cache IA

IA system P2

Vence cache de respuestas IA según TTL. Aplica retención sobre ai.requests según política.

Trigger
cron diario
Frecuencia
diario 03:00
Timeout
600 s
Max attempts
3
Dedupe key
day
Queue: maintenance System-scoped
JOB-RET-001
Media

Retención y archivado

Retención system P2

Aplica política de retención: 90-180d job_runs ok, 365d failed, 5 años audit. Archiva al storage frío.

Trigger
cron diario
Frecuencia
diario 04:00
Timeout
1800 s
Max attempts
3
Dedupe key
retention_policy + day
Queue: maintenance System-scoped
JOB-BACKUP-001
Alta

Verificación backups

Backups system P1

Verifica último backup exitoso, su edad y el último restore test. Registra en ops.backup_checks.

Trigger
cron diario
Frecuencia
diario 06:00
Timeout
300 s
Max attempts
3
Dedupe key
resource_type + day
Queue: maintenance System-scoped
JOB-HEALTH-001
Alta

Health snapshot interno

Health system P1

Toma snapshot del estado del sistema. Calcula pending_jobs, failed_jobs_24h, dead_letter_jobs, error_rate_5m.

Trigger
scheduled
Frecuencia
cada 5 min
Timeout
60 s
Max attempts
2
Dedupe key
floor(now / 5min)
Queue: health System-scoped
04 · Schema ops · 8 tablas

DDL completo del schema ops

Las 8 tablas que sostienen toda la operación técnica: catálogo de jobs, runs ejecutados, locks, schedules, errores, snapshots de salud, dead letter queue y alertas. Toda DDL es idempotente (CREATE TABLE IF NOT EXISTS), versionada y respeta company_id donde corresponde.

▸ SQL · V097 — schema ops
CREATE SCHEMA IF NOT EXISTS ops;
COMMENT ON SCHEMA ops IS 'FARO Connect · operación técnica · jobs, errores, health, backups';

4.1 · ops.jobs — catálogo de jobs

Define cada job MVP con su política de retry, timeout, cron y queue. Es la fuente única que consulta el scheduler.

▸ SQL · V098 — ops.jobs
CREATE TABLE IF NOT EXISTS ops.jobs (
  job_id uuid PRIMARY KEY DEFAULT gen_random_uuid(),

  job_code text NOT NULL UNIQUE,
  name text NOT NULL,
  description text NOT NULL,

  job_type text NOT NULL CHECK (
    job_type IN ('scheduled', 'event_driven', 'manual', 'batch', 'system')
  ),

  queue_name text NOT NULL DEFAULT 'default',

  default_priority integer NOT NULL DEFAULT 5,
  max_attempts integer NOT NULL DEFAULT 3,
  timeout_seconds integer NOT NULL DEFAULT 300,

  retry_strategy text NOT NULL DEFAULT 'exponential' CHECK (
    retry_strategy IN ('none', 'fixed', 'exponential')
  ),

  retry_delay_seconds integer NOT NULL DEFAULT 60,

  is_company_scoped boolean NOT NULL DEFAULT true,
  is_active boolean NOT NULL DEFAULT true,

  schedule_cron text NULL,
  timezone text NOT NULL DEFAULT 'America/Argentina/Mendoza',

  created_at timestamptz NOT NULL DEFAULT now(),
  updated_at timestamptz NOT NULL DEFAULT now()
);

CREATE INDEX IF NOT EXISTS idx_ops_jobs_active
ON ops.jobs(is_active, job_type);

4.2 · ops.job_runs — ejecuciones

Cada ejecución de un job queda registrada acá con su payload, resultado, attempt, status y métricas. Es la tabla más activa del schema.

▸ SQL · V099 — ops.job_runs
CREATE TABLE IF NOT EXISTS ops.job_runs (
  job_run_id uuid PRIMARY KEY DEFAULT gen_random_uuid(),

  job_id uuid NULL,
  job_code text NOT NULL,

  company_id uuid NULL,

  queue_name text NOT NULL DEFAULT 'default',

  status text NOT NULL DEFAULT 'queued' CHECK (
    status IN ('queued', 'running', 'success', 'failed',
              'retrying', 'cancelled', 'dead_letter')
  ),

  priority integer NOT NULL DEFAULT 5,
  attempt integer NOT NULL DEFAULT 0,
  max_attempts integer NOT NULL DEFAULT 3,

  dedupe_key text NULL,

  payload jsonb NOT NULL DEFAULT '{}'::jsonb,
  result jsonb NOT NULL DEFAULT '{}'::jsonb,

  error_code text NULL,
  error_message text NULL,
  error_stack text NULL,

  started_at timestamptz NULL,
  finished_at timestamptz NULL,
  duration_ms integer NULL,

  scheduled_at timestamptz NOT NULL DEFAULT now(),
  next_retry_at timestamptz NULL,

  locked_by text NULL,
  locked_at timestamptz NULL,

  request_id text NULL,
  created_by uuid NULL,
  created_at timestamptz NOT NULL DEFAULT now(),
  updated_at timestamptz NOT NULL DEFAULT now()
);

CREATE INDEX IF NOT EXISTS idx_job_runs_status_schedule
ON ops.job_runs(status, scheduled_at);

CREATE INDEX IF NOT EXISTS idx_job_runs_company_time
ON ops.job_runs(company_id, created_at DESC);

CREATE INDEX IF NOT EXISTS idx_job_runs_code_time
ON ops.job_runs(job_code, created_at DESC);

CREATE UNIQUE INDEX IF NOT EXISTS uq_job_runs_active_dedupe
ON ops.job_runs(company_id, job_code, dedupe_key)
WHERE dedupe_key IS NOT NULL
  AND status IN ('queued', 'running', 'retrying');

4.3 · ops.job_locks — locks distribuidos

Evita que dos workers ejecuten el mismo job crítico al mismo tiempo. Por ejemplo, dos recálculos de Score de Empresa Demo Cuyo S.A. en paralelo.

▸ SQL · V100 — ops.job_locks
CREATE TABLE IF NOT EXISTS ops.job_locks (
  job_lock_id uuid PRIMARY KEY DEFAULT gen_random_uuid(),

  lock_key text NOT NULL UNIQUE,
  company_id uuid NULL,

  locked_by text NOT NULL,
  locked_at timestamptz NOT NULL DEFAULT now(),
  expires_at timestamptz NOT NULL,

  metadata jsonb NOT NULL DEFAULT '{}'::jsonb
);

CREATE INDEX IF NOT EXISTS idx_job_locks_expires
ON ops.job_locks(expires_at);

4.4 · ops.job_schedules — cron por job

Permite definir múltiples schedules por job (por ejemplo, JOB-KPI-001 cada 30 min de 7 a 22 y cada 60 min de 22 a 7). Respeta timezone por empresa.

▸ SQL · V101 — ops.job_schedules
CREATE TABLE IF NOT EXISTS ops.job_schedules (
  job_schedule_id uuid PRIMARY KEY DEFAULT gen_random_uuid(),

  job_id uuid NOT NULL REFERENCES ops.jobs(job_id) ON DELETE CASCADE,
  company_id uuid NULL,

  cron_expression text NOT NULL,
  timezone text NOT NULL DEFAULT 'America/Argentina/Mendoza',

  is_active boolean NOT NULL DEFAULT true,

  last_triggered_at timestamptz NULL,
  next_trigger_at timestamptz NULL,

  payload_template jsonb NOT NULL DEFAULT '{}'::jsonb,

  created_at timestamptz NOT NULL DEFAULT now(),
  updated_at timestamptz NOT NULL DEFAULT now()
);

CREATE INDEX IF NOT EXISTS idx_job_schedules_next
ON ops.job_schedules(is_active, next_trigger_at);

4.5 · ops.error_events — taxonomía de errores

Toda excepción técnica genera un error event canónico. Se usa para alertas, dashboards y postmortems.

▸ SQL · V102 — ops.error_events
CREATE TABLE IF NOT EXISTS ops.error_events (
  error_event_id uuid PRIMARY KEY DEFAULT gen_random_uuid(),

  company_id uuid NULL,
  user_id uuid NULL,

  source text NOT NULL CHECK (
    source IN ('frontend', 'api', 'worker', 'scheduler',
                'database', 'integration', 'ai_gateway',
                'storage', 'email_provider')
  ),

  severity text NOT NULL CHECK (
    severity IN ('low', 'medium', 'high', 'critical')
  ),

  error_code text NOT NULL,
  error_message text NOT NULL,

  job_run_id uuid NULL,
  request_id text NULL,

  entity_type text NULL,
  entity_id text NULL,

  stack_trace text NULL,
  context jsonb NOT NULL DEFAULT '{}'::jsonb,

  is_resolved boolean NOT NULL DEFAULT false,
  resolved_by uuid NULL,
  resolved_at timestamptz NULL,
  resolution_note text NULL,

  created_at timestamptz NOT NULL DEFAULT now()
);

CREATE INDEX IF NOT EXISTS idx_error_events_company_time
ON ops.error_events(company_id, created_at DESC);

CREATE INDEX IF NOT EXISTS idx_error_events_source_severity
ON ops.error_events(source, severity, created_at DESC);

CREATE INDEX IF NOT EXISTS idx_error_events_unresolved
ON ops.error_events(severity, created_at DESC)
WHERE is_resolved = false;

4.6 · ops.health_snapshots — estado del sistema

Cada 5 min JOB-HEALTH-001 deja un snapshot. Sirve para graficar tendencias y disparar alertas técnicas.

▸ SQL · V103 — ops.health_snapshots
CREATE TABLE IF NOT EXISTS ops.health_snapshots (
  health_snapshot_id uuid PRIMARY KEY DEFAULT gen_random_uuid(),

  status text NOT NULL CHECK (
    status IN ('healthy', 'degraded', 'unhealthy')
  ),

  db_status text NOT NULL,
  redis_status text NULL,
  storage_status text NULL,
  email_status text NULL,
  ai_status text NULL,

  pending_jobs integer NOT NULL DEFAULT 0,
  failed_jobs_24h integer NOT NULL DEFAULT 0,
  dead_letter_jobs integer NOT NULL DEFAULT 0,

  avg_job_duration_ms numeric(12,2) NULL,
  p95_api_latency_ms numeric(12,2) NULL,
  error_rate_5m numeric(12,4) NULL,

  details jsonb NOT NULL DEFAULT '{}'::jsonb,

  created_at timestamptz NOT NULL DEFAULT now()
);

CREATE INDEX IF NOT EXISTS idx_health_snapshots_time
ON ops.health_snapshots(created_at DESC);

CREATE INDEX IF NOT EXISTS idx_health_snapshots_status
ON ops.health_snapshots(status, created_at DESC);

4.7 · ops.dead_letter_queue — DLQ explícita

Materializa los job_runs que agotaron reintentos. Permite triage, retry manual y resolución sin tocar la tabla caliente.

▸ SQL · V104 — ops.dead_letter_queue
CREATE TABLE IF NOT EXISTS ops.dead_letter_queue (
  dlq_id uuid PRIMARY KEY DEFAULT gen_random_uuid(),

  job_run_id uuid NOT NULL REFERENCES ops.job_runs(job_run_id) ON DELETE CASCADE,
  job_code text NOT NULL,
  company_id uuid NULL,

  payload jsonb NOT NULL DEFAULT '{}'::jsonb,

  error_code text NOT NULL,
  error_message text NOT NULL,
  last_stack text NULL,

  attempts integer NOT NULL,
  first_failed_at timestamptz NOT NULL,
  last_failed_at timestamptz NOT NULL DEFAULT now(),

  status text NOT NULL DEFAULT 'pending' CHECK (
    status IN ('pending', 'investigating', 'requeued',
              'resolved', 'cancelled')
  ),

  resolution_note text NULL,
  resolved_by uuid NULL,
  resolved_at timestamptz NULL,

  created_at timestamptz NOT NULL DEFAULT now()
);

CREATE INDEX IF NOT EXISTS idx_dlq_status_time
ON ops.dead_letter_queue(status, last_failed_at DESC);

CREATE INDEX IF NOT EXISTS idx_dlq_company
ON ops.dead_letter_queue(company_id, status);

4.8 · ops.alerts — alertas técnicas disparadas

Toda alerta técnica enviada queda persistida. Permite reconciliar canales (email, Slack), evitar duplicados y armar postmortems.

▸ SQL · V105 — ops.alerts
CREATE TABLE IF NOT EXISTS ops.alerts (
  alert_id uuid PRIMARY KEY DEFAULT gen_random_uuid(),

  alert_code text NOT NULL,
  company_id uuid NULL,

  severity text NOT NULL CHECK (
    severity IN ('low', 'medium', 'high', 'critical')
  ),

  title text NOT NULL,
  description text NOT NULL,

  source_table text NULL,
  source_id uuid NULL,

  dedupe_key text NULL,
  fingerprint text NULL,

  channels text[] NOT NULL DEFAULT ARRAY[]::text[],
  recipients text[] NOT NULL DEFAULT ARRAY[]::text[],

  status text NOT NULL DEFAULT 'open' CHECK (
    status IN ('open', 'acknowledged', 'silenced', 'resolved')
  ),

  triggered_at timestamptz NOT NULL DEFAULT now(),
  acknowledged_at timestamptz NULL,
  resolved_at timestamptz NULL,

  context jsonb NOT NULL DEFAULT '{}'::jsonb
);

CREATE INDEX IF NOT EXISTS idx_alerts_status_time
ON ops.alerts(status, triggered_at DESC);

CREATE UNIQUE INDEX IF NOT EXISTS uq_alerts_open_dedupe
ON ops.alerts(alert_code, dedupe_key)
WHERE status IN ('open', 'acknowledged')
  AND dedupe_key IS NOT NULL;

El schema cierra el contrato técnico. Ver modelo-sql.html para el DDL consolidado del MVP completo (incluye faro.*, ai.*, audit.* y este ops.*). Toda migración es idempotente y versionada.

05 · Worker / queue runner TS

Job registry, runner loop, failure handler y scheduler

Cuatro piezas en TypeScript que sostienen toda la operación. Cada handler es independiente, idempotente y testeable. El runner setea contexto de empresa antes de invocar el handler. El failure handler decide si reintenta o promueve a dead letter.

5.1 · Types y job registry

El registry mapea cada job_code a su handler. Agregar un job nuevo es agregar una línea acá más una migración seed.

▸ TypeScript · src/ops/ops.types.ts
export type JobPayload = {
  companyId?: string;
  triggeredBy?: string | null;
  requestId?: string;
  payload: Record<string, unknown>;
};

export type JobHandlerContext = {
  jobRunId: string;
  jobCode: string;
  companyId?: string | null;
  requestId: string;
  attempt: number;
};

export type JobHandler = (
  payload: JobPayload,
  ctx: JobHandlerContext
) => Promise<Record<string, unknown> | void>;
▸ TypeScript · src/ops/jobRegistry.ts
import { ingestPendingFiles } from "@/src/ingest/ingestPendingFiles";
import { validateDataQuality } from "@/src/quality/validateDataQuality";
import { normalizeStaging } from "@/src/etl/normalizeStaging";
import { computeKpis } from "@/src/kpi/computeKpis";
import { evaluateRules } from "@/src/engine/evaluateRules";
import { runWorkflowChecker } from "@/src/workflow/workflowChecker";
import { recalculateScoreJob } from "@/src/score/recalculateScoreJob";
import { dispatchPendingNotifications } from "@/src/notifications/dispatchPendingNotifications";
import { generateWeeklyReportsJob } from "@/src/reports/generateWeeklyReportsJob";
import { cleanAiCache } from "@/src/ai/cleanAiCache";
import { applyRetention } from "@/src/ops/applyRetention";
import { checkBackups } from "@/src/ops/checkBackups";
import { createHealthSnapshot } from "@/src/ops/createHealthSnapshot";

import type { JobHandler } from "./ops.types";

export const jobRegistry: Record<string, JobHandler> = {
  "JOB-ING-001": ingestPendingFiles,
  "JOB-DQ-001": validateDataQuality,
  "JOB-NORM-001": normalizeStaging,
  "JOB-KPI-001": computeKpis,
  "JOB-RULE-001": evaluateRules,
  "JOB-WF-001": runWorkflowChecker,
  "JOB-SCORE-001": recalculateScoreJob,
  "JOB-NOTIF-001": dispatchPendingNotifications,
  "JOB-REPORT-001": generateWeeklyReportsJob,
  "JOB-AI-001": cleanAiCache,
  "JOB-RET-001": applyRetention,
  "JOB-BACKUP-001": checkBackups,
  "JOB-HEALTH-001": createHealthSnapshot
};

5.2 · Runner loop · runJob

El worker toma un job_run en queued o retrying, lo marca como running con lock, ejecuta el handler y registra el resultado. Si falla, delega a handleJobFailure.

▸ TypeScript · src/ops/runJob.ts
import { jobRegistry } from "./jobRegistry";
import { handleJobFailure } from "./handleJobFailure";

export async function runJob(params: {
  client: any;
  jobRunId: string;
  workerId: string;
}) {
  const started = Date.now();

  const jobResult = await params.client.query(
    `
    UPDATE ops.job_runs
    SET
      status = 'running',
      locked_by = $2,
      locked_at = now(),
      started_at = now(),
      attempt = attempt + 1,
      updated_at = now()
    WHERE job_run_id = $1
      AND status IN ('queued','retrying')
    RETURNING *
    `,
    [params.jobRunId, params.workerId]
  );

  const job = jobResult.rows[0];
  if (!job) return null;

  const handler = jobRegistry[job.job_code];
  if (!handler) {
    throw new Error(`JOB_HANDLER_NOT_FOUND: ${job.job_code}`);
  }

  try {
    // Setea contexto de empresa para RLS
    if (job.company_id) {
      await params.client.query(
        `SELECT set_config('app.company_id', $1, true)`,
        [job.company_id]
      );
    }

    const result = await handler(
      {
        companyId: job.company_id,
        requestId: job.request_id,
        payload: job.payload
      },
      {
        jobRunId: job.job_run_id,
        jobCode: job.job_code,
        companyId: job.company_id,
        requestId: job.request_id,
        attempt: job.attempt
      }
    );

    await params.client.query(
      `
      UPDATE ops.job_runs
      SET
        status = 'success',
        result = $2::jsonb,
        finished_at = now(),
        duration_ms = $3,
        updated_at = now()
      WHERE job_run_id = $1
      `,
      [job.job_run_id, JSON.stringify(result ?? {}), Date.now() - started]
    );

    return result;
  } catch (error: any) {
    await handleJobFailure({
      client: params.client,
      job,
      error,
      durationMs: Date.now() - started
    });
    return null;
  }
}

5.3 · Failure handler con backoff exponencial

Decide entre retrying y dead_letter según attempt < max_attempts. Si pasa a DLQ, registra error event de severidad alta y dispara alerta si el job era P1.

▸ TypeScript · src/ops/handleJobFailure.ts
export async function handleJobFailure(params: {
  client: any;
  job: any;
  error: any;
  durationMs: number;
}) {
  const nextAttempt = Number(params.job.attempt ?? 0);
  const maxAttempts = Number(params.job.max_attempts ?? 3);

  const errorCode = params.error?.code ?? "JOB_FAILED";
  const errorMessage = params.error?.message ?? "Unknown job error";
  const stack = params.error?.stack ?? null;

  const shouldRetry = nextAttempt < maxAttempts;

  await params.client.query(
    `
    UPDATE ops.job_runs
    SET
      status = $2,
      error_code = $3,
      error_message = $4,
      error_stack = $5,
      finished_at = CASE WHEN $2 = 'dead_letter' THEN now() ELSE finished_at END,
      next_retry_at = CASE
        WHEN $2 = 'retrying' THEN now() + ($6::int || ' seconds')::interval
        ELSE NULL
      END,
      duration_ms = $7,
      updated_at = now()
    WHERE job_run_id = $1
    `,
    [
      params.job.job_run_id,
      shouldRetry ? "retrying" : "dead_letter",
      errorCode,
      errorMessage,
      stack,
      calculateRetryDelaySeconds(nextAttempt),
      params.durationMs
    ]
  );

  await params.client.query(
    `
    INSERT INTO ops.error_events (
      company_id, source, severity, error_code, error_message,
      job_run_id, stack_trace, context
    )
    VALUES ($1, 'worker', $2, $3, $4, $5, $6, $7::jsonb)
    `,
    [
      params.job.company_id,
      shouldRetry ? "medium" : "high",
      errorCode,
      errorMessage,
      params.job.job_run_id,
      stack,
      JSON.stringify({
        job_code: params.job.job_code,
        attempt: nextAttempt,
        max_attempts: maxAttempts
      })
    ]
  );

  // Si fue DLQ, materializa fila explícita
  if (!shouldRetry) {
    await params.client.query(
      `
      INSERT INTO ops.dead_letter_queue (
        job_run_id, job_code, company_id, payload,
        error_code, error_message, last_stack,
        attempts, first_failed_at
      )
      VALUES ($1, $2, $3, $4::jsonb, $5, $6, $7, $8, now())
      `,
      [
        params.job.job_run_id,
        params.job.job_code,
        params.job.company_id,
        JSON.stringify(params.job.payload ?? {}),
        errorCode,
        errorMessage,
        stack,
        nextAttempt
      ]
    );
  }
}

function calculateRetryDelaySeconds(attempt: number) {
  // Backoff: 60s, 5m, 15m, 1h
  const delays = [60, 300, 900, 3600];
  return delays[Math.min(attempt - 1, delays.length - 1)];
}

5.4 · Scheduler cron por job

El scheduler lee ops.job_schedules activos, evalúa el cron, crea un job_run idempotente con dedupe_key y actualiza next_trigger_at. Respeta timezone por empresa.

▸ TypeScript · src/ops/scheduleJobRun.ts
export async function scheduleJobRun(params: {
  client: any;
  jobCode: string;
  companyId?: string | null;
  payload?: Record<string, unknown>;
  dedupeKey?: string | null;
  scheduledAt?: string | null;
}) {
  const job = await params.client.query(
    `
    SELECT * FROM ops.jobs
    WHERE job_code = $1 AND is_active = true
    LIMIT 1
    `,
    [params.jobCode]
  );

  if (!job.rows[0]) {
    throw new Error(`JOB_NOT_FOUND: ${params.jobCode}`);
  }

  const j = job.rows[0];

  const result = await params.client.query(
    `
    INSERT INTO ops.job_runs (
      job_id, job_code, company_id, queue_name,
      status, priority, max_attempts, dedupe_key,
      payload, scheduled_at
    )
    VALUES (
      $1, $2, $3, $4,
      'queued',
      $5, $6, $7,
      $8::jsonb,
      COALESCE($9::timestamptz, now())
    )
    ON CONFLICT DO NOTHING
    RETURNING job_run_id
    `,
    [
      j.job_id, j.job_code, params.companyId ?? null, j.queue_name,
      j.default_priority, j.max_attempts, params.dedupeKey ?? null,
      JSON.stringify(params.payload ?? {}), params.scheduledAt ?? null
    ]
  );

  return result.rows[0]?.job_run_id ?? null;
}
Test mínimo de failure handler (vitest)
▸ TypeScript · ops.job-failure.test.ts
import { describe, expect, it } from "vitest";
import { handleJobFailure } from "../src/ops/handleJobFailure";
import { withTestDbContext } from "../src/helpers/dbTestContext";

describe("OPS job failure", () => {
  it("moves job to retrying when attempts remain", async () => {
    await withTestDbContext({}, async (client) => {
      const insert = await client.query(`
        INSERT INTO ops.job_runs (job_code, status, attempt, max_attempts, payload)
        VALUES ('JOB-TEST-001', 'running', 1, 3, '{}'::jsonb)
        RETURNING *
      `);

      await handleJobFailure({
        client,
        job: insert.rows[0],
        error: new Error("Test failure"),
        durationMs: 100
      });

      const result = await client.query(
        `SELECT status FROM ops.job_runs WHERE job_run_id = $1`,
        [insert.rows[0].job_run_id]
      );

      expect(result.rows[0].status).toBe("retrying");
    });
  });
});
06 · Error taxonomy · backoff · DLQ

13 categorías canónicas, política de retry y reglas DLQ

FARO usa una taxonomía cerrada de errores. Cada categoría tiene política de retry conocida. Cuando un job agota intentos, va a la dead letter queue, donde puede ser triageado, requeueado o cerrado manualmente desde /faro/ops/dead-letter.

AUTH_ERROR

No autenticado. Usuario sin sesión o token vencido. Retornar 401 y limpiar contexto.

No retry
PERMISSION_ERROR

Sin permiso. Usuario autenticado sin el scope necesario. Retornar 403.

No retry
VALIDATION_ERROR

Payload inválido. Esquema, tipos o reglas de negocio no cumplidas.

No retry
DATA_QUALITY_ERROR

Datos incompletos o inconsistentes. Marca quality_issue, requiere intervención.

No automático
INTEGRATION_ERROR

API externa falló (Tango, Calipso, banco). Suele ser transitorio.

Exponencial · 5 max
STORAGE_ERROR

No subió/bajó archivo del object storage. Suele ser transitorio.

Exponencial · 3 max
AI_ERROR

Provider IA falló (timeout, rate limit, content policy).

1 retry + fallback
DB_ERROR

Query inválida o violación de constraint. Suele requerir fix de código.

No retry · investigar
JOB_ERROR

Worker falló sin clasificar. Categoría por defecto cuando no se conoce el tipo.

Exponencial · 3 max
TIMEOUT_ERROR

Operación excedió timeout_seconds del job.

Exponencial · 3 max
RATE_LIMIT_ERROR

Límite del provider excedido. Esperar y reintentar según ventana.

Delay del provider
CONFIG_ERROR

Configuración faltante (secret, env var, feature flag). Requiere intervención de plataforma.

No retry · alertar
UNKNOWN_ERROR

Excepción no clasificada. Va al tracker con stack completo para clasificación posterior.

1 retry · luego DLQ

Estructura canónica del error API

El usuario recibe claridad. El sistema guarda detalle. Nadie recibe stack trace en pantalla.

▸ JSON · response 4xx/5xx
{
  "error": {
    "code": "PERMISSION_DENIED",
    "category": "PERMISSION_ERROR",
    "message": "No tenés permiso para realizar esta acción.",
    "request_id": "req_a8f3c1",
    "severity": "medium"
  }
}

Política de backoff exponencial

Intento Delay Aplica a Resultado si falla
160 sDefault todos los jobs retryableretrying
25 minDefault todos los jobs retryableretrying
315 minDefault · DLQ si no es JOB-NOTIFdead_letter
41 hSolo JOB-NOTIF y JOB-ING (max_attempts = 5)retrying
5DLQSolo JOB-NOTIF y JOB-INGdead_letter

Reglas DLQ

  • Cuando un job entra a DLQ, se materializa fila en ops.dead_letter_queue con payload, error_code, último stack y attempts.
  • Si el job era prioridad P1, dispara alerta técnica (alert_code = ops.dlq.critical) deduped por job_code + company_id + day.
  • Desde UI se puede requeue (reset a queued, attempt 0), cancelar (marca cancelled) o resolver con nota.
  • Toda acción sobre DLQ queda en audit log con action = ops.dlq.{retry|cancel|resolve} y risk_level = high.
07 · Health checks · simple + deep

Endpoints /health/live y /health/ready + status snapshots

Tres niveles de health check: live (¿el proceso responde?), ready (¿puede atender requests?) y deep (¿están sanas todas las dependencias?). Cada 5 min JOB-HEALTH-001 deja snapshot en ops.health_snapshots para histórico y alertas.

healthy

Todo operativo

DB, Redis, storage, email, IA responden. Sin DLQ acumulada, sin error rate elevado. Sistema funcionando normal.

degraded

Funciona con problema parcial

Una dependencia no crítica caída (ej. email provider, IA). El sistema sigue procesando jobs core, con funcionalidad reducida.

unhealthy

No puede operar correctamente

DB caída, error rate > 10% o múltiples DLQ críticos. Dispara alerta SEV-1, abre incidente automático.

7.1 · Endpoint live · GET /health/live

Liveness probe para Kubernetes/Fly/Render. Solo responde 200 si el proceso está vivo. No verifica dependencias. Pensado para timeouts cortos (1-2 s).

▸ HTTP · GET /health/live
HTTP/1.1 200 OK
Content-Type: application/json

{
  "status": "alive",
  "service": "faro-api",
  "version": "1.0.0",
  "timestamp": "2026-05-31T12:00:00-03:00"
}

7.2 · Endpoint ready · GET /health/ready

Readiness probe. Verifica conexión a DB y Redis. Si falla, el orquestador deja de mandar tráfico hasta que vuelva.

▸ HTTP · GET /health/ready
HTTP/1.1 200 OK
Content-Type: application/json

{
  "status": "ready",
  "checks": {
    "database": "healthy",
    "redis": "healthy"
  },
  "timestamp": "2026-05-31T12:00:00-03:00"
}

7.3 · Endpoint deep · GET /api/health/deep

Health check profundo. Verifica DB, Redis, storage, email provider, AI provider y devuelve métricas operativas. Más caro, protegido con permiso ops:health:read.

▸ HTTP · GET /api/health/deep
HTTP/1.1 200 OK
Content-Type: application/json

{
  "status": "degraded",
  "checks": {
    "database": "healthy",
    "redis": "healthy",
    "storage": "healthy",
    "email_provider": "degraded",
    "ai_provider": "healthy"
  },
  "jobs": {
    "pending": 12,
    "failed_24h": 2,
    "dead_letter": 1,
    "avg_duration_ms": 842,
    "queue_depth_max": 18
  },
  "metrics": {
    "p95_api_latency_ms": 245,
    "error_rate_5m": 0.012
  },
  "timestamp": "2026-05-31T12:00:00-03:00"
}

7.4 · Clasificador de status

Función pura que recibe los inputs y devuelve healthy | degraded | unhealthy. Tiene tests propios. Usada por /health/deep y JOB-HEALTH-001.

▸ TypeScript · src/ops/classifyHealthStatus.ts
export type HealthInputs = {
  db: "healthy" | "degraded" | "unhealthy";
  redis: "healthy" | "degraded" | "unhealthy";
  storage: "healthy" | "degraded" | "unhealthy";
  failedJobs24h: number;
  deadLetterJobs: number;
  errorRate5m: number;
};

export function classifyHealthStatus(
  i: HealthInputs
): "healthy" | "degraded" | "unhealthy" {
  if (i.db === "unhealthy") return "unhealthy";
  if (i.errorRate5m > 0.10) return "unhealthy";
  if (i.deadLetterJobs >= 5) return "unhealthy";

  if (i.redis !== "healthy") return "degraded";
  if (i.storage !== "healthy") return "degraded";
  if (i.failedJobs24h > 20) return "degraded";
  if (i.deadLetterJobs >= 1) return "degraded";
  if (i.errorRate5m > 0.05) return "degraded";

  return "healthy";
}
08 · Observabilidad

Logs estructurados, métricas Prometheus, traces y dashboards

Cuatro dimensiones a observar: logs (qué pasó), metrics (cuánto pasó), traces (por dónde pasó), events (qué significó para negocio). Toda request propaga request_id end-to-end. Toda métrica clave queda en Prometheus + visualizada en Grafana.

8.1 · Logs estructurados

JSON por línea, con campos canónicos. El logger de aplicación inyecta request_id, company_id, user_id y module automáticamente desde el contexto.

▸ JSON · log line
{
  "timestamp": "2026-05-31T12:00:00-03:00",
  "level": "info",
  "service": "faro-api",
  "environment": "production",
  "request_id": "req_a8f3c1",
  "company_id": "d4e1...empresa-demo-cuyo",
  "user_id": "7b2a...",
  "module": "score",
  "event": "score_recalculated",
  "message": "FARO Score recalculated for Empresa Demo Cuyo S.A.",
  "duration_ms": 842,
  "metadata": { "score": 66, "delta": -8 }
}

5 niveles canónicos.

Nivel Uso Ejemplo
debugSolo desarrollo / stagingTrace de query, dump de payload pesado
infoEvento normal del negocioscore_recalculated, action_created
warnSituación anómala controladaReintento, fallback IA, rate limit cerca
errorFallo recuperableJob que se manda a retry, integración caída
fatalSistema comprometidoDB caída, config faltante crítica

8.2 · Métricas (formato Prometheus)

Endpoint /metrics protegido. 4 grupos de métricas: API, jobs, negocio-operativas, IA.

▸ Prometheus · /metrics (ejemplo)
# HELP faro_api_request_count Total API requests
# TYPE faro_api_request_count counter
faro_api_request_count{method="GET",route="/api/v1/tensions",status="200"} 12483

# HELP faro_api_latency_ms API latency histogram
# TYPE faro_api_latency_ms histogram
faro_api_latency_ms_bucket{le="50"} 8421
faro_api_latency_ms_bucket{le="100"} 10987
faro_api_latency_ms_bucket{le="250"} 12104

# HELP faro_job_runs_total Jobs ejecutados
# TYPE faro_job_runs_total counter
faro_job_runs_total{job_code="JOB-SCORE-001",status="success"} 1284
faro_job_runs_total{job_code="JOB-SCORE-001",status="dead_letter"} 2

# HELP faro_queue_depth Jobs pendientes en queue
# TYPE faro_queue_depth gauge
faro_queue_depth{queue_name="notifications"} 12
faro_queue_depth{queue_name="score"} 0

# HELP faro_ai_cost_usd_daily Costo IA diario por empresa
# TYPE faro_ai_cost_usd_daily gauge
faro_ai_cost_usd_daily{company="empresa-demo-cuyo"} 1.84
Métrica Tipo Grupo Descripción
faro_api_request_countcounterAPIRequests totales por método/ruta/status
faro_api_error_countcounterAPIErrores 4xx/5xx por endpoint
faro_api_latency_mshistogramAPILatencia con buckets p50/p95/p99
faro_permission_denied_countcounterAPI403 por permiso
faro_auth_error_countcounterAPI401 por sesión
faro_job_runs_totalcounterJobsJobs ejecutados por code/status
faro_job_failures_totalcounterJobsJobs fallidos por code/error_code
faro_job_dead_letter_totalcounterJobsJobs promovidos a DLQ
faro_job_duration_mshistogramJobsDuración por job_code
faro_queue_depthgaugeJobsProfundidad de cola por queue_name
faro_retry_countcounterJobsReintentos por job_code
faro_stuck_jobs_countgaugeJobsJobs trabados (running > timeout)
faro_tensions_detected_countcounterNegocioTensiones detectadas por empresa
faro_actions_created_countcounterNegocioAcciones creadas por empresa
faro_actions_expired_countcounterNegocioAcciones vencidas
faro_evidence_rejected_countcounterNegocioEvidencias rechazadas
faro_score_recalculated_countcounterNegocioRecalculos de Score por empresa
faro_reports_generated_countcounterNegocioReportes generados
faro_notifications_sent_countcounterNegocioNotificaciones enviadas
faro_ai_requests_countcounterIARequests al gateway IA
faro_ai_failures_countcounterIAFallos del provider IA
faro_ai_fallback_used_countcounterIAVeces que se usó fallback
faro_ai_policy_violation_countcounterIAViolaciones de policy IA
faro_ai_cost_usd_dailygaugeIACosto diario por empresa
faro_ai_latency_mshistogramIALatencia del gateway IA

8.3 · Traces con request_id

Toda request entra con X-Request-Id (o se genera uno). Ese ID se propaga al worker cuando el handler encola un job. Permite reconstruir el viaje completo end-to-end.

▸ TypeScript · middleware request_id
export function withRequestId(handler: RequestHandler) {
  return async (req: Request, res: Response, next: NextFunction) => {
    const requestId = req.headers["x-request-id"]
      ?? `req_${crypto.randomUUID().slice(0, 8)}`;

    res.setHeader("x-request-id", requestId);

    await asyncLocalStorage.run({ requestId }, async () => {
      await handler(req, res, next);
    });
  };
}

8.4 · Dashboards Grafana mínimos

Cinco dashboards canónicos. Sin ellos, la operación es a ciegas.

  • Overview operativo: health status, queue depth, error rate, p95 latency, jobs en DLQ.
  • Jobs detail: success rate por job, duración p50/p95, attempts promedio, top errores.
  • API performance: requests por endpoint, latency histogram, error rate por ruta.
  • IA / costos: requests, fallbacks, latencia y costo diario por empresa con budget.
  • Negocio: tensiones detectadas, acciones creadas/vencidas, reportes generados, notificaciones enviadas (por empresa).
09 · 5 runbooks operativos

Procedimientos paso a paso para los 5 incidentes más frecuentes

Cada runbook es un procedimiento numerado, repetible y revisable para una falla esperada. Cuando un operador despierta a las 4 AM, no se piensa: se sigue el runbook. Si el runbook está mal, se actualiza en el postmortem.

Runbook 1 · SEV-2 · JOB-ING-001

Ingesta fallida (archivo de empresa no procesa)

SEV-2

Síntoma: Empresa Demo Cuyo S.A. subió archivo, pasaron > 30 min y no aparece en staging. JOB-ING-001 aparece en DLQ o trabado en running.

  1. Revisar raw_imports y import_batches para esa empresa: SELECT * FROM faro.raw_imports WHERE company_id = $1 ORDER BY created_at DESC LIMIT 5;
  2. Identificar error_code del último intento. Mapear contra taxonomía (sección 6).
  3. Validar formato del archivo: peso, encoding (UTF-8), separador, header row.
  4. Validar mapeo de columnas contra la plantilla FARO-PL correspondiente.
  5. Si es VALIDATION_ERROR: marcar como data issue, notificar al data owner de la empresa, no requeue automático.
  6. Si es STORAGE_ERROR o INTEGRATION_ERROR transitorio: requeue desde /faro/ops/dead-letter.
  7. Si es bug del parser: crear issue técnico con label = ops/ingest, asignar a dev backend.
  8. Reprocesar el archivo cuando esté corregido. Confirmar visualmente que aparece en staging.
  9. Registrar resolución en la fila de DLQ con resolution_note.

Cuándo escalar: si más de 3 empresas distintas fallan en la misma ventana o si el parser falla en archivos previamente válidos, escalar a SEV-1 (posible regresión).

Runbook 2 · SEV-1 · Motor stuck

Motor evaluador trabado (JOB-RULE-001 en running > timeout)

SEV-1

Síntoma: JOB-RULE-001 aparece en running hace más de 30 min. Score no se actualiza, tensiones nuevas no aparecen.

  1. Identificar el job_run_id stuck: SELECT job_run_id, company_id, started_at, locked_by FROM ops.job_runs WHERE job_code = 'JOB-RULE-001' AND status = 'running' AND started_at < now() - interval '30 minutes';
  2. Verificar si el worker que tomó el lock sigue vivo (ver locked_by, comparar con workers activos en Redis/orquestador).
  3. Si el worker está muerto: liberar el lock manualmente actualizando a status = 'retrying', locked_by = NULL, next_retry_at = now().
  4. Si el worker está vivo pero corriendo eterno: revisar log del worker por la query que está colgada (suele ser un JOIN sin índice en datos grandes).
  5. Verificar que existe score_model_version activa: SELECT * FROM faro.score_model_versions WHERE is_active = true;
  6. Revisar views de inputs del motor (v_kpi_latest, v_period_keys) que no estén explotando por NULL nuevos.
  7. Si hay una empresa específica que tiene volumen anómalo: aislarla con feature flag engine_skip_company para esa company_id y reintentar el resto.
  8. Si pasa SEV-1 escalado y persiste > 1h: rollback al release anterior. Abrir postmortem.

Mitigación temporal: activar engine_safe_mode que limita el motor a las reglas críticas (TNS-001..TNS-010) mientras se diagnostica.

Runbook 3 · SEV-2 · JOB-SCORE-001

Score divergente (Score no recalcula o da valor inesperado)

SEV-2

Síntoma: Score de Empresa Demo Cuyo S.A. aparece igual durante > 24h pese a que hay tensiones cerradas, o salta más de 20 puntos en una sola corrida.

  1. Revisar últimas ejecuciones: SELECT * FROM ops.job_runs WHERE job_code = 'JOB-SCORE-001' AND company_id = $1 ORDER BY created_at DESC LIMIT 10;
  2. Ver error_code, error_message y result de la última corrida exitosa.
  3. Confirmar que existe score_model_version activa y que coincide con la versión esperada.
  4. Revisar las views base del Score: v_score_inputs_commercial, v_score_inputs_finance, etc. Confirmar que devuelven datos para esa empresa.
  5. Confirmar que la empresa tiene datos mínimos cargados (último period con KPIs): si falta data, el Score puede quedarse pegado por diseño.
  6. Ejecutar recálculo manual: POST /api/v1/ops/jobs/JOB-SCORE-001/run con { "company_id": "..." }.
  7. Comparar snapshot nuevo vs anterior: SELECT * FROM faro.score_snapshots WHERE company_id = $1 ORDER BY period_end DESC LIMIT 3;
  8. Si la diferencia > 20 puntos sin explicación: revisar score_impact de las tensiones cerradas/abiertas en la ventana.
  9. Si falla por código: escalar a dev del módulo Score. Si falla por datos: marcar data_quality_issue.
Runbook 4 · SEV-2 · DLQ saturated

Dead letter queue saturada (> 50 jobs pending en DLQ)

SEV-2

Síntoma: dead_letter_jobs > 50 en health snapshot. Alerta automática disparada.

  1. Agrupar DLQ por error_code para entender la naturaleza: SELECT error_code, count(*) FROM ops.dead_letter_queue WHERE status = 'pending' GROUP BY error_code ORDER BY 2 DESC;
  2. Identificar la categoría dominante. Si es una sola, es muy probable que sea un mismo bug o una sola integración caída.
  3. Si es INTEGRATION_ERROR de un provider externo: verificar status del provider, ventana de incidente. Si ya volvió, requeue masivo.
  4. Si es VALIDATION_ERROR repetido: revisar último cambio de schema/contrato. Coordinar con dev backend.
  5. Si es DB_ERROR por constraint: identificar el constraint, decidir si hay que loosen o si los datos son inválidos.
  6. Para requeue masivo seguro: usar POST /api/v1/ops/dlq/requeue-bulk con filtro por error_code + window. Limitar a 100 por batch.
  7. Marcar como cancelled los que ya no aplican (datos viejos sin valor de reproceso).
  8. Si los reintentos vuelven a fallar inmediatamente: no insistir. Pausar el job en ops.jobs.is_active = false temporalmente, abrir incidente, escalar.

Prevención: revisar la alerta de DLQ con threshold > 20 (warning) antes de llegar a 50 (critical).

Runbook 5 · SEV-3 · Provider IA caído

Provider IA caído (explicaciones ejecutivas no se generan)

SEV-3

Síntoma: ai_status = degraded en health snapshot. Reportes ejecutivos muestran texto fallback. Métrica faro_ai_fallback_used_count creciendo.

  1. Verificar status del provider (status page de Anthropic/OpenAI).
  2. Confirmar latencia/errores en ai.requests: SELECT status_code, count(*) FROM ai.requests WHERE created_at > now() - interval '15 minutes' GROUP BY status_code;
  3. Si el provider devuelve RATE_LIMIT: activar feature flag ai_throttle_mode para bajar concurrencia. No reintentar más rápido.
  4. Si es timeout: aumentar timeout del gateway IA temporalmente y reintentar fallidos.
  5. Si está completamente caído: confirmar que el fallback no-IA funciona (texto canónico desde plantilla). El producto debe seguir operando, solo con explicación reducida.
  6. Si hay provider secundario configurado: activar feature flag ai_provider_secondary y monitorear.
  7. Cuando vuelva el provider primario: desactivar feature flag, dejar correr 30 min antes de re-disparar reportes pendientes.
  8. Si el costo IA del día ya superó el budget: aceptar fallback hasta el día siguiente. Notificar a stakeholders del impacto en lectura ejecutiva.

Comunicación: si dura > 2 h, comunicar a los GG de empresas activas: "los reportes de hoy llegan con resumen sintético; la explicación ejecutiva completa vuelve mañana".

Regla de runbooks vivos. Cada postmortem SEV-1/SEV-2 debe revisar el runbook que se usó (o que faltaba) y proponer mejora concreta. Un runbook que no se actualiza en 6 meses está desactualizado por definición.

10 · Backups verificables

Política de backup, restore drills y RTO/RPO

No alcanza con "hay backup". Hay que verificar último backup exitoso, edad del backup, restore test periódico y alerta si no existe backup reciente. JOB-BACKUP-001 corre cada día y deja registro en ops.backup_checks.

10.1 · Política sugerida

Recurso Estrategia Frecuencia Retención
PostgreSQLSnapshot diario + PITR (si provider lo permite)diario · WAL continuo30 días daily + 12 meses semanal
Storage evidenciasVersionado del bucket + backup cross-regioncontinuo365 días
Reportes PDFStorage privado replicadoal generar5 años (audit trail)
ConfiguraciónTabla + export periódico a object storagediario90 días
Prompts IAEn DB + versionado en migraciones (git)al cambiarindefinido (audit)
CódigoGit con mirror cross-providercontinuoindefinido
SecretsSecret manager · NO en repo · backup propio del providercontinuosegún provider

10.2 · Tabla ops.backup_checks + verificación

DDL acotada usada por JOB-BACKUP-001 para registrar el estado verificado de cada recurso backupable.

▸ SQL · V106 — ops.backup_checks
CREATE TABLE IF NOT EXISTS ops.backup_checks (
  backup_check_id uuid PRIMARY KEY DEFAULT gen_random_uuid(),

  resource_type text NOT NULL CHECK (
    resource_type IN ('database', 'storage', 'config', 'reports', 'evidence')
  ),

  status text NOT NULL CHECK (
    status IN ('ok', 'warning', 'failed')
  ),

  last_backup_at timestamptz NULL,
  last_restore_test_at timestamptz NULL,
  backup_size_bytes bigint NULL,

  provider text NULL,
  details jsonb NOT NULL DEFAULT '{}'::jsonb,

  checked_at timestamptz NOT NULL DEFAULT now()
);

CREATE INDEX IF NOT EXISTS idx_backup_checks_time
ON ops.backup_checks(resource_type, checked_at DESC);

10.3 · Restore drills

Un backup que nunca se probó no es backup. Restore drill mensual obligatorio: tomar el snapshot de production, levantarlo en ambiente aislado, validar consistencia con un set de queries canónicas y registrar el éxito en last_restore_test_at.

  1. Trigger calendárico: el primer lunes de cada mes.
  2. Tomar último snapshot exitoso del provider de DB.
  3. Restaurar en una instancia de drill (no production, no staging).
  4. Correr smoke queries: count por tabla principal, integridad referencial, último job_run exitoso, último score_snapshot.
  5. Si todo OK, actualizar last_restore_test_at = now() en ops.backup_checks.
  6. Destruir la instancia de drill.

10.4 · RTO / RPO MVP

Objetivos operativos del MVP. No son SLAs contractuales (eso es enterprise).

Indicador Definición Target MVP Cómo se mide
RTORecovery Time Objective · tiempo desde incidente hasta servicio operativo4 hCronómetro desde detected_at hasta mitigated_at en ops.incidents
RPORecovery Point Objective · máximo data loss aceptable15 minWAL continuo + snapshot diario
MTTRMean Time To Resolution · promedio de SEV-1/SEV-2 resueltos< 2 hAvg resolved_at - detected_at mensual
MTBFMean Time Between Failures · entre SEV-1 consecutivos> 30 díasDiff entre incident.started_at SEV-1 consecutivos

Sin restore drill, no hay backup. Tener un dump diario que nunca se probó es marketing interno. El día que se necesita es el día que se descubre que está corrupto, incompleto o mal versionado. Drill mensual no es opcional.

11 · Incident management · SEV-1 a SEV-4

4 severidades, on-call rotation, postmortems obligatorios

Toda interrupción operativa relevante se gestiona como incidente formal. Cuatro severidades canónicas, una tabla ops.incidents, postmortem obligatorio para SEV-1/SEV-2 y rotación on-call documentada. Esto no se hace para llenar planillas — se hace para que el próximo incidente sea más corto que el anterior.

SEV-1

Sistema no operativo

Caída total o impacto en todas las empresas. Bloquea operación.

Ejemplo: DB caída, error rate > 10%, login no funciona globalmente.

SEV-2

Función crítica caída

Una capacidad clave no opera. El resto del sistema funciona.

Ejemplo: Score no recalcula, reportes no se generan, motor stuck.

SEV-3

Degradación parcial

Funciona con problema acotado. UX deteriorada pero no bloqueante.

Ejemplo: Email provider falla intermitente, IA caída con fallback activo.

SEV-4

Bug menor

No bloquea operación, sin urgencia.

Ejemplo: UI muestra métrica mal, link roto, tooltip incorrecto.

11.1 · Tabla ops.incidents

▸ SQL · V107 — ops.incidents
CREATE TABLE IF NOT EXISTS ops.incidents (
  incident_id uuid PRIMARY KEY DEFAULT gen_random_uuid(),

  severity text NOT NULL CHECK (
    severity IN ('SEV-1', 'SEV-2', 'SEV-3', 'SEV-4')
  ),

  status text NOT NULL DEFAULT 'open' CHECK (
    status IN ('open', 'investigating', 'mitigated', 'resolved', 'closed')
  ),

  title text NOT NULL,
  description text NOT NULL,

  impact_summary text NULL,
  root_cause text NULL,
  resolution_summary text NULL,

  started_at timestamptz NOT NULL DEFAULT now(),
  detected_at timestamptz NOT NULL DEFAULT now(),
  mitigated_at timestamptz NULL,
  resolved_at timestamptz NULL,
  closed_at timestamptz NULL,

  owner_user_id uuid NULL,
  related_alert_ids uuid[] NOT NULL DEFAULT ARRAY[]::uuid[],

  metadata jsonb NOT NULL DEFAULT '{}'::jsonb,

  created_at timestamptz NOT NULL DEFAULT now(),
  updated_at timestamptz NOT NULL DEFAULT now()
);

CREATE INDEX IF NOT EXISTS idx_incidents_status_time
ON ops.incidents(status, started_at DESC);

CREATE INDEX IF NOT EXISTS idx_incidents_severity_open
ON ops.incidents(severity)
WHERE status IN ('open', 'investigating', 'mitigated');

11.2 · Postmortem MVP

Para SEV-1 y SEV-2 es obligatorio dentro de las 72 h del cierre. Sin postmortem, el incidente no se considera cerrado. Sin culpables, sin show — un documento corto, honesto, con acciones concretas.

Template de postmortem (10 puntos obligatorios): qué pasó · cuándo empezó · cuándo se detectó · impacto cuantificado · causa raíz · qué se hizo (timeline) · qué se aprendió · acciones preventivas concretas con dueño · responsable del postmortem · fecha compromiso de cierre de las acciones. No es para buscar culpables. Es para que no vuelva a pasar. A veces el culpable es "no había proceso" — y la acción es escribir el proceso.

11.3 · On-call rotation

MVP: rotación semanal sobre un grupo acotado (mínimo 2 personas para evitar burnout). Definición clara de "qué despierta": SEV-1 siempre, SEV-2 en horario laboral o si afecta a múltiples empresas, SEV-3 y SEV-4 nunca despiertan — entran al backlog técnico.

Severidad Despierta on-call Canal alerta primario Tiempo respuesta target
SEV-1Sí · 24/7PagerDuty + WhatsApp + email< 15 min
SEV-2Solo horario hábil (07-22 hora empresa)PagerDuty + email< 1 h
SEV-3NoEmail + Slack< 24 h
SEV-4NoSlack / issue trackerSprint siguiente
12 · Feature flags por release

Tabla ops.feature_flags + evaluation engine

Activar funcionalidades por empresa o ambiente sin redeploy. Permite rollout gradual, kill switch ante problemas y experimentación controlada. Toda nueva funcionalidad relevante entra detrás de un flag.

12.1 · Flags canónicos del MVP

flag_code Default Uso
ai_enabledtrueActiva/desactiva explicaciones IA por empresa
ai_provider_secondaryfalseCambia a provider IA secundario (failover)
ai_throttle_modefalseBaja concurrencia al gateway IA ante rate limit
score_v2_enabledfalseActiva Score model v2 (rollout por empresa)
weekly_report_enabledtrueGenera reporte semanal automático
evidence_strict_validation_enabledtrueAplica validación estricta de evidencia
new_workflow_engine_enabledfalseMigración gradual al nuevo workflow engine
engine_safe_modefalseMotor solo reglas críticas (TNS-001..010) · ante incidente
engine_skip_companyBypass de empresa específica · solo para mitigación

12.2 · Tabla ops.feature_flags

▸ SQL · V108 — ops.feature_flags
CREATE TABLE IF NOT EXISTS ops.feature_flags (
  feature_flag_id uuid PRIMARY KEY DEFAULT gen_random_uuid(),

  flag_code text NOT NULL,
  company_id uuid NULL,   -- null = global

  enabled boolean NOT NULL DEFAULT false,
  config jsonb NOT NULL DEFAULT '{}'::jsonb,

  rollout_percentage integer NULL CHECK (
    rollout_percentage IS NULL OR (rollout_percentage BETWEEN 0 AND 100)
  ),

  description text NULL,
  created_by uuid NULL,

  created_at timestamptz NOT NULL DEFAULT now(),
  updated_at timestamptz NOT NULL DEFAULT now(),

  UNIQUE(flag_code, company_id)
);

CREATE INDEX IF NOT EXISTS idx_feature_flags_lookup
ON ops.feature_flags(flag_code, company_id);

12.3 · Evaluation engine

Resolución determinística: primero busca flag específico de la empresa, después el global, si no existe devuelve el default. rollout_percentage evalúa hash(company_id) % 100 contra el threshold.

▸ TypeScript · src/ops/featureFlags.ts
export async function isFeatureEnabled(params: {
  client: any;
  flagCode: string;
  companyId?: string | null;
  defaultValue?: boolean;
}): Promise<boolean> {
  const rows = await params.client.query(
    `
    SELECT enabled, rollout_percentage
    FROM ops.feature_flags
    WHERE flag_code = $1
      AND (company_id = $2 OR company_id IS NULL)
    ORDER BY company_id NULLS LAST
    LIMIT 1
    `,
    [params.flagCode, params.companyId ?? null]
  );

  if (!rows.rows[0]) return params.defaultValue ?? false;

  const row = rows.rows[0];

  if (!row.enabled) return false;

  // Si hay rollout parcial, hash determinista por company
  if (row.rollout_percentage != null && params.companyId) {
    const bucket = hashToBucket(params.companyId);
    return bucket < row.rollout_percentage;
  }

  return true;
}

function hashToBucket(companyId: string): number {
  let hash = 0;
  for (let i = 0; i < companyId.length; i++) {
    hash = ((hash << 5) - hash + companyId.charCodeAt(i)) | 0;
  }
  return Math.abs(hash) % 100;
}

Regla de oro: todo flag que no se evalúa en 90 días se debe limpiar (o de la tabla, o del código). Los flags zombi son deuda técnica disfrazada de configurabilidad.

13 · Migrations + deploy + zona horaria

Versionado, ambientes, deploy seguro y timezone por empresa

Toda migración versionada, reversible cuando se puede, registrada y nunca destructiva sin backup verificado. Cuatro ambientes mínimos. Una regla brutal: nunca probar migraciones por primera vez en producción. Sí, suena obvio. También es obvio no mezclar caja personal con caja empresa y acá estamos.

13.1 · Principios de migración

  • Versionada: formato VNNN__nombre_descriptivo.sql. Numeración secuencial. Sin saltos.
  • Reversible cuando es posible: agregar columna NULL es reversible; transformar datos lo es a medias; DROP COLUMN no lo es. Documentar el rollback.
  • Sin cambios manuales en producción sin registro. Si hubo que tocar a mano, se documenta y se promueve a migración versionada.
  • Backup verificado antes de migración destructiva. No el último — el verificado en restore drill reciente.
  • Schema changes separadas de data migrations pesadas. Schema rápido y atomic; data migration con batching y resumable.
  • Idempotencia: usar CREATE TABLE IF NOT EXISTS, CREATE INDEX IF NOT EXISTS, INSERT ... ON CONFLICT DO NOTHING.

13.2 · Tipos de migración

Tipo Ejemplo Reversible Requiere backup
SchemaCrear tabla, agregar columna NULLNo (si es aditiva)
SeedInsertar catálogo (jobs, tensiones, roles)Sí (delete by code)No
PatchAgregar índice, agregar check constraintNo
Data migrationTransformar registros existentesA medias
RLS migrationAgregar policies de seguridadNo
Index migrationCrear índice (usar CONCURRENTLY si volumen alto)No
CleanupDeprecar tabla/columna obsoletaNo (típicamente)

13.3 · Ambientes mínimos

Ambiente Propósito Datos Promote desde
LocalDesarrollo individualFixtures + demo dataset
DevIntegración técnica de features en cursoFixtures + datos sintéticosmain branch
StagingPrueba pre-producción · validación QASnapshot anonimizado de prodrelease branch
ProductionClientes reales (Empresa Demo Cuyo S.A. y resto)Datos realesTag versión validada en staging

13.4 · Zona horaria por empresa

Toda timestamp se guarda en UTC (timestamptz) en la base. Toda presentación al usuario y todo cron de jobs scheduled se evalúa en la timezone de la empresa.

  • Default global: America/Argentina/Mendoza.
  • Configurable por empresa en faro.companies.timezone.
  • El reporte semanal de Empresa Demo Cuyo S.A. se genera Lunes 07:00 hora Mendoza, no UTC.
  • Los period de KPIs (day, week, month) se calculan sobre la timezone de la empresa, no la del servidor.
  • Logs y métricas Prometheus quedan en UTC. La UI traduce a tz de empresa para mostrar.
▸ SQL · ejemplo period en tz empresa
-- KPI diario de Empresa Demo Cuyo S.A.
SELECT
  c.company_id,
  c.timezone,
  date_trunc('day', sale_at AT TIME ZONE c.timezone) AS period_day,
  SUM(net_amount) AS net_sales
FROM faro.sales s
JOIN faro.companies c ON c.company_id = s.company_id
WHERE c.company_id = '...empresa-demo-cuyo'
  AND sale_at AT TIME ZONE c.timezone >= now() AT TIME ZONE c.timezone - interval '30 days'
GROUP BY 1, 2, 3
ORDER BY period_day DESC;
14 · Aceptación + rechazo + riesgos + roadmap

Cuándo se acepta, cuándo se rechaza y qué viene después

Criterios duros para cerrar FARO-OPS-001, motivos de rechazo, riesgos identificados con mitigación y el roadmap de implementación en seis fases. Sin estos chequeos, el módulo no se considera entregado.

14.1 · Criterios de aceptación funcional

CriterioEsperado
Existe catálogo de 13 jobs MVP en ops.jobs
Se registran job runs en ops.job_runs
Jobs tienen status con CHECK constraint
Jobs tienen reintentos con backoff exponencial
Jobs tienen DLQ explícita (ops.dead_letter_queue)
Jobs tienen dedupe_key con unique index parcial
Jobs P1 críticos generan alerta automática en DLQ
Health check live + ready + deep funcionando
Errores se registran en ops.error_events
Logs estructurados con request_id propagado
Métricas Prometheus expuestas en /metrics
5 runbooks operativos documentados y accesibles
Backups verificados con restore drill mensual
Incidentes registrados con tabla ops.incidents
Feature flags con evaluation engine
UI OPS básica disponible en /faro/ops/*

14.2 · Criterios de rechazo

Cualquiera de estos bloquea la entrega del módulo.

CasoSeveridad
Jobs fallan sin registro en ops.job_runsCrítica
Jobs duplican tensiones o acciones (sin dedupe)Crítica
No hay dead letter queueAlta
No hay política de reintentos con backoffAlta
No hay health check de ningún tipoAlta
No hay error tracking centralizadoAlta
No hay verificación de backupsAlta
No hay logs estructurados (solo texto plano)Media/Alta
No hay runbooks documentadosMedia/Alta
JOB-REPORT-001 falla en producción y nadie se enteraCrítica
JOB-SCORE-001 falla y no queda evento ni alertaAlta
JOB-NOTIF-001 falla silenciosamenteAlta

14.3 · Riesgos y mitigaciones

RiesgoMitigación
Jobs duplicadosdedupe_key + unique index parcial + constraints en tablas destino
Workers paralelos pisan mismos datosops.job_locks + SELECT ... FOR UPDATE en operaciones críticas
Errores silenciososops.error_events + alertas técnicas obligatorias por categoría
Reintentos infinitosmax_attempts con default 3 · DLQ obligatoria
Queue atrasada sin diagnósticoMétrica queue_depth con threshold de alerta
Reportes no generadosAlerta dedicada en JOB-REPORT-001 · destinatario técnico + GG
Score desactualizadoJOB-SCORE-001 + health check específico + alerta si pasa > 24h sin snapshot
Backups falsos / corruptosRestore drill mensual + alerta si last_restore_test_at > 45d
AI cost descontroladoMétricas diarias + budget hard cap por empresa + fallback automático
Logs con datos sensiblesRedacción automática de fields conocidos (email, telefono, CUIT)
Operación manual caótica5 runbooks documentados · actualización obligatoria en postmortems
Migración rompe producciónBackup verificado + smoke en staging + rollback documentado

14.4 · Roadmap de implementación · 6 fases

Fase 1 · Base OPS

Schema mínimo

ops schemaops.jobsops.job_runsops.job_locksops.error_events
Fase 2 · Worker + scheduler

Runtime ejecutivo

jobRegistryrunJobhandleJobFailureretryDLQscheduler
Fase 3 · Health + errores

Observabilidad mínima

/health/live/health/ready/api/health/deephealth_snapshotserror_eventsalertas técnicas
Fase 4 · Jobs críticos

Pipeline operable

workflow checkerscore recalcnotificationsweekly reportbackup checker
Fase 5 · UI OPS

Consola técnica

healthjob runsDLQerrorsbackupsincidentsfeature flags
Fase 6 · Externalización

Observabilidad externa

SentryOpenTelemetryGrafanalogs centralizadosalerting

14.5 · Próximo paso

  1. FARO-QA-001 · Testing, Calidad, CI/CD y Validación MVP. Definir la estrategia completa de calidad: unit tests, integration tests, SQL tests, RLS tests, API tests, UI tests, E2E tests, dataset demo, CI pipeline, quality gates y regression suite. Ver pieza pendiente qa-estrategia-mvp.html.
  2. FARO-DEPLOY-001 · Ambientes y release management. Detallar promociones local → dev → staging → production, blue/green, smoke automático post-deploy, rollback automatizado. Ver pieza pendiente deploy-ambientes-mvp.html.
  3. FARO-GOV-001 · Patch de permisos OPS. Agregar permisos ops:health:read, ops:jobs:run, ops:jobs:retry, ops:errors:resolve, ops:incidents:manage, ops:backups:read, ops:feature_flags:manage a la matriz de roles MVP.
  4. FARO-UI-OPS-001 · Pantallas operativas. Diseñar y construir /faro/ops/health, /faro/ops/jobs, /faro/ops/job-runs, /faro/ops/dead-letter, /faro/ops/errors, /faro/ops/backups, /faro/ops/incidents, /faro/ops/feature-flags.
XR · Cross-references

Dónde se cruza FARO-OPS-001 con el resto del pack

El módulo OPS no vive solo. Estos son los puntos donde se consume, se valida o se complementa con otras piezas del pack NDA.

DEPENDE PENDIENTE
gobierno-seguridad-mvp.html

Sistema de roles, permisos, RLS y audit. Los permisos ops:* y el contexto de empresa para workers se definen ahí.

CRUZA
frecuencia-sincronizacion.html

Mapeo de frecuencias por fuente de datos. Define el cron exacto de JOB-ING, JOB-DQ, JOB-NORM, JOB-KPI.

CRUZA
pipeline-interactivo.html

Las 25 etapas del pipeline ETL → motor → Score → reportes. Los jobs MVP son los runtime de esas etapas.

REPORTA
estado-implementacion.html

Matriz visual del avance MVP. FARO-OPS-001 actualiza el estado de los 13 jobs y del schema ops.

IMPLEMENTA
modelo-sql.html

DDL consolidado del sistema. Incluye las 8 tablas del schema ops y todas las migraciones V097..V108.

COMPLEMENTA PENDIENTE
deploy-ambientes-mvp.html

Ambientes, deploy, release management, blue/green, rollback. Cierra el contrato de operación end-to-end.

COMPLEMENTA PENDIENTE
qa-estrategia-mvp.html

CI/CD, quality gates, regression suite, dataset demo. Asegura que cada cambio no rompa la operación definida acá.

CONSUMIDO POR
workflow-escalamiento-mvp.html

El workflow checker corre como JOB-WF-001. Las reglas de escalamiento se evalúan en cada tick del job.

CONSUMIDO POR
alertas-notificaciones-mvp.html

El dispatcher de notificaciones corre como JOB-NOTIF-001. Acá se definen plantillas, canales y dedupe de alertas.

CONSUMIDO POR
motor-score-mvp.html

El recálculo de Score corre como JOB-SCORE-001, con lock para evitar dos corridas paralelas por empresa.

CONSUMIDO POR
reporte-semanal-mvp-spec.html

El generador de reporte semanal corre como JOB-REPORT-001 los Lunes 07:00 hora empresa.

CRUZA
seguridad-rls-mvp.html

El runner setea app.company_id antes de cada job para que RLS aplique correctamente en queries del handler.