Skip to content

feat: core.try, and the binding item and the failure share - #38

Open
pedromvgomes wants to merge 2 commits into
feat/repeat-and-set-varfrom
feat/try-and-item
Open

feat: core.try, and the binding item and the failure share#38
pedromvgomes wants to merge 2 commits into
feat/repeat-and-set-varfrom
feat/try-and-item

Conversation

@pedromvgomes

Copy link
Copy Markdown
Owner

Stacked on #36 (feat/repeat-and-set-var). Adds core.try and builds the item
binding it was defined by analogy to — ADR-0013 named both and shipped neither.

The debt this pays off

core.try existed in ADR-0013 and nowhere else. item was worse than absent: it
was documented in the manifest schema, reachable as {{steps.<loop id>.item}},
and resolved by nothing — and because Match treats item as matching
everything 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: unknown
reaching 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 item is the token
that 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 name
inside a name the user chose.

Nesting is the test the alternatives fail: two nested loops are two step ids, so
steps.outer.item and steps.inner.item never collide and no shadowing rule is
needed. 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 under steps:, handler under handler:.
Not two branches: under reserved labels: a branch's identity is its label,
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. In with:, as ordinary
manifest fields. until had to leave with: because FIELD_KIND_TYPES has no
mappable boolean at all. An attempt count and a backoff are numbers, and
number is 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 table
stays 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.repeat argument — but a
failure 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 observable

item resolves by reading the loop's list as a Reference, typing that path
against the loop step's own scope, and taking the element shape of the list it
names. Where it cannot resolve — list absent, not a plain Reference, naming
nothing, or naming a non-list — it 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. 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. 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 callsOf skipped 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_each declared items / t: object where the schema and ADR both say
list / t: item, and the shipped set had no list output at all — so t: item
had 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.

  • Go's LoadDefinitionValidate(), which the rules corpus never calls: covered by two invalid/ fixtures for the new handler key, one of which is a handler step missing its use — it fails only if the walk reaches the region.
  • The playground seed, which the corpus cannot reach because every scenario supplies its own manifests: this is the one that was wrong. apps/playground/src/seed.test.ts now holds it to the catalogue it is actually served, asserting the exact diagnostic set (the seed deliberately carries one) and that item resolves — a count or a "nothing new" check would miss one of the two.
  • A new addressable root, which fails loudly in Go and silently in TS: none is introduced, and this was checked rather than assumed. Both bindings are members under the existing steps root; sdk/go/expressions/conformance_test.go enumerates exactly six roots and t.Fatalfs on any other. So no eval/ scenario is needed — the trap is avoided by the design choice, not by luck.
  • Storybook fixtures: stories.fixtures.test.ts stays green, and DeepTree now 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 codegen leaves 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).
  • Nine mutations, all killed: TRY_HAS_NO_HANDLER never raised (Go); a try discharging on either region (Go and TS); the walk forgetting the handler (Go and TS); LOOP_LIST_NOT_A_LIST never raised (TS); a try in scope for its later siblings (TS); the handler unable to see the failure (TS); callsOf skipping 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; item through a list; item in two nested loops;
and item where 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 — try over the body and on failure over the handler — and
the summary reads core.try · 1 step · handler. That last part is a change made
because of looking: it read core.try · 1 step, which names the body while the
card expands into two regions. StepList.test.tsx now holds all three.

Next in the stack

The layout algorithm and the canvas. packages/layout/src/index.ts is 32 lines of
constants with no algorithm, and Placement.stepId carries no Board — which #34
made wrong, since two Blocks may each hold a step called ret. A try's second
region is the other thing waiting there: FlowMap is still a placeholder, so
nothing needed updating here.

@github-actions

github-actions Bot commented Aug 24, 2026

Copy link
Copy Markdown

bulwark

  • scan — no findings

  • coverage — go: 90.7% (baseline 89.3%); typescript: 94.8% (baseline 94.6%); go patch: 91.3% (693/759 new lines; baseline 89.3%); typescript patch: 97.2% (971/999 new lines; baseline 94.6%)

📦 Full bulwark output — complete scan and coverage logs.

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.
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