@flopod/ir
v0.3.1
Published
> Status: design spec. This document defines the abstract syntax that sits at the > center of Flopod: the thing that text (TypeScript), the visual canvas, BPMN, and > Serverless Workflow are all *renderings* of.
Downloads
755
Readme
Flopod IR — a code-first, structured workflow algebra
Status: design spec. This document defines the abstract syntax that sits at the center of Flopod: the thing that text (TypeScript), the visual canvas, BPMN, and Serverless Workflow are all renderings of.
1. What this is
Flopod IR is a deliberately narrow, structured, typed workflow algebra. It is the common denominator across the targets we care about — imperative code, a visual graph, BPMN 2.0, the CNCF Serverless Workflow spec, AWS Step Functions — chosen so that an LLM and a diagram can both reason about it.
It is not a general workflow language. It is the intersection of what every target can faithfully represent. Everything too expressive for that intersection drops one level down, into first-class referenced code (an activity body).
The narrow waist
author Flopod IR render / run
┌──────────────┐ ┌──────────────────┐ ┌──────────────────┐
│ TypeScript │ ──parse(*)──► │ │ ──total──► │ TypeScript │
│ Visual canvas│ ──total────► │ structured, │ ──total──► │ Visual canvas │
│ LLM / agent │ ──gen──────► │ typed, │ ──total──► │ BPMN 2.0 │
│ (BPMN) │ ──subset────► │ code-first │ ──total──► │ Serverless WF │
│ (SW) │ ──subset────► │ block tree │ ──total──► │ Step Functions │
└──────────────┘ └──────────────────┘ │ …more │
(*) parse = lossless only for └──────────────────┘
the structured subsetThree laws
Out is total. Anything in the IR renders faithfully to every supported target. This is enforceable because membership is defined as the intersection: a construct is admitted only if it draws cleanly and prints to clean code and maps to a BPMN block-structure and survives as Serverless Workflow.
In is partial. Not every TS / BPMN / SW maps back into the IR — and that is expected. Anything too expressive (arbitrary graphs, gotos, side-effecting expressions, jq spaghetti) has exactly one home: it becomes a leaf (a code activity). The code-first activity is the universal escape valve. We never widen the IR to absorb expressiveness; we push it down into typed code.
The graph omits nothing (graph ↔ IR is a bijection). The visual graph is not a lossy sketch — it is an isomorph of the IR.
IR → graphloses no information, andgraph → IRis total: every program is reachable by building the graph, and every graph element denotes exactly one IR element. We can guarantee this — unlike laws 1–2, which are about foreign surfaces (TS, BPMN) — because we control the graph's expressiveness: it can draw only what the IR can hold.The point of law 3 is not that people will author by dragging nodes (most authoring is conversational — an LLM emits the workflow). The point is that buildability is the proof of completeness. "You can reconstruct the program from the picture" and "the picture hides nothing" are the same property stated twice. The day the graph is not buildable-from is the day it is allowed to lie by omission — to show a clean flow while the program quietly increments a counter or accumulates a total off-screen. Buildability is what forbids that.
Corollary — the renderer may never synthesize. No printer or canvas may add information that is not in the IR (an inferred
let, a hoisted declaration, a hidden counter). Anything that must appear in the output must first exist as a node or field in the IR. The smell test for every primitive: can the graph be reconstructed from what it shows? If not, the construct is hiding state — push the missing piece onto a node (seeLoop.carry, §5) or reject it.
2. Two-tier model
Flopod is two tiers, and the tier boundary is the IR boundary:
| Tier | What it is | In the IR | Owned by | |---|---|---|---| | Branch | the orchestration: order, loops, branches, parallelism, wait | modeled abstractly | the IR / visual / LLM | | Leaf (activity) | a unit of real work: I/O, compute, libraries | referenced by name + signature; body is opaque | code (a real typed function) |
The IR knows every activity's signature (universal — types the wiring, draws the ports). It treats the activity's body as a tagged code payload it never interprets.
Pure code inside an activity is a first-class citizen. Unlike Serverless Workflow or BPMN — where inline code is a marginal
scripttask, a stringified blob — an Flopod activity body is a real typed function in a real file, referenced by the IR. That is what makes it native to coding agents and IDEs (types, imports, LSP), not a string.
3. Design principles (why the syntax is shaped this way)
The abstract syntax is a lowest-common-denominator search guided by the Workflow Patterns catalog (van der Aalst et al.). The basic/structured patterns port everywhere; the advanced ones (arbitrary cycles, OR-join, multi-merge, discriminator) are exactly what does not port. Four representational decisions carry the translatability:
Single-entry/single-exit (SESE) block tree — never a flow graph. A block tree lowers to everything: nested code blocks, nested SW
do, nested ASL states, the block-structured subset of BPMN. An arbitrary edge-graph lowers to almost nothing without node duplication. The graph is a tree, not an arbitrary cycle.Map(fan-out over a collection) is the canonical loop;whileis second-class.for-each/ multi-instance /Mapexists natively in every target. A conditionalwhiledoes not (Step Functions has no native while). PreferMap; constrainLoopto predicate-over-state and accept that some targets must simulate it.Errors are declarative policy, never inline
try.try/catchis imperative-only. Model error handling as per-activity config (retry,timeout,catchTo). This lowers to a codetry, a BPMN boundary event, and an ASLRetry/Catch.Data flow = named single-assignment bindings, not mutable shared variables. SSA-style "each step produces a named output, later steps reference it" lowers to both imperative (
const) and declarative (task input/output wiring). Mutablestateis kept only for genuine loop accumulators.
The row test (how the algebra stays portable as it grows): every proposed primitive must be green across the whole target matrix (§8). If it is not, it either gets demoted into a code leaf, or it stays with an explicit "may be simulated on target X" note. No primitive enters the IR on convenience alone.
4. Minimal core — kernel, irreducible additions, sugar
"What is the simplest grammar that can execute any flow?" has a precise answer: the structured program theorem (Böhm–Jacopini, 1966). Any computable flow can be expressed with three ways of combining work — sequence, selection, iteration — plus the unit of work and the ability to hold a value.
So the irreducible Flopod kernel is four productions:
Workflow ::= Block
Block ::= Step* -- Sequence
Step ::=
| Activity { ref, in, out } -- the atom: does work, yields a named value
| If { cond, then: Block, else: Block } -- Selection
| While { cond, body: Block } -- IterationState is free: named outputs carry values; a mutable accumulator covers the while
condition. This kernel can express any computable workflow — and it is already drawable
(If = diamond, While = back-edge, Activity = box, Sequence = vertical stack).
Simplicity and drawability are not in tension here.
Everything beyond the kernel falls on one of two axes:
Axis 1 — control flow (closed). Sequence + If + While is provably complete. Anything else here is pure sugar, desugarable to the kernel, kept only for nicer visuals:
Map=While+ iterator. Kept: a fan-out draws cleaner, and it ports wherewhiledoesn't.- multi-case
Choice= nestedIf. Kept: one N-arm node instead of a staircase of diamonds.
There is no Assign / mutable-state construct. Everything stays single-assignment: data
accumulation is Map (collect) + a leaf (reduce); the one genuinely mutable value — a loop's
carried accumulator — is folded into the Loop node (carry: { name, init }), not a
free-floating variable. This is what makes graph↔IR a bijection: there is no program state
that lacks a node to own it, and no node that owns nothing.
Axis 2 — workflow capabilities (irreducible). These add semantics the control-flow kernel does not have. They are what makes it a workflow and not merely a program:
Parallel— genuine concurrency + AND-join. Sequentializable for correctness, but a real primitive if you mean true parallel execution.Race— first-wins (structured OR-join): fixed branches, the first to complete wins, the rest are cancelled. Irreducible — "cancel the losers" cannot be desugared toIf/While/Map. This is the approval-or-timeout / fastest-of-N primitive; without it the most common durable pattern (await a signal, but time out) is inexpressible and cannot fall to a leaf.Wait(timer) /Await(external event/signal) — durability, time, the outside world. The structured theorem says nothing about "pause three days for an approval." This is the axis CWL lacks and durable engines (Temporal, Maestro) have.
| Tier | Constructs | Status |
|---|---|---|
| Kernel | Activity, Sequence, If, While | provably complete; never removed |
| Concurrency + durability | Parallel, Race, Wait, Await | irreducible additions |
| Sugar | Map, multi-case Choice | desugarable to kernel |
The leaf valve is complete for the data axis; the only true holes are durability holes. Every counterexample that is computation — a predicate that needs a call, an arithmetic update, a transform — is absorbed by hoisting it into a leaf. So the constructs that must live in the IR are exactly the ones a leaf cannot express because the hard part is the orchestration shape, not the work: concurrency and joins.
Raceis admitted on this basis; the unstructured OR-join (a data-dependent number of active branches) and arbitrary cycles are not — they stay out, by design.
Practical inversion: theory makes While the minimal loop; practice makes Map the
default and While the flagged escape — because unbounded while is a stuck-workflow
risk in a durable engine and does not port to declarative targets, while Map does. The
minimal complete set and the minimal recommended set differ by exactly that swap.
5. Abstract syntax (the ADT)
Workflow ::= { name, version, input: Type, output: Type, body: Block }
Block ::= Step* -- a sequence; single-entry/single-exit
Step ::=
| Activity { id, ref: AtomName, -- the first-class CODE leaf
in: { param: Expr, … },
out?: Binding,
config?: Policy }
| Parallel { id, branches: Block[], out?: Binding[] } -- AND split + join
| Race { id, branches: [{ body: Block, -- OR split, first-wins join ⚠
out?: Binding }], winner?: Binding } -- (losers cancelled)
| Map { id, over: Ref, as: Var, -- the portable loop (fan-out)
mode: 'parallel' | 'sequential',
body: Block, out?: Binding }
| Choice { id, cases: [{ when: Pred, body: Block }],-- XOR
else?: Block }
| Loop { id, carry: { name: Binding, init: Expr }, -- ⚠ portability-flagged FOLD
while: Pred, body: Block, -- carry init is ON the node;
update: Ref, out?: Binding } -- body produces next carry (no Assign)
| Wait { id, for: Duration | Until } -- timer
| Await { id, event: SignalSpec, out?: Binding } -- wait-for-event ⚠
| Return { value?: Expr }
Policy ::= { retry?: Int, backoff?: 'fixed'|'exponential',
timeout?: Millis, catchTo?: StepId }
Pred ::= Or
Or ::= And ( '||' And )*
And ::= Not ( '&&' Not )*
Not ::= '!'? Atom
Atom ::= Compare | Ref | '(' Pred ')' -- bare Ref = truthy check
Compare ::= Operand CmpOp Operand
CmpOp ::= '===' | '!==' | '<' | '<=' | '>' | '>='
Operand ::= Ref | Literal
Expr ::= Ref | Literal | ArrLit | ObjLit -- NO operators, NO calls
Ref ::= Binding ( '.' Ident | '[' Literal ']' )* -- a binding or a member path
Type ::= language-neutral type ref (primitive | named)Notes on the pieces
idis the identity anchor. Stable, human-meaningful step name (adopted from Serverless Workflow's nameddotask list). It survives hand-edits and is the join key between text, visual, and IR. No positional IDs.Bindingis single-assignment. A step'soutnames a value; later steps reference it viaRef. That reference is the dataflow edge drawn on the canvas.AtomNamereferences real code. The activity's signature (intypes,outtype) is in the IR; its body lives in a.tsfile (or another language), linked by name.Exprhas no operators and no calls. "Result capture only, no inline transforms." Wanta + bor a.filter(…)? That is a leaf.- No mutable assignment;
Loopis a fold. There is noAssignstep. The only mutable value is a loop's carried accumulator, and it lives on theLoopnode:carry: { name, init }holds the start value, thebodyproduces the next value, andupdate: Reffeeds it back for the next iteration. Predicate reads the carry. Everything stays single-assignment; the carry'sletis emitted from the node (so it round-trips) rather than being a free-floating variable. Predis closed and drawable. A small predicate builder, not a jq/FEEL string.
6. Data model — blocks as typed functions
SESE gives every block one control entry and one control exit. The data model is the exact mirror: every block has one input interface and one output. Together they make each block a typed function over the data scope — which is what draws as ports + wires.
Every block fits the call template:
const <output> = await <block>( <inputs> )
▲ ▲
named output arguments- Input = arguments. Each argument is an
Expr— a literal or a reference to an earlier named output. Those references are the wires; the argument list is the input ports. - Output = named binding(s). The
const x =on the left. The name is load-bearing: it is the value, the wire source for everything downstream, and (with the stepid) the thing the canvas and the LLM both point at. Each output name is declared exactly once in its scope (single assignment), so a wire's source is never ambiguous.
Wrinkles, all visible in plain code:
- Output is optional — side-effect activities and
Waithave noconst, hence no output port (void). - Output can be destructured —
const [info, dl] = await parallel(…)→ several named outputs, several ports. Loopcarries an accumulator — the carry (let x = init) is owned by the loop node, updated each iteration viaupdate, and exported as the loop's singleout. It is the one place a name is re-bound across iterations; every other block declares a new name once.
Per primitive:
| Step | Inputs (args / refs read) | Output (named binding) |
|---|---|---|
| Activity | its arg refs | its return value |
| Parallel | refs used by branches | tuple of branch outputs |
| Race | refs used by branches | the winning branch's output (+ winner tag) |
| Map | the over collection (+ outer refs) | collected array of body outputs |
| Choice | predicate + arm refs | converged binding, or void |
| Loop | carry.init + predicate/body refs | final carry value |
| Wait | duration | void |
| Await | signal spec | event payload |
| Return | the returned refs | the workflow's output |
The scope law (data single-exit). A binding produced inside a block — a Choice arm,
a Map body, a Loop body — is not visible outside unless it becomes the block's
declared output. Just as control leaves a box only through its one exit, data leaves a box
only through its one output port. No wire crosses a box wall except through that port. This
is what keeps the canvas from spaghetti and the tree portable.
Convergence (the phi rule). For a Choice to have a single output, its arms must agree —
each produces a binding of the same name and compatible type ("whichever arm ran, this is the
result"). If they do not converge, the Choice output is void and arm bindings stay local.
Same for Loop: per-iteration bindings do not escape; only the accumulator does.
Every SESE block is a typed function
(scope_in) → output: it reads a subset of the visible bindings (its inputs) and extends the scope with at most one output binding. Inner bindings are encapsulated; they escape only as the block's output, with convergence required where control paths merge.
7. Targets are renderers behind the waist
| Target | Relationship | Direction | |---|---|---| | TypeScript (committed leaf code + generated branch) | primary code rendering | out total; in lossless for the structured subset | | Visual canvas | primary visual rendering | out total; in total (UI edits are typed IR patches) | | BPMN 2.0 | interchange + execution (UiPath Maestro) | out total; in structured-subset only | | Serverless Workflow | interchange | out total; in structured-subset only | | AWS Step Functions | execution | out total (with ⚠ simulations); in partial |
Adding a new target (Python, Mermaid, Temporal, …) is just another printer behind the waist. It never touches the IR or the authoring surfaces.
The Maestro / BPMN seam
BPMN is an arbitrary flow graph; the IR is a tree. Therefore:
- IR → BPMN export is total and faithful (a structured tree is a subset of BPMN graphs).
- BPMN → IR import is structured-subset only; unstructurable graphs are rejected or flagged. We do not promise round-trip with arbitrary BPMN.
- The hard seam: Maestro tasks reference deployed units (processes, agents, API workflows). An Flopod activity is inline first-class code. Lowering to Maestro packages the code body into an invocable unit. Code stays first-class in authoring; at the Maestro boundary it gets deployed.
8. Portability matrix (the conformance table)
Each primitive is admitted because it is green across the row. ⚠ marks an honest cost —
the primitive must be simulated on that target.
| Step | Code | BPMN | Serverless WF | Step Functions |
|---|---|---|---|---|
| Activity | fn call | Service/Script Task | call / run | Task |
| Parallel | parallel/Promise | AND gateway (split/join) | fork | Parallel |
| Race | Promise.race + cancel | Event-based gateway | fork: { compete } / listen: any | ⚠ task-token + Wait race |
| Map | for…of | multi-instance sub-process | for | Map |
| Choice | if / switch | XOR gateway (split/merge) | switch | Choice |
| Loop (while) | while | Standard Loop activity | for + condition | ⚠ Choice + back-edge |
| Wait | sleep | Timer intermediate event | wait | Wait |
| Await | signal | Message/Signal event | listen | ⚠ task token |
| Return | return | End event + output | output / export | Succeed |
| Policy (retry/timeout/catch) | try + retry loop | Boundary timer/error event | try / catch | Retry / Catch |
9. Worked example
The same workflow in four renderings. (Hacker News digest, from the project conventions.)
9a. IR
name: hn-digest
version: 1.0.0
input: { schema: RunConfig }
output: { schema: Digest }
body:
- fetchIds:
activity: fetchHNTopStoryIds
in: { n: 20 }
out: storyIds
- fetchStories:
map: { over: storyIds, as: id, mode: parallel }
body:
- fetchStory: { activity: fetchHNStory, in: { id: id } }
out: rawStories
- keep:
activity: filterValidStories # filtering is WORK → a leaf
in: { raw: rawStories }
out: stories
- pickTop:
activity: selectTopStories # sort/slice is WORK → a leaf
in: { stories: stories, n: 5 }
out: top
- maybeAlert:
choice:
cases:
- when: top[0].score > 500
body:
- alert: { activity: sendAlert, in: { story: top[0] } }
return: { from: top }9b. TypeScript (what is committed)
The branch is generated/canonical; the activities are first-class hand/agent-written code.
/** @retry 3 @timeout 5000 */
async function fetchHNStory(id: number): Promise<HNStory> { /* real code */ }
async function filterValidStories(raw: (HNStory | null)[]): Promise<HNStory[]> {
return raw.filter(s => s !== null) // WORK lives in the leaf
}
export default async function main(cfg: RunConfig): Promise<Digest> {
const storyIds = await fetchHNTopStoryIds(20) // fetchIds
const rawStories = await parallel(...storyIds.map(id => fetchHNStory(id))) // fetchStories (Map)
const stories = await filterValidStories(rawStories) // keep
const top = await selectTopStories(stories, 5) // pickTop
if (top[0].score > 500) { // maybeAlert (Choice)
await sendAlert(top[0]) // alert
}
return buildDigest(top)
}9c. BPMN 2.0 (sketch)
(start)
→ [Service Task: fetchHNTopStoryIds]
→ [Sub-Process ⟳ multi-instance(parallel): fetchHNStory] ; Map
→ [Service Task: filterValidStories]
→ [Service Task: selectTopStories]
→ <XOR gateway: top[0].score > 500 ?>
├─ yes → [Service Task: sendAlert] ─┐
└─ no ─────────────────────────────┤
→ <XOR merge>
→ (end: Digest)9d. Serverless Workflow (sketch)
document: { dsl: '1.0.0', name: hn-digest, version: '1.0.0' }
do:
- fetchIds: { call: fetchHNTopStoryIds, with: { n: 20 } }
- fetchStories: { for: { each: id, in: '${ storyIds }' },
do: [ { fetchStory: { call: fetchHNStory, with: { id: '${ id }' } } } ] }
- keep: { call: filterValidStories }
- pickTop: { call: selectTopStories, with: { n: 5 } }
- maybeAlert: { switch: [ { case: 'top[0].score > 500',
then: { do: [ { alert: { call: sendAlert } } ] } } ] }(Note SW wires data with ${ } runtime expressions; Flopod uses typed binding refs and
emits the SW expression form only at export.)
10. Decisions (this round)
These were resolved while building the POC (§11) and stress-testing the algebra against counterexamples:
- The in-memory IR is our own typed node tree — not the TypeScript compiler AST. Every node
is a plain tagged object (
{ kind, … }): value semantics, structural equality, JSON-serializable, hand-constructable. ThetsAST appears at exactly one place — thelower/printboundary at the TypeScript seam — and never is the model. Usingts.Nodeas the IR would break Law 2 (it can represent everything the algebra excludes) and would privilege TS over the other authoring surfaces. - No mutable state, no
Assign;Loopis a fold. Data accumulation isMap(collect) + a leaf (reduce). The one irreducibly-mutable value — a loop's carried accumulator — is folded onto theLoopnode (carry: { name, init }+update), not a free-floating variable or a separate declaration. This was forced by Law 3: an inferred/synthesized declaration is a graph omission. Raceis a kernel-tier primitive (first-wins / structured OR-join). Earns its place by the row test (green on Code/BPMN/SW, ⚠ simulated on Step Functions). Covers approval-or-timeout and fastest-of-N, which no leaf can express.- Rejection is a contract, not a string. The lowerer rejects out-of-algebra input with a
structured error: a stable
code, a source location, the offending snippet, and a concretefix. This is the LLM-author's feedback loop (see §11) and is enforced by tests. - The leaf valve is complete for the data axis. Every computation counterexample is absorbed by hoisting it into a leaf; the only true holes are durability/concurrency joins. This bounds where the IR may ever need to grow.
11. Open items
- Primary artifact, given LLM-first authoring. Authoring is mostly conversational, not
graph-dragging (Law 3 is a completeness proof, not an authoring promise). That reframes the
IR-primary vs TS-primary question as: should the LLM generate IR directly (constrained to the
valid set by the schema; no lossy
TS → IRparse) or generate TS (which the model writes more fluently today, but which lands in the rejected zone more often)? Concretely a bake-off: constrained-but-unfamiliar (IR) vs fluent-but-lossy (TS). Leaf bodies stay in real code files either way. - Identity in committed TS. Step
ids are born in the IR and projected into generated branch code (e.g. a folded trailing marker) so the IR is re-derivable from a hand-edit. Marker syntax: TBD. Await/ event correlation. Signal specs and correlation keys need a portable shape; task-token simulation on Step Functions is the constraint to design against.- Type system.
Typeis language-neutral type refs; how named types are shared between the IR signatures and the leaf code (single source vs mirrored) is TBD. Also where the activityinparameter names come from (the POC keys args positionally for lack of signatures). - Lowering coverage. The POC lowers the structured subset (activity,
parallel,for…of,if/else-if,return).Loop/Race/Wait/Awaitexist in the model and print, but have no surface-syntax lowering yet. The third arrow —IR → runtime— should be tested against the same fixtures aslower/printso all three agree (two arrows agreeing is a coincidence). - Convergence with the existing transpiler.
@flopod/transpileris a separate, older path that goests.AST → emitted codewith no IR in between. Long-term there must be one source of truth: the transpiler's job becomes "lower to IR; the IR renders," not a second engine that understands TypeScript independently.
12. Implementation status (the POC)
packages/flopod-ir (@flopod/ir) — a zero-runtime-dependency package realizing the waist:
| File | Role | §ref |
|---|---|---|
| src/model.ts | the IR — discriminated union (Step, Block, Expr, Pred, Race, fold Loop) + constructors | §5 |
| src/lower.ts | lower: TS → IR (the only place ts appears) + structured, actionable rejection errors | §7, Law 2 |
| src/print.ts | print: IR → TS (plain) — total over the model | §7, Law 1 |
| src/emit.ts | emit: IR → durable TS (wf.activity/parallel/each/branch) — the third arrow | §16, §19#1 |
| src/graph.ts | toGraph: IR → visual nodes — the fourth arrow, total over the model (Law 3) | §18 [5], §20 |
Four arrows now exist (lower / print / emit / toGraph). toGraph is the pure
IR → visual graph renderer — total over the model (every Step kind yields a node; Return
omits, matching the runtime), zero-dependency (model + print helpers only), and it derives node
ids on the same scheme as emit. Its node path is the runtime's template path (the dev
store's join key), so static node and live event share identity — proven against the real runtime
(flopod-compiler/test/graph-runtime-equivalence.test.ts runs an emitted branch live and checks
every executed path is a toGraph node). The dev-mode codegen flip is also done — dev now routes
the runtime through emit and builds the static graph from toGraph (verified end-to-end on
samples/test1); legacy + dry run remains only as the LowerError fallback (§12.1 [5]).
Coverage milestone: samples/test1
lowers end-to-end (17 steps) after its accumulation loops were rewritten to Map-collect form
(§15(a) decision); emit reproduces the durable shapes for every construct that lowers today
(Activity, Parallel, Map ×2, Choice, Return). The §17 gate is green: a both-paths harness
(packages/flopod-compiler/test/golden-equivalence.test.ts) asserts lower→emit is behaviorally
identical to transpile (ids normalized — §19#1) across the accepted corpus.
Tests (44, all green): lower (TS→IR), print (exact IR→TS output), emit (durable wf.*
shape), roundtrip (lower(print(ir)) === ir — the Law 1/Law 3 fidelity check), golden-coverage
(§17 construct matrix + real-sample), errors (every rejection carries code + loc + found +
fix). Example rejection:
[OPERATOR_IN_EXPR] workflow.ts:3:27 — `CallExpression` is not allowed in branch position —
Expr is result-capture only (no operators, no calls, no inline transforms)
found: top.filter(s => s.score > 10)
fix: move the computation into a leaf and bind its result, e.g.
`const y = await compute(a, b)` instead of `a + b` or `xs.filter(...)`12.1 Resume point (session handoff)
Done (the convergence [1]–[4] + rename):
@flopod/ir— the model + three arrows:lower(TS→IR),print(IR→plain TS),emit(IR→durable TS).samples/test1lowers end-to-end (17 steps; rewritten to Map-collect form).- Package renamed
@flopod/transpiler→@flopod/compiler(frontendlower+ validatordiagnose+ backendemit, over the pure@flopod/ir). - The flip is live:
transpileSource(non-dev) routes through the IR (transpileViaIR = diagnose → lower → emit → assemble); unified error surface (§19#3).transpileLegacyretained for dev mode + deferred constructs. - Golden gate green; 303 tests pass,
tscclean across@flopod/ir/@flopod/compiler/@flopod/cli.
Decided this session: narrow accumulation (§15a) · derive ids (§19#1) · defer while/let
(§19#2) · both-run unified errors (§19#3) · adopt strict Expr algebra (§19#6) · packaging §20.
Next steps (pick up here):
- [5] Graph renderer off the IR — the main remaining thread. Folds in two things: rebuild
the
static-graphbuilder to read the IR (Law 3 made visible), and that's what lets dev-mode route through the IR too (today it stays ontranspileLegacybecause the live graph keys control-flow metadata off positional ids — reconcile with derived ids here, §19#1).- Part 1 done — the pure arrow:
packages/flopod-ir/src/graph.ts(toGraph(wf): { nodes, edges }), total over the model, ids derived on emit's scheme. Tests:flopod-ir/test/graph.test.ts. - Part 2a done — node identity reconciled with the runtime (the hard part).
toGraph's nodepathis now the runtime's template path: the/-joined stack of thewf.*id strings, in the form the dev store keys on afterdeindexstrips per-iteration index segments. Mirrorsflopod-runtime/src/{path,runtime}.tsexactly — full id strings as segments (not positional), branch arms add no segment, spread-Mapgets a synthetic…/templatechild, repeated siblings disambiguateseg/seg[1]. Proven against the REAL runtime:flopod-compiler/test/ graph-runtime-equivalence.test.tsruns an emitted branch on a live runtime and asserts every executed path lands on atoGraphnode (emit ↔ graph agree on runtime identity). Required one emit change: theChoiceid is now allocated before its arms (so the branch id can parent the arm paths; emit & graph must agree) — golden parity unaffected (ids normalized). 178 tests green,tscclean (@flopod/ir+@flopod/compiler). - Part 2b done — the codegen flip. Dev mode now routes through the IR.
transpileSource's dev branch callstranspileDevViaIR(transpile.ts): lower →emitBranch(derived-id runtime body) →buildStaticNodesfromtoGraph(IR)(paths = runtime template paths, JSDoc activity titles merged by label) → assemble with the static nodes embedded as a compile-time literal — no dry run, noGraphBuilderPlugin. Falls back totranspileLegacy+ dry run only whenlowerSourcethrowsLowerError(durablelet/while); genuine diagnostics still surface. The staleextractStaticGraphspike is deleted. Verified end-to-end onsamples/test1:flopod dev→ static graph now carries derived ids (parallel_0…, 25 nodes vs 49 — spread-maps show container +/template, no enumerated instances), the run completes 64/64, and every live activity path overlays a static node (0 unmatched, excluding spread-inner which roll up to the container). Tests:flopod-compiler/test/dev-ir-routing.test.ts(IR route + legacy fallback). 315 tests green repo-wide;tscclean.- Known gap: control-flow JSDoc titles (
extractControlFlowMeta, keyed positionally) are not merged onto IR-derived ids —toGraphsupplies control-node labels instead. Re-key on derived ids if branch/loop title round-trip is needed (§19#5).
- Known gap: control-flow JSDoc titles (
- Part 1 done — the pure arrow:
- Deferred constructs —
while/Loop(fold-vs-grow, §19#2) and durablelet, to be designed when a real workflow needs them, with a concrete use case (not synthetic tests). This is step 1 of thewf.statedeletion plan (§21): landingLooplowering is what unblocks deleting the legacy path and user-facing durablelet—wf.statethen survives (if at all) only as the internal durability target for a foldedLoop.carry. - Open follow-ons in §19: activity arg names from signatures (#4), display metadata round-trip
(#5). (
?.admitted intoRef— §19#6, done.)
Key files: packages/flopod-ir/src/{model,lower,print,emit}.ts;
packages/flopod-compiler/src/transpile.ts (transpileSource/transpileViaIR/transpileLegacy/
assembleFromBody); tests packages/flopod-{ir,compiler}/test/golden-*.test.ts.
13. Next steps — adopting the IR
Why this work matters (product thesis). The point of Flopod is an agent that creates, debugs, and maintains automations — killing RPA's dominant cost, maintenance (see the README). The IR convergence below serves that thesis directly: a single, structured, addressable definition is what lets an agent localize a failure to a node and reason about a repair. Keep two consequences of the thesis in view as the refactor proceeds, because they shape what the IR and its trace must eventually carry:
- The leaf is the maintenance surface. Automations rot at the leaf↔world boundary (selectors, APIs, screens). The IR rightly treats leaf bodies as opaque (for execution/portability), but the trace at that boundary will need environmental observation — DOM/screen state, API payloads, screenshots, expected-vs-actual — not just inputs/outputs/error. RPA maintenance is a perception problem as much as a code problem.
- A verification oracle. Trustworthy auto-repair needs expected-outcome assertions / golden runs to repair against. Out of scope for the convergence itself, but the IR/trace should not foreclose it.
Not a big-bang refactor. The transpiler currently runs real samples (ts.AST → emitted
runtime code, no IR in between). We do not rip it out. The IR slides in behind the
transpiler's existing interface; we prove equivalence on the real corpus, then converge. Most of
"the rest of the app" never changes, because the transpiler's public signature is preserved and
the IR becomes its internal center.
The dependency chain
[1] lowering parity → [2] IR → runtime emit → [3] golden equivalence → [4] route transpiler through IR → [5] new renderers- Lowering parity. Today
lowerhandles a partial subset (activity,parallel,for…of,if/else-if,return). It must accept everything the transpiler accepts —while/Loop, destructuring,parallel(...xs.map(...)), JSDoc@retry/@timeout→Policy, etc. — or we consciously narrow the accepted language. This is the bulk of the work and the real test of whether the algebra covers the existing corpus. - The third arrow —
IR → runtime code. The actual point of convergence.print.tsemits plain TS; the transpiler emits durable calls (wf.activity,wf.parallel,wf.each). A new emitter must produce those from the IR. The transpiler's existingemit()already knows the target shape — it just readsts.AST; porting it to read IR is tractable. - Golden equivalence (the gate). For every existing sample/fixture, assert
lower → emit(IR)is behaviorally identical to today'stranspile(ts). Until the IR path reproduces the transpiler's output on the real corpus, nothing downstream changes. This is also the "three arrows must agree" check —lower/print/emit-runtimeon the same fixtures. - Route the transpiler through the IR. Change
transpileSourceinternally tots → IR → emit, keeping its public signature and output identical. The IR is now the center and zero consumers change (CLI, dev mode, runtime all still call the same function). - New renderers. Graph and BPMN become additional readers of the IR — purely additive, they touch nothing existing. This is where Law 3 becomes visible.
Recommended first move
Do [3] first, as a measurement. Build a golden-test harness that runs the existing samples through both paths and diffs the output. It immediately quantifies the gap — i.e. how big [1] and [2] actually are — instead of guessing, and de-risks everything after it.
Fork decision — replace the transpiler
The fork is resolved: the IR becomes the single execution center. @flopod/transpiler's
public surface (transpileSource, createFlopodTransformer, getWorkflowMeta) is preserved,
but its internals are re-pointed at ts → IR → emit. One source of truth, most work. The rest
of §14–§19 is the detailed build plan for that goal.
14. The corpus we must reproduce (what "parity" is measured against)
"Parity" is not abstract — it is exact behavioral reproduction of what transpile.ts emits
today on the existing samples and fixtures. The accepted language of the current transpiler
(from packages/flopod-compiler/src/transpile.ts) is the bar:
| # | Surface construct | Transpiler output (the durable shape to reproduce) |
|---|---|---|
| C1 | const x = await act(args) | const x = await wf.activity('act', () => act(args), { varName, args:[…] }) |
| C2 | await act(args) (side effect) | same, no const, no varName |
| C3 | parallel(a(x), b(y)) | wf.parallel('parallel_<pos>\|a, b', [() => wf.activity(…), …]) |
| C4 | parallel(...xs.map(i => act(i))) | wf.parallel('parallel_<pos>\|act[]', (xs ?? []).map(i => () => wf.activity(…))) |
| C5 | const [a,b] = await parallel(…) | destructured binding over C3/C4 |
| C6 | for (const x of xs) {…} | wf.each('for_<pos>\|for each x', xs, async x => {…}) |
| C7 | if/else if/else | wf.branch('if_<pos>\|if <cond>', <key-ternary>, { if, elif_1, else\|skip }) |
| C8 | while (cond) {…} | wf.repeat('while_<pos>', async () => { if (!(cond)) return STOP; … }) |
| C9 | do {…} while (cond) | wf.repeat('do_while_<pos>', async () => { …; if (!(cond)) return STOP }) |
| C10 | let n = 0 / [] / new Map() | const n = await wf.state('n_<pos>', …) → DurableVar/List/Map |
| C11 | mutations n = …, n += …, n++, arr.push/filter, m.set/delete | await n.set(…) / await arr.push(…) etc. |
| C12 | JSDoc on leaf (title, @description) | activity/control-flow display metadata (dev mode) |
| C13 | JSDoc @retry/@backoff/@timeout | not currently emitted — see §15(c) |
| C14 | default-export params + extractParamsSpec | typed start-run form schema; codeVersion stamping |
The golden corpus: samples/test1/src/main.ts, samples/control-plane-demo/*, and every
fixture under packages/flopod-compiler/test/*. Parity = identical emit on all of them.
15. Lowering parity matrix (chain step [1])
lower.ts today accepts a strict subset (C1, C2, C3, C6 sequential, C7, return, refs/preds).
The gap to the C-corpus, and the IR node each lands on:
| Gap | In corpus | lower today | IR target | Work |
|---|---|---|---|---|
| parallel(...xs.map(…)) spread | C4 | rejected | Map { mode:'parallel' } (already prints, §print.ts) | detect spread-map in lowerAwaited; emit Map not Parallel |
| destructured parallel out | C5 | single out only | Parallel.out: Binding[] | read ArrayBindingPattern in lowerStatement |
| for…of mode | C6 | always sequential | Map.mode | fine as-is (sequential); parallel comes via C4 |
| while / do…while | C8/C9 | UNSUPPORTED_STATEMENT | Loop (fold) or see §15(a) | the hard one — reconcile fold vs. let+repeat |
| durable let + mutations | C10/C11 | rejected | no IR node — see §15(a) | the deepest conflict |
| JSDoc @retry/@timeout | C13 | ignored | Activity.config: Policy | parse leaf JSDoc tags → Policy |
| activity arg names | — | positional '0','1' | Activity.in keyed by param name | needs leaf signatures (open item §11 #4) |
| input/output types | C14 | hardcoded void | Workflow.input/output: Type | read default-export param/return type |
| display metadata | C12 | none | side-table or node field | decide: on-node vs. external map |
Two hard reconciliations (decide before writing [1])
(a) Mutable let state vs. the no-Assign IR. The corpus uses free durable let vars
(C10/C11) — counters, accumulator lists, maps — mutated anywhere in the branch. The IR
forbids all mutable state except a Loop's carry (§4, §5, Law 3). This is the single
deepest conflict in the whole convergence. Three ways out:
- Fold-map (faithful to the IR). Recognize the loop-carried
letpattern — aletinitialized before awhile/for, read in the condition, reassigned in the body — and lower it toLoop.carry+update. Non-loop-carriedlet(a flag set in one branch arm, a running total outside any loop) either folds onto the enclosing block or is rejected with a structured error pointing at the leaf-hoist fix. Most faithful; most lowering logic; rejects some currently-accepted programs. - Narrow the language. Declare durable free
letout-of-algebra; require accumulation viaMap(collect)+leaf(reduce). Smallest IR, but breaks existing samples that uselet— a migration, not a drop-in. Cleanest model, highest corpus churn. - Grow the IR an
Assign/Statenode. Admit mutable state as a first-class step. Fastest parity, but violates Law 3 (a synthesized declaration is a graph omission) and the §10 decision. Rejected unless 1+2 prove infeasible on the real corpus — measure first (§17).
Recommendation: (1), gated on §17 telling us how many corpus lets are not loop-carried.
If that number is near zero, (1) is nearly free and (2)'s churn is small.
DECIDED (accumulation sub-case) — narrow, option (2). Mutable collection accumulation — a branch
constinitialized with an empty/literal collection ([],{},new Map/Set), and thearr.push(…)/m.set(…)/obj[k] = …that feeds it — is out of the algebra.lowerrejects it withMUTABLE_ACCUMULATION+ the Map-collect remedy; the sanctioned form is a fan-out that collects (aMap/parallel(...xs.map())bound to a name) plus a leaf to reduce. Consequence:samples/test1is now consciously out-of-algebra at its accumulation lines (const npmPackages = []+.push,const topComments = {}+ index-assign) and must be rewritten to Map-collect form before it lowers end-to-end. The broader durable-letcase (C10/C11a — counters, flags) is still open and scoped to §19#2.
(b) The id scheme — load-bearing, must be decided first. The transpiler's runtime ids are
positional and label-bearing: for_<line>_<col>|for each x, if_<line>_<col>|if <cond>,
parallel_<pos>|act[], while_<pos>, n_<pos> for state, bare 'act' for activities. These
strings are not cosmetic — they are the event-log keys (resume matches on them), the graph
node ids, and the join key for JSDoc metadata (extractControlFlowMeta splits on |). The IR's
StepId is semantic and position-free (§5: "survives hand-edits, no positional IDs"). For
golden equivalence the emitter must reproduce the exact id strings — but the IR has thrown the
positions away. Options:
- Stamp positional ids into the IR at lower time (
id = "for_<pos>"). Reproduces the log exactly; but contaminates the IR with source positions and forfeits the hand-edit-stable property — semantic ids become a later, separate concern. - Carry a
provenanceside-channel ({ id, srcPos, label }) on each step, kept out of structural equality. IR stays semantic; the emitter reads provenance to rebuild legacy ids. Round-trip equality (lower(print(ir)) === ir) must ignore provenance. - Bump the id scheme +
codeVersionto semantic ids in one migration. Breaks in-flight resume (acceptable for a one-time cutover; no production runs yet), and frees the IR of positions. Cleanest end state, but the golden harness can then only assert behavioral equivalence, not byte-identical ids.
Recommendation: (2) for the parity phase (keeps the harness strict and the model clean),
with (3) as the eventual end state once parity is proven and renderers consume semantic ids.
This is the first thing to decide — it shapes both lower and the emitter.
16. The emitter — IR → durable runtime calls (chain step [2])
A new emit.ts in @flopod/ir (the third arrow). print.ts stays as the plain-TS renderer;
emit.ts is the durable renderer. Per-Step mapping (reproducing §14):
| IR Step | Emits |
|---|---|
| Activity | wf.activity(ref, () => ref(args), { varName?, args? }); config → retry/backoff/timeout opts |
| Parallel | wf.parallel(id, [() => wf.activity(…), …]); out → single or destructured binding |
| Map parallel | wf.parallel(id, (over ?? []).map(as => () => wf.activity(…))) |
| Map sequential | wf.each(id, over, async as => {…}) |
| Choice | wf.branch(id, <key-ternary from cases>, { if, elif_n, else\|skip }) — regenerate the case-key ternary and the skip no-else arm |
| Loop | wf.repeat(id, async () => { if (!while) return STOP; …; carry = update }) — depends on §15(a) |
| Wait/Await/Race | out of current corpus — the transpiler has no wait/signal/race. Emitting these requires the runtime primitives to exist first; flag as a follow-on, not part of parity |
| Return | return <expr> verbatim |
The emitter is where §15(b)'s id decision is consumed: every wf.* first argument is the
legacy id string (via provenance) or the new semantic id (post-cutover).
17. Golden-equivalence harness (chain step [3] — do this first)
Before any of [1]/[2] is written, build the measurement. A test that, for every corpus file, runs both paths and diffs:
transpileSource(src) → legacy durable TS ┐
lower(src) → emit(IR) → IR-path durable TS ┘ → normalize (ts printer) → assert equalWhat it buys, immediately and before guessing:
- Quantifies §15(a): count corpus
lets that are not loop-carried → picks option 1 vs. 2. - Quantifies §15(b): shows exactly which id strings must be reproduced.
- Sizes [1]/[2]: every diff is a concrete parity task; "all green" is the gate for the flip.
- Three-arrow agreement: extend the existing
roundtriptest solower/print/emitare checked on the same fixtures — two arrows agreeing is a coincidence; three is a proof. Start the harness in a report mode (list diffs, don't fail) so the gap is visible while it shrinks, then flip it to an assertion once green.
18. The flip and the renderers (chain steps [4]–[5])
- [4] Route through the IR. Change
transpileSourceandcreateFlopodTransformerinternally tots → lower → emit, public signatures unchanged. The diagnoser (diagnose.ts) and the IR's lowering errors must be reconciled — one rejection contract, not two (todaydiagnose.tsruns before emit; lowering errors are the IR's contract). Decide whetherdiagnosebecomes a thin pre-check or is subsumed bylower's structured errors. Zero downstream consumers (CLI, dev mode, runtime) change.- Part 1 done:
assembleFromBody(mainBody, source)is the seam — it reuses the whole module frame (imports, leaf passthrough, runtime setup, input handling, invocation) and slots in a pre-emitted IR body. Proven equivalent totranspileSourceon the accepted corpus, full module (not just the branch), ids normalized —golden-equivalence.test.ts. - Part 2 done:
transpileViaIR(source)wires the full pipelinediagnose → lower → emit → assemble, withLowerErrormapped into the singleTranspileErrorsurface (§19#3: both run, unify). Proven ≡transpileSourceon the accepted corpus; both error sources unify.@flopod/transpilernow depends on@flopod/ir. - Part 3 (rename) done:
@flopod/transpiler→@flopod/compiler(frontendlower+ validatordiagnose+ backendemit; over@flopod/ir, the pure model). CLI imports + deps updated; binflopod-compile. - Flip done (§19#6 decided: adopt strict).
transpileSource(non-dev) now routes through the IR — the IR is the execution center for the supported language. The IR enforces the strictExpralgebra (ternary,??/?., method chains, spread-in-args in the branch are rejected — they belong in leaves), which is stricter than the legacy transpiler; that narrowing is the adopted design.transpileLegacyis retained for dev mode (graph keys off positional ids, pending [5]) and the deferredwhile/let(§19#2). The legacy-permissive test suites (limits/edge-cases/diagnostics) now exercisetranspileLegacy; a new suite locks the strict IR rejections. Public signatures unchanged — CLI/runtime untouched. 303 tests green.
- Part 1 done:
- [5] Renderers as readers. The graph becomes an additional consumer of the same IR —
purely additive. The existing
static-graphbuilder (dev mode) is the first target to re-point at the IR, which is also where Law 3 (graph ↔ IR bijection) becomes observable. (BPMN / SW are deferred — design rationale only, not near-term renderers; see §20.)- Part 1 done: the pure arrow —
@flopod/ir'stoGraph(wf): { nodes, edges }(graph.ts), total over the model, node ids derived on emit's scheme so static nodes join to live events. - Part 2a done — runtime identity reconciled.
toGraph's nodepathis the runtime's template path (fullwf.*id strings as segments, index-stripped form the store keys on). Proven by running an emitted branch on a live runtime and asserting every executed path lands on atoGraphnode (graph-runtime-equivalence.test.ts). One emit change:Choiceid now allocated before arms (emit & graph must agree; golden parity unaffected). - Part 2b done — the codegen flip.
transpileSource's dev branch now routes through the IR (transpileDevViaIR): runtime runs the IR-emitted durable calls (derived ids) and the static graph is built fromtoGraph(IR)at compile time — no dry run. Falls back totranspileLegacyonly onLowerError(durablelet/while). Verified end-to-end onsamples/test1(flopod dev): derived ids, run completes 64/64, every live path overlays a static node. The legacyGraphBuilderPlugin(dry-run observer) now serves only the fallback path; the staleextractStaticGraphspike is deleted. See §12.1 [5] for the per-step detail.
- Part 1 done: the pure arrow —
19. Decision log — close before coding [1]/[2]
- Id scheme (§15(b)) — provenance side-channel vs. positional-in-IR vs. semantic cutover.
Blocks both
lowerandemit; decide first. letstate +while/Loop(§15(a)) — fold-map vs. narrow vs. grow.DECIDED — fold (Loop lowering DONE, §22). The loop-carried
let+whilenow lowers to a foldLoopwithLoop.until(poll-until); the carry's durability targets the runtimewf.stateprimitive (kept as an internal lowering target, no longer user-facing). Non-loop-carried durableletstays narrowed —DURABLE_STATE_UNSUPPORTED— as doesdo…while(LOOP_UNSUPPORTED) and a carry-lesswhile(LOOP_CARRY_UNSUPPORTED). The "grow anAssign" option was not needed. See §22 for the full design + status.- Rejection contract (§18) —
diagnose.tsvs.lowererrors as the single source. - Activity arg names (§15, open item §11 #4) — where leaf parameter names come from
(signatures) so
inis keyed by name, not position. - Display metadata (C12) — on-node field vs. external map, and whether it survives round-trip.
- IR strictness vs. legacy permissiveness — DECIDED: adopt strict. The IR
Expralgebra rejects ternary,??, method chains, and spread-in-args in the branch; these must move into leaves. The defaulttranspileSourceenforces this. Follow-on DONE —?.admitted. Optional chaining is member-access (not a transform), so it is now preserved in theRefpath:PathSegcarries anoptional?flag,lowerreadsquestionDotToken,printre-emits?./?.[]. This fixed a latent bug —a?.bwas previously silently downgraded toa.b(losing null-safety). Tests:flopod-ir/test/optional-chain.test.ts.
20. Packaging — one compiler, two packages
With an IR in the middle, the old @flopod/transpiler is not a transpiler anymore (source→source,
no model); it is a compiler with a frontend, an IR, and backends. The IR is what earns the name.
TS branch ──parse/lower──► IR ──emit────► durable TS (backend: codegen)
(frontend) │ ──print───► plain TS (backend)
└───► graph (backend, later)- frontend =
lower(TS → IR) + diagnostics — the transpiler's parse half, renamed. - IR = the model. The narrow waist.
- backends =
emit(durable TS),print(plain TS), andgraph(visual nodes,toGraph).
The split is on the dependency seam, not the pipeline stages:
@flopod/ir— the model only, kept zero-dependency (notypescript). This is the whole point of the waist: anything that reads/manipulates IR (LLM authoring, the graph) does so without pulling in the TS compiler.tsappears only at the lower/emit boundary (§5, §10).print.tsandgraph.tsare pure (imports only the model + print helpers), so they stay here.@flopod/compiler— the TS-coupled pipeline:lower+emit+ diagnostics + CLI + dev-mode. Depends on@flopod/ir+typescript. This is@flopod/transpiler, renamed and re-scoped. The rename rides along with the back-end migration (whenemit.tslands and parse moves tolower), not as a churn-only commit.
We do not merge the model into the compiler — that would couple the waist to one of its own
rendering surfaces (typescript), the one thing the design forbids.
Near-term backends are emit, print, and graph (toGraph — the IR → nodes arrow; dev mode
now renders from it, [5] done). BPMN /
Serverless Workflow / Step Functions remain in the design as the justification for the narrow
waist and the portability matrix (§8) — they are not built and not packaged now. Do not
pre-split packages for them; a future @flopod/bpmn (etc.) spins out only when actually needed.
21. Retiring wf.state and the legacy path (the deletion plan)
DECIDED — delete the legacy path; do NOT gate on
Looplowering.while/durable-letare narrowed (rejected with a structured error) until/unless §22 lands; we are not keeping legacy alive to support them. Done so far: the dev legacy fallback is removed —transpileSourceroutes everything (dev + build-time codegen) through the IR; aLowerErrornow surfaces as a cleanTranspileErrorinstead of silently dropping to legacy (181 compiler/ir tests green).DONE — the legacy path is fully deleted (§23 stages 3+4).
flopod buildusescreateIRTransformer()(IR-driven codegen spliced into the TS transformer as synthetic nodes with per-statementsetSourceMapRange, so TS emits production source maps in one pass — no composition). With nothing routing to it, all the legacy AST-rewrite machinery was then removed (~840 lines:transpileLegacy,createFlopodTransformer,transformMainFn, thecollectLetVars/rewrite*/tryRewriteParallel*family,extractControlFlowMeta, theemitdry-run branch).while/durable-letstay narrowed (rejected with a structuredLowerError) until §22Looplowering lands. 283 tests green;tscclean. Only the runtime's now-orphanedGraphBuilderPluginclass remains as a separate optional cleanup.
Goal: delete user-facing durable let and the entire transpileLegacy path. The IR direction
is "no free mutable state, no Assign" (§4, §10) — the IR route already rejects durable let
(DURABLE_STATE_UNSUPPORTED). So wf.state survives only as (a) the legacy emitter that
converts branch let → wf.state, and (b) the runtime primitive it lowers to. The end state
removes (a) entirely; (b)'s fate is a deliberate call (below). This is the last thing keeping the
legacy path alive now that the dev graph routes through the IR ([5]/Part 2b done).
Two distinct things called "wf.state" — don't conflate them:
| | What | Plan |
|---|---|---|
| User-facing free durable let | a branch let/accumulator the legacy AST rewrites to wf.state | Delete — narrow (Map-collect + leaf reduce) or fold into Loop.carry |
| The legacy emitter machinery | collectLetVars, rewriteBody, LetVarKind, DURABLE_LIST/MAP_MUTATE, the dry-run GraphBuilderPlugin, extractControlFlowMeta in transpile.ts; transpileLegacy itself | Delete once nothing routes to it |
| The runtime primitive | wf.state / DurableVar/DurableList/DurableMap (durable-var.ts, runtime.ts) | Keep or retarget — it is the natural durability mechanism for a folded Loop.carry (a carry must survive resume). Likely retained as an internal lowering target, no longer surfaced to users |
The one blocker: the legacy path is still the fallback for the two deferred constructs
(while/do…while and durable let, §19#2). Delete the fallback → those workflows stop
compiling. So the deletion is gated on the IR absorbing them, which is the deferred fold-vs-narrow
decision. Sequence:
- Decide & build
Looplowering (§19#2). Take a real workflow that needs a branch loop / accumulator (not a synthetic test). Apply the recommendation: fold loop-carriedletintoLoop.carry+update; narrow non-loop-carriedlet(reject with the Map-collect remedy — already decided for the collection sub-case, §15a). Landlower(while→Loop) andemit(Loop→ durable calls). Decide there whether the carry's durability lowers to the runtimewf.stateprimitive (recommended — reuse it, just stop exposing it) or a new carry-specific checkpoint. - Remove the fallback. With the IR accepting the real loop corpus, change
transpileSourceso dev mode no longer catchesLowerError→ legacy; the IR path is the only path. KeeptranspileLegacyexported for one release as an escape hatch if a workflow regresses. - Delete the legacy emitter. Remove
transpileLegacy,emit's dev dry-run branch, theGraphBuilderPlugindry run,collectLetVars/rewriteBody/LetVarKind/DURABLE_*_MUTATE, andextractControlFlowMeta. The §17 golden-equivalence harness (legacy ≡ IR) loses its purpose once legacy is gone — collapse it to an IR-only golden snapshot. The legacy-permissive suites (limits/edge-cases/diagnostics) either migrate to the strict IR or are dropped. - Settle the runtime primitive. If step 1 retargets
Loop.carryontowf.state, keepdurable-var.tsbut mark it internal (not a user surface). If a carry-specific checkpoint replaced it and nothing else uses it, deletedurable-var.tsand thewf.stateruntime method too.
Gate: each deletion step is guarded by "no real workflow uses the construct being dropped" — the
same evidence bar as the §19#2 defer decision. Until step 1 has a concrete use case, this stays
planned, not started; the legacy path remains only as the durable-let/while fallback.
22. Loop lowering design (step 1, anchored on poll-until-ready)
The concrete use case that drives Loop lowering (§21 step 1): poll an external job until it
reports ready, with a bounded attempt count. This is the canonical durable-workflow loop — the
human/external-system wait — so it is the right anchor (not a synthetic counter).
let attempts = 0
while (attempts < maxAttempts) { // bound (top exit)
const status = await checkJob(jobId)
if (status === 'done') break // success (mid-body exit)
await sleep('30s') // durable timer
attempts = await inc(attempts) // carry update (computation → leaf)
}What it maps to
| Source line | IR target | Status |
|---|---|---|
| let attempts = 0 | Loop.carry { name: 'attempts', init: 0 } (fold — not wf.state) | model exists (§5) |
| while (attempts < maxAttempts) | Loop.while (reads the carry + an outer ref) | model exists |
| const status = await checkJob(jobId) | Activity in Loop.body | lowers today |
| if (status === 'done') break | mid-body conditional exit | NO model node — the gap |
| await sleep('30s') | Wait { for: { iso8601 } } → wf.sleep | model exists; no lower/emit yet |
| attempts = await inc(attempts) | Loop.update: Ref (body binding fed back) | model exists; no lower yet |
The model gap — a loop needs two exits, §5 Loop has one
Loop (§5) carries a single while predicate (top-of-loop). Poll-until exits on success mid-body
and on exhaustion at the top — the classic "loop-and-a-half". Neither the IR nor the legacy path
expresses it (legacy has no break handling at all; break inside a wf.repeat arrow is a syntax
error). Three ways to close it:
Loop.until— a second, bottom-of-loop predicate (RECOMMENDED). Extend the node:Loop { carry, while, body, until?, update }.untilis evaluated at the end of the body over body-local bindings; true ⇒ exit. Poll-until becomeswhile: attempts < maxAttempts,until: status === 'done'. Still single-entry/single-exit (two test points, one exit edge), so it stays drawable (a diamond at the loop bottom) and portable (lowers to a bottomif … return STOP; BPMN standard-loop with an exit gateway; ASL Choice).breakin the surface syntax is recognized only in the sanctioned shapeif (P) breakas the last guard before the loop tail, and folded ontountil; any otherbreak/continueis rejected with a structured error.- Flag-and-merge (pure structured, no model change). Hoist success into a carried boolean:
let done = false; while (attempts < max && !done) { …; done = (status === 'done') }. Zero new IR, but forces a second carry (multi-carryLoop), a rewrite of the user's naturalbreak, and a carry whose update is a predicate result (needs the §-below computation exception or a leaf). - First-class
Breakstep. Admitbreakas an IR step (early exit ⇒return STOP). Simplest lowering, but it is an unstructured jump — breaks SESE and Law 3's "single exit" cleanliness. Rejected unless 1–2 prove unworkable.
Recommendation: (1) Loop.until. It models the real control flow as a node (so the graph shows
why the loop exits — the success test is not hidden), keeps SESE, and ports. It is a small, total
extension: print/emit/graph/toGraph each grow one optional predicate.
Secondary decisions this use case forces
- Carry update is computation.
attempts = await inc(attempts)routes the increment through a leaf (inc) because the branch forbids arithmetic. That is consistent but ergonomically heavy for+1. Open call: keep the leaf rule (consistent, no exception) vs. admit a tiny arithmetic exception for a carry update (carry = carry ± literal) emitted from the node. Lean: keep the leaf rule for now — revisit only if real workflows makeinc-style leave
