Skip to content

docs(adr): control flow nests, and jumps are deferred - #33

Open
pedromvgomes wants to merge 1 commit into
mainfrom
docs/control-flow-nests
Open

docs(adr): control flow nests, and jumps are deferred#33
pedromvgomes wants to merge 1 commit into
mainfrom
docs/control-flow-nests

Conversation

@pedromvgomes

@pedromvgomes pedromvgomes commented Aug 22, 2026

Copy link
Copy Markdown
Owner

Groundwork for the canvas, settled before any of it is built: the canvas will not let users connect nodes. Reachability is nesting — a Step runs because of where it sits in the tree — so there are no exit handles, no drawable edges, and connectors are chrome.

Docs only. Four PRs follow and are stacked on this one.

Why this is its own PR

Every ADR here has shipped inside the feature PR that gave it a reader, and ADR-0012 states the reason: a shape with no reader is guessed at. This one is an exception on three counts.

ADR-0001 currently says something false. It claims arbitrary cross-links would break derived layout. They do not — AWS Step Functions is a hand-editable file of Next: transitions with no stored coordinates and a console that derives the picture; GitHub Actions does the same with needs:. A graph does not force a file to carry positions. Leaving a wrong premise in main while four PRs are built on the right one is the worst available option, so the correction goes first.

CONTEXT.md contradicts itself in main. Canvas Mode said "dragging steps, drawing connections" while Step in the same file says "never an arbitrary graph".

The load-bearing content is a refusal, and a refusal has no implementation to wait for. It constrains the PRs that follow rather than being produced by them — and "should the canvas have edges?" deserves a review of its own, not a rubber stamp inside a 2,000-line diff.

What the real constraint is

Not layout. Exact static scope.

scopeFor answers "what can this Step read?" by walking its ancestors. In a tree, in-scope is the path from the root, and the answer is exact — which is what lets the picker offer a complete list, the completion list rank against it, the reference tree group by it, and the type marking say this fits before anything runs. Converge two branches on one Step and that stops having a static answer: it can safely read only what every inbound path produces, so scope becomes an intersection over paths — a dataflow analysis rather than a walk, degrading from exact to conservative.

n8n can allow edges because it never promised to tell you, before you run it, that {{s3.subject}} exists. LangGraph isn't a counterexample at all — its canvas renders, it doesn't round-trip.

Second cost: Hatua does not execute. Structured containers are a contract any Host runner and the Go SDK implement locally. Arbitrary transfer into a branch or out of a loop is control-flow semantics imposed on every Host, bought for a shape the tree can already express — since a tree with duplication expresses every reachability shape a DAG can. Edges buy deduplication, not expressiveness.

What carries the weight instead

A call is a cross-link with a contract; a jump is one without. That is why blocks: is not a back door to the thing this refuses. A Block is reachable from many call sites and scope stays an exact walk, because a Block reads only its declared parameters and publishes only its declared outputs. The contract replaces the analysis.

Three verbs are named and given a role — core.repeat, core.call, core.try — with each one's shape left to the PR that gives it a reader, per ADR-0012's own principle. core.try unifies retry and fallback: wrapping one Step is retry, wrapping a region is fallback. They are core.* because they are control flow; error.handler reads like a Host namespace, which is what the prefix exists to prevent.

Loop state is data.set_var, because vars escape positional scope and nothing else can carry a value backwards. Its cost is documented rather than designed away: a var written inside a loop survives into the next iteration of an enclosing loop, and nothing type-checks the reset.

Two consequences fall out. Blocks flatten, not just deduplicate — extracting one drops a four-level workflow to a three-line root, which a jump would not do (it makes a loop look flat while staying as cyclic, so a reader has to simulate execution to find what repeats). And early exit is always safe — a future core.stop/core.fail adds no inbound path to any Step, so it never touches scope. It is joins that break it, not jumps.

Deferred, not refused

A jump is not built, and the condition that reopens it is written down rather than left to taste: a jump whose target is not the head of any enclosing container. No such case was found — revising a draft and asking whether to fetch more both resolve to core.repeat — but none was proven not to exist.

Deferring is cheap because a jump verb is additive: a new use needing no migration, and drawn as a terminal chip with a jump-to-target affordance rather than an edge, it leaves the tree walk correct. The one thing that does not come back is the promise of exact static scope — but that price is identical whenever it is paid, and waiting buys real cases to design against.

What follows

1 blocks: + core.call — parameters, outputs, scope, cycle check
2 core.repeat + data.set_var
3 core.try
4 the layout algorithm, over all of it

Layout is last and visibly earns it: it has to place a call chip, a repeat container and a handler region.

Verification

Documentation only — no source, schema or generated file changes, so the build, test and lint suites have nothing to say about it that main did not already say.

@github-actions

github-actions Bot commented Aug 22, 2026

Copy link
Copy Markdown

bulwark

  • scan — no findings

  • coverage — go: 89.3% (baseline 89.3%); typescript: 94.6% (baseline 94.6%)

📦 Full bulwark output — complete scan and coverage logs.

Reachability in a Workflow Definition is nesting: a Step runs because of where
it sits in the tree, and only containers change control flow. The canvas
therefore offers no connect affordance, no exit handles, and no edge a user can
attach.

ADR-0001 gave the wrong reason for this. It said arbitrary cross-links would
break derived layout; they do not, as a file of `Next:` transitions lays out
from its graph with no coordinates in it. What they break is exact static
scope — `scopeFor` is an ancestor walk and is exact, which is what the picker,
the completion list, the reference tree and the type marking all spend. A Step
reachable from two paths can only read what both produce, turning a walk into an
intersection. ADR-0001's consequence is corrected to point here.

Reuse is a Block: declared once, invoked by `core.call`, reading only its
declared parameters. A call is a cross-link with a contract; a jump is one
without, and the contract is what replaces the analysis. Blocks also flatten a
deep workflow the way extracting a function does.

A jump is deferred rather than refused, on a written trigger: a jump whose
target is not the head of any enclosing container. It stays cheap to add — a new
verb is additive to the schema, and drawn as a chip rather than an edge it
leaves the tree walk correct.

CONTEXT.md's Canvas Mode entry described a graph editor, contradicting Step and
Derived Layout in the same file; it is rewritten, Block is defined, and the
contradiction is recorded under Flagged ambiguities.
@pedromvgomes
pedromvgomes force-pushed the docs/control-flow-nests branch from 5ea7ee3 to fbf2819 Compare August 24, 2026 15:25
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant