Skip to content

feat: core.repeat and core.set_var, and a variable that declares its type - #36

Open
pedromvgomes wants to merge 10 commits into
feat/blocks-and-namespace-closurefrom
feat/repeat-and-set-var
Open

feat: core.repeat and core.set_var, and a variable that declares its type#36
pedromvgomes wants to merge 10 commits into
feat/blocks-and-namespace-closurefrom
feat/repeat-and-set-var

Conversation

@pedromvgomes

@pedromvgomes pedromvgomes commented Aug 23, 2026

Copy link
Copy Markdown
Owner

core.set_var was named in the schema and in ADR-0013 as "the mechanism" for loop state, and implemented nowhere. core.repeat appeared in no code at all. So the schema documented a verb that did not exist, and it had been doing so since #34. This builds both, and settles the four decisions ADR-0013 left to the PR that gave them a reader.

core.repeat tests after the body

- id: revise
  use: core.repeat
  until: "{{ var.approved }}"
  steps:
    - { id: draft, use: component.agent.act }
    - { id: record, use: core.set_var, with: { key: approved, value: "{{ steps.draft.ok }}" } }

The body runs, then until is evaluated; false runs it again. A pre-tested loop was the alternative and was refused because the two are not symmetric: a pre-tested loop is expressible as a post-tested one whose body opens with a fork, while a post-tested loop is expressible as a pre-tested one only by duplicating the body above the loop — the deduplication cost blocks: exists to pay back, reintroduced by a control-flow choice.

This is also the answer to the deferral. A core.repeat discharges a Block's return obligation and a core.for_each does not, and that is one rule rather than two special cases: alwaysReturns only ever asks is this region guaranteed to run at all — a list may be empty, a repeat's first pass cannot be skipped. The two scenarios sit side by side in the corpus so a language that collapses them fails on one.

until: is a structural key beside steps:, not a field under with:. This is the wall blocks.ts documented from the other side: a manifest field carries a rendering kind and no type, so the expected type comes from FIELD_KIND_TYPES — and that vocabulary has no mappable boolean at all, because bool holds a literal rather than a Template. Under with: a condition would type-check as text, so {{ steps.s2.count }} would pass as a termination condition. repeatSlot is whenSlot with a different name.

A repeat binds nothing. core.for_each can expose item because item is resolved by following the loop's list back to its source output. A repeat has no list, so an index or count would be a binding nothing declares and nothing types — and under ADR-0014's closed roots it would need a seventh root or a second bare token beside TRIGGER. That is a permanent cost for a counter core.set_var already writes. It also keeps the corpus trap out of play: no new addressable root, so no eval/ scenario silently doing nothing in TypeScript.

Nothing bounds the iterations, and that is a decision. Recursion is refused at design time because it is a property of the document — a cycle in the call graph. Whether an until ever goes false depends on values that exist only during a run. A max: in the document would be a number Hatua could neither check nor enforce, and a runner ignoring it would still be conformant. Bounding is stated as the Host runner's obligation, in the ADR and in the schema.

core.set_var, and the type marking

- id: record
  use: core.set_var
  with: { key: approved, value: "{{ steps.draft.ok }}" }

key names a variable on the Step's own Board, and there is no second list to fall back to — which is what makes "a Block's core.set_var can never reach the workflow's variables" true by construction rather than by a rule. value is a Slot no manifest can type, making core.set_var the third such verb beside a call and a core.return.

This is the load-bearing one. varType read a variable's type off the literal beside it, and the Workflow tab was built on that: "a variable field is the one input with no type marking". That stops being true the moment a Step can write the variable — value: "" infers text, a core.set_var writes {{ 1 + 1 }}, and the builder says text while the runner produces a number.

vars gains a required t, and an optional of. The schema anticipated this in its own words — "a list of key/value objects rather than a map, so a type or label can be added later without a breaking change" — and ADR-0012's argument against a second spelling holds unchanged. A variable is still not a declaration: it carries a value, and its key is its own label.

Two alternatives, refused with reasons in the ADR:

  • Constrain set_var to the inferred type. Keeps two mechanisms for one idea, and makes the contract depend on how the first value happened to be written — an object var is unexpressible without an object literal, and an expression-valued var infers unknown, so every write into it goes unchecked.
  • Weaken the inference to unknown for any var a set_var targets. Makes the marking depend on a Step elsewhere in the document: adding a writer silently degrades every reader, so the builder gets quieter exactly as the workflow gets more complicated.

