De scripts a contratos
Lo que el sistema de tipos de Rust me enseñó sobre la composición de infraestructura — RustLab 2026, Bolonia
De scripts a contratos
Lo que el sistema de tipos de Rust me enseñó
sobre la composición de infraestructura
Jesús Pérez Lorenzo · Rust Developer · Provisioning Systems Architect
RustLab 2026 · Bolonia · Deep Space


Hablar es fácil.
Enséñame el código.
- Esta charla contiene código. Bastante.
- En el código una afirmación se vuelve real: compila, se ejecuta o falla. El resto lo puede inferir cualquier inteligencia.
- Leerlo es opcional. Entenderlo, también. Está ahí para que nada de lo que digo quede en el aire.
Rust Developer · Provisioning Systems Architect
jesusperez.pro · ontoref.dev · jpl@jesusperez.pro#[derive(Debug)]
enum Lang { COBOL, Informix4GL, SQL, C, Perl, PHP, Ruby, Bash, JavaScript, Nushell, Go, KCL, Nickel, Rust }
use Lang::*;
struct Engineer { since: u16, journey: &'static [Lang], builds: &'static [&'static str], beliefs: &'static str }
const ME: Engineer = Engineer {
since: 1987,
journey: &[COBOL, Informix4GL, SQL, C, Perl, PHP, Ruby, Bash, JavaScript, Nushell, Go, KCL, Nickel, Rust],
builds: &["provisioning", "ontoref", "rustelo"],
beliefs: "Taoism — yang is form, yin is flow; neither wins",
};
// Checked by rustc, not at run time: reorder `journey` and this does not compile.
const _: () = assert!(matches!(ME.journey.last(), Some(Rust)), "finally");
fn main() {
println!("experience: {} years — software development and systems integrations", 2026 - ME.since);
println!("journey: {:?}", ME.journey);
println!("builds: {}", ME.builds.join(" · "));
println!("community: ULPGC + SPEGC workshops · co-organizer, Rust Las Palmas");
println!("beliefs: {}", ME.beliefs);
println!("prequel: Rustikon 2026 — \"Why I Needed Rust, Finally\"");
}Rust Developer · Provisioning Systems Architect
jesusperez.pro · ontoref.dev · jpl@jesusperez.pro#[derive(Debug)]
enum Lang { COBOL, Informix4GL, SQL, C, Perl, PHP, Ruby, Bash, JavaScript, Nushell, Go, KCL, Nickel, Rust }
use Lang::*;
struct Engineer { since: u16, journey: &'static [Lang], builds: &'static [&'static str], beliefs: &'static str }
const ME: Engineer = Engineer {
since: 1987,
journey: &[COBOL, Informix4GL, SQL, C, Perl, PHP, Ruby, Bash, JavaScript, Nushell, Go, KCL, Nickel, Rust],
builds: &["provisioning", "ontoref", "rustelo"],
beliefs: "Taoism — yang is form, yin is flow; neither wins",
};
// Checked by rustc, not at run time: reorder `journey` and this does not compile.
const _: () = assert!(matches!(ME.journey.last(), Some(Rust)), "finally");
fn main() {
println!("experience: {} years — software development and systems integrations", 2026 - ME.since);
println!("journey: {:?}", ME.journey);
println!("builds: {}", ME.builds.join(" · "));
println!("community: ULPGC + SPEGC workshops · co-organizer, Rust Las Palmas");
println!("beliefs: {}", ME.beliefs);
println!("prequel: Rustikon 2026 — \"Why I Needed Rust, Finally\"");
}Este log ya lo habéis visto
$ ./deploy.sh production [ok] network applied [ok] secrets propagated [ok] orchestrator updated [ok] monitoring reloaded [ok] deploy complete exit 0
- Cada paso informó de éxito.
- El orden entre ellos era incorrecto — y nada sabía que hubiera un orden.
- El pipeline estaba en verde. Producción no.
Tres capas que yo confundía
| Capa | Pregunta | Quién lo comprueba |
|---|---|---|
| Estructura | ¿Existe este campo, con el tipo primitivo correcto? | serde, JSON Schema |
| Forma | ¿Concuerdan estos campos entre sí? | contratos, constructores validados |
| Composición | ¿Es legal el grafo entero de pasos? | … normalmente nadie |
- Rust me dio soltura en las dos primeras: newtypes, enums,
TryFrom. - De la tercera capa salieron todas las alertas de las 3 de la madrugada que recuerdo.
- Esta charla es lo que pasó cuando convertí la tercera capa en un contrato.
El stack, con honestidad
Nickel
forma — en tiempo de configuración
Contratos de componentes, DAGs de fórmulas, composición de workspaces. Rechaza antes de que nada se ejecute.
Nushell
orquestación
La CLI, el ejecutor real del DAG a día de hoy, el pegamento con los proveedores. Datos estructurados, no cadenas de texto.
Rust
la plataforma
Orquestador, traits de proveedor, servicios NATS, control center, vault post-cuántico.
Un componente es un contrato
# schemas/lib/contracts.ncl (extracto)
ComponentDef = {
name | String,
mode | [| 'taskserv, 'cluster, 'container |] | default = 'taskserv,
depends_on | Array {
name | String,
kind | [| 'Requires, 'PrefersBefore, 'ConflictsWith |] | default = 'Requires,
} | default = [],
on_error | [| 'Stop, 'Continue, 'Retry |] | default = 'Stop,
requires | {
storage | { size | String, persistent | Bool } | optional,
ports | Array { port | Number, exposure | [| 'public, 'private, 'internal |] },
} | default = {},
provides | {
service | String | optional,
port | Number | optional,
endpoints | Array String | default = [],
} | default = {},
# mandatory — never omit
concerns | ServiceConcerns,
..
}Concerns: tipar lo que nadie tipa
# schemas/lib/concerns.ncl
ConcernState = { kind | [| 'enabled, 'disabled, 'pending, 'inherited |], .. }
ServiceConcerns = {
tls | ConcernState, dns | ConcernState, certs | ConcernState,
backup | ConcernState, observability | ConcernState, security | ConcernState,
}
# un componente honesto sobre sus carencias
concerns = {
tls = c.disabled "stateless service, no TLS termination",
backup = c.pending "policy to be defined" "BACKUP-001",
..
}- No puedes omitir un concern. Solo puedes decir por qué está desactivado — o qué ticket lo cierra.
- "¿Qué componentes tienen
backup = 'pending?" pasa a ser una consulta, no una auditoría.
El grafo es un valor — y se comprueba
# schemas/lib/formula.ncl (condensado) — se ejecuta en tiempo de configuración
let _Formula = std.contract.custom (fun label value =>
let node_ids = value.nodes |> std.array.map (fun n => n.id) in
let bad_deps = value.nodes |> std.array.flat_map (fun node =>
node.depends_on
|> std.array.filter (fun dep => !(std.array.elem dep.node_id node_ids))
|> std.array.map (fun dep => "node '%{node.id}' depends_on unknown '%{dep.node_id}'")
) in
if bad_deps != [] then
std.contract.blame_with_message "invalid depends_on: %{std.string.join ", " bad_deps}" label
else 'Ok value
)- Ids de nodo duplicados,
depends_oncolgantes, extremos de arista inválidos → rechazados. - Una fórmula = un DAG de componentes en un servidor.
max_parallelyon_errorson política sobre el grafo.
Grafos de grafos: la composición
# workspaces/libre-wuji/infra/libre-wuji/dag.ncl
composition.formulas = [
{ formula_id = "libre-wuji-cp-formula",
depends_on = [],
health_gate = {
check_cmd = "kubectl get nodes … | grep -v NotReady",
retries = 18,
} },
{ formula_id = "libre-wuji-wrk-0-formula",
depends_on = [{ formula_id = "libre-wuji-cp-formula",
condition = 'Healthy }],
parallel = true },
# wrk-1, wrk-2, strg-* …
]Exactamente uno — la lección que no apliqué
1 // lo que Rust pedía desde el principio: dos estados, no cuatro
2 pub enum NodeKind {
3 TaskServ { taskserv: TaskServDef },
4 Component { component: ComponentDef },
5 }
6
7 pub struct FormulaNode {
8 pub id: String,
9 pub kind: NodeKind,
10 }
11
12 match &node.kind {
13 NodeKind::TaskServ { taskserv } => run_taskserv(taskserv),
14 NodeKind::Component { component } => run_component(component),
15 }
bail! desaparecen porque ese estado no se puede construir.Proveedores con contrato — el núcleo no se mueve
// platform/crates/provisioning-compute/src/trait_def.rs
#[async_trait]
pub trait ComputeProvider: Send + Sync + Debug + 'static {
fn kind(&self) -> ProviderKind;
async fn spawn(&self, spec: InstanceSpec) -> Result<Instance, ComputeError>;
/// MUST be idempotent: destroying an unknown handle returns Ok(()).
/// MUST NOT return NotFound — that case maps to Ok(()).
async fn destroy(&self, handle: &InstanceHandle) -> Result<(), ComputeError>;
async fn status(&self, handle: &InstanceHandle) -> Result<InstanceStatus, ComputeError>;
async fn list(&self, label_selector: &str) -> Result<Vec<Instance>, ComputeError>;
async fn health(&self) -> Result<ProviderHealth, ComputeError>;
}- Añadir un proveedor = implementar un trait + un esquema Nickel. Sin tocar ningún núcleo — vale también para tu propia infra local, o para cualquier script Nushell detrás de un sobre JSON.
#[non_exhaustive] enum ComputeError— los modos de fallo forman parte del contrato.- Pero lee los doc comments: "MUST be idempotent" es un invariante que el tipo no puede garantizar. Solo los tests pueden — así que todo proveedor, el tuyo incluido, pasa la misma suite de conformidad:
run_suite(provider).
En vivo: romper el contrato
let f = import "schemas/lib/formula.ncl" in
let Acyclic = import "acyclic.ncl" in
f.make_formula {
id = "rustlab-live",
description = "control plane, typed",
provider = "hetzner",
server = "libre-wuji-cp-0",
nodes = [
f.make_node { id = "os", taskserv = { name = "os" } },
f.make_node { id = "runc", taskserv = { name = "runc" }, depends_on = [{ node_id = "os" }] },
f.make_node { id = "crio", taskserv = { name = "crio" }, depends_on = [{ node_id = "runc" }] },
f.make_node { id = "kubernetes", taskserv = { name = "kubernetes" }, depends_on = [{ node_id = "crio" }] },
],
}En vivo: romper el contrato
let f = import "schemas/lib/formula.ncl" in
let Acyclic = import "acyclic.ncl" in
f.make_formula {
id = "rustlab-live",
description = "control plane, typed",
provider = "hetzner",
server = "libre-wuji-cp-0",
nodes = [
f.make_node { id = "os", taskserv = { name = "os" } },
f.make_node { id = "runc", taskserv = { name = "runc" }, depends_on = [{ node_id = "os" }] },
f.make_node { id = "crio", taskserv = { name = "crio" }, depends_on = [{ node_id = "runc" }] },
f.make_node { id = "kubernetes", taskserv = { name = "kubernetes" }, depends_on = [{ node_id = "crio" }] },
],
}Ownership: como analogía, no como mecanismo
| Rust | Infraestructura |
|---|---|
| Un único dueño | Un componente posee un recurso; el resto toma prestado su provides |
& frente a &mut | Leer endpoints libremente; mutar un recurso ajeno es un error de diseño |
| Lifetimes | "Esta credencial es válida para esta sesión de despliegue" |
TryFrom / constructor validado | Contratos que rechazan en la frontera |
enum en vez de Option×Option | 'pending "reason" "BACKUP-001" en lugar de un campo que falta |
| Trait bounds | "Cumple ComputeProvider y eres un proveedor" |
El estado mutable compartido por alias es la raíz de la mayoría de las data races.
Dos sistemas que creen ser dueños del mismo recurso son la raíz de la mayoría de los incidentes de infraestructura.
Por qué el rollback no es Drop
Yo quería esto:
{
let plan = Plan::apply(&ctx)?;
plan.verify()?;
plan.commit();
} // se libera sin commit → rollbackDropes síncrono. Las APIs de la nube no.Dropno puede fallar. Borrar un volumen sí.- El orden de
Dropes léxico. El orden de desmontaje es un grafo.
Lo que tengo:
#[async_trait]
pub trait ProviderRollbackHandler: Send + Sync {
async fn rollback_resource(&self, r: &ResourceSnapshot)
-> Result<()>;
async fn cleanup_resources_after(&self, cp: &Checkpoint)
-> Result<Vec<String>>;
async fn validate_rollback(&self, cp: &Checkpoint)
-> Result<Vec<String>>;
}Explícito, con checkpoints, por proveedor — y validado antes de ejecutarse.
El bug que encontré preparando esta charla
use std::collections::{HashMap, HashSet, VecDeque};
#[derive(Default)]
struct DependencyGraph {
nodes: HashSet<String>,
edges: HashMap<String, Vec<String>>, // task -> its dependencies
reverse_edges: HashMap<String, Vec<String>>, // dependency -> its dependents
}
impl DependencyGraph {
fn add_dependency(&mut self, from: &str, depends_on: &str) {
self.nodes.extend([from.to_string(), depends_on.to_string()]);
self.edges.entry(from.into()).or_default().push(depends_on.into());
self.reverse_edges.entry(depends_on.into()).or_default().push(from.into());
}
// same logic as orchestrator/src/workflow.rs:174
fn topological_sort(&self) -> Result<Vec<String>, String> {
let mut in_degree: HashMap<&str, usize> =
self.nodes.iter().map(|n| (n.as_str(), 0)).collect();
for deps in self.edges.values() {
for dep in deps {
*in_degree.entry(dep).or_default() += 1;
}
}
let mut queue: VecDeque<&str> =
in_degree.iter().filter(|(_, d)| **d == 0).map(|(n, _)| *n).collect();
let mut result = Vec::new();
while let Some(node) = queue.pop_front() {
result.push(node.to_string());
for dependent in self.reverse_edges.get(node).into_iter().flatten() {
let d = in_degree.get_mut(dependent.as_str()).ok_or("unknown node")?;
*d -= 1;
if *d == 0 { queue.push_back(dependent); }
}
}
if result.len() != self.nodes.len() {
return Err("Circular dependency detected in workflow".into());
}
Ok(result)
}
}El bug que encontré preparando esta charla
use std::collections::{HashMap, HashSet, VecDeque};
#[derive(Default)]
struct DependencyGraph {
nodes: HashSet<String>,
edges: HashMap<String, Vec<String>>, // task -> its dependencies
reverse_edges: HashMap<String, Vec<String>>, // dependency -> its dependents
}
impl DependencyGraph {
fn add_dependency(&mut self, from: &str, depends_on: &str) {
self.nodes.extend([from.to_string(), depends_on.to_string()]);
self.edges.entry(from.into()).or_default().push(depends_on.into());
self.reverse_edges.entry(depends_on.into()).or_default().push(from.into());
}
// same logic as orchestrator/src/workflow.rs:174
fn topological_sort(&self) -> Result<Vec<String>, String> {
let mut in_degree: HashMap<&str, usize> =
self.nodes.iter().map(|n| (n.as_str(), 0)).collect();
for deps in self.edges.values() {
for dep in deps {
*in_degree.entry(dep).or_default() += 1;
}
}
let mut queue: VecDeque<&str> =
in_degree.iter().filter(|(_, d)| **d == 0).map(|(n, _)| *n).collect();
let mut result = Vec::new();
while let Some(node) = queue.pop_front() {
result.push(node.to_string());
for dependent in self.reverse_edges.get(node).into_iter().flatten() {
let d = in_degree.get_mut(dependent.as_str()).ok_or("unknown node")?;
*d -= 1;
if *d == 0 { queue.push_back(dependent); }
}
}
if result.len() != self.nodes.len() {
return Err("Circular dependency detected in workflow".into());
}
Ok(result)
}
}El bug que encontré preparando esta charla (cont.)
impl DependencyGraph {
// same logic as orchestrator/src/workflow.rs:174
fn topological_sort(&self) -> Result<Vec<String>, String> {
let mut in_degree: HashMap<&str, usize> =
self.nodes.iter().map(|n| (n.as_str(), 0)).collect();
for deps in self.edges.values() {
for dep in deps {
*in_degree.entry(dep).or_default() += 1;
}
}
let mut queue: VecDeque<&str> =
in_degree.iter().filter(|(_, d)| **d == 0).map(|(n, _)| *n).collect();
let mut result = Vec::new();
while let Some(node) = queue.pop_front() {
result.push(node.to_string());
for dependent in self.reverse_edges.get(node).into_iter().flatten() {
let d = in_degree.get_mut(dependent.as_str()).ok_or("unknown node")?;
*d -= 1;
if *d == 0 { queue.push_back(dependent); }
}
}
if result.len() != self.nodes.len() {
return Err("Circular dependency detected in workflow".into());
}
Ok(result)
}
}Los tipos comprueban la forma. Los tests, el sentido.
Result<Vec<String>>lo satisfacen el orden correcto, el incorrecto y unErrque miente.- Los algoritmos de grafos necesitan propiedades, no ejemplos:
proptest! {
#[test]
fn topo_order_respects_every_edge(g in arb_dag(1..64)) {
let order = g.topological_sort().unwrap();
let pos: HashMap<_, _> = order.iter().enumerate().map(|(i, n)| (n, i)).collect();
for (task, deps) in &g.edges {
for dep in deps { prop_assert!(pos[dep] < pos[task]); }
}
}
}La frontera es por donde se filtran los tipos
ComponentDef en Nickel — más de 20 campos
mode | [| 'taskserv, … |] | default = 'taskserv,
requires | { … },
provides | { … },
concerns | ServiceConcerns, # obligatorio
.. # record abiertoComponentDef en Rust — 7 campos
#[derive(Deserialize)]
pub struct ComponentDef {
pub name: String,
#[serde(default = "default_deploy_mode")] // ¡Cluster!
pub mode: DeployMode,
// requires, provides, concerns:
// serde los descarta en silencio
}- Dos sistemas de tipos, un concepto, dos valores por defecto.
serdeignora los campos desconocidos salvo que indiques#[serde(deny_unknown_fields)].- Arreglo: una única fuente de verdad + un test de CI que exporta cada fixture de Nickel y la deserializa en modo estricto.
Un contrato solo protege donde se aplica
provides | {
service | String | optional,
port | Number | optional,
# Added 2026-08-18: the surrealdb catalog component has always declared
# provides.protocol = "http+ws" and this closed record rejected it, so the
# component could be listed in settings.ncl (which does not apply
# ComponentDef) but not used as a DAG component node, which does. The
# incompatibility stayed invisible until a workspace first tried to
# schedule it through the unified model.
protocol | String | optional,El tipado gradual es una virtud — y también un sitio donde los bugs esperan. Ten claro dónde se ejecutan tus contratos.
El estado externo no se puede tipar
condition = 'Healthyes un enum tipado. Lo que Healthy significa eskubectl get nodes | grep -v NotReady.- Un
200 OKde un control plane a menudo significa encolado, no hecho. - Consistencia eventual, límites de peticiones, ventanas de idempotencia: nada de eso aparece en una firma.
- Puedo validar el plan. No puedo validar la nube.
Operativa, no estructural. Observada, no demostrada.
Qué haría distinto
- Tipos suma de extremo a extremo. Si el lenguaje de configuración no puede expresarlos, genera el lado de Rust — no copies a mano
Option × Option. - Errores tipados en la capa de composición. Todavía devuelve
anyhow. Los proveedores ya tienen enums#[non_exhaustive]; el grafo se merece uno. - Un solo ejecutor. Nushell ejecuta el DAG real; a su lado había un motor en Rust sin tests en su ordenación. Dos motores = dos semánticas.
- Tests de propiedades en cada algoritmo de grafos. Desde el primer día — un
Result<Vec<String>>comprueba la forma de la respuesta, no su orden. - Comprobar la aciclicidad donde se declara el grafo. No donde se ejecuta — el ciclo pasó
dag.ncly solo Rust lo vio.
El mismo paradigma, otro objeto
| Rust · el código | provisioning · la infraestructura | ontoref · los proyectos | |
|---|---|---|---|
| Declarar | tipos, enums | ComponentDef, fórmulas | axiomas, tensiones, prácticas — core.ncl |
| Estado | typestate | estado de la op, health gates | state.ncl: actual → deseado, con bloqueos |
| Comprobar antes de actuar | el compilador | contrato antes del despliegue | una decisión contrastada con los invariantes · act-gate en pre-commit |
| Qué cuenta como hecho | Result, no exit 0 | el health gate, no "applied" | el veredicto de un paso es el exit code de su comprobación, no su informe |
| Traza | — | ops firmadas → registro en Radicle | orden de trabajo firmada + recibo |
forma · el plan
flujo · la nube
Qué cambia esto para los agentes de IA
Ayuda
- El contexto se consulta, no se recuerda. El agente pregunta al proyecto qué es cierto (
ontoref describe … --actor agent) en vez de deducirlo de los ficheros. - El agente propone, el contrato dispone. Los mismos validadores por MCP que en la CLI; las propuestas llegan sin firmar a
ops.pending.<ws>, y solo los comandos firmados alcanzanops.cmd.<ws>. - Su palabra no es el veredicto. El estado de un paso es el exit code de su comprobación; un autoinforme que lo contradiga se rechaza.
- Los actos humanos siguen siendo humanos. Solo una persona ratifica o acepta; un agente solo firma en su propio nombre. Su alcance es un eje declarado:
'AgentBoundary.
No resuelve
- Bien tipado y erróneo. El
topological_sortcompilaba. - Declarado ≠ leído. Un agente cita un mecanismo que nada ejecuta — exactamente igual que las personas.
- "No hay comprobación" también es una afirmación. La ausencia necesita la misma prueba que la presencia.
- Cada contrato tiene un coste de adopción. Más forma, más fricción.
La misma disciplina, un nivel más arriba
# un workspace → su dominio (libre-daoshi/.domains-ontoref/…)
{ id = "libre-daoshi-to-provisioning",
from = "libre-daoshi", to = "provisioning",
axis = 'Governance, state = 'Node, resolution = 'Merge }
# proyecto → gov, un sentido (provisioning/.domains-ontoref/…)
{ id = "provisioning-to-gov",
from = "provisioning", to = "gov",
axis = 'Dependency, state = 'Linked,
dep_kind = 'Conformist, mutual = false }'Governancees un DAG.bond validatelo recorre e informa de un ciclo como error.'Dependencyes un plano. Sin búsqueda de ciclos: un par mutuo es legal — por tipo, no por excepción.'Merge: el nodo prevalece por id; el resto se hereda del dominio.
De dónde salen las ideas de esta charla
Conclusiones
- Tipa la composición, no solo los recursos. Los incidentes viven en las aristas.
- La ausencia tiene que ser explícita.
'pending "why" "TICKET-42"es mejor que un campo que falta. - La disciplina es recursiva. Componentes → fórmulas → infraestructura → proyectos y sus bonds.
- Las costuras necesitan sus propios tests. Config ↔ Rust, plan ↔ nube, tipo ↔ significado.
- Los agentes proponen; los contratos, las comprobaciones y las personas disponen. Ten claro dónde acaba cada garantía.
Y si cuadran, todavía tiene que ser verdad.
Cifras
| Rust | 28 crates en el workspace · ~270k LOC |
| Nickel | 984 ficheros · ~71k LOC |
| Nushell | 895 ficheros · ~199k LOC |
| Workspace de producción | libre-wuji · Hetzner fsn1 · 71 definiciones de componentes |
| Proveedores de cómputo | Hetzner · UpCloud · AWS · DigitalOcean · local |
| Bus | NATS JetStream · payloads JSON · structs serde |
| Auditoría | ops.audit.* → historial firmado en Radicle |
¿Por qué no todo en Rust?
Nickel
- Configuración editada y validada sin recompilar la plataforma
- Los contratos y el merge son el lenguaje, no una biblioteca a la que llamas
- El blame señala la línea de configuración que rompió el contrato
build.rs — cada cambio de configuración es una recompilación, y cada contrato vive donde solo lo ve quien lee RustNushell
- Pegamento como datos estructurados — tablas y registros, no cadenas de texto
- Un proveedor puede ser un script:
ScriptProvider, comprobado por la misma suite de conformidad - Se cambia in situ, en la máquina que lo ejecuta
Rust
- Orquestador, servicios NATS, control center, vault post-cuántico
- Sin GC, un único binario estático,
Send + Synccomprobado - El fallo en la firma:
Result,#[non_exhaustive]
topological_sort compilaba — lo cazaron los testsRust puso el listón; no tiene por qué tipar él todo.
¿Por qué no Terraform / Pulumi / CUE / KCL / Crossplane?
| Terraform OpenTofu | Pulumi | CUE | KCL | Crossplane | provisioning | |
|---|---|---|---|---|---|---|
| Recursos tipados | ● esquemas de proveedor | ● tipos del lenguaje anfitrión | ● unificación | ● esquemas + check | ● CRD / OpenAPI | ● contratos Nickel |
| La composición es un valor declarado | ◐ implícita en las referencias | ○ se construye ejecutando código | ◐ datos, sin ejecutor | ◐ datos; la ejecuta KusionStack | ◐ Compositions, orden eventual | ● DAG de fórmula + composición |
| Política sobre el grafo política de errores · paralelismo · health gates | ○ -parallelism global | ◐ escrita en código | ○ — | ○ no está en el lenguaje | ◐ readiness checks | ● on_error · max_parallel · health_gate por nodo |
| Aspectos transversales explícitos | ○ tags, convención | ○ convención | ◐ codificables | ◐ codificables | ○ convención | ● 6 concerns tipados, 'pending + ticket |
| Grafo ilegal rechazado antes de ejecutar nada | ● refs + ciclos en validate | ◐ preview ejecuta el programa | ● lo que codifiques | ● lo que codifiques | ○ se descubre al reconciliar | ◐ refs en tiempo de configuración · ciclos solo en Rust |
| Drift: reconciliación continua | ◐ plan muestra el drift | ◐ refresh | ○ — | ○ — | ● es el modelo | ◐ gates + eventos · sin bucle de reconciliación |
| Ecosistema · proveedores | ● miles | ● amplio | ◐ en crecimiento | ◐ CNCF sandbox | ● amplio | ◐ 5 en el catálogo · cualquiera por contrato — un script, tu propia infra — comprobado por una suite de conformidad · un solo mantenedor |
las aristas entre recursos, tipadas y comprobadas antes de ejecutar nada.
"Esto se puede hacer en cualquier lenguaje"
Sí — la disciplina viaja
- La composición se tipa en Nickel y la ejecuta Nushell. Porta el ejecutor a Go y los contratos siguen valiendo.
- Ese es el sentido de la charla: un hábito de Rust, exportado a la parte del sistema que no es Rust.
- Y Rust no me salvó de un
topological_sorterróneo. Me salvaron los tests.
Lo que aporta Rust — todo en un mismo compilador
Tipos suma + match exhaustivo | estados ilegales imposibles de construir — NodeKind, no Option × Option |
Result + #[non_exhaustive] | el fallo forma parte de la firma — ComputeError |
Send + Sync + 'static | compartir un proveedor entre tareas, comprobado en tiempo de compilación |
| Sin GC · un único binario estático | daemons que no pueden atascarse: ops-controller, ncl-sync, un vault post-cuántico |
serde en la frontera | la costura tipada en ambos sentidos — y ves por dónde se filtra |
Federación (ADR-042)
- Los proyectos declaran Integration Modes; los contratos se distribuyen como artefactos OCI (
application/vnd.ontoref.mode.v1) medianteoras. - Los workspaces los consumen como imports tipados.
- El impacto entre proyectos es un BFS acotado:
impact_graph(slug, node, max_depth), profundidad ≤ 5.
Enlaces
Infraestructura
Comunidad
Protocolo · dominios gov y custody de ontoref
· lecturas
