docs(adr): control flow nests, and jumps are deferred - #33
Open
pedromvgomes wants to merge 1 commit into
Open
Conversation
|
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
force-pushed
the
docs/control-flow-nests
branch
from
August 24, 2026 15:25
5ea7ee3 to
fbf2819
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 withneeds:. A graph does not force a file to carry positions. Leaving a wrong premise inmainwhile four PRs are built on the right one is the worst available option, so the correction goes first.CONTEXT.mdcontradicts itself inmain.Canvas Modesaid "dragging steps, drawing connections" whileStepin 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.
scopeForanswers "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.tryunifies retry and fallback: wrapping one Step is retry, wrapping a region is fallback. They arecore.*because they are control flow;error.handlerreads like a Host namespace, which is what the prefix exists to prevent.Loop state is
data.set_var, becausevarsescape 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.failadds 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
useneeding 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
blocks:+core.call— parameters, outputs, scope, cycle checkcore.repeat+data.set_varcore.tryLayout 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
maindid not already say.