# ADR-003: Stratum-Orchestrator — Orquestador de Flujos Guiado por Grafo **Estado**: Aceptado **Fecha**: 2026-02-20 ## Contexto El ecosistema StratumIOps abarca múltiples proyectos activos (provisioning, kogral, syntaxis, typedialog) que evolucionan concurrentemente con dependencias cruzadas. Cada proyecto dispara flujos de trabajo de build, validación, publicación y notificación en respuesta a cambios en el código. Antes de esta decisión, dichos flujos eran: - Scripts ad-hoc por proyecto sin modelo de ejecución compartido - No auditables — ningún registro duradero de qué paso se ejecutó, en qué orden, con qué resultado - No componibles — un flujo en `provisioning` no podía reaccionar a un evento de `kogral` - No seguros — sin atomicidad, sin rollback en fallo parcial, sin alcance de credenciales - No escalables — añadir un proyecto nuevo implicaba copiar y adaptar scripts, acumulando deriva El requisito central es un **orquestador de flujos agnóstico, dirigido por eventos y multi-proyecto**, capaz de coordinar pipelines de build, tareas de agentes IA y operaciones de infraestructura sin modificarse para cada nuevo proyecto o proveedor. Se evaluaron dos enfoques de diseño: | Enfoque | Descripción | Problema | |---------|-------------|---------| | **Router de reglas estáticas** | Mapear patrones de sujeto NATS a scripts en un fichero de configuración | Las reglas se multiplican con los proyectos; cambiar un flujo exige editar el router | | **Motor guiado por grafo** | Los eventos atraviesan un DAG de nodos de acción declarados en Nickel; el orquestador es agnóstico | El orquestador nunca cambia para nuevos flujos | Se adoptó el enfoque basado en grafo. El orquestador carga definiciones de nodos desde ficheros Nickel, construye un `ActionGraph` en memoria y ejecuta pipelines atravesando el grafo — no sabe qué hace el pipeline, solo cómo coordinarlo. ## Características Fundamentales del Diseño ### 1. Ejecución Guiada por Grafo, no Enrutamiento Estático El orquestador no contiene tablas de enrutamiento. Los patrones de sujeto NATS se confrontan contra un `ActionGraph` construido a partir de definiciones de nodos en Nickel. Cada `ActionNode` declara: - `trigger`: patrones de sujeto NATS que pueden activar el nodo como punto de entrada - `input_schemas`: capacidades requeridas antes de la ejecución - `output_schemas`: capacidades producidas tras la ejecución - `compensate`: script de rollback opcional para atomicidad Saga El grafo se construye recorriendo índices de productor/consumidor de capacidades. El ordenamiento topológico genera un plan de ejecución en etapas. **Añadir un nuevo flujo de trabajo implica añadir ficheros `.ncl` — el binario del orquestador no cambia nunca.** ### 2. Orquestador Sin Estado — DB-First El proceso del orquestador no mantiene estado duradero. Cada escritura en `PipelineContext` va primero a SurrealDB y luego actualiza una caché en memoria (`DashMap`). En caso de caída, una instancia reiniciada reconstruye la caché desde la DB. Esto permite: - **Escala horizontal**: múltiples instancias del orquestador comparten una SurrealDB sin split-brain - **Recuperación ante caídas**: los pipelines reanudan desde la última capacidad persistida, no desde el principio - **Observabilidad**: el estado completo del pipeline es consultable en cualquier momento sin instrumentar el proceso ### 3. Nickel como Única Fuente de Verdad — Sin Verdad Dual Los nodos de acción, los esquemas de capacidad y la configuración de arranque del orquestador se definen en Nickel. No existe ningún proceso de indexación separado ni copia de las definiciones de nodos en base de datos usada en tiempo de ejecución. El `ActionGraph` se construye en memoria desde ficheros `.ncl` al arrancar mediante `nickel export --format json`, y se mantiene vivo mediante un observador de ficheros (`notify`) para recarga en caliente. Esto elimina el problema de verdad dual: el fichero Nickel ES la definición. No existe riesgo de que un registro de base de datos diverja del fichero en disco. ### 4. Modelo de Capacidades — Inversión de Dependencia en el Dominio de Ejecución Los nodos no dependen unos de otros directamente. Declaran las capacidades que producen y consumen. El motor de grafo resuelve las dependencias: ``` lint-crate → produce: linted-code fmt-crate → produce: formatted-code build-crate → consume: linted-code, formatted-code → produce: built-artifact install-crate → consume: built-artifact ``` Esto es Inversión de Dependencia aplicada al dominio de ejecución: `build-crate` no conoce `lint-crate`. Solo sabe que necesita `linted-code`. Cualquier nodo que produzca `linted-code` satisface la dependencia. Los nodos pueden intercambiarse, reemplazarse o paralelizarse sin modificar sus consumidores. ### 5. Tres Planos de Autenticación Independientes Autenticación y autorización están separadas en tres planos independientes, no intercambiables: | Plano | Tecnología | Alcance | Qué controla | |-------|-----------|--------|--------------| | **Auth de publicador** | NATS NKeys (ed25519) | Transporte | Quién puede publicar eventos en sujetos `dev.>` | | **Authz de flujo** | Políticas Cedar | Orquestador | Qué pipelines/nodos puede disparar un principal | | **Credenciales de ejecución** | Vault (SecretumVault) | Por nodo, por paso | Secretos de alcance limitado con TTL = timeout del nodo | Las credenciales de Vault se inyectan como variables de entorno en el subproceso Nushell y se revocan si el nodo falla. Nunca aparecen en mensajes NATS, logs ni en `PipelineContext` (se redactan antes del almacenamiento). ### 6. Atomicidad Saga — Compensación, no Transacciones Los pipelines se ejecutan hacia adelante a través de etapas. Si una etapa falla, el orquestador no deshace una transacción de base de datos — ejecuta scripts `compensate.nu` en orden inverso a través de todas las etapas previamente exitosas. Este es el patrón Saga: ``` Etapa 0: lint (ok) + fmt (ok) → ejecutada Etapa 1: build (FALLO) → dispara compensación Compensación etapa 0: → deshacer lint, deshacer fmt (en paralelo, inverso) ``` La compensación es de mejor esfuerzo: los fallos de compensación se registran pero no impiden que el pipeline alcance el estado `Compensated`. El registro en DB captura la traza completa de compensación. ### 7. Etapas Paralelas con JoinSet + CancellationToken Dentro de cada etapa, los nodos sin dependencias de capacidad entre sí se ejecutan en paralelo usando `tokio::task::JoinSet`. El fail-fast se implementa mediante `CancellationToken`: el primer fallo de un nodo cancela el token, abortando todas las tareas hermanas de la etapa. ``` Etapa 0: [lint-crate ‖ fmt-crate] — paralelo (sin dependencia mutua) Etapa 1: [build-crate] — secuencial (necesita ambas capacidades) Etapa 2: [install-crate] — secuencial (necesita built-artifact) ``` ### 8. OCI para Todo — Artefactos con Direccionamiento por Contenido Tanto las definiciones de nodos como la biblioteca base de Nickel se publican como artefactos OCI en un registro Zot. El pipeline de publicación para cada uno es: `nickel typecheck` → `gitleaks detect` → `nickel export` → `sha256sum` → `oras push` con anotaciones de hash de contenido. El binario `ncl-import-resolver` hace de puente entre OCI y el sistema de ficheros local: descarga cada capa OCI referenciada al arrancar el orquestador, verifica el digest contra el hash anotado y expone una ruta local para las importaciones de Nickel. Esto impide cargar definiciones de nodos no verificadas o manipuladas. Sigue el mismo modelo que las imágenes de contenedor: construir → escanear → publicar → consumir por digest. ### 9. Nushell como Unidad de Ejecución — Agnóstico por Diseño El campo `handler` de cada nodo de acción apunta a un script Nushell. El executor lanza `nu --no-config-file `, pasa los inputs del `PipelineContext` como JSON en stdin y lee el JSON de salida desde stdout. Esto hace la ejecución: - **Agnóstica al dominio**: el orquestador no conoce qué hace el script - **Reemplazable en caliente**: actualizar un flujo de trabajo implica reemplazar un fichero `.nu`, no recompilar el binario - **Aislable**: cada nodo se ejecuta en su propio proceso con credenciales Vault de alcance limitado - **Testeable de forma independiente**: los scripts pueden invocarse directamente con `echo '{}' | nu script.nu` ### 10. Alcance de TypeDialog — Solo Config de Arranque TypeDialog se usa exclusivamente para la configuración de arranque del orquestador (URL de SurrealDB, NATS, Zot, Vault, nivel de log, flags de funcionalidad). **No** se usa para declarativas de proyectos, definiciones de flujos ni configuración de nodos. Estas viven en ficheros Nickel gestionados por cada proyecto. Esto evita que TypeDialog se convierta en una herramienta de configuración general y mantiene su alcance acotado. ## Decisión `stratum-orchestrator` se implementa como una familia de crates nuevos en el monorepo StratumIOps: | Crate | Dominio | Responsabilidad | |-------|---------|----------------| | `stratum-graph` | Conocimiento | `ActionNode`, `Capability`, trait `GraphRepository` | | `stratum-state` | Operacional | `PipelineRun`, `StepRecord`, trait `StateTracker` | | `platform-nats` | Transporte | Consumidor JetStream con auth NKey | | `stratum-orchestrator` | Coordinación | `ActionGraph`, `PipelineContext`, `StageRunner`, auth, executor | El aislamiento de dominios es estructural: `stratum-graph` y `stratum-state` son crates separados con namespaces de tabla SurrealDB separados. `stratum-orchestrator` depende de sus traits, no de sus implementaciones — cumplimiento en tiempo de compilación. Secuencia de arranque del binario: cargar config TypeDialog → conectar SurrealDB → conectar NATS → resolver importaciones Nickel OCI → construir ActionGraph → iniciar observador notify → inicializar políticas Cedar → iniciar servidor HTTP (health + callback agente) → entrar en bucle pull JetStream. ## Justificación ### ¿Por Qué No un Motor de Flujos de Propósito General (Temporal, Argo, etc.)? | Consideración | Motor externo | stratum-orchestrator | |--------------|---------------|----------------------| | Modelo de eventos multi-proyecto | Requiere adaptador por proyecto | Coincidencia nativa de sujetos NATS | | Integración Nickel | No viable | Primera clase: los nodos son ficheros `.ncl` | | Ejecución Nushell | No soportado | Executor nativo de subprocesos | | Coste operacional | Pesado (cluster Temporal, Argo en K8s) | Binario único + SurrealDB + NATS | | Modelo de auth personalizado | Difícil de extender | Tres planos diseñados desde el inicio | ### ¿Por Qué Saga en vez de 2PC? El commit en dos fases entre scripts Nushell distribuidos no es viable: los scripts son procesos externos sin coordinador de transacciones. Los scripts de compensación Saga (`compensate.nu`) son el único modelo de atomicidad realista para flujos de trabajo multi-proceso. La concesión es asumida: la compensación es de mejor esfuerzo, no garantizadamente atómica, pero los casos de fallo son registrados y auditables. ### ¿Por Qué ActionGraph en Memoria vs Nodos Persistidos en DB? Almacenar las definiciones de nodos en SurrealDB crea verdad dual. El fichero en disco y el registro en DB pueden divergir. La recarga en caliente mediante `notify` sobre el sistema de ficheros es más simple, más rápida y elimina el problema de sincronización. SurrealDB solo se usa para el estado operacional (ejecuciones de pipeline, almacenes de capacidades) — el conocimiento (definiciones de nodos) permanece en el sistema de ficheros. ## Consecuencias **Concesiones asumidas**: - `nickel export` es una llamada a subproceso por fichero al arrancar — añade ~50ms por fichero de nodo al tiempo de arranque. Mitigado con carga paralela mediante `JoinSet` durante el arranque. - La compensación Saga es de mejor esfuerzo — un script de compensación que falla se registra pero no bloquea la progresión de estado. Es una concesión conocida del patrón Saga. - Coste de subproceso Nushell por nodo — cada ejecución de nodo lanza un proceso. Para scripts sub-segundo esto es latencia observable. Aceptable para flujos CI/CD e infraestructura. - Descarga de capa OCI al arrancar — los arranques en frío requieren descargar capas de la biblioteca Nickel. Mitigado con caché local de digest en `~/.cache/stratum/ncl/`. **Beneficios obtenidos**: - Nuevos flujos de trabajo solo requieren nuevos ficheros `.ncl` — cero cambios en el binario del orquestador - Traza de auditoría completa del pipeline en SurrealDB: cada paso, cada depósito de capacidad, cada compensación - La recuperación ante caídas es gratuita: reiniciar el orquestador, el pipeline reanuda desde el último estado persistido - La autenticación no es negociable: identidad del publicador (NKeys), autorización del flujo (Cedar) y credenciales de ejecución (Vault) se aplican en cada invocación de pipeline - Escalado horizontal: orquestador sin estado + SurrealDB compartida permite múltiples instancias en el mismo stream de eventos ## Referencias - Plan de implementación: `.coder/2026-02-20-stratum-orchestrator-plan.plan.md` - Diagrama de arquitectura: `assets/diagrams/arch-stratum-orchestrator.svg` - Flujo de pipeline de build: `assets/diagrams/flow-stratum-build-pipeline.svg` - Biblioteca base Nickel: `nickel/stratum-base/stratum-base.ncl` - Crates: `crates/stratum-graph/`, `crates/stratum-state/`, `crates/platform-nats/`, `crates/stratum-orchestrator/` - Relacionados: ADR-001 (stratum-embeddings), ADR-002 (stratum-llm)