ADR-0013: Declared Step Outputs — A Step's Output Is Its Comment
Status: Accepted — 2026-10-11
Decider: olivier-norture
Related: #756 (this decision), #747 (Dify comparison), #813 / ADR-0009 (typed workflow inputs), #754 (deterministic tool steps), #755 (fan-in), ADR-0003 (context management)
Context
Section titled “Context”The primary state channel between steps today is the issue comment thread:
each step posts a comment and the next agent reads the history (or a pinned
subset). There is no first-class “this step declares output X, later steps
reference X” contract. Dify’s App DSL has exactly that — {{#node_id.field#}}
selectors — and builds every prompt from declared node outputs.
Per the #747 scope decision, richer state storage belongs to the future SaaS version; the deliverable here is the protocol first, compatible with comment-state. The design must therefore answer four questions without introducing a new store: what an output is, how a downstream step references it, where the value lives, and how large it may grow.
A first draft (v5 of the plan) proposed a per-step outputs: declaration
carrying a list of named outputs ({{ plan.plan_doc }}). Review rejected it:
every name would have resolved to the same full comment, so the naming layer
bought zero semantic value while costing grammar, config surface, lint rules,
and documentation. The simplification is the decision below.
Decision
Section titled “Decision”A step’s output is the comment it posts. There is no declaration and no output object: naming a step in data-flow position is the entire protocol.
Grammar
Section titled “Grammar”- No
steps[].outputskey. The pre-#756 declaration and its<step>.<out>reference form are removed; a leftover key or dotted ref fails at load time with a migration hint (see Migration). Amended by #973: a step may now declare the shape of its own comment withsteps[].output(see workflows.md), and the dotted form is re-introduced only for a field that declaration names —<step>.<field>addresses one declared section. A dotted ref to an undeclared field is still a lint error. - A data-flow reference is a bare step name. Two surfaces consume it:
- the step
inputs:seam —inputs: [plan](list shorthand) or the mapping forminputs: {context: plan}for an alias; and - the step
prompt:text — see Prompt reference syntax.
- the step
- The workflow-level
inputs/outputssignature from ADR-0009 is unchanged; only the per-step named-output half of that ADR is superseded here. inputis a reserved namespace prefix:input.<name>always addresses a workflow parameter, so a step namedinputis flagged by the linter.
Prompt reference syntax
Section titled “Prompt reference syntax”Inside steps[].prompt, a {{ <step> }} placeholder is replaced, by the engine
and before the agent runs, with the referenced step’s full
state-stripped comment:
{{ <step> }} # e.g. {{ plan }}- Grammar:
{{\s*([A-Za-z_][A-Za-z0-9_-]*)\s*}}(PROMPT_REF_PATTERN). - Resolution is single-pass and non-recursive: a substituted body is never
re-scanned, so a step that itself quotes
{{ … }}cannot induce recursion or injection amplification. - An unresolved reference degrades to
_(unavailable: <step>)_, matching the existinginput_wiringdegradation wording — the engine never fails a step over a missing prompt reference. {{ … }}is thus reserved insidesteps[].prompt; there is no escape syntax in v1 (lint rule L11 is the guard).
Storage — locators in WORKFLOW_STATE v1
Section titled “Storage — locators in WORKFLOW_STATE v1”The value lives in the existing WORKFLOW_STATE v1 JSON, under
current_context["step_outputs"], as a list of locators:
{"current_context": {"step_outputs": [{"step": "implement", "comment_id": "1234"}]}}StepOutputRecordis{step, comment_id}; uniqueness is onstep; the latest write wins, so a loop re-run (e.g.planafterrejected) re-points the locator at the newest comment.comment_idis unknown when the state is built (the comment is posted afterwards), so it is backfilled: at publish time fromadapter.post()’s return, and on the read path by index alignment (each state comment’s embeddedstep→ that comment’s id).- Comment bodies are deliberately not stored. The state block is
re-embedded untruncated into every posted comment, and there is no
current_contextsize guard, so persisting bodies would compound against GitHub’s 65,536-char comment limit on every turn. Locators plus the existing bounded thread read (thread_snapshot) resolve bodies on demand. - No new store, no DB. The SaaS runner’s richer store remains out of scope.
Size and truncation policy
Section titled “Size and truncation policy”None. Dify truncates variables at 2,000 characters and caps files at 50 MB; both are explicitly rejected here. A step’s output is the whole state-stripped comment, and the only bounds are the pre-existing ones:
MAX_COMMENT_LENGTH = 60000on an answer body, and- GitHub’s 65,536-character hard limit on a comment.
Storing locators rather than bodies is what makes this safe: resolution returns
text that already fits in a comment, because it is a comment. The known
pre-existing gap — no current_context size guard — is a follow-up, not a
goal of this ADR.
Relationships
Section titled “Relationships”dispatch.pinstays a display affordance for the thread snapshot. It is orthogonal to output resolution: pinned-comment injection into the prompt is not wired at runtime today (pre-existing; out of scope here).build_state.artifactsremain file-path hand-offs (the 50 MB-file question does not arise); step outputs are comment hand-offs. The two are complementary, not competing.- Tool steps (#754): a tool step’s comment is its output — no special
case. Fan-in #755 is
inputs: [plan, implement]: one step consuming N upstream step names, each resolved to its comment. - ADR-0009 otherwise stands; this ADR amends only its named-output clause.
Migration
Section titled “Migration”steps[].outputs:→ remove the key; reference the step name instead. The loader fails with: “‘outputs’ was removed from a step by issue #756; a step’s output is its posted comment, referenced by the step name…”.<step>.<out>→{{ <step> }}(prompt) orinputs: [<step>]. A dotted non-input.*ref fails with a#756migration hint. Amended by #973: a dotted ref naming a field the step declares in itsoutput:block is valid again and resolves to that section.input.<name>workflow-input references from ADR-0009 are unchanged.- A step with no
inputs/promptbehaves exactly as today: no prompt rewriting is required for existing workflows.
Object design
Section titled “Object design”StepOutputRecord(workflow_state/step_output_record.py) — pydantic domain entity, the locator:step: str,comment_id: str | None. Methodsupsert_into,load_all,index.ResolvedOutput(workflow_state/features/resolve_step_output/) —@dataclass(frozen=True)immutable feature result:step,ok,body,detail.resolve_step_output(records, comment_index, step)— pure join of the locator with the comment index; degradation reasons:not recorded,no comment recorded,comment not found.render_prompt_refs(prompt, resolved)— pure, single-pass substitution.build_comment_index(comments)(thread_snapshot.py) — mapscomment_id → state-stripped body, delegating toto_git_comment.strip_state_sectionso a value is byte-identical to what_agent_facing_bodyshows agents.- One resolver, two surfaces:
input_wiring.materialize(payload, step_scope=…)resolves bare<step>refs to comments (list shorthand supported onWorkflowStep); the run loop appliesrender_prompt_refsto the step prompt. Both shareStepOutputRecord.index+build_comment_index.
Alternatives considered
Section titled “Alternatives considered”- Named outputs (
outputs: [plan_doc],{{ plan.plan_doc }}) — the v5 design. Rejected: every name resolved to the same full comment, so the layer added cost with no semantics. It can return in the SaaS runner if typed fields ever matter. - Prompt-only interpolation, no
inputsseam — rejected as the sole surface: it would discard the shipped typed/lintedrender_inputsseam (#813/#842) and duplicate bodies with escaping hazards. - Bodies in state — rejected: the state is re-embedded into every comment, so bodies-in-state compound against the comment size limit.
- Step-name references, one resolver, two surfaces — chosen.
Consequences
Section titled “Consequences”- Data flow between steps is declared, referenceable, and lint-checked
(
L10-input-refresolves both reference shapes; newL11-prompt-refvalidates{{ <step> }}). - Backward compatible for every workflow that declares no outputs; migration is explicit (load-time errors), never silent.
- Resolution cost is O(1) per reference (a dict lookup) plus one bounded thread read already performed for the prompt.
- If a future engine wants typed fields, the wire format gains an optional
fieldalongsidestep— an additive change to the locator.
Worked example
Section titled “Worked example”defaults/workflows/spec-driven-impl.yaml (excerpt):
- name: plan # architect agent: architect next: stub # ...
- name: implement # dev agent: dev inputs: [plan] # ← the plan comment, materialized next: review
- name: review agent: review inputs: [plan, implement] # ← fan-in: plan + dev output prompt: | Review the implementation against the plan. Plan under review: {{ plan }} Both step outputs are also materialized above as deterministic inputs. conditions: - on_result: approved next: null - on_result: implement next: planAt run time review’s prompt carries ## Step inputs (deterministic) with
#### \plan`and#### `implement`(both full, state-stripped comments), plus{{ plan }}substituted inline — all resolved by the engine beforereview` runs. This doubles as the #755 fan-in demonstration.