01 · Resumen ejecutivo

La acción es la unidad mínima de ejecución FARO

Esta UI convierte a FARO Connect en sistema de ejecución, no solo de diagnóstico. Una tensión dice hay un problema; una acción dice esto hay que hacer; el workflow dice quién lo hace, antes de cuándo, con qué prueba y quién lo valida. Sin este flujo, FARO queda elegante pero flojo de control, y la elegancia sin control es PowerPoint caro.

FARO-UI-003 es la pantalla donde una acción se ejecuta de punta a punta. Convierte una acción en una operación trazable: el responsable la toma, carga evidencia, envía a revisión, el aprobador valida o rechaza, el gerente cierra y el sistema impacta el FARO Score. Cada paso queda registrado en execution_events con actor, transición y payload.

La pieza descansa sobre seis decisiones canónicas:

  • 10 estados oficiales con matriz de transiciones permitidas auditable contra workflow-escalamiento-mvp.html (WF-001). Cualquier transición fuera del set se rechaza con INVALID_STATUS_TRANSITION.
  • Una acción no se cierra sin evidencia aprobada. Regla dura. El único atajo es EVD-009 · Cierre manual justificado, y queda auditado con menor nivel de confianza.
  • Una tabla única de eventos: execution_events (D3 aplicado). 40 event_types con CHECK constraint cubren acción + evidencia + escalamiento + extensión + decisión. action_events queda como vista filtrada, no como tabla paralela.
  • El escalamiento es evento, no estado (D4 aplicado). escalated no entra al enum de estados para no inflar el workflow; vive como event_type = 'action_escalated'.
  • Backend manda, UI acompaña. Cada comando valida sesión, seteá RLS, bloquea fila con FOR UPDATE, valida transición, ejecuta, registra evento y commitea. La UI puede ocultar botones, pero el backend decide.
  • Fuente de verdad de cierre: faro.can_close_action(p_action_id). Una sola función PL/pgSQL devuelve (can_close boolean, blocking_reasons text[]) y la consumen frontend, backend y tests por igual.

Esta pieza es crítica porque es donde el modelo FARO toca la realidad operativa: si esta UI deja cerrar acciones sin evidencia, todo el sistema pierde sentido. El motor evaluador puede detectar tensiones perfectas, los catálogos pueden estar inmaculados, los reportes pueden verse premium, pero si una acción crítica se cierra con "ya está hecho, confiá", FARO se convierte en PowerPoint caro.

02 · Tesis del producto

Ver → iniciar → evidencia → revisión → cierre → Score

El ciclo de vida de una acción FARO es un flujo lineal de siete etapas. Cada una tiene actor, evento y precondiciones. Cada una deja huella en execution_events. El cierre solo es válido si todas las precondiciones están cumplidas y la evidencia está aprobada.

01
Ver acción
02
Iniciar
03
Cargar evidencia
04
Enviar a revisión
05
Aprobar / Rechazar
06
Cerrar acción
07
Impacto Score

Regla central. Una acción FARO no está terminada porque alguien la marque como hecha. Está terminada cuando cumple su criterio de cierre y tiene evidencia válida. La diferencia entre marcada como hecha y terminada es exactamente la diferencia entre una app de tareas y un sistema de dirección.

Qué debe lograr la pantalla en cada etapa

  • Ver acción. El responsable entiende qué debe hacer, por qué existe, qué tensión la originó, cuál es la evidencia que se va a pedir y qué Score puede recuperar.
  • Iniciar. Pasa de new a in_progress. Se registra action_started con actor, timestamp y transición.
  • Cargar evidencia. El responsable sube documento, captura, comentario, comprobante o validación contra el catálogo de evidencias (EVD-NNN). Se registra evidence_uploaded.
  • Enviar a revisión. La acción pasa a in_review. El aprobador recibe notificación.
  • Aprobar / rechazar. El aprobador (rol con permiso) decide. Si aprueba, la evidencia queda approved y la acción habilita cierre. Si rechaza, evidencia rejected y acción vuelve a waiting_evidence.
  • Cerrar acción. Solo si can_close_action() devuelve true. Sin evidencia aprobada, el botón cerrar está deshabilitado y muestra blocking_reasons.
  • Impacto Score. Al cerrar, se calcula la recuperación contra expected_score_recovery_min/max y se refleja en FARO Score.

Condiciones que toda acción debe cumplir

Cualquier acción presentada en esta UI tiene seis atributos no negociables, según FARO-UI-003 §2:

CondiciónSignificado operativo
ComprensibleEl responsable entiende qué debe hacer y por qué.
EjecutableTiene pasos accionables y un criterio de cierre escrito.
TrazableTiene eventos y cambios registrados en execution_events.
ValidableTiene evidencia requerida tipificada del catálogo EVD.
AuditablePuede revisarse después con trazabilidad completa.
MedibleTiene impacto Score esperado declarado.

Diferencia con la bandeja del responsable

FARO-UI-002 (dashboard responsable) responde «¿qué tengo pendiente?». FARO-UI-003 responde «¿cómo cierro correctamente esta acción?». La primera es lista; la segunda es ejecución.

03 · Estados y transiciones

10 estados oficiales · matriz de 14 transiciones permitidas

Los 10 estados son la verdad oficial del workflow FARO. Coinciden 1:1 con workflow-escalamiento-mvp.html (WF-001). Cualquier transición fuera de la matriz es rechazada por el backend con INVALID_STATUS_TRANSITION. Escalated es evento, no estado.

new

Nueva

Acción creada por el motor FARO o por intervención manual. Todavía no la tomó nadie.

Botón: Iniciar acción
in_progress

En ejecución

El responsable inició la ejecución. Está trabajando en la acción y puede cargar evidencia o solicitar extensión.

Botón: Cargar evidencia
waiting_evidence

Esperando evidencia

La acción necesita evidencia para avanzar. Bloqueada hasta que se cargue la documentación requerida.

Botón: Cargar evidencia
in_review

En revisión

Evidencia enviada al aprobador. Espera decisión: aprobar, rechazar o pedir más información.

Botón: Ver revisión
approved

Aprobada

Evidencia aprobada, lista para cierre. can_close_action() devuelve true. Habilita botón cerrar.

Botón: Cerrar acción
closed

Cerrada

Acción cerrada correctamente con evidencia aprobada. Genera evento action_closed e impacta FARO Score.

Botón: Ver cierre
blocked

Bloqueada

Imposibilidad de avance informada por el responsable. Requiere desbloqueo, escalamiento o cancelación.

Botón: Ver bloqueo / Escalar
expired

Vencida

Pasó la due_date sin cierre. Disparada por job batch (24h). Aún puede regularizarse, bloquearse o escalarse.

Botón: Regularizar
rejected

Rechazada

Acción o evidencia rechazada por el aprobador. Si era evidencia, la acción puede volver a waiting_evidence.

Botón: Ver motivo
cancelled

Cancelada

Acción cancelada con justificación por Gerente o Director. No impacta Score, queda auditada.

Botón: Ver cancelación

Matriz de transiciones permitidas

14 transiciones canónicas con actor permitido. Coincide con workflow-escalamiento-mvp.html (WF-001).

# Desde Hacia Actor permitido event_type emitido
T-01newin_progressresponsibleaction_started
T-02newblockedresponsibleaction_blocked
T-03newcancelledgeneral_manager · directoraction_cancelled
T-04in_progresswaiting_evidenceresponsible · systemstatus_changed
T-05in_progressblockedresponsibleaction_blocked
T-06waiting_evidencein_reviewresponsiblesubmitted_to_review
T-07in_reviewapprovedapproverevidence_approved
T-08in_reviewrejectedapproverevidence_rejected
T-09rejectedwaiting_evidenceresponsiblestatus_changed
T-10approvedclosedgeneral_manager · approveraction_closed
T-11blockedin_progressgeneral_manager · responsibleaction_unblocked
T-12expiredin_progressgeneral_manager · responsiblestatus_changed
T-13expiredblockedresponsibleaction_blocked
T-14expiredin_reviewgeneral_manager · directoraction_escalated + submitted_to_review

