Skip to content

feat: Blocks, Boards, and closed namespace roots - #34

Open
pedromvgomes wants to merge 14 commits into
docs/control-flow-nestsfrom
feat/blocks-and-namespace-closure
Open

feat: Blocks, Boards, and closed namespace roots#34
pedromvgomes wants to merge 14 commits into
docs/control-flow-nestsfrom
feat/blocks-and-namespace-closure

Conversation

@pedromvgomes

@pedromvgomes pedromvgomes commented Aug 22, 2026

Copy link
Copy Markdown
Owner

Implements ADR-0013's blocks: — the reuse mechanism the previous PR argued for in prose — and closes the two namespaces that were open at the root.

Schema, model, services, conformance and both SDKs. No React and no canvas: PR 4 places the call chip, and the extract this selection into a Block gesture needs a canvas selection to extract. The commands such a gesture would issue are here, because a Block created on the canvas and one hand-written in Text Mode must be the same edit.

Namespace roots are closed (ADR-0014, new)

Every Expression path now begins with one of six roots and every Component verb with one of three, so a name a user chooses always sits one segment below a root and can collide with nothing.

roots
Expression run. triggers. params. steps. var. + the built-in TRIGGER
Verb core. (Hatua) block. (this document) component. (the Host)

ADR-0010 made this argument for functions and stopped one level short — "namespacing also removes the need for reserved words". ADR-0012 hit the wall from the other side, recording that run had to become a root because "a step may legitimately be called run". Under an open root that promise costs a special case in every resolver; under a closed one it is free.

The expensive half is prefixing the Host's verbs. component.email.send is longer than email.send, forever, and it buys a language where Hatua reserves no bare word at all — so no component a Host ever declares can be refused because Hatua got to the name first.

The expression language itself is untouched. Scope reaches @hatua/expressions as ScopeEntry[], and validate.ts already resolved dotted paths generically by prefix — that is how triggers.nightly has always been one entry rather than two. What changes is what scopeFor writes into path, and that resolve() loses its fallback: root() becomes a table where an unrecognised name is missing rather than a step id to go looking for.

Breaking, with no fallback spelling and no version bump. Hatua is unreleased, no Host has a document on disk, and accepting both spellings would put a second definition of every root into the language on the day it was closed.

Blocks

blocks:
  - id: archive_entry
    params:
      - { k: entry, label: Entry, t: object, of: [{ k: headline, label: Headline, t: text }] }
    outputs:
      - { k: url, label: Archive URL, t: text }
    vars:
      - { key: attempt_note, value: "" }
    steps:
      - { id: put, use: component.s3.upload, with: { body: "{{ params.entry }}" } }
      - { id: ret, use: core.return, with: { url: "{{ steps.put.location }}" } }

steps:
  - id: audit_1
    use: block.archive_entry
    with: { entry: "{{ steps.s8 }}" }

Invoking a Block turns out not to need a verb. core.call was the first draft; once the verb namespace closed, a call is use: block.<id> resolved against blocks: instead of against the manifest set. ADR-0013's budget of three additions holds with a different three — core.repeat, core.return, core.try — and no Block has to avoid a parameter called block.

Parameters and outputs are {k, label, t, of}, ADR-0012's shape, so outputsToType types {{ params.entry.headline }} and {{ steps.audit_1.url }} with no new code and params. is an exact mirror of triggers..

A call's Slots are typed by the declaration directly, not through a synthesized manifest. That was the first draft and it does not survive contact with the field vocabulary: a manifest field carries a rendering kind and no type, and FIELD_KIND_TYPES cannot express "a Template that must produce a boolean" at all, because bool holds a literal. Synthesizing one would have discarded exactly the half of the contract a call site exists to check.

Boards — the load-bearing decision

A Board is one drawable Step tree and the root that gives it its parameters: the root Board, whose root is triggers:, and one per Block, whose root is its contract.

