Adopt the SDK-native preflight gate
SkillDev toolsBump 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.
No other account needed.
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}:preflightas the first activity of every extraction workflow. It calls the app's oneHandler.preflight_check. PreflightOutput.statusis the gate verdict:NOT_READYis always reported (asoutcome="would_block"), and aborts the run only when the app has opted into hard mode (preflight_gate_mode = "hard", typedPreflightFailed, red activity);READYandPARTIALproceed.PARTIALis display-only — use it for "advisory check failed, run anyway".- There is no per-check
blockingflag. Importance is expressed by control flow: required checks short-circuit (returnNOT_READYearly), advisory checks run and only influencePARTIAL. - 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 thePREFLIGHT_CHECK_FAILEDsentinel. - Behavior to say plainly: by default the gate is soft — a handler that
returns
NOT_READYdoes not block the run; the verdict is always reported (asoutcome="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 readconnection.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"(withgate_mode="soft"and the per-checkcheck_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 onNOT_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_READYverdicts. Sizing to p95 blocks 5% of runs. - Read
gate_duration_ms, nevercheck_matrixdurations. Per-checkduration_msis 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_msis measured by the SDK. Headroom isgate_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
@taskelsewhere, itstimeout_secondsis 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; adeadlinewhose 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
PARTIALfor 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_READYfor 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 typedRateLimitedError/DependencyUnavailableErrorinstead; 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":- the app's
would_blockrows track real workflow failures (the checks are right, not just loud), and - the app's p99
gate_duration_mssits 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.
- the app's
- An app with no
preflight_checkhandler needs no posture: the DefaultHandler never returnsNOT_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.
| attribute | meaning |
|---|---|
outcome | proceeded / would_block / blocked / no_verdict / skipped |
gate_mode | resolved posture; absent on the workflow-emitted rows |
gate_classification | verdict / source_unverifiable / gate_broken |
gate_duration_ms | SDK-measured elapsed; the only number that can size a budget |
gate_timeout_seconds | the budget in force, so headroom needs no join |
gate_attempt | distinguishes a first-try pass from a retry rescue |
check_matrix | per-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
blockedrow does not prove the run was blocked; the workflow may have moved on long before it was written - the same
workflow_run_idcan carry bothblockedandno_verdict, both at attempt 1 — that pair is the signature check_matrixdurations 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:
- Consolidation always flows activity → handler, never the other way. The handler is reachable by both surfaces; an activity is reachable by one.
- 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.
- 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:
- Collision class — a
@taskwhose activity name resolves to{app_name}:preflight(a task method literally namedpreflight, or explicitname="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. - 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. - Hidden preflight (semantic hunt) — readiness logic that is preflight in
behavior but not in name. Scan
@taskbodies 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_authwarm-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.
- connect-and-probe patterns (
- Gate eligibility — the entrypoint input contract must carry the
credential-routing triple (
extraction_method,credential_guid,agent_json), normally by extending the toolkitExtractionInput(checkapp/generated/*_input.pyorcontract/). Missing triple on a source-ful app = the gate silently skips; fix the contract. - Handler presence — no
Handler.preflight_checkat 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. - Silent-drift audit — list every
input.metadata/input.connection_configkey read insidepreflight_check, and cross-check each against the input contract's fields. On the gate path, metadata is rebuilt from the extraction input'smodel_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. - 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_guidtriple. The gate resolves only that one triple, so the symptom on the gate path is the handler receivingcredentials=[], 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_guidfields on the input contract, or a handler that resolves guids itself (CredentialResolver/get_credentialscalled insidepreflight_check). If found, the app adopts the SDKpreflight_credential_refsprimitive in phase 2 (see 2g) — it must NOT hand-roll credential resolution or the fail-open taxonomy. - Multi-entrypoint class (crawler + miner, etc.) — more than one
@entrypointmethod on the App class (grep@entrypoint). The gate is injected per workflow type andPreflightInput.entrypointis baked from the entry-point's registered name — so a miner run reliably arrives withentrypoint="miner", a crawler run withentrypoint="crawler". The singlepreflight_checkmust branch oninput.entrypointand run only that entrypoint's checks; each entrypoint gets its own tree in phase 2 (see 2h). Two traps: (a) agetattr(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). - Late-credential-derivation class — the input carries the credential
triple, but
credential_guid/credential_refis 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 onconnection.attributes.defaultCredentialGuidand the body derives it (input.model_copy(update={"credential_guid": ...})). The gate runs before any body code, so it resolves an empty triple →credentials=[]→ the handler's connect step fails → softwould_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 ordefaultCredentialGuidreads 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. - Non-routable
extraction_methodclass — the credential router (CredentialRef.resolve) readsextraction_methodas the routing selector and accepts onlydirect(+credential_guid) oragent(+ populatedagent_json); anything else raisesCredentialRoutingError. Query-extraction / miner-family manifests commonly setextraction-methodto an extraction kind instead — e.g.query_history— which names what is extracted, not how creds are routed. Left as-is, the gate'sresolve()raises on every run and survives only via the deprecatedlegacy_credential_reffallback (removed in SDK v4.0) — so it looks like it works (a caughtCredentialRoutingErrorstacktrace on every run) until v4.0 removes the fallback or the app flips to hard mode, at which point the gate resolvescredentials=[]and soft-skips or hard-aborts. Detect by grepping the manifest (app/generated/**/*.json) and the input model forextraction-method/extraction_methodvalues that are notdirect/agent. Fix in phase 2 by normalizing it at input construction (see 2h). Same SDK ticket: CNCT-92.
Phase 1 — Bump and boot
- 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 resolvedatlan-application-sdkversion; if it is below 3.20.0, orgrep -rn "import daft" app/hits, invoke/migrate-off-daftnow and complete it before proceeding — it owns the breakage taxonomy (empty[daft]extra, changed transformer contracts, missingduckdb, 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