t is required rather than defaulted, for the reason ADR-0014 rewrote every document at once: a fallback spelling is a second definition of the thing on the day it was declared. Every fixture carrying a var is rewritten.

Two things fall out. value becomes what it always was — the initial value — and gains a type to be checked against, which it never had, so a var seeded with {{ … }} was previously unchecked in both languages. And it settles a divergence the inference had no answer for: yaml.v3 decodes value: 2024-01-01T00:00:00Z into a time.Time while the builder's parser leaves it a string, so the two languages typed one scalar differently and the Go SDK carried a comment saying so. A declared type is decoder-independent.

What the corpus caught, and what it cannot

17 new scenarios in conformance/definition/rules/repeat-and-variables.yaml, run by both languages — 41 in the corpus now. Two rules were mutation-tested rather than trusted on a green first run:

  • Removing the RepeatVerb case from Go's alwaysReturns fails two scenarios, including the one that pairs it against core.for_each.
  • Making TypeScript's varsOn fall back to the workflow's vars fails "a set_var inside a block cannot reach the workflow's variables".

The corpus reaches ValidateDefinition and stops there, and a review found the gap that leaves: the Go SDK's LoadDefinition validated a variable's key and nothing else, so it accepted t: unknown, t: item and t: Text where the JSON Schema refuses all three — a document the runner loads and the builder will not open. unknown and item are the sharp ones rather than the misspelling: the checker treats both as matching everything, so either switches the type gate off for that variable while the builder still draws a marking beside it. The type check is now one function called from both the declaration path and the variable path, a variable's of goes through validateDeclarations, and conformance/definition/invalid/variable-type-is-not-a-declared-type.yaml pins it — both languages run that directory and require rejection, so it covers the loader here and the zod gate there.

The corpus compares diagnostics, so it cannot reach the type marking itself. packages/model/src/loops.test.ts and sdk/go/loops_test.go mirror each other over that half — a var declared boolean refuses {{ 1 + 1 }} written into it and the same document with t: number accepts it, from the document through the Slot to a verdict. Round-trip covers both new document keys, comments and quoting included.

Consequences worth flagging

Corrected documentation of record. ADR-0013 carries both verbs' shapes and resolves the deferral. CONTEXT.md's Slot entry said the type is declared by the Component Manifest or by the language — a Block's params already made that incomplete and a declared var type makes it wrong, so it now names three sources; Variable and Repeat get entries of their own. docs/handoff.md's Workflow tab now says which control moves the type marking and which one does not.

Deferred: the variables stack reads flat. Four variables now render as twelve near-identical boxes — key, type, value, repeated — with only the bin button separating one from the next. Adding the type row made an existing flatness worse. The section wants a divider or extra spacing between variables; that is a follow-on refactor, not part of this PR, and docs/handoff.md is where the row shape is specified when it happens.

The builder gains a type control. A variable row has a Select for t, and addVariable writes t: text — for the reason it mints a key rather than leaving one blank. The value input gains an expectedType in exchange.

A repeat draws with no layout change. Nothing in @hatua/react, @hatua/services or @hatua/layout names core.for_each: they read step.steps generically, so core.repeat nests and renders for free. That is ADR-0014's closed namespace paying off rather than anything this PR did.

until: has no editing command, and neither does a Branch's when. StepList renders a branch condition as read-only <code>, and @hatua/services holds no setBranchWhen, no addBranch, no branch mutation of any kind — conditions have been display-only since forks existed. until lands in exactly that state: both languages know it, the checker types it, round-trip carries it, and no control writes it.

Deliberately not closed here. Building it for until alone would leave the builder able to author a repeat's condition and not a fork's, which is a worse gap than a uniform one — the two are the same shape and want one slice. What this PR does owe is the design of record: handoff.md's Expression-editing table listed three sites holding a Template and there are now four, so until takes its row beside when, and the table states that whatever surface holds one holds the other.

Verification

pnpm typecheck && pnpm lint && pnpm test — 1,724 TypeScript tests. go vet ./... && go clean -testcache && go test ./... green on a cold cache, gofmt clean. pnpm codegen leaves no drift, checked by hashing the generated files across a second run.

Next in the stack: core.try → the layout algorithm → the canvas that draws Boards.

@github-actions

github-actions Bot commented Aug 23, 2026

Copy link
Copy Markdown