root Board a Block's
run.* Run Context the same — the one thing that crosses
triggers.*, TRIGGER the parameter contract absent
params.* absent the parameter contract
var.* the workflow's the Block's, rebuilt per call
steps.* this Board's this Board's

scopeFor roots its walk at the Board an id sits on and never leaves it. Step ids are Board-local — two Blocks may each hold a ret, and {{steps.ret}} means the one on the Board it is written on.

A Block cannot read the workflow's variables or ask which Trigger fired. Run Context is the single exception and earns it: nothing in the document declares it, so it is exact on every path of every Board with no intersection to compute. That makes ADR-0013's sentence — "a Block reads only its declared parameters" — literally true rather than approximately, and it is what makes a Block portable between documents.

Traversal, and two bugs it fixes

walkDocument yields every Step on every Board, so a validator gains Block coverage by construction rather than by remembering to ask. Two things were already wrong and are fixed on the way through:

  • validity.ts carried a private byte-identical copy of walkSteps — the two-traversals problem, already present. Deleted.
  • steps.ts's AST walk hardcoded ['steps'] in three places. It is not a duplicate (it yields paths, not Steps), so instead it takes the Board's root path. That one parameter is what makes an edit inside a Block the same command as an edit at the root.

stepKey({board, id}) mints the one composite spelling anything needing a flat key uses, and Diagnostic gains blockId.

Diagnostics

New schemas/definition-diagnostics.yaml, generated into @hatua/model and the Go SDK. Separate from diagnostics.yaml, which the generator emits into the expressions subtree of both languages — a package deliberately ignorant of manifests, documents and Boards — and whose phase: means design-time-vs-run-time for one Template rather than a rule about a whole document.

The five structural codes that were inline string literals in one language move there too, because blocks is part of the contract: a code that stopped Publish in TypeScript and merely informed in Go would let a workflow publish from one builder and not another. Blocks adds BLOCK_UNKNOWN, BLOCK_RECURSION, RETURN_OUTSIDE_BLOCK, BLOCK_PATH_WITHOUT_RETURN, STEP_AFTER_RETURN and STEP_ID_DUPLICATE.

core.return's rules. Outside a Block it blocks Publish, not editing — moving a Step from a Block to the root is ordinary building. A Block declaring no outputs needs no return. A path returns via a root-level return or a root-level Fork whose every branch returns; a return inside a core.for_each exits the Block early and is legal but never discharges the obligation, because the list may be empty and the body may never run. That is the sibling-branch argument applied to time instead of to paths.

Verification

Both languages, fixtures first. conformance/ caught the divergence it exists to catch: Go accepted a step id the expression grammar cannot parse, and rejected a blocks: section outright.

  • conformance/definition/valid/blocks.yaml — the worked example: one Block called from the root and from inside another, two Blocks each holding a ret, Run Context read across a Block boundary.
  • Three new invalid fixtures, each carrying its # expect: header.
  • conformance/expression/diagnostics/scope.yaml gains a Block's Board — including the scenarios that must fail.
  • round-trip.test.ts: a document with blocks: reproduces byte for byte, flow entries, nested of: and comments included; and adding a blocks: section to a file that has none leaves every other line untouched.

Covered in packages/model/src/blocks.test.ts: a Block called twice, a Block calling a Block, a call to a missing Block, a direct cycle, an indirect cycle naming only the Blocks on it, a cycle through a Fork branch, an unfilled parameter — and a Reference from inside a Block to a Step outside it, which must fail. If that ever passes, scope has stopped being an exact walk and blocks: is the jump ADR-0013 refuses. sdk/go/slots_test.go mirrors it.

An id is now held to an identifier pattern in both languages: a name sits one segment below a reserved root, so a name the grammar cannot parse is a name nothing can ever address.

What review changed

Three passes ran over this branch and all three found real defects. What they
found is worth stating, because two of them were invisible to 1,616 passing
tests.

