Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 34 additions & 3 deletions CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -114,9 +114,9 @@ _Avoid_: state, global, parameter, field

**Mapping**:
A **Step** (`core.map`) whose outputs are the entries the user wrote into it rather than anything
its **Component Manifest** declares. It is the third verb Hatua interprets structurally, alongside
`core.fork` and `core.for_each` and the only one read from a field's *value* rather than from its
position in the tree. Each entry is a key, a **Template** and a declared type, so a downstream
its **Component Manifest** declares. It is the one verb Hatua interprets structurally by reading a
field's *value* rather than a position in the tree — `core.fork`, `core.for_each`, `core.repeat` and
`core.try` are all read from where their children sit. Each entry is a key, a **Template** and a declared type, so a downstream
**Step** addresses `{{steps.s8.headline}}` and type-checks against it like any other output.
_Avoid_: transform, set variables, assign, formula step

Expand Down Expand Up @@ -150,6 +150,37 @@ distinguishes it from `core.for_each` — a list may be empty — and what lets
by `core.set_var`. Nothing in the document bounds the iterations — a runner imposes its own ceiling.
_Avoid_: while, do-while, until-loop, retry

**Try**:
A container **Step** (`core.try`) with **two** child regions where every other container has one: a
protected body under `steps:`, and a fallback under `handler:` that runs if the body fails.
**Wrapping one Step is retry; wrapping a region is fallback**, so one verb serves both. The two
regions are *siblings* — the body cannot see the handler, and the handler cannot read the body's
**Steps**, because which of them completed before the failure is not a fact the document holds. Its
retry policy lives under `with:` as ordinary **Component Manifest** fields, because a count and a
delay are numbers and `number` is a mappable field kind — the argument that put a **Repeat**'s
`until` beside `steps:` was about booleans and does not reach here. It discharges a **Block**'s
obligation to reach a `core.return` only when *both* regions return. Error-type matching needs no
matcher: a **Fork** inside the handler branches on the failure's `type`.
_Avoid_: catch, rescue, error handler, on-error branch

**Binding**:
A name a container puts into the scope of the children it owns — a **Loop**'s `item`, a **Try**'s
`error`. **A binding is an output of the container Step itself**, read as
`{{steps.<container id>.<k>}}`, which is one mechanism rather than two and costs no namespace root
and no bare token: ADR-0014 closed the roots precisely so a structural idea could not take a word
away from users, and a **Step** id already sits one segment below `steps.`. Nesting needs no
shadowing rule, because two containers are two Step ids.
_Avoid_: variable, loop variable, context, implicit

**Item**:
A **Loop**'s **Binding**: one element of the list its `list` field names, read as
`{{steps.<loop id>.item}}`. Declared `t: item` in the **Component Manifest**, which is the one type
whose meaning depends on the **Step** declaring it — the shape is not in the manifest at all, but is
resolved by following `list` to its source output's `of:`. Where it cannot resolve, `item` stays
`item` and matches anything, and the wrongness is reported against the *list* rather than guessed
into a shape.
_Avoid_: element, current, each, loop var

**Derived Layout**:
The rule that a **Step**'s position on the flow map is computed from the tree on every render and
never persisted. This is what guarantees a hand-edited **Workflow Definition** and the map can never
Expand Down
127 changes: 120 additions & 7 deletions docs/adr/0013-control-flow-nests.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@ that gives it a reader**, and amends this ADR when it does.
enclosing Block's `outputs:`.
- **`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.
its own: a `core.fork` inside the handler branches on the failure. Its shape is below.

**Invoking a Block is not a verb.** `core.call` was the first draft and it was refused once the verb
namespace closed (ADR-0014): a call is `use: block.<slug>`, resolved against `blocks:` instead of
Expand All @@ -103,7 +103,8 @@ not a run-time depth limit.

`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.
which the Fork's per-branch scoping already establishes. **Both bindings are the same mechanism, and
the section below says what it is** rather than leaving one defined by analogy to the other.

The names are `core.*` because **Hatua ships them**, which is what that root means (ADR-0014) —
`core.schedule`, `core.manual` and `core.end` are `core.*` too and none of them is control flow.
Expand Down Expand Up @@ -196,7 +197,8 @@ condition fork is first-match-wins, so one whose every branch is conditional can
and fall straight through. A return inside a `core.for_each` body exits the Block early and is perfectly
legal, but it never discharges the obligation, because the list may be empty and the body may never
run. A `core.repeat`'s body does discharge it, for the mirror-image reason, and the section below
settles why. That is the same reasoning that keeps sibling branches out of scope, applied to time
settles why. A `core.try` discharges it only when both its regions do, which is this same
all-branches reasoning asked of a region that may or may not execute. That is the same reasoning that keeps sibling branches out of scope, applied to time
instead of to paths. Steps sitting after a return on the same path can never run, and are reported the way an
unconditional Branch that swallows the ones behind it already is.