D4 aplicado. «Escalated» no es estado; es evento. La acción permanece en blocked o in_review y se registra action_escalated con payload { escalated_to_role, reason }. Esta decisión evita inflar el enum de estados (que ya tiene 10) y separa el qué (estado del workflow) del cómo (eventos de gestión).

04 · Regla dura de cierre

Sin evidencia aprobada no se cierra · EVD-009 es la única excepción

La regla más importante de toda la pieza: una acción se cierra solo cuando cumple su criterio y tiene evidencia válida. Esta regla la consume frontend, backend, motor evaluador, tests y reportes. Es la diferencia entre «FARO sistema de ejecución» y «FARO sistema de tareas pintado con paleta premium».

Las 7 condiciones que valida can_close_action()

Una acción puede cerrarse solamente si las siete condiciones son verdaderas. Si alguna falla, el botón cerrar permanece deshabilitado y la UI muestra el array blocking_reasons retornado por la función.

#CondiciónValidación
C-01Tiene criterio de cierre definidoactions.closure_criteria IS NOT NULL y no vacío.
C-02Tiene evidencia requerida cargadaCada evidence_required_codes[] tiene al menos una evidencia submitted.
C-03La evidencia existe en catálogoCada evidence_code referenciado está en evidence_definitions.
C-04La evidencia está approvedPara cada requerida, hay al menos una evidencia con status='approved'.
C-05Si priority='critical', validación fuerteAprobador con rol general_manager o superior.
C-06No está bloqueadaactions.status <> 'blocked'.
C-07Usuario tiene permiso para cerrarRLS + check de rol: aprobador autorizado, gerente o director.

Excepción auditada: EVD-009 · Cierre manual justificado

Hay un único atajo. Cuando dirección decide cerrar una acción sin evidencia formal (por ejemplo, una acción organizativa cuya evidencia "es" el resultado del próximo cierre mensual), se carga EVD-009. Es una evidencia tipificada del catálogo que:

  • Solo puede cargarla un rol senior (general_manager o director).
  • Requiere texto justificativo obligatorio (no se acepta «ok cerrado»).
  • Queda con trust_level='low' y confidence_flag='manual_override' en el registro.
  • Genera evento action_closed_with_override en execution_events con payload { justification, override_role }.
  • Aparece marcada con badge ámbar en reportes ejecutivos y en el timeline de auditoría.

Regla dura MVP. Acción sin evidencia aprobada no se cierra. Si alguien quiere cerrarla igual, debe usar EVD-009 · Cierre manual justificado, y eso debe quedar auditado con menor confianza. No hay tercer camino. No hay flag force=true escondido. No hay endpoint admin que salte la validación.

Por qué esta regla no se negocia

Sin esta regla, FARO se convierte en una app de tareas con paleta sobria. Los responsables aprenden a marcar acciones como cerradas para limpiar su bandeja. Los reportes empiezan a mostrar «el 85 % de las acciones críticas están cerradas» aunque nada haya pasado. El motor Score recupera puntos basados en cierres ficticios. Las decisiones de dirección se toman sobre una realidad ajustada.

La regla dura es el contrato entre FARO y la dirección de la empresa: cuando FARO dice «cerrado», hubo evidencia revisada y aprobada. Esa es la diferencia operativa que justifica el producto.

05 · Layout

Desktop con sidebar lateral · mobile con botón principal contextual

Dos layouts simétricos: en desktop, columna principal con ejecución y columna lateral con contexto. En mobile, todo apilado y el botón principal cambia según estado para no obligar al usuario a buscar la acción correcta.

Desktop (≥ 1024 px)
+------------------------------------------------------------+
| HEADER ACCIÓN                                              |
| ACT-COM-001 · Revisar política de descuentos         |
| Estado · Prioridad · Responsable · Vence |
+------------------------------+-----------------------------+
| COLUMNA PRINCIPAL            | COLUMNA LATERAL             |
|                              |                             |
| Propósito ejecutivo          | Tensión asociada            |
| Criterio de cierre           | Evidencia requerida         |
| Evidencias cargadas          | Aprobador                   |
| Timeline                     | Score recovery              |
|                              | Riesgos / bloqueos          |
+------------------------------+-----------------------------+
| FOOTER STICKY                                              |
| Iniciar · Cargar evidencia · Revisión · Cerrar |
+------------------------------------------------------------+
Mobile (< 768 px)
+--------------------------------+
| HEADER COMPACTO                |
| ACT-COM-001                    |
| Estado + Prioridad + Vence     |
+--------------------------------+
| Propósito (colapsable)         |
| Criterio de cierre             |
| Tensión asociada               |
| Evidencia requerida            |
| Evidencias cargadas            |
+--------------------------------+
| TIMELINE COLAPSABLE            |
+--------------------------------+
| BOTÓN PRINCIPAL CONTEXTUAL     |
| [Iniciar | Cargar | Cerrar]    |
| Acciones secundarias (kebab)   |
+--------------------------------+

Botón principal contextual por estado (mobile)

En mobile no hay espacio para mostrar todos los comandos. La UI elige el botón principal según el estado actual de la acción.

EstadoBotón principalAcciones secundarias
newIniciar acciónBloquear · Cancelar
in_progressCargar evidenciaBloquear · Escalar · Extensión
waiting_evidenceCargar evidenciaBloquear · Escalar
in_reviewVer revisiónComentar
approvedCerrar acciónComentar
blockedVer bloqueo / EscalarDesbloquear · Cancelar
expiredRegularizarExtensión · Bloquear · Escalar
rejectedVer motivoVolver a cargar evidencia
closedVer cierreVer timeline · Ver impacto Score
cancelledVer cancelaciónVer timeline

Diseño visual. Fondo arena claro, header card blanca translúcida, pills sobrias, stepper azul petróleo, evidencia faltante ámbar sobrio, bloqueo rojo tenue, cierre aprobado verde sobrio, Score recovery azul petróleo + número destacado, footer sticky blanco blur, timeline con línea fina y puntos sobrios. Nada de confeti visual cuando se cierra una acción: esto no es una app de hábitos, es dirección empresaria.

06 · Data contract

Response JSON canónico de GET /api/v1/actions/:id

El endpoint detalle es la fuente única que consume la UI. Devuelve la acción, responsable, aprobador, tensión asociada, requerimientos de evidencia, evidencia ya cargada, capacidades del workflow (con blocking_reasons) y el timeline cronológico. La UI no necesita llamar a ningún otro endpoint para renderizar el detalle completo.

