Skip to content
Back to skills

Orchestration Scan

ASecurity

Airflow, Dagster, Prefect DAGs: wall-clock windows, parse-time I/O, overlap, trigger rules, retries, Airflow 3.

  • 2 stars
  • 0 votes
  • 0 copies
  • 0 views
  • Added October 20, 2026
ai-agentspythongobashsqlnodespringdockerkubernetesapidatabase

Works with

  • cli
  • api

Security analysis

A100/100

Scanned October 20, 2026

npx -y skills add adnanmokhtar/refract --skill orchestration-scan --agent claude-code

Installs into .claude/skills of the current project.

Are you the author of Orchestration Scan?

Add the live security badge to your README. It updates with every re-scan.

Security grade badge for Orchestration Scan
[![Security: A — Skills Directory](https://www.skillsdirectory.com/api/skills/adnanmokhtar-orchestration-scan/badge)](https://www.skillsdirectory.com/skills/adnanmokhtar-orchestration-scan)

More formats (shields.io, HTML) on the badges page. Keep it an A: scan every change in CI with Pro.

Download with Pro
SKILL.md
---
name: orchestration-scan
description: "Airflow, Dagster, Prefect DAGs: wall-clock windows, parse-time I/O, overlap, trigger rules, retries, Airflow 3."
kind: skill
pack: data-engineering
allowed-tools: [Read, Grep, Glob, Bash]
---

# Skill: orchestration-scan

## Premise

A data orchestrator promises that a run for an interval reads that interval and only that interval, can be replayed for a past interval with the same result, and does not publish when its inputs failed. A task that computes its window from `datetime.now()` reads "today" during a backfill of last March; a DAG file that calls an API at import time runs that call on every parse; a publish task with `trigger_rule="all_done"` republishes yesterday over a failed load; three overlapping runs interleave writes into one partition. **Airflow 3 changed defaults under existing code** (Airflow "Upgrading to Airflow 3", 3.3.2 docs, accessed 2026-10-19): `catchup_by_default` and `create_cron_data_intervals` are now `False`, so a cron string maps to `CronTriggerTimetable` and `data_interval_start == data_interval_end` — a task that filters on the interval reads **zero rows and succeeds**; `execution_date`, `prev_execution_date`, `next_execution_date` and the yesterday/tomorrow `ds` variables are gone; tasks can no longer open metadata-database sessions.

This skill owns research ids OR-1…OR-9 (`research-data-engineering.md` § 1.2), shipped as `DE-OR-<n>`: **whether a data-orchestrator run is bounded by its interval, replayable, ordered and gated on its upstream.**

**Every finding cites the DAG/asset/flow `<file:line>` + the orchestrator and its version read from a pin + the run it breaks (a backfill of a past interval, a retry, two overlapping runs, a failed upstream, the first run after the Airflow 3 upgrade) + the oracle or the test that would settle it + the fix in the orchestrator's own API.** A grep hit is a candidate. "The DAGs look fine" is never printed; the output is the class-coverage block (§ Output). Verification follows [`refute-before-report.md`](../../../../snippets/refute-before-report.md) with [`precedents/data-engineering.md`](../../../../snippets/precedents/data-engineering.md); the oracles are [`proof-oracles.md`](../../../../snippets/proof-oracles.md) § Data engineering, V540, V549–V551 and V553.

## The rule

A candidate is a finding when all four hold:

1. **Reachable.** The DAG, asset or flow is deployed: it sits in the DAGs folder the deployment mounts, a Dagster `Definitions` that the code location loads, or a Prefect deployment. An example DAG behind `load_examples` or a paused-forever prototype fails here.
2. **The version is read.** Airflow 2 vs 3, Dagster and Prefect majors come from a pin (`requirements*.txt`, `pyproject.toml`, the `apache/airflow:<tag>` image, a constraints URL). Several classes are a different verdict on each major; with no pin the row is `[report-flagged]` with the version named as the gap.
3. **No existing control, across files.** An explicit timetable, `catchup` set to what the team needs, `max_active_runs=1` or a pool of 1 on a shared partition, a short-circuit before the publish, a delete-then-insert keyed on the interval, a sensor `timeout`, an `airflow.cfg` / `AIRFLOW__…` setting that restores the old default.
4. **The oracle fired, or the fact is two-ended.** The task run for a past interval produced output that depends on the wall clock, the rendered interval is zero-width, the upstream failure did not stop the publish; or the static fact cites the version pin and the construct the version removed or redefined.

## Boundaries

- **Application schedulers** — Nest `@Cron`/`@Interval`, BullMQ repeatables, Celery beat, Sidekiq-cron, Laravel `schedule`, Spring `@Scheduled`, Kubernetes CronJobs: overlap, misfire and schedule zone → backend `job-lifecycle-scan` §4–§6; locks and leases → distributed-systems `coordination-scan`. Each when installed; without them the row is `[report-flagged]` here with the route named. **This skill reads data-orchestrator definitions only.**
- **The load the task runs** — merge key, watermark, overwrite scope → `incremental-load-scan`. §5 here is the retry that replays a non-idempotent write; whether the write itself is idempotent is `incremental-load-scan` §2's verdict, and the two rows cite each other.
- **A data test or asset check that cannot fail** → `data-test-scan`; §9 here only owns whether a failed Dagster check **blocks** downstream materialization.
- **Secrets fetched at parse time** are §2's cost and correctness row; the secret's exposure is security's.

## Adapt to the codebase — the lanes

| Lane | Detect by | Version source | What changes across versions |
|---|---|---|---|
| **Airflow** 2.x / 3.x | `from airflow`, `DAG(`, `@dag` | `apache-airflow` pin, `apache/airflow:<tag>`, `constraints-<v>` URL | AF3: `catchup` default False; cron → `CronTriggerTimetable` (zero-width interval) unless `create_cron_data_intervals` or an explicit `CronDataIntervalTimetable`; removed `execution_date` and friends, `schedule_interval`, SubDAGs, `DummyOperator`, `days_ago`; `airflow.datasets` → assets; no metadata-DB sessions in tasks; Task SDK imports (`airflow.sdk`) |
| **Dagster** 1.x | `from dagster`, `@asset`, `Definitions(` | `dagster` pin | asset checks non-blocking unless `blocking=True`; `BackfillPolicy.single_run` hands a partition **range** to one run |
| **Prefect** 2.x / 3.x | `from prefect`, `@flow`, `@task` | `prefect` pin | 3.x caching needs result persistence (off by default), so a "cached" task re-executes |
| **dbt Cloud / cron-run dbt** | job JSON, `dbt build` in a scheduler | — | the schedule's own overlap; `--full-refresh` in a schedule is `incremental-load-scan` §8 |

`ruff check --select AIR` (ruff 0.17.1, MIT, no telemetry) catches the Airflow 3 removals and moves (`AIR301`–`AIR303`); `--preview` adds `AIR304` (dynamic values in DAG arguments, which on Airflow 3 write a new DAG version per parse) and `AIR003`. Run it when installed, with `--no-cache` so nothing is written into the repo.

## Pre-flight

Run as **one** script from the repo root (bash 3.2-safe). Grep **N** feeds §N; 0 builds the orchestrator surface and reads the versions. Every `rg` names its path; include globs first, exclusions last, every exclusion `**/`-rooted; a path glob is never combined with an extension glob. Every grep ends in `ck`: rg exit 2 is a **blocked** class, never zero hits. Nothing is written outside `$OUT`.

```bash
ROOTS=(.)   # monorepo: name the orchestrator roots, e.g. ROOTS=(dags orchestration)
OUT="${OUT:-$(mktemp -d "${TMPDIR:-/tmp}/de-orch.XXXXXX")}"; mkdir -p "$OUT/none"
h() { printf '## %s\n' "$1"; }; ck() { [ "$2" -lt 2 ] || echo "$1 BLOCKED (rg exit $2) — the class is blocked, never 0 hits"; }
X=(-g '!**/node_modules/**' -g '!**/vendor/**' -g '!**/{dist,build,out,target,coverage,.venv,venv,dbt_packages,logs}/**'
   -g '!**/{test,tests,__tests__,spec,e2e,fixtures,__fixtures__,mocks,testdata,example_dags,examples}/**'
   -g '!**/test_*.py' -g '!**/*_test.py' -g '!**/conftest.py' -g '!**/{docs,ai,.claude}/**')
NC=(rg -v --pcre2 '^[^:]+:\d+:\s*#')
LOGL=(rg -v --pcre2 '\b(?:log(?:ger|ging)?|LOG|print)\b\s*[.(]|\.(?:info|debug|warning|error|exception)\(')
# 0 THE SURFACE — 0a Airflow, 0b Dagster, 0c Prefect files; 0d versions; 0e Airflow config that restores old defaults
h 0a; rg -l --pcre2 '^\s*(?:from|import)\s+airflow\b|\bDAG\s*\(|@dag\b' -g '*.py' "${X[@]}" "${ROOTS[@]}" | tee "$OUT/AF.txt" | wc -l; ck 0a ${PIPESTATUS[0]}
h 0b; rg -l --pcre2 '^\s*(?:from|import)\s+dagster\b' -g '*.py' "${X[@]}" "${ROOTS[@]}" | tee "$OUT/DG.txt" | wc -l; ck 0b ${PIPESTATUS[0]}
h 0c; rg -l --pcre2 '^\s*(?:from|import)\s+prefect\b' -g '*.py' "${X[@]}" "${ROOTS[@]}" | tee "$OUT/PF.txt" | wc -l; ck 0c ${PIPESTATUS[0]}
cat "$OUT/AF.txt" "$OUT/DG.txt" "$OUT/PF.txt" | sort -u > "$OUT/ORCH.txt"
O=(); while IFS= read -r f; do O+=("$f"); done < "$OUT/ORCH.txt"; [ ${#O[@]} -gt 0 ] || O=("$OUT/none")
A=(); while IFS= read -r f; do A+=("$f"); done < "$OUT/AF.txt"; [ ${#A[@]} -gt 0 ] || A=("$OUT/none")
h 0d; rg -n --pcre2 '(?i)\b(?:apache-airflow|dagster|prefect)(?:\[[^\]]*\])?["\x27]?\s*(?:==|>=|~=|<=|=|\^|:)\s*["\x27]?\d+[\d.]*|apache/airflow:\d[\w.-]*|constraints-\d+\.\d+\.\d+' -g '*.{txt,in,toml,cfg,lock,yaml,yml}' -g '**/Dockerfile*' -g '!**/node_modules/**' "${ROOTS[@]}"; ck 0d $?
h 0e; rg -n --pcre2 '(?i)\bcreate_cron_data_intervals\b|\bcatchup_by_default\b|AIRFLOW__SCHEDULER__(?:CREATE_CRON_DATA_INTERVALS|CATCHUP_BY_DEFAULT)' -g '*.{cfg,ini,env,yaml,yml,toml,py,sh}' -g '**/Dockerfile*' -g '!**/node_modules/**' "${ROOTS[@]}"; ck 0e $?
# 1 OR-1 wall-clock windows — clock reads in orchestrator code and operator SQL, logging lines dropped (read: does it select data or name a partition?)
h 1; rg -n --pcre2 '\b(?:datetime\.(?:now|utcnow|today)|pendulum\.(?:now|today)|date\.today|timezone\.utcnow|time\.time)\s*\(|(?i:\bcurrent_date\b|\bcurrent_timestamp\b|\bnow\(\)|\bgetdate\(\)|\bsysdate\b)' "${O[@]}" | "${NC[@]}" | "${LOGL[@]}" | rg -v --pcre2 '\bstart_date\s*='; ck 1 ${PIPESTATUS[0]}
# 2 OR-2 parse-time work — 2a module-level calls that do I/O (AST: outside any def/lambda/class); 2b dynamic start_date or DAG arguments
h 2a; [ -s "$OUT/ORCH.txt" ] && python3 -I -B - "$OUT/ORCH.txt" <<'PY'
import ast, re, sys
IO = re.compile(r'(^|\.)(Variable\.get|get_connection|get_secret\w*|urlopen|create_engine|connect|execute|query|read_sql\w*|'
                r'read_csv|read_parquet|read_json|client|resource|listdir|scandir|glob|sleep|run|check_output)$|^(requests|httpx|boto3|'
                r'subprocess|psycopg2?|pymysql|snowflake\.connector|google\.cloud\.\w+)\.|^open$')
for path in open(sys.argv[1]).read().split('\n'):
    if not path:
        continue
    try:
        src = open(path, encoding='utf-8', errors='replace').read()
        tree = ast.parse(src)
    except (OSError, SyntaxError) as e:
        print('%s: NOT PARSED (%s) — read by hand' % (path, type(e).__name__))
        continue
    stack = list(ast.iter_child_nodes(tree))
    while stack:
        node = stack.pop()
        if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.Lambda, ast.ClassDef)):
            continue
        if isinstance(node, ast.Call):
            name = ast.get_source_segment(src, node.func) or ''
            if IO.search(name) and not re.search(r'environ\.get$|getenv$', name):
                print('%s:%d: parse-time call %s(…)' % (path, node.lineno, name))
        stack.extend(ast.iter_child_nodes(node))
PY
h 2b; rg -n --pcre2 '\bstart_date\s*=\s*(?:datetime\.(?:now|utcnow|today)|pendulum\.(?:now|today)|days_ago|timezone\.utcnow|datetime\.combine\(\s*(?:date\.today|datetime\.now))|\b(?:dag_id|tags|description)\s*=\s*f["\x27][^"\x27\n]*\{(?:datetime|pendulum|time)\.' "${O[@]}" | "${NC[@]}"; ck 2b ${PIPESTATUS[0]}
# 3 OR-3 overlap and catchup — 3a concurrency > 1, catchup on, depends_on_past; 3b DAG files that never state catchup (read with the version)
h 3a; rg -n --pcre2 '\bmax_active_runs\s*[=:]\s*(?:[2-9]|\d{2,})\b|\bcatchup\s*=\s*True\b|\bdepends_on_past\b["\x27]?\s*[=:]\s*True\b' "${A[@]}" | "${NC[@]}"; ck 3a ${PIPESTATUS[0]}
h 3b; [ -s "$OUT/AF.txt" ] && tr '\n' '\0' < "$OUT/AF.txt" | xargs -0 rg -l --pcre2 '\bDAG\s*\(|@dag\b' | tr '\n' '\0' | xargs -0 -r rg --files-without-match '\bcatchup\b'
# 4 OR-4 publish regardless of upstream — trigger rules that run after a failure
h 4; rg -n --pcre2 '\btrigger_rule\s*=\s*(?:["\x27](?:all_done|none_failed\w*|always|one_success|one_done|all_skipped)["\x27]|TriggerRule\.(?:ALL_DONE|ALWAYS|NONE_FAILED\w*|ONE_SUCCESS|ONE_DONE|ALL_SKIPPED))' "${A[@]}" | "${NC[@]}"; ck 4 ${PIPESTATUS[0]}
# 5 OR-5 retries around writes — 5a retries > 0 (task, default_args, Prefect); 5b Prefect caching without persisted results
h 5a; rg -n --pcre2 '\bretries\b["\x27]?\s*[=:]\s*[1-9]' "${O[@]}" | "${NC[@]}"; ck 5a ${PIPESTATUS[0]}
h 5b; [ -s "$OUT/PF.txt" ] && tr '\n' '\0' < "$OUT/PF.txt" | xargs -0 rg -l --pcre2 '\bcache_policy\s*=|\bcache_key_fn\s*=' | tr '\n' '\0' | xargs -0 -r rg --files-without-match --pcre2 '\bpersist_result\s*=\s*True|PREFECT_RESULTS_PERSIST_BY_DEFAULT'
# 6 OR-6 sensors with no timeout (one nesting level of parentheses read), poke mode, clock-offset dependencies
h 6; rg -n -U --pcre2 '\b\w+Sensor(?:Async)?\s*\((?:(?!\btimeout\s*=)[^()]|\((?:[^()]|\([^()]*\))*\))*\)|@task\.sensor\((?:(?!\btimeout\s*=)[^()])*\)|\bmode\s*=\s*["\x27]poke["\x27]' "${A[@]}" | "${NC[@]}"; ck 6 ${PIPESTATUS[0]}
# 7 OR-7 Airflow 3 removals (finding only when 0d pins Airflow >= 3); ruff AIR3 when installed (--no-cache: nothing written)
h 7a; rg -n --pcre2 '\{\{\s*(?:execution_date|next_execution_date|prev_execution_date(?:_success)?|tomorrow_ds\w*|yesterday_ds\w*|prev_ds\w*|next_ds\w*)\b|\bexecution_date\b|\bschedule_interval\s*=|from\s+airflow\.(?:operators\.(?:subdag|dummy\w*)|datasets|utils\.dates)\b|\b(?:SubDagOperator|DummyOperator|days_ago)\b|\bprovide_session\b|\bcreate_session\s*\(|\bsettings\.Session\b' "${A[@]}" | "${NC[@]}"; ck 7a ${PIPESTATUS[0]}
h 7b; if command -v ruff >/dev/null 2>&1 && [ -s "$OUT/AF.txt" ]; then tr '\n' '\0' < "$OUT/AF.txt" | xargs -0 ruff check --no-cache --select AIR3 --output-format concise; else echo '7b NOT-RUN (ruff absent or no Airflow file)'; fi
# 8 OR-8 zero-width interval — cron or preset schedule ∩ data_interval use ∩ no explicit interval timetable (0e can restore the AF2 default)
h 8; [ -s "$OUT/AF.txt" ] && tr '\n' '\0' < "$OUT/AF.txt" | xargs -0 rg -l --pcre2 '\bschedule\s*=\s*["\x27](?:@(?:hourly|daily|weekly|monthly|quarterly|yearly|annually)|[0-9*/,\- ]{9,})["\x27]' | tr '\n' '\0' | xargs -0 -r rg -l --pcre2 '\bdata_interval_(?:start|end)\b' | tr '\n' '\0' | xargs -0 -r rg --files-without-match --pcre2 'CronDataIntervalTimetable|create_cron_data_intervals'
# 9 OR-9 Dagster — 9a single-run backfill ∩ partition_key; 9b asset checks with no blocking=True (read: does the asset have dependants?)
h 9a; [ -s "$OUT/DG.txt" ] && tr '\n' '\0' < "$OUT/DG.txt" | xargs -0 rg -l 'BackfillPolicy\.single_run' | tr '\n' '\0' | xargs -0 -r rg -H -n --pcre2 '\bcontext\.(?:partition_key|asset_partition_key_for_output)\b'
h 9b; rg -n -U --pcre2 '@(?:multi_)?asset_check\((?:(?!\bblocking\s*=\s*True)[^()]|\([^()]*\))*\)' -g '*.py' "${X[@]}" "${ROOTS[@]}"; ck 9b $?
```

### Measured (2026-10-19)

- **Scratch sample** (three Airflow files — an Airflow 3 DAG with seeded OR-1…OR-8, an Airflow 3 decoy with an explicit `CronDataIntervalTimetable`, `catchup=False`, a pendulum literal start and a `datetime.now()` inside a log line, an Airflow 2 file with removed constructs; a Dagster file; a Prefect file; `apache-airflow==3.0.2`): under 1 s, 0 `rg` errors, `find -newer` empty. 0a 3 · 0b 1 · 0c 1. **§1** 1 (the callable's `datetime.now().date()` selecting a day; the decoy's log line is dropped by `LOGL`). **§2** 2a 1 (`Variable.get` at module level — the AST pass, which a column-0 regex misses inside `with DAG(…)` bodies); 2b 1 (`days_ago`). **§3** 3a 2 (`max_active_runs=3`, `catchup=True` in the AF2 file), 3b 1 (the AF3 DAG never states `catchup`). **§4** 1 (`all_done` publish). **§5** 5a 3 (`retries=3` on the insert task, the decoy's `retries=2` around a delete-then-insert — read and refute, the Prefect `retries=3` around a POST), 5b 1 (Prefect `cache_policy` with no persisted results). **§6** 1 (`ExternalTaskSensor` with `mode="poke"` and no timeout — one line covers both). **§7** 7a 5 (`execution_date` template, `schedule_interval`, `airflow.operators.dummy`, `days_ago` twice — the AF2 file's rows are findings only because 0d pins Airflow 3). **§8** 1 (the cron DAG that renders `data_interval_start/end`; the decoy is excluded by its timetable). **§9** 9a 1, 9b 1 (the non-blocking check; the `blocking=True` one is excluded).
- **Fixtures** (26 trees): 0 `rg` errors; 0a/0b/0c 0 on every tree — no data orchestrator in any fixture, so every § prints `N/A (no Airflow, Dagster or Prefect file)`. Application schedulers in `ds-defects`, `backend-defects` and `slow-marketplace` are backend's (§ Boundaries).
- **Real repo, read-only** (the NestJS monorepo, `ROOTS=(apps/api/src)`): 0a/0b/0c 0 — no data orchestrator; its 70 `@Cron`/`@Interval`/`@Processor` files are backend `job-lifecycle-scan`'s. **Fixture-measured only**: no real Airflow, Dagster or Prefect repo is in the program's allowed set (eval O-2).

## The 3,000-hit path

1. **Read the versions first** (0d, 0e): one Airflow pin decides every §7 row and every §8 row at once; an `airflow.cfg` with `create_cron_data_intervals = True` closes §8 repo-wide with one cited line.
2. **Group by DAG file**, then by construct: one `default_args` with `retries` covers every task that inherits it; one shared factory module that builds DAGs is one decision.
3. **Rank** DAGs that write consumed tables (marts, exports, reverse-ETL pushes) above maintenance DAGs; publish and reverse-ETL tasks first for §4/§5.
4. **Read the top 30 groups fully** (the callable, its SQL, the schedule, the interval it reads).
5. **Prefer the bulk oracle**: a DagBag import test on the pinned Airflow and one `dags test <dag> <past-date>` per DAG in a throwaway `AIRFLOW_HOME` under `$OUT` (V549) answers §1/§2/§7/§8 for every DAG in one pass.
6. **Ledger the rest** as `Live, unreviewed (k groups / n hits)` with the command that continues them.

## Scans for

Severity assumes the DAG writes a table or a destination something reads. One step lower for a DAG that only notifies or cleans up. §7 is high on Airflow ≥ 3 and low on a pinned Airflow 2 (a migration note).

| § | Class (`DE-` id) | Default severity | Oracle |
|---|---|---|---|
| 1 | OR-1 task window from the wall clock | high | V549 |
| 2 | OR-2 parse-time I/O or a dynamic `start_date` / DAG argument | high | V549 |
| 3 | OR-3 overlapping runs on shared partitions; catchup implicit | medium | V550 |
| 4 | OR-4 publish runs regardless of upstream | high | V551 |
| 5 | OR-5 retries or caching around a non-idempotent write | high | V540 |
| 6 | OR-6 sensor with no timeout; clock-offset cross-DAG dependency | medium | V551 |
| 7 | OR-7 Airflow 3 removals still used | high (AF ≥ 3) / low (AF 2) | V549 |
| 8 | OR-8 Airflow 3 cron schedule + interval filter = zero-width window | high | V549 |
| 9 | OR-9 Dagster single-run backfill reading one partition key; non-blocking check on a critical asset | high | V550, V553 |

### 1. Task window from the wall clock

```
BAD:   def load(**ctx): day = datetime.now().date(); run(f"… WHERE day = '{day}'")
GOOD:  def load(data_interval_start=None, data_interval_end=None): run(sql, (data_interval_start, data_interval_end))
```
**Read the code:** Does the clock value select data, name a partition or set a file path — or only log or name a temp file (precedent § 13)? Is the task ever re-run for a past interval (backfill, clear-and-rerun, catchup)? **Finding when** the output of a run for interval I depends on when it ran. **Proof:** V549 — run the task for a past interval twice on different days (or with a frozen clock): identical output, bounded by I.

### 2. Parse-time I/O, or a dynamic `start_date`

```
BAD:   API_KEY = Variable.get("shop_api_key")                    # every parse, every scheduler loop
       DAG("orders", start_date=days_ago(1)) · dag_id=f"x_{datetime.now():%Y%m%d}"
GOOD:  read the Variable inside the task, or as a "{{ var.value.shop_api_key }}" template; start_date=pendulum.datetime(2024, 1, 1, tz="UTC")
```
**Read the code:** Is the call inside a `def` (runs at task time) or at module level, including inside a `with DAG(…)` body (runs at parse)? Does the call touch the network, a database, the metadata DB or a secrets backend? Is `start_date` or any DAG argument computed from the clock (on Airflow 3 a new DAG version per parse; on any version a moving `start_date` means runs never trigger)? Constants and imports are not this class. **Finding when** parsing the file performs I/O or produces a different DAG on each parse. **Proof:** V549 — two re-serializations produce the same DAG hash; the DagBag import test passes with the network blocked.

### 3. Overlapping runs on shared partitions; catchup implicit

**Read the code:** Do two runs of this DAG write the same table or partition (a full refresh, a delete+insert on a key not scoped to the interval)? Is `max_active_runs` > 1 (or unset with a deployment default > 1)? Is catchup stated, and is the stated value what the team needs on this major — Airflow 2 defaulted to `True` (a flood of history on unpause), Airflow 3 to `False` (silent gaps after a pause; precedent § 14)? Does order matter (`depends_on_past`)? **Finding when** two runs can interleave writes into one partition, or the version's catchup default backfills or skips intervals the team relies on. **Proof:** V550 — two overlapping runs (or a range backfill) on one partition: no duplicates, every partition written exactly once.

### 4. Publish runs regardless of upstream

```
BAD:   publish = PythonOperator(task_id="publish", …, trigger_rule="all_done")   # runs after a failed load
GOOD:  default all_success on publish; cleanup tasks keep all_done
```
**Read the code:** Is the task downstream of a load or transform, and does it publish (reverse-ETL, a file to a partner, a cache swap, a dashboard refresh, a notification that numbers are ready)? A cleanup or alert task with `all_done` is correct. **Finding when** a failed upstream lets a publish run on stale or partial data. **Proof:** V551 — fail the upstream in a test run: the publish is skipped.

### 5. Retries or caching around a non-idempotent write

**Read the code:** What does the retried callable write (an `INSERT` without a conflict clause, `to_sql(append)`, a POST without an idempotency key, a message publish)? Does a failure after the write and before success trigger the retry? Does `incremental-load-scan` §2 already rate the write idempotent (then this is refuted with that row cited)? For Prefect 3, does a `cache_policy` rely on results that are not persisted? A side effect keyed by an idempotency key is not this class. **Finding when** a retry or a cache miss repeats a side effect. **Proof:** V540 — inject a failure after the write; retry; count the side effects (one).

### 6. Sensors with no timeout; dependencies by clock offset

**Read the code:** Does every sensor set `timeout` (and `mode="reschedule"` or a deferrable variant for long waits, so it does not hold a worker slot)? Does a DAG depend on another by scheduling an hour later instead of a sensor, an asset or a dataset trigger? **Finding when** an upstream that never arrives holds a slot forever or lets the downstream run on stale data. **Proof:** V551 — the sensor's upstream never arrives: the sensor times out and alerts; the downstream does not run.

### 7. Airflow 3 removals still used

**Read the code:** Which Airflow does 0d pin? On ≥ 3: `execution_date` and the yesterday/tomorrow/prev/next `ds` templates, `schedule_interval=`, `SubDagOperator`, `DummyOperator`, `days_ago`, `airflow.datasets`, metadata-DB sessions inside tasks (`provide_session`, `settings.Session`). On a pinned Airflow 2 the same rows are a migration note (low). Prefer `ruff --select AIR3` output (7b) where installed. **Finding when** the pinned version no longer provides the construct. **Proof:** V549 — the DagBag import test on the pinned version reports no import error, and the task renders.

### 8. Cron schedule + interval filter = a zero-width window

```
BAD:   DAG("orders", schedule="0 2 * * *")  +  WHERE updated_at >= '{{ data_interval_start }}' AND updated_at < '{{ data_interval_end }}'
GOOD:  schedule=CronDataIntervalTimetable("0 2 * * *", timezone="UTC")  — or filter on [logical_date - 1 day, logical_date)
```
**Read the code:** Is Airflow ≥ 3 pinned, and is `create_cron_data_intervals` left at its new default (0e)? Does the schedule string map to `CronTriggerTimetable` on that version (a cron string does; read the installed version's docs for a preset)? Does any template or callable bound its read by `data_interval_start`/`data_interval_end`? An explicit `CronDataIntervalTimetable` is the control. **Finding when** the rendered interval is zero-width and the task's read is bounded by it — it reads no rows and succeeds. **Proof:** V549 — render one run's templates: `data_interval_end > data_interval_start`.

### 9. Dagster: single-run backfill and non-blocking checks

**Read the code:** Under `BackfillPolicy.single_run()`, a backfill hands one run a **range** of partitions; does the asset read `context.partition_key` (one key) instead of the partition key range or time window? For each `@asset_check` without `blocking=True`, does the checked asset have downstream assets that consume bad data when the check fails (precedent § 3 keeps advisory checks)? **Finding when** a range backfill processes one partition, or a failed check lets a critical asset feed its dependants. **Proof:** V550 — materialize a three-partition range: three partitions written; V553 — a failing check stops the downstream materialization.

## Output

```
orchestration-scan — lanes: airflow 3.0.2 (requirements.txt:1), dagster 1.9.3, prefect 3.1.0; create_cron_data_intervals: default
Surface: 3 Airflow files · 1 Dagster · 1 Prefect

Findings: 3 · Unconfirmed: 2

1. dags/orders_dag.py:16                                          [report-with-fix]   high
   §8 OR-8 — schedule "0 2 * * *" on Airflow 3.0.2 with no interval timetable; the transform reads
   [data_interval_start, data_interval_end). V549: rendered interval 2026-10-18T02:00 → 2026-10-18T02:00, 0 rows, success.
   Fix: schedule=CronDataIntervalTimetable("0 2 * * *", timezone="UTC").
2. dags/orders_dag.py:7                                           [report-with-fix]   high
   §2 OR-2 — Variable.get at module level: a secrets-backend call on every parse. Fix: "{{ var.value.shop_api_key }}".
3. dags/orders_dag.py:23                                          [report-with-fix]   high
   §4 OR-4 — publish with trigger_rule all_done after load and transform. V551: load failed, publish ran.
4. dags/orders_dag.py:10                                          [report-flagged]    high
   §1 OR-1 — the load selects `day = datetime.now().date()`. Unproven link: whether the DAG is ever backfilled. Settling: V549.
5. dags/orders_dag.py:18                                          [report-flagged]    high
   §5 OR-5 — retries=3 around an INSERT…SELECT with no conflict clause (incremental-load-scan §2 row 2b). Settling: V540.

Verification: 19 candidates → 3 findings · 2 unconfirmed · 4 hardening · 10 refuted (self-verified; precedents § 13, § 14, § 3)

Class coverage:
  §1 wall clock        checked (1 → 1 unconfirmed)
  §2 parse-time I/O    checked (2a 1 + 2b 1 → 1 finding; days_ago in the AF2 file → §7)
  §3 overlap/catchup   checked (3a 2 + 3b 1 → hardening: one run per partition, delete-then-insert by ds)
  §4 trigger rules     checked (1 → 1 finding)
  §5 retries           checked (5a 3 + 5b 1 → 1 unconfirmed; the decoy deletes the interval first)
  §6 sensors           checked (1 → hardening: the upstream DAG is a 5-minute job; timeout added as advice)
  §7 AF3 removals      checked (7a 5 → 0 findings in deployed DAGs; af2_dag.py is not mounted — read the compose volume)
  §8 zero-width        checked (1 → 1 finding)
  §9 Dagster           checked (9a 1 + 9b 1 → refuted: orders has no dependants yet — advisory)
```

`[report-with-fix]`: the oracle failed (a rendered interval, a test run with the upstream failed, a reserialization diff) or the fact is two-ended with the version pin quoted, and the fix is local to the DAG, the asset or the flow. `[report-flagged]`: one link is unproven (whether backfills happen, the deployment's mounted folder, the side effect's idempotency); the row names it and the settling oracle. The coverage block lists **all nine** §s on every run: `checked (k → m)`, `N/A (<reason>)`, `blocked (<grep>: rg exit 2)`, `→ <owner> (k)` or `Live, unreviewed (k)`.

## False positives / precedents

The verifier reads [`precedents/data-engineering.md`](../../../../snippets/precedents/data-engineering.md) and [`refute-before-report.md`](../../../../snippets/refute-before-report.md) § 4. The ones this skill meets most:
- § 13 `datetime.now()` for logging or a temp name; § 14 no `catchup=` on Airflow 3 (flips back when the team needs backfills, or on Airflow 2); § 3 a non-blocking check that is advisory.
- A delete-then-insert keyed on the run's own interval makes a retry or an overlap idempotent (§5 and §3 refute with the line cited).

## When to run

- On any diff that touches a DAG, asset, flow, `default_args`, a sensor, `airflow.cfg`, the orchestrator pin or its image tag — `/audit-data-quality`'s orchestration signal; before and after an Airflow 2 → 3 upgrade (with `/upgrade-dep`).
- On every whole-repo `/audit-data-quality`, reporting all nine §s.

## Halt conditions

- **Do not report a high row as confirmed until a separate verifier has refuted it.** That verifier is not the run that found it, gets the version pin, the `file:line` and the run that breaks — never this run's reasoning — and passes the row through [`refute-before-report.md`](../../../../snippets/refute-before-report.md) §1–§8 with [`precedents/data-engineering.md`](../../../../snippets/precedents/data-engineering.md). Under `/audit-data-quality` that is `@dag-reviewer` in refute mode, then `@verifier` mode `refute` on every BLOCKER and HIGH; standalone, apply the protocol as a second pass and tag the rows `self-verified`.
- **Halt before §7 and §8 when no version pin is read**: they print `[report-flagged]` with the missing pin, never a verdict from what Airflow "usually" is.
- **Never trigger, clear, backfill or unpause a DAG on a shared or production Airflow, and never run a Dagster or Prefect job against production resources.** Oracles run in a throwaway environment: `AIRFLOW_HOME="$OUT/airflow"` (Airflow writes its home on import), `AIRFLOW__CORE__LOAD_EXAMPLES=False`, `DO_NOT_TRACK=1` and `SCARF_ANALYTICS=false` set defensively (no usage-data option was found in the 3.3 configuration reference, fetched 2026-10-19), no connections to real systems. Whether the Airflow 3 DagBag import needs a metadata DB is UNVERIFIED; a test that cannot be isolated is `NOT-RUN`.
- **This skill writes nothing into the audited repo**; `ruff` runs with `--no-cache`. `find <root> -newer "$OUT"` is empty at the end; a fix is `/data-fix`'s.
- **No `[report-with-fix]` without the V-oracle failing** or a two-ended static fact (the pin and the construct). **Do not grade application schedulers** (§ Boundaries). **Do not print "the DAGs look fine"** without the coverage block.

## Related

- `/audit-data-quality` dispatches this skill; `@dag-reviewer` judges OR rows; `/data-fix` closes them; `/upgrade-dep` owns an Airflow major upgrade and reads §7/§8 as its parity list.
- This pack: `incremental-load-scan` (the write a retry replays; backfill shapes), `data-test-scan` (checks that cannot fail).
- backend `job-lifecycle-scan`, distributed-systems `coordination-scan`, when those packs are installed.

## Sources

From the data-engineering research (2026-10-19): Airflow "Upgrading to Airflow 3", best practices and FAQ (3.3.2 docs, Apache-2.0); apache-airflow 3.3.2 on PyPI (2026-09-17); ruff AIR rules and AIR304 (ruff 0.17.1, 2026-10-15, MIT); Dagster asset checks and backfills (1.13.26 docs); Prefect 3 caching. Airflow configuration reference 3.3 fetched 2026-10-19 for the telemetry note. Class ids DE-A-10…19 as catalogued there. Counts are from read-only runs of the block above. Paraphrased; no text reproduced beyond identifiers.

Attribution

Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.

Comments

Loading comments…

Newsletter

Get the weekly list of safe new skills, and the ones we pulled.