Iterion DSL — Validation Diagnostics
All diagnostic codes emitted during compilation (ir.Compile) and validation (ir.Validate), plus the bundle-consistency codes (C2xx) that iterion validate reports for a packaged bot. Diagnostics are either errors (block execution) or warnings (informational).
The compiler carries its own copy of each row's Fix (pkg/dsl/ir/diag_catalog.go): iterion validate prints it as the fix: line under every finding, the studio shows it in the diagnostic badge, and the MCP local_validate result carries it as hint. A code the compiler can emit that has no catalogue entry fails TestDiagCatalogCoversEveryCode, so a finding never arrives without a next step. Parse-stage codes (E0xx, pkg/dsl/parser/diagnostic.go) carry a fix line the same way.
A finding is positioned on the line the author has to edit. One about a property's VALUE — compress: bogus, a compaction.threshold out of range, an unreadable allow: rule, a sandbox: mode — points at that property's line in the declaration, not at its workflow w: / agent a: header; the parser records the span of every property line it reads (ast.PropertySpans), and iterion validate --json names the property (property) and, on a .bot source, closes the span (end_line, end_column, the column after its last rune; a YAML twin carries the start alone). A finding about the declaration itself keeps its header line; one inside a tool body (C157, C158, C188) points at the body's property line and carries the body-relative line in its message, the AST keeping no position inside a body (#1094).
Compilation Diagnostics
| Code | Severity | Description | Cause | Fix |
|---|---|---|---|---|
| C001 | error | Unknown node reference | An edge references a node that is not declared | Declare the node or fix the name typo |
| C002 | error | Unknown schema reference | A node's input: or output: references an undeclared schema | Declare the schema or fix the name |
| C003 | error | Unknown prompt reference | A node's system: or user: references an undeclared prompt | Declare the prompt or fix the name |
| C004 | error | Bad template reference | A {{...}} template expression is malformed. The code also carries the refusals that have no code of their own: an unsupported enum value on language: or env_expansion: (tool recipes), on a subbot's merge_strategy:, or on a subbot's merge_into:, and a property written where the body it governs is absent (language: without script:, env_expansion: on an action: node) | Use {{vars.X}}, {{input.X}}, {{outputs.node.field}}, {{artifacts.X}}, {{attachments.X}}, {{loop.name.iteration}}, or {{run.id}}. For an enum value, read the accepted words the message spells — language: takes js/node/py/python/python3/sh/bash, env_expansion: takes braced/none, a subbot's merge_strategy: takes squash/merge, a subbot's merge_into: takes none/current/a branch name — and for an inert property, either give the node the body it governs or remove the line |
| C005 | error | Duplicate loop definition | Multiple edges share a loop name but disagree on max_iterations | Use the same max_iterations value or use different loop names |
| C006 | error | No workflow found | The file has no workflow declaration | Add a workflow name: block |
| C007 | error | Multiple workflows | More than one workflow block found | V1 supports one workflow per file — remove extras |
| C008 | error | Missing entry node | The entry: node name doesn't match any declared node | Fix the entry name or declare the node |
| C018 | error/warning | Missing model/backend or LLM interaction requirements | Without a route the chain decides — a model, a decided node backend:, or a workflow default_backend: naming a backend (auto and empty fields fall through; a field the source writes but cannot answer, a ${X} with no :- or a {{vars.x}}, is declared and not judged) — agents/judges warn when target capabilities are unknown and error when an explicit target snapshot reports no auto-resolvable backend. An available snapshot suppresses C018. LLM routers retain the warning for unknown/unavailable targets. Compilation never probes host credentials. Human nodes using interaction: llm or interaction: llm_or_human must set model: or interaction_model: and must declare output:. | Add model: "...", backend: "...", or a workflow default_backend: naming a backend; provision credentials on the execution target; set explicit model/backend for LLM routers when you do not want runtime defaulting; for LLM-backed human nodes add the interaction model and output schema. |
| C024 | error | Duplicate MCP server | A mcp_server name is declared more than once | Use unique names for each MCP server |
| C025 | error | Invalid MCP server config | MCP server misconfigured (e.g., stdio without command, http/sse without url) | Match properties to transport type: stdio needs command; http and sse need url and must not set command or args |
| C039 | error | Compute node has no expressions | A compute node was declared without any expr: key: "<expression>" entries | Add at least one expression mapping an output schema field to an expression — or remove the node |
| C040 | error | Expression failed to parse | An expression in a compute node or in a quoted when "..." clause isn't valid | Check operators, parentheses, namespace prefixes (vars / input / outputs / artifacts / loop / run), and built-in calls (length, concat, unique, contains, join, if) |
| C041 | error | Duplicate node id | Two declarations share the same node name across agents/judges/routers/humans/tools/computes | Rename one — node ids are a single global namespace |
| C042 | error | Reserved node name | A user node is named done or fail (those are reserved terminal targets) | Pick a different node name |
| C044 | error | Invalid sandbox mode | A node or workflow's sandbox: mode is outside the accepted set ("", none, auto, inline); or inline mode is missing an image/build or sets both | Set sandbox: to auto, none, inline, or omit it. Block-form sandbox config with image:, build:, env:, mounts:, or network: compiles as inline mode unless mode: is specified; inline requires exactly one of image: or build:. |
| C045 | error | Sandbox auto without config | Reserved diagnostic code; not currently emitted by compile/validation. Normal CLI/runtime auto mode supplies a default iterion-sandbox-slim:<version> fallback when no .devcontainer/devcontainer.json is present | No compile-time action. If an embedder disables the default image and runtime reports a missing devcontainer, add .devcontainer/devcontainer.json, provide a default image, or use inline sandbox: with image:/build: (see docs/sandbox.md). |
| C046 | error | Invalid budget cost | budget.max_cost_usd is negative, NaN, or infinity | Use a non-negative finite USD amount, or omit the field to disable the cost cap. |
Validation Diagnostics
| Code | Severity | Description | Cause | Fix |
|---|---|---|---|---|
| C009 | error | Session at convergence point | A node with await: (or multiple incoming sources) uses session: inherit or session: fork | Change to session: fresh, session: artifacts_only, or session: persist |
| C010 | error | Multiple unconditional edges | A non-router node has more than one unconditional outgoing edge. A loop back-edge (as name(N)) does not count: src -> body as name(N) next to a bare src -> exit is the loop-exhaustion exit — the bare edge fires once the loop has spent its iterations — and is the one allowed shape with two unconditional edges from a node | Keep only one default edge (plus a loop back-edge and its exhaustion exit), or use a router for fan-out |
| C011 | error | Ambiguous conditions | Same condition field appears twice with same polarity from the same source. One pair is permitted: an iteration back-edge (as name(N), as foreach …) declared first, then the exit it guards — the order the evaluator honours, the back-edge winning while the iteration has work, and only while the exit leaves the iteration instead of leading back into its body | Remove the duplicate edge, use different conditions, or — for a back-edge and the exit that shares its condition — declare the back-edge first and route the exit out of the iteration |
| C012 | error | Missing fallback | A node has conditional edges but no unconditional fallback and conditions aren't exhaustive | Add when not X to complement when X, or add an unconditional edge |
| C013 | error | Condition field not boolean | A when clause references a field that isn't bool in the source output schema | Change the schema field to bool |
| C014 | error | Condition field not found | A when clause references a field that doesn't exist in the source output schema | Add the field to the schema or fix the field name |
| C015 | error | Else without conditional sibling | An else edge has no when sibling from the same source — a fallback with nothing to fall back from is just an unconditional edge wearing a misleading keyword | Add the conditional sibling(s), or use a plain src -> dst edge |
| C016 | error | Unreachable node | A declared node cannot be reached from the workflow's entry: node | Add edges to reach the node, or remove the unused declaration |
| C017 | error | History ref not in loop | {{outputs.node.history}} is used but the referenced node is not part of any declared loop | Add a loop declaration (as loop_name(N)) to the edge cycle, or remove the .history reference |
| C019 | error | Undeclared cycle | A cycle (back-edge) exists without any loop declaration on its edges | Add as loop_name(N) to the back-edge to bound the cycle |
| C020 | error | Round-robin too few edges | A round_robin router has fewer than 2 unconditional outgoing edges | Add at least 2 outgoing edges for alternation |
| C021 | error | LLM router too few edges | An llm router has fewer than 2 outgoing edges | Add at least 2 outgoing edges for the LLM to choose from |
| C022 | error | LLM router edge has condition | An edge from an llm router has a when clause | Remove the when clause — LLM routers select targets directly |
| C023 | error | LLM-only property on non-LLM router | Properties model, backend, system, user, multi, or reasoning_effort are set on a router that isn't mode: llm | Remove these properties or change the mode to llm |
| C026 | error | Invalid loop iterations | A loop's max_iterations is less than 1 | Set max_iterations to at least 1 |
| C027 | error | Invalid reasoning effort | reasoning_effort has a value other than none, low, medium, high, xhigh, max, ultracode | Use one of the seven valid values |
| C028 | error | Duplicate with-mapping key | The same with key appears on multiple non-conditional edges to the same target | Use unique keys, or make edges conditional/convergent |
| C029 | error | Unknown outputs node reference | A {{outputs.<node>...}} template targets a node not declared anywhere in the file | Declare the node or fix the typo |
| C030 | error | Imports not resolved | The file still carries import "lib/x.bot" lines: it was compiled alone, not as a unit, so its fragments were never merged in — and compiling it anyway would only report what the main happens to reference | Validate or run the file through a path that loads the unit (iterion validate, run, the studio); an inline source cannot import — launch the bot as a bundle |
| C031 | error | outputs ref field not in output schema | {{outputs.<node>.<field>}} references a field absent from that node's output: schema | Reference an existing field, or add the field to the schema |
| C032 | warning | outputs ref on schemaless node | {{outputs.<node>.<field>}} targets a node that has no output: schema, so the field cannot be verified. The same warning fires for {{input.<field>}} in an edge with mapping whose source has no output: schema — those mappings resolve from the source output, not from run inputs. An entry router copies the run payload (skipped). A mid-graph router only passes through incoming with keys (plus llm/fan_out_each bindings); a field in none of those warns. | Add an output: schema, map the field onto the source, drop the access, or use {{vars.x}} for a launch-time value |
| C033 | error | Undeclared variable | {{vars.X}} (or vars.X inside an expression) targets a variable not declared in the file-level or workflow-level vars: block | Declare the variable, or fix the name |
| C034 | error | input ref field not in the validated namespace | In a prompt, command, or compute expr, {{input.<field>}} is checked against the consuming node's input: schema. In an edge with mapping, it is checked against the source node's output: schema (the payload available when the edge fires). Run-level inputs and vars: are a different namespace. | Reference a field in that namespace, or switch to {{outputs.<node>.x}} / {{vars.x}} |
| C035 | error | Unknown artifact | {{artifacts.X}} targets an artifact never produced via publish: | Add publish: <name> on a prior node, or fix the artifact name |
| C036 | error | Reference to non-reachable node | {{outputs.<node>...}} targets a node not reachable from the entry before the consumer | Reorder the graph or wire an edge so the producer runs first |
| C037 | warning | Node max_tokens exceeds workflow budget | A node-level max_tokens is greater than the workflow's budget.max_tokens | Lower the node cap, or raise the workflow budget |
| C038 | error | Unsupported MCP auth type | mcp_server.auth.type is something other than oauth2 (the only wired type) | Drop the auth: block, or change type to oauth2 |
| C043 | error | Invalid compaction values | compaction.threshold is outside (0, 1] or compaction.preserve_recent is < 1 | Use a fraction like 0.85 for threshold and an integer >= 1 for preserve_recent; omit either to inherit the default |
| C047 | warning | Memory enabled on unsupported backend | A node sets memory: enabled: true but its resolved backend doesn't read the field — only the claw backend wires memory tools today. The check is informational; the run still proceeds | Drop the memory: block, or switch the node to backend: claw (or another backend that has memory wired). |
| C048 | error | Memory missing scope | A node sets memory: enabled: true without a non-empty scope: <name> — the runtime needs the scope to locate ~/.iterion/projects/<key>/memory/<scope>/ | Add scope: <name> to the node's memory: block (the scope becomes the directory the memory_read / memory_write / memory_list tools operate against). |
| C049 | warning | Artifact labels without publish | A node sets artifact_labels: but has no publish:, so the labels have no artifact to attach to | Add publish: <name>, or remove the artifact_labels: (judge nodes never publish, so their labels are dropped at compile time) |
| C050 | error | Duplicate attachment | An attachment name is declared more than once across file-level and workflow-level attachments: blocks | Rename the duplicate, or merge the definitions |
| C051 | error | Attachment / var name collision | An attachment name collides with a declared vars: entry | Rename one of them — attachments and vars share a single template namespace |
| C052 | error | Invalid attachment MIME | An accept_mime: entry is not in type/subtype form (e.g. image/png, application/pdf) | Use type/subtype MIME values, optionally with * subtype wildcards |
| C053 | error | Unknown attachment reference | {{attachments.X}} references an attachment that is not declared in a file-level or workflow-level attachments: block | Declare the attachment, or fix the name |
| C054 | error | Unknown attachment sub-field | {{attachments.<name>.<subfield>}} uses a sub-field the runtime does not expose | Drop the sub-field or pick a supported one (path, url, mime, size, sha256) |
| C055 | error | Bad prompt include | A prompt {{include "..."}} marker could not be resolved: the file is missing, is a directory, exceeds the 256 KiB cap, uses an absolute path, or escapes the .bot directory | Point the include at an existing file inside the .bot's directory subtree, below the size cap |
| C060 | error | Playwright MCP server requires browser-capable sandbox image | An MCP server with the Playwright transport is configured but the workflow's sandbox image is not browser-capable | Use ghcr.io/socialgouv/iterion-sandbox-browser (or another browser-capable image whose name matches the validator predicate, such as one containing sandbox-browser or sandbox-full-browser), or remove the Playwright MCP server |
| C070 | error | Preset references unknown variable | A presets: entry sets a key that does not match any name in vars: | Add the variable to vars:, or remove/rename the preset key |
| C071 | error | Preset value type mismatch | A presets: value's type (string/int/bool/list) does not match the declared vars: type | Cast the value to the declared type, or change the var's type |
| C072 | error | Duplicate preset name | The same preset name appears more than once in the presets: block | Rename or merge the duplicate preset |
| C080 | warning | Unknown capability | A capabilities: entry isn't in the built-in registry (currently: board.read, board.create, board.move, board.assign, board.label, board.close, board.comment, watch.subscribe, watch.unsubscribe) | Either fix the typo or accept the warning — unknown caps still propagate to the executor (the registry is open for extension) |
| C081 | error | Malformed capability | A capabilities: entry doesn't match the shape domain or domain.action (lowercase letters/digits/underscores) | Use the lowercase domain.action form, e.g. board.create |
| C082 | warning | Board capability inside sandbox | A node grants a board.* capability while running under a sandbox — the stdio __mcp-board transport is unavailable, so the runtime falls back to the token-gated HTTP transport the host process serves on the per-run gateway listener (the studio/server locally, the runner pod on iterion.cloud — pkg/hostmcp) | No action required when the run is served by a host that wires the transport (a studio/server run, or a cloud runner with the board plane); a bare iterion run without a server leaves the capability disabled with a delegate warning — drop the capability or disable the sandbox for that node |
| C083 | warning | Unknown cursor reference | An agent/judge cursors: setting references a cursor name not declared at workflow scope | Declare it with cursor <name>: or drop the setting — see docs/cursors.md |
| C084 | error | Invalid cursor value | A cursor invocation value is not in the enum, falls outside [0, 1], or doesn't match any band. ${VAR} values defer to runtime | Use a declared enum value or a numeric in range; for env-driven values, ensure the substituted result is valid |
| C085 | error | Malformed cursor declaration | A cursor <name>: block declares neither values: nor bands:, declares both, has overlapping bands, or has a range outside [0, 1] | Pick exactly one form (enum or numeric); ensure bands cover disjoint sub-ranges of [0, 1] |
| C086 | error | Duplicate cursor name | The same cursor <name>: declaration appears twice in one source | Rename one of them, or merge their values: / bands: entries |
| C087 | warning | Unknown provider | A provider: chain token is outside the known provider set | Fix the provider name, or accept the warning for a newly-added provider |
| C088 | warning | Provider chain ignored | A multi-element provider: chain is set on a backend whose chain the runtime does not walk (claw, codex, kimi, grok, pi, opencode) — only claude_code tries a second provider | Drop the extra chain elements, or switch to a backend that honours provider hints |
| C089 | warning | Ultracode model gate | reasoning_effort: ultracode on a model other than claude-opus-4-8 — the orchestration half is 4.8-only, so it degrades to plain xhigh | Use model: "claude-opus-4-8" for full ultracode, or accept the xhigh degrade |
| C090 | error | Duplicate secret | A secret name is declared more than once in the secrets: block | Rename or merge the duplicate |
| C091 | error | Secret / var name collision | A secret name collides with a declared vars: entry | Rename one — secrets and vars share a template namespace |
| C092 | error | Invalid secret host | A secret's egress host scoping (Layer 2 hosts:) is ill-formed | Use valid host entries (hostnames / domains) |
| C093 | error | Unknown secret reference | {{secrets.X}} references a secret not declared in the secrets: block | Declare the secret, or fix the name |
| C094 | error | Malformed file secret | An as: file secret declaration is malformed (a bad mount_path:, an env: that is not an identifier) | Fix the offending property. A bare as: file (optionally optional: true) is complete on its own — it resolves the stored secret by name and mounts it; value:/env:/mount_path: are additions, not requirements |
| C095 | error | Unsupported secret sub-field | {{secrets.X.<subfield>}} uses a sub-field the runtime does not expose | Drop the sub-field, or reference {{secrets.X}} directly |
| C097 | error | Unbounded loop without fuel | An as name(unbounded) loop has no fuel ceiling (neither a per-loop unbounded <N> nor a workflow budget.max_iterations) — the "no silent infinity" invariant | Add a per-loop fuel (as name(unbounded 200)) or a workflow budget.max_iterations |
| C098 | warning | Unbounded loop without exit | An unbounded loop's body has no edge leaving the loop — only fuel/liveness can stop it | Add a when-exit (convergence condition) so the loop terminates by its own logic |
| C100 | error | Review without worktree | interaction: review without worktree: auto — there is nothing to merge | Add worktree: auto, or drop the review interaction |
| C101 | warning | Review URL unknown ref | review_url references an output node that does not exist | Fix the node reference, or remove review_url |
| C102 | error | Invalid compress value | compress: is not one of on, off, ultra | Use on, off, or ultra |
| C103 | error | Invalid policy | A Verified Action tool node's policy: is not one of required, recover, best_effort (ADR-044) | Use a known policy value |
| C104 | error | Recovery without postcondition | A tool node configures recovery: (or policy: recover) without a postcondition: — the deterministic truth oracle that makes adaptive recovery safe | Add a postcondition:, or drop the recovery |
| C105 | error | Recovery on a gate | Recovery rungs are attached to a GATE (a node where recipe == postcondition) | Remove the recovery: block — gates stay deterministic; never attach LLM recovery to a gate |
| C106 | warning | Recovery without recover policy | recovery: bounds are present but policy: is not recover — dead config | Set policy: recover, or remove the recovery bounds |
| C107 | warning | Expression operand type mismatch | A comparison inside a compute or quoted when "..." expression has statically-known operands of incompatible type classes (e.g. string[] == int, count < "x") | Compare compatible types. Inference is conservative: json (= any) fields, vars, and unresolved refs bail to "no opinion" and are never flagged |
| C108 | warning | when-expression not boolean | A quoted when "<expr>" is a bare numeric value (e.g. when "count") rather than a boolean — int/float coerce to truthy, which is rarely the author's intent | Use a comparison such as when "count > 0". Bare bool, string[], and string values ride the documented truthy idiom and are not flagged |
| C109 | error | Var default type mismatch | A vars: entry's default literal type doesn't match its declared type (e.g. count: int = "x") | Fix the default to match the type. int→float widening is allowed (ratio: float = 5); json and string[] accept loose literals and are never flagged |
| C110 | error | Invalid permission | permission: is not one of off, ask, deny | Use off, ask, or deny |
| C111 | warning | Permission rules that reach no gated reader | A declared allow/ask/deny rule list can never decide a tool call. Four shapes, one class: (1) an agent/judge node's own list under an effective mode of ""/off; (2) a workflow list when the graph has no reader at all; (3) a workflow list when readers exist but none runs gated; (4) a workflow list that is SHADOWED — every reader declares its own list of that kind, which REPLACES it. A reader is any node that issues LLM tool calls: an agent or judge, and also a tool node whose Verified-Action recovery can reach its agent rung — that synthetic agent declares no lists of its own, so it is gated by the workflow's. Deliberately NOT flagged: a workflow list a node turns the gate on for; any workflow-level verdict on a file where C110 refused a mode — all four shapes go quiet until the mode parses, the absolute one included; the node-level warnings still fire. Shape (2) is the only verdict no run-time override can falsify; (1), (3) and (4) read the DSL, and (1) and (3) say so — --permission / ITERION_PERMISSION can still arm the gate. Shape (4) is override-proof by construction: a reader counts against shadowing whatever its mode says | Set permission: ask or deny where the rules live (node or workflow); or, for the shadowing shape, remove the workflow list or restate it in the node lists that need it (every reader already declares its own, so there is no node left to move it to) |
| C112 | warning | Tool-node permission inert | permission: on a tool node — parsed but not enforced (a tool node runs a fixed command, not an agent). Its Verified-Action agent rung is gated, but by the WORKFLOW's block: the synthetic recovery agent declares no mode and no rules, and this node's own permission: is not copied onto it | Remove the permission: from the tool node; gate the agent nodes instead. Where this node's Verified Action can reach its agent rung (policy: recover with recovery.max_agent_attempts > 0 — the bound alone never runs a rung, C106), that recovery agent IS gated — by the workflow's permission:/allow:/ask:/deny:, which is where its rules belong |
| C113 | error | fan_out_each without over | A fan_out_each router has no over: array source | Add over: "{{...array...}}" to the router |
| C114 | error | fan_out_each property on non-fan_out_each | over/as/key/depends_on set on a router that isn't mode: fan_out_each | Remove the property, or change the router mode |
| C115 | error | fan_out_each edge count | A fan_out_each router must have exactly one outgoing template edge | Keep a single template edge from the router |
| C116 | error | Use references unknown group | A use ... as statement references a group that is not declared | Declare the group, or fix the name |
| C117 | error | Use param mismatch | A use provides an unknown param, or omits a declared one | Match the group's declared params exactly |
| C118 | error | foreach conflicts with loop | An edge combines as foreach with as <loop> (mutually exclusive) | Use one iteration form per edge |
| C119 | error | subbot without source | A subbot node has no source: child .bot | Add source: <path>.bot to the subbot |
| C120 | warning | Index on scalar | A subscript [...] is applied to a statically-scalar value (string/bool/int/float), which is not indexable | Index an array/map, or drop the subscript |
| C121 | error | Enum literal never matches | A when "field == 'literal'" / != comparison (or a compute expression) compares an enum-typed field against a literal that is not one of its enum: values — the comparison can never match, so it is almost always a typo | Use a declared enum value, or fix the field's enum: set. json fields and unresolved refs are never flagged |
| C122 | error | Invalid node timeout | An agent/judge timeout: is not a positive Go duration (after ${VAR:-default} expansion) | Use a positive Go duration string, e.g. timeout: "20m" or "1200s" |
| C123 | error | Multiple else edges | A source node has more than one else edge — two fallbacks firing on the same miss is the C010 ambiguity under a new name | Keep exactly one else per source |
| C124 | error | Else alongside unconditional | A source node has both an else edge and a bare unconditional edge — two competing defaults | else IS the fallback: remove the bare unconditional edge, or drop the else keyword |
| C125 | error | Var enum on non-string type | A vars: entry declares an [enum: ...] constraint on a non-string type (e.g. count: int [enum: "a"]) — enums constrain string values only | Declare the var as string, or drop the constraint |
| C126 | error | Var default not in enum | An enum-constrained var's LITERAL default is not one of the declared values (e.g. mode: string [enum: "a", "b"] = "c"). A default carrying an env reference is not judged here — it expands at run start, so the verbatim comparison was a false error: it is C181 (unresolvable) or C182 (violates the compile-time reading) instead (#1610). Launch-provided values get the same check at run start: the engine rejects any --var / HTTP / dispatcher / preset value outside the enum set | Use one of the enum values as the default, or extend the list |
| C127 | warning | Duplicate var enum value | The same value appears more than once in a var's [enum: ...] list — the duplicate is dropped (first occurrence kept, order preserved) | Remove the duplicate value |
| C160 | error | Var matching on non-string type | A vars: entry declares a [matching: "<re>"] constraint on a non-string type — a pattern constrains text, and for a non-string var the declared type is already the constraint. json / string[] are refused for a sharper reason: they render differently on the default and the override path, so a pattern would certify one rendering while the run uses the other | Declare the var as string, or drop the constraint |
| C161 | error | Var default does not match its pattern | A pattern-constrained var's LITERAL default does not satisfy its own pattern (e.g. agent: string [matching: "^[a-z]+$"] = "Code X"). Checked on the literal text as written, like C126: a default never reaches the launch gate, so a default excused here would be checked on no path at all. A default carrying an env reference is C181/C182 instead (#1610), exactly as on the enum arm — the two constraints do not diverge | Make the default satisfy the pattern, or widen the pattern |
| C162 | error | Var matching pattern is not valid RE2 | The [matching: ...] pattern does not compile. Go's regexp accepts RE2 — no backreferences, no lookaround | Fix the pattern |
| C163 | warning | Var matching pattern is not anchored | The pattern is not anchored, so — Go matching by search — it also admits a value that merely contains a match: [a-z]+ accepts --flag and codeX --some-flag. (?m)^...$ counts as unanchored too: under the m flag the anchors become LINE anchors and a newline payload passes | Anchor it with ^...$ (alternations must anchor each branch: ^a$|^b$) |
| C164 | error | Var redeclared without its constraint | A workflow-level vars: entry re-uses a top-level var's name and omits the [enum: ...] / [matching: ...] the earlier declaration carries. The later declaration replaces the earlier one, so the guard would be removed in silence and the launch gate would accept anything | Repeat the constraint on the redeclaration, or drop it from both |
| C165 | warning | Preset value violates its var's constraint | An in-source presets: entry sets a var to a value its [enum: ...] does not admit, or that its [matching: ...] pattern refuses: a run that selects that preset and keeps this value is refused at the launch gate — unless the engine resolves the var itself, as it does for review_mode, mono_family, plan_review and llm_families. A warning and not an error — only that run is affected, and an error would block a run selecting another preset or none, and would strand a paused run whose declaration was tightened after it started (a compile error is not reachable by resume --force); C152 is the precedent. Only a value the run's expander leaves alone is judged: one it rewrites (${VAR}, $NAME) goes to the gate, which sees it expanded, and a bundle's file-based preset (presets/<name>.md) is merged after compilation. It is not C126/C161 because the site differs, the way a type mismatch on a default is C109 and the same mismatch on a preset value is C071 | Use a value the var admits, or widen the constraint |
| C166 | warning | Preset key set more than once | A key written twice inside one presets: entry keeps the LAST value — the earlier line is dead text, read by no run. Every neighbouring duplicate is already diagnosed (a var twice is E010, a preset name twice C072, a duplicate enum value C127); this one was silence. A warning like C127's, not an error like E010/C072: the program is unambiguous, so nothing is broken — the author has simply written a line that does nothing (#1661). Fires once per shadowed line, positioned on it, naming the line of the value that wins. A key whose every occurrence was already refused (unknown var C070, type mismatch C071) draws nothing more — there is no winner to name | Remove the shadowed line — or, if the earlier value was the intent, the later one |
| C168 | warning | Numeric pending syntax floor | release-floors sees a numeric syntax pin above the newest released version. The next-minor rule still admits it, but releases on main can overtake it while its branch waits | Use the symbolic pin "next"; the release cut resolves all symbolic Go pins and shipped manifest floors to its version |
| C169 | warning | Bang prefix in a prompt body | A {{!ns.x}} written in a prompt: parsed, then ignored — a prompt has no shell to escape, so the value renders exactly as without the bang. Only where a shell reads the value (a tool command: / script: / postcondition:) is the raw form a decision; there it stays silent (the tree-noise C157/C158 family names its own shapes). Fires per occurrence, positioned on the prompt | Drop the ! — the value renders either way; the raw spelling keeps its meaning in a tool body |
| C128 | warning | Sandbox opt-out | The workflow explicitly declares sandbox: none — every tool and shell command runs directly on the host/runner with its credentials and filesystem, while sandboxing is the engine default. A node-level sandbox: none is flagged too, for the opposite reason: it is parsed but not honoured at run time, so the node still runs in the workflow's sandbox (under a workflow that already opted out it is redundant and draws nothing) | Workflow-level: remove the sandbox: none block to run sandboxed; keep the opt-out only if the flow genuinely needs the host environment. Node-level: drop it — to run on the host, set sandbox: none on the workflow or launch with --sandbox none |
| C129 | error | file field outside a human pause | A schema used as the output: of a node that never pauses for an operator declares a file-typed field — any non-human node, or a human node with interaction: llm (auto-answered by a model) or interaction: review (output is the engine-built verdict). Only an operator upload at a real pause produces one | Move the file field to a human node's output: schema with interaction: human (or llm_or_human, which can escalate to a pause), or use string if the node computes a path itself |
| C130 | error | Reserved answer key | A human node's output: schema declares a key the engine writes on resume (_attachments, which carries the gate's ad-hoc operator uploads) — the operator's answer would be silently replaced | Rename the field. The reservation applies only to human gates; elsewhere the name is an ordinary field |
| C131 | error | Invalid auto_memory value | auto_memory: (workflow or agent/judge node) is not one of on, off — a typo would silently read as "inherit", i.e. off | Use on or off, or drop the field to inherit |
| C132 | warning | auto_memory on an unsupported backend | An agent/judge node explicitly declares auto_memory: on but its backend: does not consume MEMORY.md (claude_code, claw and pi do) | Switch to one of those backends, or drop the auto_memory: field |
| C133 | error | Invalid loop_budget_guard value | The workflow's loop_budget_guard: is not one of on, off — a typo would silently read as "inherit", i.e. the default on, keeping a guard the operator meant to lift | Use on or off, or drop the field to inherit ITERION_LOOP_BUDGET_GUARD then the default |
| C134 | error | Invalid repo_devbox value | The workflow's repo_devbox: is not one of on, off — a typo would silently read as "inherit", i.e. the default on, and the target repo's whole toolchain would keep installing at every run | Use on or off, or drop the field to inherit ITERION_REPO_DEVBOX then the default |
| C135 | error on an identifiable mistake, warning otherwise | Unknown tool name | An agent/judge tools: entry (or a Verified Action's recovery: agent_tools:) names a bare tool the registry cannot resolve, on a backend where the list is a real constraint — claw, whether as the node's own backend or as a fallbacks: route. CLI backends run their own native toolset and ignore the list, so nothing is reported there. It blocks only what the compiler can positively identify: a legacy phantom name (list_files, run_command, git_diff, search_codebase, …), a near-miss typo of a real built-in, or a ${VAR}/{{ref}} entry (tools: is the one node field iterion never expands). Any other unrecognised name warns: a bare name also resolves onto an MCP tool when unique across the connected servers, and the ambient catalog (project .mcp.json, enabled plugins) is merged after compilation. Visible MCP wiring (mcp_server: or an mcp: block on the workflow or node) softens even the identifiable names. Never flagged: qualified MCP references (mcp.<server>.<tool>, the mcp__server__tool alias, wildcards) and iterion's own board/watch tools by their bare name (create_issue, subscribe, …) | Use a claw built-in (read_file, write_file, file_edit, glob, grep, bash, web_fetch, …) — the diagnostic names the nearest match; for an MCP tool use its mcp.<server>.<tool> name |
| C136 | warning | Gated backend needs a host-side run | Two shapes on a workflow that has not opted out of the sandbox: (1) a node routes permission: ask|deny to grok or kimi (primary or fallback) — those two enforce the gate through a host-side PreToolUse hook the sandbox cannot reach; (2) a node routes an ask-capable policy (mode ask, or any explicit ask: rule, which outranks mode deny) to claw — sandboxed claw enforces deny-shaped policies in-container (the policy crosses the IPC pre-task), but an Ask decision cannot pause the parent run from inside the container. Either way the node is refused at execution time, and the shipped default sandbox: auto hits this on the common shape (no sandbox: block) | Declare sandbox: none on the workflow (or the node), launch with --sandbox none / ITERION_SANDBOX_DEFAULT=none, or (claw shape) drop the ask rules / use deny. A warning rather than an error because those run-time overrides make the workflow legal without it saying anything |
| C137 | warning — error for artifacts, attachments, loop | Tool command quotes a ref the runtime already quotes | A tool command: wraps a {{ref}} in single quotes of its own (BASE_REF='{{vars.base_ref}}', --out '{{input.dir}}/f.json', STD='--flag {{input.x}}'). The runtime shell-escapes every ref by wrapping the value in single quotes, so the author's quote CLOSES it instead of nesting: the value lands as bare shell syntax, and on a forge-controlled var (a fork PR's branch name, a title) that is command execution. A warning rather than an error because the shape is inert for values without shell metacharacters, so a repo full of them keeps running while it is cleaned up — except for artifacts, attachments and loop, an error: those namespaces resolve in a tool body since 3.151 (before, the braces reached the shell literally, an inert command), and a value another node or a forge produced would land as shell syntax. Double quotes are reported too, for a different reason: the runtime's single quotes survive as DATA (X="{{ref}}" with main hands the interpreter 'main') and a value carrying " closes the author's span and injects | Remove the surrounding quotes — the runtime adds them (iterion fix does it). For an optional flag, build it with ${VAR:+--flag "$VAR"} from a bare VAR={{ref}} assignment |
| C138 | error | Builtin call the evaluator cannot satisfy | A compute expression or a quoted when "..." calls a builtin with an argument count outside the range that builtin accepts (length(a, b), slice(a, b), max()). The NAME is caught by C040 at parse; the ARITY is not visible there, so such a call used to compile clean and die mid-run — which on a cloud launch costs a sandbox, a clone and a plan phase before it is discovered. The most common cause is a bot authored against a NEWER engine, whose builtin accepts a shape this one does not (min/max gained their variadic form) | Fix the call, or run it on an engine whose evaluator accepts that shape. Accepted counts: length/unique/sort/keys/values/sum/flatten/floor/round take 1; contains/join/tail take 2; if/slice take 3; concat/min/max take 1 or more |
| C140 | warning | Node references an empty schema | A node's input: or output: names a schema that has no field — a legal declaration (the studio saves a schema the moment it is created), not a contract a model can fill: the node's output would always be an empty object | Give the schema at least one field, or point the node at another schema |
| C141 | warning | Use of an empty group | A use <group> as <prefix> instantiates a group that declares no node, so the instance is nothing — and nothing else reports it unless a node of the instance is referenced. A bare group g: is a legal declaration (the studio saves it the moment it is created), or a body that landed at the wrong indentation after a blank line | Give the group at least one node, or drop the use |
| C139 | error | Invalid workspace_checkpoint value | The workflow's workspace_checkpoint: is not one of on, off — a typo would silently read as "inherit", i.e. the default on, and the run would keep force-pushing its whole sandbox tree as an iterion/run-<id>-checkpoint branch onto the repository it was pointed at, which is exactly what an author writing this field is stopping | Use on or off, or drop the field to inherit ITERION_WORKSPACE_CHECKPOINT then the default |
| C142 | error | Invalid worktree value | The workflow's worktree: is not one of auto, none — the runtime reads auto and nothing else, so a typo ran the workflow IN PLACE, in the operator's own checkout, with every commit the run made landing there, without a word | Use auto (a fresh git worktree per run, finalised into a branch) or none (run in place), or drop the field for auto |
| C143 | warning | Escaped quote in a shell string | sandbox.post_create contains a backslash-escaped quote. In a "…" value the lexer keeps every \X verbatim unless the file opts into ## strict-escape: on, so the backslash reaches the shell, which reads \" as a literal quote character: the command runs with quotes inside its arguments instead of around them. Since post_create is best-effort the failure is never read — measured on a bootstrap that died with npm error code EINVALIDPACKAGENAME and had therefore never run once. A warning, because the same value can arrive from a backtick raw string or a | block scalar, where \" is verbatim by design and correct inside a shell double-quoted region; the compiler cannot tell the three apart at this point | In a "…" value: leave a space-free value unquoted, or use single quotes when it has spaces. In a backtick or block-scalar value: ignore |
| C144 | warning | Profile 1 assumed, and it matters | The file has no dsl: header, so it is read as profile 1 — and profile 2 would read it otherwise somewhere: a \ inside a "…" literal (kept verbatim by profile 1, decoded by profile 2), a blank line inside a prompt body (dropped by profile 1, kept by profile 2). Reported by iterion validate only, never at launch; a headerless file with nothing profile 2 reads otherwise draws nothing | Run iterion dsl migrate --to 2 <file>: it adds the header, re-spells every literal so its value is unchanged, and names the prompts whose paragraph breaks will now reach the model. Or keep profile 1 knowingly |
| C145 | warning | Bounded loop with no exit at its cap | A node's bounded loop edge (as <name>(N)) has no exit once the loop is spent: at its cap the runtime declines the back-edge whatever its when, and the edges left do not cover every outcome — the run dies of LOOP_EXHAUSTED | Add the loop-exhaustion exit: a bare edge from the same node taken once the loop is spent — to a typed fail <name>: when exhaustion is a refusal, onward when the work banked so far should still be delivered. An unbounded loop is not named: its fuel is its ceiling, and its logic exit is C098's |
| C146 | warning | Division into an int field | A compute field typed int is fed by a division with an operand typed float, outside floor()/round(): the fractional result fails at run time (EXPRESSION_FAILED), and only then; an operand the compiler cannot type is not held against the author | Wrap the division in floor(...) or round(...), or type the field float |
| C147 | warning | Loop reference to an undeclared loop | A {{loop.<name>.…}} reference names a loop no edge declares (as <name>(N)), or a field the loop namespace has not — the runtime renders no value for it (the reference stays as written, validate --exec names it) | Name a declared loop and one of iteration, max, previous_output |
| C148 | warning | Routing field holds a template that will not resolve — or resolves to a list | A model:, backend:, provider: or interaction_model: — on a node or on one of its fallbacks: routes, a human node's companion model, a verified action's recovery model — or the workflow's default_backend: holds a {{…}} span the executor will not resolve: a namespace other than vars.* ({{outputs.x.m}}, {{input.p}}, {{loop.x.iteration}}, {{secrets.t}}), a span that is not a reference at all ({{var.m}}, {{Vars.m}}, {{vars}}, {{env.HOME}}), an opening {{ with no }}. The route is decided before the node runs, so the executor resolves {{vars.…}} there and nothing else, and keeps every other span as written — left alone, that text reached the backend (an invalid spec on claw, a model literally named {{var.m}} on the claude CLI) after the workspace and the sandbox had been paid for. A span that DOES resolve can still be unroutable: {{vars.x}} naming a string[] var resolves to the list's JSON spelling (["a","b"]), a backend name nobody registered (#1605). A json var is not a list by declaration — its default document decides: a SCALAR document (a string, a number, a bool) resolves to that scalar and stays SILENT (b: json = "\"claude_code\"" is the routable "claude_code", no override involved); a list or object document, or no default at all (the launch supplies the document), warns; a null document warns for what it does instead — it renders as the empty string, so the field is UNSET at dispatch and silently falls back to its default. A ${…} in the default changes nothing: the run parses the document before it expands a leaf, so je: json = "${BACKEND}" is the routable string it expands to, whatever the environment holds. The compiler cannot know the launch VALUE (an override may hand the var a scalar, which is why this stays a warning), only the declared type and the static default. A warning rather than an error, like C147: a bot in the field that carries the shape compiled yesterday and runs today until that node is reached, and the failure there stays loud. A supervisor's model: is a routing field like a node's: a WHOLE {{vars.<name>}} reference resolves at spawn against the run's resolved vars (supervise.SpecsFromWorkflow → resolveModelVarsRefs), and every OTHER span is C148 there — the text reaches the provider as written and the supervisor degrades to its provider hint, the run starting all the same. A declared var the launch did not set and that declares no default keeps its reference as written: that garbage pin makes every eval soft-fail and the supervision parks, which is why the compiler names the span instead of leaving it to the run. An undeclared var in the same fields is C033 | Write the model or backend id, an environment form (${MODEL:-anthropic/claude-opus-5}), or a {{vars.x}} reference to a declared SCALAR var; a launch-time choice goes through iterion run --model / --backend |
| C149 | warning | Input ref on a kind that has no input: surface | {{input.x}} in a subbot/emit node's own with:, or in a fail node's message:, cannot be checked against anything: those kinds accept no input: declaration (E012: unknown subbot property "input"), and the runtime silently resolves the reference against the RUN's inputs — the launch payload (resolveMapping → resolveRef's input arm; a fail message: is rendered through it at fail time, so an incoming edge's with can never feed it). A warning, not an error, because the parent's launch payload may legitimately carry an undeclared key (--var k=v builds the input map wholesale; the cloud launch path forwards every key it received) and that key is reachable ONLY through {{input.x}} — refusing the shape would break the forwarding channel. A typo still lands nil silently at run time (suppressing the child's declared default for the mapped key) | If the key is a launch-time value declared here, prefer {{vars.<name>}} (checked at compile time); if it is forwarded from an undeclared parent payload, keep it and be aware of the silent-nil-on-typo risk |
| C150 | error | secrets ref in a data mapping or compute expression | {{secrets.<name>}} in an edge / subbot / emit with: value, in a fail message:, or inside a compute node's expr: resolves to nil at run time: resolveRef has no arm for the secrets namespace when it is walked from a mapping, and pkg/dsl/expr's evalNamespaces excludes the namespace deliberately | Move the secret to an execution sink that materialises it (a tool's command:/script:/postcondition:, a tool action's params: value, or a prompt body) |
| C151 | error | attachments ref in a compute expression | {{attachments.<name>}} inside a compute node's expr: resolves to nil at run time: pkg/dsl/expr's evalNamespaces excludes the namespace deliberately. A data mapping (an edge / subbot / emit with: value, a fail message:) resolves the namespace like a prompt body does since #2864 — only the compute arm remains refused | Move the attachment reference out of the expr: — into a with: mapping, a tool's command:/script:/postcondition:, a tool action's params: value, or a prompt body |
| C152 | warning | with: value reaches a typed field as a string | An edge with: value arrives as a string unless it is exactly one reference — resolveMapping returns dm.Raw for a ref-less literal, and interpolates a reference into prose in a mixed template ("{{vars.n}} " renders the var as text); only a single whole-reference mapping passes the value's type through (the literal-open form {{"{{"}} renders the literal text). A bool / int / float / string[] field can never hold a string, so every text-arriving value fires there (xs: "a" as much as xs: "[\"a\"]"); a json field accepts a string, so a plain word stays silent — and a bare number stays silent as a deliberate exception (a consumer doing arithmetic on it reads the string) — while a literal that visibly attempts an encoding ("[]", "{}", "''", "null") fires — the author meant the decoded value, the destination gets the text. A tolerant sink survives it (an LLM reads false for what it is; the expr truthy() handler folds "0" / "false" / ""; a tool command: renders the list [] and the text [] alike), a compute breaks: passing the value through to a typed output fails SCHEMA_VALIDATION (expected bool, got string, expected string array, got string), and length() counts the bytes of "[]". A warning at every consumer: the divergence is real, and the runtime tolerates it at some sinks, so no shipped bot turns red on a pattern that runs today | A scalar: emit the constant from a compute's expr: (ok: "false", n: "0") and reference {{outputs.<compute>.<field>}}, or declare a var of the type and reference {{vars.<name>}}. A list or an object: emit the constant from a compute's expr: collection literal (xs: "['a']", doc: "{k: 'v'}") and reference {{outputs.<compute>.<field>}}, or bind a producer's typed output — a node whose output: schema declares the field (xs: string[]) and prints it ({"xs": ["a"]}) — and reference {{outputs.<node>.<field>}} (a string value is an expr string — k: "'text'", the empty string k: "''"). Declare the field string when one string is what is meant |
| C153 | warning | Unknown run namespace member | A {{run.<member>…}} reference names a member the namespace does not carry (known: run.id, the consumption members, the max_* caps, run.tree_noise) — the runtime renders no value for it, and "renders empty" is exactly how an exclusion list vanishes from a scope gate's git command: the gate lives in a prompt, the typo'd member renders to nothing, and the git add -A -- ':/' it scripts stages the whole tree in silence, tree noise included. The failure is LOUD on the executable surfaces instead: a tool command: keeps the unresolved placeholder and git refuses the pathspec it cannot match; a script: body JSON-encodes it into a word git refuses too (#1464). A warning rather than an error, like C147: the bot compiled yesterday and runs until the node is reached. Not walked on the expression surfaces (compute/when) — the same hole C147 has there | Use a known member; for tree-noise exclusions the member is run.tree_noise, and validate --exec renders the exact value a command will carry. In a tool command: read $ITERION_TREE_NOISE instead — a rendered member arrives shell-escaped as one argument: git add refuses it, a git status-based gate silently ignores it and lists the noise anyway |
| C154 | error | Unreadable permission rule | An allow/ask/deny entry — on the workflow or on an agent/judge node — is not a rule the gate's parser accepts: an empty string, an unclosed (, or a missing tool name. The runtime builds the policy from these exact strings through the same parser, so the entry fails the node the moment the gate is armed — after the run has paid for a workspace and, on a cloud launch, a clone and a plan phase. (Under permission: off the strings are never parsed, so the failure is latent rather than immediate; the rule still breaks the instant anyone arms the gate.) The check has no spelling of its own to keep in step: it calls permission.ValidateRule | Write Tool or Tool(pattern) — Bash(go test:*), Read(**), mcp__github__get_*. The message carries the parser's own reason |
| C155 | warning | A site reads a list-typed var whose default moved | A when: expression, a compute field, a fallback gate, a loop-cap expression, or a tool's command: / postcondition: / script: body reads a string[]/json var whose default the run now reads as a value of its declared type — the same value an override of the same text produces (#1285). A site written against the compiler's raw text reads something else, and says nothing: length(vars.tags) counted the characters of "a,b" and counts two elements; TAGS={{vars.tags}} keeps the first element and runs the rest as a command; a script: receives a list where it received a quoted string. Fires only where the default's VALUE moves, and the shell arm only for string[], whose argv splitting is the one thing that moves a command's arity | Supply the var at launch, write the site against the value, or declare the var string when the text is what is wanted |
| C156 | error | Unknown review-gate posture or merge strategy | A review gate's posture: is not human_required or agent_verdict_ok, or its merge_strategy: is not squash or merge. An unknown word used to read as the DEFAULT (human_required / squash) — fail-safe for the tree, but a silent replacement of the author's explicit choice, the shape C142 refuses for worktree:. The IR keeps the fail-safe default, so a launch surface that ignores compile errors still gates on a human and still squashes | Write one of the enumerated values: posture: human_required / agent_verdict_ok, merge_strategy: squash / merge |
| C157 | warning | Tree-noise reference in an executable body | A plain {{run.tree_noise}} in a SHELL body — a tool's command:, postcondition:, or a script: whose language: is sh/bash/empty — renders as one word that, read as a git pathspec list, matches no file (shell-escaped in a command, a JSON-quoted string in a script): the tree-noise exclusion vanishes in silence — a git status-based gate lists the noise anyway, git add refuses the pathspec, and validate --exec reports the gate "clean" either way (#1555; the review-fanout scaffold shipped exactly this until #1530's verdict caught it). A js/py script body stays silent: refs render there as JSON literals, so the ref arrives as data the script consumes, and both shell fixes would break it (the bang form is a SyntaxError, a bare $ITERION_TREE_NOISE never env-expands in a script). A ref inside the quotes of a shell script stays silent too — the check cannot tell interpreter-bound data from a collapsed word there (the python3 -c "… {{run.tree_noise}} …" shape), and a miss is the honest direction. The member is the PROMPT rendering of the exclusion list — in a prompt body it is exactly what an agent pastes, and stays silent there. Fires once per body: repeats of the same ref are the same finding. A warning like C153: yesterday's bot keeps compiling. Positioned on the body's property line (command:, script:, postcondition:); the AST keeps no position inside a body, so the line inside it travels in the message as the body-relative line | In a shell body, write {{!run.tree_noise}} (verbatim substitution) or read $ITERION_TREE_NOISE unquoted — the engine exports it to every tool process |
| C158 | warning | Tree-noise env var quoted | ONE shape is named, the unambiguous one: a standalone word that is exactly a quoted span holding exactly the var — "$ITERION_TREE_NOISE", '…', "${ITERION_TREE_NOISE}" or a braced parameter-expansion form ("${ITERION_TREE_NOISE:-}"). Inside DOUBLE quotes the var expands as ONE word — the list collapses into a single pathspec that matches nothing: git exits 0, the whole tree stages. Inside SINGLE quotes the var never expands at all — the literal text reaches the command. The message matches the quote style. "Standalone" is judged per side: whitespace and command separators on both, plus >, < and ) on the CLOSING side (git add "$V">/dev/null, OUT=$(… "$V") hand the collapsed word to argv the same), never on the opening side — a redirect target (2>"$V") is one word by design, and (-glued spans stay silent. Everything else stays silent BY DESIGN (precision over recall — a lint that misses is acceptable, a lint that lies is not): [ … ] / [[ … ]] / test … operands (quoting is mandatory there; [ -n $VAR ] unquoted is true on an empty var), assignment right-hand sides (an assignment never word-splits — and name="$V" in argument position fails LOUD, not silently), a case word and case patterns (no splitting in case syntax — the compound is tracked with a per-depth state stack, so nesting, an inner case inside the case word, and esac as an arm argument all behave; the pattern region opens only from the "case word consumed, awaiting in" state, so for f in "$V" in an arm still fires), arithmetic $(( … )) (no splitting, and << is a shift, not a heredoc), and ONE pipe rule: a DOUBLE-quoted span produced by echo/printf whose command ends at a ` | — piped output: the downstream may re-split, and the check cannot tell, so it stays silent (nothing downstream is parsed — not the command, not flags, not continuations — soxargs -0/-I/-dare documented misses, the honest direction; a span whose producer consumes its argv, likegit add "$V" |
| C159 | warning | Declared tool claw cannot serve | An author-written tool list names a tool the claw registry holds — so C135 is silent — that iterion cannot serve, so every call earns a typed capability refusal (backend "claw" cannot serve …) instead of a result. No name is in that state today: agent, claw's subagent spawner, left the list when iterion wired the subagent runner at both of its hosts — in process and in the sandbox container (#1940) — and nothing else claw registers is beyond iterion. The code stays armed for the next capability claw ships before iterion serves it; its three arms are exercised through an injected entry (toolcatalog.SetClawRefusedBuiltinForTest), since a mechanism no test can fire is a mechanism nobody knows still works. A deployment whose OWN host wires no subagent runner is a different fact — a run-time one, answered by toolcatalog.ClawUnwiredSubagentRefusal, which no compiler can predict because the compiler cannot know which host will build the registry. Every list the author writes is read: an agent/judge tools:, reported on the node's own backend or, once, on its first claw fallbacks: route (the primary's own message already carries the fact, so the route adds none), and a Verified Action's rung-4 recovery: agent_tools:, which executor_verified_action hands verbatim to a synthetic agent node on the same registry. The refusal reaches the model as a tool error, so the node still runs. A warning, not an error: a host MAY serve the capability, and the parity rule asks for a typed refusal, not a rejected workflow. The tools the ENGINE appends are out of scope — ultracode grants agent to every claw node that restricts its tools: list, and that line is not the author's; there the tool's own description is what tells the model | Do the work in the node itself, or route the node to a backend that serves the capability |
| C167 | warning | Shell tools no rule bounds, or an allow rule over a whole tool class | Two cells, one registry table. FIRST: a gated claw node can call shell-capable built-ins (bash, repl, diagnostic_shell — the registry tags what executes model-supplied code) that its effective deny/ask rows do not name. The canonical shape is deny: ["Bash"] beside claw's repl: the spelling row bounds its tool, the unnamed second shell falls through to the mode default — an approval prompt in ask mode, or a pass wherever an allow row reaches first. Coverage counts deny AND ask rows (an ask match pauses for a human in every mode), at the tool level, whatever (arg) pattern the row carries. Fires only where the gate is on, the node reaches claw's registry — it runs on claw, or its first claw fallbacks: route can serve it, as C135 and C159 read reach; a Verified Action's recovery agent counts as the node, its surface being the declared agent_tools: list — the ambient registry is the sandboxed-runner shape; an undeclared in-process recovery agent runs no tool loop at all, and the compiler still warns loudly on the absent declaration (agent_tools: [] stays silent) — and SOME bound is declared (a gated claw node with no deny and no ask rows warns nothing). A declared tools: list contributes its own members, no declaration contributes the registry's full set. Measured as a CI fact — TestShippedCorpusStaysSilentOnClassCoverage compiles every shipped .bot as a unit and fails on the first fire: 0 fires today, the copilot bot asking diagnostic_shell and rightly silent among them. SECOND: an allow: row naming a class grants present AND future members (allow: widens) — warned, not refused; ask: over a class widens protectively and stays silent, and an allow: class row an ask row of the same class already gates stays silent too (the ask decides first in Evaluate, every member call pauses, the allow grants nothing). Fires once per node, naming the uncovered tools | Deny the class instead of spellings — deny: ["class:shell"] bounds every shell-capable built-in, including ones registered later; move an allow: class grant to ask: to keep future members behind an approval, or accept the widening |
| C170 | error | Invalid memory visibility | memory: visibility: has an unknown value | Use a known visibility (bot/project/cross_project/user/org/global) |
| C171 | error | Memory visibility conflict | memory: visibility: is combined with the legacy project_root: | Use visibility: alone — drop the legacy project_root: |
| C319 | warning | Global memory space is not run-writable | A node declares memory: visibility: "global" with write: true (the default when memory: enabled is set), so the ONE space every team reads back would be written from a run. A mutation there is a platform act: it answers to platform authority carried on the context (knowledge.AuthorizeSpaceWrite, the single gate both store adapters and the tool installer call), and a run carries none — so memory_read / memory_list are installed and memory_write is NOT, with a <workspace_memory_write_refused> system block telling the model why rather than leaving a silent gap. A warning, not an error: the combination stays expressible and installs the tool the day a super-admin run's authority reaches the engine, and the operator surfaces (PUT /api/memory/doc as super-admin, iterion memory import) write the catalogue today | Set write: false, or pick a team-scoped visibility (org, cross_project, project, bot, user), and write the instance catalogue through the REST surface or iterion memory import |
| C172 | warning | Malformed provider step | A provider: chain element of the provider:model form has an empty provider or model part | Provide both parts, e.g. anthropic:claude-sonnet-4-6 |
| C173 | error | Malformed fallback route | A fallbacks: route declares neither backend, model nor provider (it would re-issue the identical failing call), duplicates another route's name, or switches backend without pinning its own model: (model specs are not portable across backends) | Give the route a distinct target; a route that changes backend: must also set model: |
| C174 | warning | Command ignored | A per-node command: CLI-binary override is set on a backend that ignores it (claw, codex, kimi, grok, pi, opencode) — only claude_code honors it: it is the sole reader of the task's command field, while every CLI-agent backend resolves argv[0] from the binary its registry entry was built with. The backend is read by its default, so a dial counts (${B:-claw} is claw); a reference nothing answers stays undecided | Switch to backend: "claude_code", or drop the command: |
| C175 | warning | Unknown fallback trigger | A fallbacks: route's on: list names a category the runtime does not classify | Use usage_window, auth, unavailable, transient_exhausted or any |
| C176 | error | Unsafe fallback crossing | A backend written as a dial is screened by its DEFAULT — ${VAR:-claude_code} is claude_code — on the node's own backend:, on the workflow default_backend: and on the route's, so spelling a backend as an env reference no longer turns the comparison off (#1389). What the dial is SET to is screened at launch, not here: a compile verdict does not move with the shell. A field the source does not decide (${X} with no default, {{vars.x}}) stays undecided and does not inherit default_backend:. A fallbacks: route would silently change what the node can DO: it runs on a backend that cannot enforce the node's permission: gate, or it crosses the claw⇄CLI boundary on a node with an empty tools: list (empty means NO tools on claw and the FULL native toolset on a CLI backend), or it changes backend: on a node with session: inherit / inherit_if_available / fork / persist | Route to a gate-enforcing backend (claude_code/claw/pi), declare an explicit tools: list, or drop session continuity when crossing backends |
| C177 | warning | Fallback capability drift | A fallbacks: route runs on a backend that silently ignores one of the node's settings (e.g. reasoning_effort:) | Accept the degradation, or pin the route to a backend that honours the setting |
| C178 | error | Invalid loop guard value | A loop clause's guard: (as name(…) guard: off) is not one of on, off — a typo would silently read as "inherit", i.e. keep the affordability guard the author meant to lift for that loop | Use guard: on or guard: off, or drop the clause to inherit the loop_budget_guard: chain |
| C179 | warning | Recovery with no reachable rung | A tool node under policy: recover budgets only recovery: max_repair_attempts: — rung 3, the self-repair generation, which proposes a corrected COMMAND — while its recipe is a bare registered tool name (command: "noop_tool"). Since #2783 dispatch gates rung 3 on the recipe's genre and skips it on such a recipe, and rung 4 is off without max_agent_attempts: no recovery rung is reachable, so the recovery is inert and used to say so nowhere (the other inert shape, bounds under another policy, is C106). The DEFAULTED recovery fires it too — policy: recover with no recovery: block budgets one self-repair attempt, which a registered tool name cannot spend. A warning, never a rejection | Budget recovery: max_agent_attempts: so rung 4's agent re-plans, or write the recipe as a command: with template refs / shell metacharacters or a script: body so rung 3 has a command to correct |
| C326 | error | Invalid loop sized_by signal | A loop clause's sized_by (as name(…) sized_by "outputs.probe.pending") carries template braces, an expression that does not parse, a reference outside vars/outputs, or a provably non-numeric type — at run time an unreadable signal prices unscaled, so a typo would pass silently as "no proration" | Write the signal as a bare expression over vars/outputs reading as a number, or drop the clause to keep the unscaled pricing |
| C180 | warning | with: whole reference delivers a list to a string field | An edge with: mapping that is exactly one {{vars.<name>}} reference passes the value through with its type (C152's silence rule) — and since #1285 a string[] var IS a list on the default path as much as on the override one, while a json var is what its default DOCUMENT is (the run parses it before it expands a leaf, so the text decides): a list or an object fires, a scalar or a null document stays silent (a scalar arrives, as a scalar var's value would — a number or a bool on a string field is another divergence, not this one — or nothing does), and a var with no default fires on what the launch may supply. The collection arrives WHOLE on a field declared string, and there is no runtime type check on a with: value to say so. What measurably breaks is the shell: a field declared string carries no shape declaration, so a tool command: reading {{input.<field>}} takes shellEscapeValue's undeclared-shape arm — a list of scalars is spread into argv as individually-quoted tokens (in a VAR={{input.<field>}} assignment the first element is kept, the rest become a command of their own: the mechanism C155 names), and an object, or a list holding one, arrives as ONE JSON token where a string was declared. The mirror of C152 in the other direction — a typed value arriving on a string field rather than a text arriving on a typed one — and a warning for the same reason: a prompt or a text template reads the rendered value tolerantly | Declare the field string[] or json, or interpolate the reference into prose ("{{vars.b}} ") if one string is meant |
| C181 | warning | Constrained var default unverifiable at compile time | A var with [enum: ...] or [matching: ...] has a default carrying a reference compile time cannot resolve — an environment variable (workspace_dir: string [matching: "^/.+$"] = "${PROJECT_DIR}"), or an engine-supplied name (PROJECT_DIR, PROJECT_SCRATCH_DIR, PROJECT_MEMORY_DIR, BUNDLE_DIR, BUNDLE_SKILLS_DIR) the RUN's expander answers itself, so a compile-time reading that resolved it against an empty environment would certify a value no run starts with. Comparing the text verbatim was a false C126/C161 error — ${...} expands at run start — but a SKIP is not the fix either: a default never reaches the launch gate (it reads the operator's values only), so an excused default would be checked on no path at all while its declaration reads as constrained. What cannot be resolved is NAMED here — loudly unchecked, never silently. A warning, never a refusal: the launch (the operator's environment, or the engine's own answer) decides the value (#1610) | Make the default literal, or give an environment reference a :-default the compiler can judge (a violation of that reading is C182); otherwise know the launch expansion is unchecked. :- is the only operator the expander has: ${VAR:+alt} and ${VAR-default} look up a variable literally named VAR:+alt / VAR-default, and a computed name (${${SEL}}) is whatever the launch environment makes of it — C181 all the same |
| C182 | warning | Constrained var default violates its compile-time reading | A constrained var's default carries only environment ${VAR:-default} references — an engine-supplied name would be C181 — and expanded with nothing set in the launch environment the value is outside the enum / off the pattern (mode: string [enum: "a", "b"] = "${MODE:-c}"). That reading IS the value a bare launch starts with, and no run path re-checks a default, so the divergence would otherwise surface nowhere. A warning, not C126/C161's error: the launch environment decides the actual value, and a refusal would reject a bot that runs clean under the operator's env (#1610). Design-accepted residual: an always-set operator name (HOME, USER, PATH) is read at compile time as UNSET — consulting the shell would make validate say different things on different machines — so a SATISFYING fallback certifies the nothing-set reading only, and a bare launch inherits the operator's shell | Fix the :-default (or widen the constraint) so the value a bare launch starts with satisfies it |
| C183 | warning | Compute literal outside the field's enum | A compute field whose output: schema declaration carries an [enum: ...] constraint is fed by a statically-known string literal that is not a member (s: "'zzz'" on s: string [enum: "a", "b"]): the enum arm of the runtime schema check (checkFieldType) refuses the value at the node — SCHEMA_VALIDATION — so the typo is cheaper to name at compile time. A scalar literal is held against a string field, an all-string list literal against each element of a string[] field (the collection arm activates with the expr collection-literal syntax, #1525); an expression the compiler cannot fully evaluate — a ref, a call, an if() — stays silent, like the rest of the static typing pass. A warning rather than an error, per the prefer-warn stance — the run still fails loud at the node if the author keeps the value | Fix the literal to one of the declared enum: values, or widen the enum |
| C184 | error | Invalid ambient_context value | ambient_context: (workflow or agent/judge node) is not one of none, workspace, operator, all — a typo would silently read as "inherit" and hand the node the default (ADR-119) | Use one of the four values, or drop the field to inherit (default workspace) |
| C185 | warning | ambient_context not enforced on this backend | An agent/judge node declares ambient_context: but its effective backend, or a backend its fallback chain may route to, has not been measured against that policy, so it keeps its own instruction-file conventions — today grok for workspace and all (the per-backend table); also once per workflow whose own ambient_context: no node inheriting it can honour | Run the node on a backend that applies the policy, or drop the field |
| C186 | warning | Gateway route on a non-claw backend | A literal openai_compatible/… model on a route whose backend or provider hint explicitly names a non-claw wire: an agent/judge node's backend: or the workflow default_backend: it inherits, a fallbacks: entry's, a mode: llm router's, or a literal vendor provider: hint beside the gateway model. The executor refuses the same crossing per chain element at dispatch — gateway routes run on the claw backend only (backends.md) — so the warning names it before a run rather than refusing it. A {{vars.…}} or ${VAR} model is not judged here (it is no route at compile time), a route whose backend: is such a reference stays silent (the launch decides it), and so do the shapes that never name a backend — a human node's interaction: llm companion route, a supervisor's model and a tool node's recovery.model resolve with no backend at run time | Route the node to the claw backend (drop the explicit backend:, or set it to claw), drop the vendor provider: hint, or pick a model the named backend serves |
| C187 | error | ambient_context refused on this backend | A declared ambient_context: — the node's own, or the workflow's that it inherits — meets a backend, its own or a fallback route's, that loads instruction files the policy excludes whatever iterion passes it: none/operator on kimi (its AGENTS.md walk has no switch), workspace/operator/none on opencode (its global AGENTS.md and configured instructions, opencode 2's walk past the repository root, and the AGENTS.md of every directory it reads a file in), none/operator on grok (it reads the project's instruction files whenever it trusts the folder). The engine refuses the same at dispatch — a fallback element, a run override and ITERION_AMBIENT_CONTEXT included — rather than run the node with files its policy excludes (ADR-119) | Declare a policy the backend applies (all or workspace on kimi, all on opencode), drop the value to let the backend apply the default as far as it can, or run the node on claude_code, claw, codex or pi |
| C188 | warning | Braced env read defeats the body's own assignment | A SHELL-bound tool body (command:, postcondition:, or a script: whose language: is sh/bash/empty) assigns a name and THEN reads it back in a braced form the engine expands — ${NAME} or ${NAME:-default}. The engine rewrites the body BEFORE any shell sees it (expandBracedEnv, pkg/backend/model/executor_tool.go, ADR-130): any :- form is resolved unconditionally, and a plain form whenever lookupToolEnv answers the name — an ITERION_* name from the bot-var overlay first, any name from the engine's own process when its process-env policy exposes it (ir.ProcessEnvReadable: a cloud server and runner install platformcfg.CloudProcessEnvPolicy, which refuses a credential-shaped name or a platform secret unless ITERION_CLOUD_ENV_PASSTHROUGH lists it, so there a plain ${TOKEN} reaches bash verbatim while ${TOKEN:-none} still resolves — to the default) — so the read answers the engine's environment, and the value the body just set never reaches it. Precision over recall: C188 is meant to fire only where the body's assignment, once it runs, persists to the braced read in bash, on a form the engine answers before any shell runs, and every shape the scanner knows to be otherwise stays silent; where it cannot see that an assignment never reaches the read, it still fires and the finding is wrong — its known limits (what it promises, its documented misses and known limits). The production witness: sec-audit-source's run-id guard read ${RUN_ID:-}, the engine resolved it to "" on every pass, and the guard refused every production deep-scan from 16/09 to 21/09 while the harness stayed green (#1473 repaired that bot; #1522 generalises the class). The reader predicate is the engine's own segmentation (a form ends at the first }; looksLikeEnvRef's C-identifier test), so ${X#y}, ${X:+y}, ${X%y}, ${#X}, ${p.name} and a nested ${A:-${B:-c}} (outer name only — bash answers the inner form) are never judged reads. The writer half is judged by the same scanner as C157/C158, so a COUNT=1 inside a comment, a single-quoted span or a heredoc body never arms it, and only the writer arms bash accepts assignments at count (a NAME=value word before the command's verb, the NAME=value operands of export and readonly — flags included, under bash export -n NAME=1 assigns (dash refuses -n), while past an f flag the operands name functions and assign nothing —, read NAME through its flags and prompt, for NAME in), and of those it leaves out every spelling it recognises of an assignment bash confines elsewhere: to its command's environment (NAME=v cmd, NAME=v \cmd, env NAME=v cmd, command NAME=v), to a subshell, a $( … ) or a backquoted substitution, to a backgrounded command (NAME=v &), to a read that is a pipeline element (cmd | read X), or to a function (local, declare and typeset record nothing — the scanner has no function scope), and a for NAME do, which iterates the positional parameters a tool body runs without; a bare export NAME assigns nothing, and a NAME=value word past the verb is an operand (make COMMIT=abc). sh keeps more than bash: dash, like bash --posix, keeps a prefix before a special builtin (X=1 :, X=1 exec), a documented miss for a script: it runs. Those arms are not a complete bash grammar: a name assigned through a form they do not model (let, printf -v, mapfile, a select variable, an arithmetic ((NAME=…))) is a documented miss, the direction a lint may take. The halves meet on a POSITION, so the finding names a read the body assigns BEFORE it — the TERMINATOR of the earliest command that assigns it, which is why a one-line PORT=4891; curl "…:${PORT}/api" counts (the ; ends the assigning command) while a read inside that command does not: a command reads its own text before it assigns — bash expands a command's words and redirections before its prefix assignments take effect, and a declaration utility's operands, a for's word list and a read's redirection before they assign — so the right-hand side (PATH="${PATH}:/opt/bin", RUN_ID="${RUN_ID:-none}", X=$(tr : , <<< "${X}")), a for's word list (for ARCH in ${ARCH:-amd64}), a read's redirection target (read -r CFG < "${CFG}") and a second operand of one export all read the ambient value by design — as does a read above the assignment. Calling any of them dead would be false, and on the self-default shape the bare rewrite would delete the default. The cost is three documented misses: a read a LATER loop iteration would answer from the body (a ${X} above X=1 inside a for), and two reads inside an assignment-only command, which assigns left to right, each assignment seeing the previous one (A=1 B="${A}" reads 1), and under bash before its own redirections (X=new >"f.${X}" creates f.new; dash expands the redirection first) — the engine defeats each, C188 stays silent. The dial read stays silent by construction too: a body that never assigns the name it reads (PORT=${ITERION_DISPATCHER_PORT:-4891}, the ADR-093 dial) is a deliberate mechanism, not a defect. Also silent, as documented misses the dry run names: a read inside a comment, a single-quoted span or a heredoc body — not because such a position is safe (the engine is quote-blind and answers all three, and an unquoted heredoc delimiter leaves its body live for the shell as well), but because the scanner cannot tell a read there from text meant verbatim, and a warning firing on both would train authors to ignore it — and a non-shell js/py script body (the bare-$NAME remedy is not valid code there). One finding per body and name, on the read it reports: the STRONGEST arm the body carries for that name, so ${RUN_ID} on one line and ${RUN_ID:-} on the next is reported as the unconditional defeat it is, whichever order they were written in. A warning like C157: yesterday's bot keeps compiling, and bots/catalog_braced_env_test.go holds every shipped workflow at zero findings — it loads each one the way a launch does, compiles it and reports THIS diagnostic, so the floor and the lint cannot disagree | Read the variable bare ($NAME) where the braces carry nothing else — the body assigns it before the read, so the shell answers with the value this body just set, inside double quotes included ($NAME expands to the same one word ${NAME} did). Two forms need more, and the diagnostic says which: where an identifier byte follows the brace, close the word instead ("$NAME"_items) — a bare $NAME takes that byte into the name and reads another variable; where the form carries a :- default, spell the fallback as a statement the shell runs ([ -z "$NAME" ] && NAME="…", quoted — an unquoted multi-word default runs its tail as a command) — a bare read drops the default. Or declare env_expansion: none on the node (ADR-130, dsl.md), which keeps the environment forms of its three bodies verbatim and makes this check silent on it: that is the answer where the braces are the downstream language's (a js template literal) or where the :- default must reach the shell, and it costs the ADR-093 overlay for those bodies — a ${ITERION_*} dial read there is no longer answered by the engine |
| C189 | warning | Root vars subscript in a fallback gate | A fallback when: subscripts the root vars namespace (vars['name'] or vars[expression]). The receiver reaches the ordinary ref walk as vars with an empty path, so C173 cannot check the selected declaration: an unknown static key and every computed key warn but compile, and an absent key reads as false and disables the route. Bare vars and subscripts on a selected var (vars.items[0]) stay silent. A static bracket read still counts for C155 when it names a string[]/json default whose reading moved; C189 is emitted exactly once per gate even though the C155 and C173 passes both inspect it | Use vars.<name> for a static key so C173 verifies its declaration. Computed keys remain legal; make sure the selected var is declared and present |
| C190 | warning | Supervisor watches non-agent | A supervisor watches: a node id that isn't an agent node | Watch an agent node, or fix the node id |
| C191 | warning | Malformed supervisor | A supervisor declaration is malformed (e.g. a bad cooldown duration) | Use a valid Go duration for cooldown |
| C192 | error | Duplicate supervisor | The same supervisor <name>: is declared twice | Rename or merge |
| C193 | warning | Unknown supervisor prompt | A supervisor's system: references an undeclared prompt | Declare the prompt, or fix the name |
| C194 | error | Invalid resource capacity | A resources.<name> capacity is ≤ 0 | Use a capacity ≥ 1 |
| C195 | error | Unknown resource in needs | A needs: references a resource not declared in the resources: block | Declare the resource, or fix the name |
| C196 | error | Event node without name | An emit/wait node has no event: name (ADR-051) | Add event: "<name>" |
| C197 | error | Wait without timeout | A wait node has no (or an invalid/non-positive) timeout: — the mandatory bound, the "no silent infinity" invariant for events | Add timeout: "30s" (a positive Go duration) |
| C198 | warning | Dangling event | A wait awaits an event no emit produces (it can only ever time out), or an emit produces an event no wait consumes (dead event) | Pair each wait with an emit of the same event name (a wait on an externally-sourced event is expected to warn until external-event support lands) |
| C199 | warning | Invalid skill reference | A skills: entry (on a node or the workflow) is not a valid skill name — a single path segment of letters/digits/./-/_, not starting with a dot (ADR-059). Existence in the library is resolved at run time, not here, so an unknown-but-well-formed name does not warn | Fix the name; quote kebab-case names (skills: ["changelog-writer"]) |
| C240 | error | Async interaction on human node | interaction: async is set on a human node — async questions are posted by agent/judge nodes; a human node IS the blocking question | Move interaction: async to the asking agent/judge and use an await_answers node as the sync point |
| C241 | error | await_answers without timeout | An await_answers node has no (or an invalid/non-positive) timeout: — the mandatory bound, the "no silent infinity" invariant | Add timeout: "30m" (a positive Go duration) |
| C242 | warning | await_answers with dead from | An await_answers from: names a node that is missing or not an interaction: async agent/judge — no async question can originate there, so the await can only ever time out | Fix the from: reference, or set interaction: async on the referenced node |
| C243 | error | Persist in fan-out body | A node with session: persist sits in a fan_out_all / fan_out_each / llm multi: true subgraph (before the join) | Move persist to the trunk after the join, or use session: fresh on the parallel nodes |
| C244 | error | Bounded iteration crosses a parallel-branch boundary | A loop/foreach edge has no single branch owner: it leaves the fan-out router, re-enters a body from the collector, crosses sibling branches, or touches a node shared by multiple branch scopes — or a loop/foreach NAME is reused across two scopes (a trunk back-edge and a branch-local one, or two sibling branches), since edges sharing a name fold into one loop and a branch-local counter would shadow the enclosing one. Cycles wholly contained in one fan_out_all, fan_out_each, or llm multi: true branch are valid and receive private durable counters. On an llm multi: true router each branch is bounded by the collector its OWN edge can elect: the model may select that edge alone, and the runtime then elects a collector from the narrower set, so a cycle that only looks branch-local against the full declared set is rejected | Keep every node in the cycle inside one branch and give each scope's loop its own name; otherwise move the loop to the trunk (after the join or wrapping the router), or use a subbot for explicit isolation |
| C245 | error | Trunk-only human mode in parallel branch | A human node inside a fan_out_all, fan_out_each, or llm multi: true body declares interaction: review or interaction: llm_or_human; their companion/auto-answer orchestration is trunk-only | Use a plain interaction: human branch gate, or move the review/LLM-assisted gate after the collector |
| C246 | warning | Implicit fan-out collector execution moved into branches | A node was previously elected as an implicit collector only because a bounded back-edge counted as its second predecessor; bounded iteration is now branch-local, so the node executes once per branch | Add await: wait_all or await: best_effort to the intended collector to preserve one trunk execution, or keep it unmarked when per-branch iteration is intentional |
| C247 | error | Malformed fail code | A fail <name>: declaration's code: is not an UPPER_SNAKE identifier (^[A-Z][A-Z0-9_]*$). The value is persisted as the run's failure_code and read by machines — iterion runs list, the studio, the merge-gate notice, the alert sinks — so a lowercase, spaced or digit-leading code would reach all of them. The node still compiles, untyped, so a naming mistake does not cascade into unreachable targets | Rename to the shape the engine's own codes use: code: PLAN_BUDGET_EXHAUSTED |
| C248 | error | Fail code collides with an engine code | A fail <name>: declaration's code: is one of the engine's own store.FailureCode values (BUDGET_EXCEEDED, TIMEOUT, RATE_LIMITED, USAGE_LIMIT_BLOCKED, FAIL_NODE, …). The vocabulary is open-world for READERS but not for writers: the engine reads those codes as control flow — the --auto-resume allow-list and the cloud runner's usage-window retry key on them — so a deliberate refusal wearing one would be auto-retried as a transient provider fault, and since a resumable: true fail re-executes the same guard, every attempt burns for nothing. The reserved set is derived from store.ReservedFailureCodes, never hand-copied | Pick a code of the bot's own: code: PLAN_BUDGET_EXHAUSTED |
| C249 | warning | Duplicate fan-out target | A branch-spawning router — fan_out_all, or an llm router with multi: true — declares more than one edge to the same target node. Both modes spawn one goroutine per outgoing edge and derive the branch identity from the TARGET (branch_<router>_<target>), so those executions share one branch id: they collapse onto one output slot at convergence and onto one durable BranchCheckpoint whose cursor each goroutine overwrites, which lets a resume restart one execution at the other's position. Every outgoing edge counts, conditional or not — fan_out_all takes them all without evaluating any condition. Warned, never refused: the shape has always compiled. fan_out_each is immune (its branch ids are item-indexed, branch_<router>_<i>) and round_robin / single-select llm / condition spawn no branch at all, so none of them warn | Remove the duplicate edge; on fan_out_all, use a fan_out_each router when the intent is N executions of the same node |
| C268 | error | Named session slot without persistence | An agent or judge declares session_slot without session: persist; a shared durable slot has no meaning for fresh/inherit/fork modes | Set session: persist, or remove session_slot |
| C269 | error | Conflicting foreach definitions | Two edges declare the same as foreach name over a different element binding or a different collection. Sharing a foreach across edges is legal — one cursor, re-entered, exactly as edges may share a loop — but registration keeps the FIRST declaration, so a disagreement was resolved in silence: the second edge carried the name and walked the first one's collection, with no error and no warning. The loop arm refuses the same shape (C-DiagDuplicateLoop) for the same reason: the runtime resolution would be ambiguous | Give one of them its own name, or make both declare the same <item> in <collection> |
| C270 | warning | Declared-empty tools: is not enforced | An agent or judge declares tools: [] — no tools at all, which is a value and not an absence — on a backend, or a fallbacks: route, that never receives the list. claw resolves zero tools from it, claude_code turns it into --disallowedTools over its whole native roster and codex maps it onto the read-only sandbox; pi, kimi and grok are driven through the CLI-agent seam, which never passes the list, so there the bound is dropped and the node runs with that CLI's own full toolset | Bound the node with deny: rules — on kimi and grok those are enforced through a host-side hook, so they also need sandbox: none (C136) — or route it to a backend that receives the list (claw, claude_code, codex) |
| C271 | warning | Sync interaction inert on this backend | An agent or judge declares interaction: in its synchronous form (human, llm, llm_or_human, human_or_host) on codex, kimi, grok or opencode — as the primary route or an explicit fallback. The runtime grants ask_user to claw outright, or to a node whose tools: list constrains the route, and that list never reaches these CLIs' argv; the ask_user wiring itself exists only for claude_code (a native MCP server), claw (the in-process registry) and pi (the embedded RPC extension). What these four keep is the PROMPT text of the interaction protocol, so the node runs to completion without ever pausing (#1644). A warning, not C267's error: the async pair is type-asserted at dispatch and fails without it, while the sync form degrades — the requirement was "not silence", not a break at upgrade. And unlike the async half, a sync interaction on a route the source does not decide (auto, an unanswered ${X}, a {{vars.x}}) has NO dispatch backstop at all: it degrades to prompt text in silence, with no CAPABILITY_UNSUPPORTED to catch it — pin the backend where the pause matters | Route the node to a backend whose ask_user is wired (claude_code, claw, pi on its RPC transport), or drop interaction: |
| C272 | warning | unknown bot category | The manifest category: is not one of the six closed slugs (build, verify, harden, document, operate, steer) — usually a typo — so the bot is grouped under Uncategorized on every surface. The value is never rejected or rewritten | Fix the slug, or accept Uncategorized (the bot stays visible, last) |
| C273 | warning | tag outside the curated seed | A manifest tags: entry is outside the curated vocabulary (pkg/bundle/vocab.go). The set is open on purpose; the warning exists so a new facet is a deliberate act, not drift. Reuse an existing tag before inventing one | Reuse a seeded tag, or seed the new facet in pkg/bundle/vocab.go (a shipped catalog bot must — the fleet consistency gate holds the catalog to zero warnings) |
| C274 | warning | skill teaches a durable-memory write the bundle cannot serve | A bundle's skills/*.md names the durable memory store's own write tool — the member of toolcatalog.DurableWriteCanonicals that no backend roster projects, claw's memory_write — while NO node of the bundle can put bytes in a memory tree, so every reader of that page is taught a contract the bot cannot honour (iterion#2773). The two halves are asymmetric on purpose: what the page must NAME is the store's tool, because Write and bash write the WORKSPACE, which no run keeps — a page documenting the DSL's bash -c recipe, or naming the writers a permission gate denies, promises nothing; what a node may SERVE is the whole durable-write vocabulary, shell included, because the memory tree is a directory under $ITERION_HOME. The capability is read from what the RUNTIME INSTALLS, never from the mere presence of a block or a list entry (iterion#2832), in TWO arms: a memory: block whose write tool a run gets (write: false and the instance-wide visibility: "global" both withhold it, which is what C319 warns about), or a durable writer the node HOLDS on one of its routes. What it holds is asked of the engine's own seam (model.ClawExecutor.EffectiveToolNamesOnRoutes) rather than re-derived, so the appends a dispatch makes are counted exactly once: interaction: re-populates an EMPTY claw tools: list and unlocks todo_write and the auto_memory: trio with it, while an UNDECLARED list is no restriction on a CLI route and NO TOOL AT ALL on claw (where the list is the whole surface), and tools: [memory_write] is not a path at all — a tools: entry cannot install the store's own builtin, a memory: block does. Both arms end at the permission GATE: under permission: deny a writer runs only where an allow:/ask: row covers it, and a rule the grammar refuses (C111) is skipped row by row instead of read as no gate at all. The gate is asked PER ROUTE, with the holding route's own spellings — a writer refused on the route that holds it is not credited through another route's surviving spelling (iterion#2881) — and the surface a route offers is the capability registry's per-backend reading, never a second table: readonly: withholds the file writers on claude_code (the shell survives), every writer on codex (a read-only OS sandbox) and pi (a readers-only list), and nothing on the backends that ignore the flag, while a declared tools: list bounds only the backends that RECEIVE it, so pi, kimi, grok and opencode keep their native roster whatever the list says (iterion#2882; toolcatalog.ReadonlyWithholdsDurableWrite and toolcatalog.DeclaredListBounds are the two parameters of that shared question). The corpus twin (bots.TestABundleSellsADurableBriefOnlyWhereItCanWriteOne) asks the same predicate (bundlelint.NodeDurableWriter), so the two readers of the question cannot diverge | Give a node a memory: block on backend: "claw" that keeps its write tool, or auto_memory: on, or a tools: list naming a writer its gate leaves; or scope the page to say this bundle has no memory tree, and root a SHARED skill on the member that can serve it (internal/skillshare/families.go) |
| C260 | error | Malformed action id | A tool node's action: is not a <connector>.<resource>.<verb> id (lowercase segments, at least three of them). The id is what ADDRESSES the operation in a connector package, so a malformed one resolves to nothing — and it is worth catching at compile time because the alternative is a run that reaches the launch, resolves the catalog, and fails on its first node | Write the operation's full id: action: forgejo.issue.comment |
| C261 | error | Action without a connection | A tool node declares action: but no connection:, or names it with a {{…}} template. A connector call carries a credential resolved from a named connection; without one there is nothing to authenticate with, and no default is safe to invent — a run must not pick a tenant's credential on an author's behalf. A template is refused for the same reason it is on action:/retry:/timeout:, and one of its own: the alias is not rendered (nothing renders it, and no ref check inspects it), so it would fail mid-run reading as a missing connection — and an alias computed from an output would let an upstream node choose which credential the call carries | Add connection: <alias>, written as the literal alias iterion connections add --alias stored |
| C262 | error | Recovery on a deterministic action | A tool … action: node declares recovery: or policy: recover. ADR-044's ladder ends in an LLM repairing the recipe, which is precisely what an action node promises does not happen: its whole offer is that no model decides the operation, builds the arguments or reads the answer. Refused at compile time rather than left to an operator to avoid, because the promise is what a .bot author is relying on | Drop the recovery block; branch on the typed failure with a when edge, or use a command: recipe if a repair ladder is genuinely wanted |
| C263 | error | Postcondition on a deterministic action | A tool … action: node declares postcondition:. A postcondition is a SHELL command judging success, and on an action the vendor's own typed answer is the truth — letting an exit code overrule it would let a node report success on a call that failed, or failure on one that worked | Drop it; the operation's result and error class are its success oracle |
| C264 | error | Malformed action parameter | A params: entry has no name, or the same key is declared twice. Silently keeping one of a duplicate pair would send a value the author did not write, with nothing in the run to notice it | Name every entry, and declare each key once |
| C265 | error | Malformed action timeout or retry | timeout: is not a Go duration, or retry: is not a count of extra attempts. A value that merely LOOKS like a bound is worse than none: the call would go out unbounded while the .bot reads as if it were capped | Write timeout: 30s / timeout: 2m, and retry: 3 — a duration is refused there, the delay between attempts being the vendor's Retry-After to name |
| C266 | warning | Connector property without an action | connection:, params:, retry: or timeout: appears on a tool node that declares no action:. The property is INERT there — read only by the connector recipe — and an inert property reads as configured: a node looks bound to a connection it never uses, and command: go test ./... with timeout: 30s runs with no bound at all. A warning, not an error: the node is otherwise perfectly runnable | Remove the property, or add the action: it belongs to |
| C267 | error | Async interaction unsupported by backend | An async agent/judge selects codex, kimi, grok, or opencode as its primary backend or an explicit fallback; these backends do not expose the async question tools | Use claw, claude_code, or pi with RPC transport. Routes resolved at runtime are checked before dispatch and fail with CAPABILITY_UNSUPPORTED when incapable |
| C300 | error | Contract input not bound | An input of a contract is not a declared var, or its type differs from the var's, or it carries from: or a file: block, or its required: / default: contradict the var (an input is required exactly when its var has no default, and defaults to what the var defaults to; a defaultless input is optional only as nullable: true, omitted it is null); a contract declared twice, a version: below 1, a workflow whose contract: names no declaration, a duplicate port or effect (ADR-099: a contract the program does not keep is refused, never displayed) | Declare the var with the port's type (vars: goal: string), drop from: on inputs, let required: / default: mirror the var, declare each contract once, name a declared contract |
| C301 | error | Contract output not produced | An output names no producer, or from: <node>.<field> names a node the program does not declare or a field its output schema lacks, or the field's type differs from the port's; from: <node> on a port not typed with the node's output schema; a file port naming a field or a node that publishes no artifact, a file schema: no schema declares; a default on an output | Write from: <node>.<field> with the field's type; from: <node> for a file port or a port typed with the node's output schema |
| C302 | error | Contract criterion or value invalid | A criterion names no kind, an unknown port or one not written input.<name> / output.<name>, an evaluator that does not take the port's type, or parameters its declaration refuses; a default: or params: the text cannot write (a signed number, an exponent), a default of another type than the port, or null on a port that is not nullable | Name a declared port, give the evaluator the type it takes and its parameters ({min: 2}), write a positive decimal or a string, set nullable: true for a null default |
| C303 | warning | Unregistered criterion kind | A criterion's kind: has no registered evaluator (this build ships min_length and pattern) — the criterion is declared and rendered, not evaluated | Use a registered kind, or ship the evaluator with the plugin that defines the kind |
| C305 | error | Subbot source is an author document | A subbot's source: names an author document (.bot.yaml, the YAML twin of a .bot): a draft is never launched, and the refusal belongs where the parent is compiled — the runtime resolver runs after the parent has launched | Write the child's .bot (iterion fmt --to bot <child>.bot.yaml) and name it in source:; a draft is never launched |
| C304 | warning | Contract output produced only on failure | An output's producer (from: <node>…) is on no path to done — the bot produces it only when it fails, while the contract lists what it produces on success | Bind the output to a node on a path to done, or drop it from the contract |
| C310 | warning | A contract port is bound to a node that runs more than once | An output's producer (from: <node>…) executes inside a fan-out branch body, a foreach, or a bounded loop cycle: the node runs once per branch / item / iteration under the same id, and the port capture keeps one entry per node id — the declared port projects whichever execution finished last, one execution's output, not the collection. Stable only when the value does not vary between executions | Bind the port at or after the convergence point (where the executions join and it runs once), or aggregate the executions into one compute node and bind that; a value that genuinely does not vary may keep the binding — the warning names the capture |
| C311 | error | budget.on_exceeded is not a policy the runtime knows | budget.on_exceeded carries a word that is neither fail nor pause — the run would read the policy as fail, ending at the cap while the author expected the pause | Write fail or pause; the diagnostic names the word it refused |
| C312 | warning | network: block without mode: is inert | A sandbox.network: block with no mode: (empty, or carrying only a preset: / rules:) resolves to open at the policy layer: the CONNECT proxy filters no host (it starts only when Layer 2 or a gateway credential needs it), so the curated list the author wrote enforces nothing. On a repo-defined sandbox (auto + a found devcontainer.json) the runtime instead reads the block's content as the allowlist it is shaped like — and rules without a preset also gain the iterion-default base, so the run's own LLM calls survive the completed policy; everywhere else the block is silently open. A warning because the bot still compiles and runs — it just runs without the guard its author believed they had written | Write mode: allowlist above the preset:/rules: (that is what the shape means), or mode: open / mode: denylist if that is what was intended |
| C313 | warning | A human node clause its interaction mode ignores | A human node declares system: or model: in a mode that runs no companion model (human, human_or_host, none), or a review-gate clause (posture:, merge_strategy:, merge_into:, max_turns:, review_url:) outside interaction: review. The compiler keeps those values only inside their own mode branch, so elsewhere the authored value is DROPPED — a gate flipped from llm_or_human to human lost its system prompt in silence, and the prompt reference was not even checked (validatePromptRef lives in the same branch). A warning, not an error: the mode is the author's choice, and the clause may be a leftover | Remove the clause, or set a mode that reads it — interaction: llm / llm_or_human / review for system: and model:, interaction: review for the review-gate clauses |
| C314 | error | Session slot named like another node | A session: persist node declares session_slot equal to the id of another node of the same workflow. A persisted conversation is keyed on the slot when the node declares one and on the node id otherwise, in one keyspace, so a slot spelt like a node id reads and writes THAT node's session — a revise pass with session_slot: draft replays draft's turns, its failed ones included, and compacts them on retry. A slot may carry its own node's id (the same key either way) | Rename the slot to a name no node carries, or drop session_slot so the node keys its conversation on its id |
| C315 | warning | A run setting read from the process environment | A route field (model:, backend:, provider:, command:, reasoning_effort:, timeout:, interaction_model:, a fallbacks: route's own three, a verified action's recovery model, the workflow's default_backend:), a vars: default, or a shell-bound tool body the engine expands consults ${NAME} — so a run's setting comes from the process the run happens to start in, the one source no tenant can write and no record can audit (ADR-132 §1). Named on any env name, not on an ITERION_ prefix: a doctrine with a prefix-shaped hole only moves the next dial to another spelling. In a var default the engine-supplied names (PROJECT_DIR, BUNDLE_DIR, BUNDLE_SKILLS_DIR, PROJECT_MEMORY_DIR, PROJECT_SCRATCH_DIR) are not named: varExpandFn answers those from run state, so they are a write by the engine rather than a run read. In a tool body the same names ARE named — that reader is lookupToolEnv, the ITERION_ overlay then os.LookupEnv, which never consults run state. A node declaring env_expansion: none is never named at all, its bodies being left verbatim (ADR-130). Stage 1 of the record's transition: a warning, so a deployment's pins keep working while the catalogue is de-dialled | Write the value the author means as a literal, and express a tenant's choice where the system of record can see it — a route is a policy overrides cell (platform < org < team < bot < binding < launch), a bot knob is the bot-var ladder above the bot. In a tool body, where the name is one the engine constructs into the child process's environment, read it bare ($NAME): the braces make the engine answer from its OWN environment before the body's shell runs |
| C316 | warning | A codex node's tools: list bounds nothing | An LLM node routed to codex — as its primary backend, through a fallbacks: route, or by a ${VAR:-codex} default — declares a tools: list that codex reads as buying its workspace-write sandbox. Codex runs its built-in shell outside the SDK's per-name callbacks, so a list buys a sandbox MODE and nothing else: read-only when every name it carries reads (read_file, grep, glob, ls, web_search, toolsearch, todo_write, skill by canonical name), workspace-write for any other name — bash and write_file, but also git_diff, list_files, tree, web_fetch, which other backends read as reads. The question is asked of the one classifier the delegate runs (toolcatalog.CodexDeclaredListIsReadOnly), never of a copy of its list. An undeclared list is the unrestricted native toolset by contract and is not warned about; readonly: and full_access: are the author's own locks and silence it | Set readonly: true, or declare a read-only list such as tools: [read_file] |
| C317 | error | A deliverable is not a workspace path | A node's deliverables: entry is not a workspace-relative file path: it is empty, absolute (/tmp/report.md), escapes upwards (../report.md) or names a directory (out/). The run collects a declared deliverable from the node's own workspace and serves it under its base name, so none of those can ever be collected — the declaration is a contract, and a promise no run can keep is refused where it is written rather than lost at run time. A {{…}} reference or a ${…} form does not excuse the shape — no rendering makes /tmp/report.md relative — so a templated entry is held to the same rules; what it renders TO is the collector's to refuse, where the path is resolved. Said once per node, the refused entries listed by excerpt with their reason — absolute, climbs out of the workspace, a directory, empty — the first sixteen by name and the rest by count (a diagnostic per entry grew with the list, #2779) | Write the path relative to the node's workspace (report.md, out/annex.html) |
| C318 | error | Two deliverables share one name | Two deliverables: entries of the same node have the same base name (out/report.md and tmp/report.md), so both would be served under the handle report.md. The run store refuses every one after the first rather than overwrite it — a stable name is the point of the verb, and a reviewer reading pass N-1 must not be handed the bytes of pass N — so the collision is refused at compile time, where renaming is free (C314's doctrine: a collision that cannot be authored needs no run-time guard). Said once per node with its totals, each shared name with the entries served as it — sixteen entries quoted in all, by excerpt and around their difference when two long paths would read alike, the names and entries past them by count — so the message keeps its counts and its remedy under the diagnostic bound; entries that spell one file are said as a repeat of that file, since there is nothing to rename, and the remedy names the gesture the list calls for | Rename all but one of the files under each shared name, and drop a repeated entry |
| C320 | error | Expression past the read budget | An expression — a compute field's, a quoted when's, a loop cap's, a fallback when: — is past the read budget (ITERION_DSL_READ_BUDGET, 2^20 records), the expressions of a bot counted together on a budget of the same count: every token (a name, a value, an operator, a bracket, a punctuation mark) spends one, the end of the expression one, a group's expressions once per use on the text the use expands them to, a loop cap once per edge that carries it, and the reader stops at the token the count ran out on, before the tree it would have built. An expression lives inside one string, which the lexer counts as a single record, so the budget the lexer spends never sees it: a 10 MiB a+a+… built millions of nodes, retained 480 MiB at compile time and overflowed the goroutine stack (#2627). The message leaves the expression's text out and says what the expressions read before it spent | Shorten the bot's expressions — the budget counts them together, a loop cap once per edge that carries it — or raise the budget: ITERION_DSL_READ_BUDGET wherever a bot is read, 0 removes it |
| C321 | warning | Backend absent from every published image | A node's primary route runs a CLI no image iterion publishes carries: kimi, grok and opencode, whose image-presence cell reads unwired (no image) — derived from the image manifests themselves (docker/llm-clis/package.json pins three CLIs, the stock sandbox symlinks two of them and extends no PATH), not from prose. A sandboxed node on one of them dies at exec: <cli>: not found today; once the run's sandbox image is RESOLVED and it is one of the images iterion publishes, the engine refuses before the pulls and names the image, the executable and both ways out (CAPABILITY_UNSUPPORTED). A warning here and not an error, because the limit is the IMAGE's contents and the compiler knows neither the image a run resolves nor whether it will be sandboxed at all: an operator whose own image bakes the CLI keeps a bot that compiles, and the predicate IS the escape hatch (#1643, ADR philosophy #1). codex is deliberately NOT warned — it is on the runner images' PATH, and a sandboxed codex route is already refused on its own terms. Silenced by the workflow's own sandbox: none; a node-level sandbox: is not honoured at run time, so it cannot silence it | Run the node on the host (sandbox: none, --sandbox none, ITERION_SANDBOX_OVERRIDE=none), or start the sandbox from an image that ships the CLI (sandbox: { image: … } / build:, --sandbox-default-image, ITERION_SANDBOX_DEFAULT_IMAGE) |
| C322 | error | Invalid secret sink grant | A secret's sinks: names something outside the vocabulary (prompt, agent, command, params, file, egress), names a sink twice, or names prompt alone (it hands a model the placeholder and resolves it nowhere); or an as: file secret's list lacks file (the mount IS its materialisation); or a value secret grants file (nothing mounts it). An absent list — and sinks: [], which reads as no list — grants every sink (ADR-146, axis 2: the default is ALL, the restriction opt-in) | Name only the six sinks, each once, with at least one that spends the secret; keep file on file secrets and off value secrets — or omit sinks: to grant every sink |
| C323 | error | Secret reference reaches an ungranted sink | A {{secrets.<name>}} reference stands in a prompt body or an LLM node's images: path, both rendered at the prompt sink, a tool node's command: / postcondition: / script: (the command sink) or an action's params: value (the params sink), and the secret's sinks: does not grant that sink — the value would be materialised where its declaration says it may not go. On an images: path the finding points at the secret's file form rather than at a grant: the backend opens the rendered path as a FILE, which a value secret's text — its placeholder, or its bytes — never is, so only an as: file secret's {{secrets.X.path}} can stand there. A bundle preset's prompt body is held to the same grants under this code, by pkg/bundlelint rather than the compiler: the file is merged into the workflow after compilation, so no compile walk reads it, and only where the workflow builds a model task for the body to be appended to — a workflow with no agent, judge or agent-recovery rung renders it nowhere and draws nothing. A file secret's reference renders its mounted path and is never refused here; the two sinks the compiler cannot see — a placeholder a MODEL emits (agent) and a request on the wire (egress) — are honoured at run time by the secret guard from the same declaration | Add the named sink to the secret's sinks: list, or move the reference to a sink the secret grants (secrets) |
| C324 | warning | Egress granted toward any host | A secret's sinks: grants egress and the declaration has no hosts:: the sandbox's inspecting proxy substitutes the placeholder toward ANY host the agent sends it to, and content DLP has no approval list to refuse against — the author asked for the wire and left it open. A warning: a secret with no sinks: has always gone toward any host | Add hosts: naming the hosts the secret may be substituted toward, or drop egress from the list |
| C325 | warning | Sandbox image names no known class | A sandbox: block's image: is a bare word (image: foo) that names none of the stock image classes (slim, full, sec, browser, rgaa — pkg/sandbox/imageclass, #2632). A KNOWN class resolves at launch to the published variant (ghcr.io/socialgouv/iterion-sandbox-<class>:<version>, pinned to the running binary with a :latest fallback); anything else — a full reference, a digest pin, a ${VAR} indirection, and the unknown bare word itself — passes through VERBATIM: it may be a locally built image, and re-pointing it at a registry would be the silent replacement philosophy #1 refuses. A warning and not an error, because the verbatim reading is a working seam — this is the diagnostic that tells the two bare-word readings apart before the pull does | Name a stock class for a published variant, or write the full reference (registry/repo:tag) of your own image; keep the bare word only when a local image of that exact name is what you mean |
| C327 | error | A group's expansion expands past its bound | A use <group> as <prefix> with {…} substitutes its {{params.*}} markers into every string field of the instance, and the lexer counts a marker and its bound value as one record each: K markers × an S-byte value built a K·S-byte string at compile time that no budget ever saw — an 850 KB bot asked the compiler for 10 GB and died in substParams, a 220 KB one sat a 1 GB command: string in the IR, accepted, and even WITHOUT parameters N use lines cloned every string of the group (569 KB of source → 1.3 GB, accepted) (#2774). The expansion now spends before it writes: one field may not expand past 10 MiB (the size cap a source file is held to), and the uses of a bot share ONE substitution budget — every string field an instance writes, substituted or cloned verbatim, spends one record per field and one per 64 bytes of the read budget's count. A per-use count lets N uses multiply: 40 uses individually under each bound allocated 11.5 GiB. Refused on the use line, the instance dropped | Give the group smaller values, fewer markers or fewer uses — a value that big wants a file the nodes read, not a parameter — or raise the count: ITERION_DSL_READ_BUDGET wherever a bot is read, 0 removes it |
| C328 | warning | Host tool without its capability | A node names one of iterion's host tools — the board (create_issue, …), watch (subscribe/unsubscribe) or runs (run_events, …) families, in any accepted spelling (bare shorthand, mcp.iterion_<family>.<tool>, mcp__iterion_<family>__<tool>), a family wildcard expanded per member — without holding the capabilities: grant that opens it. Since the capability gate, the tool opens ONLY through the grant: the grant filters registration, so the agent loses the tool with a process-log line, a tool node's dispatch fails on unknown tool, and a call that still reaches the board layer is refused (boardops.ErrCapabilityDenied). The effective grant is the node's own declared list (a declared capabilities: [] holds NOTHING, it is not an inheritance), else the workflow's; a tool node — and its rung-4 recovery agent's agent_tools: — holds the workflow's. Where MCP wiring is visible on the node or the workflow (an mcp: block naming, inheriting, autoloading or disabling servers — a bare mcp_server: declaration alone wires nothing), bare names step aside (a bare subscribe may be the server's own) and only the qualified spellings are judged. A warning, not a refusal: an unknown name is C135's call, this check judges only the known host tools (#1801) | Declare the named capability in the node's capabilities: list — or the workflow's, which is the only list a tool node (and its recovery agent) has |
| C329 | error | Subbot landing intent without isolation | A subbot declares a landing intent — auto_merge:, merge_into: or merge_strategy: — without isolated: true. A child running in the parent's workspace executes in place: no worktree of its own, no finalization of its own, so the declared landing can never fire and the child's converged work waits for a human — the exact incident the intent exists to close. Closed where it is written (an error, like C314), not discovered at the terminal | Declare isolated: true on the subbot so the child owns a worktree, or drop the landing fields |
| C330 | error | Subbot landing fields without auto_merge | A subbot declares merge_into: or merge_strategy: without auto_merge: true: the arming flag absent, the compiler would drop the fields in silence and the author would read a landing configuration that does nothing | Add auto_merge: true, or drop the landing fields |
| C331 | warning | Server-minted credential referenced in a script body | A tool node's script: body holds a {{vars.<name>}} naming a credential the server mints for the run (the forge publish grant, store.ServerMintedSecretVars). The RUN refuses that reference at the node, before the script's temp file is written: a script body is a program in its own language, so no placeholder→env swap turns the reference into a variable read (the single-quoted form the command: path hands to the materialiser is shell, and each interpreter's env read is its own expression — python's needs an import the body may not have), and a rendered bearer would rest in that temp file in clear, pushed into the workspace by a copy-based sandbox. Until this code nothing said so before the run: validation was clean and a preview rendered a tidy placeholder, so the author learned it mid-node, after the spend upstream. A warning and not a refusal because the compiler reads the NAME while the run asks its guard: a launch that holds no grant (the var left at its empty default) renders the empty value and runs. The reference the check judges is the author's own, in its default form — the raw {{!vars.<name>}} form is their explicit choice and is not judged, and a bearer arriving through a data mapping is a plain input by then | Read the credential from the interpreter's environment — the body names the placeholder itself (__ITERION_SECRET_<var>__), as bots/dep-update-guard does — or write the raw {{!vars.<name>}} form to inline it by your own choice (secrets) |
| C306 | warning | A comparison between collections is constant | A compute expression or a quoted when "..." compares two collections with == / != — a list/object literal on either side (tags == ['a'], {a: 1} == {a: 1}), or two operands the compiler knows as collections: string[] fields, or the results of the total collection helpers (keys, values, sort, unique, flatten, tail, slice, concat, map, filter — keys(m) == keys(m) is the same trap). The evaluator's equality never walks into a slice or a map, so the comparison is constant: == false, != true — [1] == [1] is false. A comparison whose operands are known AND incompatible (string[] == string) is C107's, not double-reported here | Compare what you mean: length(...) against a number, an element (xs[0]), keys(...) / values(...), or join(xs, ",") == "a,b" against a string |
| C307 | warning | A compute field cannot hold the collection literal | A compute field is fed by a list/object literal its declared type cannot hold: a statically non-string element under string[] — a literal (['a', 1]), a nested collection, or an expression of known type ([input.n == 1] is a bool element) — an object under string[], or any collection under a scalar field; the value fails SCHEMA_VALIDATION at run time, and only there. An element the compiler cannot type (a json ref, an untypable call) is not held against the author; a json field accepts any collection | Quote the element ('42'), write a list where the field is a list, or type the field json |
| C308 | warning | An {{input.X}} no incoming edge maps | A node body (a prompt, a tool command: / script: / action params:, a compute expr:) reads {{input.X}}, and no incoming edge of the node maps X in its with:. A node's input is built only from the with mappings of its incoming edges (plus the run-level payload on the entry node, which the compiler cannot know and does not check), so the field is empty on every path — a certain defect the dry run could only name after a full pass. The path-only case — mapped by ANOTHER incoming edge (a loop's back-edge, a sibling when path), legitimately empty on some paths — is not a compile finding; the dry run names it as its own kind (unmapped_on_path), a warning, worded with the edges. Only the surfaces the runtime resolves against THAT input are held to the rule: a mapping value (a subbot/emit with:, a fail message:) reads the run's inputs instead — C149 — and an interaction_prompt: reads the question map the asking node built, so a with prescribed there would silence the warning and change nothing. | Map the field on an incoming edge (… -> <node> with { x: "{{outputs.<producer>.x}}" }) or read the producer's output directly ({{outputs.<producer>.x}}); the diagnostic names the node's first five incoming edges and counts the rest |
| C309 | error | Loop cap on a router edge the runtime does not read | A round_robin or llm router's outgoing edge carries as name(N) — or the foreach form as foreach name(item in …), the same evaluator being the only reader of both — but neither router goes through evaluateEdgesWithLoopsRS: round_robin alternates over its unconditional edges, llm takes the target the model named. The iteration is accepted at compile time and silently ignored at run time, so as spin(1) gets an unbounded alternation instead of the bound the author wrote, and a foreach runs its body once. Symmetric to C244 (a loop on a fan_out_all / fan_out_each / llm-multi boundary is refused too) | Put the loop on the edge that re-enters the router — the runtime bounds THAT edge with the loop's cap — and give the node that now carries it the exhaustion exit C145 asks for |
Parallel-branch migration note: expression-form guards (when "...") are now evaluated against each branch's private runtime scope. Older runtimes skipped those edges inside fan_out_all, fan_out_each, and llm multi: true bodies and selected the fallback instead. This is a runtime behavior change, not a new diagnostic; validate the guarded and fallback routes when upgrading.
Note on
C103–C106(Verified Actions, ADR-044): these four codes are the adaptive-recovery firewall on deterministic ACTION tool nodes, with C179 joining them as the warning for the recovery whose rungs are all unreachable (a registry recipe budgeting onlymax_repair_attempts). The enum-literal type check that earlier releases emitted asC103is nowC121— aC103always means invalid policy. See docs/adr/044-adaptive-recovery-for-deterministic-action-nodes.md.
Bundle Consistency Diagnostics (manifest ↔ workflow)
These C2xx diagnostics come from pkg/bundlelint, emitted when iterion validate runs on a bundle (a .botz archive or a directory with manifest.yaml + main.bot). They cross-check the manifest against the compiled workflow — something neither the manifest parser (pkg/bundle) nor the DSL compiler (pkg/dsl/ir) can do alone, because each only sees one side. They are reported under a separate bundle_diagnostics list in --json output. Manifest/workflow chat-contract violations (C205–C209, C212) and the memory identity mismatch (C230) are errors; warnings are surfaced but do not fail validation.
| Code | Severity | Description | Cause | Fix |
|---|---|---|---|---|
| C200 | warning | dispatch_vars key not a workflow var | A manifest.dispatch_vars key names no variable in the workflow vars: block | Declare the var in main.bot, or fix/remove the manifest key — an undeclared key is silently dropped at dispatch time |
| C201 | warning | context_vars key not a workflow var | An invocations[].context_vars key names no workflow var | Same as C200 |
| C202 | warning | schedule.default_vars key not a workflow var | An invocations[].schedule.default_vars key names no workflow var | Same as C200 |
| C203 | warning | launch_vars key not a workflow var | A forge.webhook.launch_vars key names no workflow var | Same as C200 |
| C204 | warning | args_var not a workflow var | An invocations[].args_var names no workflow var, so the trigger's free-text payload is dropped | Declare the var, or fix the name |
| C205 | error | chat node not in workflow | A chat.nodes key names no compiled workflow node | Fix the node id or add the node to main.bot |
| C206 | error | chat human maps to non-human node | A manifest kind: human entry points at a compiled node whose kind is not human | Align the manifest kind and workflow node kind |
| C207 | error | chat output field missing or wrong type | summary_field, text_field, or approved_field is absent from the node output schema, or is not respectively string/string/boolean | Declare the exact field with the required type, or fix the manifest field name |
| C208 | error | chat seed_var missing or not string | chat.seed_var names no workflow var, or that var is not typed string | Declare a string launch var or fix/remove seed_var |
| C209 | error | chat launcher var missing or not string | A chat.launcher_vars[].name names no workflow var, or that var is not typed string | Declare the string var or fix/remove the launcher entry |
| C210 | warning | forge secret not declared | The forge secret the bot expects (forge.secret, default forge_token) has no matching declaration in the main.bot secrets: block | Declare secrets: { <name>: { as: file, optional: true } }, or point forge.secret at an existing secret. Only checked when the bot is forge-triggerable (has forge.events or a kind: forge invocation) |
| C212 | error / warning | host-event gate half-declared | The manifest's chat.nodes.<id>.host_event_field and the workflow node's interaction: human_or_host disagree. Error when the node declares the mode with no field to receive on — the gate advertises a standby nothing can ever deliver. Warning the other way: the gate works, but reading main.bot gives no hint that anything but the operator can resume it | Declare both halves: interaction: human_or_host on the human node, and host_event_field: <name> in the manifest with a matching json field in the node's output schema |
| C211 | warning | forge secret not a file mount | The forge secret is declared but not as: file — managed forge tokens are bound as a file mount | Set as: file on the secret declaration |
| C220 | warning | manifest capability granted by no node | A manifest.capabilities entry is granted by no workflow-level or node-level capabilities: list | Add it to a node's capabilities:, or drop it from the manifest (it is documentation-only otherwise) |
| C221 | warning | frontmatter capabilities override manifest | The main.bot ## --- frontmatter declares capabilities: that differ from and silently override the manifest's | Keep one source of truth — drop the frontmatter list or align the two |
| C222 | warning | bundle does not open | The document is a bundle's main.bot (an iterion manifest.yaml/.yml or a skills/ beside it) but the bundle does not open — its manifest does not decode — so the studio validated the document alone, without the bundle's prompts/*.md, presets and skills; a reference to a bundle prompt reads as C003 until it opens (iterion validate refuses the same state outright) | Fix the manifest named in the message (iterion validate <bundle dir> refuses with the same decode error) |
| C223 | warning | manifest beside main.bot not read | A file named manifest.yaml/.yml sits beside the main.bot but was not read as this bundle's manifest — it carries top-level keys no iterion manifest has and none only ours have (a typo in the only distinctive key), or the parser cannot read it, or it exceeds 1 MiB — so the file was validated ALONE, without the prompts/, presets and skills beside it | Fix the manifest the message names (the reason says which keys); a manifest of another tool beside a loose main.bot is the expected case and needs nothing |
| C230 | error | per-bot memory name mismatch | A node uses memory: visibility: bot, but the manifest name, workflow name, and bundle dir name are not all identical — so the bot's memory tree splits across CLI (workflow name) and dispatcher (bundle name) launches | Make all three identical |
| C231 | warning | skill has no name: | A skills/*.md file has no name: frontmatter, so it is undiscoverable by name once mirrored into .claude/skills/ | Add name: <kebab-case-id> to the skill frontmatter |
| C232 | warning | skill has no description: | A skills/*.md file has no description: frontmatter, so the router (Nexie) has no signal for when to select it | Add a description: saying what the skill is for and when it applies |
| C233 | warning | skill description: too terse | A skill description: is present but too short to route on (e.g. "Security stuff") | Describe what the skill does and the situation it applies to. Routability only — no phrasing template is imposed |
| C234 | warning | duplicate skill name | Two skills/*.md files declare the same name:, so one silently clobbers the other when mirrored | Give each skill a unique name: |
| C250 | error | engine requirement unmet | The manifest declares requires: { iterion: ">= X.Y.Z" } and this build is below it — the bundle uses something the running engine does not have | Upgrade iterion, or lower requires.iterion to a build that carries what the bot uses. The same predicate refuses the bundle at iterion remote admin bots push (409, --force overrides) and at launch on a runner (BOT_REQUIRES_NEWER_ENGINE, terminal) |
| C251 | warning | engine requirement unchecked | A requires.iterion is declared but this build carries no orderable version (a dev build, a fork's naming scheme), so the comparison could not run | Validate with a released build, or one built through task build (which injects the version). Reported rather than passed in silence — an unchecked contract that reads as satisfied is what the declaration exists to prevent |
| C252 | warning | syntax needs a floor | The bundle's executable sources use a syntax that shipped after the first release — a dsl: N profile above 1, import, a contract — but the manifest declares no requires.iterion, or one below the release that reads it: a runner between the two admits the bundle and fails at its first parse of a subbot child, a fragment or the contract | Declare requires: { iterion: ">= <release>" } at or above the release the warning names (iterion dsl migrate writes it for a profile; a scaffold derives it from the sources) |
| C253 | warning | executable source not read for its profile | An executable source resolves beyond what the profile walk reads — a root-level .bot entry or a subbot source: that is a symlink out of the collection, a child in a sibling bundle (../other/main.bot, a child shape the runner resolves within the bundle's collection), an absolute path — so the profile reported for the bundle does not speak for that source | Declare requires.iterion for the newest profile among those sources, or make the source resolve inside the bundle. iterion validate <bundle> reads a sibling that sits in the same collection; a bot source pushed alone cannot |
| C254 | warning | manifest contradicts the contract | The workflow keeps a contract and the manifest's public face disagrees with it: a launch.primary entry names no contract input (launch.hidden, the inputs the form never renders, is not held), or a produces[].node names a node no contract output comes from (ADR-099) | Declare the input in the contract (it must be a declared var) or drop the name from launch.primary:; bind a contract output to the hand-off node (from: <node>.<field>), or hand off the node the contract names |
| C255 | warning | subbot disagrees with the child's contract | A subbot node's with: block does not pass an input the child's contract requires (one whose var has no default). The parent's output: schema is not held: a subbot's output is the child's terminal output, which the contract's outputs do not define. Checked for the children validate can read within the bundle's collection and compile clean; an unread child is C253 | Pass the input (with { name: "{{vars.name}}" }, or a literal) or give the child's var a default |
| C256 | error | executable source not read whole for its floor | An executable source of the bundle — its main.bot, a root-level entry, a subbot source: child — is past the read budget its files share (ITERION_DSL_READ_BUDGET) or holds a file past the 10 MiB size cap, so its unit is not read whole: the syntax its unread files use is not known, and no requires.iterion floor read off the rest certifies the bundle. The push admission refuses such a bundle (409, --force overrides), iterion dsl migrate writes no floor for it and the scaffold refuses it | Raise ITERION_DSL_READ_BUDGET wherever the bot is read (the CLI, the server, the runner) — the files of a bot share it, so splitting is no way past it — or shrink the source |
The engine-contract checks (C250–C251) hold a bundle's declared requires.iterion against the build reading it. The grammar is deliberately total: >= X.Y.Z or a bare version, dotted numeric, optional leading v; every other shape is a manifest parse error, and an unknown key under requires: is refused by the strict manifest decoder. A requirement iterion cannot read is never a requirement iterion ignores.
The skill-authoring checks (C231–C234) guard routability — that a skill can be discovered and chosen by the router — not prose style. They impose no phrasing template and are all warnings, so a skill gap never fails validation.
A bundle's presets/<name>.md prompt bodies are read here too, and they are the one check that reports OUTSIDE this family: the body is appended at run time to the system prompt of every model task the run builds (the prompt sink), and the file is merged into the workflow after compilation, so the defect is the compiler's (C093, C095, C323 on a {{secrets.X}} reference the grants withhold; C004, as a warning, on a body whose template the parser cannot read) and it is reported under the compiler's code, sentence and hint rather than under a second code for one defect.
Every check that reads a bundle's pages (C231–C234 and C274) reads BOTH canonical forms, the flat skills/<name>.md and the directory skills/<name>/SKILL.md the engine's mirror copies through unchanged, and names the page file rather than its directory: the enumeration is skilllib.ListDirPages, shared with the skill library, so a form the engine accepts cannot go unjudged.
Quick Troubleshooting
"I get C019 (undeclared cycle)" Every back-edge (edge that creates a cycle) needs as loop_name(N). This is the shape that fails:
schema verdict:
approved: bool
agent worker:
model: "anthropic/claude-sonnet-4-6"
output: verdict
judge evaluator:
model: "anthropic/claude-sonnet-4-6"
output: verdict
workflow w:
entry: worker
worker -> evaluator
evaluator -> done when approved
evaluator -> worker when not approved # C019: a cycle with no declared loopAnd the fix, on the back-edge:
evaluator -> worker when not approved as retry(3) with { feedback: "{{outputs.evaluator.summary}}" }"I get C009 (session at convergence)" Nodes that receive from multiple branches (via await: or fan-out) cannot use session: inherit or fork. Use session: fresh, session: artifacts_only, or session: persist.
"I get C012 (missing fallback)" If you have when approved, you need either when not approved or an unconditional edge from the same source. Conditions must be exhaustive.
"I get C018 (missing model or backend)" For agents and judges, add model: "...", backend: "...", or a workflow default_backend: naming a backend — default_backend: auto falls through and does not silence this check. Provision backend credentials on the execution target; the compiler does not inspect the compiling host. With unknown target capabilities C018 is a warning, while an explicitly unavailable target makes it an error. For mode: llm routers, either set an explicit model:/backend: or accept the warning and runtime default. For human nodes with interaction: llm or interaction: llm_or_human, add model: or interaction_model: and declare an output: schema.
