picatz / picatz/flowstate

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

Open
#1,887 0 comments 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

design dsl engine
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):

  1. A checker fact. steps.<id>.value and vars.<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 says dyn and a validate refusal where the two are both known and disagree (type: int on value: ${"abc"}).
  2. A run-time assertion. The computed value is checked against the type where the step completes, on both drivers, through the one function EvalRunOutputs already uses for outputs (checkDeclaredLiteralType / the Type walk #1641 lands), failing the step with a type-mismatch-class error that names the declared and computed types. A dyn that turns out to be a string when the author said list(string) fails at the step, not three steps later as no such overload.
  3. A documented contract. Hover on steps.parsed.value shows the declared type; flow tasks-class listings and the MCP catalog show it for a callee's value: steps the way they show outputs:.

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 (Value node kind, workflow.proto:1264): type: as a sibling key, stored as a flowstate.v1.Type on the node (proto-first; one vocabulary, #1452/#1578).
  • Workflow-level and step-level vars:: vars: { region: { value: ${...}, type: string } } beside the existing bare form; fmt writes the bare form when no type is declared.
  • outputs: shaping on a task (http's outputs: map): the same key per shaped output, which is how an http body's fields get a type without a descriptor — the dyn case #1638 names and cannot infer past.
  • Not on inputs: (already typed), not on loop bindings (as: takes the element type of items:, #1642; declaring one on the binding would be a second spelling).

Acceptance criteria

  • The file above with type: list(string) on names and a type: naming the item shape on parsed (a map(string, dyn) today; a named type once #637 lands) refuses also at validate with a position, and a run whose raw carries {"items": "oops"} fails at parsed naming list expected and string computed.
  • type: int on value: ${"abc"} is a validate refusal; type: int on value: ${steps.fetch.json.n} validates and is enforced at run on both drivers (conformance case).
  • A vars: entry with type: 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 fmt round-trips the key; flow fix leaves 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: is dyn exactly as today.

Open questions

  1. Does a declared type: on a value: 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.
  2. Should must: be allowed beside type: on a value: step (the same pair inputs: 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

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. 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

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.