Skip to content

Reject undeclared task arguments at validation time (v0.1.13) - #56

Merged
Volv-G merged 1 commit into
masterfrom
piforge/evaluate-fix/tangle-cli-reject-undeclared-tas-cd23a0c
Sep 12, 2026
Merged

Volv-G merged 1 commit into
masterfrom
piforge/evaluate-fix/tangle-cli-reject-undeclared-tas-cd23a0c

Conversation

@Volv-G

@Volv-G Volv-G commented Sep 12, 2026

Copy link
Copy Markdown
Collaborator

The bug

An undeclared task argument — a key in arguments that 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.

layer sees component schema? verdict on an undeclared argument
Python SDK trace/compile no — ref(url=…) dereferenced only at hydrate tolerates
Subpipeline compile (_validate_subpipeline_inputs) yes rejects — but only for @pipeline children
Dehydrated JSON schema (TaskSpec.arguments) n/a — free-form map tolerates
Submit / deploy (validate_pipeline_for_run) yes (runs after hydration) tolerates ← this PR
Execution n/a — no consumer for the key tolerates
Tangle UI editor / clone yes REJECTS → clone blocked

This was fail-open, not missing information. pipeline_runner.py hydrates (~343) and validates (~402), so componentRef.spec is inlined and readable by the time validation 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.

Real-world impact

Two live instances were just fixed by hand in Shopify/discovery — https://github.com/Shopify/discovery/pull/34616:

  • snapshot_date on Evaluate — a dead/renamed key (the component declares merchant_match_reference_snapshot_date). Blocks UI clone across 20+ compiled daily-pulse targets.
  • wait instead of timeout on 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 --wait flag 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 Evaluate spec declaring only run_id + merchant_match_reference_snapshot_date; the task passes run_id, a dead snapshot_date, and an invented totally_made_up_arg:

# before
validate_component_inputs(spec)    # -> []
collect_pipeline_spec_errors(spec) # -> []

# after
Task 'evaluate': argument 'snapshot_date' is not a declared input of component 'Evaluate'.
  Did you mean 'merchant_match_reference_snapshot_date'? Declared inputs: [...]
Task 'evaluate': argument 'totally_made_up_arg' is not a declared input of component 'Evaluate'.
  Declared inputs: [...]

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 real PipelineHydrator:

  • 2 of 21 pipelines newly fail; 9 errors total.
  • competitor_v3_google_vs_shop — 5 (wait ×1, snapshot_date ×4)
  • competitor_v2_google_vs_shop — 4 (wait ×1, snapshot_date ×2, plus run_id_prefix on daily pulse subset scrape, which the manual audit had missed)
  • 19 clean, zero false positives — the digest/name-pinned pipelines all hit the skip path as designed.

Once #34616 lands the snapshot_date rows disappear. The Python-authored pipelines (daily_pulse*.py) were not measurable without the tangle-deploy toolchain; 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:

case behaviour why
unhydrated url: ref, digest-pinned or name-pinned component whose spec isn't inlined skip _get_component_spec returns None; the existing early return covers it
inputs: present but malformed (not a list) skip declared set is untrustworthy → false positives
inputs: [] well-formed and empty error a component declaring no inputs genuinely accepts no arguments
reserved/metadata keys inside arguments no allowlist needed the vendored pipeline_schema.json keeps annotations, executionOptions and isEnabled as siblings of arguments; 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 @pipeline children — 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 deliberately: collect_pipeline_spec_errors is error-only, and the compiler's warnings list is a separate CompileResult surface 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_tablebq-table), a difflib near match for ordinary typos, then token containment (snapshot_datemerchant_match_reference_snapshot_date). A semantic rename with no lexical overlap (waittimeout) 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, empty inputs → 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, passed arguments: {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.120.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.

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
Volv-G requested a review from Ark-kun as a code owner September 12, 2026 13:25
@Volv-G
Volv-G merged commit f208e9b into master Sep 12, 2026
6 checks passed
@Volv-G
Volv-G deleted the piforge/evaluate-fix/tangle-cli-reject-undeclared-tas-cd23a0c branch September 12, 2026 13:30
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant