Cardinality Is the Loop You Didn't Write
One flow shape shows up everywhere: walk a whole dataset in an external store, transform each record, write it out. Here it re-embeds a knowledge base between models — an example chosen to show the capabilities that make such a flow genuinely usable in production.
There's a shape of work that turns up in every data-heavy system, over and over: reach into a store you don't fully own, walk every record in it, do something to each one, and write the result somewhere. Migrations are this shape. So are backfills, cleanups, re-taggings, format conversions, moving data from one system into another — all of them reduce to one sentence: for each of a set of things, transform it and write it somewhere. Hold that sentence. The pattern is what this article is really about.
The example I'll build it with is re-embedding a knowledge base — reading a Qdrant collection that was vectored under one model and writing it back under a newer one, gemini-embedding-2, cleaning the payload tags on the way. It's a real job with real stakes (a retriever is only as good as the vectors under it, and models move on), but it's here as an illustration. What's worth taking away isn't the re-embed; it's the handful of runtime capabilities that turn "walk a dataset and transform every record" from a script you'd have to babysit into a flow that's genuinely usable in production — and that you'll reuse in a dozen unrelated cases.
Two of those capabilities are about iteration, and they're the reason for the title. The per-record transform is one node, and the "for each" over the records is not something I wrote — while the "for each" over the pages is a loop I very much did draw. Two kinds of iteration, each where it fits.
First: the store the runtime never met
FloMorphic ships a built-in vector store, and for a lot of flows that's the right answer. But this knowledge base doesn't live there. It lives in our Qdrant, on our network, holding vectors from long before this flow existed. The runtime was never compiled against it and shouldn't have to be.
So the flow reaches it through the Qdrant plugin — and the plugin's most important property here is where it runs. It isn't baked into the FloMorphic or Inflowenger binary. It's installed the way a browser extension is installed, and it can sit anywhere it can reach the database — the same host, the same VPC, right next to a Qdrant that has been serving other systems for years. I installed it on our network, pointed it at our org's Qdrant, and from that moment the runtime knew three new actions — create a collection, upsert points, scroll points — without a single line of it being recompiled. That decoupling is the precondition for this whole job: you can't migrate a store the platform can't see, and a plugin is how the platform comes to see one it was never built for.
The credentials to touch that store live on the plugin node, scoped to it, never ambient to the flow. The graph you export and share — the recipe — carries none of them.
The whole flow, end to end
Start
↓
Create collection qdrant.collection.create → selected_docs (3072-dim)
↓
Scroll source qdrant.points.scroll ←──────────────────────────┐ one page of the old collection
↓ offset: its own last cursor │
Upsert points qdrant.points.upsert · scope …result.points[*] │ once per point in the page
↓ │
Page Cursor js · counter += this page's point count │
↓ │
Rule continue while the scroll still returns a cursor ─┘
└─ done → end
Create collection calls qdrant.collection.create once and makes the target — selected_docs, size: 3072, distance: Cosine. That 3072 is the tell. Vector size isn't a stylistic choice; it's the dimensionality of the target model, gemini-embedding-2. The old collection's vectors are the wrong length for the new model, which is precisely why this is a migration and not a copy. The number that has to change is the one thing a plain export/import could never fix for you.
Upsert points is the node that does the work, and it's the reason to read this at all. Everything in the next section is about it.
The other three — Scroll source, Page Cursor, Rule — are the page loop that carries that upsert across the entire collection. I'll come to them after the upsert, because the cardinal node is the idea the title is about.
One node, once per element
Here is the trick, and it isn't a trick. The upsert node has a scope:
scope: $.source_stage.result.points[*]
$.source_stage.result.points is the page the scroll just fetched — the points to migrate. The [*] makes the scope many-valued, and in FloMorphic a node's scope is its cardinality: a single-valued scope runs the node once; a many-valued scope runs it once per matched element. One upsert node on the canvas, forty points in the page, forty writes to Qdrant. I did not draw a loop for that. I did not add a map step or a "for each" block. I told the node what set it ranges over, and the runtime ran it across that set.
This is the inversion worth internalising. In most tools, iterating is a thing you build — a loop node, a foreach, a fan-out operator you wire up and then reason about. In FloMorphic, iterating is a thing you declare: it's an attribute of the node, sitting right next to its title. The node doesn't know it's in a loop, because there is no loop. There's a node with a plural scope.
Inside each run, the node sees its own element as $this. That's what closes the loop you didn't write — a way to name the current one out of the set:
text: {{$this.payload.text}}
tags: component ← {{$this.payload.component}}
The first line takes the current point's stored text and hands it to the new embedding model; the plugin embeds it and writes the vector, keeping payload.text so the migrated point still carries its own source. The second line is the part I most want people to see. A payload tag, initialised at runtime from the element itself. The value {{$this.payload.component}} isn't known when I build the flow — it's resolved, per element, from whatever document is passing through right now. Every written point is shaped by the one it came from. Nothing is hardcoded, and nothing needed a code node to reshape it: the node's fields are templates, and inside a cardinal node those templates resolve against the current element.
That's the second lesson stacked on the first. Cardinality gives you "once per element." $this plus template fields give you "and each one configured by its data." Between them you have a transform — read a document, re-embed it, tag it from its own payload, write it — expressed as one node's configuration, not a program.
This isn't instead of a loop — it's the easy path when it fits
Be clear about what cardinality is and isn't. FloMorphic and the Inflowenger runtime treat the topology loop — a real backward edge over the durable Context — as the first-class way to iterate, and they mean it: it's the primitive the docs push you toward, the one that carries state across passes, branches, counts, and stops on a condition. The scroll pump later in this piece is that loop. Cardinality doesn't replace it and isn't in tension with it.
What a many-valued scope gives you is a shortcut for one specific, extremely common shape: apply the same transform to every element of a set, independently. No shared state between elements, no condition deciding when to stop, no branch — just "do this to each of them." When that's the job, drawing a topology loop with a counter and a backward edge is more machinery than the task deserves, and a scope with a [*] in it collapses the whole thing to one node. This migration is exactly that shape — each source point is embedded and written on its own, caring nothing about the others — which is why the array scope makes it so easy. Reach for the topology loop when the passes depend on each other; reach for cardinality when they don't.
It's a transform, not a copy
It's worth being blunt about what happens inside that upsert, because "migration" can sound like moving bytes. In these jobs it never is — the value is in the transform, and here you can see it plainly. The old vector is discarded. The text goes through gemini-embedding-2 and a new 3072-dimension vector is computed. The payload comes across, cleaned — I'm collapsing a couple of legacy tag shapes into a single component tag as the documents pass through, the "fix the data on the way" step that every real migration turns out to need. What lands in selected_docs is genuinely new: same knowledge, different model, tidier tags. Whatever your version of the transform is — re-embed, redact, reshape, translate — this is where it lives, and here it's four fields on a node.
The page loop: a cursor, a counter, and a rule
The cardinal upsert transforms one page. A knowledge base is many pages, and closing that gap is where the other kind of iteration comes in — the topology loop, a real backward edge you draw over the durable Context.
qdrant.points.scroll hands back a next_page_offset — a cursor into the collection — and the Scroll source node feeds that cursor straight back into its own next request:
offset: {{$.source_stage.result.next_page_offset}}
Read a page; the next run reads from exactly where the last one stopped. Three nodes turn that cursor into a sweep:
- Scroll source fetches one page (40 points) into
$.source_stage.result. On the first pass the offset is empty, so it starts at the top; on every later pass it resumes from the cursor. - Page Cursor, a
jsnode, keeps the running total —input.counter += …points.length. It's scoped to$.migrate_state, so its own slice arrives asinputand it accumulates there, while reaching the freshly-scrolled page out of its scope with_get("$.source_stage.result"). That split — write your own scope, read across into another — is the wholeinput-vs-_getdistinction in three lines. - Rule is the loop's stop condition, and its two handlers are its output ports. It reads the cursor from the last scroll: a
next_page_offsetpresent firescontinue; an empty one firesdone. A rule firing a tag is the branch the flow takes — so "are there more pages?" is a node on the canvas, not a condition hidden in code.
The one edge that makes it a loop is Rule --continue--> Scroll source. The loop turns — scroll a page, fan the upsert across its points, count, check the cursor — until the scroll comes back empty, the Rule routes done, and the whole collection has been re-embedded into selected_docs.
So the flow holds both iterations at once, and the contrast is the lesson. Across pages the passes depend on each other — each one needs the previous page's cursor, and the loop has to know when to stop — so that's a topology loop, with state and a condition. Within a page the points are independent — same transform, no shared state, no stop — so that's a scope with a [*]. You draw the first. You declare the second. Reaching for the wrong one is how flows get complicated: a topology loop where a scope would do is machinery you didn't need; a scope where the passes actually depend on each other is a correctness bug waiting to happen.
What the flow is really teaching
Strip the Qdrant specifics and this flow is a general claim about how iteration should feel.
- Reaching a system is an install, not a build. The store predates the flow by years; a plugin installed next to it let the runtime operate on it without being recompiled or knowing it existed.
- Iterating over a set is a declaration, not a construction.
scope: $.source_stage.result.points[*]is the entire "for each" over a page. The node runs once per point because you said what set it ranges over — no loop node, no map, no fan-out to reason about. - Each iteration configures itself from its own data.
$thisand{{...}}templates mean the current point shapes the write — text to embed, tags to attach — with no code node in the middle. - Sweeping the whole store is one edge. A cursor the store already returns, a
jsnode to count, arulenode to ask "more pages?", and one backward edge — and the per-page transform becomes a full migration that stops itself. - The two iterations are different tools. Independent elements → a scope. Passes that depend on each other and must terminate → a drawn loop. The flow uses each exactly where it fits.
And none of it is really about embeddings. Swap Qdrant for Postgres or an object store, swap "re-embed" for "redact", "reformat", "translate", "backfill a new field", "copy into the new system" — the flow is the same four capabilities: a plugin that reaches the system, a scope that fans a transform across a page of records, runtime-bound fields so each write is shaped by its own record, and a cursor-driven loop that stops itself when the data runs out. That's why the example is worth building in the open — not for the migration, but for the shape you can lift into whatever real-world case is in front of you. Walking a whole dataset and transforming every record is the kind of job that used to mean a script, a babysitter, and a bad afternoon; here it's a handful of nodes cheap enough to do on a Tuesday.
Run it: the flow is in the Flow Cookbook — import
qdrant-migrate.json, install the Qdrant plugin next to your store, point the collections at yours, and watch the page loop turn: one upsert firing once per point, the counter climbing, until the cursor runs out.