ADR-0019: Service Requirements and Engine Bindings
Status
Proposed (amends ADR-0011 §1–§4)
Date
2026-06-04
Context
ADR-0011 places the full service configuration — URLs, paths, auth, credentials — inside
every TCK manifest (env.services). In practice this configuration is identical across most
TCKs: a connector is a connector regardless of which standard is being certified. Repeating it
per TCK is redundant, couples reusable test content to one deployment, and forces test authors
to handle infrastructure secrets they should never see.
Two distinct concerns are conflated today:
- What a TCK needs — an abstract capability ("an EDC connector, Saturn, CX-0018"). Stable, portable, authored once by the test author.
- How that capability is satisfied — concrete endpoint, paths, and credentials. Volatile, deployment-specific, supplied by the operator running the run.
The wider ecosystem separates these the same way: Helm/npm peerDependencies (declare a
requirement, the consumer provides the instance), Kubernetes resource requests vs the cluster
that schedules them, Terraform required_providers vs provider config, and ports-and-adapters
dependency injection (required interface vs bound implementation).
A second case is the System Under Test. Almost every TCK needs the SUT to expose a connector to TestLab. That is not TestLab configuration — it is a requirement the operator must satisfy before the run, declared as a requirement and surfaced to steps as a variable (ADR-0018), not configured as a service.
A third case is the dataspace itself — which ecosystem and version the run targets (e.g.
Catena-X, saturn). This is not a service to bind; it is the context the engine and SUT are
certified within. It belongs at the top level, alongside infrastructure, and supplies the
default version/ecosystem that capability constraints inherit.
Decision
Declare the topology with two top-level blocks. dataspace: states the ecosystem context
(ecosystem, version). infrastructure: names the two bindable sides — engine (what the
embedding host operates, i.e. TestLab as a library) and sut (what the System Under Test must
provide). Under each side, capabilities are keyed by name; there is no name, role, or list.
The operator supplies concrete bindings mirroring the infrastructure structure.
The side is named engine, not testlab, so the manifest stays host-agnostic: TestLab remains a
library that any embedding system can drive without the YAML hardcoding the product name. Side
keys are plain identifiers (engine, sut) — not hyphenated — so they stay valid in
${{ ... }} dot-path references.
1. Requirements — TCK level, keyed by side and capability
The capability is the key, so it never repeats as a field. The value is always an object with an
explicit required flag plus optional constraints. Direction (provider vs consumer) is not
modelled here — a connector is one engine-side instance, and how a step drives it is decided per
step.
dataspace: # ecosystem context the run targets
ecosystem: Catena-X
version: saturn
infrastructure:
engine: # infrastructure the embedding host (TestLab) operates
connector:
required: true
standard:
id: CX-0018
version: 2.1.3
sut: # what the operator must provide → request variables
connector:
required: true
standard:
id: CX-0018
version: 2.1.3
dtr:
required: true
standard:
id: CX-0002
version: 1.0.0
required: false declares a capability the TCK knows about but does not require for this run — so
required is also how a dependency is marked required (true) vs optional (false).
The standard constraint is optional and only worth writing when the TCK genuinely certifies
against a specific standard — typically the SUT capability under test. It is an object with the
standard id and version; when standard.version is omitted it inherits from
dataspace.version. The common case is just required: true.
The required flag exists because not every certification needs every capability. A connector is
only relevant when the test performs connector-mediated data exchange (DSP contract negotiation,
transfer). Many TCKs certify capabilities that authorize and operate without a connector — for
example Discovery Finder, BPN/EDC discovery, or shared services such as BPDM — where access is
governed by IAM (OAuth2/OIDC token) rather than connector contract authorization. Such a TCK marks
connector: { required: false } (or omits it) and the engine neither demands a connector binding
nor accepts connector steps, while still requiring the capabilities those tests do exercise.
Capability keys come from a registry. Per side:
| Side | Typical capabilities |
|---|---|
engine |
connector, dtr |
sut |
connector, dtr |
The mock server is not a bindable capability — it is the engine's own built-in component
(TestLab operates it internally), so it is never declared here or in bindings.
2. Engine Bindings — operator level, same structure
The operator supplies one binding profile mirroring the infrastructure shape — same sides, same
capability keys. Secrets stay here, never in the portable TCK.
bindings:
engine:
connector:
base_url: https://connector.local
management_path: /management/v3
dsp_path: /api/v1/dsp
auth: { type: api_key, api_key: ${ENV_PROVIDER_KEY}, api_key_header: X-Api-Key }
sut:
connector:
dsp_url: https://sut.example.com/api/v1/dsp
bpn: BPNL000000000SUT
3. Matching and validation (fail fast)
Before a run the engine resolves every required infrastructure.<side>.<capability> against the
matching bindings.<side>.<capability>, checks each constraint, and aborts with a typed error on
any unbound or violated requirement. Unbound sut capabilities surface as unresolved request
variables for the operator to fill (ADR-0021), not configuration errors.
At boot the engine announces this resolved topology once via tck.boot.requirements (ADR-0016) \u2014
each side's capabilities with their required flag (true = enabled, false = declared but not
exercised) \u2014 so the operator and any client know up front what each side, especially the SUT, must
provide. Engine-operated components it then starts (the connector service client, the built-in mock
server) are logged via tck.boot.service.*, distinct from the endpoint validation of
tck.boot.binding.*.
4. Steps reference variables, never infrastructure or bindings
Amendment (2026-07): The strict separation below is the recommended architecture for production TCKs. In practice, V2 test files MAY reference
${{ infrastructure.<side>.<capability>.<field> }}directly as a convenience — the player seeds these from the operator's binding profile into the variable context at boot time. This shorthand avoids requiring authors to redeclare every infrastructure field as an explicitenvvariable. The compiler and player resolve these identically toenv.*references. See ADR-0010 Appendix A.4.
A step's only reference surface is a variable (ADR-0018) — ${{ env.<name>.<field> }} or
another step's output. Steps never name infrastructure.* or bindings.*; that topology is
engine-internal. The two sides surface to authoring differently:
- Engine side is hidden plumbing, injected by capability. The player resolves the engine
binding from the operator profile and injects it into a step by the step's capability — a
connector/*step drivesengine.connector, adtr/*step drivesengine.dtr— with no handle in the step. The injection happens once at boot and is recorded there (tck.boot.binding.*, ADR-0016): non-secret config (urls, paths, bpn, version) in the eventoutputs, secret fields (auth, credentials) JWE-encrypted. This honours "hide plumbing" — authors never wire the engine's own connector or DTR into a step, and the concrete EDC/DTR config enters the run in exactly one place. - SUT side surfaces as a variable. Each required
infrastructure.sut.<capability>resolves to a variable:knownwhen the operator pre-supplied a binding,requestwhen unbound (the operator fills it at run start). A step targets the SUT by referencing that variable's fields, e.g.${{ env.sut_connector.counter_party_address }}.
What a step references is always a variable, never the capability handle.
A capability gates its own steps. A step may only be used when its capability is declared
required: true on the relevant side. If a TCK uses a step whose capability is missing or
required: false, the compiler aborts with a typed error naming the step and the capability it
needs. There is no implicit enablement: using a step is the contract that its capability must be
required.
Consequences
- TCKs become portable: the same TCK runs against any deployment by swapping the binding profile.
- A capability gates its steps: a step whose capability is not
required: truecannot be used, failing fast at compile time with a typed error — no silent no-ops at runtime. - Test authors never touch URLs or secrets; operators own all deployment configuration.
- The keyed form is minimal — a typical TCK nests
connector: { required: true }underinfrastructure.engineandinfrastructure.sut; thestandardconstraint is an object (id,version) whoseversioninherits fromdataspace.versionand is spelled out only when a capability certifies a specific standard. dataspace.versionis the single source of the ecosystem version; the oldmetadata.dataspace_versionis removed — nothing duplicates it.- SUT requirements unify with the variables model (ADR-0018, ADR-0021) — an unbound
infrastructure.sutcapability surfaces as arequestvariable the operator fills at run start, and steps reference that variable, never the capability handle. - Engine EDC/DTR configuration enters the run in exactly one place — injected by capability at boot
and recorded in
tck.boot.binding.*(ADR-0016), with secret fields JWE-encrypted — so the concrete config never appears in authored YAML or per-step inputs. - ADR-0011 §1–§4 (full config in
env.services) is superseded; a migration converts existingenv.servicesinto theinfrastructureblock plus a default binding profile. - One capability = one instance per side. A TCK that genuinely needs two distinct connectors on the same side is out of scope; revisit with explicit provider/consumer keys only if a real case appears (deliberately deferred — do not pre-build it).
- New maintained surfaces: the capability registry and the binding-profile schema/loader.
- Open: exact binding-profile location (CLI flag, engine config file, or env injection).