Expand Down Expand Up @@ -239,10 +241,9 @@ different name.
**A repeat binds nothing.** `core.for_each` exposes `item`, and it can: `item` is resolved by
following the loop's `list` back to its source output, so its type is derivable from the document. A
repeat has no list. An iteration index or count would therefore be a binding nothing declares and
nothing types — and it would have to live somewhere, which under ADR-0014's closed roots means a
seventh root or a second bare token beside `TRIGGER`. Both are a permanent cost for a counter
`core.set_var` already writes, which is the trade the section below makes once and should not make
twice.
nothing types — and a binding with no type is the one thing the mechanism below cannot carry, because
that mechanism is an ordinary manifest output. `core.set_var` already writes a counter, which is the
trade the section on loop state makes once and should not make twice.

**Nothing bounds the iterations, and that is a decision rather than an omission.** Recursion is
refused above because it is a property of the *document* — a cycle in the call graph, decidable by
Expand All @@ -253,6 +254,118 @@ keep. **Bounding is the Host runner's obligation**: a runner imposes its own ite
fails the execution when it is reached, the way it already owns timeouts and retries. Hatua does not
execute, so that is the one place the contract can honestly sit.

## A container's binding is an output of the container

`core.for_each` exposes `item` and `core.try` exposes the failure. That is one idea asked twice, and
it gets one mechanism: **a container binds a name for the children it owns by declaring it as an
ordinary output of itself**, read as `{{ steps.<container id>.<k> }}`.

```yaml
- id: each
use: core.for_each
with: { list: "{{ steps.fetch.messages }}" }
steps:
- { id: send, use: component.email.send, with: { to: "{{ steps.each.item.address }}" } }

- id: guard
use: core.try
with: { attempts: 3, backoff_ms: 500 }
steps:
- { id: publish, use: component.s3.upload }
handler:
- { id: warn, use: component.chat.post, with: { text: "{{ steps.guard.error.message }}" } }
```

**This costs no namespace root and no bare token, which is the whole reason it is the answer.**
ADR-0014 closed the roots on the argument that "every structural idea Hatua adds is a name taken away
from users" — and a binding owned by a container is exactly such an idea. A bare `item` is the token
that argument refuses. A seventh root costs a word forever, for two bindings and every future one. A
Step id already sits one segment below `steps.`, so a binding hung off the container is a name inside
a name the user chose, and collides with nothing.

**Nesting needs no rule, and that is the test the alternatives fail.** Two nested loops are two Step
ids, so `steps.outer.item` and `steps.inner.item` are different paths and neither shadows the other.
A bare `item` would need a shadowing rule — innermost wins — and then an escape hatch for reaching
the outer one, which is a second concept and a worse one, because the reader has to count enclosing
loops to know what a word means. Here they read the id and are done.

**The failure's shape is declared where the verb is.** `core.try`'s manifest declares
`error: {message, type, step}` the way any component declares its outputs, so the type checker, the
completion list and the reference tree need no code at all for it — which is also what makes
"a `core.fork` inside the handler branches on the failure" true rather than aspirational: `error.type`
is an ordinary text member. Hatua ships the verb, so Hatua declares the shape, and every Host runner
fills it in.

**`item` is the one output whose type is not in the manifest**, and `t: item` is what says so. It is
resolved by reading the loop's `list` field as a Reference, typing that path against the loop Step's
own scope, and taking the element shape of the list it names — the `of:` the source output declared.
This is the debt this decision pays off: `t: item` was documented, reachable and resolved by nothing,
and because the checker treats `item` as matching everything, the gap surfaced not as an error but as
a type check that always passed.

Where it cannot resolve — `list` absent, not a plain Reference, naming nothing, or naming something
that is not a list — `item` stays `item` and stays permissive. Guessing `object` would be a shape
nothing declared, and every `{{ steps.each.item.<field>}}` would then type-check against members the
manifest never had. **The wrongness is reported instead**: `LOOP_LIST_NOT_A_LIST` names a loop whose
`list` has a known type that is not a list. It has to be its own rule because `list` is a `ref` field
and `FIELD_KIND_TYPES` maps `ref` to `unknown`, so the ordinary Slot check accepts anything written
there — the same "a check that always passes" failure, one layer up.

