ADR 0046 — Pipeline DQ gate: an orchestrator asks DataQ whether to continue¶
- Status: Accepted (2026-09-29)
- Date: 2026-09-29
- Deciders: @TheurgicDuke771
- Related: ADR 0005 / 0016 (severity ladder), 0026 (PATs), 0027 (per-suite
view/edit), 0035 (rate limits); ADR 0004 (theOrchestrationProvidercontract this extends).
Context¶
Orchestration providers (ADF, Airflow, dbt) have exactly three responsibilities in DataQ: monitor runs, detect failure, and trigger bound suites when a pipeline run succeeds. None of them lets a pipeline act on a DQ verdict. The proven feature in this market is a circuit breaker: a pipeline stage asks "did the data pass?" and halts its own downstream stages on a breach.
A read-only "did the suite triggered for my run pass?" endpoint would not work for the common case. Suites are triggered when a pipeline run completes, so a stage in the middle of a run would be waiting for a trigger that cannot fire until the run it is blocking finishes.
Decision¶
- A fourth responsibility: answer a gate request. DataQ still never mutates a pipeline.
The customer's DAG or pipeline chooses to call DataQ and to wait or fail on the answer.
The gate is provider-agnostic: it is keyed by the existing
trigger_bindings(provider,pipeline_or_dag_id,env) and has no provider-specific branch. - One endpoint, two modes:
POST /api/v1/orchestration/gatewithprovider,pipeline_or_dag_id,env,provider_run_id,fail_on(warn|fail|critical, defaultfail) andtrigger(defaulttrue). - Trigger-and-wait (
trigger: true): DataQ ensures one run of every enabled bound suite exists for this pipeline run and returns its current state. The run carries the sametriggered_bymarker the success-event path uses (<provider>:<pipeline_or_dag_id>:<provider_run_id>), so the existing partial unique index makes it idempotent: repeated polls, and the run's own later success event, never start a second run. Polling the same request until a terminal state is the intended client loop. - Status only (
trigger: false): DataQ creates nothing. It reports on runs that already exist for the marker, e.g. a downstream pipeline gating on an upstream run that the normal success event triggered. - States, never a bare boolean:
awaiting_trigger(status-only, no run yet),running(any bound run queued or running),passed,failed(some result at or abovefail_on),error(a run failed operationally or was cancelled, or a check could not be evaluated). The response lists each bound suite's run, status, worst severity and counts, plus aretry_after_secondshint.erroris distinct fromfailedso a client can choose; the shipped snippets fail closed on both. - Authorization is the manual-trigger rule. Starting a suite run is
editon the suite (asPOST /suites/{id}/run), so trigger mode requiresediton every bound suite and status-only requiresview. Callers use a PAT (dq_live_…). A binding with no enabled suites the caller can see is a 404 with no leak of which suites exist. - Rate-limited by the existing per-user API class. A gate poll is cheap once the run
exists (the idempotent insert is a no-op), and the client honours
retry_after_seconds. - Clients: a
dataq gatecommand in the Python client/CLI (the universal client: any shell step, and dbt betweendbt buildinvocations), an Airflow sensor snippet inintegrations/airflow/, and an ADF pipeline snippet (Web activity inside an Until loop) inintegrations/adf/. All exit or fail non-zero onfailedanderror, and time out on the client side.
Consequences¶
- CLAUDE.md §4's contract becomes four responsibilities; the anti-patterns are unchanged, since the gate reads bindings and runs through generic code.
- No schema change: runs, results and the dedup index already carry everything the gate needs.
- Not in scope: MCP exposure (the gate is machine-to-machine), and DataQ pausing or failing a pipeline itself.