# schemas/lib/formula.ncl — Workspace Formula DAG # # A Formula is a typed DAG that is simultaneously: # - A validatable declaration (Nickel typecheck + referential integrity) # - An executable pipeline (Orchestrator consumes the DAG via nickel export) # - A governable artifact (on+re tracks state, gates, and audit) # # Usage: # let f = import "schemas/lib/formula.ncl" in # f.make_formula { id = "...", nodes = [...], ... } let ts = import "contracts.ncl" in let _dep_kind = [| 'Always, 'OnSuccess, 'OnFailure |] in let _on_error = [| 'Stop, 'Continue, 'Retry |] in # Dependency from one FormulaNode to another (by node id) let _FormulaDep = { node_id | String, kind | _dep_kind | default = 'OnSuccess, } in # A node in the formula DAG. # Exactly one of `taskserv` or `component` must be present. # - taskserv: L2 nodes — legacy field, existing formulas unchanged # - component: L3+ nodes — unified model, orchestrator uses component.mode to resolve let _FormulaNode = std.contract.custom (fun label value => let base = value | { id | String, taskserv | ts.TaskServDef | optional, component | ts.ComponentDef | optional, depends_on | Array _FormulaDep | default = [], parallel | Bool | default = false, on_error | _on_error | default = 'Stop, max_retries | Number | default = 0, } in let has_taskserv = std.record.has_field "taskserv" base in let has_component = std.record.has_field "component" base in if has_taskserv && has_component then std.contract.blame_with_message "FormulaNode '%{base.id}': exactly one of 'taskserv' or 'component' must be present, not both" label else if (!has_taskserv) && (!has_component) then std.contract.blame_with_message "FormulaNode '%{base.id}': exactly one of 'taskserv' or 'component' must be present" label else 'Ok base ) in # An explicit edge declaration (alternative to depends_on inside nodes) let _FormulaEdge = { from | String, to | String, kind | _dep_kind | default = 'OnSuccess, } in # Base structure without cross-field validation let _FormulaBase = { id | String, description | String, provider | String, server | String, nodes | Array _FormulaNode, edges | Array _FormulaEdge | default = [], max_parallel | Number | default = 4, } in # Contract: all node_id values in depends_on must reference an existing node id. # Also validates edge endpoints. let _Formula = std.contract.custom (fun label value => let base = value | _FormulaBase in let node_ids = base.nodes |> std.array.map (fun n => n.id) in # Check: duplicate node ids let dup_ids = node_ids |> std.array.fold_left (fun acc id => if std.record.has_field id acc.seen then { seen = acc.seen, dups = acc.dups @ [id] } else { seen = acc.seen & { "%{id}" = true }, dups = acc.dups } ) { seen = {}, dups = [] } in if std.array.length dup_ids.dups > 0 then std.contract.blame_with_message "Formula '%{base.id}': duplicate node ids: %{std.string.join ", " dup_ids.dups}" label else # Check: depends_on referential integrity let bad_deps = base.nodes |> std.array.flat_map (fun node => node.depends_on |> std.array.filter (fun dep => !(node_ids |> std.array.any (fun id => id == dep.node_id)) ) |> std.array.map (fun dep => "node '%{node.id}' depends_on unknown '%{dep.node_id}'" ) ) in if std.array.length bad_deps > 0 then std.contract.blame_with_message "Formula '%{base.id}' has invalid depends_on: %{std.string.join ", " bad_deps}" label else # Check: edge referential integrity let bad_edges = base.edges |> std.array.filter (fun e => !(node_ids |> std.array.any (fun id => id == e.from)) || !(node_ids |> std.array.any (fun id => id == e.to)) ) |> std.array.map (fun e => "'%{e.from}' -> '%{e.to}'") in if std.array.length bad_edges > 0 then std.contract.blame_with_message "Formula '%{base.id}' has invalid edge endpoints: %{std.string.join ", " bad_edges}" label else 'Ok base ) in { FormulaDep = _FormulaDep, FormulaNode = _FormulaNode, FormulaEdge = _FormulaEdge, Formula = _Formula, make_dep = fun data => _FormulaDep & data, make_node = fun data => data | _FormulaNode, make_edge = fun data => _FormulaEdge & data, make_formula = fun data => data | _Formula, }