Skip to content
Like what we’re building? Star on GitHub

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 ​

CodeSeverityDescriptionCauseFix
C001errorUnknown node referenceAn edge references a node that is not declaredDeclare the node or fix the name typo
C002errorUnknown schema referenceA node's input: or output: references an undeclared schemaDeclare the schema or fix the name
C003errorUnknown prompt referenceA node's system: or user: references an undeclared promptDeclare the prompt or fix the name
C004errorBad template referenceA {{...}} 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
C005errorDuplicate loop definitionMultiple edges share a loop name but disagree on max_iterationsUse the same max_iterations value or use different loop names
C006errorNo workflow foundThe file has no workflow declarationAdd a workflow name: block
C007errorMultiple workflowsMore than one workflow block foundV1 supports one workflow per file — remove extras
C008errorMissing entry nodeThe entry: node name doesn't match any declared nodeFix the entry name or declare the node
C018error/warningMissing model/backend or LLM interaction requirementsWithout 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.
C024errorDuplicate MCP serverA mcp_server name is declared more than onceUse unique names for each MCP server
C025errorInvalid MCP server configMCP 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
C039errorCompute node has no expressionsA compute node was declared without any expr: key: "<expression>" entriesAdd at least one expression mapping an output schema field to an expression — or remove the node
C040errorExpression failed to parseAn expression in a compute node or in a quoted when "..." clause isn't validCheck operators, parentheses, namespace prefixes (vars / input / outputs / artifacts / loop / run), and built-in calls (length, concat, unique, contains, join, if)
C041errorDuplicate node idTwo declarations share the same node name across agents/judges/routers/humans/tools/computesRename one — node ids are a single global namespace
C042errorReserved node nameA user node is named done or fail (those are reserved terminal targets)Pick a different node name
C044errorInvalid sandbox modeA node or workflow's sandbox: mode is outside the accepted set ("", none, auto, inline); or inline mode is missing an image/build or sets bothSet 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:.
C045errorSandbox auto without configReserved 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 presentNo 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).
C046errorInvalid budget costbudget.max_cost_usd is negative, NaN, or infinityUse a non-negative finite USD amount, or omit the field to disable the cost cap.

Validation Diagnostics ​

