feat: core.try, and the binding item and the failure share - #38
Open
pedromvgomes wants to merge 2 commits into
Open
feat: core.try, and the binding item and the failure share#38pedromvgomes wants to merge 2 commits into
pedromvgomes wants to merge 2 commits into
Conversation
|
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>}}`. One mechanism for both
of ADR-0013's bindings: a loop's `item` and a try's `error`.
It costs no namespace root and no bare token, which is why 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 step id already sits one segment below `steps.`, so nesting needs
no shadowing rule: two containers are two step ids.
`t: item` was documented, reachable and resolved by nothing, and because the
checker treats `item` as matching everything the gap surfaced as a type check
that always passed rather than as an error. It now resolves by reading the loop's
`list` as a Reference, typing it against the loop step's own scope, and taking
the element shape of the list it names. Where it cannot resolve it stays `item`
and stays permissive, and LOOP_LIST_NOT_A_LIST reports the list instead —
necessary because `list` is a `ref` field, which FIELD_KIND_TYPES maps to
`unknown`, so the ordinary Slot check accepts anything written there.
core.try nests twice: a body under `steps:` and a fallback under `handler:`. A
key rather than two branches under reserved labels, because a branch's identity
is its label — free text a user renames — and a region renameable out of
existence is not a region. The two regions are siblings, so the body cannot see
the handler and the handler cannot read the body's steps, with no code saying so;
which of them completed before the failure is not a fact the document holds.
The retry policy sits in `with:` as ordinary manifest fields. `until` left `with:`
because FIELD_KIND_TYPES has no mappable boolean; an attempt count is a number and
`number` IS mappable, so that argument is absent here and following it anyway
would copy a conclusion without its reason.
A try discharges a block's return obligation only when both regions return. Its
body always runs, but a failure part-way through it enters the handler instead,
so the guarantee is a conjunction rather than the repeat's "the body always runs".
Also fixed, found by sweeping the walkers rather than by a failing test:
TypeScript's `callsOf` skipped the handler, so recursion through one went
unreported while Go caught it. Both languages now walk it, pinned by a scenario.
The catalogue's `core.for_each` declared `items`/`t: object`, which the schema and
ADR both call `list`/`t: item`; the playground seed was written against the old
key and iterated a single object. Both corrected, and `seed.test.ts` now holds the
seed to the catalogue it is actually served — the layer the rules corpus, which
supplies its own manifests, cannot reach.
…y has one type
Two places where the two languages could hand a Host a different answer about
the same document.
loopElementType answered `object` for a list that declared no `of:`. That is
ElementOf's right answer for a projection — what does `.name` read off this —
and the wrong one here: it marks item as a shape nothing declared, so
{{ steps.each.item }} in a text field raises EXPR_TYPE_MISMATCH, an error that
refuses Publish on a correct workflow. It now returns nothing, so item stays
item and is accepted with EXPR_TYPE_UNKNOWN — a warning, and the code whose own
summary names item members as its motivating case. The function's docstring
already said this ("Guessing `object` instead would be a shape nothing
declared"); the code did not.
blockOutputType was last-wins in Go and first-wins in TypeScript. A block
declaring one output key twice is a document the schema loads and
DECLARATION_KEY_DUPLICATE only stops at Publish, so the same call site typed
number in one builder and text in the other. Go is first-wins now, matching
BlockOf and CyclicBlocks.
Neither is reachable from the conformance corpus: the expression corpus supplies
a scope rather than building one from a document, and the rules corpus never
runs the expression pass. So both are pinned by a test in each language instead,
and each one fails without its fix.
pedromvgomes
force-pushed
the
feat/try-and-item
branch
from
August 24, 2026 15:25
a77989f to
30502a0
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.
Stacked on #36 (
feat/repeat-and-set-var). Addscore.tryand builds theitembinding it was defined by analogy to — ADR-0013 named both and shipped neither.
The debt this pays off
core.tryexisted in ADR-0013 and nowhere else.itemwas worse than absent: itwas documented in the manifest schema, reachable as
{{steps.<loop id>.item}},and resolved by nothing — and because
Matchtreatsitemas matchingeverything in both languages, the gap surfaced not as an error but as a type
check that always passed. That is the same failure mode #36 found in
t: unknownreaching the Go loader, one layer up.
The decisions
Where a container's binding lives. 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>}}. One mechanism, both bindings.It costs no namespace root and no bare token, which is why it is the answer
rather than merely an 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
itemis the tokenthat argument refuses; a seventh root costs a word forever. A step id already
sits one segment below
steps., so a binding hung off the container is a nameinside a name the user chose.
Nesting is the test the alternatives fail: two nested loops are two step ids, so
steps.outer.itemandsteps.inner.itemnever collide and no shadowing rule isneeded. A bare token would need one, plus an escape hatch for reaching outwards,
and a reader would have to count enclosing loops to know what a word means.
core.try's document shape. Body understeps:, handler underhandler:.Not two
branches:under reserved labels: a branch's identity is itslabel,which is free text a user renames, so the meaning of the document would depend on
a display name and a region could be renamed out of existence. A key collides
with nothing, because nothing inside a step is user-named.
Where the retry policy lives — and it is not
until. Inwith:, as ordinarymanifest fields.
untilhad to leavewith:becauseFIELD_KIND_TYPEShas nomappable boolean at all. An attempt count and a backoff are numbers, and
numberis a mappable field kind, so that argument is simply absent here.Reaching for a structural key by analogy would copy a conclusion without its
reason and cost a schema key, a diagnostic and a form control the manifest gives
for free. The payoff is visible in
docs/handoff.md: the Template-site tablestays four rows long.
The return obligation. A try discharges it only when both regions return.
The body always runs, which alone looks like the
core.repeatargument — but afailure part-way through the body is what a try exists to admit, and that path
leaves the body unfinished and enters the handler. So every path out goes through
the body or the handler: the Fork's all-branches reasoning asked of two regions,
one of which is conditional.
What a handler's children can read. The failure, everything above the try,
and nothing from the body. The two regions are siblings, so this needs no code
— it is the rule that already keeps a Fork's branches out of each other's scope,
and it is right for the right reason: the body failed somewhere, and which of
its steps completed is not a property of the document. The try step itself is in
scope only inside its handler; a step after the try cannot read it either,
because whether there was a failure at all is a run-time fact.
item, and the rule that makes it observableitemresolves by reading the loop'slistas a Reference, typing that pathagainst the loop step's own scope, and taking the element shape of the list it
names. Where it cannot resolve —
listabsent, not a plain Reference, namingnothing, or naming a non-list — it stays
itemand stays permissive. Guessingobjectwould be a shape nothing declared, and everysteps.each.item.<field>would then type-check against members the manifest never had.
The wrongness is reported instead:
LOOP_LIST_NOT_A_LIST. It has to be itsown rule because
listis areffield andFIELD_KIND_TYPESmapsreftounknown, so the ordinary Slot check accepts anything written there — the same"a check that always passes" failure, one layer up. Only a statically-known
conflict is reported, matching the lattice everywhere else.
New codes:
TRY_HAS_NO_BODY,TRY_HAS_NO_HANDLER,LOOP_LIST_NOT_A_LIST.Two things found by looking, not by a failing test
TypeScript's
callsOfskipped the handler. Go's (which this PR updated)walked it. So a Block recursing through a try's handler would be reported by Go
and published by the builder — a language divergence, in the layer the corpus
only reaches if a scenario exists. Both now walk it, pinned by a scenario, and
mutation-tested in both directions.
The catalogue and the playground seed disagreed with the schema.
core.for_eachdeclareditems/t: objectwhere the schema and ADR both saylist/t: item, and the shipped set had no list output at all — sot: itemhad nothing to resolve through, which is part of why the debt went unnoticed. The
seed was written against the old key and iterated a single object. Both corrected.
Which layer could hold this wrong and stay green?
Asked once, in writing, as #36's postmortem requires — and it found the seed.
LoadDefinition→Validate(), which the rules corpus never calls: covered by twoinvalid/fixtures for the newhandlerkey, one of which is a handler step missing itsuse— it fails only if the walk reaches the region.apps/playground/src/seed.test.tsnow holds it to the catalogue it is actually served, asserting the exact diagnostic set (the seed deliberately carries one) and thatitemresolves — a count or a "nothing new" check would miss one of the two.stepsroot;sdk/go/expressions/conformance_test.goenumerates exactly six roots andt.Fatalfs on any other. So noeval/scenario is needed — the trap is avoided by the design choice, not by luck.stories.fixtures.test.tsstays green, andDeepTreenow carries a try.Verification
pnpm typecheck && pnpm lint && pnpm test— clean.go vet ./...,go clean -testcache && go test ./...— clean. Cache cleared before believing any Go result; the corpus runs 64 scenarios in each language.pnpm codegenleaves no drift, verified by hashing the 8 generated files across two runs (with a file-count guard — the first attempt hashed nothing and passed vacuously).TRY_HAS_NO_HANDLERnever raised (Go); a try discharging on either region (Go and TS); the walk forgetting the handler (Go and TS);LOOP_LIST_NOT_A_LISTnever raised (TS); a try in scope for its later siblings (TS); the handler unable to see the failure (TS);callsOfskipping the handler (Go and TS).Round-trip fidelity covers both new keys and the retry fields. Corpus scenarios
cover: a try with no handler; with no body; a handler reading the failure; a
handler reading a body step (refused); a try inside a Block that does and does
not discharge the obligation;
itemthrough a list;itemin two nested loops;and
itemwhere the list is not one.On screen
Checked in Storybook before being written down, not after. The try renders as one
card with two chips —
tryover the body andon failureover the handler — andthe summary reads
core.try · 1 step · handler. That last part is a change madebecause of looking: it read
core.try · 1 step, which names the body while thecard expands into two regions.
StepList.test.tsxnow holds all three.Next in the stack
The layout algorithm and the canvas.
packages/layout/src/index.tsis 32 lines ofconstants with no algorithm, and
Placement.stepIdcarries no Board — which #34made wrong, since two Blocks may each hold a step called
ret. A try's secondregion is the other thing waiting there:
FlowMapis still a placeholder, sonothing needed updating here.