▸ GET /api/v1/actions/23000000-0000-0000-0000-000000000001 · 200 OK
{
  "action": {
    "action_id": "23000000-0000-0000-0000-000000000001",
    "action_code": "ACT-COM-001",
    "title": "Revisar política de descuentos",
    "description": "Analizar descuentos aplicados por vendedor, producto, cliente y sucursal.",
    "status": "in_progress",
    "priority": "critical",
    "action_type": "corrective",
    "due_date": "2026-06-06",
    "is_overdue": false,
    "closure_criteria": "Nueva política de descuentos aprobada por dirección y comunicada al equipo comercial.",
    "expected_impact": "Mejora de margen, disciplina comercial y rentabilidad.",
    "expected_score_recovery_min": 3,
    "expected_score_recovery_max": 8,
    "created_at": "2026-05-30T09:03:00-03:00",
    "updated_at": "2026-05-30T11:20:00-03:00"
  },
  "responsible": {
    "user_id": "12000000-0000-0000-0000-000000000002",
    "full_name": "María Fernández",
    "email": "maria@empresa-demo-cuyo.com",
    "role": "commercial_manager"
  },
  "approver": {
    "user_id": "12000000-0000-0000-0000-000000000001",
    "full_name": "Tomás Pombo",
    "email": "direccion@empresa-demo-cuyo.com",
    "role": "general_manager"
  },
  "tension": {
    "tension_id": "22000000-0000-0000-0000-000000000001",
    "tension_code": "TNS-001",
    "title": "Crecimiento no rentable",
    "severity": "critical",
    "priority_score": 92,
    "score_impact": -8.5,
    "business_question": "¿Estamos vendiendo más pero ganando menos?",
    "executive_diagnosis": "La empresa crece en volumen, pero sacrifica rentabilidad."
  },
  "evidence_requirements": [
    {
      "evidence_code": "EVD-007",
      "name": "Cambio de política",
      "description": "Documento, registro o aprobación que acredita un cambio de política.",
      "evidence_type": "document",
      "trust_level": "critical",
      "requires_review": true,
      "can_close_action": true,
      "status": "missing",
      "submitted_count": 0,
      "approved_count": 0
    },
    {
      "evidence_code": "EVD-012",
      "name": "Validación de dirección",
      "description": "Validación formal de dirección registrada en sistema.",
      "evidence_type": "approval",
      "trust_level": "critical",
      "requires_review": false,
      "can_close_action": true,
      "status": "missing",
      "submitted_count": 0,
      "approved_count": 0
    }
  ],
  "submitted_evidence": [],
  "workflow": {
    "can_start": false,
    "can_upload_evidence": true,
    "can_submit_review": false,
    "can_approve": false,
    "can_reject": false,
    "can_close": false,
    "can_block": true,
    "can_escalate": true,
    "can_request_extension": true,
    "blocking_reasons": [
      "Falta EVD-007 · Cambio de política",
      "Falta EVD-012 · Validación de dirección"
    ]
  },
  "timeline": [
    {
      "event_id": "90000000-0000-0000-0000-000000000001",
      "event_type": "action_created",
      "title": "Acción creada por motor FARO",
      "description": "La acción fue creada como recomendación de TNS-001.",
      "actor": { "full_name": "Sistema FARO" },
      "created_at": "2026-05-30T09:03:00-03:00"
    },
    {
      "event_id": "90000000-0000-0000-0000-000000000002",
      "event_type": "action_started",
      "title": "Acción iniciada",
      "description": "El responsable tomó la acción.",
      "actor": { "full_name": "María Fernández" },
      "created_at": "2026-05-30T11:20:00-03:00"
    }
  ]
}

Contrato de campos por bloque

  • action: metadata canónica de la acción. title y description pueden venir del catálogo (action_definitions) si la instancia no los sobreescribió. is_overdue es calculado por la vista.
  • responsible / approver: del JOIN con faro.users. role opcional para que la UI ajuste tono visual.
  • tension: JOIN con faro.tensions y faro.tension_definitions para enriquecer con business_question y executive_diagnosis canónicos.
  • evidence_requirements: agregación por evidence_code de la vista v_action_evidence_detail. Incluye status calculado: missing | submitted | approved | rejected | needs_more_info.
  • submitted_evidence: lista cruda ordenada por submitted_at DESC.
  • workflow: capacidades del usuario actual sobre esta acción. blocking_reasons viene de can_close_action().
  • timeline: lista de eventos de execution_events filtrados por action_id, ordenados ASC.
07 · Vistas SQL

Dos vistas que sostienen toda la lectura del workflow

El detalle de acción no se ensambla desde el route handler con seis SELECT sueltos. Se ensambla desde dos vistas oficiales con JOINs prearmados: v_action_workflow_detail para acción + tensión + responsable + aprobador, y v_action_evidence_detail para evidencia requerida con estado agregado.

7.1 · faro.v_action_workflow_detail

JOIN entre actions, action_definitions, users (responsable y aprobador), tensions y tension_definitions. Calcula is_overdue y trae los campos canónicos enriquecidos para que el route handler arme el response sin más JOINs.

▸ V036__create_v_action_workflow_detail.sql
CREATE OR REPLACE VIEW faro.v_action_workflow_detail AS
SELECT
  a.company_id,
  a.action_id,
  a.action_code,
  ad.name AS catalog_name,
  a.title,
  a.description,
  a.action_type,
  a.status,
  a.priority,
  a.due_date,

  CASE
    WHEN a.status NOT IN ('closed', 'cancelled', 'rejected')
     AND a.due_date < CURRENT_DATE THEN true
    ELSE false
  END AS is_overdue,

  a.responsible_user_id,
  ru.full_name AS responsible_name,
  ru.email AS responsible_email,

  a.approver_user_id,
  au.full_name AS approver_name,
  au.email AS approver_email,

  a.evidence_required,
  a.closure_criteria,
  a.expected_impact,
  a.expected_impact_amount,

  ad.executive_purpose,
  ad.success_metric,
  ad.expected_business_impact,
  ad.expected_score_recovery_min,
  ad.expected_score_recovery_max,
  ad.score_dimension AS action_score_dimension,

  t.tension_id,
  t.tension_code,
  t.title AS tension_title,
  t.description AS tension_description,
  t.severity AS tension_severity,
  t.priority_score AS tension_priority_score,
  t.score_impact AS tension_score_impact,

  td.business_question,
  td.executive_diagnosis,
  td.trigger_logic,
  td.area_code,
  td.module_code,

  a.payload,
  a.created_at,
  a.updated_at

FROM faro.actions a

LEFT JOIN faro.action_definitions ad
  ON ad.action_code = a.action_code
 AND ad.status = 'active'

LEFT JOIN faro.users ru
  ON ru.user_id = a.responsible_user_id

LEFT JOIN faro.users au
  ON au.user_id = a.approver_user_id

LEFT JOIN faro.tensions t
  ON t.tension_id = a.tension_id
 AND t.company_id = a.company_id

LEFT JOIN faro.tension_definitions td
  ON td.tension_code = t.tension_code
 AND td.status = 'active';

-- RLS: la vista hereda RLS de faro.actions vía company_id
-- Index recomendado: faro.actions(company_id, action_id) ya existente

7.2 · faro.v_action_evidence_detail

Por cada evidence_required_codes[] definida en el catálogo de la acción, devuelve una fila con el estado agregado de la evidencia cargada (missing | submitted | approved | rejected). Es la base del checklist visual y del cálculo de can_close_action().

▸ V037__create_v_action_evidence_detail.sql
CREATE OR REPLACE VIEW faro.v_action_evidence_detail AS
SELECT
  a.company_id,
  a.action_id,
  required.evidence_code,

  ed.name,
  ed.description,
  ed.evidence_type,
  ed.trust_level,
  ed.requires_review,
  ed.can_close_action,
  ed.allowed_submission_modes,
  ed.allowed_file_types,
  ed.required_metadata_keys,
  ed.validation_rules,

  COUNT(e.evidence_id) AS submitted_count,
  COUNT(e.evidence_id) FILTER (
    WHERE e.status = 'approved'
  ) AS approved_count,
  COUNT(e.evidence_id) FILTER (
    WHERE e.status = 'rejected'
  ) AS rejected_count,

  CASE
    WHEN COUNT(e.evidence_id) FILTER (WHERE e.status = 'approved') > 0 THEN 'approved'
    WHEN COUNT(e.evidence_id) FILTER (WHERE e.status = 'submitted') > 0 THEN 'submitted'
    WHEN COUNT(e.evidence_id) FILTER (WHERE e.status = 'rejected') > 0 THEN 'rejected'
    ELSE 'missing'
  END AS evidence_status