params.* type-checked and resolved to nothing. scopeFor emitted the
entries and the checker accepted them, but root() had no params case and
neither evaluation context had a bucket for a call's arguments — so every Block
parameter was MISSING at run time, in both languages. Blocks did not work. It
survived because the corpus had params scenarios under diagnostics/ and
none under eval/, which is the difference between checking a path and
resolving one.

A fork discharged the return obligation without being exhaustive. A condition
fork is first-match-wins, so one whose every branch carries a when can match
none and fall through. alwaysReturns credited it anyway: a Block that could
finish without returning went unreported, and a Step legitimately placed after
such a fork was refused publish as unreachable.

Go enforced the identifier rule on a declaration's k and the schema did
not
, so Go rejected documents zod accepted. The schema was wrong — params.<k>
is a path segment. Writing the fixtures for the fields that rule reaches then
found Go never applied it to a trigger id either.

mismatchedConnections was a third private walk over doc.steps, so a
conn field inside a Block went unchecked — including two codes that block
editing. The claim below that walkDocument means no validator can skip a Block
was false when it was first written; it is true now, and tested.

The Go SDK had the diagnostic codes and none of the rules. load.go checked
shape and deferred everything cross-field, so a runner accepted a block that
called itself, a fork with one branch, a required field left empty, and two
blocks under one id — all of which the builder refuses. sdk/go/validity.go is
now the mirror of packages/model/src/validity.ts, rule for rule, and
conformance/definition/rules/ holds the two together: 23 scenarios carrying a
definition and the diagnostics both must report, compared as a sorted set and
rendered in full. Mutation-tested — removing the fork-exhaustiveness condition
from the Go port fails the scenario written for it.

Smaller: a Reference the rename sweep missed in the playground's seed workflow;
three Storybook carets left at their pre-prefix offsets, two of which demoed the
completion list finding the wrong thing; data.set_var naming a verb root this
branch abolished; an ADR paragraph justifying a rule by the mechanism it refuses
seventeen lines earlier; renameBlock missing the collision guard
renameVariable has; repeated block ids, step ids and declaration keys resolving
two ways; BoardOf materialising every Board to read one; and the member maps
that predate this branch getting the null prototype the new ones already had.

One thing worth a reviewer's eye

The rename changes the Reference picker's grouping, and it is a visible behaviour change the closure forces rather than a decision taken freely. referenceTree groups by the first dot, so a Step now groups under steps exactly as a Trigger groups under triggers — the source select offers a handful of sources that no longer grow with the workflow, instead of one group per Step. The asymmetry it removes only existed because step ids used to sit at the root.

The presentational layer that shared the word — packages/react/src/blocks/ — is renamed to units/. It holds only its README today, so the rename costs one directory and two noRestrictedImports globs; leaving it would have been the Flow tab / FlowMap collision the handoff documents at length, in a branch that coins Block as a domain noun.

@github-actions

github-actions Bot commented Aug 22, 2026

Copy link
Copy Markdown

bulwark

  • scan — no findings

  • coverage — go: 89.7% (baseline 89.3%); typescript: 94.6% (baseline 94.6%); go patch: 89.6% (475/530 new lines; baseline 89.3%); typescript patch: 96.5% (688/713 new lines; baseline 94.6%)

📦 Full bulwark output — complete scan and coverage logs.

ADR-0014 records the decision that every Expression path and every Component
verb begins with a reserved root, so a name a user chooses always sits one
segment below one and can collide with nothing. It applies ADR-0010's argument
about function namespaces one level up, and makes ADR-0012's promise that a Step
may be called `run` free rather than a special case in every resolver.

ADR-0013 gains the shapes it deliberately left to the PR that gives them a
reader: `blocks:` as a top-level list, parameters and outputs in ADR-0012's
`{k, label, t, of}`, the Board as the unit scope is computed against, Board-local
Step ids, and `core.return`'s placement and completeness rules. Invoking a Block
turns out not to need a verb at all, so the budget of three additions holds with
`core.return` in place of `core.call`.

