Adopt the SDK-native preflight gate

SkillDev tools

Bump a v3 app to the latest application-sdk and adopt the SDK-native preflight gate safely. The gate runs the app's preflight_check handler as the mandatory first activity of every extraction workflow and always reports the verdict; by default it is soft (every outcome is reported but the run proceeds), and blocking real runs is a per-app opt-in (preflight_gate_mode = "hard"). Hard mode blocks on everything the gate can attribute to the source — a NOT_READY verdict, a probe overrunning the enforced budget, a handler crash, a provably absent credential — while failures of the gate's own plumbing always fail open. Classifies the app's rollout bucket, sizes the check budget (preflight_gate_timeout_seconds, default 150s, ceiling 300s) and retry attempts (preflight_gate_max_attempts) against what the handler actually costs, sizing from SDK-measured gate_duration_ms rather than handler-authored check durations, fixes name collisions, audits the handler's status logic against the new semantics, runs an interactive check-design session with the developer (visualized as a decision tree: which checks block, which are advisory, what is missing), hunts for hidden preflight logic by behavior (not name) and consolidates it into the handler so both surfaces run one implementation, adopts typed check errors, and updates tests. Interactive: every blocking/advisory/consolidation decision belongs to the developer; the skill proposes, never imposes.

Available today. Use it from your connected AI after setup.

Connect ahel once, and every AI you use reads what you have installed.

Then ask your AI: use the Adopt the SDK-native preflight gate skill

What this skill tells your AI

The instructions your AI receives, as published by atlanhq/application-sdk in .claude/skills/adopt-preflight-gate/SKILL.md and read by ahel’s review.

Scope boundary — check first

This skill is for v3 apps only (subclasses App, @entrypoint methods, Handler in app/handler.py). If the app is v2 (Argo-era layout, application_sdk.workflows/handlers imports), STOP and run /upgrade-v3 first; this skill picks up after.