CodeSeverityDescriptionCauseFix
C009errorSession at convergence pointA node with await: (or multiple incoming sources) uses session: inherit or session: forkChange to session: fresh, session: artifacts_only, or session: persist
C010errorMultiple unconditional edgesA 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 nodeKeep only one default edge (plus a loop back-edge and its exhaustion exit), or use a router for fan-out
C011errorAmbiguous conditionsSame 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 bodyRemove 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
C012errorMissing fallbackA node has conditional edges but no unconditional fallback and conditions aren't exhaustiveAdd when not X to complement when X, or add an unconditional edge
C013errorCondition field not booleanA when clause references a field that isn't bool in the source output schemaChange the schema field to bool
C014errorCondition field not foundA when clause references a field that doesn't exist in the source output schemaAdd the field to the schema or fix the field name
C015errorElse without conditional siblingAn 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 keywordAdd the conditional sibling(s), or use a plain src -> dst edge
C016errorUnreachable nodeA declared node cannot be reached from the workflow's entry: nodeAdd edges to reach the node, or remove the unused declaration
C017errorHistory ref not in loop{{outputs.node.history}} is used but the referenced node is not part of any declared loopAdd a loop declaration (as loop_name(N)) to the edge cycle, or remove the .history reference
C019errorUndeclared cycleA cycle (back-edge) exists without any loop declaration on its edgesAdd as loop_name(N) to the back-edge to bound the cycle
C020errorRound-robin too few edgesA round_robin router has fewer than 2 unconditional outgoing edgesAdd at least 2 outgoing edges for alternation
C021errorLLM router too few edgesAn llm router has fewer than 2 outgoing edgesAdd at least 2 outgoing edges for the LLM to choose from
C022errorLLM router edge has conditionAn edge from an llm router has a when clauseRemove the when clause — LLM routers select targets directly
C023errorLLM-only property on non-LLM routerProperties model, backend, system, user, multi, or reasoning_effort are set on a router that isn't mode: llmRemove these properties or change the mode to llm
C026errorInvalid loop iterationsA loop's max_iterations is less than 1Set max_iterations to at least 1
C027errorInvalid reasoning effortreasoning_effort has a value other than none, low, medium, high, xhigh, max, ultracodeUse one of the seven valid values
C028errorDuplicate with-mapping keyThe same with key appears on multiple non-conditional edges to the same targetUse unique keys, or make edges conditional/convergent
C029errorUnknown outputs node referenceA {{outputs.<node>...}} template targets a node not declared anywhere in the fileDeclare the node or fix the typo
C030errorImports not resolvedThe 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 referenceValidate 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
C031erroroutputs ref field not in output schema{{outputs.<node>.<field>}} references a field absent from that node's output: schemaReference an existing field, or add the field to the schema
C032warningoutputs 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
C033errorUndeclared variable{{vars.X}} (or vars.X inside an expression) targets a variable not declared in the file-level or workflow-level vars: blockDeclare the variable, or fix the name
C034errorinput ref field not in the validated namespaceIn 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}}
C035errorUnknown artifact{{artifacts.X}} targets an artifact never produced via publish:Add publish: <name> on a prior node, or fix the artifact name
C036errorReference to non-reachable node{{outputs.<node>...}} targets a node not reachable from the entry before the consumerReorder the graph or wire an edge so the producer runs first
C037warningNode max_tokens exceeds workflow budgetA node-level max_tokens is greater than the workflow's budget.max_tokensLower the node cap, or raise the workflow budget
C038errorUnsupported MCP auth typemcp_server.auth.type is something other than oauth2 (the only wired type)Drop the auth: block, or change type to oauth2
C043errorInvalid compaction valuescompaction.threshold is outside (0, 1] or compaction.preserve_recent is < 1Use a fraction like 0.85 for threshold and an integer >= 1 for preserve_recent; omit either to inherit the default
C047warningMemory enabled on unsupported backendA 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 proceedsDrop the memory: block, or switch the node to backend: claw (or another backend that has memory wired).
C048errorMemory missing scopeA 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).
C049warningArtifact labels without publishA node sets artifact_labels: but has no publish:, so the labels have no artifact to attach toAdd publish: <name>, or remove the artifact_labels: (judge nodes never publish, so their labels are dropped at compile time)
C050errorDuplicate attachmentAn attachment name is declared more than once across file-level and workflow-level attachments: blocksRename the duplicate, or merge the definitions
C051errorAttachment / var name collisionAn attachment name collides with a declared vars: entryRename one of them — attachments and vars share a single template namespace
C052errorInvalid attachment MIMEAn accept_mime: entry is not in type/subtype form (e.g. image/png, application/pdf)Use type/subtype MIME values, optionally with * subtype wildcards
C053errorUnknown attachment reference{{attachments.X}} references an attachment that is not declared in a file-level or workflow-level attachments: blockDeclare the attachment, or fix the name
C054errorUnknown attachment sub-field{{attachments.<name>.<subfield>}} uses a sub-field the runtime does not exposeDrop the sub-field or pick a supported one (path, url, mime, size, sha256)
C055errorBad prompt includeA 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 directoryPoint the include at an existing file inside the .bot's directory subtree, below the size cap
C060errorPlaywright MCP server requires browser-capable sandbox imageAn MCP server with the Playwright transport is configured but the workflow's sandbox image is not browser-capableUse 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
C070errorPreset references unknown variableA presets: entry sets a key that does not match any name in vars:Add the variable to vars:, or remove/rename the preset key
C071errorPreset value type mismatchA presets: value's type (string/int/bool/list) does not match the declared vars: typeCast the value to the declared type, or change the var's type
C072errorDuplicate preset nameThe same preset name appears more than once in the presets: blockRename or merge the duplicate preset
C080warningUnknown capabilityA 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)
C081errorMalformed capabilityA 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
C082warningBoard capability inside sandboxA 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
C083warningUnknown cursor referenceAn agent/judge cursors: setting references a cursor name not declared at workflow scopeDeclare it with cursor <name>: or drop the setting — see docs/cursors.md
C084errorInvalid cursor valueA cursor invocation value is not in the enum, falls outside [0, 1], or doesn't match any band. ${VAR} values defer to runtimeUse a declared enum value or a numeric in range; for env-driven values, ensure the substituted result is valid
C085errorMalformed cursor declarationA 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]
C086errorDuplicate cursor nameThe same cursor <name>: declaration appears twice in one sourceRename one of them, or merge their values: / bands: entries
C087warningUnknown providerA provider: chain token is outside the known provider setFix the provider name, or accept the warning for a newly-added provider
C088warningProvider chain ignoredA 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 providerDrop the extra chain elements, or switch to a backend that honours provider hints
C089warningUltracode model gatereasoning_effort: ultracode on a model other than claude-opus-4-8 — the orchestration half is 4.8-only, so it degrades to plain xhighUse model: "claude-opus-4-8" for full ultracode, or accept the xhigh degrade
C090errorDuplicate secretA secret name is declared more than once in the secrets: blockRename or merge the duplicate
C091errorSecret / var name collisionA secret name collides with a declared vars: entryRename one — secrets and vars share a template namespace
C092errorInvalid secret hostA secret's egress host scoping (Layer 2 hosts:) is ill-formedUse valid host entries (hostnames / domains)
C093errorUnknown secret reference{{secrets.X}} references a secret not declared in the secrets: blockDeclare the secret, or fix the name
C094errorMalformed file secretAn 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
C095errorUnsupported secret sub-field{{secrets.X.<subfield>}} uses a sub-field the runtime does not exposeDrop the sub-field, or reference {{secrets.X}} directly
C097errorUnbounded loop without fuelAn as name(unbounded) loop has no fuel ceiling (neither a per-loop unbounded <N> nor a workflow budget.max_iterations) — the "no silent infinity" invariantAdd a per-loop fuel (as name(unbounded 200)) or a workflow budget.max_iterations
C098warningUnbounded loop without exitAn unbounded loop's body has no edge leaving the loop — only fuel/liveness can stop itAdd a when-exit (convergence condition) so the loop terminates by its own logic
C100errorReview without worktreeinteraction: review without worktree: auto — there is nothing to mergeAdd worktree: auto, or drop the review interaction
C101warningReview URL unknown refreview_url references an output node that does not existFix the node reference, or remove review_url
C102errorInvalid compress valuecompress: is not one of on, off, ultraUse on, off, or ultra
C103errorInvalid policyA Verified Action tool node's policy: is not one of required, recover, best_effort (ADR-044)Use a known policy value
C104errorRecovery without postconditionA tool node configures recovery: (or policy: recover) without a postcondition: — the deterministic truth oracle that makes adaptive recovery safeAdd a postcondition:, or drop the recovery
C105errorRecovery on a gateRecovery 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
C106warningRecovery without recover policyrecovery: bounds are present but policy: is not recover — dead configSet policy: recover, or remove the recovery bounds
C107warningExpression operand type mismatchA 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
C108warningwhen-expression not booleanA 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 intentUse a comparison such as when "count > 0". Bare bool, string[], and string values ride the documented truthy idiom and are not flagged
C109errorVar default type mismatchA 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
C110errorInvalid permissionpermission: is not one of off, ask, denyUse off, ask, or deny
C111warningPermission rules that reach no gated readerA 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 saysSet 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)
C112warningTool-node permission inertpermission: 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 itRemove 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
C113errorfan_out_each without overA fan_out_each router has no over: array sourceAdd over: "{{...array...}}" to the router
C114errorfan_out_each property on non-fan_out_eachover/as/key/depends_on set on a router that isn't mode: fan_out_eachRemove the property, or change the router mode
C115errorfan_out_each edge countA fan_out_each router must have exactly one outgoing template edgeKeep a single template edge from the router
C116errorUse references unknown groupA use ... as statement references a group that is not declaredDeclare the group, or fix the name
C117errorUse param mismatchA use provides an unknown param, or omits a declared oneMatch the group's declared params exactly
C118errorforeach conflicts with loopAn edge combines as foreach with as <loop> (mutually exclusive)Use one iteration form per edge
C119errorsubbot without sourceA subbot node has no source: child .botAdd source: <path>.bot to the subbot
C120warningIndex on scalarA subscript [...] is applied to a statically-scalar value (string/bool/int/float), which is not indexableIndex an array/map, or drop the subscript
C121errorEnum literal never matchesA 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 typoUse a declared enum value, or fix the field's enum: set. json fields and unresolved refs are never flagged
C122errorInvalid node timeoutAn 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"
C123errorMultiple else edgesA source node has more than one else edge — two fallbacks firing on the same miss is the C010 ambiguity under a new nameKeep exactly one else per source
C124errorElse alongside unconditionalA source node has both an else edge and a bare unconditional edge — two competing defaultselse IS the fallback: remove the bare unconditional edge, or drop the else keyword
C125errorVar enum on non-string typeA vars: entry declares an [enum: ...] constraint on a non-string type (e.g. count: int [enum: "a"]) — enums constrain string values onlyDeclare the var as string, or drop the constraint
C126errorVar default not in enumAn 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 setUse one of the enum values as the default, or extend the list
C127warningDuplicate var enum valueThe 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
C160errorVar matching on non-string typeA 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 otherDeclare the var as string, or drop the constraint
C161errorVar default does not match its patternA 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 divergeMake the default satisfy the pattern, or widen the pattern
C162errorVar matching pattern is not valid RE2The [matching: ...] pattern does not compile. Go's regexp accepts RE2 — no backreferences, no lookaroundFix the pattern
C163warningVar matching pattern is not anchoredThe 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 passesAnchor it with ^...$ (alternations must anchor each branch: ^a$|^b$)
C164errorVar redeclared without its constraintA 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 anythingRepeat the constraint on the redeclaration, or drop it from both
C165warningPreset value violates its var's constraintAn 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 C071Use a value the var admits, or widen the constraint
C166warningPreset key set more than onceA 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 nameRemove the shadowed line — or, if the earlier value was the intent, the later one
C168warningNumeric pending syntax floorrelease-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 waitsUse the symbolic pin "next"; the release cut resolves all symbolic Go pins and shipped manifest floors to its version
C169warningBang prefix in a prompt bodyA {{!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 promptDrop the ! — the value renders either way; the raw spelling keeps its meaning in a tool body
C128warningSandbox opt-outThe 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
C129errorfile field outside a human pauseA 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 oneMove 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
C130errorReserved answer keyA 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 replacedRename the field. The reservation applies only to human gates; elsewhere the name is an ordinary field
C131errorInvalid auto_memory valueauto_memory: (workflow or agent/judge node) is not one of on, off — a typo would silently read as "inherit", i.e. offUse on or off, or drop the field to inherit
C132warningauto_memory on an unsupported backendAn 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
C133errorInvalid loop_budget_guard valueThe 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 liftUse on or off, or drop the field to inherit ITERION_LOOP_BUDGET_GUARD then the default
C134errorInvalid repo_devbox valueThe 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 runUse on or off, or drop the field to inherit ITERION_REPO_DEVBOX then the default
C135error on an identifiable mistake, warning otherwiseUnknown tool nameAn 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
C136warningGated backend needs a host-side runTwo 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
C137warning — error for artifacts, attachments, loopTool command quotes a ref the runtime already quotesA 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 injectsRemove 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
C138errorBuiltin call the evaluator cannot satisfyA 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
C140warningNode references an empty schemaA 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 objectGive the schema at least one field, or point the node at another schema
C141warningUse of an empty groupA 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 lineGive the group at least one node, or drop the use
C139errorInvalid workspace_checkpoint valueThe 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 stoppingUse on or off, or drop the field to inherit ITERION_WORKSPACE_CHECKPOINT then the default
C142errorInvalid worktree valueThe 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 wordUse auto (a fresh git worktree per run, finalised into a branch) or none (run in place), or drop the field for auto
C143warningEscaped quote in a shell stringsandbox.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 pointIn a "…" value: leave a space-free value unquoted, or use single quotes when it has spaces. In a backtick or block-scalar value: ignore
C144warningProfile 1 assumed, and it mattersThe 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 nothingRun 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
C145warningBounded loop with no exit at its capA 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_EXHAUSTEDAdd 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
C146warningDivision into an int fieldA 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 authorWrap the division in floor(...) or round(...), or type the field float
C147warningLoop reference to an undeclared loopA {{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
C148warningRouting field holds a template that will not resolve — or resolves to a listA 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 C033Write 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
C149warningInput 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
C150errorsecrets 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 deliberatelyMove 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)
C151errorattachments 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 refusedMove 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
C152warningwith: value reaches a typed field as a stringAn 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 todayA 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
C153warningUnknown run namespace memberA {{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 thereUse 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
C154errorUnreadable permission ruleAn 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.ValidateRuleWrite Tool or Tool(pattern) — Bash(go test:*), Read(**), mcp__github__get_*. The message carries the parser's own reason
C155warningA site reads a list-typed var whose default movedA 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 aritySupply the var at launch, write the site against the value, or declare the var string when the text is what is wanted
C156errorUnknown review-gate posture or merge strategyA 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 squashesWrite one of the enumerated values: posture: human_required / agent_verdict_ok, merge_strategy: squash / merge
C157warningTree-noise reference in an executable bodyA 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 lineIn a shell body, write {{!run.tree_noise}} (verbatim substitution) or read $ITERION_TREE_NOISE unquoted — the engine exports it to every tool process
C158warningTree-noise env var quotedONE 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"
C159warningDeclared tool claw cannot serveAn 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 modelDo the work in the node itself, or route the node to a backend that serves the capability
C167warningShell tools no rule bounds, or an allow rule over a whole tool classTwo 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 toolsDeny 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
C170errorInvalid memory visibilitymemory: visibility: has an unknown valueUse a known visibility (bot/project/cross_project/user/org/global)
C171errorMemory visibility conflictmemory: visibility: is combined with the legacy project_root:Use visibility: alone — drop the legacy project_root:
C319warningGlobal memory space is not run-writableA 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 todaySet 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
C172warningMalformed provider stepA provider: chain element of the provider:model form has an empty provider or model partProvide both parts, e.g. anthropic:claude-sonnet-4-6
C173errorMalformed fallback routeA 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:
C174warningCommand ignoredA 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 undecidedSwitch to backend: "claude_code", or drop the command:
C175warningUnknown fallback triggerA fallbacks: route's on: list names a category the runtime does not classifyUse usage_window, auth, unavailable, transient_exhausted or any
C176errorUnsafe fallback crossingA 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 / persistRoute to a gate-enforcing backend (claude_code/claw/pi), declare an explicit tools: list, or drop session continuity when crossing backends
C177warningFallback capability driftA 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
C178errorInvalid loop guard valueA 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 loopUse guard: on or guard: off, or drop the clause to inherit the loop_budget_guard: chain
C179warningRecovery with no reachable rungA 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 rejectionBudget 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
C326errorInvalid loop sized_by signalA 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
C180warningwith: whole reference delivers a list to a string fieldAn 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 tolerantlyDeclare the field string[] or json, or interpolate the reference into prose ("{{vars.b}} ") if one string is meant
C181warningConstrained var default unverifiable at compile timeA 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
C182warningConstrained var default violates its compile-time readingA 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 shellFix the :-default (or widen the constraint) so the value a bare launch starts with satisfies it
C183warningCompute literal outside the field's enumA 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 valueFix the literal to one of the declared enum: values, or widen the enum
C184errorInvalid ambient_context valueambient_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)
C185warningambient_context not enforced on this backendAn 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 honourRun the node on a backend that applies the policy, or drop the field
C186warningGateway route on a non-claw backendA 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 timeRoute 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
C187errorambient_context refused on this backendA 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
C188warningBraced env read defeats the body's own assignmentA 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 disagreeRead 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
C189warningRoot vars subscript in a fallback gateA 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 itUse vars.<name> for a static key so C173 verifies its declaration. Computed keys remain legal; make sure the selected var is declared and present
C190warningSupervisor watches non-agentA supervisor watches: a node id that isn't an agent nodeWatch an agent node, or fix the node id
C191warningMalformed supervisorA supervisor declaration is malformed (e.g. a bad cooldown duration)Use a valid Go duration for cooldown
C192errorDuplicate supervisorThe same supervisor <name>: is declared twiceRename or merge
C193warningUnknown supervisor promptA supervisor's system: references an undeclared promptDeclare the prompt, or fix the name
C194errorInvalid resource capacityA resources.<name> capacity is ≤ 0Use a capacity ≥ 1
C195errorUnknown resource in needsA needs: references a resource not declared in the resources: blockDeclare the resource, or fix the name
C196errorEvent node without nameAn emit/wait node has no event: name (ADR-051)Add event: "<name>"
C197errorWait without timeoutA wait node has no (or an invalid/non-positive) timeout: — the mandatory bound, the "no silent infinity" invariant for eventsAdd timeout: "30s" (a positive Go duration)
C198warningDangling eventA 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)
C199warningInvalid skill referenceA 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 warnFix the name; quote kebab-case names (skills: ["changelog-writer"])
C240errorAsync interaction on human nodeinteraction: async is set on a human node — async questions are posted by agent/judge nodes; a human node IS the blocking questionMove interaction: async to the asking agent/judge and use an await_answers node as the sync point
C241errorawait_answers without timeoutAn await_answers node has no (or an invalid/non-positive) timeout: — the mandatory bound, the "no silent infinity" invariantAdd timeout: "30m" (a positive Go duration)
C242warningawait_answers with dead fromAn 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 outFix the from: reference, or set interaction: async on the referenced node
C243errorPersist in fan-out bodyA 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
C244errorBounded iteration crosses a parallel-branch boundaryA 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 rejectedKeep 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
C245errorTrunk-only human mode in parallel branchA 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-onlyUse a plain interaction: human branch gate, or move the review/LLM-assisted gate after the collector
C246warningImplicit fan-out collector execution moved into branchesA 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 branchAdd 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
C247errorMalformed fail codeA 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 targetsRename to the shape the engine's own codes use: code: PLAN_BUDGET_EXHAUSTED
C248errorFail code collides with an engine codeA 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-copiedPick a code of the bot's own: code: PLAN_BUDGET_EXHAUSTED
C249warningDuplicate fan-out targetA 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 warnRemove the duplicate edge; on fan_out_all, use a fan_out_each router when the intent is N executions of the same node
C268errorNamed session slot without persistenceAn agent or judge declares session_slot without session: persist; a shared durable slot has no meaning for fresh/inherit/fork modesSet session: persist, or remove session_slot
C269errorConflicting foreach definitionsTwo 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 ambiguousGive one of them its own name, or make both declare the same <item> in <collection>
C270warningDeclared-empty tools: is not enforcedAn 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 toolsetBound 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)
C271warningSync interaction inert on this backendAn 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 mattersRoute the node to a backend whose ask_user is wired (claude_code, claw, pi on its RPC transport), or drop interaction:
C272warningunknown bot categoryThe 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 rewrittenFix the slug, or accept Uncategorized (the bot stays visible, last)
C273warningtag outside the curated seedA 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 oneReuse 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)
C274warningskill teaches a durable-memory write the bundle cannot serveA 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 divergeGive 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)
C260errorMalformed action idA 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 nodeWrite the operation's full id: action: forgejo.issue.comment
C261errorAction without a connectionA 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 carriesAdd connection: <alias>, written as the literal alias iterion connections add --alias stored
C262errorRecovery on a deterministic actionA 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 onDrop the recovery block; branch on the typed failure with a when edge, or use a command: recipe if a repair ladder is genuinely wanted
C263errorPostcondition on a deterministic actionA 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 workedDrop it; the operation's result and error class are its success oracle
C264errorMalformed action parameterA 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 itName every entry, and declare each key once
C265errorMalformed action timeout or retrytimeout: 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 cappedWrite timeout: 30s / timeout: 2m, and retry: 3 — a duration is refused there, the delay between attempts being the vendor's Retry-After to name
C266warningConnector property without an actionconnection:, 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 runnableRemove the property, or add the action: it belongs to
C267errorAsync interaction unsupported by backendAn 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 toolsUse claw, claude_code, or pi with RPC transport. Routes resolved at runtime are checked before dispatch and fail with CAPABILITY_UNSUPPORTED when incapable
C300errorContract input not boundAn 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
C301errorContract output not producedAn 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 outputWrite from: <node>.<field> with the field's type; from: <node> for a file port or a port typed with the node's output schema
C302errorContract criterion or value invalidA 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 nullableName 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
C303warningUnregistered criterion kindA criterion's kind: has no registered evaluator (this build ships min_length and pattern) — the criterion is declared and rendered, not evaluatedUse a registered kind, or ship the evaluator with the plugin that defines the kind
C305errorSubbot source is an author documentA 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 launchedWrite the child's .bot (iterion fmt --to bot <child>.bot.yaml) and name it in source:; a draft is never launched
C304warningContract output produced only on failureAn 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 successBind the output to a node on a path to done, or drop it from the contract
C310warningA contract port is bound to a node that runs more than onceAn 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 executionsBind 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
C311errorbudget.on_exceeded is not a policy the runtime knowsbudget.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 pauseWrite fail or pause; the diagnostic names the word it refused
C312warningnetwork: block without mode: is inertA 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 writtenWrite mode: allowlist above the preset:/rules: (that is what the shape means), or mode: open / mode: denylist if that is what was intended
C313warningA human node clause its interaction mode ignoresA 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 leftoverRemove 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
C314errorSession slot named like another nodeA 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
C315warningA run setting read from the process environmentA 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-dialledWrite 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
C316warningA codex node's tools: list bounds nothingAn 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 itSet readonly: true, or declare a read-only list such as tools: [read_file]
C317errorA deliverable is not a workspace pathA 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)
C318errorTwo deliverables share one nameTwo 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 forRename all but one of the files under each shared name, and drop a repeated entry
C320errorExpression past the read budgetAn 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 spentShorten 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
C321warningBackend absent from every published imageA 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 itRun 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)
C322errorInvalid secret sink grantA 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
C323errorSecret reference reaches an ungranted sinkA {{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 declarationAdd the named sink to the secret's sinks: list, or move the reference to a sink the secret grants (secrets)
C324warningEgress granted toward any hostA 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 hostAdd hosts: naming the hosts the secret may be substituted toward, or drop egress from the list
C325warningSandbox image names no known classA 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 doesName 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
C327errorA group's expansion expands past its boundA 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 droppedGive 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
C328warningHost tool without its capabilityA 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
C329errorSubbot landing intent without isolationA 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 terminalDeclare isolated: true on the subbot so the child owns a worktree, or drop the landing fields
C330errorSubbot landing fields without auto_mergeA 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 nothingAdd auto_merge: true, or drop the landing fields
C331warningServer-minted credential referenced in a script bodyA 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 thenRead 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)
C306warningA comparison between collections is constantA 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 hereCompare what you mean: length(...) against a number, an element (xs[0]), keys(...) / values(...), or join(xs, ",") == "a,b" against a string
C307warningA compute field cannot hold the collection literalA 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 collectionQuote the element ('42'), write a list where the field is a list, or type the field json
C308warningAn {{input.X}} no incoming edge mapsA 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
C309errorLoop cap on a router edge the runtime does not readA 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 only max_repair_attempts). The enum-literal type check that earlier releases emitted as C103 is now C121 — a C103 always 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.

