Migrate asset modeling (P028, P052, P055, O002, O003, O004)
SkillDev toolsMove a connector app's asset modeling onto the canonical shape the conformance asset-modeling rules check: qualifiedNames from pyatlan_v9 creators instead of hand-built f-strings (P028), every entity serialized through the SDK's entity_bytes seam instead of to_nested_bytes / to_nested_dict / to_atlas_format / .dict() (P052, O002), asset mappers with a return annotation (O003), and asset models imported from pyatlan_v9.model.assets instead of the legacy pyatlan.model.assets (O004), and 1-to-N links written from the child's single reference rather than the parent's list (P055). The skill inventories every site, decides per site whether a creator owns the grammar (rewrite), no creator owns it (centralise into one module plus a justified ignore), or the change would alter asset identity or wire shape (owner decision), then applies the agreed changes and proves them with re-detection, the test suite and an output diff. The done-bar is identity parity: every qualifiedName the app emits after the change equals the one it emitted before, unless the developer explicitly accepts the change. An app whose mappers emit plain dicts (no pyatlan import) gets P028 only: creators produce the strings, the dict shape stays. Run after migrate-off-daft when the app is below SDK 3.20.0; adopting creators needs SDK 3.39.0 or later.
Use Migrate asset modeling (P028, P052, P055, O002, O003, O004) in Claude, ChatGPT or Ahel Desktop
Free. Sign in, add Migrate asset modeling (P028, P052, P055, O002, O003, O004) and connect your AI. About a minute.
Also: Claude Code · Cursor · Codex
Then ask your AI: use the Migrate asset modeling (P028, P052, P055, O002, O003, O004) skill
Details
Instructions available. Your AI can read the instructions. Execution depends on the setup they require.
Account requirements not reviewed. Check the skill instructions before use; Ahel provides instructions and does not run this skill.
No other account needed.
Add Ahel to your AI once: Claude, ChatGPT, Cursor, Claude Code or Codex. Then ask it to use this.
What this skill tells your AI
The instructions your AI receives, as published by atlanhq/application-sdk in packages/conformance/conformance/skills/migrate-asset-modeling/SKILL.md and read by Ahel’s review.
Conformance rules this skill clears
P028, P052, P055, O002, O003, O004. These rules name this skill as their
remediation_reference, and /remediate hands their findings here. When the
skill is done, run
atlan-application-sdk-conformance detect --rule P028,P052,P055,O002,O003,O004 and
confirm none of these rule ids is still reported.
Why this matters
A qualifiedName is an asset's identity. When an app builds it by hand, the
grammar lives in many places and drifts; pyatlan's creator() factories own
it in one place. entity_bytes is the one seam where the SDK injects
connectionName, applies the entity envelope and strips the placeholder guid
that every creator sets (SDK 3.39.0). An entity serialized another
way misses every central fix. Both changes can alter what the app publishes,
so the skill proves parity before it finishes.
What each rule fires on
| Rule | Fires on | Stops when |
|---|---|---|
| P028 | an f-string that interpolates a name matching qualified_name, *_qn or qn and has a / literal (an object-store key, with a / literal before the name, is exempt) | the qualifiedName comes from X.creator(...).qualified_name or Process.generate_qualified_name(...), or the site carries a justified ignore |
| P052 | .to_nested_bytes(), .to_nested_dict(), pyatlan_v9 to_atlas_format, or to_atlas_format_dict, in app/ (not app/generated/) | the entity goes through entity_bytes |
| P055 | a pyatlan_v9 mapper populating the list end of a 1-to-N relationship — Table(columns=...), process.fabric_activities = [...], schema.tables.append(...) | each child sets its single reference to the parent (column.table = RelatedTable(...)) and the parent's list is left unset |
| O002 | any .dict() call in a module that imports pyatlan asset models | .dict() is gone, or the module no longer imports asset models |
| O003 | a function with no return annotation that returns a directly constructed asset X(...) | the function has a return annotation |
| O004 | an import of pyatlan.model.assets | the import is pyatlan_v9.model.assets |
Scope: P028, P055 and the O-rules scan every non-test Python file (main.py and
scripts/ too); P052 scans app/ only. A suppression directive must be on
the finding's line or on a comment-only line directly above it.
Do not dodge P028. The checker does not see "/".join(...), +,
.format(), %, renamed variables, or the deprecated
application_sdk.transformers.common.utils.build_atlas_qualified_name. Using
any of them clears the finding and keeps the hand-built grammar. That is not a
fix. If the app already has such helpers (for example a _qn(*parts) join),
treat their call sites as part of this migration.
Reference shapes
String wanted — derive it from the creator, memoized on hot paths
(atlan-mysql-app app/mysql.py):
from functools import lru_cache
from pyatlan_v9.model.assets import Database, Schema
@lru_cache(maxsize=4096)
def _database_qn(name: str, connection_qn: str) -> str:
qn = Database.creator(name=name, connection_qualified_name=connection_qn).qualified_name
assert isinstance(qn, str)
return qn
Asset wanted — build it with the creator (atlan-openapi-app
app/asset_mapper.py):
def map_api_spec(record: SpecRecord, connection_qn: str) -> APISpec:
return APISpec.creator(name=record.title, connection_qualified_name=connection_qn)
No creator owns the grammar — one module, one justified ignore per helper,
directly above the f-string (atlan-metabase-app app/qualified_names.py):
def bi_process_qn(connection_qn: str, question_id: Any) -> str:
# conformance: ignore[P028] bespoke BIProcess qualifiedName (questions_dashboards/{id}) — no pyatlan_v9 creator owns this grammar; centralised here as the single source of truth.
return f"{connection_qn}/questions_dashboards/{question_id}"
1-to-N link from the child side — the parent's list stays unset
(atlan-metabase-app app/asset_mapper.py):
asset.metabase_collection = RelatedMetabaseCollection(qualified_name=collection_qn)
Process identity — Process.generate_qualified_name(..., process_id=...)
returns f"{connection_qualified_name}/{process_id}".
Serialization (atlan-openapi-app app/connector.py):
from application_sdk.common.asset_serialization import entity_bytes
from application_sdk.common.entity_envelope import EntityEnvelopePolicy, EnvelopeShape
ENTITY_ENVELOPE = EntityEnvelopePolicy(shape=EnvelopeShape.FLATTENED)
out_f.write(entity_bytes(asset, entity_type="api_spec", envelope=ENTITY_ENVELOPE) + b"\n")
Attributes the v9 model cannot hold — decode the entity_bytes output and
add them (atlan-metabase-app serialize_entity); never fall back to
to_nested_bytes(), which trips P052.
Step 0 — Preconditions
- Read the resolved SDK version from
uv.lock.- 3.39.0 or later: no change.
- Below 3.39.0 and the app will emit creator-built assets: raise the
SDK to the newest release that is at least 7 days old and at or above that floor
(dependency cooldown; never a release younger than 7 days unless it fixes
a known vulnerability). List releases with dates:
curl -s https://pypi.org/pypi/atlan-application-sdk/json | jq -r '.releases | to_entries[] | select((.value | length) > 0 and (.value[0].yanked | not)) | "\(.key) \(.value[0].upload_time)"'(yanked releases are skipped). Creators set a random placeholder guid on every call, and onlyentity_bytesfrom 3.39.0 removes it; without it, publish sees every entity as changed on every run. An app that only reads.qualified_namestrings from creators does not need the raise. - Below 3.20.0: stop, and run
migrate-off-daftfirst.
- Record the baseline: run
atlan-application-sdk-conformance detect --rule P028,P052,P055,O002,O003,O004 --exit-zero --output "$TMPDIR/before.sarif"and the test suite. Record the tests that already fail; they do not block this skill, and they must not get worse. - Capture the app's current output for parity. Run the offline (recorded
or cassette) integration test with
--basetemp=<scratch dir>and collect the transformed entity files, one JSONL line per entity, outside the repo. If the app has no such test, stop and ask the developer how to produce the output. Check coverage: the capture must contain every asset type and process kind (each distinct process qualifiedName grammar, for example{conn}/{model}@allmodule) in the Step 1 inventory. For a type it does not contain, first check whether an existing unit test already pins its qualifiedName; if none does, add one for a sample input before any edit.
Step 1 — Inventory and classify every site
For each finding, and for every hand-built qualifiedName the checker cannot see, record the asset type, the current grammar and one class. One row per finding; unseen sites that call the same helper share one row that names the helper and lists their lines.
- creator — a
pyatlan_v9.model.assetsclass with acreator()produces the same string. Check it: build one value both ways and compare, with a realistic input and with every edge input the app can receive: an id that isNoneor empty, an empty connection qualifiedName, and the real connection qualifiedName format. An input is reachable unless a guard or a required non-empty type stops it before the site;.get(k, ""),.get(k)and a field declaredstr = ""all make the empty value reachable. When the qualifiedName is built through a chain of creators, check every id in the chain, not only the last one: each goes in as aname. Creators enforce segment counts (Databaseneeds a 3-segment connection qualifiedName,Schema4,Table/View/Procedure5) and raiseValueErrorwhere an f-string would produce…/Noneor//. If a creator can raise on any input the app accepts, the site is an identity change, whether the error drops the entity, fails the task or fails the run. When the app keys a name on an id, pass the id asnameto get the string only; never emit that creator-built asset, because itsnameis then the id. Some families have no creators at all; check per asset type. - process — a Process or ColumnProcess identity with the grammar
{connection_qn}/{process_id}:Process.generate_qualified_name(name=..., connection_qualified_name=..., inputs=<non-empty>, outputs=<non-empty>, process_id=...). Withoutprocess_idit returns a hash, not the app's grammar; with an emptyinputsoroutputsit raises. - decode — code that takes a qualifiedName apart (
split("/"),"/".join(parts[:-1])). It does not build an identity, so it is not a P028 site. Keep it unchanged, or derive the ids from the source record instead; never re-encode the grammar there. - centralise — no creator owns the grammar. Move every such f-string into
one
app/qualified_names.pywith one helper per shape and one justified ignore per helper. - identity change — the only creator produces a different string. This changes asset identity (duplicates or orphaned assets). Owner decision; do not apply.
For P052, O002 and O004 sites, record the serialization call and the model
generation. For P052, the envelope keeps the released wire shape:
to_nested_bytes() / to_nested_dict() ⇒ EnvelopeShape.PYATLAN (deprecated,
removed in v4.0); to_atlas_format() ⇒ EnvelopeShape.FLATTENED. Moving to
FLATTENED from a nested shape changes the wire shape: owner decision.
Stop 1 (see Agent protocol).
Step 2 — Apply, in this order
- O004 — change the imports to
pyatlan_v9.model.assets. Check every construction site: the keyword arguments must exist on the v9 class. Sites that do not match go to the developer, not to a guess. An app pinned on purpose to the legacyAtlasTransformerkeeps the import with an ignore that names that pin. - O003 — add the return annotation (
-> Table, a union, orOptional). - P028 — put every qualifiedName helper in one
app/qualified_names.py:lru_cachehelpers that returnX.creator(...).qualified_namefor the creator sites,generate_qualified_namehelpers for the process sites, and the f-string helpers with their ignores for the centralise sites. Call sites use the helpers. In a dict-mapper app the helpers return strings only, and the emitted dicts keep their shape. Leave identity change and decode sites untouched and unsuppressed: they stay P028 findings, listed in the PR description, until the owner decides (for example to reject blank ids inrun(), which makes the creator rewrite safe). - P052 / O002 — serialize every entity with
entity_bytes(asset, envelope=ENTITY_ENVELOPE, entity_type=...), one module-level policy. Passconnection_name=andlast_sync=unless the mapper already stamps them. Callresolve_last_sync_details()(application_sdk.common.last_sync) once per transform activity, outside the per-record loop, and pass that same value for every entity: each call reads the current time, so a per-record call gives one run's entities differentlastSyncRunAtvalues.entity_bytesreturns bytes: the sink must accept bytes (JSONL). A.dict()on a model that is not an asset is a false positive: ignore it with that reason. - P055 — remove the list from the parent and set the single reference
the finding names on each child instead (
activity.fabric_process = RelatedProcess(qualified_name=process_qn)). The child's mapper must know its parent's qualifiedName; derive it from the child's source record, not from the parent asset. If a child is emitted only through the parent's list (no mapper of its own), that is an owner decision: the app has to start emitting the child.
Step 3 — Prove it
atlan-application-sdk-conformance detect --rule P028,P052,P055,O002,O003,O004 --exit-zero --output <file>— no finding left except the agreed ignores and the owner-decision sites (expected, unsuppressed). The checker does not see the hand-built helpers from Step 1 (for example a_qn(*parts)join): grep their call sites and confirm each is migrated or listed as an owner decision.- The test suite passes, with no failure that was not in the baseline.
- Parity: regenerate the output from Step 0.3 and compare, as multisets
(counts, not sets: an anonymised fixture can repeat a qualifiedName), both
the
(typeName, qualifiedName)pairs and everyuniqueAttributes.qualifiedNamein relationship references — a mapper that builds a reference changes identity as surely as one that builds an entity. Every value must be present before and after, with the same count. Ignore volatile fields such aslastSyncRunAt. On theentity_bytespath,connectionNameadded and placeholder guids removed are expected; anything else is a defect unless the developer accepts it. P055 fixes move references by design: each parent loses its list of child references and each child gains one reference to its parent. Compare those as the set of(parent, child)pairs, which must be equal before and after. - Pin the parity in the repo: commit the before-snapshot as JSON next to the
recorded fixture, assert
Counterequality against it in the offline test, and change one grammar on purpose to confirm the test fails, then revert.
Stop 2 (see Agent protocol).
Agent protocol
Two stops, developer decides at each:
- After Step 1 — the inventory: every site, its class, the grammar comparison for each creator rewrite, the envelope for each serialization site, and the list of owner decisions. No edits yet.
- After Step 3 — the evidence: the re-detect output, the test result, the parity diff and the new parity test. Then hand off for PR review.
If the app is below SDK 3.20.0, run migrate-off-daft first: this skill can
raise the SDK.
Signals
- GitHub stars
- 29
- Forks
- 17
- Last commit
- Oct 2026
Advanced
- Item type
- skill
- Key
migrate-asset-modeling- Source
- github.com/atlanhq/application-sdk
github.com/atlanhq/application-sdk