flow.ir IR + interpreter substrate for the mlua ecosystem. 7 Node kinds + 20 Expr ops, no I/O, no concurrency, no agent dispatch — pure substrate.
This workspace publishes two crates:
| Crate | Role |
|---|---|
flow-ir-core |
Pure Rust schema + sync eval + Dispatcher trait (no mlua, no async) |
mlua-flow-ir |
Async runtime (AsyncDispatcher / eval_async, including Fanout join-mode support) + mlua module() binding. Re-exports flow-ir-core. |
Host-side concerns (Spawner / Worker / Loop / AuthzPolicy / cp_state persist) live in the upstream mlua-swarm-engine crate. This workspace is intentionally substrate-only.
- 7 Node kinds —
Step { ref, in, out },Seq { children },Branch { cond, then, else },Fanout { items, bind, body, join, out },Loop { counter, cond, body, max },Try { body, catch, err_at },Let { at, value }(canonicallet— see the v0.3.0 Migration note for the rename fromAssign) - 20 Expr ops — read/literal (
Path,Lit), comparison (Eq,Ne,Lt,Lte,Gt,Gte), boolean (Not,And,Or), existence (Exists), arithmetic (Add,Sub,Mul,Div,Mod), aggregate (Len,In), and theCallExternhatch - Discriminated —
#[serde(tag = "kind")]/#[serde(tag = "op")]+deny_unknown_fields - Dispatcher = callback — host provides concrete implementations (process spawn, mlua callback, MCP call, direct LLM, etc.)
use mlua_flow_ir::{eval, Dispatcher, EvalError, Node};
use serde_json::{json, Value};
let node: Node = serde_json::from_value(json!({
"kind": "step",
"ref": "uppercase",
"in": { "op": "path", "at": "$.input" },
"out": { "op": "path", "at": "$.output" },
})).unwrap();
struct FixtureDispatcher;
impl Dispatcher for FixtureDispatcher {
fn dispatch(&self, _ref: &str, input: Value) -> Result<Value, EvalError> {
if let Value::String(s) = input {
Ok(Value::String(s.to_uppercase()))
} else {
Ok(input)
}
}
}
let result = eval(&node, json!({ "input": "hello" }), &FixtureDispatcher).unwrap();
assert_eq!(result, json!({ "input": "hello", "output": "HELLO" }));- v0.0.1–0.0.3 — pre-split prototype (single crate)
- v0.0.4 — workspace split:
flow-ir-core(Pure Rust) +mlua-flow-ir(async + mlua) - v0.1.x —
Expr::CallExternhatch +ExternsDI registry,Expr::Mod, canonical wire-format alignment (gte/lte,args/arg),Node::Loop/Node::Try/Node::Assign, RFC 9535-style bracket path notation - v0.2.0 — typed [
Path] IR (parse-don't-validate),EvalError::TypeError/ArithError,mluafeature passthrough (lua51/lua52/lua53/lua54/luajit/luau+ weakvendored), CI gates - v0.3.0 (current) — canonical wire-format alignment:
Node::Assign→Node::Let(canonicallet),Pathparser root-token whitelist expansion (ctx/ctx.foo/ctx["foo"]accepted alongside$— see Migration below), MSRV bump1.77→1.85(lua-src v550.0.0edition2024requirement) - Future — JSON / YAML loader split into
mlua-flow-json/mlua-flow-yamlsibling crates; engine integration viamlua-swarm-engine(Spawner / Worker / Loop / AuthzPolicy)
See CHANGELOG.md for the full per-release list.
Three breaking changes are bundled into v0.3.0 so downstream consumers migrate in a single hop:
-
Node::Assign→Node::Let. The wire tag is"let"(was"assign") andatis now a bare [Path] string rather than aPathExprobject.// v0.2.x { "kind": "assign", "at": { "op": "path", "at": "$.foo" }, "value": ... } // v0.3.0 { "kind": "let", "at": "ctx.foo", "value": ... }
Rust API:
// v0.2.x Node::Assign { at: Expr, value: Expr } // v0.3.0 Node::Let { at: Path, value: Expr }
-
Pathparser root-token whitelist expanded. Both$(read prefix) andctx(write prefix, canonical) are now accepted by the parser; the distinction between them is delegated to the caller (the surroundingNodefield contract) — read paths continue to use$, whileNode::Let.atusesctx.. The v0.2.0 typo-suspender rejections ($foo/ctxfoo/foo.bar, empty segments, malformed brackets) are preserved. -
MSRV bump
1.77→1.85. Required by the transitivelua-src v550.0.0dependency (pulled in viamlua'svendoredfeature), which requiresedition2024. Downstream consumers with an MSRV floor below1.85will need to bump their toolchain.
flow-ir-core first, then mlua-flow-ir (depends on flow-ir-core via path+version).
MIT OR Apache-2.0