FROM faro.actions a

LEFT JOIN faro.action_definitions ad
  ON ad.action_code = a.action_code
 AND ad.status = 'active'

CROSS JOIN LATERAL unnest(
  COALESCE(ad.evidence_required_codes, ARRAY[]::text[])
) AS required(evidence_code)

LEFT JOIN faro.evidence_definitions ed
  ON ed.evidence_code = required.evidence_code
 AND ed.status = 'active'

LEFT JOIN faro.evidence e
  ON e.company_id = a.company_id
 AND e.action_id = a.action_id
 AND e.evidence_code = required.evidence_code

GROUP BY
  a.company_id,
  a.action_id,
  required.evidence_code,
  ed.name,
  ed.description,
  ed.evidence_type,
  ed.trust_level,
  ed.requires_review,
  ed.can_close_action,
  ed.allowed_submission_modes,
  ed.allowed_file_types,
  ed.required_metadata_keys,
  ed.validation_rules;

Decisión arquitectónica. Las dos vistas reemplazan código de aplicación. Si una vista necesita un campo nuevo (por ejemplo, last_approver_comment), se agrega ahí y todos los consumidores (UI, motor Score, reportes, exports) lo reciben sin recompilar nada. Las vistas también heredan RLS de faro.actions via company_id, asi que no hay forma de leer una acción de otra empresa.

08 · execution_events

Tabla única de eventos de ejecución · 40 event_types canónicos

Decisión D3 aplicada: una sola tabla execution_events guarda eventos de acción, evidencia, escalamiento, extensión y decisión. No hay action_events y evidence_events en paralelo. action_events queda como vista filtrada por event_category='action'. Este es el lugar oficial donde se define la tabla, no se duplica en otra pieza.

D3 · Una tabla, una verdad. Mantener tablas separadas para eventos de acción y de evidencia obliga a UNIONs en cada timeline y a duplicar índices, RLS y políticas. Una tabla única con event_category + event_type mantiene un solo timeline auditable, una sola política RLS, un solo set de índices y un solo enum mantenido. Las consultas por categoría se cubren con índice parcial.

8.1 · Las 40 categorías de eventos

El CHECK constraint sobre event_type garantiza que no entre nada fuera del catálogo. Agregar un nuevo tipo requiere migración explícita, no se hace en runtime.

Categoríaevent_typeCuándo se emite
Acción (workflow)action_createdMotor crea la acción a partir de una tensión.
action_startedResponsable inicia ejecución (new → in_progress).
status_changedTransición de estado genérica no cubierta por otros eventos.
action_blockedResponsable marca acción como bloqueada con motivo.
action_unblockedGerente o responsable desbloquea acción.
action_escalatedAcción elevada a rol superior (D4: evento, no estado).
action_closedAcción cerrada con evidencia aprobada.
action_closed_with_overrideCierre manual justificado vía EVD-009.
action_cancelledAcción cancelada con justificación por gerente/director.
action_reassignedCambio de responsable o aprobador.
Evidenciaevidence_uploadedResponsable subió un documento, captura o comentario.
submitted_to_reviewEvidencia enviada al aprobador (waiting_evidence → in_review).
evidence_approvedAprobador valida la evidencia.
evidence_rejectedAprobador rechaza con motivo. Evidencia queda como histórico.
evidence_needs_more_infoAprobador pide ampliación al responsable.
evidence_replacedNueva versión de una evidencia previamente rechazada.
evidence_archivedEvidencia retirada por motivos de governance (sin borrar).
evidence_metadata_updatedCambio de metadata sin reemplazar archivo.
Extensiónextension_requestedResponsable pide nueva fecha de vencimiento con motivo.
extension_approvedGerente o director aprueba extensión.
extension_rejectedExtensión rechazada con motivo.
due_date_changedCambio de fecha resultante de extensión aprobada.
extension_expiredSolicitud de extensión vencida sin respuesta.
Escalamientoescalation_triggeredSistema detecta condición de escalamiento automático.
escalation_assignedAcción asignada a rol superior por escalamiento.
escalation_acknowledgedRol escalado confirma recepción.
escalation_resolvedEscalamiento cerrado con decisión.
Comentarios y comunicacióncomment_addedComentario en hilo de acción (visible a involucrados).
internal_note_addedNota interna visible solo para dirección/auditor.
notification_sentNotificación enviada (email/in-app/webhook).
reminder_sentRecordatorio automático de SLA.
mention_addedMención a usuario en comentario.
Decisión e impactodecision_loggedDecisión vinculada a la acción registrada en bitácora.
score_recovery_appliedRecuperación de Score aplicada tras cierre exitoso.
score_recovery_reversedReversión de recuperación por re-evaluación.
kpi_recalculatedKPIs recalculados por cierre de acción.
Auditoría técnicapermission_check_failedIntento de comando sin permiso (queda en log).
invalid_transition_attemptedIntento de transición no permitida por workflow.
integration_syncedSincronización con sistema externo (ERP, CRM).
data_quality_flag_raisedBandera de calidad de dato disparada por el motor.

8.2 · DDL de la tabla

▸ V038__create_execution_events.sql
CREATE TABLE IF NOT EXISTS faro.execution_events (
  execution_event_id uuid PRIMARY KEY DEFAULT gen_random_uuid(),

  company_id uuid NOT NULL,

  -- contexto: al menos uno de action_id, evidence_id, decision_id debe estar
  action_id   uuid NULL,
  evidence_id uuid NULL,
  decision_id uuid NULL,
  tension_id  uuid NULL,

  event_category text NOT NULL CHECK (
    event_category IN (
      'action', 'evidence', 'extension', 'escalation',
      'comment', 'decision', 'audit'
    )
  ),

  event_type text NOT NULL CHECK (
    event_type IN (
      -- action workflow (10)
      'action_created', 'action_started', 'status_changed',
      'action_blocked', 'action_unblocked', 'action_escalated',
      'action_closed', 'action_closed_with_override',
      'action_cancelled', 'action_reassigned',
      -- evidence (8)
      'evidence_uploaded', 'submitted_to_review',
      'evidence_approved', 'evidence_rejected',
      'evidence_needs_more_info', 'evidence_replaced',
      'evidence_archived', 'evidence_metadata_updated',
      -- extension (5)
      'extension_requested', 'extension_approved',
      'extension_rejected', 'due_date_changed',
      'extension_expired',
      -- escalation (4)
      'escalation_triggered', 'escalation_assigned',
      'escalation_acknowledged', 'escalation_resolved',
      -- comment / comms (5)
      'comment_added', 'internal_note_added',
      'notification_sent', 'reminder_sent', 'mention_added',
      -- decision / impact (4)
      'decision_logged', 'score_recovery_applied',
      'score_recovery_reversed', 'kpi_recalculated',
      -- audit (4)
      'permission_check_failed', 'invalid_transition_attempted',
      'integration_synced', 'data_quality_flag_raised'
    )
  ),

  actor_user_id uuid NULL,
  actor_role    text NULL,
  actor_kind    text NOT NULL DEFAULT 'human'
    CHECK (actor_kind IN ('human', 'system', 'integration', 'webhook')),

  from_status text NULL,
  to_status   text NULL,

  title       text NOT NULL,
  description text NULL,

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

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

  CHECK (
    action_id IS NOT NULL
    OR evidence_id IS NOT NULL
    OR decision_id IS NOT NULL
  )
);

