decision: a `type:` beside `value:` and on `vars:` — an author-declared, run-enforced narrowing at the joint where inference stops at `dyn` (`json_parse`, an `http` body, a plugin output), with the same contract `outputs:` already has: checked at validate where knowable, enforced at run on both drivers, carried forward by the checker
Nobody has claimed this yet.
- Dominant language
- Go
- Stars
- 9
- Forks
- 0
- Avg merge
- 3h 3m
- Merged PRs (30d)
- 509
Description
Written against 7530b29. A decision record beside the #1634 program, which makes the checker infer every type the file already fixes; this is the complementary half, where the file cannot fix one and the author can.
Problem
Inference has a floor and the floor is dyn. The moment a value comes from outside the file — json_parse(...), an http task's body, a plugin task with no descriptor, a wait payload — everything computed from it is dyn, and #1634's own rule is that the checker stays silent there ("where the honest answer is dyn … the checker stays silent"). That is correct for a checker that may not guess. It leaves the author with no way to say: the language has a type: on inputs: and on outputs: (workflow.proto:815, :1198) and nowhere in between. A vars: entry and a value: step, the two positions where an author names a computed value, cannot declare what it is.
Reproduced at 7530b29:
edition: v2026.3
name: typed-probe
inputs:
raw: { type: string, default: '{"items": [{"id": 1, "name": "a"}, {"id": 2, "name": "b"}]}' }
steps:
- id: parsed
value: '${json_parse(inputs.raw)}'
- id: names
value: '${steps.parsed.value.items.map(i, i.name)}'
- id: also
value: '${steps.parsed.value.items[0].nam}'
outputs:
names: { type: list, value: '${steps.names.value}' }
flow validate: ok. flow run local: step "also": evaluating value: evaluate expression: no such key: nam. The checker cannot know nam is a typo, and after #1634 lands in full it still cannot: parsed is dyn by the honest rule, so names is list(dyn) and every read under parsed is unchecked forever. The author knows the shape. There is no position to write it.
The declaration surface already has the contract this needs, decided in docs/DSL.md:617-650: a declared type is not decoration because flow validate refuses what contradicts it and EvalRunOutputs enforces it on the computed value on both drivers. outputs: got that in the "since written" note; vars: and value: did not.
What the decision is
A type: beside value: (on a step and on a workflow output already; on a vars: entry as the same key) means three things at once, and all three or none (invariant 8):
- A checker fact.
steps.<id>.valueandvars.<name>are declared with the written type in every later expression's environment — the same carrying #1636 does for an inferred type, with the declared type winning where inference saysdynand a validate refusal where the two are both known and disagree (type: intonvalue: ${"abc"}). - A run-time assertion. The computed value is checked against the type where the step completes, on both drivers, through the one function
EvalRunOutputsalready uses for outputs (checkDeclaredLiteralType/ theTypewalk #1641 lands), failing the step with atype-mismatch-class error that names the declared and computed types. Adynthat turns out to be a string when the author saidlist(string)fails at the step, not three steps later asno such overload. - A documented contract. Hover on
steps.parsed.valueshows the declared type;flow tasks-class listings and the MCP catalog show it for a callee'svalue:steps the way they showoutputs:.
What it is not: a cast. type: int on a string does not convert; it refuses. Conversion stays in the expression (int(x)), so a declared type never changes a value.
Where it reaches
value:steps (Valuenode kind,workflow.proto:1264):type:as a sibling key, stored as aflowstate.v1.Typeon the node (proto-first; one vocabulary, #1452/#1578).- Workflow-level and step-level
vars::vars: { region: { value: ${...}, type: string } }beside the existing bare form;fmtwrites the bare form when no type is declared. outputs:shaping on a task (http'soutputs:map): the same key per shaped output, which is how anhttpbody's fields get a type without a descriptor — thedyncase #1638 names and cannot infer past.- Not on
inputs:(already typed), not on loop bindings (as:takes the element type ofitems:, #1642; declaring one on the binding would be a second spelling).
Acceptance criteria
- The file above with
type: list(string)onnamesand atype:naming the item shape onparsed(amap(string, dyn)today; a named type once #637 lands) refusesalsoat validate with a position, and a run whoserawcarries{"items": "oops"}fails atparsednaminglistexpected andstringcomputed. type: intonvalue: ${"abc"}is a validate refusal;type: intonvalue: ${steps.fetch.json.n}validates and is enforced at run on both drivers (conformance case).- A
vars:entry withtype:is checked and enforced the same way at the position vars are evaluated (workflow-level through the resolution activity, step-level in workflow code), on both drivers. - Hover, completion (#1636's carried type) and the MCP catalog show the declared type;
flow fmtround-trips the key;flow fixleaves files without it untouched. - The refusal corpus (#1646) gains one file per position.
Constraints
- One vocabulary: the key's value is #1640's spelling of
flowstate.v1.Type; no new grammar. - Invariant 3: enforcement is the function both drivers already call for outputs, so the refusal is byte-identical.
- Invariant 5: the run-time walk is bounded by the value's own element bound; a declared
list(map(string, string))over 10,000 elements costs one pass. - Additive: every existing file means what it meant; a missing
type:isdynexactly as today.
Open questions
- Does a declared
type:on avalue:step replace the inferred type or intersect with it? Recommend: a known inferred type that disagrees is a validate refusal; otherwise the declaration wins, so an author is never allowed to write something the checker can see is false. - Should
must:be allowed besidetype:on avalue:step (the same pairinputs:has)? It follows from the same contract and is #1503's uncached path; recommend yes, in the same change, since a type without a predicate is the half the author reaches for next.
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Read workflow.proto around lines 815, 1198, and 1264, then docs/DSL.md:617-650 and the existing EvalRunOutputs/checkDeclaredLiteralType path. Trace validation, type carrying, vars/value evaluation, and both drivers before resolving the open questions. Done means the acceptance criteria pass, including refusal cases, runtime enforcement, hover/completion, MCP catalog, formatting, and the refusal corpus.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- go
- Domain
- backend, devtools
- Issue type
- Feature
- Difficulty
- 5/5
- Estimated time
- Over a week
- Activity status
- Active
- Clarity
- Mostly clear
- Newbie friendliness
- 35/100