Reject undeclared task arguments at validation time (v0.1.13) - #56
Merged
Volv-G merged 1 commit intoSep 12, 2026
Merged
Conversation
A task argument that no component input declares was accepted everywhere
in this repo and then rejected by the Tangle UI validator, which blocks
cloning or editing the stored run. Execution tolerates the extra key --
nothing binds it, so the pipeline runs on schedule for years -- and that
is exactly why the defect survives: it only surfaces when a human opens
the run in the UI, far from the commit that introduced it.
This was a fail-open bug, not missing information. `pipeline_runner.py`
hydrates (~343) and validates (~402), so `componentRef.spec` is inlined
and readable by the time `validate_pipeline_for_run` runs. But
`_validate_task_inputs` only iterated declared inputs -> arguments. It
never computed the reverse set difference, so extra and misspelled keys
passed silently even post-hydration. On a hydrated spec declaring
`run_id` and `merchant_match_reference_snapshot_date`, a task passing a
dead `snapshot_date` plus an invented `totally_made_up_arg` returned
`validate_component_inputs(spec) -> []`.
Compute `set(arguments) - set(declared_inputs)` beside the existing
required-input loop and report each extra key as an error, mirroring the
rule the compiler already enforces for `@pipeline` subpipeline children
in `_validate_subpipeline_inputs`. This fails closed at
`pipeline run` / deploy time -- in CI, hours to days before anyone hits
the UI-clone block.
Errors carry a nearest-match hint, because the real cases are near
misses rather than nonsense. Three deterministic passes: separator-
insensitive equality (`bq_table` -> `bq-table`), a difflib near match
for ordinary typos, then token containment (`snapshot_date` ->
`merchant_match_reference_snapshot_date`). A semantic rename with no
lexical overlap (`wait` -> `timeout`) deliberately yields no hint; a
confident wrong suggestion is worse than the declared-input list alone.
The check fails OPEN wherever the declared set is unknowable, so it can
never block a deploy over something it cannot see:
* An unresolvable component spec is skipped. Pre-hydration `url:` refs,
digest-pinned and name-pinned components whose spec is not inlined all
return nothing from `_get_component_spec`, and the existing early
return already covers them.
* A malformed, non-list `inputs:` field is skipped, since the declared
set cannot be trusted.
* A well-formed empty `inputs: []` is NOT skipped. A component that
declares no inputs genuinely accepts no arguments.
No reserved-key allowlist is needed: the vendored `pipeline_schema.json`
keeps `annotations`, `executionOptions` and `isEnabled` as siblings of
`arguments`, so `arguments` is a pure input map with no metadata keys.
Default is a hard error, matching the UI validator this exists to
anticipate, with `TANGLE_ALLOW_UNDECLARED_TASK_ARGUMENTS=1` as an escape
hatch for repositories that still carry undeclared arguments and need to
deploy before cleaning them up. The env var follows the `TANGLE_*`
boolean convention already used by `tangle_verbose_enabled` and
`TANGLE_TRUSTED_HYDRATION_ALLOW_ALL`; a warning channel was rejected
because `collect_pipeline_spec_errors` is error-only and the compiler's
`warnings` list is a separate `CompileResult` surface that submit-time
validation does not reach.
One existing test fixture passed `arguments: {config: ...}` to a
component declaring no inputs. It exercises payload sanitization, not
validation, so the fixture now declares the input and its assertions are
unchanged -- a small demonstration that the check catches real drift.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Assisted-By: devx/1bc39432-2e61-4aeb-8d86-e2f19fd326df
Volv-G
deleted the
piforge/evaluate-fix/tangle-cli-reject-undeclared-tas-cd23a0c
branch
September 12, 2026 13:30
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The bug
An undeclared task argument — a key in
argumentsthat no component input declares — was accepted by every layer in this repo, and then rejected by the Tangle UI validator, which blocks cloning or editing the stored run.Execution tolerates the extra key (nothing binds it), which is exactly why the defect survives: affected pipelines run on schedule for years, and the breakage only surfaces when a human opens the run in the UI — far from the commit that introduced it.
ref(url=…)dereferenced only at hydrate_validate_subpipeline_inputs)@pipelinechildrenTaskSpec.arguments)validate_pipeline_for_run)This was fail-open, not missing information.
pipeline_runner.pyhydrates (~343) and validates (~402), socomponentRef.specis inlined and readable by the time validation runs. But_validate_task_inputsonly iterated declared inputs → arguments. It never computed the reverse set difference, so extra and misspelled keys passed silently even post-hydration.Real-world impact
Two live instances were just fixed by hand in Shopify/discovery — https://github.com/Shopify/discovery/pull/34616:
snapshot_dateonEvaluate— a dead/renamed key (the component declaresmerchant_match_reference_snapshot_date). Blocks UI clone across 20+ compiled daily-pulse targets.waitinstead oftimeouton competitor scraping Retrieve — strictly worse. It blocks the UI and silently drops the value: the component declares{name: timeout, default: '1000'}and the container passes--timeout {inputValue: timeout}, so there is no--waitflag and the intended 1440 was silently replaced by the default 1000.That PR's "no behavior change" framing understates it — removing a dead argument restores UI cloneability. This PR moves the detection to
pipeline run/ deploy time, in CI, hours to days before anyone hits the UI-clone block.Before / after
Hydrated
Evaluatespec declaring onlyrun_id+merchant_match_reference_snapshot_date; the task passesrun_id, a deadsnapshot_date, and an inventedtotally_made_up_arg:Blast radius
Measured against real hydrated Oasis pipelines in Shopify/discovery (at
9fe02c3a12, i.e. before #34616 lands) — all 21 YAML pipelines hydrated through the realPipelineHydrator:competitor_v3_google_vs_shop— 5 (wait×1,snapshot_date×4)competitor_v2_google_vs_shop— 4 (wait×1,snapshot_date×2, plusrun_id_prefixondaily pulse subset scrape, which the manual audit had missed)Once #34616 lands the
snapshot_daterows disappear. The Python-authored pipelines (daily_pulse*.py) were not measurable without thetangle-deploytoolchain; a static audit puts them in #34616's scope.Edge cases
The check fails open wherever the declared set is unknowable, so it can never block a deploy over something it cannot see:
url:ref, digest-pinned or name-pinned component whose spec isn't inlined_get_component_specreturnsNone; the existing early return covers itinputs:present but malformed (not a list)inputs: []well-formed and emptyargumentspipeline_schema.jsonkeepsannotations,executionOptionsandisEnabledas siblings ofarguments; it is a pure input map. Confirmed empirically against the Oasis scan.Error vs warning
Hard error by default, matching the UI validator this exists to anticipate and the rule the compiler already enforces for
@pipelinechildren — withTANGLE_ALLOW_UNDECLARED_TASK_ARGUMENTS=1as an escape hatch for repositories that still carry undeclared arguments and need to deploy before cleaning them up. The env var follows theTANGLE_*boolean convention already used bytangle_verbose_enabledandTANGLE_TRUSTED_HYDRATION_ALLOW_ALL.A warning channel was rejected deliberately:
collect_pipeline_spec_errorsis error-only, and the compiler'swarningslist is a separateCompileResultsurface that submit-time validation never reaches.Hints
Errors carry a nearest-match hint, because the real cases are near misses rather than nonsense. Three deterministic passes: separator-insensitive equality (
bq_table→bq-table), adifflibnear match for ordinary typos, then token containment (snapshot_date→merchant_match_reference_snapshot_date). A semantic rename with no lexical overlap (wait→timeout) deliberately yields no hint — a confident wrong suggestion is worse than the declared-input list alone.Tests
15 new cases: undeclared → error, valid args → clean, unresolvable spec → skipped (3 parametrized ref shapes), malformed
inputs→ skipped, emptyinputs→ error, nested subgraph tasks, each hint pass, and the env-var opt-out. Full suite 1412 passed.One existing fixture,
test_pipeline_runs_submit_dry_run_prints_sanitized_payload, passedarguments: {config: …}to a component declaring no inputs. It exercises payload sanitization, not validation, so the fixture now declares the input and every assertion is unchanged — a small demonstration that the check catches real drift.Version
Patch bump
0.1.12→0.1.13(pyproject.toml,__init__.py,test_packaging.py,uv.lock), matching the convention in #55.Follow-up (not in this PR)
Shopify/discovery vendors this repo as the submodule
oasis/tangle-deploy/tangle-cli, so picking this validation up there is a separate submodule-bump PR.