Step Contracts — the Single Source of Truth
Every step in TestLab has exactly one canonical contract: one step id, one set of parameter names, one output shape. The YAML tests, the Python executors, the compiler's contract checks, the generated reference documentation and JSON schemas, and the assertion system all read that same contract — none of them keeps its own copy of it.
This page explains where the contract lives, how it is enforced, and the patterns that keep the different layers from drifting apart. It is the conceptual layer above Block Lifecycle, which traces a single step through the pipeline.
The rule: one name, one shape
The rule is deliberately blunt:
A step accepts each parameter under exactly one name, produces its output in exactly one shape, and is addressed by exactly one id. No aliases, no backward-compat shims — when a name changes, every test and document is migrated to the new one.
Why so strict? Aliases are how the drift started. Back when a parameter answered to
provider_url and counter_party_address, tests wrote one spelling, the engine
documented another, the JSON Schema rendered a third, and no check could any longer
tell a typo from a valid alternative — which is exactly why the rule exists.
The historical record of every conflict this caused
and how each was resolved lives in the
contract conflict decision sheet (C01–C47), executed via
the migration plan.
Three practical consequences of the rule:
- Unknown parameters are an error, not something to silently ignore — a misspelled
with:key must fail the step, not no-op. - The JSON Schema produced from a step is faithful: what
describe()says is exactly what the executor accepts. - The step reference and the JSON schemas are generated from the engine registry, because there is nothing left to hand-maintain about names and shapes.
Where the contract lives
The contract is declared in Python, next to the executor, using three Pydantic base
classes from src/tractusx_testlab/steps/step_contract.py:
| Base class | Declares | Notes |
|---|---|---|
StepParams |
the step's inputs — one field per accepted with: key |
validated before execute runs |
StepPayload |
the output as an object shape | extra="forbid"; StepPayload.of(doc) binds a document received from a counterpart |
StepValue[T] |
the output as a bare value (e.g. util/base64 → str) |
the docstring becomes the field description |
There is no separate export channel: every step publishes all of its return
outputs, always. Each top-level field of the output becomes a context variable of
the same name after the step runs (None values leave the variable unset), so the
constants in syntax.context_vars are simply the output field names downstream
steps read back as parameter fallbacks.
A complete declaration looks like this:
class ExtractDatasetParams(StepParams):
datasets: list[dict]
dct_type: str
class ExtractDatasetOutput(StepPayload):
dataset: Optional[dict] = None
offer_id: Optional[str] = None
asset_id: Optional[str] = None
@step("connector/consumer/extract_dataset")
class ExtractDatasetStep(BaseStep[ExtractDatasetParams, ExtractDatasetOutput]):
params_model = ExtractDatasetParams
output_model = ExtractDatasetOutput
BaseStep.describe() projects these models into a machine-readable StepContract
(step_type, description, params_schema, output_schema — all
JSON Schema). Everything downstream — the generated step reference, the compiler's
contract checks, assertion resolution — is derived from describe() or from the models
behind it.
Enforcement is at import time
BaseStep.__init_subclass__ calls _require_declared_contract, which raises TypeError
the moment a step class is defined without a proper params_model/output_model. The
check lives in __init_subclass__ rather than in the @step decorator on purpose: the
decorator and a direct StepRegistry.register call can never diverge. There is therefore
no such thing as a registered step whose interface is undocumented.
The execution path honours the contract
BaseStep.invoke() is the only way a step runs, and it is a straight line through the
declared models:
bind_params raw `with:` dict → params_model (unknown/invalid keys fail here)
execute the step's own logic, typed params in, typed output out
bind_output TypeError if execute returned anything but the declared output_model,
then serialised with mode="json", by_alias=True, exclude_unset=True
publish_output every top-level field of the serialised output written into the
run context under its own name (None values leave the variable unset)
There is no code path where a step reads an undeclared parameter or emits an undeclared field.
Shared contract modules
When two steps talk about the same thing, they share one model instead of re-declaring it. The shared models live in three places:
steps/shared_models.py— cross-step models: parameter mixins (FilterExpressionParams,HttpTransportParams,HttpCallParams), theFilterExpressionshape (snake_case in, camelCase only on serialisation), the unifiedCatalogOutput(catalog+datasets, shared by everyquery_catalog*step),DataAddressPayload(an EDR data address document) andNoOutputfor steps that deliberately return nothing.steps/counter_party.py—CounterPartyParams, the counter-party of a DSP request.steps/mock/_models.py— mock models shared by themock/*steps, most importantlyMockInstance(see below).
Per-step models live beside their executor (steps/connector/provision/asset.py,
steps/digital_twin_registry/provider/shell.py, …). A model earns a place in shared_models.py
only once a second step needs it.
MockInstance: a contract that crosses the test
mock/api returns a MockInstance object (endpoint_id, path, method,
base_mock_url, full_mock_url); mock/wait/http_request takes that same object as its
only way to identify the endpoint. One typed value flows step → test variable → step,
replacing the old guessing between a bare URL, an id or a path. Note the separation of
layers: server/mock_registry.py (plain dataclasses, HTTP routing) describes what the
mock server serves; MockInstance describes what a test holds. They meet only in
the mock/* steps.
Step ids
Ids follow <category>/<module>/<function>:
- category — the domain under test (
connector,digital-twin-registry,notification) or an engine facility (util,flow,validate,http,mock); - module — the component or access path within the category (
consumer,provider,dataplane,submodel); - function — the operation.
The module segment is omitted only when the category has no sub-division (util/log,
flow/delay, validate/assert) — and once a category grows one, every id in it carries
one. A fourth segment is allowed when the access path is itself what distinguishes the
step: digital-twin-registry/consumer/dataplane/lookup_shell is a different step from
digital-twin-registry/provider/get_shell_descriptor precisely because of how the registry is
reached.
Note that steps never name the service they run against — connector services are seeded
into the run context at runtime, and data-plane steps take exactly dataplane_url +
edr_token.
Guided siblings (wizard/ steps)
Some resources can sensibly be created two ways: by handing over the whole document
(tests driven by env.variables), or field by field (tests that spell out each field).
One step accepting both shapes would violate the one-shape rule, so each of the four
creation steps has a guided sibling under a wizard/ module:
connector/provider/create_asset ⇄ connector/provider/wizard/create_asset
connector/provider/create_policy ⇄ connector/provider/wizard/create_policy
digital-twin-registry/provider/create_shell_descriptor ⇄ digital-twin-registry/provider/wizard/create_shell_descriptor
digital-twin-registry/provider/create_submodel_descriptor⇄ digital-twin-registry/provider/wizard/create_submodel_descriptor
The anti-drift mechanism is structural, not disciplinary: each pair funnels into a single
module-level helper (e.g. _register_asset in steps/connector/provision/asset.py) — the raw
step hands over the document it was given, the wizard hands over the document it
assembled, and both get the same call and the same error handling. Both siblings also
share the same output model, so returns: is identical whichever one a test uses.
Keeping it from drifting
Three mechanisms guard the contract, in decreasing order of strength:
- Import-time enforcement — a step without declared models cannot exist (see above).
- Contract tests — tests that assert on the declared models themselves, not just on
behaviour:
tests/unit/steps/test_step_contracts.py(drivesdescribe()),tests/unit/steps/mock/test_mock_and_http_contract.py(including tests that assert the absence of retired parameter spellings),tests/unit/steps/connector/test_catalog_query_contract.py. - Generated artefacts with
--check—poetry run testlab docs --checkregenerates the step reference (docs/api-reference/steps/) from the registry and fails if the committed pages or themkdocs.ymlstep navigation differ (renderer:authoring/step_catalog.pylays out the page,authoring/step_docs.pyrenders each step).
The --check gate is the weaker guard: they catch drift after the fact. The point of the
architecture is that most drift is impossible to express — there is only one place to
write a name down.
Related reading
| Document | What it covers |
|---|---|
| Contract Conflict Decisions | The decision sheet: every conflict C01–C47 and its resolution |
| Contract Migration Plan | The executed migration, cluster by cluster (E1–E9) |
| Block Lifecycle | End-to-end trace of one step: YAML → registry → executor → SDK |
| Creating a Step | How-to for adding a new step (and therefore a new contract) |
| ADR-0025 (decision records) | Assertions read the declared returns: of the referenced step |