npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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 subset

Three laws

  1. 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.

  2. 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.

  3. 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 → graph loses no information, and graph → IR is 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 (see Loop.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 script task, 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:

  1. 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.

  2. Map (fan-out over a collection) is the canonical loop; while is second-class. for-each / multi-instance / Map exists natively in every target. A conditional while does not (Step Functions has no native while). Prefer Map; constrain Loop to predicate-over-state and accept that some targets must simulate it.

  3. Errors are declarative policy, never inline try. try/catch is imperative-only. Model error handling as per-activity config (retry, timeout, catchTo). This lowers to a code try, a BPMN boundary event, and an ASL Retry/Catch.

  4. 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). Mutable state is 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 }              -- Iteration

State 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 where while doesn't.
  • multi-case Choice = nested If. 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 to If/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. Race is 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

  • id is the identity anchor. Stable, human-meaningful step name (adopted from Serverless Workflow's named do task list). It survives hand-edits and is the join key between text, visual, and IR. No positional IDs.
  • Binding is single-assignment. A step's out names a value; later steps reference it via Ref. That reference is the dataflow edge drawn on the canvas.
  • AtomName references real code. The activity's signature (in types, out type) is in the IR; its body lives in a .ts file (or another language), linked by name.
  • Expr has no operators and no calls. "Result capture only, no inline transforms." Want a + b or a .filter(…)? That is a leaf.
  • No mutable assignment; Loop is a fold. There is no Assign step. The only mutable value is a loop's carried accumulator, and it lives on the Loop node: carry: { name, init } holds the start value, the body produces the next value, and update: Ref feeds it back for the next iteration. Predicate reads the carry. Everything stays single-assignment; the carry's let is emitted from the node (so it round-trips) rather than being a free-floating variable.
  • Pred is 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 step id) 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:

  1. Output is optional — side-effect activities and Wait have no const, hence no output port (void).
  2. Output can be destructuredconst [info, dl] = await parallel(…) → several named outputs, several ports.
  3. Loop carries an accumulator — the carry (let x = init) is owned by the loop node, updated each iteration via update, and exported as the loop's single out. 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. The ts AST appears at exactly one place — the lower/print boundary at the TypeScript seam — and never is the model. Using ts.Node as 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; Loop is a fold. Data accumulation is Map (collect) + a leaf (reduce). The one irreducibly-mutable value — a loop's carried accumulator — is folded onto the Loop node (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.
  • Race is 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 concrete fix. 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 → IR parse) 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. Type is 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 activity in parameter 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/Await exist in the model and print, but have no surface-syntax lowering yet. The third arrow — IR → runtime — should be tested against the same fixtures as lower/print so all three agree (two arrows agreeing is a coincidence).
  • Convergence with the existing transpiler. @flopod/transpiler is a separate, older path that goes ts.AST → emitted code with 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/test1 lowers end-to-end (17 steps; rewritten to Map-collect form).
  • Package renamed @flopod/transpiler@flopod/compiler (frontend lower + validator diagnose + backend emit, 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). transpileLegacy retained for dev mode + deferred constructs.
  • Golden gate green; 303 tests pass, tsc clean 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):

  1. [5] Graph renderer off the IR — the main remaining thread. Folds in two things: rebuild the static-graph builder to read the IR (Law 3 made visible), and that's what lets dev-mode route through the IR too (today it stays on transpileLegacy because 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 node path is now the runtime's template path: the /-joined stack of the wf.* id strings, in the form the dev store keys on after deindex strips per-iteration index segments. Mirrors flopod-runtime/src/{path,runtime}.ts exactly — full id strings as segments (not positional), branch arms add no segment, spread-Map gets a synthetic …/template child, repeated siblings disambiguate seg/seg[1]. Proven against the REAL runtime: flopod-compiler/test/ graph-runtime-equivalence.test.ts runs an emitted branch on a live runtime and asserts every executed path lands on a toGraph node (emit ↔ graph agree on runtime identity). Required one emit change: the Choice id 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, tsc clean (@flopod/ir + @flopod/compiler).
    • Part 2b done — the codegen flip. Dev mode now routes through the IR. transpileSource's dev branch calls transpileDevViaIR (transpile.ts): lower → emitBranch (derived-id runtime body) → buildStaticNodes from toGraph(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, no GraphBuilderPlugin. Falls back to transpileLegacy + dry run only when lowerSource throws LowerError (durable let/while); genuine diagnostics still surface. The stale extractStaticGraph spike is deleted. Verified end-to-end on samples/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; tsc clean.
      • Known gap: control-flow JSDoc titles (extractControlFlowMeta, keyed positionally) are not merged onto IR-derived ids — toGraph supplies control-node labels instead. Re-key on derived ids if branch/loop title round-trip is needed (§19#5).
  2. Deferred constructswhile/Loop (fold-vs-grow, §19#2) and durable let, to be designed when a real workflow needs them, with a concrete use case (not synthetic tests). This is step 1 of the wf.state deletion plan (§21): landing Loop lowering is what unblocks deleting the legacy path and user-facing durable letwf.state then survives (if at all) only as the internal durability target for a folded Loop.carry.
  3. Open follow-ons in §19: activity arg names from signatures (#4), display metadata round-trip (#5). (?. admitted into Ref — §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
  1. Lowering parity. Today lower handles a partial subset (activity, parallel, for…of, if/else-if, return). It must accept everything the transpiler acceptswhile/Loop, destructuring, parallel(...xs.map(...)), JSDoc @retry/@timeoutPolicy, 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.
  2. The third arrow — IR → runtime code. The actual point of convergence. print.ts emits 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 existing emit() already knows the target shape — it just reads ts.AST; porting it to read IR is tractable.
  3. Golden equivalence (the gate). For every existing sample/fixture, assert lower → emit(IR) is behaviorally identical to today's transpile(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-runtime on the same fixtures.
  4. Route the transpiler through the IR. Change transpileSource internally to ts → 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).
  5. 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:

  1. Fold-map (faithful to the IR). Recognize the loop-carried let pattern — a let initialized before a while/for, read in the condition, reassigned in the body — and lower it to Loop.carry + update. Non-loop-carried let (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.
  2. Narrow the language. Declare durable free let out-of-algebra; require accumulation via Map(collect)+leaf(reduce). Smallest IR, but breaks existing samples that use let — a migration, not a drop-in. Cleanest model, highest corpus churn.
  3. Grow the IR an Assign/State node. 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 const initialized with an empty/literal collection ([], {}, new Map/Set), and the arr.push(…) / m.set(…) / obj[k] = … that feeds it — is out of the algebra. lower rejects it with MUTABLE_ACCUMULATION + the Map-collect remedy; the sanctioned form is a fan-out that collects (a Map/parallel(...xs.map()) bound to a name) plus a leaf to reduce. Consequence: samples/test1 is 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-let case (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:

  1. 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.
  2. Carry a provenance side-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.
  3. Bump the id scheme + codeVersion to 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 equal

What 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 roundtrip test so lower / print / emit are 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 transpileSource and createFlopodTransformer internally to ts → lower → emit, public signatures unchanged. The diagnoser (diagnose.ts) and the IR's lowering errors must be reconciled — one rejection contract, not two (today diagnose.ts runs before emit; lowering errors are the IR's contract). Decide whether diagnose becomes a thin pre-check or is subsumed by lower'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 to transpileSource on the accepted corpus, full module (not just the branch), ids normalized — golden-equivalence.test.ts.
    • Part 2 done: transpileViaIR(source) wires the full pipeline diagnose → lower → emit → assemble, with LowerError mapped into the single TranspileError surface (§19#3: both run, unify). Proven ≡ transpileSource on the accepted corpus; both error sources unify. @flopod/transpiler now depends on @flopod/ir.
    • Part 3 (rename) done: @flopod/transpiler@flopod/compiler (frontend lower + validator diagnose + backend emit; over @flopod/ir, the pure model). CLI imports + deps updated; bin flopod-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 strict Expr algebra (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. transpileLegacy is retained for dev mode (graph keys off positional ids, pending [5]) and the deferred while/let (§19#2). The legacy-permissive test suites (limits/edge-cases/diagnostics) now exercise transpileLegacy; a new suite locks the strict IR rejections. Public signatures unchanged — CLI/runtime untouched. 303 tests green.
  • [5] Renderers as readers. The graph becomes an additional consumer of the same IR — purely additive. The existing static-graph builder (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's toGraph(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 node path is the runtime's template path (full wf.* 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 a toGraph node (graph-runtime-equivalence.test.ts). One emit change: Choice id 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 from toGraph(IR) at compile time — no dry run. Falls back to transpileLegacy only on LowerError (durable let/while). Verified end-to-end on samples/test1 (flopod dev): derived ids, run completes 64/64, every live path overlays a static node. The legacy GraphBuilderPlugin (dry-run observer) now serves only the fallback path; the stale extractStaticGraph spike is deleted. See §12.1 [5] for the per-step detail.

19. Decision log — close before coding [1]/[2]

  1. Id scheme (§15(b)) — provenance side-channel vs. positional-in-IR vs. semantic cutover. Blocks both lower and emit; decide first.
  2. let state + while/Loop (§15(a)) — fold-map vs. narrow vs. grow.

    DECIDED — fold (Loop lowering DONE, §22). The loop-carried let + while now lowers to a fold Loop with Loop.until (poll-until); the carry's durability targets the runtime wf.state primitive (kept as an internal lowering target, no longer user-facing). Non-loop-carried durable let stays narrowed — DURABLE_STATE_UNSUPPORTED — as does do…while (LOOP_UNSUPPORTED) and a carry-less while (LOOP_CARRY_UNSUPPORTED). The "grow an Assign" option was not needed. See §22 for the full design + status.

  3. Rejection contract (§18) — diagnose.ts vs. lower errors as the single source.
  4. Activity arg names (§15, open item §11 #4) — where leaf parameter names come from (signatures) so in is keyed by name, not position.
  5. Display metadata (C12) — on-node field vs. external map, and whether it survives round-trip.
  6. IR strictness vs. legacy permissivenessDECIDED: adopt strict. The IR Expr algebra rejects ternary, ??, method chains, and spread-in-args in the branch; these must move into leaves. The default transpileSource enforces this. Follow-on DONE — ?. admitted. Optional chaining is member-access (not a transform), so it is now preserved in the Ref path: PathSeg carries an optional? flag, lower reads questionDotToken, print re-emits ?./?.[]. This fixed a latent buga?.b was previously silently downgraded to a.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), and graph (visual nodes, toGraph).

The split is on the dependency seam, not the pipeline stages:

  1. @flopod/ir — the model only, kept zero-dependency (no typescript). 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. ts appears only at the lower/emit boundary (§5, §10). print.ts and graph.ts are pure (imports only the model + print helpers), so they stay here.
  2. @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 (when emit.ts lands and parse moves to lower), 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 Loop lowering. while/durable-let are 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 — transpileSource routes everything (dev + build-time codegen) through the IR; a LowerError now surfaces as a clean TranspileError instead of silently dropping to legacy (181 compiler/ir tests green).

DONE — the legacy path is fully deleted (§23 stages 3+4). flopod build uses createIRTransformer() (IR-driven codegen spliced into the TS transformer as synthetic nodes with per-statement setSourceMapRange, 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, the collectLetVars/rewrite*/tryRewriteParallel* family, extractControlFlowMeta, the emit dry-run branch). while/durable-let stay narrowed (rejected with a structured LowerError) until §22 Loop lowering lands. 283 tests green; tsc clean. Only the runtime's now-orphaned GraphBuilderPlugin class 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 letwf.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:

  1. Decide & build Loop lowering (§19#2). Take a real workflow that needs a branch loop / accumulator (not a synthetic test). Apply the recommendation: fold loop-carried let into Loop.carry + update; narrow non-loop-carried let (reject with the Map-collect remedy — already decided for the collection sub-case, §15a). Land lower(whileLoop) and emit(Loop → durable calls). Decide there whether the carry's durability lowers to the runtime wf.state primitive (recommended — reuse it, just stop exposing it) or a new carry-specific checkpoint.
  2. Remove the fallback. With the IR accepting the real loop corpus, change transpileSource so dev mode no longer catches LowerError → legacy; the IR path is the only path. Keep transpileLegacy exported for one release as an escape hatch if a workflow regresses.
  3. Delete the legacy emitter. Remove transpileLegacy, emit's dev dry-run branch, the GraphBuilderPlugin dry run, collectLetVars/rewriteBody/LetVarKind/DURABLE_*_MUTATE, and extractControlFlowMeta. 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.
  4. Settle the runtime primitive. If step 1 retargets Loop.carry onto wf.state, keep durable-var.ts but mark it internal (not a user surface). If a carry-specific checkpoint replaced it and nothing else uses it, delete durable-var.ts and the wf.state runtime 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:

  1. Loop.until — a second, bottom-of-loop predicate (RECOMMENDED). Extend the node: Loop { carry, while, body, until?, update }. until is evaluated at the end of the body over body-local bindings; true ⇒ exit. Poll-until becomes while: 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 bottom if … return STOP; BPMN standard-loop with an exit gateway; ASL Choice). break in the surface syntax is recognized only in the sanctioned shape if (P) break as the last guard before the loop tail, and folded onto until; any other break/continue is rejected with a structured error.
  2. 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-carry Loop), a rewrite of the user's natural break, and a carry whose update is a predicate result (needs the §-below computation exception or a leaf).
  3. First-class Break step. Admit break as 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 make inc-style leave