bulwark

  • scan — no findings

  • coverage — go: 90.3% (baseline 89.3%); typescript: 94.7% (baseline 94.6%); go patch: 91.0% (573/630 new lines; baseline 89.3%); typescript patch: 97.0% (795/820 new lines; baseline 94.6%)

📦 Full bulwark output — complete scan and coverage logs.

@pedromvgomes
pedromvgomes force-pushed the feat/repeat-and-set-var branch from 77432a4 to c447629 Compare August 23, 2026 10:46
@pedromvgomes pedromvgomes changed the title core.repeat and core.set_var, and a variable that declares its type feast: core.repeat and core.set_var, and a variable that declares its type Aug 23, 2026
@pedromvgomes pedromvgomes changed the title feast: core.repeat and core.set_var, and a variable that declares its type feat: core.repeat and core.set_var, and a variable that declares its type Aug 24, 2026
…res its type

The schema documented `core.set_var` as how a var is written and nothing
implemented it; `core.repeat` appeared in no code at all. Both are declared here,
together with the corpus scenarios both languages are then held to.

Three shapes are settled.

`until:` is a structural key beside `steps:`, not a field under `with:`. A
manifest field carries a rendering kind and no type, so FIELD_KIND_TYPES is what
recovers the expected type — and that vocabulary has no mappable boolean at all.
Under `with:` a repeat's condition would type-check as text, which is the half of
the contract the field exists to carry. A Branch's `when` sits in the same
position for the same reason.

`core.set_var` takes a var key and a value typed by the var that key names. No
manifest knows which Board a step is on, so it joins a call and a `core.return`
as a verb whose fields are read from the document rather than from a manifest.

A variable declares `t`, and `of` for shape. `value` is only the FIRST value once
a step can write the same variable, so a type read off it is a claim about one
moment in an execution — the builder says text while the runner produces a
number, and every downstream check was answered against that. Required rather
than defaulted, because a fallback spelling is a second definition of the thing
on the day it was declared.

The corpus lands before either implementation. Its load-bearing pair sits side by
side: a repeat discharges a block's return obligation because its body always
runs, and a for_each does not because the list may be empty.
validity.ts and validity.go move rule for rule, because the corpus compares them
as a sorted set and a code that blocked Publish in one and informed in the other
would let a workflow publish from one builder and not another.

`alwaysReturns` gains one line rather than a special case: a `core.repeat`
discharges the obligation its body discharges. The question it asks is only ever
whether a region is guaranteed to run at all — a list may be empty, a repeat's
first pass cannot be skipped — so both loop verbs now have one answer instead of
two exceptions.

`varsOn` is what makes "a set_var inside a block can never reach the workflow's
variables" true by construction. There is no second list to fall back to, so
naming a workflow var from inside a block is an unknown name rather than a scope
the two runtimes might resolve differently.

varType is gone from both languages. It also settles a divergence it had no
answer for: yaml.v3 decodes `value: 2024-01-01T00:00:00Z` into a time.Time while
the builder's parser leaves it a string, so the two typed one scalar differently
and the Go SDK carried a comment saying so.

loops.test.ts and loops_test.go mirror each other over the half the corpus cannot
reach — what a Slot expects and what the checker then says about it, which is
where the type marking lives.
…being one

A variable row gains a Select for its declared type, and `addVariable` writes
`t: text` rather than leaving it out — for the reason it mints a key rather than
leaving one blank: the schema requires it, so a row without one is a document
that stops projecting the moment it appears.

The type control is now the one edit on the row that re-types every Expression
reading the variable, and the value box is not. The value input gains an
`expectedType` in exchange, which is the one place a variable's value is a Slot —
everywhere else the variable is read rather than written, and until `t` existed
there was nothing to check an initial value against.

Round-trip covers both new document keys. An `until:` beside `steps:` and a `t:`
inside `vars:` are structure the CST has to carry through untouched, and a key
silently dropped there is a condition or a type marking that vanishes the first
time anything edits the file.

Every fixture carrying a var is rewritten. No fallback spelling, for the reason
ADR-0014 gives.
ADR-0013 named both verbs and left their shape to the PR that gave them a reader.
It now carries both, and resolves the deferral it named: a repeat tests after the
body, so the body always runs, so it discharges a block's return obligation where
a for_each cannot. The rejected alternatives are recorded because that is what an
ADR is for — a pre-tested loop, iteration state on the container, a declared
iteration bound, and the two ways of keeping the type inference alive.

