<!-- AUTO-GENERATED — do not edit this file directly.
     Source of truth: conformance/suite/rules/prescriptions.py, conformance/suite/rules/orchestration.py, conformance/suite/rules/storage.py, conformance/suite/rules/entrypoint_alignment.py, conformance/suite/rules/entrypoint.py, conformance/suite/rules/client_seam.py, conformance/suite/rules/determinism.py, conformance/suite/rules/app_name_alignment.py, conformance/suite/rules/transform_templates.py
     To regenerate:  uv run atlan-application-sdk-conformance gen-rule-docs
     To check CI staleness: uv run atlan-application-sdk-conformance gen-rule-docs --check -->

# Prescription Rules (P-series)

**42 rules** · Checker: `suite.checks.prescriptions` (P001–P003, P008–P015), `suite.checks.orchestration` (P004–P007, scans test files too), `suite.checks.entrypoint_alignment` (P016), `suite.checks.entrypoint` (P017–P018, scans test files too), `suite.checks.client_seam` (P019), `suite.checks.determinism` (P020–P024, P031), `suite.checks.app_name_alignment` (P025), `suite.checks.sdr` (P029/P030, P037/P038/P039, P041), `suite.checks.transform_templates` (P040, scans template YAML) (all AST-based / cross-artifact)

Suppress a finding on the violating line or the line directly above it:

```python
# conformance: ignore[P001] intentional: generic cleanup payload
```

**Rule-id stability (non-migration policy):** rule ids are a permanent public contract —
each is exposed in the SARIF `help_uri` and referenced by inline `# conformance:
ignore[...]` suppressions across the fleet.  An id therefore **never migrates and never
changes**, even if a future domain series (S/B/T/A/…) later subsumes the same topic.
When a domain series takes over an area, the rule is retired in place (kept documented,
no longer firing) and the new rule gets a fresh id — the original id is never reused or
reassigned.

