Contract migration — implementation plan
Executable handoff plan for the step-contract harmonization. The decisions being implemented are in contract-conflict-decisions.md (every conflict now has exactly one ticked box; margin notes beside a ticked box override it — notably C04's note supersedes C27's step id).
Repo: tractusx-testlab (this repo). Python 3.12, pydantic v2, pytest.
Test: poetry run pytest -q (narrow with a path or -k). Acceptance: the full
suite green.
Ground rules (non-negotiable):
- One canonical name, one canonical shape per concept. Never keep an alias, a deprecated-but-accepted spelling, or dual input shapes. Rename fully and migrate every caller.
- After any rename, grep the WHOLE repo (src, tests, docs/examples, stubs, e2e fixtures) for the old spelling and fix every hit.
- Steps never name their connector service — services are seeded at runtime.
The only sanctioned way to address a remote party's data is the
dataplane_url+edr_tokenparameter pair. - Copy the Apache-2.0 header verbatim from a neighboring file when creating files. Match existing docstring/comment style. No speculative abstraction.
- Commit per cluster, in the order below (later clusters depend on earlier renames; C47 last because it hard-fails any stale spelling left behind).
Branch state right now (engine, feat/run_security_consitency):
- Cluster E1 is DONE — commit
3be95d1("refactor: drop engine-internal alias spellings…"). Details below so nobody redoes it. - These uncommitted files are the repo owner's separate WIP — do NOT commit,
revert, or fold them into migration commits:
models/__init__.py,models/primitives/enums.py,models/runtime/events.py(untracked),player/execution/_trace_formatter.py,player/execution/monitor.py,player/execution/phases/_run_phase.py,player/execution/player.py,docs/developer/contract-conflict-decisions.md. (Theevents.py/player files look like the beginnings of C46 — see E8; coordinate with them rather than duplicating.)
CLUSTERS (tractusx-testlab)
E1 — Internal aliases ✅ DONE (commit 3be95d1)
Already implemented; recorded here for context:
- C20/C21:
CounterPartyParams(steps/_contracts.py) lost theprovider_url/bpnlAliasChoices— onlycounter_party_address/counter_party_idvalidate now. - C22:
FilterExpressioninput accepts onlyoperand_left/operand_right;operandLeft/operandRightremain serialization-only (serialization_alias), used byto_sdk(). - C25:
validate/schema(steps/utility/validate.py) — fieldjson_schemanow hasvalidation_alias="schema"only; tests writeschema:, thejson_schema:spelling is dead. - C19 (shared-model half):
FilterExpressionParams.filter_expressionrenamed tofilters(no alias);CatalogFilter(the nestedfilter:block dual shape) deleted;QueryCatalogParams/QueryCatalogByBpnlParamsnow declarefilters; callers incatalog_query.py,catalog_filter.pyupdated (do_dsp.py/pull_data/_executor.pygo through the untouchedsdk_filter_expression()helper, which now readsself.filters). - Tests updated:
tests/test_catalog_query_contract.py(alias tests replaced with canonical + rejection tests),tests/test_step_docs.py(alias test now uses the local_Sample.aliasedfield; page test assertsprovider_urlabsent),tests/test_ccm_steps.py,tests/test_connector_do_dsp.py(inputs snake_case, SDK asserts camelCase), e2e yamltests/e2e/connector-dtr-smoke/tests/connector_negotiation.yaml(filter_expression:→filters:). - KNOWN DEBT for E9:
tests/test_step_docs.py::TestGeneratedPage:: test_committed_page_matches_the_codefails until the docs page is regenerated (see E9). Do not "fix" it before then.
E2 — Negotiate / transfer (C01, C02, C10, C11, C28, C33, C34, plus C18's engine half)
Files: steps/connector/negotiate.py, transfer.py, dataplane.py,
_contracts.py, syntax/context_vars.py, catalog_query.py (exports).
- C01 rename step id
connector/consumer/negotiate_contract→connector/consumer/negotiate(@step(...)innegotiate.py; class/docstring references too). - C10 in
NegotiateContractParams: rename fieldtarget→asset_id. The context-var fallback chain: renameCATALOG_TARGET = "catalog_target"→CATALOG_ASSET_ID = "catalog_asset_id"insyntax/context_vars.py, and rename thecatalog_targetexport field inQueryCatalogByAssetIdExports(catalog_query.py) tocatalog_asset_id(alias = the new constant). Grep forcatalog_targetandCATALOG_TARGETacross repo (also used innegotiate.pyand possiblydo_dsp.py). - C11 verify-only:
policyparam stays an ODRL document fed fromcatalog_policy— already correct today, nooffer_idsupport. - C33 negotiate outputs gain
agreement_id+state: afterstart_edr_negotiation, poll the negotiation until terminal (FINALIZED/TERMINATED) or timeout. SDK surface available on the consumer service (verified by introspection):contract_negotiations(controller accessor),get_edr_entry(negotiation_id=…), plusedrs,transfer_processes. The EDC management API answer for one negotiation isGET /v3/contractnegotiations/{id}→{"state": …, "contractAgreementId": …}. Inspectconsumer.contract_negotiationsfor aget_by_id-style method; if the SDK exposes none, do a plainrequests.getagainstcontext.get_consumer_endpoint_url("contract_negotiations", negotiation_id)with the seeded management headers (look at howcontext.get_consumer_endpoint_urlis used intransfer.py/dataplane.py). Addagreement_id+statetoNegotiationOutputandNegotiationExports(context var name:contract_agreement_idalready exists asDSP_CONTRACT_AGREEMENT_ID; add a plainAGREEMENT_ID = "agreement_id"constant — do not overload the DSP ones, they belong to the raw-DSP step family). Poll with the same delay/timeout styletransfer.py's neighbors use; keep it short (a few seconds default). - C02 rename step id
connector/consumer/transfer_data→connector/consumer/initiate_transfer(transfer.py). Keep behavior: resolvenegotiation_id→transfer_id→fetch_data_address(...). - C28 add
transfer_type: Optional[str]to the params (HttpData-PULLdefault when omitted; accepted:HttpData-PUSH,AmazonS3-PUSH). PUSH mode issues a realPOST /v3/transferprocessesvia the SDK (consumer.transfer_processescontroller — inspect it; else raw POST tocontext.get_consumer_endpoint_url("transfer_processes")) with body{transferType, contractId (the agreement id), counterPartyAddress, dataDestination}and then polls the transfer process state. Add adata_destination: Optional[dict]param for the PUSH target (required whentransfer_typeis*-PUSH; validate that in amodel_validator). PULL path unchanged. - C34 output canonicalization —
data_addressstays,dataplane_endpointdies everywhere (they're two names for one value): syntax/context_vars.py: deleteDATAPLANE_ENDPOINT; keepDATA_ADDRESS = "data_address"; delete the "older spelling" comment._contracts.pyDataplaneExports: rename fielddataplane_endpoint→data_address(aliasDATA_ADDRESS).transfer.pyTransferDataExports: drop its duplicateddata_addressfield (now inherited) and stop exportingdataplane_endpoint.transfer.pyoutput/exports gainstate(the transfer/negotiation state string, mirroring C33).dataplane.pyGetEdrStep.execute:DataplaneExports(data_address=…).dataplane.pyDataplaneCallStep.execute: fallback var becomesDATA_ADDRESS.- Grep
dataplane_endpointrepo-wide (docs/examples too). - C18 engine half in
dataplane.pyDataplaneCallParams: rename fieldendpoint→dataplane_urlandtoken→edr_token; delete bothAliasChoices(theurl,endpoint,tokenspellings die). Keep the dict-or-string coercion inendpoint_url()(rename to match). Drop the now-unusedAliasChoicesimport. - Update ALL callers/tests: grep
negotiate_contract,transfer_data,"target"(in negotiate contexts),catalog_target,dataplane_endpoint,"endpoint"/"token"raw-param keys in tests (tests/test_transfer_and_dataplane.py,test_connector_negotiate*.pyif present — discover with grep), e2e yaml undertests/e2e/, anddocs/examples/**andstubs/**tests.
Test: poetry run pytest tests -k "negotiate or transfer or dataplane or edr" -q.
E3 — Catalog / pull_data / contract-definition (C13, C14, C23, C29, C35, C36)
Files: steps/connector/provision.py, pull_data/_executor.py,
pull_data/_constants.py, catalog_query.py, do_dsp.py,
catalog_filter.py, consume.py.
- C13
expected_policiesis THE param name for every consumer-side policy filter: renamepolicies→expected_policiesinpull_data_filtered_by_policyparams,query_catalog_by_asset_idparams (catalog_query.py), anddo_dsp/do_dsp_with_bpnlparams (do_dsp.py). The kwarg passed INTO the SDK (consumer.do_dsp(policies=…)) keeps the SDK's name — only the test-facing param renames. - C14
create_contract_definition(provision.py): paramcontract_id→contract_definition_id; output fieldcontract_def_id→contract_definition_id. One name both directions. - C23 same step: field
usage_policy_id→contract_policy_id(the on-record recommendation was overridden). The value still lands in the EDCcontractPolicyIdAPI field. - C29 same step: add
asset_selector: list[FilterExpression](reuseFilterExpressionfrom_contracts.py— sameoperand_left/operator/operand_rightshape, serialized camelCase viato_sdk()), passed to the EDC contract-definitionassetsSelectorarray.asset_idremains as the simple single-asset form; when both given,asset_selectorwins (document that in the field description); when onlyasset_idgiven, build the one-criterion selector from it (that is what the step already does implicitly today — check howprovision.pybuilds the definition body). - C35
query_catalogoutput wrap: todayQueryCatalogStepreturns the rawCatalogPayloadas value. Change its output to theFilteredCatalogOutputshape already defined incatalog_filter.py(catalog+datasets) — move that model into_contracts.py(or import it) so both steps share one output model, and returncatalog=<full document>,datasets=as_dataset_list(catalog). A test'sreturns:then readscataloganddatasets— neverdcat:dataset. - C36
pull_data_filtered(pull_data/_executor.py+ its step wrapper inpull_data/__init__.pyorconsume.py— locate with greppull_data_filtered): addagreement_idto its output/exports, sourced the same way its_by_policysibling publishes it.
Update callers/tests: grep contract_id, contract_def_id,
usage_policy_id, "policies" (test-facing), plus e2e/docs/stubs.
Test: poetry run pytest tests -k "provision or contract or pull_data or catalog" -q.
E4 — DTR (C37 + new C04/C27 lookup step)
File: steps/industry/dtr.py (~230 lines; read fully first — its shared
DtrParams/DescriptorPayload pattern is the style to follow).
- C37
DescriptorPayload.id_short: changealias="idShort"→validation_alias="idShort"so the AAS API's camelCase is accepted on input but the field ALWAYS serializes asid_short. Verify with a quick check that step output rendering (.of(body)→bind_output) dumpsid_short. C03/C05/C15 are already correct on the engine side (idsdigital-twin-registry/provider/create_shell_descriptor,…/create_submodel_descriptor, paramaas_identifier) — verify, no change. - C04+C27 new step — id EXACTLY
digital-twin-registry/consumer/dataplane/lookup_shell(deliberate 4-segment exception; do not normalize). It searches a COUNTERPARTY's registry through an EDC dataplane — it must NOT callcontext.get_aas_service()(that's the locally-seeded registry). - Params:
specific_asset_ids: list[dict](AAS specificAssetIds criteria,[{"name": …, "value": …}]),dataplane_url: str,edr_token: str. - Behavior: the AAS registry lookup API is
GET {base}/lookup/shells?assetIds=<base64url(JSON of each criterion)>(oneassetIdsquery param per criterion, each a base64url-encoded JSON object — this is the AAS v3 spec encoding; checktractusx_sdk.industryfor an existing encoder before hand-rolling). Issue it withrequests.get(dataplane_url + "/lookup/shells", …), headerAuthorization: <edr_token>— same bare-HTTP pattern asDataplaneCallStep.executeinsteps/connector/dataplane.py. The response is{"result": [<shell ids>]}(v3 paginated shape:{"paging_metadata": …, "result": […]}). - Optionally follow up with
GET {dataplane_url}/shell-descriptors/{b64(id)}per id to fillshell_descriptors; keep it simple — one page, no pagination loop. - Output model:
shell_ids: list[str]+shell_descriptors: list[dict]. - Return a real
HttpRequest/HttpResponsepair in theStepOutputlike every other step in the file.
Test: poetry run pytest tests -k "dtr or shell or submodel" -q (add a unit
test for the new step with a mocked requests — copy the mocking style of
existing dataplane tests).
E5 — Wizard creation steps (C26)
Files: steps/connector/provision.py, steps/industry/dtr.py.
Two shapes = two separate steps (never one step accepting either shape):
- Existing raw-payload steps stay as-is:
connector/provider/create_asset(paramasset: full document),connector/provider/create_policy(parampolicy),digital-twin-registry/provider/create_shell_descriptor(paramshell_descriptor),…/create_submodel_descriptor(paramsubmodel_descriptor). - NEW wizard siblings with flat guided fields that assemble the document and
then call the SAME underlying creation logic (extract a module-level helper
from each raw step's
execute; no duplicated API calls): connector/provider/wizard/create_asset— fieldsasset_id,name,description(opt),base_url,content_type(opt),properties(opt dict) → assembles the EDC asset document.connector/provider/wizard/create_policy— fieldspolicy_id,permissions(list),prohibitions(opt list),obligations(opt list) → assembles the ODRL policy document.digital-twin-registry/provider/wizard/create_shell_descriptor— fieldsid(opt, generate UUID urn if absent),id_short,global_asset_id(opt),specific_asset_ids(opt list),submodel_descriptors(opt list) → assembles a ShellDescriptor.digital-twin-registry/provider/wizard/create_submodel_descriptor— fieldsaas_identifier,id(opt),id_short,semantic_id,endpoint_url→ assembles a SubModelDescriptor (semantic_id becomes the AASsemanticIdreference structure; endpoint_url becomes the single SUBMODEL-3.0 endpoint entry).
Test: poetry run pytest tests -k "asset or policy or wizard or provision" -q.
E6 — HTTP / mock (C17, C30, C31, C38, C39; C24 verify-only)
Files: steps/server/mock.py, steps/server/wait.py,
server/mock_registry.py, HTTP step (grep http/http_request — likely
steps/utility/ or steps/server/), steps/industry/notification.py.
- C30
http/http_request: addquery_params: dict[str, str](default{}), merged into the URL (passparams=torequests.request). - C31
mock/api: addresponse_headers: dict[str, str]param; the mock endpoint replies with them (thread throughserver/mock_registry.py/player/execution/mock_server.py— find where status/body are stored per endpoint and add headers alongside). - C38
mock/apioutput becomes a structured mock instance (replaces the bare string): fieldsendpoint_id,base_mock_url(mock server root),full_mock_url(root + the unique generated path — directly callable). Build it from what the registry already knows when registering the mock. - C17
mock/wait/http_requestparam:endpoint_id: str→mock, typed as the C38 mock-instance object (dict/model with at leastendpoint_id). The step readsmock["endpoint_id"](or model attr) to find the endpoint. Accept ONLY the object — not a bare id string (no dual shape). - C39
mock/wait/http_requestoutputs: keep/ensurerequest_method,request_path,request_headers,request_body; ADDrequest_query_paramsandelapsed_ms(wall-clock waited). Rename any un-prefixed spellings. - C24 verify
notification/consumer/sendusesdataplane_url,edr_token,content(should already; fix anyendpoint_url/auth_token/payloadleftovers). - C07 (
http/http_requestname) and C08 (mock module inconsistency): keep as-is — no-ops by decision.
Test: poetry run pytest tests -k "mock or wait or http" -q.
E7 — flow/if, semantic_schema deletion, C16, fallback restriction (C06, C32, C16, C40)
- C06 new file
steps/flow/if.py(idflow/if), modeled onsteps/flow/retry.py(read it first — nestedlist[StepDefinition]params, global-registry lookup via_ANY_VERSION, sequential nested execution,StepValue[list[Any]]output): - Params:
condition: str(a${{ }}expression — evaluate with the EXISTING evaluator insteps/conditions.py/_condition_parsing.py; do not write a new parser),then: list[StepDefinition](required,min_length=1),else_: list[StepDefinition](default[],validation_alias="else",serialization_alias="else"). - Behavior: evaluate once; run
thensteps in order when truthy,elsewhen falsy (no-op if empty). Nested failures propagate like retry's do. - Output:
branch_taken("then"/"else"/"none"),condition_result: bool,outputs: list(executed branch's outputs). - Register the module import wherever
steps/flow/__init__.py/steps/__init__.pyimportsdelay/retry. flow/conditionis NOT an engine step —conditionis the expression string itself. Do not register it.- C32 delete
validate/semantic_schemaentirely: grepsemantic_schema(step likely insteps/industry/semantic.py); remove the step class + registration; keep any unrelated code in the file; update__init__imports; grep docs. - C16
util/json_path_extract(steps/utility/json_extract.pyline ~118) currently hasvalidation_alias=AliasChoices("source", "variable"). The canonical name isinput(ADR-0025 renamedutil/validate_pathalready — verify). Rename the field toinputwith NO aliases (sourceandvariableboth die), update callers/tests/examples. - C40 restrict universal output fallbacks —
steps/_checks/extraction.py: request/responsestay on everyStepOutputand stay visible in logs/trace (do not touch logging).- But
${{ execution.<step>.<name> }}resolution must only resolve names the step's declaredoutput_modelactually has. The blanket fallthrough lives in_fallback_resolution()(triesoutput.responseattrs →response.bodydict keys →StepOutputslots for ANY name) and theresponse_body/response_headersaliases in_resolve_first_segment(). - Implementation: thread the resolving step's
output_model(or itsmodel_fieldsname-set) intoextract_pathfrom the call site (find callers: grepextract_path(— resolver lives aroundplayer/execution/_helpers.py/authoringexpression resolution). When the first segment is NOT in the declared field set (and not a key of a dictvalue), returnNoneinstead of falling through to response internals. Delete_fallback_resolutionif nothing legitimate remains, or gate it on the declared-field check. - Fix tests that relied on blanket fallbacks (
status_code,body,duration_ms,response_bodyreferences in tests) to use declared outputs.validate/assert-family steps that deliberately readinput:values are unaffected (they receive values, not paths into other steps).
Test: poetry run pytest tests -k "flow or condition or extract or validate" -q,
then the full suite briefly — C40 has wide blast radius.
E8 — Typed execution events (C46)
⚠️ The repo owner's uncommitted WIP (models/runtime/events.py,
player/execution/monitor.py, _trace_formatter.py, _run_phase.py,
player.py, models/primitives/enums.py) appears to BE the start of this
work. READ those diffs first (git diff + the untracked events.py). Build on
them; do not start parallel.
Target design (per decision C46): an event manager every execution component
publishes typed lifecycle events through, with an explicit kind field, so
no client ever has to string-sniff step_type.
- Event kinds (minimum):
job_started,job_completed,job_failed,job_cancelled,step_started,step_completed,step_failed,step_skipped,assertion_result. - Payloads reuse
StepStatus/StepResultfrommodels/primitives/enums.py/models/runtime/results.py; each step event carriesstep_id,uses,status, and a short error/output summary.assertion_resultadditionally carries pass/fail + message, so no client has to infer an assertion failure fromstep_type. - Wire through the existing SSE stack:
server/streaming/routes.py,lifecycle.py,_event_buffer.py(ordering/ids come fromEventBuffer, not wall-clock),formatter.py(keepTERMINAL_EVENTSnamesjob.completed/job.failed/job.cancelledon the wire). - Write
docs/developer/execution-events.md: every kind, payload shape, one example JSON each. This document is the contract the server's SSE clients implement against — it must be complete.
Test: poetry run pytest tests -k "stream or sse or event or monitor" -q.
E9 — extra="forbid" + full-suite gate + docs regen (C47)
MUST run last.
steps/base.py(whereStepParams'ConfigDictlives — it may be insteps/base.pyor_contracts.py; grepextra="allow"understeps/): flip StepParams toextra="forbid". Unknownwith:keys are now validation errors surfaced at compile/run.- Run the FULL suite:
poetry run pytest -q. Every failure is a stale spelling somewhere — fix the caller, never loosen back toallow. Also flip/keepDescriptorPayload-style output payloads onextra="allow"— the forbid applies toStepParams(inputs) only; AAS/DCAT documents legitimately carry unknown keys. - Regenerate the step reference page (fixes the E1 known-debt test):
the generator is
cli/docs.py— runpoetry run python -m tractusx_testlab docs(checkcli/docs.pyfor the exact subcommand/output path; the committed page isdocs/api-reference/steps.md).
FINAL VERIFICATION
- Engine:
poetry run pytest -q→ all green. - Commit messages: one commit per cluster (
E2…E9), message naming the conflict ids covered. Do not push or open PRs without the owner's say-so.