Reference implementation for everything below: atlan-mysql-app (PR #340) — short-circuiting auth check with typed error, advisory tables check, PARTIAL status. Read its app/handler.py before proposing changes.

What changed (context you state to the developer up front)

  • The SDK injects {app}:preflight as the first activity of every extraction workflow. It calls the app's one Handler.preflight_check.
  • PreflightOutput.status is the gate verdict: NOT_READY is always reported (as outcome="would_block"), and aborts the run only when the app has opted into hard mode (preflight_gate_mode = "hard", typed PreflightFailed, red activity); READY and PARTIAL proceed. PARTIAL is display-only — use it for "advisory check failed, run anyway".
  • There is no per-check blocking flag. Importance is expressed by control flow: required checks short-circuit (return NOT_READY early), advisory checks run and only influence PARTIAL.
  • Return the verdict; don't raise it. The returned status is what both surfaces render, so a block belongs there. But raising is not a no-op, and what it does depends on the error's type: a typed plumbing error (RateLimitedError, DependencyUnavailableError, ResourceExhaustedError) means "I could not determine readiness" and fails open in both postures; anything else — an untyped crash, a typed source error, overrunning the budget — is treated as an unverifiable source and blocks in hard mode. So an uncaught probe exception is a run-aborting bug for a hard app, not a harmless fail-open.
  • A failed check should carry error=<SDK leaf>(...).to_failure_details() — category/code/audience/suggested_action flow to the Automation Engine and dashboards. Untyped failures fall back to the PREFLIGHT_CHECK_FAILED sentinel.
  • Behavior to say plainly: by default the gate is soft — a handler that returns NOT_READY does not block the run; the verdict is always reported (as outcome="would_block") but the run proceeds. Blocking real runs is a per-app opt-in (preflight_gate_mode = "hard", next section) taken once the app's checks are trusted.
  • The two surfaces receive credentials differently — a nuance the "one implementation" principle hides. The UI's Test-Connection sends credentials inline in the request body; the gate has no body and resolves credentials from the extraction input's top-level triple (credential_guid / credential_ref / agent_json). It does not read connection.attributes.defaultCredentialGuid. So a green UI check is not proof the gate will resolve the same credential — if the guid only reaches the input via the connection, the gate sees none (phase-0 bucket 9).

Hard mode — the gate-level opt-in (CNCT-81)

Enforcement is a gate property, not a handler property. The handler always returns the honest verdict; the gate decides what to do with NOT_READY:

  • soft (default): never raise — the run proceeds and the dodged block is emitted as outcome="would_block" (with gate_mode="soft" and the per-check check_matrix) on the gate outcome event. The verdict is always reported, so connector-pulse can rank apps by how often they would have blocked real runs; that list is the "your checks are ready to enforce" queue.
  • hard: raise PreflightFailed, run aborts on NOT_READY. The opt-in for every app whose checks are trusted to gate real runs.

Opting in is deliberately explicit and deliberately small:

class MyApp(App):
    preflight_gate_mode = "hard"   # git-blamed: checks are trusted to block runs

or, ops-side without an app release: ATLAN_PREFLIGHT_GATE_MODE=hard on the worker deployment (env wins over the attribute; any value other than the literal hard resolves to soft — malformed config never blocks a run by accident). The worker logs an INFO line per hard app at boot, and emits a queryable Preflight gate posture event per app carrying the resolved mode and budget. Prefer the per-app attribute: the env lever applies to every app on that worker, including ones whose checks have never been validated against real runs.

Hard mode covers every outcome the gate attributes to the source — a NOT_READY verdict, a probe overrunning the budget, a handler crash, a provably absent credential. Failures of the gate's own plumbing (rate limit, secret-store outage, a credential lookup that failed for any other reason, worker unavailable) always fail open, in both postures. The outcome event carries gate_classification (source_unverifiable vs gate_broken) so the two are separable in pulse.

The check budget — size it before flipping to hard

Handler.preflight_check gets App.preflight_gate_timeout_seconds (default 150, clamped 5-300) and the SDK enforces it: the gate cancels the handler when it elapses. In hard mode an overrun blocks the run, so this is not a formality.

It bounds the whole handler call, not each check — one slow probe can consume the budget and leave the rest unrun. And it is a deadline, not a reservation: a handler returning in 3s holds its worker slot for 3s regardless of the budget. So a generous budget costs nothing on a healthy run; it only changes the run that would otherwise have been cut short.

App.preflight_gate_max_attempts (default 2, clamped 1-3) sets the retries. A retry rescues a transient — a cold pool, a cluster resuming — by trying again; it cannot rescue a systematically slow check, which needs a bigger budget instead. Both timeouts derive from these two numbers, so an app declaring a large budget usually wants 1: at the 300s ceiling, two attempts reserve a ~10 minute schedule_to_close.

class MyApp(App):
    preflight_gate_mode = "hard"
    preflight_gate_timeout_seconds = 250   # this source's probe is genuinely slow
    preflight_gate_max_attempts = 1        # a retry won't rescue a slow check

What the skill checks during adoption:

  • Size from the p99 of successful runs, not the max. Sizing to the worst observed run makes the timeout decorative — nothing ever overruns, so hard mode is back to enforcing only NOT_READY verdicts. Sizing to p95 blocks 5% of runs.
  • Read gate_duration_ms, never check_matrix durations. Per-check duration_ms is written by the app, and rows predating SDK 3.25 carry durations from abandoned attempts (see the orphaned-attempt note below), so they read far above any budget that was ever in force. gate_duration_ms is measured by the SDK. Headroom is gate_duration_ms / (gate_timeout_seconds * 1000).
  • Measure the handler's real cost before flipping to hard. If the app runs a comparable probe as a @task elsewhere, its timeout_seconds is the honest estimate — a check that mirrors a 600s task will not fit in the default.
  • Size probes to PreflightInput.timeout_seconds, don't defeat it. That field carries what remains after credential resolution. max(input.timeout_seconds, <bigger constant>) discards it; a deadline whose per-probe floor never forces an early return makes it decorative. Both read as "budget honoured" in review and are not.
  • Bound the whole handler, not just the probes. Client build, connect, and auth are network I/O and count against the budget.
  • Keep probes awaitable. Cancellation lands at an await, so blocking synchronous I/O on the event loop escapes the budget entirely and stalls the worker's other activities. Run blocking drivers in a thread.
  • Fan-out is where budgets die. A per-catalog/per-schema loop scales with the source, not with the code. Prefer a bounded scope, concurrency, or an early exit once the check is satisfied.

Rules the skill enforces during adoption:

  • Never soften the handler to dodge the gate. Returning PARTIAL for a failure that should block hides the truth from every surface; posture belongs on the App class, verdicts belong in the handler.
  • Never return NOT_READY for a transient. A 429 or a dependency outage is "ask me later", not "the source is not ready" — collapsing them makes hard mode fail closed on a blip. Raise a typed RateLimitedError / DependencyUnavailableError instead; the gate routes those to fail-open.
  • Soft is the default landing state with two exit conditions, both required before adding preflight_gate_mode = "hard":
    1. the app's would_block rows track real workflow failures (the checks are right, not just loud), and
    2. the app's p99 gate_duration_ms sits comfortably inside its declared budget (the checks fit, so an overrun is signal rather than routine). Flipping on (1) alone converts a fail-open into a block on every slow run.
  • An app with no preflight_check handler needs no posture: the DefaultHandler never returns NOT_READY, so the gate never has anything to enforce.

What the gate emits, and which numbers to trust

Every outcome writes one Preflight gate outcome row. v3 apps log to otel_logs.service_logs (not combined_workflow_logs, which is the Argo path), and LogAttributes is a Map, so LogAttributes['outcome'] works directly while check_matrix needs JSONExtractArrayRaw on the string value.

attributemeaning
outcomeproceeded / would_block / blocked / no_verdict / skipped
gate_moderesolved posture; absent on the workflow-emitted rows
gate_classificationverdict / source_unverifiable / gate_broken
gate_duration_msSDK-measured elapsed; the only number that can size a budget
gate_timeout_secondsthe budget in force, so headroom needs no join
gate_attemptdistinguishes a first-try pass from a retry rescue
check_matrixper-check name/passed/error_code/duration_ms; [] where no check ran

check_matrix is present on every outcome, so parse it unconditionally rather than branching on field presence — a branch mishandled in the dropping direction is how a gate that never reached a verdict vanishes from the numerator.

The orphaned-attempt caveat, for anything sized on historical data. Before SDK 3.25 the gate had no timeout of its own. Temporal's start_to_close is enforced server-side, and a non-heartbeating activity's coroutine is not stopped by it — so the workflow gave up at 25s and failed open while the abandoned handler kept running, finished minutes later, and emitted its own outcome row. Consequences when reading old data:

  • a blocked row does not prove the run was blocked; the workflow may have moved on long before it was written
  • the same workflow_run_id can carry both blocked and no_verdict, both at attempt 1 — that pair is the signature
  • check_matrix durations far above any budget come from those orphans, not from probes that were permitted to run that long

The SDK-side cancel fixes this: the gate's own timer fires before Temporal's, so the work actually stops and the row means what it says. Size budgets from gate_duration_ms going forward, or from temporal.activity.duration_ms on the activity.ended event, which the log interceptor has always measured honestly.

The design principle every decision flows from

One implementation, two surfaces. Handler.preflight_check is the single authority on "is this source ready" — the UI's Test-Connection button and the gate at the head of every run both execute the same function. Two copies of readiness logic (a handler AND an activity, or checks buried in extraction code) inevitably drift: the UI says ready while the run fails, or worse the inverse. Drift is the anti-pattern this whole feature exists to kill.

Corollaries the skill acts on:

  1. Consolidation always flows activity → handler, never the other way. The handler is reachable by both surfaces; an activity is reachable by one.
  2. Anything that behaves like preflight IS preflight. If code verifies source readiness before extraction — regardless of what it is named or where it lives — its natural home is the handler, where the UI benefits from it too. Phase 0 hunts for these by behavior, not by name.
  3. Deletion requires a coverage diff. Consolidating or deleting duplicate readiness logic must never lose a check: enumerate what the old code verified, prove each item exists in the handler, fold gaps in first.

Frame it to the developer as what they gain: write a check once, and the Test-Connection button, every scheduled run, the Temporal failure pane, and the failure dashboards all get it — with a typed, actionable error — for free.

Phase 0 — Classify the app

Run these detections and report the bucket(s) before changing anything:

  1. Collision class — a @task whose activity name resolves to {app_name}:preflight (a task method literally named preflight, or explicit name="preflight"). The worker will REFUSE TO BOOT on the bumped SDK (WorkerActivityNameCollisionError). Known fleet members: atlan-presto-app, clickhouse, power-bi-app, qlik-sense-cloud-app, redshift-app, teradata-app, trino.
  2. Coexistence class (named) — other *preflight*-named @tasks (e.g. miner_preflight_check). Non-breaking: they keep running alongside the gate. Each is a consolidation candidate for phase 2.
  3. Hidden preflight (semantic hunt) — readiness logic that is preflight in behavior but not in name. Scan @task bodies and entrypoint code that runs BEFORE the extraction fan-out for:
    • connect-and-probe patterns (SELECT 1, ping, token validation, list-one API call) whose result only gates whether to continue;
    • tasks named test_*, validate_*, check_*, verify_*, *_probe;
    • early-exit guards in run() that abort before any data is extracted. Classify each hit with the developer in mind:
    • True preflight — read-only source-readiness verification, no side effects the extraction depends on → consolidation candidate: it belongs in the handler so the UI check runs it too.
    • Execution guard owned by the SDK (e.g. the sql template's prime_sql_auth warm-up) → leave alone; it is infrastructure, not app preflight, and the SDK maintains it.
    • Business logic wearing a check's clothes (produces state extraction consumes, seeds caches the run needs) → leave in the workflow; moving it to the handler would make the UI path perform work. When unsure which of the three, ask the developer — that is a phase-2 question, not a guess.
  4. Gate eligibility — the entrypoint input contract must carry the credential-routing triple (extraction_method, credential_guid, agent_json), normally by extending the toolkit ExtractionInput (check app/generated/*_input.py or contract/). Missing triple on a source-ful app = the gate silently skips; fix the contract.
  5. Handler presence — no Handler.preflight_check at all → the gate runs the SDK DefaultHandler no-op (never blocks). Valid state; offer to write a handler in phase 2 but do not require it.
  6. Silent-drift audit — list every input.metadata / input.connection_config key read inside preflight_check, and cross-check each against the input contract's fields. On the gate path, metadata is rebuilt from the extraction input's model_dump; a UI-form-only key is absent — a hard [...] read crashes, which is a handler crash and therefore blocks every run in hard mode; a defensive .get(..., default) silently runs the check with wrong config. Every unmatched key needs a decision in phase 2.
  7. Multi-credential class — an app that needs more than one credential to verify a source (e.g. an API token AND an object-store credential), whose per-auth-type guids live in separate input fields, not on the single top-level credential_guid triple. The gate resolves only that one triple, so the symptom on the gate path is the handler receiving credentials=[], defaulting to one auth type, and raising missing-credential on every gated run — reported on every run in soft mode, and aborting every run in hard mode. Detect by: multiple *_credential_guid fields on the input contract, or a handler that resolves guids itself (CredentialResolver / get_credentials called inside preflight_check). If found, the app adopts the SDK preflight_credential_refs primitive in phase 2 (see 2g) — it must NOT hand-roll credential resolution or the fail-open taxonomy.
  8. Multi-entrypoint class (crawler + miner, etc.) — more than one @entrypoint method on the App class (grep @entrypoint). The gate is injected per workflow type and PreflightInput.entrypoint is baked from the entry-point's registered name — so a miner run reliably arrives with entrypoint="miner", a crawler run with entrypoint="crawler". The single preflight_check must branch on input.entrypoint and run only that entrypoint's checks; each entrypoint gets its own tree in phase 2 (see 2h). Two traps: (a) a getattr(input, "entrypoint", "crawler")-style default is dead code — the field always exists, defaulting to "", which is falsy against == "miner", so a mis-set default silently routes miner runs down the crawler branch; (b) entrypoints usually differ in how credentials arrive (next bucket).
  9. Late-credential-derivation class — the input carries the credential triple, but credential_guid / credential_ref is populated inside the entrypoint body rather than at input construction. Classic case: a miner that reuses the crawler's connection, so the guid arrives only on connection.attributes.defaultCredentialGuid and the body derives it (input.model_copy(update={"credential_guid": ...})). The gate runs before any body code, so it resolves an empty triplecredentials=[] → the handler's connect step fails → soft would_block, while extraction (which derives the guid late) still succeeds. Tell-tale triad: UI Test-Connection green, miner gate soft-fails, extraction runs anyway. Detect by grepping entrypoint bodies for credential assignment or defaultCredentialGuid reads before the first extraction activity. Fix in phase 2 by lifting the derivation to input construction (see 2h). SDK-side fix tracked in CNCT-92.
  10. Non-routable extraction_method class — the credential router (CredentialRef.resolve) reads extraction_method as the routing selector and accepts only direct (+ credential_guid) or agent (+ populated agent_json); anything else raises CredentialRoutingError. Query-extraction / miner-family manifests commonly set extraction-method to an extraction kind instead — e.g. query_history — which names what is extracted, not how creds are routed. Left as-is, the gate's resolve() raises on every run and survives only via the deprecated legacy_credential_ref fallback (removed in SDK v4.0) — so it looks like it works (a caught CredentialRoutingError stacktrace on every run) until v4.0 removes the fallback or the app flips to hard mode, at which point the gate resolves credentials=[] and soft-skips or hard-aborts. Detect by grepping the manifest (app/generated/**/*.json) and the input model for extraction-method / extraction_method values that are not direct / agent. Fix in phase 2 by normalizing it at input construction (see 2h). Same SDK ticket: CNCT-92.

Phase 1 — Bump and boot

  1. Daft-cliff pre-check (mandatory when the lock resolves SDK <3.20.0). SDK v3.20.0 removed daft and moved the transformation layer to DuckDB/pyarrow — the bump below drags the app across that cliff. Check uv.lock's resolved atlan-application-sdk version; if it is below 3.20.0, or grep -rn "import daft" app/ hits, invoke /migrate-off-daft now and complete it before proceeding — it owns the breakage taxonomy (empty [daft] extra, changed transformer contracts, missing duckdb, the silent literal-vs-column precedence flip) and its done-bar is output parity: transformed output structurally identical, no attribute lost. Only when its step-4 evidence is green does phase 1 continue; the daft migration and the gate adoption land as separable changes, in that order.

Shortened here. Read the whole file on GitHub.

Signals

GitHub stars
29
Forks
17
Last commit
Sep 2026
Advanced
Catalog kind
skill
Gateway key
adopt-preflight-gate
Source
github.com/atlanhq/application-sdk