| ID | Name | Tier | Scope | Category | Autofixable | Since |
|---|---|---|---|---|---|---|
| [P001](#p001) | `UnboundedContractFields` | `block` | `both` | `contract-payload-safety` | — | 0.3.0 |
| [P002](#p002) | `CategoryFieldOverride` | `block` | `both` | `category-immutability` | — | 0.3.0 |
| [P003](#p003) | `ErrorCodePrefixMismatch` | `block` | `both` | `error-code-shape` | — | 0.3.0 |
| [P004](#p004) | `DirectTemporalImport` | `warn` | `app` | `orchestration-seam` | — | 0.5.0 |
| [P005](#p005) | `PrivateOrchestrationInternalImport` | `warn` | `app` | `orchestration-seam` | — | 0.5.0 |
| [P006](#p006) | `TemporalImportOutsideAdapter` | `warn` | `sdk` | `orchestration-seam` | — | 0.5.0 |
| [P007](#p007) | `RawTemporalInPublicSurface` | `warn` | `sdk` | `orchestration-seam` | — | 0.5.0 |
| [P008](#p008) | `FrameworkTransferInsideTask` | `warn` | `app` | `storage-seam` | — | 0.6.0 |
| [P009](#p009) | `ManualObjectStoreConstruction` | `warn` | `app` | `storage-seam` | — | 0.6.0 |
| [P010](#p010) | `ManualFileReferenceConstruction` | `warn` | `app` | `storage-seam` | — | 0.6.0 |
| [P011](#p011) | `RawBytesInContract` | `warn` | `app` | `storage-seam` | — | 0.6.0 |
| [P012](#p012) | `FilePathStringInContract` | `warn` | `app` | `storage-seam` | — | 0.6.0 |
| [P013](#p013) | `UntypedEntrypointBoundary` | `block` | `app` | `typed-contract-boundary` | — | 0.6.0 |
| [P014](#p014) | `UntypedTaskBoundary` | `block` | `app` | `typed-contract-boundary` | — | 0.6.0 |
| [P015](#p015) | `UnmodeledBoundedContractField` | `warn` | `app` | `contract-modeling` | — | 0.6.0 |
| [P016](#p016) | `EntryPointContractCodeDrift` | `block` | `app` | `entrypoint-alignment` | — | 0.6.0 |
| [P017](#p017) | `ManualWorkerBootstrap` | `warn` | `app` | `entrypoint-conformance` | — | 0.6.0 |
| [P018](#p018) | `ManualServerBootstrap` | `warn` | `app` | `entrypoint-conformance` | — | 0.6.0 |
| [P019](#p019) | `RawHttpToAtlan` | `warn` | `both` | `client-seam` | — | 0.7.0 |
| [P020](#p020) | `NonDeterministicPrimitiveInWorkflow` | `warn` | `both` | `determinism` | — | 0.8.0 |
| [P021](#p021) | `SideEffectIoInWorkflow` | `warn` | `both` | `determinism` | — | 0.8.0 |
| [P022](#p022) | `UnawaitedCoroutine` | `warn` | `both` | `async-correctness` | — | 0.8.0 |
| [P023](#p023) | `BlockingCallInAsyncDef` | `warn` | `both` | `async-correctness` | — | 0.8.0 |
| [P024](#p024) | `SyncAtlanClientInApp` | `warn` | `both` | `async-correctness` | — | 0.8.0 |
| [P025](#p025) | `AppNameContractCodeDrift` | `block` | `app` | `app-name-alignment` | — | 0.9.0 |
| [P026](#p026) | `GetattrOnTypedContractField` | `warn` | `app` | `typed-contract-boundary` | — | 0.9.0 |
| [P027](#p027) | `AppStateAsCrossTaskChannel` | `warn` | `app` | `state-seam` | — | 0.9.0 |
| [P028](#p028) | `ManualQualifiedNameFString` | `warn` | `app` | `asset-modeling` | — | 0.9.0 |
| [P029](#p029) | `SdrManifestMissingAgentJson` | `block` | `app` | `sdr-readiness` | — | 0.9.0 |
| [P030](#p030) | `SdrUploadNotCalled` | `warn` | `app` | `sdr-readiness` | — | 0.9.0 |
| [P031](#p031) | `SharedDefaultExecutorOffload` | `warn` | `both` | `async-correctness` | — | 0.13.0 |
| [P032](#p032) | `ReservedPreflightActivityName` | `warn` | `app` | `preflight-gate` | — | 0.15.0 |
| [P033](#p033) | `DuplicateInWorkflowPreflight` | `warn` | `app` | `preflight-gate` | — | 0.15.0 |
| [P034](#p034) | `UntypedPreflightCheckFailure` | `warn` | `app` | `preflight-gate` | — | 0.15.0 |
| [P035](#p035) | `PreflightMetadataContractParity` | `warn` | `app` | `preflight-gate` | — | 0.15.0 |
| [P036](#p036) | `HandRolledProcessIsolation` | `warn` | `both` | `async-correctness` | — | 0.15.0 |
| [P037](#p037) | `SdrAgentJsonNotConsumed` | `warn` | `app` | `sdr-readiness` | — | 0.16.0 |
| [P038](#p038) | `SdrArtifactMisrooted` | `warn` | `app` | `sdr-readiness` | — | 0.16.0 |
| [P039](#p039) | `SdrAgentJsonDroppedByInputContract` | `warn` | `app` | `sdr-readiness` | — | 0.16.0 |
| [P040](#p040) | `TransformTemplateReservedKeyword` | `warn` | `app` | `transform-templates` | — | 0.18.0 |
| [P041](#p041) | `SdrHardPreflightGate` | `warn` | `app` | `sdr-readiness` | — | 0.18.0 |
| [P042](#p042) | `SdrHandRolledUploadBridge` | `warn` | `app` | `sdr-readiness` | — | 0.18.0 |

---

## P001 — `UnboundedContractFields` {#p001}

**Tier:** `block` · **Scope:** `both` · **Category:** `contract-payload-safety` · **Autofixable:** — · **Since:** 0.3.0

> Input/Output contract declared with allow_unbounded_fields=True — opts out of payload safety

**Rationale:** Temporal enforces a hard 2MB payload limit on workflow/activity I/O (ADR-0008).
Unbounded fields can silently grow past it in production, failing the workflow with a
cryptic size error instead of a type error at import time. A justified inline
suppression keeps every opt-out visible in review and auditable in SARIF.

An `Input`/`Output` contract subclass declared with the `allow_unbounded_fields=True`
class keyword opts out of the SDK's payload-safety enforcement: arbitrary, untyped
fields may cross task boundaries unchecked.  This is intended to be *extremely*
exceptional — the sanctioned way to use it is an inline, justified suppression at the
declaration site (`# conformance: ignore[P001] <reason>`), so the carve-out is reviewed
and stays visible.

Suppressed declarations are still emitted to the SARIF report (counted in their own
category), so every opt-out is reported every single time. This rule is `BLOCK`
(suppress-only): an unsuppressed declaration fails the conformance gate — the only
sanctioned use is the justified inline suppression above — see BLDX-1428.

---

## P002 — `CategoryFieldOverride` {#p002}

**Tier:** `block` · **Scope:** `both` · **Category:** `category-immutability` · **Autofixable:** — · **Since:** 0.3.0

> AppError subclass redeclares the `category` ClassVar — drifts the canonical taxonomy

**Rationale:** FailureCategory is consumed as an immutable reporting metric by the Automation Engine,
SLA dashboards, and on-call routing (ADR-0013). A redeclaration either duplicates the
parent (drifts on rename) or substitutes a different value (splits one failure mode
across two buckets), corrupting the reporting layer for every downstream consumer.

`FailureCategory` is the closed, single-axis taxonomy the SDK owns — every value is the
canonical answer to *what happened* and is consumed as an immutable reporting metric
(dashboards, SLA gates, on-call routing). The 15 categorical leaves in
`application_sdk.errors.leaves` (and `AppError` itself) are the sole defining sites:
each leaf binds exactly one `FailureCategory` to its `category` `ClassVar`.

Domain subclasses MUST inherit `category` from their categorical-leaf parent — never
redeclare it.  A redeclaration is either a same-value duplication (clutter that drifts
as soon as the parent is renamed) or a true override (silent taxonomy drift that splits
a metric across lookalike values).  Both are blocked uniformly: domain subclasses
specialise via `code` and evidence fields, not `category`.

Suppressed declarations are still emitted to the SARIF report (counted in their own
category), so every opt-out is reported every single time. This rule is `BLOCK`
(suppress-only): an unsuppressed redeclaration fails the conformance gate — the only
sanctioned use is the justified inline suppression `# conformance: ignore[P002]
<reason>` at the declaration site — see BLDX-1432.

---

## P003 — `ErrorCodePrefixMismatch` {#p003}

**Tier:** `block` · **Scope:** `both` · **Category:** `error-code-shape` · **Autofixable:** — · **Since:** 0.3.0

> AppError subclass code missing or doesn't start with the parent leaf's category prefix

**Rationale:** Each categorical leaf owns a prefix that embeds its category into every error code
(`AUTH_`, `INTERNAL_`, etc.). Without it, the code column is opaque — dashboards must
join the category column for every query, and subclasses that inherit the bare leaf code
collapse all their distinct failure modes into one undifferentiated bucket.

Every concrete subclass of an `application_sdk.errors` leaf (`AuthError`,
`InternalError`, `InvalidInputError`, etc.) must declare its own `code: ClassVar[str]`
that starts with the leaf's category prefix and an underscore (`AUTH_`, `INTERNAL_`,
`INVALID_INPUT_`, etc.).  Without that prefix the code is opaque to dashboards and
on-call routing — the category column has to be joined for every query.  Without an
override, every site of the subclass collapses into the leaf's bare bucket and is
impossible to triage.

The check resolves inheritance transitively: an intermediate class with no `code` (a
'pass-through' subclass between a leaf and a concrete leaf) is also flagged so failures
don't silently inherit the bare leaf's code.  Suppress with `# conformance: ignore[P003]
<reason>` at the declaration when an intermediate is genuinely abstract — see
typed-error-prescription §4 and BLDX-1431.

---

## P004 — `DirectTemporalImport` {#p004}

**Tier:** `warn` · **Scope:** `app` · **Category:** `orchestration-seam` · **Autofixable:** — · **Since:** 0.5.0

> App imports 'temporalio' directly instead of the SDK orchestration seam

**Rationale:** Apps must interact with the orchestration layer only through the SDK's wrappers, never
the underlying Temporal engine. Keeping the SDK as the single seam lets the
orchestration layer evolve (Temporal upgrades, engine swaps, interceptor changes)
without breaking every app. A direct 'temporalio' import couples the app to the engine
and bypasses that seam (BLDX-1417).

A consumer app imports `temporalio` (the raw orchestration engine) directly.  Everything
an app needs is re-exported through the SDK seam: runtime primitives and decorators via
`application_sdk.app` (`task`, `signal`, `query`, `update`, `now`, `sleep`, `uuid4`,
`wait_condition`), and client/worker/converter construction via
`application_sdk.execution` (`create_temporal_client`, `create_worker`, `AppWorker`).
Importing `temporalio` directly couples the app to the engine and defeats the
single-seam contract — see BLDX-1417.  This rule scans test/harness files too: an
integration conftest that connects to Temporal directly is exactly the case to catch.

Land as `WARN`: a justified inline `# conformance: ignore[P004] <reason>` records any
unavoidable exception and stays visible in SARIF.

---

## P005 — `PrivateOrchestrationInternalImport` {#p005}

**Tier:** `warn` · **Scope:** `app` · **Category:** `orchestration-seam` · **Autofixable:** — · **Since:** 0.5.0

> App imports SDK-private orchestration internals instead of the public seam

**Rationale:** The SDK's public orchestration seam is `application_sdk.app` and
`application_sdk.execution`. Importing past it into private internals
(`application_sdk.execution._temporal.*` and other `_`-prefixed modules) couples the app
to implementation details that change without a deprecation cycle, so the SDK can no
longer evolve the seam safely (BLDX-1417).

A consumer app imports from an SDK-private module — anything with a `_`-prefixed segment
under `application_sdk` (most commonly `application_sdk.execution._temporal.*`) — or
imports a private (`_`-prefixed) name from a public SDK module.  These internals are not
a stable contract: the public re-exports in `application_sdk.execution`
(`create_temporal_client`, `create_worker`, `AppWorker`, `create_data_converter`) and
`application_sdk.app` are the supported surface.  Where no public equivalent exists,
raise it with the SDK team — see BLDX-1417.

Land as `WARN`: a justified inline `# conformance: ignore[P005] <reason>` records any
unavoidable exception and stays visible in SARIF.

---

## P006 — `TemporalImportOutsideAdapter` {#p006}

**Tier:** `warn` · **Scope:** `sdk` · **Category:** `orchestration-seam` · **Autofixable:** — · **Since:** 0.5.0

> SDK imports 'temporalio' outside the execution/_temporal adapter

**Rationale:** For the SDK to stay the single seam, Temporal must be contained behind one adapter
(`application_sdk/execution/_temporal/`). When 'temporalio' is imported elsewhere in the
SDK the engine bleeds across the codebase, and a future orchestration change has to be
made in many places instead of one — the exact coupling this contract exists to prevent
(BLDX-1417).

`temporalio` is imported in an SDK module outside the orchestration adapter
`application_sdk/execution/_temporal/`.  The adapter is the one place that may touch the
engine directly; the curated primitive re-export site `application_sdk/app/__init__.py`
(the seam apps consume) and test files are exempt.  Everywhere else, relocate the
Temporal usage into the adapter (or behind it) so the SDK keeps a single seam — see
BLDX-1417.

Land as `WARN`: the current spread of in-SDK Temporal usage is the backlog this rule
surfaces; some sites (e.g. workflow-class generation) are structural and stay until
refactored.  Suppress a deliberate exception with `# conformance: ignore[P006]
<reason>`.

---

## P007 — `RawTemporalInPublicSurface` {#p007}

**Tier:** `warn` · **Scope:** `sdk` · **Category:** `orchestration-seam` · **Autofixable:** — · **Since:** 0.5.0

> SDK public API re-exports or exposes a raw 'temporalio' type

**Rationale:** If the SDK's public API hands callers raw Temporal objects (re-exports a 'temporalio'
symbol, or returns/accepts a 'temporalio' type), apps are coupled to the engine through
the very surface meant to insulate them, and the seam leaks. The public contract must
trade only in SDK-owned types so the engine can change underneath it (BLDX-1417).

The SDK's public surface leaks Temporal in one of two ways: a public package `__init__`
lists a name in `__all__` that was imported straight from `temporalio`, or a publicly
re-exported function exposes a raw `temporalio` type in a parameter/return annotation
(e.g. `create_temporal_client() -> Client`, `create_worker(client: Client)`). The
curated runtime-primitive re-exports in `application_sdk/app/__init__` are the
sanctioned seam and are exempt, as are test files.  Closing a leak means wrapping the
value in an opaque SDK type (a refactor) — see BLDX-1417.

Land as `WARN`: this rule detects the leaks; closing them is tracked separately.
Suppress a reviewed exception with `# conformance: ignore[P007] <reason>`.

---

## P008 — `FrameworkTransferInsideTask` {#p008}

**Tier:** `warn` · **Scope:** `app` · **Category:** `storage-seam` · **Autofixable:** — · **Since:** 0.6.0

> App calls self.upload()/self.download() inside a @task method

**Rationale:** App.upload/App.download are themselves @task methods. Calling
self.upload(...)/self.download(...) inside another @task-decorated method nests one
activity inside another, which violates Temporal's single-activity-per-call contract and
bypasses the SDK's store routing (upstream vs deployment). For task-to-task data, return
a FileReference on the contract instead and let the activity interceptor move the bytes
(BLDX-1398).

An `App` subclass calls `self.upload(...)` or `self.download(...)` from within a
`@task`-decorated method.  `App.upload` and `App.download` are themselves `@task`
methods, so this nests an activity inside an activity — Temporal expects a single
activity per call, and the nested transfer also bypasses the SDK's store routing
(upstream store vs deployment store).

The SDK has two data-flow paths and this is the wrong one for in-task data.
**Task-to-task** data should travel as a `FileReference` on the contract: the task
writes a local file, returns `FileReference.from_local(path, tier=...)`, and the
activity interceptor persists and materialises it across the boundary. **App-to-app**
data is the only sanctioned use of `App.upload()` — called from `run()`, not from inside
a task.  Nesting the transfer in a task breaks the activity contract and routes bytes
through the wrong store — see BLDX-1398.

Land as `WARN`: a justified inline `# conformance: ignore[P008] <reason>` records any
unavoidable exception and stays visible in SARIF.

---

## P009 — `ManualObjectStoreConstruction` {#p009}

**Tier:** `warn` · **Scope:** `app` · **Category:** `storage-seam` · **Autofixable:** — · **Since:** 0.6.0

> App constructs its own cloud client or object store instead of the SDK storage seam

**Rationale:** Constructing a cloud client or object store directly (boto3, an obstore
S3Store/GCSStore/AzureStore, or a create_store_from_binding* call) bypasses the SDK's
two-store routing contract. In SDR mode the wrong store may receive data, breaking the
extract→publish hand-off. The SDK's infrastructure context (get_infrastructure().storage
/ .upstream_storage) and App.upload() route to the correct store (BLDX-1398).

A consumer app builds its own storage backend directly: importing `boto3` (`import
boto3` / `from boto3 ...`), constructing an obstore store (`S3Store`, `GCSStore`,
`AzureStore`), or calling `create_store_from_binding*`.  Any of these bypasses the SDK's
two-store routing contract, so in SDR mode bytes can land in the wrong store and break
the extract→publish hand-off.

The supported surface is the SDK's infrastructure context —
`get_infrastructure().storage` and `get_infrastructure().upstream_storage` — and
`App.upload()` / `App.download()`, which route to the correct store for the current
mode.  Where no SDK equivalent exists, raise it with the SDK team — see BLDX-1398.

Land as `WARN`: a justified inline `# conformance: ignore[P009] <reason>` records any
unavoidable exception and stays visible in SARIF.

---

## P010 — `ManualFileReferenceConstruction` {#p010}

**Tier:** `warn` · **Scope:** `app` · **Category:** `storage-seam` · **Autofixable:** — · **Since:** 0.6.0

> FileReference constructed with SDK-managed durability fields (storage_path/is_durable/file_count)

**Rationale:** FileReference's durability fields (storage_path, is_durable, file_count) are owned by
the SDK and populated by the activity interceptor's persist/materialize contract.
Hand-setting them at construction bypasses that contract and can produce stale or wrong
references that point at the wrong store or a non-existent path. Use
FileReference.from_local(path, tier=...) instead (BLDX-1398).

A `FileReference(...)` is constructed with one of the SDK-managed durability fields set
explicitly: `storage_path=`, `is_durable=`, or `file_count=`.  The SDK owns these fields
— they are populated by the activity interceptor as it persists a local file to the
upstream store and materialises it back across the task boundary.  Hand-setting them
bypasses that persist/materialise contract and can yield a stale or wrong reference
(pointing at the wrong store, or a path that does not exist on the consuming worker).

Construct the reference from a local file instead — `FileReference.from_local(path,
tier=...)` — and let the interceptor fill the durability fields.  See BLDX-1398.

Land as `WARN`: a justified inline `# conformance: ignore[P010] <reason>` records any
unavoidable exception and stays visible in SARIF.

---

## P011 — `RawBytesInContract` {#p011}

**Tier:** `warn` · **Scope:** `app` · **Category:** `storage-seam` · **Autofixable:** — · **Since:** 0.6.0

> Input/Output contract field annotated bytes/bytearray/memoryview — risks the 2MB payload limit

**Rationale:** Temporal enforces a hard 2MB payload limit on workflow/activity I/O. A contract field
carrying raw bytes can silently grow past it in production, causing truncation or a
serialization error at runtime instead of a clear failure. Store the data as a file and
pass a FileReference, which crosses the task boundary as a small durable handle
(BLDX-1398).

An `Input`/`Output` contract subclass declares a field annotated `bytes`, `bytearray`,
`memoryview`, or their `| None` / `Optional[…]` variants.  A raw binary blob on a
contract crosses the task boundary as a Temporal payload, which has a hard 2MB limit; a
blob that grows past it in production fails the workflow with a serialization or size
error rather than a clear, early signal.

Store the data as a file and pass a `FileReference` instead: the bytes live in the
object store and only a small durable handle travels on the contract, well under the
payload limit.  See BLDX-1398.

Land as `WARN`: a justified inline `# conformance: ignore[P011] <reason>` records any
unavoidable exception and stays visible in SARIF.

---

## P012 — `FilePathStringInContract` {#p012}

**Tier:** `warn` · **Scope:** `app` · **Category:** `storage-seam` · **Autofixable:** — · **Since:** 0.6.0

> Input/Output contract field is a `str` path that should be a durable FileReference

**Rationale:** A contract field that carries a file or directory path as a bare string is a
local-filesystem hint: the path is valid on the worker that produced it and meaningless
on the worker that consumes it, so the data does not survive the task boundary. The path
must be a durable FileReference so the interceptor persists and materialises the
underlying file (BLDX-1398).

An `Input`/`Output` contract subclass declares a `str` / `str | None` field whose name
or documentation indicates a file or directory path (e.g. `output_path`, `local_dir`, a
doc string describing a path on disk).  A bare string path is a local-filesystem hint:
it is valid only on the worker that produced it and meaningless on a different worker
that consumes the contract, so the referenced data does not survive transfer across the
task boundary.

Carry the path as a durable `FileReference` instead, so the activity interceptor
persists the underlying file to the object store and materialises it on the consuming
worker.  See BLDX-1398.

This rule is a heuristic — it matches on field name and documentation text — so a string
field that is genuinely not a transferable path will occasionally be flagged.  Suppress
a genuine exception with an inline `# conformance: ignore[P012] <reason>`, which stays
visible in SARIF.

---

## P013 — `UntypedEntrypointBoundary` {#p013}

**Tier:** `block` · **Scope:** `app` · **Category:** `typed-contract-boundary` · **Autofixable:** — · **Since:** 0.6.0

> @entrypoint (or implicit run()) input/output is not an SDK Input/Output subclass

**Rationale:** Every @entrypoint method (and the implicit run() override) is the public API boundary of
the app — the payload that crosses it must be validated, versioned, and evolvable.
Using a primitive, a container, or a class that does not subclass Input/Output bypasses
the SDK's payload-safety validation, config-hash computation, and
backwards-compatibility tracking.  The runtime @entrypoint decorator already rejects
these at import time, so no conforming running app is untyped today — this rule surfaces
the violation earlier (PR/CI) and covers pre-decorator code paths.

A method decorated with `@entrypoint` (or a concrete `run()` override on an `App`
subclass, which is the implicit single-entrypoint form) must declare:

* its non-`self` parameter as a subclass of `Input`   (`application_sdk.contracts`); *
its return type as a subclass of `Output`   (`application_sdk.contracts`).

Violations include: missing annotation, a primitive / container type (`dict`, `list`,
`str`, `Any`, etc. — even subscripted/bounded forms like `dict[str, str]`), or a class
that exists in the scanned source tree but does not transitively subclass
`Input`/`Output` (e.g. a plain `pydantic.BaseModel` subclass or a dataclass).

Suppressed declarations are still emitted to the SARIF report. This rule is `BLOCK`
(suppress-only): an unsuppressed violation fails the conformance gate — suppress with `#
conformance: ignore[P013] <reason>` at the method definition.

---

## P014 — `UntypedTaskBoundary` {#p014}

**Tier:** `block` · **Scope:** `app` · **Category:** `typed-contract-boundary` · **Autofixable:** — · **Since:** 0.6.0

> @task input/output is not an SDK Input/Output subclass

**Rationale:** Every @task method is an internal activity boundary — the payload must be typed and
bounded so the SDK can validate it at the activity layer and detect drift across
deployments.  Using an untyped structure bypasses the SDK's payload-safety enforcement
and makes the task's I/O invisible to dashboards, schema tooling, and the contract
registry.  The runtime @task decorator already rejects these at import time, so no
conforming running app is untyped today — this rule surfaces the violation earlier
(PR/CI).

A method decorated with `@task` must declare:

* its non-`self` parameter as a subclass of `Input`   (`application_sdk.contracts`); *
its return type as a subclass of `Output`   (`application_sdk.contracts`).

Violations include: missing annotation, a primitive / container type (`dict`, `list`,
`str`, `Any`, etc. — even subscripted/bounded forms like `dict[str, str]`), or a class
that exists in the scanned source tree but does not transitively subclass
`Input`/`Output` (e.g. a plain `pydantic.BaseModel` subclass or a dataclass).

Suppressed declarations are still emitted to the SARIF report. This rule is `BLOCK`
(suppress-only): an unsuppressed violation fails the conformance gate — suppress with `#
conformance: ignore[P014] <reason>` at the method definition.

---

## P015 — `UnmodeledBoundedContractField` {#p015}

**Tier:** `warn` · **Scope:** `app` · **Category:** `contract-modeling` · **Autofixable:** — · **Since:** 0.6.0

> Input/Output contract field uses a container of primitives/Any — replace with a typed nested model

**Rationale:** Bounded containers (Annotated[dict[str, str], MaxItems(50)]) pass payload-safety
validation (P001) but are still stringly-typed: keys and values carry no schema, typos
surface only at runtime, and the field is invisible to contract diffing and schema
tooling.  The SDK's make-contract guidance explicitly prefers typed properties over
arbitrary string keys ('avoid stringly-typed contracts where the user can typo a key and
only discover it at runtime').  WARN (not BLOCK) because the bounded form is technically
sanctioned; this is a modeling nudge toward a typed nested model.

A field on an `Input`/`Output` contract whose annotation is a container of primitives or
`Any` — `dict[str, str]`, `list[str]`, `set[int]`, or the bounded equivalents
`Annotated[dict[str, str], MaxItems(N)]` — is considered an unmodeled boundary.  Even
though the bounded form satisfies the payload-safety gate (P001), the container has no
schema: keys and values are opaque strings, typos are runtime-only failures, and the
field is invisible to contract diffing and the SDK's `is_backwards_compatible` checker.

The SDK contract guidance (`make-contract` skill, §6) prefers typed properties / a
nested `pydantic.BaseModel` subclass over arbitrary string keys.

**Exempt:** `list[FooModel]`, `dict[str, FooModel]` — containers of a typed class are
the canonical bounded pattern and are fine.

This rule lands as `WARN` (not `BLOCK`) because the bounded form is technically
sanctioned — this is a modeling nudge, not a gate failure.  Suppress with `#
conformance: ignore[P015] <reason>` when a typed replacement is not feasible.

---

## P016 — `EntryPointContractCodeDrift` {#p016}

**Tier:** `block` · **Scope:** `app` · **Category:** `entrypoint-alignment` · **Autofixable:** — · **Since:** 0.6.0

> Entry-point names in @entrypoint code do not match app/generated/ contract dirs

**Rationale:** Two independent sources define an app's entry-point names: the contract
(contract/app.pkl → pkl eval → app/generated/<name>/ dirs) and the code
(@entrypoint-decorated App methods). poe generate only runs pkl eval and never imports
app code, so the two can drift silently. When they drift, the HTTP create path fails —
/manifest?entrypoint=<name> may return 200 while /input-contract?entrypoint=<name>
returns 404 — with no caller-side workaround (BLDX-1425).

An app's entry-point names are declared in two independent places:

1. **Contract (toolkit-owned):** `contract/app.pkl` → `pkl eval` →
`app/generated/<name>/manifest.json`.  The `<name>` is what callers pass as
`?entrypoint=<name>` and the Automation Engine `workflow_type`.

2. **Code (developer-owned):** `@entrypoint`-decorated `App` methods. The registered
wire name is the explicit `name=` argument or the method name in kebab-case
(`extract_metadata` → `extract-metadata`).

`poe generate` only runs `pkl eval`; it never imports app code, so the two can drift
silently.  Drift causes the HTTP create path to fail — `/manifest?entrypoint=crawler`
returns 200 while `/input-contract?entrypoint=crawler` returns 404 — with no caller-side
workaround (BLDX-1425).

**This rule checks (per repo):**

* **Multi-entry-point apps** (`app/generated/` has named subdirs each containing
`manifest.json`): the set of `@entrypoint` wire names must exactly equal the set of
subdir names.

* **Single-entry-point apps** (root `app/generated/manifest.json`, no subdirs): every
code `@entrypoint` must be declared as a DAG route — a `workflow_type` of the form
`"<app>:<wire-name>"` in the manifest DAG (the BLDX-1342 route/card split: one
marketplace card plus additional DAG-invoked routes, without per-entry-point bundle
subdirs). A code `@entrypoint` the DAG never routes to is flagged. When the manifest
declares no such routes (legacy), the check falls back to permitting at most one
`@entrypoint`.

* **Non-literal `name=`:** a `@entrypoint(name=variable)` that cannot be statically
resolved is flagged as unverifiable.

* **No `app/generated/`:** the repo is not a native-app-contract repo; this rule is a
no-op.

**Remediation**

The contract `app/generated/` tree is the authoritative source; align code to it as the
default fix:

1. For each `@entrypoint` name in *code* that is not in the contract, pin it:
`@entrypoint(name="<contract-name>")`.  Confirm the pairing (code name → contract name)
intentionally — renaming an entry point is a **breaking wire change** (the Temporal
workflow type and `?entrypoint=` value both change; coordinate with callers).

2. For each contract name not matched in code, add or rename an
`@entrypoint(name="<missing-name>")` on the corresponding App method.

3. If the *code* name is the intended one and the contract is wrong, update the
`entrypoints { new Entrypoint { name = "..." } }` block in `contract/app.pkl` and re-run
`pkl eval` so the `app/generated/<name>/` dir and `workflow_type` follow — never
hand-edit `app/generated/` (C002 catches stale generated artifacts).

4. For a single-entry-point app that now has multiple `@entrypoint`s, either add named
`Entrypoint` blocks in `contract/app.pkl` or remove the extra `@entrypoint`.

Land as `BLOCK`: a justified inline `# conformance: ignore[P016] <reason>` on the
decorator line (or the comment-only line directly above it) records the exception and
stays visible in SARIF.

---

## P017 — `ManualWorkerBootstrap` {#p017}

**Tier:** `warn` · **Scope:** `app` · **Category:** `entrypoint-conformance` · **Autofixable:** — · **Since:** 0.6.0

> App manually constructs a Temporal worker or client instead of using the SDK launcher

**Rationale:** The SDK launcher owns worker startup: the base-image CLI (`application-sdk --mode
worker|combined`) or `run_dev_combined` in local dev auto-discovers every registered
`App` and `task` — the app never wires workers itself. Calling `create_worker`,
`create_temporal_client`, or `AppWorker` directly, importing removed v2 boot surface, or
calling v2 lifecycle methods couples the app to infrastructure details that the SDK
manages on its behalf, and breaks the guarantee that every app starts up in a
consistent, SDK-controlled way (BLDX-1411).

The app calls `create_worker(...)`, `create_temporal_client(...)`, or `AppWorker(...)`
directly, imports removed v2 worker/client boot surface (`application_sdk.worker`,
`application_sdk.application`, `application_sdk.clients.temporal`), or calls a
distinctive v2 lifecycle method (`setup_workflow`, `start_workflow`, `start_worker`).

The SDK launcher owns worker startup entirely — subclass `App`, define `@task` methods
and a `run()` / `@entrypoint` method (typed `Input`/`Output`), and set
`ATLAN_APP_MODULE=module:ClassName`. Production launches via the base-image CLI
(`application-sdk --mode worker|combined --app ...`); local dev uses `await
run_dev_combined(MyApp, credentials={...}, ...)`. Workers and activities are
auto-discovered from `AppRegistry` / `TaskRegistry` — there is nothing to wire.

Files under `tests/integration/` are exempt from the construction-call and
lifecycle-call violation classes: those harnesses need an in-process worker/client
handle to submit workflows and tear down per test, and `run_dev_combined` blocks until
shutdown with no handle-returning mode to substitute. The removed-v2-import class stays
enforced everywhere, including under `tests/integration/`.

Land as `WARN`: a justified inline `# conformance: ignore[P017] <reason>` records any
unavoidable exception outside that exemption and stays visible in SARIF.

---

## P018 — `ManualServerBootstrap` {#p018}

**Tier:** `warn` · **Scope:** `app` · **Category:** `entrypoint-conformance` · **Autofixable:** — · **Since:** 0.6.0

> App manually constructs a FastAPI/uvicorn HTTP server instead of using the SDK launcher

**Rationale:** The SDK owns the FastAPI handler server: `run_handler_mode` and `run_combined_mode`
create and manage the HTTP server on the app's behalf. Constructing `FastAPI(...)`
directly, calling `uvicorn.run(...)` manually, or invoking v2 server lifecycle methods
(`setup_server`, `start_server`, `include_router`) couples the app to server-startup
mechanics that the SDK controls, and prevents the SDK from evolving the handler server
safely (BLDX-1411).

The app constructs `FastAPI(...)` directly (name imported from `fastapi`), calls
`uvicorn.run(...)`, or invokes a distinctive v2 server lifecycle method (`setup_server`,
`start_server`, `include_router`).

The SDK owns the FastAPI handler server: launch via the base-image CLI (`application-sdk
--mode handler|combined --app ...`) or `run_dev_combined` in local dev — the SDK creates
and configures the FastAPI app and uvicorn server internally.  Express HTTP surface
through `@entrypoint` methods on the `App` subclass; trigger them via `POST
/workflows/v1/start?entrypoint=<name>`.

Land as `WARN`: a justified inline `# conformance: ignore[P018] <reason>` records any
unavoidable exception and stays visible in SARIF.

---

## P019 — `RawHttpToAtlan` {#p019}

**Tier:** `warn` · **Scope:** `both` · **Category:** `client-seam` · **Autofixable:** — · **Since:** 0.7.0

> Raw HTTP request to an Atlan endpoint (/api/meta, /api/service) instead of the pyatlan client

**Rationale:** pyatlan is a core dependency and the single supported client for Atlan services; the SDK
exposes it via application_sdk.credentials (AtlanClientMixin /
create_async_atlan_client). Hand-rolling raw HTTP (httpx/requests/aiohttp/urllib)
against an Atlan endpoint re-implements auth, retries, pagination, and typing that
pyatlan already provides, and drifts from the contract every other app follows
(BLDX-1430).

A raw HTTP call — `httpx`/`requests`/`aiohttp` request method or
`urllib.request.urlopen`/`Request` — targets an Atlan service: its URL statically
contains `/api/meta` (Atlas metastore) or `/api/service` (Heracles).  `pyatlan` is the
supported client for these and is a core SDK dependency; the SDK wraps it behind
`application_sdk.credentials` — `AtlanClientMixin.get_or_create_async_atlan_client()`
for App subclasses, or `create_async_atlan_client(cred)` for ad-hoc use — so callers get
auth, retries, pagination, typed models, and the `x-atlan-app-*` observability headers
for free.  Hand-rolling HTTP reinvents all of that and diverges from the fleet-wide
contract — see BLDX-1430.

Constructing `pyatlan`'s `AtlanClient` / `AsyncAtlanClient` directly is **not** flagged
— that is reuse of the blessed dependency.

This rule is a **heuristic**: the request destination is a runtime value and pyatlan
hides routing, so the only static Atlan signal is the URL path marker.  It matches the
marker against the call's URL argument (string literal, f-string literal segments, or
`+` concatenation); a URL assembled entirely from variables will not be caught.
Legitimate non-Atlan HTTP (Dapr `/v1.0`, Segment, Prometheus, Temporal metrics,
third-party connector APIs, proxy detection, `urllib.parse` helpers) carries no such
marker and stays silent.

Land as `WARN`: a justified inline `# conformance: ignore[P019] <reason>` records any
unavoidable exception and stays visible in SARIF.

---

## P020 — `NonDeterministicPrimitiveInWorkflow` {#p020}

**Tier:** `warn` · **Scope:** `both` · **Category:** `determinism` · **Autofixable:** — · **Since:** 0.8.0

> Non-deterministic time/uuid/sleep/random call in workflow-context code

**Rationale:** Temporal workflow code is re-executed on replay, so it must be deterministic: the same
inputs must always produce the same sequence of commands. Reading the wall clock
(datetime.now/time.time), generating a UUID (uuid.uuid4), sleeping on the wall clock
(time.sleep/asyncio.sleep), or drawing randomness produces a different value on replay
and corrupts the workflow history. The SDK exposes deterministic equivalents through its
seam — self.now()/now, self.uuid()/uuid4, sleep — that record their result in history so
replay is faithful.

Inside an `App` subclass's workflow-context method (`run`, an `@entrypoint` method, or a
`@signal` / `@query` / `@update` handler) a call reads wall-clock time, generates a
UUID, sleeps, or draws randomness.  Workflow code is replayed deterministically, so use
the SDK seam instead: `self.now()` or `from application_sdk.app import now`;
`self.uuid()` or `from application_sdk.app import uuid4`; `from application_sdk.app
import sleep`.  `@task` activity bodies are exempt — they run once and may do any of
this.

Randomness (`random` / `secrets` / `os.urandom`) is also flagged: it is a real
determinism bug, but the SDK exposes no deterministic-random primitive, so the fix is to
move it into a `@task` or raise a seam request with the SDK team rather than a
mechanical swap.

Matching is receiver-anchored: only a `.now()` whose receiver is `datetime` is flagged,
so the sanctioned `self.now()` is untouched. Land as `WARN`; record an unavoidable
exception with `# conformance: ignore[P020] <reason>`.

---

## P021 — `SideEffectIoInWorkflow` {#p021}

**Tier:** `warn` · **Scope:** `both` · **Category:** `determinism` · **Autofixable:** — · **Since:** 0.8.0

> File / network / env / process I/O in workflow-context code

**Rationale:** Workflow code is replayed, so any interaction with the outside world — opening a file,
making a network call, reading the environment, spawning a thread or process — runs
again on every replay and produces non-deterministic, un-recorded results that break
replay correctness. Side effects belong in a @task activity, which runs exactly once and
whose result is durably recorded in workflow history.

Inside an `App` subclass's workflow-context method a call performs side-effecting I/O —
`open`, `requests`/`httpx`/`urllib`, `socket`, `subprocess`,
`threading`/`multiprocessing`, `os.getenv` / `os.environ[...]`.  Move it into a `@task`
method: workflow code must be deterministic, and activities are where I/O and external
state belong.

The detected surface is a curated high-signal subset, not an exhaustive list of every
I/O API.  Remediation is structural (extract a `@task`), so findings route to residue
rather than an autofix.  Land as `WARN`; suppress a reviewed exception with `#
conformance: ignore[P021] <reason>`.

---

## P022 — `UnawaitedCoroutine` {#p022}

**Tier:** `warn` · **Scope:** `both` · **Category:** `async-correctness` · **Autofixable:** — · **Since:** 0.8.0

> A same-class async method is called without await (dropped coroutine)

**Rationale:** SDK app methods (run, @task, @entrypoint, interaction handlers) are async and return a
coroutine. Calling one like a sync function — 'self.fetch(x)' as a bare statement
instead of 'await self.fetch(x)' — constructs the coroutine and immediately discards it,
so the work never runs and the bug is silent (no error, no result). This is the most
common way apps misuse the SDK's async surface.

A bare expression statement calls a same-class `async def` method via `self.<name>(...)`
without `await` and without wrapping it in `asyncio.create_task` / `asyncio.gather`.
The call returns a coroutine that is never scheduled, so the work silently does nothing.
Add `await` (or schedule it explicitly if concurrency is intended).

Scope is intentionally narrow — a bare `self.<async-method>()` statement inside an
`async def` — so the target is provably a coroutine and the finding is
false-positive-free.  Land as `WARN`; suppress with `# conformance: ignore[P022]
<reason>`.

---

## P023 — `BlockingCallInAsyncDef` {#p023}

**Tier:** `warn` · **Scope:** `both` · **Category:** `async-correctness` · **Autofixable:** — · **Since:** 0.8.0

> Event-loop re-entry bridge or blocking sync call inside an async def

**Rationale:** Inside an async function the event loop must never be blocked or re-entered. Calling
asyncio.run()/loop.run_until_complete() from within a running loop raises or deadlocks;
calling a synchronous blocking library (requests, time.sleep) stalls the loop and every
other coroutine on it. Tree-scale filesystem work (shutil.rmtree / copytree / move, and
the SafeFileOps rmtree / move wrappers) blocks for as long as the tree takes to walk,
which starves a @task's auto-heartbeat and makes Temporal retry an activity that is
still making progress — App.cleanup_files shipped with exactly that bug while this
rule's inventory was network/sleep-only. The correct pattern is to await an async
equivalent, or offload blocking work via App.run_in_thread() inside a @task — not to
bridge async with a sync workaround.

Inside an `async def`, code either re-enters the event loop (`asyncio.run(...)` or
`*.run_until_complete(...)`, including `loop.run_until_complete` /
`asyncio.get_event_loop()....`), makes a blocking synchronous call (`requests.*`,
`urllib.request.*`, `time.sleep`), or does tree-scale filesystem work (`shutil.rmtree` /
`shutil.copytree` / `shutil.move`, plus the SDK's `SafeFileOps.rmtree` /
`SafeFileOps.move` wrappers).  Await the coroutine directly, or offload genuinely
blocking work with `App.run_in_thread()` inside a `@task`.

Single-syscall filesystem operations (`os.remove`, `os.unlink`, `os.rmdir`) are **not**
flagged: one inode operation does not earn a thread hop, and flagging them would bury
the tree-scale findings.  Tree *traversal* (`os.walk`, `os.scandir`) is likewise out of
scope for now.

Blocking sync I/O and filesystem work are reported only **outside** workflow context —
inside workflow methods the same calls are owned by P020 (sleep) and P021 (file/network
I/O), so they are not double-counted.  Remediation is a restructure, so findings route
to residue.  Land as `WARN`; suppress with `# conformance: ignore[P023] <reason>`.

---

## P024 — `SyncAtlanClientInApp` {#p024}

**Tier:** `warn` · **Scope:** `both` · **Category:** `async-correctness` · **Autofixable:** — · **Since:** 0.8.0

> Synchronous pyatlan AtlanClient used instead of the async client

**Rationale:** pyatlan (atlan-python) ships both a synchronous AtlanClient and an asynchronous
AsyncAtlanClient. App code runs in an async execution path (Temporal activities, the
FastAPI server), so the sync client blocks the event loop on every Atlan call and stalls
every other coroutine on it. The async client must be used instead — ideally through the
SDK seam (create_async_atlan_client /
AtlanClientMixin.get_or_create_async_atlan_client), which returns a configured,
observability-stamped AsyncAtlanClient. This complements P019 (use pyatlan, not raw
HTTP) by requiring the async variant of that client.

App code constructs or invokes pyatlan's synchronous `AtlanClient` (or the vendored
`pyatlan_v9` equivalent) — its constructor or a factory like
`AtlanClient.from_token(...)`.  Because the app's execution path is async, the sync
client blocks the event loop.  Use `AsyncAtlanClient` (`pyatlan.client.aio`) —
preferably via the SDK seam: `await self.get_or_create_async_atlan_client(cred)` on an
`App` that mixes in `AtlanClientMixin`, or `create_async_atlan_client(cred)` from
`application_sdk.credentials`.

Matching is receiver-anchored: `AsyncAtlanClient` and the SDK seam helpers are not
flagged, only the sync `AtlanClient` under a pyatlan root.  Closing it makes downstream
calls `await`-ed, so remediation is a restructure routed to residue.  Land as `WARN`;
suppress with `# conformance: ignore[P024] <reason>`.

---

## P025 — `AppNameContractCodeDrift` {#p025}

**Tier:** `block` · **Scope:** `app` · **Category:** `app-name-alignment` · **Autofixable:** — · **Since:** 0.9.0

> App name in code, atlan.yaml, or .env.example do not agree

**Rationale:** Three independent sources declare an app's name: the App subclass (code, authoritative),
atlan.yaml top-level name: (contract), and ATLAN_APPLICATION_NAME in .env.example (env).
When they diverge, extract and publish write/read at different artifact paths, leaving 0
files for publish to find and stalling the workflow (BLDX-1491, observed in the MSSQL
connector).

Three independent sources declare an app's name:

1. **Code (authoritative):** the `App` subclass (or an SDK template subclass).  The SDK
derives the name as `cls.name or _pascal_to_kebab(cls.__name__)`: an explicit `name =
"..."` wins; otherwise the PascalCase class name is kebab-cased
(`MSSQLMetadataExtractor` → `mssql-metadata-extractor`).

2. **Contract:** `atlan.yaml` top-level `name:` (generated from `contract/app.pkl`).
This value becomes `ATLAN_APPLICATION_NAME` at deploy and drives the task-queue prefix,
`workflow_type`, and log-identity tags.

3. **Env:** `ATLAN_APPLICATION_NAME=...` in `.env.example`, read at runtime into
`constants.APPLICATION_NAME` (defaults silently to `"default"` when unset).

`poe generate` runs `pkl eval` but never imports app code, so the three can drift
silently.  Drift causes artifact-path mismatches: `App.upload()` writes to
`artifacts/apps/<code-name>/…` while publish reads `artifacts/apps/<contract-name>/…`,
finding 0 files (BLDX-1491).

**This rule checks (per repo):**

* **Code vs contract:** the code-derived app name must equal the `atlan.yaml` top-level
`name:` value.

* **Code vs env:** the code-derived app name must equal `ATLAN_APPLICATION_NAME` in
`.env.example`.

* **Non-literal `name =`:** a class-level `name = variable` that cannot be statically
resolved is flagged as unverifiable.

**No-op when:**

* No App-family leaf class is found in the Python source (SDK repos, utility libraries).
* No `atlan.yaml` and no `.env.example` are present (non-native-app repos). * Multiple
App-family leaf classes with different names are found (bundle repos — handled by P016
instead).

**Remediation**

The code-derived name is the source of truth (mirrors
`AppRegistry.resolve_running_app_name` from PR #2380).  To fix drift:

1. Add an explicit `name = \"<intended-name>\"` class variable to the App subclass so
the code name is unambiguous.

2. Update `contract/app.pkl` `name` to match and re-run `pkl eval` (never hand-edit
`atlan.yaml` — C002 catches stale generated artifacts).

3. Update `ATLAN_APPLICATION_NAME` in `.env.example` to match the code name.

Suppress with `# conformance: ignore[P025] <reason>` on the App class definition line
(or the comment-only line directly above it) when a deliberate mismatch is justified.

---

## P026 — `GetattrOnTypedContractField` {#p026}

**Tier:** `warn` · **Scope:** `app` · **Category:** `typed-contract-boundary` · **Autofixable:** — · **Since:** 0.9.0

> getattr() with a default on a typed entrypoint/task contract param — defeats the typed boundary

**Rationale:** P013/P014 buy a typed Input/Output boundary; reading a declared field via getattr(param,
'field', default) spends it. A renamed or removed contract field silently yields the
default instead of raising AttributeError, so contract drift goes undetected at the call
site and the type annotation stops being load-bearing.

Inside an `@entrypoint` or `@task` method, a declared field of a typed `Input`/`Output`
contract parameter is read via `getattr(param, "field", default)` instead of attribute
access. Only the three-argument form (a *default* present) is flagged: it silently
substitutes the default when the field is renamed or removed, where `param.field` would
raise `AttributeError` and surface the drift.  This defeats the typed boundary P013/P014
establish and hides the change from the contract ledger (B005/B006), which only sees
schema edits, not reads.

Fix: use attribute access (`param.field`).  Suppress with `# conformance: ignore[P026]
<reason>` only when a value genuinely may be absent and the contract models it as
`Optional` with a real default.

---

## P027 — `AppStateAsCrossTaskChannel` {#p027}

**Tier:** `warn` · **Scope:** `app` · **Category:** `state-seam` · **Autofixable:** — · **Since:** 0.9.0

> get_app_state(KEY) with no matching set_app_state(KEY) writer anywhere — dead cross-task side channel

**Rationale:** app_state is an in-memory bag scoped to a single execution id. Using it to hand data
between tasks silently no-ops across activity/worker boundaries. A get_app_state(KEY)
whose KEY is never written by any set_app_state(KEY) is a dead side channel — the read
always falls through to its default, so the intended hand-off never happens.

An `App.get_app_state(KEY)` read whose `KEY` is never written by a `set_app_state(KEY,
<non-None value>)` anywhere in the app (a writer that only stores `None` — a placeholder
'claim ownership' write whose real populating write never lands — does not count).
`app_state` is in-memory and keyed by execution id, so it cannot carry data across
activity or worker boundaries; a read with no writer always returns the default and the
optimisation it was meant to enable is dead code.

Cross-file: keys are resolved through module-level string constants, so a key defined in
one module and read in another is matched.  Keys that do not resolve to a string literal
are ignored on both sides.

Fix: pass cross-task data through the typed entrypoint/task contract. Suppress with `#
conformance: ignore[P027] <reason>` only when the writer is genuinely external to the
scanned source.

---

## P028 — `ManualQualifiedNameFString` {#p028}

**Tier:** `warn` · **Scope:** `app` · **Category:** `asset-modeling` · **Autofixable:** — · **Since:** 0.9.0

> Asset qualifiedName composed by hand with an f-string instead of via pyatlan asset creators

**Rationale:** qualifiedName is the identity primitive for every Atlan asset (dedup, lineage, linking).
Building it with an f-string scatters the grammar (segments, order, separator, escaping)
across every connector, so a single grammar change breaks each one independently and
silently. The pyatlan asset .creator() factories own the grammar centrally.

An f-string composes a slash-delimited `qualifiedName` — it both interpolates a
`*qualified_name` / `*_qn` value and contains a `/` separator (e.g.
`f"{connection_qualified_name}/{schema}"`). qualifiedName is Atlan's asset identity;
hand-building it duplicates the grammar across the fleet, and a grammar change (tenant
scoping, escaping) then breaks every connector independently with no single source of
truth.

Not flagged — object-store keys: a qn reference preceded by a `/`-bearing literal
segment (e.g. `f"persistent-artifacts/.../{connection_qualified_name}/publish-state"`)
is a storage path that embeds a qn, not an asset qualifiedName rooted at its parent qn,
so it is not flagged.

Fix: construct assets through the pyatlan asset `.creator()` factories, which compute
qualifiedName from typed parent references.  WARN tier — suppress with `# conformance:
ignore[P028] <reason>` where a raw qualifiedName string is genuinely required.

---

## P029 — `SdrManifestMissingAgentJson` {#p029}

**Tier:** `block` · **Scope:** `app` · **Category:** `sdr-readiness` · **Autofixable:** — · **Since:** 0.9.0

> SDR agent manifest must surface agent_json + extraction_method at the top level of dag.extract.inputs.args

**Rationale:** In SDR (agent) mode the platform (Heracles/AE) derives the customer agent task queue and
fills the credential-routing spec from agent_json + extraction_method at the TOP LEVEL
of dag.extract.inputs.args. If an agent manifest nests them only under args.metadata,
the platform can't see them and strands the agent extraction on the cloud queue
(atlan-tableau-app / atlan-snowflake-app); if it omits them entirely the agent receives
no credentials and produces zero assets — the MSSQL silent-failure regression
(atlan-mssql-app#177, DISTR-752). Either way the workflow reports 'success', invisible
to status-only test pipelines.

For apps declaring `self_deployed_runtime: true` in `atlan.yaml`, every *agent
extraction* `manifest.json` under `app/generated/` must surface both `agent_json` and
`extraction_method` at the TOP LEVEL of `dag.extract.inputs.args`. An agent extraction
is any node whose args carry the `{{agent-json}}` routing placeholder; a miner/QI or
`clean` entrypoint that carries none is exempt.

In SDR (agent) mode the platform (Heracles/AE) derives the agent task queue
(`atlan-<agent-name>`) and fills the credential-routing spec from these top-level fields
at dispatch. Two failure modes:

* **Misplaced / partial** — the fields are nested only under   `args.metadata` (or
`extraction_method` is omitted). The platform   can't derive the queue, so the
extraction strands on the cloud queue   `atlan-<app>-<deployment>` — which the
customer's firewalled source   can't reach — and never progresses (atlan-tableau-app,
atlan-snowflake-app). * **Missing entirely** — no manifest declares agent routing at
all; the   agent receives no credentials and writes zero assets while the   workflow
reports success (atlan-mssql-app#177). The hand-written SDR   test supplied the value
directly, bypassing the manifest, so it   passed; adopting
`BaseSDRIntegrationTest.manifest_path` (T003)   closes the test gap, this rule closes
the static gap.

**Remediation:** surface `agent_json` + `extraction_method` at the extract-args top
level in the app's `contract/app.pkl` (keep them under `metadata` too if the connector
reads there) and re-run `pkl eval` to regenerate `app/generated/<name>/manifest.json`.
Do not hand-edit the generated manifest — C002 tracks drift.

Toolkit-version notes (from fleet remediation):

* Manifests generated by **older contract-toolkit versions** may not   emit the
`{{agent-json}}` slot at all — the missing-entirely mode   above.  The fix is to
**regenerate with the current toolkit**, not to   hand-patch the slot in. *
`flatManifestArgs: true` is required in the contract so   `agent_json` +
`extraction_method` land at the extract-args TOP   LEVEL; with `flatManifestArgs: false`
the toolkit buries them under   `args.metadata`, which is exactly the misplaced mode
above   (agent-queue routing breaks when the fields are only in metadata). * The
platform (AE) fetches the manifest from the **deployed app   service**, not from the
repo — a manifest fix only takes effect once   the deployed image ships it.  A merged PR
alone does not clear the   runtime failure.

---

## P030 — `SdrUploadNotCalled` {#p030}

**Tier:** `warn` · **Scope:** `app` · **Category:** `sdr-readiness` · **Autofixable:** — · **Since:** 0.9.0

> SDR app has no self.upload() call in source — ENABLE_ATLAN_UPLOAD path unreachable

**Rationale:** In SDR mode the ENABLE_ATLAN_UPLOAD env var gates whether extracted assets are
transferred to the Atlan tenant bucket. If the app never calls self.upload(), the
ENABLE_ATLAN_UPLOAD path is unreachable: extraction completes with status 'success' but
no assets land in the bucket — a regression that slipped through status-only SDR CI
(DISTR-752). This rule detects the structural absence of the upload call in app source
so the regression class is caught at static-analysis time rather than in a customer
deployment. Fleet remediation confirmed the finding is REAL more often than assumed: 4
of 15 swept connectors had a genuine silent-zero-asset publish behind a P030 finding
that had been presumed a false positive.

For apps declaring `self_deployed_runtime: true` in `atlan.yaml`, at least one Python
source file (outside `tests/`) must contain a `self.upload(` call.

`App.upload()` is the SDK's sanctioned way to transfer extracted assets to the Atlan
tenant bucket (the upstream store) in SDR mode. It is gated by `ENABLE_ATLAN_UPLOAD`:
when the env var is `true` the upload runs and assets land in the bucket; when false,
the app runs in a local-only mode.  If `self.upload()` is never called anywhere in the
app source, the gate is structurally unreachable — the workflow completes with status
'success' regardless of the flag value, and no assets move to the bucket in production.

A *working* hand-rolled `upload_to_atlan` bridge is reported by **P042**, not here:
bytes do move, so it is not this rule's silent-zero-asset shape.  It is not silence
either — see P042 for why a working bridge still has to change.

**What does NOT satisfy this rule** (patterns fleet remediation found behind real
silent-zero-asset publishes):

* **Delegating to SDK `SqlApp.run()`** — `run()` persists   extraction output to the
*deployment* store only; the publish stage   reads the *tenant bucket*.  Extraction
'succeeds' and 0 assets   publish (observed for an OLAP connector in fleet testing). *
**A no-op `upload_to_atlan` stub** — a method defined with no   storage-transfer call in
its body (e.g. a body that only logs or   returns, with a comment claiming another stage
owns the transfer).   The checker flags these definitions specifically (observed for a
document-store connector in fleet testing).  A transfer is matched   on whole
`_`-separated tokens of the callee name, never a bare   substring, so `compute_summary`
/ `output_stats` /   `get_inputs` do not read as transfers while `upload_file` /
`storage_upload_file` / `migrate_from_objectstore_to_atlan` do;   a bare verb (`sync`,
`push`, `put`, `copy`) additionally   requires a store-naming receiver.  Delegation is
resolved one level   into the same class, and an abstract declaration   (`raise
NotImplementedError` / `pass` / `...`) is not a stub. * **A deprecated SDK upload shim**
that re-roots artifacts under the   code-derived app name and drops the `transformed/`
segment — the   transfer runs but publish finds nothing at the expected prefix
(observed for a managed-Postgres connector in fleet testing; see   also P038 for the
mis-rooting class). * **Inline writers that target the deployment store only** — output
is   written, but never to the tenant bucket (observed for a key-value   store connector
in fleet testing).

**Never mark a P030 finding a false positive without a green full-DAG e2e** (extract →
publish) proving assets actually land in Atlas.  The workflow status is not evidence —
every failure mode above reports 'success'.  The live e2e's asset-count floor is the
arbiter.

This is a WARN (not BLOCK): some apps are legitimately preflight-only or delegate upload
to a base-class method defined in the SDK template. Review the finding before
suppressing — if the app genuinely performs an extract-and-upload cycle, add `await
self.upload(...)` to the `run()` method or the relevant `@entrypoint` method.

Note: P008 flags `self.upload()` *inside* `@task` methods (the wrong location); P030
flags the *absence* of any upload call; P042 flags a hand-rolled bridge standing in for
it.  All three should be clean for a correctly-wired SDR app.

Exemption: this rule is skipped for apps whose `contract/app.pkl` sets `pipeline.publish
= null` (no publish stage), which compiles to a generated manifest with no `dag.publish`
node.  An extract-only app has nothing to hand the extracted assets off to, so the
absence of `self.upload()` is by design, not a gap.

---

## P031 — `SharedDefaultExecutorOffload` {#p031}

**Tier:** `warn` · **Scope:** `both` · **Category:** `async-correctness` · **Autofixable:** — · **Since:** 0.13.0

> Thread offload onto asyncio's shared default executor instead of run_in_thread()

**Rationale:** asyncio.to_thread(...) and run_in_executor(None, ...) both dispatch onto asyncio's
shared default executor. Temporal's Python SDK uses that same default executor
internally for its own scheduling, so routing long-running blocking work through it can
exhaust the pool and deadlock the worker. The SDK exposes a dedicated escape hatch,
run_in_thread(), which dispatches onto its own thread pool specifically to avoid this
contention — a prior fix shipped a raw asyncio.to_thread(...) call that nothing caught
in review.

A call offloads blocking work onto asyncio's **shared default** executor instead of the
SDK's dedicated `run_in_thread()` pool: `asyncio.to_thread(...)`, or
`run_in_executor(None, ...)` (including `loop.run_in_executor(None, ...)` /
`asyncio.get_event_loop().run_in_executor(None, ...)`).  Temporal's Python SDK uses that
same default executor for its own internal scheduling, so sharing it with long-running
blocking calls can exhaust the pool and deadlock the worker.  Use `run_in_thread()` —
`App.run_in_thread()` or `self.task_context.run_in_thread()` — which dispatches onto the
SDK's own dedicated `sdk-blocking-*` thread pool.

`run_in_executor(<some-executor>, ...)` with any executor other than `None` is not
flagged — a call-site-owned `ThreadPoolExecutor` is not the shared-pool contention this
rule targets. `application_sdk/execution/heartbeat.py` is exempt: that is where
`run_in_thread()`'s own dedicated-executor dispatch lives.

Remediation is a restructure (swap in `run_in_thread()`), so findings route to residue.
Land as `WARN`; suppress a reviewed exception with `# conformance: ignore[P031]
<reason>`.

---

## P032 — `ReservedPreflightActivityName` {#p032}

**Tier:** `warn` · **Scope:** `app` · **Category:** `preflight-gate` · **Autofixable:** — · **Since:** 0.15.0

> An app @task registers the 'preflight' activity name reserved by the SDK gate

**Rationale:** The SDK injects a mandatory pre-extraction gate as the activity '{app_name}:preflight'.
An app @task that also registers the 'preflight' activity name collides with it: the
worker raises WorkerActivityNameCollisionError at boot and never starts. Catching the
collision statically surfaces it in the PR instead of on the first deploy.

The SDK reserves the activity name `{app_name}:preflight` for the injected preflight
gate and registers it unconditionally on the worker. An app `@task` whose effective
activity name is `preflight` (an explicit `@task(name="preflight")` or a bare `@task` on
a method named `preflight`) collides with the reserved name; worker boot fails with
`WorkerActivityNameCollisionError`.

Remediation: rename the task, or fold its logic into the app's `Handler.preflight_check`
(which the gate already calls). A non-literal `@task(name=<expr>)` is not statically
resolvable and is not flagged.

---

## P033 — `DuplicateInWorkflowPreflight` {#p033}

**Tier:** `warn` · **Scope:** `app` · **Category:** `preflight-gate` · **Autofixable:** — · **Since:** 0.15.0

> App defines Handler.preflight_check and a separate preflight-named @task that will drift

**Rationale:** An app that defines Handler.preflight_check AND its own preflight-named @task has two
preflight implementations that inevitably drift. The gate runs preflight_check, so the
app-owned activity is redundant — the exact anti-pattern the SDK-native gate eliminates.

When an app declares a `Handler.preflight_check` and also registers its own
preflight-named `@task` (any `@task` whose effective name contains `preflight` as a
token but is not the reserved gate name — that exact case is P032), the two preflight
paths diverge over time. The SDK gate invokes `Handler.preflight_check`; the app-owned
activity is dead weight that silently rots.

Remediation: delete the app-owned preflight activity and keep the single
`Handler.preflight_check` implementation the gate calls.

---

## P034 — `UntypedPreflightCheckFailure` {#p034}

**Tier:** `warn` · **Scope:** `app` · **Category:** `preflight-gate` · **Autofixable:** — · **Since:** 0.15.0

> PreflightCheck(passed=False) constructed without a typed error= — untyped failure

**Rationale:** A failed PreflightCheck constructed without a typed error= falls back to the generic
PREFLIGHT_CHECK_FAILED code, so the Automation Engine and the UI lose the
category/code/audience/suggested_action the typed form carries on the wire. This points
at the exact lines to migrate to typed failures.

A `PreflightCheck` with an explicit `passed=False` and no typed `error=` (absent, or the
literal `None`) is an untyped failure: the gate falls back to the generic
`PREFLIGHT_CHECK_FAILED` code and only the deprecated free-text `message` reaches the
caller. The typed form `error=AuthError(message=..., suggested_action=...,
cause=exc).to_failure_details()` carries category / code / audience / retryable /
suggested_action to the Automation Engine and the UI.

Only an explicit `passed=False` literal is flagged; a failure expressed purely by the
default `passed` and a non-literal `passed` are left alone to keep false positives near
zero. A locally-defined, non-SDK class named `PreflightCheck` is not flagged.

---

## P035 — `PreflightMetadataContractParity` {#p035}

**Tier:** `warn` · **Scope:** `app` · **Category:** `preflight-gate` · **Autofixable:** — · **Since:** 0.15.0

> A preflight_check metadata key is not declared on any entrypoint Input contract

**Rationale:** On the gate path metadata is rebuilt from the extraction input's model_dump, so a
metadata key that is not a field on any entrypoint Input contract is silently absent — a
defensive input.metadata.get(key, default) read then passes vacuously with the wrong
config. No runtime signal can catch this class of silent drift; the static check is the
only guard.

The preflight gate does not forward the live UI form: it rebuilds
`PreflightInput.metadata` from the extraction input's `model_dump()`
(`_config_from_snapshot`), so only fields declared on an entrypoint `Input` contract
survive. A key read inside `preflight_check` via `input.metadata.get("key", ...)` or
`input.metadata["key"]` that is absent from the union of every entrypoint Input
contract's fields is silently missing on the gate path, so a defensive `.get(key,
default)` read passes vacuously with the wrong configuration (e.g. database scoping
silently dropped).

Remediation: declare the key as a field on the extraction input contract (matching the
UI form), or stop reading it in `preflight_check`. Keys are compared to contract field
names with underscore/hyphen normalization; field aliases are not treated as allowed
because `model_dump` emits field names, not aliases. The rule does not fire when no
entrypoint Input contract is resolvable or when a contract (or an in-repo ancestor) opts
into extra keys via either `model_config` form: `ConfigDict(extra="allow")` or
`{"extra": "allow"}`.

---

## P036 — `HandRolledProcessIsolation` {#p036}

**Tier:** `warn` · **Scope:** `both` · **Category:** `async-correctness` · **Autofixable:** — · **Since:** 0.15.0

> Bare ProcessPoolExecutor / multiprocessing child instead of the run_fault_isolated() / run_best_effort() seam

**Rationale:** A native fault — a SIGSEGV in a C extension — is not a Python exception: it bypasses
every try/except and, in a worker thread, kills the whole Temporal worker mid-poll. The
SDK exposes a sanctioned child-process seam for this: run_fault_isolated() runs the work
in an isolated process so the fault is contained as a catchable BrokenProcessPool, and
run_best_effort() layers warn-and-continue on top for non-essential work. Hand-rolling a
ProcessPoolExecutor or multiprocessing child re-implements that seam without its crash
containment, timeout, spawn-not-fork safety, and width management, and fragments the
worker's process model — the class of bug behind the CNCT-85 worker crash.

Code constructs a process-based execution primitive directly —
`ProcessPoolExecutor(...)` or `multiprocessing.Process(...)` / `Pool(...)` — instead of
routing crash-prone or best-effort native work through the SDK's sanctioned
child-process seam. Use `run_fault_isolated()` (raises a catchable `BrokenProcessPool`
on a native crash) or `run_best_effort()` (logs and continues) from
`application_sdk.execution.heartbeat`.

Matching is construction-anchored and import-resolved (so an aliased `from
concurrent.futures import ProcessPoolExecutor as PPE; PPE(...)` is caught).
`multiprocessing.get_context(...).Process()` on a runtime receiver is not statically
resolvable and is not flagged; `ThreadPoolExecutor` is a thread pool, out of scope here
(thread offload onto the shared default executor is governed by P031).
`application_sdk/execution/heartbeat.py` is exempt — that is where the seam's own pool
lives.

Remediation is a restructure (route through the seam), so findings route to residue.
Land as `WARN`; suppress a reviewed exception (e.g. a deliberate CPU-bound pool that
never touches the worker) with `# conformance: ignore[P036] <reason>`.

---

## P037 — `SdrAgentJsonNotConsumed` {#p037}

**Tier:** `warn` · **Scope:** `app` · **Category:** `sdr-readiness` · **Autofixable:** — · **Since:** 0.16.0

> SDR app resolves credentials by credential_guid only and never routes through an agent-aware resolver — agent_json is ignored

**Rationale:** In SDR (agent) mode the platform forwards agent_json on the workflow input; the SDK's
agent-aware resolvers (CredentialRef.resolve / CredentialRef.from_workflow_args) consume
it to route the credential fetch through the customer-side agent. An app that instead
resolves credentials with a custom, GUID-only path — a hand-rolled vault read plus
resolve_credential_raw, or a bare CredentialRef(credential_guid=...) — ignores
agent_json entirely. Its manifest can be P029-clean, but in agent mode the credential
never resolves and the workflow writes zero assets while reporting 'success' (observed
for a table-format connector in fleet testing).

For apps declaring `self_deployed_runtime: true` in `atlan.yaml`, credential resolution
must be able to consume `agent_json` — the agent-mode routing spec the platform forwards
on the workflow input.

This rule fires when an app performs *custom* credential resolution — a bare
`CredentialRef(credential_guid=...)` construction or a `resolve_credential_raw(...)`
call — but NEVER routes through an agent-aware resolver entry point anywhere in its
source:

* `CredentialRef.resolve(input)` — reads `input.agent_json` and   picks the agent vs.
direct-GUID route. * `CredentialRef.from_workflow_args(workflow_args)` — the same,
reading `agent_json` off the args payload.

An app that resolves strictly by `credential_guid` (a custom local vault read that only
ever builds `CredentialRef(name=guid, credential_guid=guid)`) ignores `agent_json`, so
in agent mode the credential never resolves and zero assets are written — a silent
failure invisible to status-only pipelines.

Apps that lean on the SDK's transparent resolution (they build no `CredentialRef` and
call no `resolve_credential_raw`) are not gated in and never flagged.

This is a WARN (not BLOCK): the static heuristic recognises the two sanctioned resolver
entry points and a direct `agent_spec`-carrying ref, but an app could resolve
`agent_json` through a bespoke helper the heuristic does not know about.  Review before
suppressing.

**Remediation:** route credential resolution through `CredentialRef.resolve(input)` or
`CredentialRef.from_workflow_args(workflow_args)` (both consume `agent_json` and pick
the correct route), keeping the direct `credential_guid` path only as a fallback after
the agent-aware call.

---

## P038 — `SdrArtifactMisrooted` {#p038}

**Tier:** `warn` · **Scope:** `app` · **Category:** `sdr-readiness` · **Autofixable:** — · **Since:** 0.16.0

> SDR object-store prefix rooted from the empty-defaulting input application_name field instead of APPLICATION_NAME

**Rationale:** The object-store output prefix must be rooted from the SDK app identity
(APPLICATION_NAME / self._app_name), which the SDK's own WORKFLOW_OUTPUT_PATH_TEMPLATE
does correctly. An app that instead roots 'artifacts/apps/<identity>/...' from the
workflow-input application_name field mis-roots: that field's contract default is '' and
AE forwards only manifest-declared args, so it stays empty and artifacts land under
'artifacts/apps//workflows/...' (empty app segment). self.upload() then succeeds but 0
assets publish — P030 passes the app (upload IS called), so this distinct rule catches
the wrong-root case (observed for a document-store connector in fleet testing).

For apps declaring `self_deployed_runtime: true` in `atlan.yaml`, the object-store
output path/prefix (`artifacts/apps/<identity>/workflows/...`) must be rooted from the
SDK app identity — the `APPLICATION_NAME` constant, `self._app_name`, or the SDK's
`WORKFLOW_OUTPUT_PATH_TEMPLATE` (which fills `application_name` from the app identity).

This rule fires when the app instead builds that path from a *workflow-input*
`application_name` field — read as `input_data.get("application_name", ...)`,
`input_data["application_name"]`, or `input.application_name` — and interpolates it
(directly or via a local variable) into an f-string whose literal contains
`artifacts/apps`.  The contract default of that field is `""` and AE forwards only
manifest-declared args, so the value stays empty; the artifacts then land under
`artifacts/apps//workflows/...` (note the empty app segment). `self.upload()` reports
success but the publish app finds 0 assets at the expected prefix.

This is complementary to P030: P030 checks that `self.upload()` is *called*; P038 checks
that what it uploads is rooted correctly.  An app can pass P030 and still fail P038.

This is a WARN (not BLOCK): the failure is a silent zero-asset publish, not a hard
crash, and the heuristic is deliberately narrow (it keys on the `application_name` input
field feeding an `artifacts/apps` literal).  It does NOT catch every mis-rooting — an
app that forwards an empty `output_prefix` input field without an `artifacts/apps`
literal is indistinguishable from a correct app statically and is left to runtime/e2e
detection.

**Remediation:** root the object-store prefix from `APPLICATION_NAME` / `self._app_name`
(or use `WORKFLOW_OUTPUT_PATH_TEMPLATE.format( application_name=APPLICATION_NAME,
...)`), or default the contract field to the app name; never derive it from an
empty-defaulting workflow arg.

---

## P039 — `SdrAgentJsonDroppedByInputContract` {#p039}

**Tier:** `warn` · **Scope:** `app` · **Category:** `sdr-readiness` · **Autofixable:** — · **Since:** 0.16.0

> SDR generated extract-input contract drops the forwarded agent_json (bare Input subclass, no agent_json field, extra fields rejected)

**Rationale:** In SDR (agent) mode the platform forwards agent_json on the extract input. A generated
extract-input contract that subclasses the bare Input base, declares no agent_json
field, and rejects extra fields makes Pydantic silently drop that forwarded agent_json —
the extract input's credential_ref is then None and extraction fails with
PipelineContractError and 0 assets, even though the manifest is P029-clean (declares
{{agent-json}}). This is distinct from P029 (manifest side) and P037 (code resolves by
guid only): here the manifest and code are fine but the typed contract eats the field
(observed for a BI connector in fleet testing).

For apps declaring `self_deployed_runtime: true` in `atlan.yaml` whose generated
manifest declares agent routing (the `{{agent-json}}` placeholder at the extract-args
top level — i.e. P029 passes), the generated extract-input contract model must be able
to *receive* the forwarded `agent_json`.

This rule fires when the generated extract-input contract (`AppInputContract` in a
generated `_input.py`):

* subclasses the bare `Input` base (NOT the SDK `*ExtractionInput`   family, which
declares `agent_json`), AND * declares no `agent_json` field of its own, AND * rejects
extra fields (no `allow_unbounded_fields=True` class keyword,   no `extra="allow"` in
the model config).

In that shape Pydantic silently drops the forwarded `agent_json` at model construction.
The extract input's `credential_ref` is then `None` and extraction fails with
`PipelineContractError` ("No credential_ref or inline_credentials on input") — 0 assets
— while the manifest and the connector code both look correct.

This is orthogonal to P029 (which checks the *manifest*) and P037 (which checks that the
*code* consumes `agent_json`): all three must be clean.

This is a WARN (not BLOCK): the heuristic reads the generated contract's declared bases,
fields, and model config; an app that receives `agent_json` through a base the heuristic
does not recognise could be flagged.  Review before suppressing.

**Remediation:** declare `agent_json` on the extract-input contract in
`contract/app.pkl` and regenerate, subclass the SDK `ExtractionInput` family (which
declares it), or set `allow_unbounded_fields=True` on the contract — as the passing
counterexample connectors in fleet testing do.

---

## P040 — `TransformTemplateReservedKeyword` {#p040}

**Tier:** `warn` · **Scope:** `app` · **Category:** `transform-templates` · **Autofixable:** — · **Since:** 0.18.0

> Transform SQL template references an unquoted DuckDB reserved keyword in source_query

**Rationale:** The SDK query transformer renders each flattened column as '{source_query} AS {name}',
so a bare DuckDB reserved keyword ('column', 'order', 'group', 'qualify', ...) used as a
source_query column reference reaches DuckDB unquoted and every transform of that type
fails at runtime with a ParserException on SDK >= 3.22 (the daft-less runtime executes
all transforms through DuckDB). The breakage is latent — templates load, imports
succeed, mocked tests pass — and surfaced live on main for a document-store connector in
fleet testing only when the full pipeline ran. Linting the templates catches it at
review time. The alias (identifier) position is not graded: DuckDB accepts a reserved
keyword after AS, so there is no runtime failure to report there.

In an app's transform templates (YAML consumed by `application_sdk.transformers.query`),
a `source_query:` value that is a bare DuckDB **reserved keyword** must be SQL-quoted.

The transformer compiles each flattened column to `{source_query} AS {name}`.  The
`source_query` lands in the expression slot, where a bare reserved keyword — `column`,
`order`, `group`, `select`, `qualify`, `pivot`, … — is a column *reference* and DuckDB
raises `ParserException` at runtime for every row batch of that entity type.  On SDK >=
3.22 there is no daft fallback: the DuckDB path is the only transform path.

Real templates express the identifier as a nested YAML mapping key:

```python
columns:
  attributes:
    someAttr:
      source_query: '"order"'
```

YAML-level quoting does not help: `source_query: "order"` and `source_query: order`
parse to the same string.  The quotes must be part of the *value* so they survive into
the SQL.

**Scope — the expression position only.**  The column identifier is not graded.  It
reaches the `AS` alias slot, which DuckDB does not restrict (`SELECT 1 AS column` and
`AS qualify` both parse on the pinned 1.5.5), so grading it would describe a runtime
failure that does not occur.

Values containing a dot, embedded `"` quotes, whitespace or `(` are exempt (auto-quoted,
already quoted, or an expression rather than a bare reference).  YAML scalar literals
(`true` / `false` / `null`) take the transformer's literal path and are not flagged.

This is a WARN (new-rule tier policy) and the scan is text-based (dependency-free),
keyed on files that carry both a `columns:` key and a `source_query:` key — ordinary
CI/Helm YAML never matches. Review before suppressing: on a daft-less SDK below the
fixed version the finding is a guaranteed runtime parse failure, not a style preference.

**Version scope — fixed at the root from SDK 3.28.0.**  The transformer now quotes a
`source_query` that resolved as a plain column reference, so a reserved keyword renders
as valid SQL with no template change at all; the `source_columns`-driven route, which
carries arbitrary SQL, is left unquoted.  This rule therefore describes only apps pinned
**below** that version and is marked `superseded_by: sdk>=3.28.0` rather than dropped —
an app on an older SDK still fails at runtime, and dropping the rule would take the only
static signal away from exactly that population.

The marker names the next SDK *minor* rather than the exact patch: the patch number is
assigned by release CI at merge time, and erring late only keeps the rule firing on some
already-fixed apps, never the reverse.  Retire the rule (set `until`) once the fleet
floor has crossed it.

**Do not hand-remediate templates that the version bump fixes.** Embedding quotes in the
value was the interim advice and it is worse than it looks on an unfixed SDK: below
3.28.0 the transformer matched `'"order"'` against the available columns as raw text,
found nothing, and dropped the attribute from published output — a silent missing
attribute in place of a loud `ParserException`. From 3.28.0 both spellings resolve and
render identically, so the upgrade is the fix and the template edit is unnecessary.

---

## P041 — `SdrHardPreflightGate` {#p041}

**Tier:** `warn` · **Scope:** `app` · **Category:** `sdr-readiness` · **Autofixable:** — · **Since:** 0.18.0

> SDR app hard-codes preflight_gate_mode = 'hard' — non-secret-config preflight failures abort agent-mode workflows

**Rationale:** In SDR (agent) mode the app resolves its configuration through the customer's secret
store, and customers rightly mirror only *secrets* into it — non-sensitive values
(database names, schema filters) are often absent. Preflight checks that depend on such
non-secret config then report NOT_READY even though the run would succeed. Most
connectors run the preflight gate non-fatally, but an app that hard-codes
preflight_gate_mode = 'hard' aborts the entire workflow on that spurious verdict
(observed for a query-engine connector in fleet testing). Until the SDK differentiates
gate enforcement by run mode, the interim app-side posture is to soften the gate for
self-deployed runs — e.g. preflight_gate_mode = 'soft' if ENABLE_ATLAN_UPLOAD else
'hard' — keeping it env-overridable via ATLAN_PREFLIGHT_GATE_MODE.

For apps declaring `self_deployed_runtime: true` in `atlan.yaml`, an unconditional
`preflight_gate_mode = "hard"` assignment is flagged.

The SDK's preflight gate (`App.preflight_gate_mode`) defaults to `"soft"` — a NOT_READY
verdict is logged (`would_block`) but the run proceeds.  An app opts into `"hard"` when
its checks are trusted to gate real runs.  That trust assumption breaks in SDR (agent)
mode: configuration is resolved through the *customer's* secret store, and customers
rightly mirror only secrets into it — non-sensitive values (a database name for a
schema-existence check, for example) are commonly absent.  The preflight check then
fails for a reason unrelated to the run's actual viability, and a hard gate aborts the
whole workflow (observed for a query-engine connector in fleet testing; connectors with
soft gates ran the same checks non-fatally and succeeded).

This is suggest-only (WARN): a hard gate is a legitimate posture for cloud-hosted runs,
and the right long-term fix is SDK-side — gate enforcement differentiated by run mode,
so the same app can be hard in cloud mode and soft in agent mode.  Until then:

**Remediation (interim app-side posture):** derive the gate mode from the run mode
instead of hard-coding it, e.g.:

```python
preflight_gate_mode = "soft" if ENABLE_ATLAN_UPLOAD else "hard"
```

and keep it env-overridable (the SDK's `ATLAN_PREFLIGHT_GATE_MODE` env var takes
precedence over the class attribute, so operators can restore the hard gate per
deployment).

**What is and is not flagged.**  The exemption is semantic, not structural: `"hard"` is
safe only on the *else* path.

Silent on:

* `preflight_gate_mode = "soft" if ENABLE_ATLAN_UPLOAD else "hard"`   and its
`if`/`else` statement spelling (an ordinary style   choice once the branches grow past
one line); * `os.environ.get("ATLAN_PREFLIGHT_GATE_MODE", <that ternary>)` —   the same
posture spelled through the override.

Still flagged:

* a plain `preflight_gate_mode = "hard"`; * the inverted ternary `"hard" if
ENABLE_ATLAN_UPLOAD else "soft"`   — hard exactly when upload is on, the reverse of the
intent; * a both-arms-hard ternary (unconditional in a conditional's   clothing); *
`os.environ.get("ATLAN_PREFLIGHT_GATE_MODE", "hard")` and
`os.environ.get("ATLAN_PREFLIGHT_GATE_MODE") or "hard"` — hard on   every deployment
that leaves the variable unset.  Only the known   env-lookup callees (`os.environ.get` /
`os.getenv` /   `os.environ.setdefault`) have their default read this way; an
arbitrary helper call (`resolve_gate(env, default='hard')`) is   opaque to a static
check — its constant default says nothing about   the value it returns, so it is *not*
flagged; * one hop of constant indirection (`MODE = "hard"` /   `preflight_gate_mode =
MODE`), at module level **or** in the   same class body — `preflight_gate_mode` is
conventionally a   class attribute, so the class-body form is at least as common.   The
name resolves when every straight-line assignment to it in   that body is a string
literal; the last one wins (so   `MODE = "soft"` followed by `MODE = "hard"` still
fires). A   name ever assigned a computed value is not resolved at all.

Known limit: a condition written with inverted polarity (`"hard" if not
ENABLE_ATLAN_UPLOAD else "soft"`) reads as the true-arm shape and is flagged — suppress
it inline with the reason.

---

## P042 — `SdrHandRolledUploadBridge` {#p042}

**Tier:** `warn` · **Scope:** `app` · **Category:** `sdr-readiness` · **Autofixable:** — · **Since:** 0.18.0

> SDR app performs the tenant-bucket transfer through a hand-rolled upload_to_atlan bridge instead of App.upload()

**Rationale:** An app that moves extracted assets to the tenant bucket through its own upload_to_atlan
method, with no self.upload() anywhere, has reimplemented a contract the SDK owns — on a
symbol the SDK has marked @deprecated with removal_version 4.0.0. Bytes do move, so this
is not P030's silent-zero-asset shape and it should not carry P030's message; but it is
not a false positive either. App.upload() does ADR-0014 dual-write routing,
transformed-asset validation in a child process, the canonical
artifacts/apps/{app}/workflows/{wf}/{run} prefix and @task retry/replay, and the
transfer beneath it adds the cross-pod deployment-store fallback (a KEDA-scaled SDR
worker where local_path does not exist on this pod), partial-local reconcile, and
SHA-256 sidecar dedup for idempotent replay. A hand-rolled bridge has none of those, and
a green full-DAG e2e proves only that bytes moved on that run, not that the app tracks
the contract. Reporting it separately also settles a contradiction: B001 already flags
.upload_to_atlan(...) call sites from the generated deprecation manifest, so the same
repo would otherwise get a B001 finding alongside P-series silence.

For apps declaring `self_deployed_runtime: true` in `atlan.yaml`, this rule fires when a
custom `upload_to_atlan` method **does** perform a real storage/store transfer (in its
own body or via same-class delegation) and no `self.upload(` call exists anywhere in the
app source.

It is the counterpart to P030, which owns the shapes where nothing moves at all: no
upload path, or an `upload_to_atlan` stub whose body performs no transfer.  Here the
transfer works — which is exactly why it needs a different message.  Treating it as a
P030 false positive moved these repos from WARN to silent, and silence is wrong for
three reasons:

* **It is the SDK's own deprecated symbol.**  `upload_to_atlan` is   `@deprecated` in
`application_sdk.templates.base_metadata_extractor`   with `removal_version: 4.0.0`.
Going quiet on a locally   reimplemented version of a name scheduled for deletion is the
opposite of what the deprecation lifecycle is for. * **The suite would contradict
itself.**  B001 matches   `.upload_to_atlan(...)` call sites receiver-agnostically from
the   generated manifest.  Where the bridge is invoked explicitly rather   than only
dispatched as a DAG task, the repo already gets a B001   finding — alongside P-series
silence. * **A bridge cannot be equivalent, and the gap is silent-data-loss   shaped.**
`App.upload()` carries ADR-0014 dual-write routing,   transformed-asset validation in a
child process, the canonical   `artifacts/apps/{app}/workflows/{workflow_id}/{run_id}`
prefix, and   `@task` retry/replay semantics; the transfer underneath adds the
cross-pod deployment-store fallback (a KEDA-scaled SDR worker where   `local_path` does
not exist on this pod), partial-local reconcile,   and SHA-256 sidecar dedup for
idempotent replay.

**A green full-DAG e2e is not a clearance.**  It shows bytes moved on that run.  It does
not show the bridge preserves the key layout under replay, survives a pod that never
held the local files, or reconciles a partial local state — and it says nothing about
v4.0.

**Remediation:** replace the bridge body with `await self.upload(...)` in the `run()`
method or the relevant `@entrypoint` method (crawler AND miner), and delete the bridge.
If the bridge exists because `App.upload()` genuinely cannot express something the app
needs, that is an SDK gap worth filing rather than a reason to suppress.

This is a WARN, and deliberately a *lower*-urgency one than P030: the app is working
today.  The deadline is v4.0, not the next run — which is why the rule carries
`superseded_by: sdk>=4.0.0`.  It retires when that removal lands and the shape stops
being expressible.

---