CREATE INDEX IF NOT EXISTS idx_exec_events_company_action
  ON faro.execution_events(company_id, action_id, occurred_at DESC)
  WHERE action_id IS NOT NULL;

CREATE INDEX IF NOT EXISTS idx_exec_events_company_evidence
  ON faro.execution_events(company_id, evidence_id, occurred_at DESC)
  WHERE evidence_id IS NOT NULL;

CREATE INDEX IF NOT EXISTS idx_exec_events_category_type
  ON faro.execution_events(event_category, event_type);

CREATE INDEX IF NOT EXISTS idx_exec_events_actor
  ON faro.execution_events(actor_user_id, occurred_at DESC);

-- RLS por company_id
ALTER TABLE faro.execution_events ENABLE ROW LEVEL SECURITY;

CREATE POLICY execution_events_rls
  ON faro.execution_events
  USING (company_id::text = current_setting('app.company_id', true));

-- Vista compatibilidad: action_events queda como filtro
CREATE OR REPLACE VIEW faro.action_events AS
SELECT *
FROM faro.execution_events
WHERE event_category = 'action'
   OR action_id IS NOT NULL;

Compatibilidad sin duplicación. faro.action_events ya no es tabla; es vista sobre execution_events filtrada por action_id IS NOT NULL. Todo código legacy que la consultaba sigue funcionando, pero la verdad única vive en execution_events. Migraciones de datos: si existía una tabla previa, migrar con INSERT INTO faro.execution_events SELECT ... FROM faro.action_events_legacy + DROP TABLE.

09 · can_close_action()

Función SQL que devuelve blocking_reasons

La fuente única de verdad para "¿puedo cerrar esta acción?" es una función PL/pgSQL. Devuelve (can_close boolean, blocking_reasons text[]). La consume el route handler para responder al frontend, el endpoint close para validar antes de actualizar, y los tests para verificar la regla dura. No hay lógica de cierre duplicada en TS o en motor evaluador.

▸ V039__create_can_close_action_function.sql
CREATE OR REPLACE FUNCTION faro.can_close_action(p_action_id uuid)
RETURNS TABLE (
  can_close boolean,
  blocking_reasons text[]
) AS $$
DECLARE
  v_missing_count   integer;
  v_action_status   text;
  v_action_priority text;
  v_closure_criteria text;
  v_evidence_count  integer;
  v_reasons         text[] := ARRAY[]::text[];
BEGIN
  -- 1. Leer estado actual de la acción
  SELECT status, priority, closure_criteria
  INTO v_action_status, v_action_priority, v_closure_criteria
  FROM faro.actions
  WHERE action_id = p_action_id;

  IF v_action_status IS NULL THEN
    RETURN QUERY SELECT false, ARRAY['Acción inexistente']::text[];
    RETURN;
  END IF;

  -- 2. Estados terminales no se reabren
  IF v_action_status IN ('closed', 'cancelled', 'rejected') THEN
    RETURN QUERY SELECT false, ARRAY['La acción ya no está abierta']::text[];
    RETURN;
  END IF;

  -- 3. Bloqueada no se cierra
  IF v_action_status = 'blocked' THEN
    v_reasons := array_append(v_reasons, 'La acción está bloqueada');
  END IF;

  -- 4. Necesita criterio de cierre
  IF v_closure_criteria IS NULL OR trim(v_closure_criteria) = '' THEN
    v_reasons := array_append(v_reasons, 'La acción no tiene criterio de cierre');
  END IF;

  -- 5. Cuenta de evidencia requerida
  SELECT COUNT(*)
  INTO v_evidence_count
  FROM faro.v_action_evidence_detail
  WHERE action_id = p_action_id;

  IF v_evidence_count = 0 THEN
    v_reasons := array_append(v_reasons, 'La acción no tiene evidencia requerida configurada');
  END IF;

  -- 6. Evidencias requeridas con estado distinto de approved
  SELECT COUNT(*)
  INTO v_missing_count
  FROM faro.v_action_evidence_detail
  WHERE action_id = p_action_id
    AND evidence_status <> 'approved';

  IF v_missing_count > 0 THEN
    FOR r IN
      SELECT evidence_code, name
      FROM faro.v_action_evidence_detail
      WHERE action_id = p_action_id
        AND evidence_status <> 'approved'
      ORDER BY evidence_code
    LOOP
      v_reasons := array_append(
        v_reasons,
        format('Falta %s · %s', r.evidence_code, r.name)
      );
    END LOOP;
  END IF;

  -- 7. Si priority='critical', requiere aprobador con rol senior
  IF v_action_priority = 'critical' THEN
    IF NOT EXISTS (
      SELECT 1
      FROM faro.actions a
      JOIN faro.users u ON u.user_id = a.approver_user_id
      WHERE a.action_id = p_action_id
        AND u.role IN ('general_manager', 'director')
    ) THEN
      v_reasons := array_append(
        v_reasons,
        'Acción crítica requiere aprobador general_manager o director'
      );
    END IF;
  END IF;

  RETURN QUERY SELECT cardinality(v_reasons) = 0, v_reasons;
END;
$$ LANGUAGE plpgsql STABLE;

-- Test rápido en la demo:
-- SELECT * FROM faro.can_close_action('23000000-0000-0000-0000-000000000001');
-- Esperado para Empresa Demo Cuyo S.A.:
--   can_close = false
--   blocking_reasons = {'Falta EVD-007 · Cambio de política',
--                       'Falta EVD-012 · Validación de dirección'}

Consumidores de la función

  • Route handler GET: la llama al armar el response, mapea el resultado a workflow.can_close y workflow.blocking_reasons.
  • Endpoint POST close: la llama dentro de la transacción, antes del UPDATE. Si can_close=false responde 409 con los motivos.
  • Componente ActionClosureCriteria: muestra los motivos al usuario para que sepa qué falta.
  • Tests de integración: validan la regla con casos golden (con y sin evidencia).
  • Motor de notificaciones: si los blocking_reasons cambian, dispara recordatorios al responsable.
10 · Endpoints REST

11 endpoints de workflow · lectura + comandos

Un endpoint para leer detalle completo y diez endpoints de comando, uno por transición operativa. Todos validan sesión, seteán RLS, bloquean fila con FOR UPDATE, validan transición, ejecutan UPDATE, insertan evento y commitean. Las firmas son simétricas para que el cliente sea trivial de mantener.

GET /api/v1/actions/:id Detalle completo: acción + tensión + evidencia + workflow + timeline.
PATCH /api/v1/actions/:id/status Cambio de estado genérico (último recurso; preferir comandos específicos).
POST /api/v1/actions/:id/start Iniciar acción (new → in_progress). Emite action_started.
POST /api/v1/actions/:id/evidence Cargar evidencia (multipart o JSON). Emite evidence_uploaded.
POST /api/v1/actions/:id/submit-review Enviar evidencia a revisión. Pasa a in_review.
POST /api/v1/actions/:id/approve Aprobar evidencia. Body: { evidence_id, comment? }.
POST /api/v1/actions/:id/reject Rechazar evidencia. Body: { evidence_id, reason } obligatorio.
POST /api/v1/actions/:id/block Bloquear acción con motivo. Body: { reason, suggested_owner? }.
POST /api/v1/actions/:id/escalate Escalar a rol superior. Body: { escalated_to_role, reason }.
POST /api/v1/actions/:id/request-extension Solicitar nueva fecha. Body: { requested_due_date, reason }.
POST /api/v1/actions/:id/close Cerrar acción. Valida can_close_action(). 409 si falla, 200 si OK.

Códigos de respuesta canónicos