CONTEXT.md gains Board, and flags the collision between the domain term Block and
the React presentational layer that shares the word.
Every Expression path now begins with one of six roots and every Component verb
with one of three, so a name a user chooses always sits one segment below a root
and can collide with nothing Hatua reserves.

The expression language itself is untouched: scope reaches @hatua/expressions as
ScopeEntry[] and validate.ts already resolved dotted paths generically by prefix,
which is how `triggers.nightly` has always been one entry rather than two. What
changes is what scopeFor writes into `path`, and that resolve() loses its
fallback — root() becomes a table where an unrecognised name is missing rather
than a step id to go looking for.

The picker's reference tree follows: a Step groups under `steps` exactly as a
Trigger groups under `triggers`, so the source select offers a handful of sources
that no longer grow with the workflow.

`data.map` becomes `core.map`, and every Host verb gains `component.`.
A Board is one drawable Step tree and the root that gives it its parameters: the
root Board, whose root is `triggers:`, and one per Block, whose root is its
declared contract. Scope is computed against a Board and never across two, which
is what makes a call a cross-link with a contract rather than a jump.

`walkDocument` is the traversal that cannot forget a Block, and `validity.ts`'s
private copy of `walkSteps` is gone with it — every rule now covers a Block's
steps by construction rather than by remembering to ask. The AST walk in
services stays separate on purpose: it yields paths, not Steps.

A call's Slots are typed by the Block's declaration directly rather than through
a synthesized Component Manifest. A manifest field carries a rendering kind and
no type, and that vocabulary cannot express "a Template that must produce a
boolean" at all — so synthesizing one would have discarded exactly the half of
the contract a call site exists to check.

Diagnostic codes for the document move into schemas/definition-diagnostics.yaml,
generated into both languages, so what a code blocks stops being five inline
string literals in one of them. The expression language keeps its own file:
`phase:` there means design-time-vs-run-time for one Template, and its output
belongs in a package that knows nothing about documents.
Covers a block called twice, a block calling a block, a call to a block nothing
declares, a direct cycle, an indirect one that names only the blocks on it, a
cycle reached through a fork branch, and a parameter nobody filled in.

The last test is the load-bearing one: a Reference from inside a block to a step
outside it is refused, along with the workflow's vars and triggers, while the
block's own params, vars and steps resolve. If that ever passes, scope has
stopped being an exact walk and `blocks:` is the jump ADR-0013 refuses.

Return completeness gets its own group, and the loop case is the one worth
reading: a return inside a for_each exits the block early and is legal, but never
discharges the obligation, because the list may be empty.

`core.return` joins the conformance catalogue. Its fields are the enclosing
block's outputs, which no manifest can declare — the mirror of core.map, whose
outputs no manifest can declare either.
…dy has

An InsertPoint carries a Board, and every AST path is rooted there rather than
at ['steps']. That one parameter is what makes an edit inside a Block the same
command as an edit at the root — which is what the extract-into-a-block gesture
will compose from, and why a Block built on the canvas and one written by hand
in Text Mode are the same document.

Ids are minted against the Board, not the document: a block's first step is `s1`
even when the root already has one, because the alternative is a name nobody
chose about a tree nobody is looking at.

`topLevelList` generalises to `listIn`, so a Block's own `vars:` is created in
its documented position by the same function that creates the workflow's. Two
functions would be two answers about where a created key lands, and a Block
written by the canvas would then diff differently from one written by hand.

Renaming or removing a Block leaves its call sites alone: they go stale and are
reported, exactly as a renamed variable key's References are.
…the builder

The Go SDK grows Boards, the Block and Declaration types, and a Board-rooted
ScopeFor. Fixtures came first and caught the divergence they exist to catch: Go
accepted a step id the expression grammar cannot parse, and rejected a document
with a `blocks:` section outright.

conformance/definition/valid/blocks.yaml is the worked example — one block called
from the root and from inside another, two blocks each holding a step called
`ret`, and Run Context read across a block boundary. The expression corpus gains
a block's board, including the scenario that must fail: a Reference from inside a
block to a step outside it.

