Note: This issue was created by an automated, scheduled Claude code-review check. Please verify before acting on it.
Summary
check_type_annotations_full (the dora build / dora graph --validate type-annotation checker) never performs cross-edge type-mismatch detection or type inference for any edge whose source is a single-operator node (a node declared with the operator: block). Multi-operator (operators:) source nodes work correctly. The result is that a real type mismatch on such an edge produces no warning, and in --strict mode it produces a misleading warning instead.
Root cause: a key-shape mismatch caused by running the check pre-resolution
In libraries/core/src/descriptor/validate.rs, operator outputs are registered into output_type_map with an operator-id-prefixed key:
// single-operator node (line ~890)
let op_id = op.id.as_ref().map(|id| id.to_string())
.unwrap_or_else(|| super::SINGLE_OPERATOR_DEFAULT_ID.to_string()); // "op"
...
output_type_map.insert((nid.clone(), format!("{op_id}/{output_id}")), urn.clone());
The consumer-side lookup uses the mapping exactly as written in the descriptor (line ~1108):
let key = (mapping.source.to_string(), mapping.output.to_string());
let upstream_urn = output_type_map.get(&key);
check_type_annotations_full runs on the unresolved descriptor — both callers (binaries/cli/src/command/validate.rs:153 and binaries/cli/src/command/build/mod.rs:376) invoke it before resolve_aliases_and_set_defaults(). In the unresolved descriptor, a consumer of a single-operator node writes the bare output name (source: A, output: out). The op-id prefix (A/op/out) is only added later, during resolution:
// libraries/core/src/descriptor/mod.rs:105-107
if let Some(op_name) = single_operator_nodes.get(&mapping.source).copied() {
mapping.output = DataId::from(format!("{op_name}/{}", mapping.output));
}
So at check time the producer key is (A, "op/out") but the consumer lookup key is (A, "out") — they never match, and upstream_urn is always None. This drops the edge into the "no upstream type" branches:
- non-strict:
(None, Some(in_urn)) → _ => {} — the mismatch check and the (Some, None) inference branch are both skipped.
- strict:
(None, Some(in_urn)) → emits "upstream A/out has no type annotation" even when the operator does declare output_types for that port — a false/misleading warning.
The multi-operator (operators:) form is unaffected because there the consumer writes the operator id verbatim (A/opid/out), so the lookup key already matches the registered key. I confirmed the default op id used on both sides is the same constant (SINGLE_OPERATOR_DEFAULT_ID = "op"), so the divergence is purely bare-vs-prefixed, i.e. an ordering bug — not intentional.
Reproduction (conceptual)
A single-operator node A declaring output_types: { out: std/core/v1/Float32 }, feeding a consumer with input_types: { x: std/media/v1/Image } and inputs: { x: A/out }, produces no type-mismatch warning from dora build / dora graph --validate. The equivalent dataflow written with the operators: (multi-operator) form does warn.
Impact
Advisory subsystem only — it does not accept an invalid dataflow or reject a valid one; the dataflow still runs. But the type-annotation feature (mismatch warnings + inference) silently covers only part of the graph, and --strict emits an incorrect "no type annotation" message for these edges.
Suggested fix
Either run the annotation check after resolve_aliases_and_set_defaults() (so consumer outputs are already op-prefixed), or normalize the single-operator consumer key at lookup time in check_edge_mismatches_with_compat — apply the same {op_id}/{output} prefixing the resolver uses when mapping.source is a single-operator node before probing output_type_map. A regression test covering an operator:-sourced edge (both mismatch and inference cases) would lock this in; the existing tests appear to exercise only the operators: form.
Summary
check_type_annotations_full(thedora build/dora graph --validatetype-annotation checker) never performs cross-edge type-mismatch detection or type inference for any edge whose source is a single-operator node (a node declared with theoperator:block). Multi-operator (operators:) source nodes work correctly. The result is that a real type mismatch on such an edge produces no warning, and in--strictmode it produces a misleading warning instead.Root cause: a key-shape mismatch caused by running the check pre-resolution
In
libraries/core/src/descriptor/validate.rs, operator outputs are registered intooutput_type_mapwith an operator-id-prefixed key:The consumer-side lookup uses the mapping exactly as written in the descriptor (line ~1108):
check_type_annotations_fullruns on the unresolved descriptor — both callers (binaries/cli/src/command/validate.rs:153andbinaries/cli/src/command/build/mod.rs:376) invoke it beforeresolve_aliases_and_set_defaults(). In the unresolved descriptor, a consumer of a single-operator node writes the bare output name (source: A, output: out). The op-id prefix (A/op/out) is only added later, during resolution:So at check time the producer key is
(A, "op/out")but the consumer lookup key is(A, "out")— they never match, andupstream_urnis alwaysNone. This drops the edge into the "no upstream type" branches:(None, Some(in_urn))→_ => {}— the mismatch check and the(Some, None)inference branch are both skipped.(None, Some(in_urn))→ emits "upstream A/out has no type annotation" even when the operator does declareoutput_typesfor that port — a false/misleading warning.The multi-operator (
operators:) form is unaffected because there the consumer writes the operator id verbatim (A/opid/out), so the lookup key already matches the registered key. I confirmed the default op id used on both sides is the same constant (SINGLE_OPERATOR_DEFAULT_ID = "op"), so the divergence is purely bare-vs-prefixed, i.e. an ordering bug — not intentional.Reproduction (conceptual)
A single-operator node
Adeclaringoutput_types: { out: std/core/v1/Float32 }, feeding a consumer withinput_types: { x: std/media/v1/Image }andinputs: { x: A/out }, produces no type-mismatch warning fromdora build/dora graph --validate. The equivalent dataflow written with theoperators:(multi-operator) form does warn.Impact
Advisory subsystem only — it does not accept an invalid dataflow or reject a valid one; the dataflow still runs. But the type-annotation feature (mismatch warnings + inference) silently covers only part of the graph, and
--strictemits an incorrect "no type annotation" message for these edges.Suggested fix
Either run the annotation check after
resolve_aliases_and_set_defaults()(so consumer outputs are already op-prefixed), or normalize the single-operator consumer key at lookup time incheck_edge_mismatches_with_compat— apply the same{op_id}/{output}prefixing the resolver uses whenmapping.sourceis a single-operator node before probingoutput_type_map. A regression test covering anoperator:-sourced edge (both mismatch and inference cases) would lock this in; the existing tests appear to exercise only theoperators:form.