feat: Blocks, Boards, and closed namespace roots - #34
Open
pedromvgomes wants to merge 14 commits into
Open
Conversation
|
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
force-pushed
the
feat/blocks-and-namespace-closure
branch
from
August 24, 2026 15:25
b9a6be4 to
7c2688c
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.
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.
run.triggers.params.steps.var.+ the built-inTRIGGERcore.(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
runhad to become a root because "a step may legitimately be calledrun". 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.sendis longer thanemail.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/expressionsasScopeEntry[], andvalidate.tsalready resolved dotted paths generically by prefix — that is howtriggers.nightlyhas always been one entry rather than two. What changes is whatscopeForwrites intopath, and thatresolve()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
Invoking a Block turns out not to need a verb.
core.callwas the first draft; once the verb namespace closed, a call isuse: block.<id>resolved againstblocks: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 calledblock.Parameters and outputs are
{k, label, t, of}, ADR-0012's shape, sooutputsToTypetypes{{ params.entry.headline }}and{{ steps.audit_1.url }}with no new code andparams.is an exact mirror oftriggers..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
kindand no type, andFIELD_KIND_TYPEScannot express "a Template that must produce a boolean" at all, becauseboolholds 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.run.*triggers.*,TRIGGERparams.*var.*steps.*scopeForroots its walk at the Board an id sits on and never leaves it. Step ids are Board-local — two Blocks may each hold aret, 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
walkDocumentyields 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.tscarried a private byte-identical copy ofwalkSteps— 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, andDiagnosticgainsblockId.Diagnostics
New
schemas/definition-diagnostics.yaml, generated into@hatua/modeland the Go SDK. Separate fromdiagnostics.yaml, which the generator emits into the expressions subtree of both languages — a package deliberately ignorant of manifests, documents and Boards — and whosephase: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
blocksis 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 addsBLOCK_UNKNOWN,BLOCK_RECURSION,RETURN_OUTSIDE_BLOCK,BLOCK_PATH_WITHOUT_RETURN,STEP_AFTER_RETURNandSTEP_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 acore.for_eachexits 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 ablocks:section outright.conformance/definition/valid/blocks.yaml— the worked example: one Block called from the root and from inside another, two Blocks each holding aret, Run Context read across a Block boundary.# expect:header.conformance/expression/diagnostics/scope.yamlgains a Block's Board — including the scenarios that must fail.round-trip.test.ts: a document withblocks:reproduces byte for byte, flow entries, nestedof:and comments included; and adding ablocks: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 andblocks:is the jump ADR-0013 refuses.sdk/go/slots_test.gomirrors 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.scopeForemitted theentries and the checker accepted them, but
root()had noparamscase andneither 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
paramsscenarios underdiagnostics/andnone under
eval/, which is the difference between checking a path andresolving one.
A fork discharged the return obligation without being exhaustive. A condition
fork is first-match-wins, so one whose every branch carries a
whencan matchnone and fall through.
alwaysReturnscredited it anyway: a Block that couldfinish 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
kand the schema didnot, 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.
mismatchedConnectionswas a third private walk overdoc.steps, so aconnfield inside a Block went unchecked — including two codes that blockediting. The claim below that
walkDocumentmeans no validator can skip a Blockwas 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.gocheckedshape 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.goisnow the mirror of
packages/model/src/validity.ts, rule for rule, andconformance/definition/rules/holds the two together: 23 scenarios carrying adefinition 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_varnaming a verb root thisbranch abolished; an ADR paragraph justifying a rule by the mechanism it refuses
seventeen lines earlier;
renameBlockmissing the collision guardrenameVariablehas; repeated block ids, step ids and declaration keys resolvingtwo ways;
BoardOfmaterialising every Board to read one; and the member mapsthat 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.
referenceTreegroups by the first dot, so a Step now groups understepsexactly as a Trigger groups undertriggers— 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 tounits/. It holds only its README today, so the rename costs one directory and twonoRestrictedImportsglobs; leaving it would have been the Flow tab /FlowMapcollision the handoff documents at length, in a branch that coins Block as a domain noun.