CodeSeverityDescriptionCauseFix
C200warningdispatch_vars key not a workflow varA manifest.dispatch_vars key names no variable in the workflow vars: blockDeclare the var in main.bot, or fix/remove the manifest key — an undeclared key is silently dropped at dispatch time
C201warningcontext_vars key not a workflow varAn invocations[].context_vars key names no workflow varSame as C200
C202warningschedule.default_vars key not a workflow varAn invocations[].schedule.default_vars key names no workflow varSame as C200
C203warninglaunch_vars key not a workflow varA forge.webhook.launch_vars key names no workflow varSame as C200
C204warningargs_var not a workflow varAn invocations[].args_var names no workflow var, so the trigger's free-text payload is droppedDeclare the var, or fix the name
C205errorchat node not in workflowA chat.nodes key names no compiled workflow nodeFix the node id or add the node to main.bot
C206errorchat human maps to non-human nodeA manifest kind: human entry points at a compiled node whose kind is not humanAlign the manifest kind and workflow node kind
C207errorchat output field missing or wrong typesummary_field, text_field, or approved_field is absent from the node output schema, or is not respectively string/string/booleanDeclare the exact field with the required type, or fix the manifest field name
C208errorchat seed_var missing or not stringchat.seed_var names no workflow var, or that var is not typed stringDeclare a string launch var or fix/remove seed_var
C209errorchat launcher var missing or not stringA chat.launcher_vars[].name names no workflow var, or that var is not typed stringDeclare the string var or fix/remove the launcher entry
C210warningforge secret not declaredThe forge secret the bot expects (forge.secret, default forge_token) has no matching declaration in the main.bot secrets: blockDeclare 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)
C212error / warninghost-event gate half-declaredThe 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 itDeclare 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
C211warningforge secret not a file mountThe forge secret is declared but not as: file — managed forge tokens are bound as a file mountSet as: file on the secret declaration
C220warningmanifest capability granted by no nodeA manifest.capabilities entry is granted by no workflow-level or node-level capabilities: listAdd it to a node's capabilities:, or drop it from the manifest (it is documentation-only otherwise)
C221warningfrontmatter capabilities override manifestThe main.bot ## --- frontmatter declares capabilities: that differ from and silently override the manifest'sKeep one source of truth — drop the frontmatter list or align the two
C222warningbundle does not openThe 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)
C223warningmanifest beside main.bot not readA 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 itFix 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
C230errorper-bot memory name mismatchA 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) launchesMake all three identical
C231warningskill 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
C232warningskill has no description:A skills/*.md file has no description: frontmatter, so the router (Nexie) has no signal for when to select itAdd a description: saying what the skill is for and when it applies
C233warningskill description: too terseA 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
C234warningduplicate skill nameTwo skills/*.md files declare the same name:, so one silently clobbers the other when mirroredGive each skill a unique name:
C250errorengine requirement unmetThe manifest declares requires: { iterion: ">= X.Y.Z" } and this build is below it — the bundle uses something the running engine does not haveUpgrade 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)
C251warningengine requirement uncheckedA requires.iterion is declared but this build carries no orderable version (a dev build, a fork's naming scheme), so the comparison could not runValidate 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
C252warningsyntax needs a floorThe 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 contractDeclare 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)
C253warningexecutable source not read for its profileAn 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 sourceDeclare 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
C254warningmanifest contradicts the contractThe 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
C255warningsubbot disagrees with the child's contractA 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 C253Pass the input (with { name: "{{vars.name}}" }, or a literal) or give the child's var a default
C256errorexecutable source not read whole for its floorAn 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 itRaise 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:

iter
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 loop

And the fix, on the back-edge:

iter
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.