An id is now held to an identifier in both languages. A name sits one segment
below a reserved root, so a name the grammar cannot parse is a name nothing can
ever address — refused rather than accepted and reported as a broken Reference on
every use of it.
The deepest structure a Workflow Definition holds — a mapping of lists of
mappings, with a nested `of:` inside a declaration, flow entries and comments
among them. If anything were going to be reformatted on the way through, it is
this.

ADR-0013 drops the claim that a Block synthesizes a Component Manifest. A
manifest field carries a rendering kind and no type, and that vocabulary cannot
express "a Template that must produce a boolean" at all, so synthesizing one
would have discarded the half of the contract a call site exists to check. The
declaration's `t` is the expected type directly.
Three defects were real and none of them could fail a test as written.

`params.*` type-checked and resolved to nothing. `scopeFor` emitted the entries
and the checker accepted them, but `root()` had no `params` case and neither
evaluation context had a bucket to put a call's arguments in — so every Block
parameter was MISSING at run time, in both languages. The corpus had `params`
scenarios under `diagnostics/` and none under `eval/`, which is the difference
between checking a path and resolving one. Adding the eval scenarios found that
the Go harness rejects an unknown context key, so the gap was one `case` away
from having been caught all along.

`alwaysReturns` credited any fork whose branches all returned. A condition fork
is first-match-wins, so one whose every branch carries a `when` can match none
and fall through: a Block that could finish without returning went unreported,
and a Step legitimately placed after such a fork was refused publish as
unreachable. A fork now discharges the obligation only when its last branch is
unconditional.

A declaration's `k` is read as `{{params.<k>}}` and `{{steps.<call>.<k>}}` — a
path segment like every other user-chosen name — but the schema left it off the
identifier rule while Go enforced it, so Go rejected documents zod accepted. The
schema is what was wrong. Fixtures for the fields that rule reaches then found
Go never applied it to a trigger id either.

Two blocks under one id now report rather than resolve two ways: `blockOf` took
the first and `cyclicBlocks` took the last, so recursion could be analysed
against one block's steps and reported against another's.

`data.set_var` named a verb root the same branch abolished; it is `core.set_var`.
ADR-0013 justified a rule by the synthesized manifest it refuses seventeen lines
earlier. `docs/handoff.md` and the layouts README still named `workflowScope`.
Three Storybook carets kept their pre-prefix offsets, so two stories demonstrated
the completion list finding the `steps` root rather than the member under the
caret.

In the Go SDK, `BoardOf` materialised every Board to read one, and
`stepOutputType` linear-scanned blocks per upstream step while the manifest
lookup beside it was indexed once; Run Context now dedupes as the TypeScript
half does; `of:` nesting is bounded here rather than by whichever input the YAML
decoder happens to refuse first; and `load.go` says which rules live in the model
layer instead of implying it has them.
A Go runner could not execute a Block call at all. `SlotsFor` iterates a
manifest's fields, and neither `block.<id>` nor `core.return` has a manifest, so
both yielded nothing — the previous pass gave the evaluator a `params` bucket and
never gave Go the code that fills it. `CallSlots`, `ReturnSlots` and one
`SlotsForStep` entry point mirror the TypeScript, so a runner never has to know
which two verbs a manifest cannot describe.

`mismatchedConnections` was a third private walk over `doc.steps`, so a `conn`
field inside a Block was unchecked — including two codes that block editing. It
walks every Board and files the Board it found the Step on. The claim that
`walkDocument` means no validator can skip a Block is now true rather than
merely intended.

`BoardScope` looked a block up before deciding whether the Board was the root.
`BoardID` is a bare string with `""` as the root, so a block with an empty id
answered for the root Board — handing it that block's parameters and losing the
workflow's triggers and vars. The schema forbids an empty id; this must not
depend on validation having run.

`maxDeclarationDepth` is dropped. It refused documents the schema accepts, which
is the divergence the function it sat in exists to prevent — a bound belongs in
the shared contract or nowhere.