## `core.try` has two regions, and both of them are `steps:`-shaped

**The body is `steps:` and the handler is `handler:`.** `steps:` is already "the children a container
owns", and a try's body is exactly that, so the traversal covers it unchanged.

**Two `branches:` under reserved labels was the alternative and is refused.** A Branch's identity is
its `label`, which is free text the user renames — so the meaning of the document would depend on a
display name, and a region could be renamed out of existence. It also costs the schema its first
reserved word, which is the thing ADR-0014 spent a whole decision removing. A key cannot collide with
anything a user chooses, because **nothing inside a step is user-named**: `id`, `use`, `name`,
`with`, `branches`, `steps`, `until` and `handler` are a closed set the schema owns.

The cost is that a region is now a third thing a traversal can forget, beside a Branch's steps and a
loop's body. That is paid where it is cheapest: `walkSteps` and `stepLists` are the only walks, in
each language, and a `handler:` fixture in `conformance/definition/invalid/` holds both loaders to
reaching it.

**The retry policy is in `with:`, and the `until` precedent does not reach it.** `until` had to leave
`with:` because `FIELD_KIND_TYPES` has no mappable boolean at all — a condition there would have
type-checked as *text*, and half the contract would have been gone. An attempt count and a backoff
are **numbers**, and `number` is a mappable field kind, so that argument is simply absent here.
Putting them in a structural key by analogy would be copying a conclusion without its reason, and
would cost a schema key, a diagnostic and a form control that a manifest field gives for nothing.

**A `core.try` discharges a Block's return obligation only when BOTH regions return.** The body always
runs, which on its own looks like the `core.repeat` argument — but a failure part-way through the body
is precisely what a try exists to admit, and that path leaves the body unfinished and enters the
handler instead. So every path out of a try goes through the body *or* through the handler, and a
region that may skip its return leaves one of them open. That is the Fork's all-branches reasoning
asked of two regions, one of which is conditional; it is not the repeat's "guaranteed to run at all".

## What a handler's children can read

**The failure, everything above the try, and nothing from the body.**

The two regions are **siblings**, so the body cannot see the handler and the handler cannot see the
body's Steps — and this needs no code, because it is the rule that already keeps a Fork's branches out
of each other's scope. It is also the right rule for the right reason. The body failed *somewhere*;
which of its Steps completed before it did is not a property of the document, so offering them would
make scope an intersection over paths. That is the analysis this ADR refuses edges in order to avoid,
arriving through a different door.

The try Step itself is in scope **only** inside its handler, which is the one place its binding means
anything:

| reading from | sees `steps.<try id>` |
| --- | --- |
| the body | no — the body is what produces the failure |
| the handler | yes — it is the failure being handled |
| a Step after the try | no — whether there was a failure at all is a run-time fact |

The last row is the one worth stating. A Step after the try is on a path where either the body
succeeded or the handler ran, and "the failure, or nothing" is a value whose existence depends on the
run. Offering it would be the same intersection, one level out.

## Loop state is a Board variable

A repeated region usually has to carry something backwards — the reviewer's feedback reaching the
Expand Down
6 changes: 6 additions & 0 deletions docs/handoff.md
Original file line number Diff line number Diff line change
Expand Up @@ -252,6 +252,12 @@ field under `with:`, so neither is reached through the **Component Manifest** an
than as a field of their own. Whatever surface holds one holds the other; a builder that could
author a repeat's condition and not a fork's would be a worse gap than having neither.

**A `core.try` adds no row, and that is the point of where its retry policy sits.** A container with
a structural key needs a surface of its own; a container whose configuration is ordinary `with:`
fields does not. An attempt count and a backoff are numbers, `number` is a mappable field kind, and
the argument that pushed a condition out of `with:` was about booleans — so a try's fields are edited
in the Step editor exactly as any Component's are, and the table stays four rows long.

### The Template input

