feat: core.repeat and core.set_var, and a variable that declares its type - #36
Open
pedromvgomes wants to merge 10 commits into
Open
feat: core.repeat and core.set_var, and a variable that declares its type#36pedromvgomes wants to merge 10 commits into
pedromvgomes wants to merge 10 commits into
Conversation
|
pedromvgomes
force-pushed
the
feat/repeat-and-set-var
branch
from
August 23, 2026 10:46
77432a4 to
c447629
Compare
…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
force-pushed
the
feat/repeat-and-set-var
branch
from
August 24, 2026 15:25
3c5a66b to
99ebc2e
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.
core.set_varwas named in the schema and in ADR-0013 as "the mechanism" for loop state, and implemented nowhere.core.repeatappeared 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.repeattests after the bodyThe body runs, then
untilis 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 costblocks:exists to pay back, reintroduced by a control-flow choice.This is also the answer to the deferral. A
core.repeatdischarges a Block's return obligation and acore.for_eachdoes not, and that is one rule rather than two special cases:alwaysReturnsonly 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 besidesteps:, not a field underwith:. This is the wallblocks.tsdocumented from the other side: a manifest field carries a renderingkindand no type, so the expected type comes fromFIELD_KIND_TYPES— and that vocabulary has no mappable boolean at all, becauseboolholds a literal rather than a Template. Underwith:a condition would type-check as text, so{{ steps.s2.count }}would pass as a termination condition.repeatSlotiswhenSlotwith a different name.A repeat binds nothing.
core.for_eachcan exposeitembecauseitemis resolved by following the loop'slistback 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 besideTRIGGER. That is a permanent cost for a countercore.set_varalready writes. It also keeps the corpus trap out of play: no new addressable root, so noeval/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
untilever goes false depends on values that exist only during a run. Amax: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 markingkeynames a variable on the Step's own Board, and there is no second list to fall back to — which is what makes "a Block'score.set_varcan never reach the workflow's variables" true by construction rather than by a rule.valueis a Slot no manifest can type, makingcore.set_varthe third such verb beside a call and acore.return.This is the load-bearing one.
varTyperead 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: ""inferstext, acore.set_varwrites{{ 1 + 1 }}, and the builder says text while the runner produces a number.varsgains a requiredt, and an optionalof. The schema anticipated this in its own words — "a list of key/value objects rather than a map, so atypeorlabelcan 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:
set_varto 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 infersunknown, so every write into it goes unchecked.unknownfor any var aset_vartargets. 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.tis 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.
valuebecomes 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.v3decodesvalue: 2024-01-01T00:00:00Zinto atime.Timewhile 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:RepeatVerbcase from Go'salwaysReturnsfails two scenarios, including the one that pairs it againstcore.for_each.varsOnfall back to the workflow's vars fails "a set_var inside a block cannot reach the workflow's variables".The corpus reaches
ValidateDefinitionand stops there, and a review found the gap that leaves: the Go SDK'sLoadDefinitionvalidated a variable's key and nothing else, so it acceptedt: unknown,t: itemandt: Textwhere the JSON Schema refuses all three — a document the runner loads and the builder will not open.unknownanditemare 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'sofgoes throughvalidateDeclarations, andconformance/definition/invalid/variable-type-is-not-a-declared-type.yamlpins 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.tsandsdk/go/loops_test.gomirror each other over that half — a var declaredbooleanrefuses{{ 1 + 1 }}written into it and the same document witht: numberaccepts 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.mdis where the row shape is specified when it happens.The builder gains a type control. A variable row has a
Selectfort, andaddVariablewritest: text— for the reason it mints a key rather than leaving one blank. The value input gains anexpectedTypein exchange.A repeat draws with no layout change. Nothing in
@hatua/react,@hatua/servicesor@hatua/layoutnamescore.for_each: they readstep.stepsgenerically, socore.repeatnests 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'swhen.StepListrenders a branch condition as read-only<code>, and@hatua/servicesholds nosetBranchWhen, noaddBranch, no branch mutation of any kind — conditions have been display-only since forks existed.untillands 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
untilalone 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, sountiltakes its row besidewhen, 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,gofmtclean.pnpm codegenleaves 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.