CONTEXT.md's Slot entry said the type is the field's, declared by the Component
Manifest or by the language. A Block's params already made that incomplete and a
declared var type makes it wrong: there are three sources, and a declaration in
the document is one of them. Variable and Repeat get entries of their own.

handoff.md built the Workflow tab on "a variable field is the one input with no
type marking". That is false once a step can write the variable, and the section
now says which control moves the marking and which one does not.
The Expression-editing table listed three sites and there are four. `until` sits
in the same position a Branch's `when` does — a structural key on a container,
typed boolean by the language rather than reached through a Component Manifest —
so it is edited through the container that owns it, and whatever surface holds
one holds the other.
`Validate()` checked a variable's key and stopped there, while a block's
declarations went through the full contract. So `LoadDefinition` accepted
`t: unknown`, `t: item` and `t: Text` on a var, and the JSON Schema refuses all
three — a document the runner loads and the builder will not open, which is the
divergence conformance/ exists to prevent.

`unknown` and `item` are the sharp ones rather than the misspelling: the checker
treats both as matching everything, so either switches the type gate off for
that variable while the builder still draws a marking beside it. A var typed
`unknown` reads as checked and is not.

The type check is one function now, called from both the declaration path and
the variable path, because a second copy is a second answer. A variable's `of`
goes through `validateDeclarations` for the same reason: it carries the nested
shape a declaration's does, so its members are held to the same contract rather
than to none.

The invalid fixture lands with it. Both languages run
`conformance/definition/invalid/` and require rejection, so it pins the loader
here and the zod gate there — the layer the rules corpus cannot see, because
that corpus calls ValidateDefinition directly and never reaches Validate().
…uences

The doc comment on `variableType` named the reading it replaced, which is a
sentence only a reader holding the previous version can act on. What matters is
the property: a declared type is decoder-independent, and reading the value is
not — yaml.v3 turns a timestamp scalar into a time.Time where the builder's
parser leaves it a string, so the two languages disagree about one document.

ADR-0013's section promised three consequences and argued four.
Two vars in the Workflow stories carried no `t`, so the whole document failed to
project — and a region handed a failed projection draws the state it shows a Host
that wired nothing up. Every Workflow story rendered that instead of the builder,
including the variables panel holding the type control.

Nothing caught it because nothing runs a story fixture: Storybook renders it by
hand, no test mounts it, and the file still compiles. That makes a story the one
document in the repo a schema change can break silently, which is the failure
worth pinning rather than the two missing keys.

The guard reads the fixtures out of the story sources and projects each through
@hatua/document, which is the path the editing store takes — a test-only
dependency, and nothing under src/ outside that file imports it. It reads source
rather than the module, so it undoes the escapes the compiler would have: a
fixture written with `\n` holds two characters where the running story holds a
newline.
…t an empty type

`setVarSlot` took a list of variables, so the caller decided whether a Block falls
back to the workflow's — the one rule this verb exists inside. It now takes the
Board, matching `SetVarSlot` in the Go SDK argument for argument, and `varsOn`
moves to `tree.ts` beside `boardOf` and is exported: a runner has to answer the
same question the builder does, and a rule restated at two call sites is two
rules the day one of them gains a fallback.

`variableType` used `??`, which passes an empty string through, while Go tests
`T == ""`. A hand-edited `t: ""` therefore typed as `''` here — matching nothing,
so every read of that variable reported a mismatch — and as `unknown` there,
matching everything. Same document, two verdicts.

The JS SDK re-exports `varsOn`, `variableOn` and the two verb constants, so a
runner can dispatch and resolve without restating either.
Three places claimed a variable's value box carries a type marking. Looking at
it in Storybook says otherwise: `expectedType` reaches `expectedAt` and from
there the completion list, so it decides which candidate rows are railed as
fitting. The field itself is never marked — nothing is marked wrong anywhere in
the picker — so the box looks identical typed or not.

What a reader can actually see is the picker: a variable's value could rail no
candidate at all before, and rails them now. The reference tree shows the
declared type beside every `var.*` row, where an expression-valued variable read
`unknown`.

`expectedAt` still said its argument is undefined for "a workflow variable,
whose type is read from its value". Nothing reads a type from a value any more,
so the sentence describes a field that no longer exists rather than the one it
guards.
@pedromvgomes
pedromvgomes force-pushed the feat/repeat-and-set-var branch from 3c5a66b to 99ebc2e 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