`min-height` 40px (76px for textarea), `--radius-md`, 1px `--border-strong`, `--surface-card`,
Expand Down
6 changes: 5 additions & 1 deletion source/apps/playground/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,13 @@
"react-dom": "^19.2.0"
},
"devDependencies": {
"@hatua/expressions": "workspace:*",
"@hatua/model": "workspace:*",
"@hatua/schema": "workspace:*",
"@hatua/sdk": "workspace:*",
"@types/react": "^19.2.2",
"@types/react-dom": "^19.2.1",
"@vitejs/plugin-react-swc": "^4.3.3"
"@vitejs/plugin-react-swc": "^4.3.3",
"yaml": "^2.8.1"
}
}
61 changes: 61 additions & 0 deletions source/apps/playground/src/seed.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
/** biome-ignore-all lint/correctness/noNodejsModules: a Node test reading the catalogue from disk; the playground's own code never does. */
import { readFileSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
import { coreFunctions, validate } from '@hatua/expressions'
import { indexManifests, scopeFor, validateDefinition } from '@hatua/model'
import type { Manifest, WorkflowDefinition } from '@hatua/schema'
import { describe, expect, it } from 'vitest'
import { parse } from 'yaml'
import { SEED } from './workflow-store'

/**
* The seed workflow, held against the catalogue the playground actually serves.
*
* This is the layer the conformance corpus cannot reach. The corpus supplies its
* own manifests per scenario, so a rename in `conformance/manifest/catalogue.yaml`
* — a field key, an output's type — leaves every scenario green while the first
* screen a person sees fills with markers. That is exactly what happened to the
* loop below: it was written against a field key the catalogue no longer has.
*
* Two assertions rather than one. "Nothing is reported" catches the rename;
* "`item` resolves to the element the source declared" catches the quieter
* failure, where `t: item` goes unresolved, the checker treats it as matching
* everything, and a wrong path type-checks clean.
*/

const CATALOGUE: Manifest[] = parse(
readFileSync(
fileURLToPath(new URL('../../../conformance/manifest/catalogue.yaml', import.meta.url)),
'utf8',
),
).components

const seed = (): WorkflowDefinition => parse(SEED)

describe('the seed workflow', () => {
/*
* The exact set rather than "nothing", because the seed is not meant to be
* clean: s1's connection is left empty on purpose, so the Flow tab has a
* marker to show on the first screen anyone sees. Pinning the set catches a
* diagnostic appearing AND the deliberate one going away, where a count or a
* "nothing new" check would miss one of the two.
*/
it('reports exactly the one problem it is seeded with, against the catalogue it serves', () => {
const found = validateDefinition(seed(), indexManifests(CATALOGUE)).all
expect(found.map((one) => `${one.code} on ${one.stepId ?? one.triggerId ?? ''}`)).toEqual([
'FIELD_REQUIRED on s1',
])
})

it('resolves its loop’s `item` to the element its list declares', () => {
const scope = scopeFor(seed(), { board: null, id: 's5' }, CATALOGUE)
const context = { scope, functions: coreFunctions() }

expect(validate('{{ steps.s4.item.filename }}', 'text', context)).toEqual([])
// The gate is on, rather than switched off by an unresolved `item`: a text
// member is refused where a number is declared, and a member nothing
// declares is not silently accepted.
expect(validate('{{ steps.s4.item.filename }}', 'number', context)).not.toEqual([])
expect(validate('{{ steps.s4.item.bytes }}', 'number', context)).toEqual([])
})
})
9 changes: 6 additions & 3 deletions source/apps/playground/src/workflow-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,16 +93,19 @@ steps:
steps: []
- id: s4
use: core.for_each
name: "Each message"
name: "Each attachment"
with:
items: "{{ triggers.overnight.message }}"
# A list, and the loop's binding is one element of it: \`{{steps.s4.item}}\`
# carries the members \`attachments\` declares, with no shape written here.
list: "{{ triggers.overnight.message.attachments }}"
steps:
- id: s5
use: component.email.send
name: "Send the digest"
name: "Forward it on"
with:
connection: mailbox
to: me@example.com
subject: "{{ steps.s4.item.filename }}"
`

interface Stored {
Expand Down
18 changes: 18 additions & 0 deletions source/conformance/definition/invalid/handler-is-not-a-list.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# expect: SCHEMA_INVALID
#
# `handler` holds a step list. A mapping there is the shape a hand-edit reaches
# for when it thinks of the handler as one step rather than a region — and a
# reader that accepted it would have a `core.try` whose fallback is a step nothing
# walks, which is the same silence an unwalked region gives.
id: wf
name: W
version: 1
status: draft
steps:
- id: guard
use: core.try
steps:
- { id: s1, use: component.email.send }
handler:
id: s2
use: component.email.send
Loading
Loading