From fbf2819a7dc61ab7981c6abd19c7a9465d2e73f3 Mon Sep 17 00:00:00 2001 From: Pedro Gomes Date: Sat, 22 Aug 2026 12:38:11 +0100 Subject: [PATCH] docs(adr): control flow nests, and jumps are deferred MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- CONTEXT.md | 22 +++- ...01-yaml-document-is-the-source-of-truth.md | 6 +- docs/adr/0013-control-flow-nests.md | 121 ++++++++++++++++++ 3 files changed, 145 insertions(+), 4 deletions(-) create mode 100644 docs/adr/0013-control-flow-nests.md diff --git a/CONTEXT.md b/CONTEXT.md index eef56dd..f673c4b 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -30,9 +30,10 @@ identity and its typed input and output contract. Hatua treats these as given an _Avoid_: node type, block, plugin, component spec **Canvas Mode**: -Editing a **Workflow Definition** graphically — dragging steps, drawing connections, mapping outputs -to inputs. -_Avoid_: visual mode, graph editor, builder +Editing a **Workflow Definition** graphically — adding, moving and configuring **Steps** on the flow +map, and mapping outputs to inputs. There is nothing to connect: reachability is nesting, so the +canvas has no exit handles and draws no edge a user can attach. Connectors are chrome. +_Avoid_: visual mode, graph editor, builder, drawing connections **Text Mode**: Editing the same **Workflow Definition** as raw YAML text inside Hatua's own UI. @@ -95,6 +96,14 @@ position in the tree. Each entry is a key, a **Template** and a declared type, s **Step** addresses `{{s8.headline}}` and type-checks against it like any other output. _Avoid_: transform, set variables, assign, formula step +**Block**: +A named, reusable sequence of **Steps** declared once in a **Workflow Definition** and invoked with +`core.call`, taking declared parameters and publishing declared outputs. It is what serves reuse and +what keeps a deep workflow readable — extracting one flattens the tree exactly as extracting a +function does. A **Block** is reachable from many call sites without making the model a graph, +because it reads only what it declares. +_Avoid_: subflow, subroutine, group, macro, function + **Fork**: A container **Step** (`core.fork`) holding two or more **Branches**, in either `condition` mode (first match wins, last branch is the fallback) or `parallel` mode. @@ -162,6 +171,13 @@ Execution** is only ever read. Never say "the YAML" without naming which. never writes. Hatua does write **Workflow Definitions**; it must return them with the **Host**'s and the user's comments, key order and style intact. +**"Drawing connections"** — the **Canvas Mode** entry described a graph editor, contradicting **Step** +and **Derived Layout** in this same file. Resolution: there are no connections to draw. Control flow +is expressed by containers — `core.fork`, `core.for_each`, `core.repeat`, `core.call`, `core.try` — +and a **Step** runs because of where it nests. Reuse is a **Block**, not an edge into a shared node. +See [ADR-0013](docs/adr/0013-control-flow-nests.md), which also corrects ADR-0001's reason for the +constraint: cross-links break exact static scope, not derived layout. + **"Tumika" vs "Hatua"** — the design handoff names the product *Tumika workflow builder* and its design system *Tumika*. Tumika is a self-hostable personal assistant that runs scheduled routines; Hatua is this repo, the embeddable builder. Their tokens are byte-identical (ink `#232d47`, accent diff --git a/docs/adr/0001-yaml-document-is-the-source-of-truth.md b/docs/adr/0001-yaml-document-is-the-source-of-truth.md index fe29eed..e669c06 100644 --- a/docs/adr/0001-yaml-document-is-the-source-of-truth.md +++ b/docs/adr/0001-yaml-document-is-the-source-of-truth.md @@ -15,7 +15,11 @@ a defect we would rather make structurally impossible than test for. - Node positions are **never stored**. The layout is computed from the tree on every render, which is what makes it impossible for a hand-edited file to disagree with the map. Free node positioning is - therefore not available, and arbitrary cross-links between steps would break this property. + therefore not available. + *(Arbitrary cross-links between steps are refused too, but not by this decision: a file of `Next:` + transitions lays out from its graph with no coordinates in it, so a graph does not force stored + positions. Cross-links break **exact static scope**, and + [ADR-0013](0013-control-flow-nests.md) carries that argument and the decision resting on it.)* - Comments, key order and quoting style survive a round trip, because we never re-serialise the whole document from typed objects. - The model stays a **tree**, matching the YAML's own nesting (`branches:`, `steps:`). diff --git a/docs/adr/0013-control-flow-nests.md b/docs/adr/0013-control-flow-nests.md new file mode 100644 index 0000000..dfdbab1 --- /dev/null +++ b/docs/adr/0013-control-flow-nests.md @@ -0,0 +1,121 @@ +# Control flow nests, and jumps are deferred + +A **Step** may be reached from more than one place — three branches of a **Fork** that all end by +notifying the same person, a review that repeats until a human approves it. Every graphical workflow +builder answers this with edges: a user drags from one node's exit to another's entry, and +reachability becomes an arbitrary graph. + +Hatua does not. **Reachability is nesting**: a Step runs because of where it sits in the tree, and the +only things that change control flow are *containers* — `core.fork`, `core.for_each`, `core.repeat`, +`core.call` and `core.try`. There is no edge to draw, so the canvas offers no connect affordance, no +exit handles and no drawn connectors the user can attach anything to. + +## Why, and why not the reason ADR-0001 originally gave + +ADR-0001 said arbitrary cross-links "would break this property", meaning derived layout. **That +reasoning is wrong and is corrected there.** AWS Step Functions is a hand-editable file with `Next:` +transitions, no stored positions, and a console that derives the picture; GitHub Actions does the +same with `needs:`. A graph does not force a file to carry coordinates. + +The real cost is **exact static scope**, which is Hatua's and not those tools'. + +`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 list that is +complete, 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 the question stops having +a static answer: that Step can safely read only what **every** inbound path produces, so scope becomes +an intersection over paths — a dataflow analysis rather than a walk, and one that degrades from +*exact* to *conservative*. n8n can allow edges because it never promised to tell you, before you run +it, that `{{s3.subject}}` exists. + +The second cost is that **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 a +control-flow semantics we would be imposing on every Host, in exchange for a shape the tree can +already express. + +Because a tree with duplication expresses every reachability shape a DAG can — inline the shared tail +into each path — what edges actually buy is **deduplication, not expressiveness**. That is a real cost +and it is paid deliberately, and `core.call` pays most of it back. + +## A call is a cross-link with a contract; a jump is one without + +This is the whole distinction, and it is why `blocks:` is not a back door to the thing this ADR +refuses. + +A **Block** is a named, reusable sequence declared once in the document and invoked with `core.call`. +It is reachable from three call sites and scope stays an exact walk, **because a Block reads only its +declared parameters and publishes only its declared outputs**. The contract is what replaces the +analysis. A jump target is reachable from three places with no contract at all, so there is nothing to +compute scope from except the intersection. + +The same reasoning admits a further consequence: **early exit is safe.** A jump that only ever goes to +the *end* — a future `core.stop` or `core.fail` — adds no inbound path to any Step, so scope is +untouched. It is **joins** that break static scope, not jumps. + +## Blocks are also the flattening tool + +Deduplication is the obvious use and the smaller one. A workflow with a session loop around a +per-entry loop around a revision loop around a decision is four levels deep and hard to read on a +canvas; extracting the middle into a Block leaves a root of three lines, exactly as extracting a +function does. A jump would not flatten it — it would make a loop *look* flat while remaining as +cyclic, so a reader has to simulate execution to find what repeats and where it stops. Nesting is what +makes the boundary visible without running anything, and collapse already handles depth as chrome. + +## The verbs + +`core.fork`, `core.for_each` and `data.map` already exist. This decision adds three, keeping the set +small enough for a Host runner to implement. Named and given a role here because the refusal above is +meaningless without saying what carries the weight instead; each one's **shape is settled by the PR +that gives it a reader**, and amends this ADR when it does. + +- **`core.repeat`** — repeats its children until a condition holds. The gap `core.for_each` leaves: + it iterates a collection, and nothing repeated on a condition. This is what "send it back for + another revision" is, and what "ask whether to process another batch" is — the target of both is + the head of an enclosing container, which is why neither needs a jump. +- **`core.call`** — invokes a **Block** by name, taking declared parameters and publishing declared + outputs. Blocks may call blocks; recursion is refused, because unbounded recursion is the jump + problem wearing a contract's clothes. +- **`core.try`** — a region with a retry policy and a fallback handler. **Wrapping one Step is retry; + wrapping a region is fallback**, so one verb serves both. Error-type matching needs no matcher of + its own: a `core.fork` inside the handler branches on the failure. + +`core.try` exposes the failure to its **handler** children and not to its body, the way +`core.for_each` exposes `item` — a container putting a binding into the scope of children it owns, +which the Fork's per-branch scoping already establishes. + +The names are `core.*` because these are control flow. `error.handler` was the first draft and reads +like a Host namespace, which is the confusion the `core.` prefix exists to prevent. + +## Loop state is a workflow variable + +A repeated region usually has to carry something backwards — the reviewer's feedback reaching the +draft step that runs before it. Nothing positional can do that: the writer runs after the reader, so +it is not in scope. **`data.set_var` is the mechanism**, because `vars` are workflow-scoped and +readable anywhere regardless of where they were written. + +The alternative was iteration state declared on `core.repeat` itself, initialised and advanced by the +container, typed, and reset structurally on re-entry. It was rejected for costing a second concept +where one already works. + +The cost is real and is documented rather than designed away: **a var written inside a loop survives +into the next iteration of an enclosing loop**, so a workflow that must start each pass clean resets +it explicitly. Nothing type-checks that reset. + +## Deferred, not refused + +A jump is **not built**, and the condition that reopens this is written down rather than left to +taste: *a jump whose target is not the head of any enclosing container*. Neither of us could name one +— revising a draft, and asking whether to fetch more, both resolve to `core.repeat` — but neither of +us proved none exists. + +Deferring costs little, which is why it is a deferral and not a wall. A jump verb is **additive**: a +`core.goto` with a `target:` is a new `use` like any other, needing no migration of existing +documents. It does not disturb layout either, provided it renders as a terminal chip with a +jump-to-target affordance rather than a drawn edge — the tree walk stays correct. + +The one thing that does not come back is **the promise of exact static scope**. Once the picker and +the type marking say "these are the values you can read here, and this one fits", weakening that to +"unless a jump reaches this Step" is a user-visible regression. But that price is identical whenever +it is paid — it does not compound by waiting, and waiting buys real jump cases to design against +instead of a guessed shape. What does get harder is the runner contract: Hosts shipping against +structured control flow would need a version bump to accept transfer.