A repeated declaration key resolved two ways and is now reported, the way a
repeated step id and a repeated block id are. `renameBlock` refuses a collision
the way `renameVariable` does: every reader takes the first block, so the edit
commands would target the wrong one long before Publish was stopped. And
`alwaysReturns` reads a falsy `when` as the fallback, agreeing with
`malformedContainers` about the `when: ""` the schema permits.

The member maps that predate this branch get the null prototype the new ones
already had, `FormatDefinitionMessage` exists in both languages rather than only
in the generated documentation that promised it, and `pnpm codegen` no longer
half-writes the tree on a machine with no Go toolchain.
…uages to them

Go had the diagnostic codes and none of the rules. `load.go` checked shape and
deferred everything cross-field to the builder's model layer, so a runner linking
this SDK accepted a block that called itself, a fork with one branch, a required
field left empty, and two blocks under one id — while TypeScript refused all four.
A code that stops Publish in one builder and passes in another is the divergence
this SDK exists to prevent, and deferring the rules was that divergence written
down rather than fixed.

`validity.go` is the mirror of `packages/model/src/validity.ts`, rule for rule:
unknown components and unknown blocks, required fields against a manifest and
against a block's declarations, the structural verbs, recursion, return placement
and completeness, and the three repeated-name rules. `CyclicBlocks` and `CallsOf`
come with it, first-wins so every reader resolves a repeated block id the same
way, and reporting in declaration order so both languages describe one document
identically.

`conformance/definition/rules/` is what keeps it true. `definition/invalid/` holds
documents the schema refuses; these parse and are still wrong, which is the class
no JSON Schema can express and the class each language therefore implements
alone. Twenty-three scenarios carry a definition and the diagnostics both must
report, compared as a sorted set and rendered in full, so a scenario cannot
quietly not check a subject. Mutation-tested: removing the fork-exhaustiveness
condition from the Go port fails the scenario written for it.

`FieldVisible` renders a `when` comparand the way `String(…)` does on the other
side, because a field hidden in one builder and required in the other is the same
divergence one level down.
A Block is a domain term — a named, reusable sequence of Steps invoked as
`use: block.<id>` — and the layer of presentational units it shared a word with
draws none of them. One word for two things in one repo is the Flow tab /
FlowMap collision this repo has already paid for once, and CONTEXT.md flags it
by name.

The layer holds only its README today, so the rename costs one directory, two
`noRestrictedImports` globs and the entry that recorded the tension, which now
records the resolution.
A static scan flagged `readAt` walking a user-editable document by dynamic key.
Its callers all build paths from literals and list indices, so nothing reaches
`__proto__` through it today — but a reader over a hand-editable file has to be
safe on its own terms rather than by every caller's discipline.

Chasing it found a real one. The identifier rule this branch added permits
underscores, so `__proto__` is a legal declaration key, field key and var key —
and `values[declaration.k]` on a plain object then reads `Object.prototype`
rather than nothing, so `unfilled` calls a missing parameter filled and no
FIELD_REQUIRED is raised. Go has no prototype to find and reported it correctly,
which made this a divergence between the two languages on a document the schema
accepts.

`own()` is the guarantee, and it is the one `resolve.ts` already gives for
`{{ steps.s2.constructor }}`. Every place a document-supplied key indexes a
document-supplied map now goes through it: required fields, field visibility,
call and return Slots, mappable field values, and connection references.

The scenario is in the rules corpus, where it failed in TypeScript and passed in
Go before the fix.
`readAt` walked the projection with its own `Object.hasOwn` check followed by
`value = value[key]`. The guarantee was right, but the shape is the one a static
scan reads as prototype pollution: a self-assignment indexed by a dynamic key
inside a loop, which the preceding guard does not change.

`own` is that guarantee expressed once, and every other place a document-supplied
key indexes a document-supplied map already goes through it.
@pedromvgomes
pedromvgomes force-pushed the feat/blocks-and-namespace-closure branch from b9a6be4 to 7c2688c 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