Pipeline Validation
Running spade check <pipeline.yaml> validates your pipeline file against a set of rules before execution. This catches structural errors, missing blocks, broken references, and type mismatches early -- before any blocks actually run.
A valid pipeline produces output like:
Pipeline "satellite-reproject" is valid.(If this is the first time a short-code pipeline is checked, spade check prints an extra Wrote <pipeline-stem>.lock.yaml line first, since that's also when the lockfile gets created.)
An invalid pipeline produces the batched error list instead:
Pipeline validation failed with 2 error(s):
- <first error>
- <second error>Every line in that list is exactly error.Error() for one entry in the slice core.ValidatePipeline returns -- there is no separate human-friendly formatting layer. The examples below use the real strings the validator produces (see core/pipeline.go), wrapped exactly the way cli/cmd/check.go prints them.
Validation rules🔗
Spade checks the following seven core rules, in order, plus two additional rule sets that only apply if your pipeline uses short codes or map/reduce (see Additional validation for short codes and map/reduce below). Each core rule is described below with an explanation and an example of a pipeline that violates it.
Rule 1: Unique invocation IDs🔗
Every block invocation in the pipeline must have a unique id. No two blocks may share the same invocation ID.
Why this matters: Invocation IDs are used to reference blocks in inputs lists. Duplicate IDs would make references ambiguous, and Spade would not know which invocation to use as the data source.
Example of a violation:
name: duplicate-id-example
version: "1.0"
blocks:
- id: "@fetch" # <-- same ID
name: data.census_acs
inputs: []
args:
year: 2022
dataset: "acs5"
table: "B01003"
geography: "state:*"
variables: ""
- id: "@fetch" # <-- same ID
name: fiadb.parameters
inputs: []
args:
name: "snum"Real error (duplicate block invocation id: %s in core/pipeline.go):
Pipeline validation failed with 1 error(s):
- duplicate block invocation id: 019cf4bc-1111-7000-0000-000000000000Note that the message is intentionally terse -- it names the offending UUID only, not the two block names that collided.
Rule 2: All referenced IDs exist🔗
Every invocation ID referenced in an inputs list must correspond to an actual block invocation in the pipeline. This applies to both bare references and the block key in explicit references.
Why this matters: A reference to a non-existent ID means the block is expecting data from a step that does not exist. This is usually caused by a typo or a deleted block.
Example of a violation:
name: broken-ref-example
version: "1.0"
blocks:
- id: "@source"
name: data.read
inputs: []
args:
uri: "s3://example-bucket/raw.parquet"
format: "Parquet"
- id: "@filtered"
name: base.filter_rows
inputs:
- "@deleted-block" # <-- does not exist
args:
expression: "state = 'ME'"Real error: this rule and the acyclic-graph check (Rule 4) both independently notice the same broken reference, so a single typo produces two entries in the batched list -- one from ValidatePipeline's own loop (block %s references unknown invocation id %s), and a second from BuildDependencyGraph, which ValidatePipeline calls as part of Rule 4 and which does its own existence check while building edges (block %s references unknown dependency %s):
Pipeline validation failed with 2 error(s):
- block 019cf4bc-2222-7000-0000-000000000000 references unknown invocation id 019cf4bc-9999-7000-0000-000000000000
- block 019cf4bc-2222-7000-0000-000000000000 references unknown dependency 019cf4bc-9999-7000-0000-000000000000Because BuildDependencyGraph failing is treated as fatal (there's no valid graph to check further rules against), ValidatePipeline returns immediately after this -- rules 5, 6, 7, and the map/reduce checks never run in the same invocation when a reference is broken this way.
Rule 3: Block names refer to installed blocks🔗
Every name field in the blocks list must refer to a block that is installed in the local Spade environment. The name uses collection.block format, and both the collection and the specific block must be present.
Why this matters: If Spade cannot find the block definition, it cannot determine the block's inputs, outputs, or how to execute it.
Example of a violation:
name: missing-block-example
version: "1.0"
blocks:
- id: "@source"
name: data.read
inputs: []
args:
uri: "s3://example-bucket/raw.tif"
format: "GeoTIFF"
- id: "@transform"
name: gdal.fancy-transform # <-- not a real block
inputs:
- "@source"
args: {}Real behavior is split across two layers here, and it's worth knowing both:
core.ValidatePipeline itself has a dedicated rule for this (block %s references unknown block type %q) that would appear in the batched list like any other rule -- if it ever ran. In practice, spade check <pipeline.yaml> almost never reaches it: before calling ValidatePipeline at all, cli/cmd/check.go looks up every block name in the local registry to load its manifest, and returns immediately on the first failure:
Error: block type "gdal.fancy-transform" not found in registry: record not foundThis is a plain top-level error (via cobra's default Error: ... printing), not one entry in a Pipeline validation failed with N error(s) list -- and because check.go returns as soon as this happens, no other rule gets a chance to run in the same invocation, even if the rest of the pipeline also has problems.
If you call core.ValidatePipeline directly with a manifest map that's missing an entry (for example, from a different tool built on the core library), you'd see the batched form instead:
Pipeline validation failed with 1 error(s):
- block 019cf4bc-2222-7000-0000-000000000000 references unknown block type "gdal.fancy-transform"Rule 4: Dependency graph is acyclic🔗
The dependency graph formed by inputs references must be a directed acyclic graph (DAG). In other words, there must be no circular dependencies where Block A depends on Block B, which depends on Block C, which depends on Block A.
Why this matters: Spade executes blocks by running dependencies first. A cycle means no block in the cycle can run before the others, creating a deadlock. Processing pipelines are inherently feedforward -- data flows from sources to sinks.
Example of a violation:
name: cycle-example
version: "1.0"
blocks:
- id: "@process-a"
name: gdal.warp
inputs:
- "@process-c" # depends on Block 3
args:
target_crs: "EPSG:4326"
resolution: 0
resampling: "bilinear"
output_format: "GTiff"
- id: "@process-b"
name: gdal.sieve
inputs:
- "@process-a" # depends on Block 1
args:
threshold: 10
connectedness: 8
- id: "@process-c"
name: gdal.fill_nodata
inputs:
- "@process-b" # depends on Block 2
args:
max_distance: 100
smoothing_iterations: 0Real error (bare cycle detected in dependency graph, with no interpolation at all) -- and it actually appears twice:
Pipeline validation failed with 2 error(s):
- cycle detected in dependency graph
- cycle detected in dependency graphThis duplication is real, not a copy-paste mistake in this doc: ValidatePipeline runs graph.TopologicalSort() directly as Rule 4 and appends the error, but does not stop there -- it continues on to the remaining rules (unlike a totally unknown dependency ID, which does stop everything early in Rule 2 above, since there's no valid graph left to check further rules against). Validation then reaches the map/reduce structural checks at the end of ValidatePipeline, which call BuildContextTree unconditionally, even when the pipeline has no map or reduce blocks at all -- and the very first thing BuildContextTree does is call TopologicalSort() again on the same graph, which fails the same way and appends the identical message a second time. If your pipeline's cycle happens to also break rules 5, 6, or 7, you'll see those errors sandwiched between the two identical cycle lines.
Rule 5: Input/output type compatibility🔗
When two blocks are connected (one listed in the other's inputs), the upstream block's output type must be compatible with the downstream block's input type. For bare references, at least one unambiguous type match must exist. For explicit references that use as, the named input's type must be compatible with the named output's type.
Type compatibility is checked at the coarse type field only (file, collection, json, string, number, boolean, expansion, directory) -- the more specific format annotation you see in manifests (GeoTIFF, CSV, Parquet, GeoJSON, ...) is documentation only and is never compared by the validator. A block declaring a file/CSV input is, as far as spade check is concerned, fully compatible with any upstream file/GeoTIFF output. If a block reads the wrong format, that fails at runtime inside the block's handler, not during validation.
Why this matters: Type checking prevents runtime failures where a block receives data of a structurally incompatible kind (a JSON summary where a file collection was expected, for instance). It does not, however, protect you from wiring together files of the wrong format -- that's on you (or the block's own input validation) to catch.
Example of a violation:
name: type-mismatch-example
version: "1.0"
blocks:
- id: "@snum-dictionary"
name: fiadb.parameters
inputs: []
args:
name: "snum"
# fiadb.parameters produces output "dictionary", type: json
- id: "@upload"
name: data.write_collection
inputs:
- block: "@snum-dictionary"
output: dictionary
as: files
args:
uri: "s3://example-bucket/uploads/"
overwrite: false
# data.write_collection's "files" input is type: collectionReal error (explicit reference to %q: type %q not compatible with output type %q, wrapped as block %s (%s): %w):
Pipeline validation failed with 1 error(s):
- block 019cf4bc-2222-7000-0000-000000000000 (data.write_collection): explicit reference to "files": type "collection" not compatible with output type "json"Rule 6: Named outputs match declarations🔗
When an explicit reference uses the output key, the named output must actually exist in the upstream block's manifest. If the upstream block does not declare an output with that name, validation fails.
Why this matters: An explicit reference to a non-existent output is an error -- there would be no data to wire. This is often caused by a typo in the output name.
Example of a violation:
name: bad-output-name-example
version: "1.0"
blocks:
- id: "@source"
name: data.read
inputs: []
args:
uri: "s3://example-bucket/raw.parquet"
format: "Parquet"
- id: "@filtered"
name: base.filter_rows
inputs:
- "@source"
args:
expression: "state = 'ME'"
# base.filter_rows declares exactly one output: "result"
- id: "@next"
name: base.select_columns
inputs:
- block: "@filtered"
output: filtered # <-- "filtered" is not a declared output
args:
columns: "county_fips"
mode: keepReal error (block %s has no output named %q, wrapped as block %s (%s): %w):
Pipeline validation failed with 1 error(s):
- block 019cf4bc-2222-7000-0000-000000000000 (base.select_columns): block 019cf4bc-1111-7000-0000-000000000000 has no output named "filtered"Note the message names the upstream block's ID a second time inside the wrapped text -- it isn't repackaged into a friendlier "available outputs: ..." style message.
Rule 7: Required args are present🔗
Every scalar input (string, number, or boolean) declared in a block's manifest must have a corresponding entry in the args map of the pipeline invocation. This is unconditional: core's InputDeclaration type has no default-value or required/optional field at all, so a description that mentions a "default" (for example, gdal.warp's resampling: "Default nearest") is documentation only -- it is not read or enforced by validation or by the block runtime. If the key is missing from args, validation fails regardless of what the manifest's description text says.
Why this matters: If a required parameter is missing, the block handler will fail at runtime when it tries to read the parameter from params.yaml.
Example of a violation:
name: missing-args-example
version: "1.0"
blocks:
- id: "@source"
name: data.read
inputs: []
args:
uri: "s3://example-bucket/raw.parquet"
format: "Parquet"
- id: "@filtered"
name: base.filter_rows
inputs:
- "@source"
args: {}
# Missing required arg: expression
- id: "@reproject"
name: gdal.warp
inputs:
- "@filtered"
args:
resolution: 0
resampling: "bilinear"
output_format: "GTiff"
# Missing required arg: target_crsReal error (block %s missing required arg %q):
Pipeline validation failed with 2 error(s):
- block 019cf4bc-1111-7000-0000-000000000000 missing required arg "expression"
- block 019cf4bc-2222-7000-0000-000000000000 missing required arg "target_crs"Additional validation for short codes and map/reduce🔗
These rules only apply to pipelines that use the relevant feature -- they run in addition to the seven core rules above.
Short codes🔗
If a pipeline uses @-prefixed short codes instead of UUIDs, spade check additionally verifies:
- Grammar -- every short code matches
@[A-Za-z_][A-Za-z0-9_]*. - Resolution -- every short code referenced in
inputsis defined as theidof some block in the pipeline. - Uniqueness -- no two blocks share the same short code (a repeat resolves to the same UUID, which then trips Rule 1 above).
- Lockfile validity -- every binding in the sibling
.lock.yamlis a valid UUIDv7, and every bound short code that's still referenced in the source exists.
See Short Codes and Hand-Authoring for the full detail and error message examples.
Map/reduce🔗
If a pipeline contains kind: map or kind: reduce blocks, spade check additionally verifies:
- Map blocks output
expansion-- akind: mapblock must declare at least one output of typeexpansion. Real error:map block %s (%s) must have an expansion output. - Reduce blocks accept
collection-- akind: reduceblock must declare at least one input of typecollection. Real error:reduce block %s (%s) must have a collection input. - Every map context is closed by a reduce -- a fan-out that never reaches a matching
kind: reduceblock is rejected. Real error:map block %s (%s) has no reduce block closing its context. - Contexts are well-nested -- a block may not combine the outputs of two sibling map contexts unless at least one has already been closed by its reduce.
- Nesting depth does not exceed 4 levels -- since invocation counts multiply at each nesting level, this bounds worst-case fan-out.
See Map/Reduce Pipelines and Nested map/reduce for the full mechanics.
Summary of validation rules🔗
| # | Rule | What it checks |
|---|---|---|
| 1 | Unique invocation IDs | No two blocks share the same id |
| 2 | Referenced IDs exist | Every ID in inputs corresponds to a block in the pipeline |
| 3 | Block names installed | Every name refers to a locally installed block (in practice, spade check reports this earlier and separately from the batched list -- see Rule 3 above) |
| 4 | Acyclic graph | The dependency graph has no cycles |
| 5 | Type compatibility | Connected blocks have compatible type fields (format is not checked) |
| 6 | Named outputs exist | Explicit output references match declared outputs |
| 7 | Required args present | Every scalar parameter is present in args, unconditionally -- there is no manifest-level default |
| -- | Short codes (if used) | Grammar, resolution, uniqueness, lockfile validity -- see above |
| -- | Map/reduce (if used) | Output/input types, context closure, well-nestedness, depth ≤ 4 -- see above |
Tips for fixing validation errors🔗
- Duplicate IDs: Generate a new UUIDv7 for one of the conflicting blocks. If you used the same short code (e.g.
"@foo") on two blocks, rename one -- short codes resolve to the same UUID, so duplicates trigger this rule. - Broken references: Check for typos in the invocation ID or short code. Copy-paste the ID or short code directly from the target block.
- Missing blocks: Run
spade install <repository>to install the required collection. Remember that this error surfaces on its own, before the rest of validation runs -- fix it and re-runspade checkto see any remaining problems. - Cycles: Restructure your pipeline so data flows in one direction. If you have a feedback loop in your algorithm, consider implementing it inside a single block rather than across multiple blocks.
- Type mismatches: Check the block manifests (
spade checkwith no arguments in a collection directory) to confirm the input and outputtypefields. Rememberformatisn't enforced -- if two blocks agree ontypebut notformat, validation will pass and the mismatch will only surface as a runtime failure inside the handler. - Bad output names: Run
spade checkin the upstream block's collection to see its declared outputs, or inspect itsblocks/<name>.yamlmanifest directly. - Missing args: Check the block manifest for every scalar (
string/number/boolean) input and make sure each one has a key in yourargsmap -- a "default" mentioned in the description is not enforced anywhere. - Corrupt lockfile: If
spade checkreportsinvalid lockfile: ..., delete the sibling<pipeline-stem>.lock.yamlto regenerate bindings from scratch. See Short Codes and Hand-Authoring for the full set of lockfile rules.