HTTPBodyCuándo
200{ ok: true } o response detalladoComando exitoso.
400{ error, details }Validación fallida (campo faltante, formato incorrecto).
401{ error: "Unauthorized" }Sin sesión válida.
403{ error: "Forbidden" }Sesión OK pero sin permiso para el comando.
404{ error: "Action not found" }Acción no existe en la company_id de la sesión.
409{ error, blocking_reasons[] }Conflicto de estado: INVALID_STATUS_TRANSITION o ACTION_CANNOT_BE_CLOSED.
500{ error }Error inesperado (transacción revertida).
11 · Frontend + RLS

Tipos TypeScript · 16 componentes React · route handler con RLS

Stack canónico: Next.js + React + TypeScript + Tailwind. Tipos en lib/faro/action-workflow.types.ts, componentes en components/action-workflow/*, API client en lib/faro/action-workflow.api.ts, route handlers en app/api/v1/actions/[id]/* con seteo de RLS por sesión.

11.1 · Tipos TS canónicos

▸ lib/faro/action-workflow.types.ts
export type ActionWorkflowStatus =
  | "new"
  | "in_progress"
  | "waiting_evidence"
  | "in_review"
  | "approved"
  | "closed"
  | "blocked"
  | "expired"
  | "rejected"
  | "cancelled";

export type ActionWorkflowPriority =
  | "low"
  | "medium"
  | "high"
  | "critical";

export type EvidenceStatus =
  | "missing"
  | "submitted"
  | "approved"
  | "rejected"
  | "needs_more_info";

export type EvidenceRequirementDetail = {
  evidence_code: string;
  name: string;
  description: string | null;
  evidence_type: string;
  trust_level: "low" | "medium" | "high" | "critical";
  requires_review: boolean;
  can_close_action: boolean;
  status: EvidenceStatus;
  submitted_count: number;
  approved_count: number;
};

export type SubmittedEvidence = {
  evidence_id: string;
  evidence_code: string;
  title: string;
  description: string | null;
  status: "submitted" | "approved" | "rejected" | "needs_more_info";
  submitted_by: string | null;
  submitted_at: string;
  reviewed_by: string | null;
  reviewed_at: string | null;
  review_comment: string | null;
};

export type ActionTimelineEvent = {
  event_id: string;
  event_type: string;
  title: string;
  description: string | null;
  actor: { full_name: string | null };
  created_at: string;
};

export type ActionWorkflowDetailResponse = {
  action: {
    action_id: string;
    action_code: string;
    title: string;
    description: string | null;
    status: ActionWorkflowStatus;
    priority: ActionWorkflowPriority;
    action_type: string;
    due_date: string | null;
    is_overdue: boolean;
    closure_criteria: string | null;
    expected_impact: string | null;
    expected_score_recovery_min: number | null;
    expected_score_recovery_max: number | null;
    created_at: string;
    updated_at: string;
  };
  responsible: {
    user_id: string | null;
    full_name: string | null;
    email: string | null;
    role?: string | null;
  };
  approver: {
    user_id: string | null;
    full_name: string | null;
    email: string | null;
    role?: string | null;
  };
  tension: {
    tension_id: string | null;
    tension_code: string | null;
    title: string | null;
    severity: string | null;
    priority_score: number | null;
    score_impact: number | null;
    business_question: string | null;
    executive_diagnosis: string | null;
  };
  evidence_requirements: EvidenceRequirementDetail[];
  submitted_evidence: SubmittedEvidence[];
  workflow: {
    can_start: boolean;
    can_upload_evidence: boolean;
    can_submit_review: boolean;
    can_approve: boolean;
    can_reject: boolean;
    can_close: boolean;
    can_block: boolean;
    can_escalate: boolean;
    can_request_extension: boolean;
    blocking_reasons: string[];
  };
  timeline: ActionTimelineEvent[];
};

11.2 · 16 componentes React principales

Cada componente tiene una responsabilidad acotada y consume solo el slice que necesita del response.

ActionWorkflowPage
Página contenedora. Fetch + estado de loading + grilla principal.
ActionWorkflowHeader
Header con código, título, estado, prioridad, vencimiento, responsable y aprobador.
ActionStatusStepper
Stepper de 6 etapas (new → closed). Resalta etapa actual.
ActionPurposeBlock
Propósito ejecutivo + bloque «por qué existe esta acción» (diagnóstico de la tensión).
ActionClosureCriteria
Criterio de cierre + lista de blocking_reasons si can_close=false.
LinkedTensionSummary
Card de la tensión origen con código, título, business_question y priority_score.
EvidenceRequirementChecklist
Checklist de EVD-* requeridas con estado, trust_level y flags.
EvidenceSubmittedList
Lista de evidencias cargadas ordenada por fecha desc.
EvidenceUploadPanel
Modal o panel de carga (puente con FARO-UI-004).
ActionReviewPanel
Panel del aprobador: aprobar / rechazar / pedir más info.
ActionBlockPanel
Formulario de bloqueo con motivo y sugerencia de owner alternativo.
ActionEscalationPanel
Formulario de escalamiento con rol destino y motivo.
ActionExtensionPanel
Formulario de solicitud de extensión con nueva fecha y motivo.
ActionTimeline
Línea de tiempo cronológica con dots, título, descripción y actor.
ActionScoreImpact
Card con la recuperación potencial de Score (rango min-max).
ActionWorkflowFooter
Footer sticky con botones contextuales según workflow.*.

11.3 · Página Next.js + componente principal

▸ app/faro/actions/[id]/page.tsx
import { ActionWorkflowPage } from "@/components/action-workflow/ActionWorkflowPage";

export default function Page({ params }: { params: { id: string } }) {
  return <ActionWorkflowPage actionId={params.id} />;
}
▸ components/action-workflow/ActionWorkflowPage.tsx
"use client";

import { useEffect, useState } from "react";
import { getActionWorkflowDetail } from "@/lib/faro/action-workflow.api";
import type { ActionWorkflowDetailResponse } from "@/lib/faro/action-workflow.types";
import { ActionWorkflowHeader } from "./ActionWorkflowHeader";
import { ActionStatusStepper } from "./ActionStatusStepper";
import { ActionPurposeBlock } from "./ActionPurposeBlock";
import { ActionClosureCriteria } from "./ActionClosureCriteria";
import { LinkedTensionSummary } from "./LinkedTensionSummary";
import { EvidenceRequirementChecklist } from "./EvidenceRequirementChecklist";
import { EvidenceSubmittedList } from "./EvidenceSubmittedList";
import { ActionTimeline } from "./ActionTimeline";
import { ActionScoreImpact } from "./ActionScoreImpact";
import { ActionWorkflowFooter } from "./ActionWorkflowFooter";

export function ActionWorkflowPage({ actionId }: { actionId: string }) {
  const [data, setData] = useState<ActionWorkflowDetailResponse | null>(null);
  const [loading, setLoading] = useState(true);

  async function reload() {
    const result = await getActionWorkflowDetail(actionId);
    setData(result);
  }

  useEffect(() => {
    let cancelled = false;
    (async () => {
      setLoading(true);
      try {
        const result = await getActionWorkflowDetail(actionId);
        if (!cancelled) setData(result);
      } finally {
        if (!cancelled) setLoading(false);
      }
    })();
    return () => { cancelled = true; };
  }, [actionId]);

  if (loading && !data) return <LoadingState />;
  if (!data) return <ErrorState />;

  return (
    <main className="min-h-screen bg-[#F7FAFF] px-4 py-5 text-[#071A44] md:px-6 md:py-6">
      <div className="mx-auto flex max-w-7xl flex-col gap-5">
        <ActionWorkflowHeader data={data} />
        <ActionStatusStepper status={data.action.status} />

        <section className="grid gap-5 xl:grid-cols-[1.2fr_.8fr]">
          <div className="space-y-5">
            <ActionPurposeBlock data={data} />
            <ActionClosureCriteria data={data} />
            <EvidenceSubmittedList evidence={data.submitted_evidence} />
            <ActionTimeline events={data.timeline} />
          </div>

          <aside className="space-y-5">
            <LinkedTensionSummary tension={data.tension} />
            <EvidenceRequirementChecklist requirements={data.evidence_requirements} />
            <ActionScoreImpact action={data.action} />
          </aside>
        </section>

        <ActionWorkflowFooter data={data} onChanged={reload} />
      </div>
    </main>
  );
}

11.4 · Route handler con seteo RLS

El endpoint GET ensambla el response a partir de las vistas y la función. Seteá RLS via set_config('app.company_id', ...) al inicio de la transacción.

▸ app/api/v1/actions/[id]/route.ts
import { NextRequest, NextResponse } from "next/server";
import { db } from "@/lib/server/db";
import { getSessionContext } from "@/lib/server/session";

export async function GET(
  _request: NextRequest,
  { params }: { params: { id: string } }
) {
  const session = await getSessionContext();

  if (!session?.companyId || !session?.userId) {
    return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
  }

  const client = await db.connect();

  try {
    await client.query("BEGIN");

    // Setear RLS por sesión
    await client.query(`SELECT set_config('app.company_id', $1, true)`, [session.companyId]);
    await client.query(`SELECT set_config('app.user_id', $1, true)`,    [session.userId]);
    await client.query(`SELECT set_config('app.role_codes', $1, true)`, [session.roleCodes.join(",")]);

    // Detalle desde la vista
    const actionResult = await client.query(
      `SELECT * FROM faro.v_action_workflow_detail
        WHERE company_id = $1 AND action_id = $2 LIMIT 1`,
      [session.companyId, params.id]
    );

    if (actionResult.rows.length === 0) {
      await client.query("ROLLBACK");
      return NextResponse.json({ error: "Action not found" }, { status: 404 });
    }
    const action = actionResult.rows[0];

    const evidenceRequirements = await client.query(
      `SELECT * FROM faro.v_action_evidence_detail
        WHERE company_id = $1 AND action_id = $2 ORDER BY evidence_code`,
      [session.companyId, params.id]
    );

    const submittedEvidence = await client.query(
      `SELECT e.*, su.full_name AS submitted_by_name,
              ru.full_name AS reviewed_by_name
         FROM faro.evidence e
         LEFT JOIN faro.users su ON su.user_id = e.submitted_by
         LEFT JOIN faro.users ru ON ru.user_id = e.reviewed_by
        WHERE e.company_id = $1 AND e.action_id = $2
        ORDER BY e.submitted_at DESC`,
      [session.companyId, params.id]
    );

    const timeline = await client.query(
      `SELECT ee.execution_event_id AS event_id, ee.event_type, ee.title,
              ee.description, u.full_name AS actor_name, ee.occurred_at AS created_at
         FROM faro.execution_events ee
         LEFT JOIN faro.users u ON u.user_id = ee.actor_user_id
        WHERE ee.company_id = $1 AND ee.action_id = $2
        ORDER BY ee.occurred_at ASC`,
      [session.companyId, params.id]
    );

    const closeCheck = await client.query(
      `SELECT * FROM faro.can_close_action($1)`,
      [params.id]
    );

    await client.query("COMMIT");

    const canClose = closeCheck.rows[0]?.can_close ?? false;
    const blockingReasons = closeCheck.rows[0]?.blocking_reasons ?? [];

    return NextResponse.json({
      action: mapAction(action),
      responsible: mapUser(action, "responsible"),
      approver:    mapUser(action, "approver"),
      tension:     mapTension(action),
      evidence_requirements: evidenceRequirements.rows.map(mapEvidenceRequirement),
      submitted_evidence:    submittedEvidence.rows.map(mapSubmittedEvidence),
      workflow: buildWorkflow(action, canClose, blockingReasons, session),
      timeline: timeline.rows.map(mapTimelineEvent)
    });
  } catch (error) {
    await client.query("ROLLBACK");
    return NextResponse.json({ error: "Could not load action detail" }, { status: 500 });
  } finally {
    client.release();
  }
}

function buildWorkflow(action: any, canClose: boolean, blockingReasons: string[], session: any) {
  const isResponsible = action.responsible_user_id === session.userId;
  const isApprover    = action.approver_user_id    === session.userId;
  const isManager = session.roleCodes.includes("general_manager")
                || session.roleCodes.includes("director");

  return {
    can_start: isResponsible && action.status === "new",
    can_upload_evidence: isResponsible &&
      ["new", "in_progress", "waiting_evidence", "expired"].includes(action.status),
    can_submit_review: isResponsible &&
      ["waiting_evidence", "in_progress", "expired"].includes(action.status),
    can_approve: (isApprover || isManager) && action.status === "in_review",
    can_reject:  (isApprover || isManager) && action.status === "in_review",
    can_close:   canClose && (isApprover || isManager),
    can_block:   isResponsible && !["closed", "cancelled", "rejected"].includes(action.status),
    can_escalate: isResponsible || isManager,
    can_request_extension: isResponsible &&
      !["closed", "cancelled", "rejected"].includes(action.status),
    blocking_reasons: blockingReasons
  };
}

Patrón obligatorio para todos los comandos. Cada endpoint POST sigue el mismo esqueleto: BEGIN → setear RLS → SELECT ... FOR UPDATE de la fila → validar estado actual → validar transición permitida → validar permisos → UPDATEINSERT execution_eventsCOMMIT. Sin FOR UPDATE, dos usuarios podrían cerrar la misma acción al mismo tiempo y ahí empieza la fiesta de inconsistencias.

12 · Mockup visual

Detalle de acción + timeline · Empresa Demo Cuyo S.A.

Mockup estático del detalle de acción ACT-COM-001 sobre la tensión TNS-001 «Crecimiento no rentable» en Empresa Demo Cuyo S.A. Acción en estado in_progress, evidencia todavía no cargada, botón cerrar deshabilitado con motivos visibles. Renderizado sin frameworks, solo HTML + CSS del pack.

app.faroconnect.io / faro / actions / 23000000-...001
ACT-COM-001 Critical In progress Demo Cuyo

Revisar política de descuentos

Analizar descuentos aplicados por vendedor, producto, cliente y sucursal. Definir nueva política y comunicarla al equipo comercial.

Responsable
María Fernández
Aprobador
Tomás Pombo
Vence
06 Jun 2026
Recuperación Score
+8
01
Nueva
02
Ejecución
03
Evidencia
04
Revisión
05
Aprobada
06
Cerrada
Propósito ejecutivo
Mejora de margen, disciplina comercial y rentabilidad
Esta acción busca recuperar margen comercial estableciendo una política clara de descuentos. Hoy, el descuento promedio del trimestre subió de 7,2 % a 11,8 % sin política aprobada que lo respalde.
Por qué existe esta acción: la empresa crece en volumen, pero sacrifica rentabilidad. No es crecimiento sano; es crecimiento comprado con margen. (Diagnóstico ejecutivo de TNS-001).
Criterio de cierre
Nueva política de descuentos aprobada y comunicada
Documento de política firmado por dirección y comunicado formalmente al equipo comercial. Sin estos dos elementos, la acción no puede cerrarse.
Todavía no se puede cerrar.
  • Falta EVD-007 · Cambio de política
  • Falta EVD-012 · Validación de dirección
Evidencias cargadas
Sin evidencia cargada todavía
Todavía no hay evidencia para esta acción. Esta acción no puede cerrarse hasta que se cargue y apruebe la evidencia requerida.
Timeline
Historial de eventos (execution_events)
Acción creada por motor FARO
La acción fue creada como recomendación de TNS-001 (Crecimiento no rentable). Severidad disparada: critical.
action_created · Sistema FARO · 30 May 2026 · 09:03
Acción iniciada
El responsable tomó la acción y comenzó la ejecución. Transición new → in_progress.
action_started · María Fernández · 30 May 2026 · 11:20
Pendiente: cargar evidencia EVD-007 y EVD-012
El sistema espera evidencia formal de cambio de política y validación de dirección para habilitar el cierre.
próximos eventos esperados · evidence_uploaded · submitted_to_review

Lectura del mockup. La pantalla muestra la acción ACT-COM-001 en in_progress. Tres elementos visuales comunican la regla dura: (1) el botón «Cerrar acción» aparece pero deshabilitado, (2) la card de criterio de cierre muestra los dos blocking_reasons en ámbar, (3) la columna lateral lista las dos evidencias requeridas como missing con badge coral. El usuario no necesita leer documentación para saber qué le falta.

13 · Aceptación + tests

Criterios de aceptación funcional, técnica y de rechazo

FARO-UI-003 se acepta solo si cumple los tres bloques: funcional (qué tiene que mostrar y permitir), técnico (cómo lo construye), y rechazo (qué condiciones disparan rebuild). Tests mínimos cubren UI, API y la regla dura.

13.1 · Funcional

Qué tiene que mostrar y permitir

  • Muestra detalle completo de la acción (header + cuerpo + lateral).
  • Muestra tensión asociada con código, título y diagnóstico ejecutivo.
  • Muestra criterio de cierre y, si aplica, blocking_reasons.
  • Muestra checklist de evidencia requerida con estado por EVD.
  • Muestra evidencia ya cargada con estado y aprobador/comentario.
  • Muestra timeline cronológico con eventos de execution_events.
  • Muestra recuperación potencial de Score (rango).
  • Permite iniciar, cargar evidencia, enviar a revisión, bloquear, escalar y solicitar extensión.
  • Permite cerrar solo si can_close=true.
  • Es responsive (desktop + mobile).
13.2 · Técnica

Cómo lo construye

  • Consume GET /api/v1/actions/:id contra v_action_workflow_detail.
  • Usa RLS via set_config('app.company_id', ...) en cada transacción.
  • Valida permisos en backend, no solo oculta botones en frontend.
  • Usa SELECT ... FOR UPDATE en cada comando que modifica estado.
  • Registra evento en execution_events por cada cambio.
  • Registra auditoría en audit.audit_log en paralelo.
  • Usa faro.can_close_action() como única fuente de verdad de cierre.
  • No permite cerrar sin evidencia aprobada (excepto EVD-009 auditado).
  • No borra evidencia rechazada; queda con status='rejected'.
  • Soporta los 40 event_type canónicos sin alterar el enum.
13.3 · Rechazo

Qué dispara rebuild conceptual

  • Crítica: permite cerrar sin evidencia aprobada (rebuild inmediato).
  • Crítica: no valida permisos en backend, solo oculta botones.
  • Crítica: borra evidencia rechazada en vez de archivar.
  • Alta: no muestra criterio de cierre.
  • Alta: no muestra tensión asociada.
  • Alta: no registra timeline en execution_events.
  • Alta: no usa FOR UPDATE en endpoint close.
  • Alta: no muestra motivos de bloqueo cuando can_close=false.
  • Alta: no es usable en mobile (botón principal mal ubicado).
  • Media: permite confeti visual al cerrar (no es app de hábitos).

13.4 · Tests mínimos

CapaTestEsperado
UIRender acción newMuestra botón «Iniciar acción» primario.
Render acción in_progressMuestra botón «Cargar evidencia» primario.
Render sin evidencia aprobadaBotón «Cerrar» deshabilitado + lista de motivos.
Render con evidencia aprobadaBotón «Cerrar» habilitado.
Render acción vencidaMuestra pill ámbar «Vencida».
Render timeline vacíoEmpty state «Sin eventos registrados».
APIGET detalle de acción existente200 con estructura completa.
GET de otra company_id404 (RLS filtra).
POST start sobre in_progress409 INVALID_STATUS_TRANSITION.
POST close sin evidencia409 ACTION_CANNOT_BE_CLOSED con blocking_reasons.
POST close con evidencia aprobada200 + evento action_closed en execution_events.
POST escalate sin reason400 «reason is required».
SQLcan_close_action() sin evidenciacan_close=false, blocking_reasons con dos motivos.
can_close_action() con evidencia aprobadacan_close=true, blocking_reasons=[].
INSERT en execution_events con event_type inválidoFalla por CHECK constraint.

13.5 · Test golden: no cerrar sin evidencia

▸ tests/integration/api.action.close.test.ts
import { describe, expect, it } from "vitest";
import { withTestDbContext } from "../src/helpers/dbTestContext";

describe("FARO-UI-003 · Regla dura de cierre", () => {
  it("no cierra acción sin evidencia aprobada", async () => {
    await withTestDbContext(
      {
        companyId: "10000000-0000-0000-0000-000000000001", // Empresa Demo Cuyo S.A.
        userId:    "12000000-0000-0000-0000-000000000001", // Tomás Pombo (general_manager)
        roleCodes: ["general_manager"]
      },
      async (client) => {
        const result = await client.query(
          `SELECT * FROM faro.can_close_action($1)`,
          ["23000000-0000-0000-0000-000000000001"] // ACT-COM-001
        );

        expect(result.rows[0].can_close).toBe(false);
        expect(result.rows[0].blocking_reasons.length).toBeGreaterThan(0);
        expect(result.rows[0].blocking_reasons).toContain("Falta EVD-007 · Cambio de política");
        expect(result.rows[0].blocking_reasons).toContain("Falta EVD-012 · Validación de dirección");
      }
    );
  });

  it("genera evento action_closed al cerrar con evidencia", async () => {
    // 1. Cargar y aprobar EVD-007 y EVD-012 sobre ACT-COM-001
    // 2. POST /api/v1/actions/:id/close
    // 3. Verificar response 200
    // 4. Verificar que existe evento execution_events con event_type='action_closed'
  });
});
+ · Cross-references

Dónde se cruza esta pieza con el resto del pack

FARO-UI-003 no vive sola. Consume catálogos, alimenta dashboards, dispara cambios de Score y coordina con la pieza de workflow y escalamiento.

Próximos pasos del pack UI

  1. FARO-UI-004 · Carga de Evidencia. Construir el flujo específico de selección de tipo EVD, subida de archivo o registro de comentario, validación de metadata y envío a revisión. Esta UI define el workflow; FARO-UI-004 lo hace robusto en la captura.
  2. FARO-UI-005 · Timeline de Auditoría. Vista extendida del timeline embebido aquí, con filtros por event_category, exportación CSV y diff de cambios de estado. Pensada para auditor interno.
  3. FARO-ENG-004 · Motor de notificaciones. Disparador que escucha execution_events y manda email/in-app cuando cambian blocking_reasons, vence un SLA o se escala una acción.
  4. FARO-SCORE-001 · Motor FARO Score. Al recibir action_closed, recalcular Score afectado por la dimensión correspondiente y emitir score_recovery_applied con el delta efectivo.
  5. FARO-TEST-003 · Suite end-to-end de workflow. Playwright + DB seed que recorra el ciclo completo en Empresa Demo Cuyo S.A.: ver → iniciar → cargar → revisar → aprobar → cerrar → verificar Score.