From 1923728e9fbfc3a6598904e51c662ae08962388b Mon Sep 17 00:00:00 2001 From: Simon Schrottner Date: Mon, 27 Jul 2026 11:11:42 +0200 Subject: [PATCH 01/55] feat(provider-tck): add Java conformance suite for OpenFeature providers OpenFeature promises that swapping providers does not change application behaviour, but nothing verifies that today. Every provider tests differently, so "implements the provider contract" is an unverified claim. Adds tools/provider-tck: the canonical Gherkin, the Cucumber step definitions, and an abstract JUnit Platform Suite that owns the whole test lifecycle. A provider author implements a four-method factory interface and supplies a docker-compose stack; the TCK owns container lifecycle, dynamic port discovery, control API calls, provider registration and event awaiting. Also adds a standardised backend control API (OpenAPI), derived from the endpoints flagd-testbed's launchpad already implements, and the canonical flag set the feature files assume. Both are packaged in the JAR alongside the features so consumers need no git submodule. Two normative requirements are documented in the control API spec: * Backend unavailability MUST be simulated inside the running stack, never by stopping or restarting a container. Testcontainers cannot reliably preserve dynamically mapped host ports across a container restart, and which bindings preserve them differs by language. * /start resets flag state; /restart preserves it. An outage must be observable as a change in availability, never in flag values. flagd is the first adopter, wrapping the unmodified flagd-testbed image. The adoption is 48 lines of code plus a compose file and a one-line ServiceLoader registration; flagd-testbed is not modified and the existing flagd e2e suites are untouched. Scenario coverage is a representative subset covering each architectural mechanism once: typed evaluation with value/variant/reason, the integer/float distinction, TYPE_MISMATCH and FLAG_NOT_FOUND returning code defaults without throwing, provider init success and failure, and configuration-change and stale/ready event transitions. Two findings from the first run against flagd: * The flagd provider silently narrows a float flag to an integer: evaluating float-flag (0.5) as an integer returns 0 with no error code, rather than TYPE_MISMATCH with the code default. Reported as a visible skip via the STRICT_NUMERIC_TYPING capability pending a fix. * Cucumber parallelism inherited from a consuming module's junit-platform.properties silently corrupts the suite, because control API state is global to the stack. The base suite now pins serial execution rather than relying on documentation. Refs #1829 Signed-off-by: Simon Schrottner --- .github/component_owners.yml | 2 + .release-please-manifest.json | 1 + pom.xml | 1 + providers/flagd/pom.xml | 13 + .../providers/flagd/e2e/FlagdTckTest.java | 74 ++++ ...ntrib.tools.providertck.ProviderTckHarness | 1 + .../test/resources/tck/docker-compose.yaml | 15 + release-please-config.json | 11 + tools/provider-tck/README.md | 323 +++++++++++++++ tools/provider-tck/lombok.config | 2 + tools/provider-tck/pom.xml | 176 +++++++++ .../providertck/AbstractProviderTckTest.java | 46 +++ .../tools/providertck/BackendEndpoint.java | 77 ++++ .../contrib/tools/providertck/Capability.java | 95 +++++ .../tools/providertck/ControlApiClient.java | 228 +++++++++++ .../tools/providertck/FlagUnderTest.java | 57 +++ .../providertck/ProviderEventRecord.java | 47 +++ .../tools/providertck/ProviderTckHarness.java | 217 +++++++++++ .../contrib/tools/providertck/TckRuntime.java | 175 +++++++++ .../contrib/tools/providertck/TckState.java | 64 +++ .../contrib/tools/providertck/TckValues.java | 58 +++ .../providertck/steps/AbstractSteps.java | 39 ++ .../tools/providertck/steps/ContextSteps.java | 71 ++++ .../tools/providertck/steps/EventSteps.java | 119 ++++++ .../tools/providertck/steps/FlagSteps.java | 255 ++++++++++++ .../providertck/steps/ProviderSteps.java | 216 ++++++++++ .../main/resources/features/errors.feature | 42 ++ .../resources/features/evaluation.feature | 59 +++ .../main/resources/features/events.feature | 42 ++ .../main/resources/features/lifecycle.feature | 33 ++ .../main/resources/flags/canonical-flags.json | 82 ++++ .../main/resources/openapi/control-api.yaml | 368 ++++++++++++++++++ tools/provider-tck/version.txt | 1 + 33 files changed, 3010 insertions(+) create mode 100644 providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/e2e/FlagdTckTest.java create mode 100644 providers/flagd/src/test/resources/META-INF/services/dev.openfeature.contrib.tools.providertck.ProviderTckHarness create mode 100644 providers/flagd/src/test/resources/tck/docker-compose.yaml create mode 100644 tools/provider-tck/README.md create mode 100644 tools/provider-tck/lombok.config create mode 100644 tools/provider-tck/pom.xml create mode 100644 tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/AbstractProviderTckTest.java create mode 100644 tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/BackendEndpoint.java create mode 100644 tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/Capability.java create mode 100644 tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/ControlApiClient.java create mode 100644 tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/FlagUnderTest.java create mode 100644 tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/ProviderEventRecord.java create mode 100644 tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/ProviderTckHarness.java create mode 100644 tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/TckRuntime.java create mode 100644 tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/TckState.java create mode 100644 tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/TckValues.java create mode 100644 tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/steps/AbstractSteps.java create mode 100644 tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/steps/ContextSteps.java create mode 100644 tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/steps/EventSteps.java create mode 100644 tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/steps/FlagSteps.java create mode 100644 tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/steps/ProviderSteps.java create mode 100644 tools/provider-tck/src/main/resources/features/errors.feature create mode 100644 tools/provider-tck/src/main/resources/features/evaluation.feature create mode 100644 tools/provider-tck/src/main/resources/features/events.feature create mode 100644 tools/provider-tck/src/main/resources/features/lifecycle.feature create mode 100644 tools/provider-tck/src/main/resources/flags/canonical-flags.json create mode 100644 tools/provider-tck/src/main/resources/openapi/control-api.yaml create mode 100644 tools/provider-tck/version.txt diff --git a/.github/component_owners.yml b/.github/component_owners.yml index 4e4a03415e..90a018aa95 100644 --- a/.github/component_owners.yml +++ b/.github/component_owners.yml @@ -46,6 +46,8 @@ components: - toddbaert tools/flagd-http-connector: - liran2000 + tools/provider-tck: + - aepfli ignored-authors: - renovate-bot diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 7e3e7f267e..4fbee09ec2 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -17,6 +17,7 @@ "tools/flagd-http-connector": "0.0.5", "tools/flagd-api": "1.0.0", "tools/flagd-api-testkit": "0.2.1", + "tools/provider-tck": "0.0.1", "tools/flagd-core": "2.0.1", ".": "1.0.0", "providers/optimizely": "1.0.0" diff --git a/pom.xml b/pom.xml index 29963bb064..ee48359381 100644 --- a/pom.xml +++ b/pom.xml @@ -28,6 +28,7 @@ + tools/provider-tck tools/flagd-api-testkit tools/flagd-api tools/flagd-core diff --git a/providers/flagd/pom.xml b/providers/flagd/pom.xml index 8980e8f090..15001f4bbc 100644 --- a/providers/flagd/pom.xml +++ b/providers/flagd/pom.xml @@ -22,6 +22,8 @@ 1.2.28 [2.0.0,3.0.0) + + [0.0.1,) flagd @@ -98,6 +100,17 @@ 5.14.3 test + + + dev.openfeature.contrib.tools + provider-tck + ${provider-tck.version} + test + org.testcontainers testcontainers diff --git a/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/e2e/FlagdTckTest.java b/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/e2e/FlagdTckTest.java new file mode 100644 index 0000000000..2d1d6ef7ee --- /dev/null +++ b/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/e2e/FlagdTckTest.java @@ -0,0 +1,74 @@ +package dev.openfeature.contrib.providers.flagd.e2e; + +import dev.openfeature.contrib.providers.flagd.Config; +import dev.openfeature.contrib.providers.flagd.FlagdOptions; +import dev.openfeature.contrib.providers.flagd.FlagdProvider; +import dev.openfeature.contrib.tools.providertck.AbstractProviderTckTest; +import dev.openfeature.contrib.tools.providertck.BackendEndpoint; +import dev.openfeature.contrib.tools.providertck.Capability; +import dev.openfeature.sdk.FeatureProvider; +import java.io.File; +import java.util.Collections; +import java.util.EnumSet; +import java.util.List; +import java.util.Set; + +/** + * Runs the OpenFeature Provider TCK against the flagd provider in RPC mode. + * + *

The entire adoption is this class plus {@code src/test/resources/tck/docker-compose.yaml} and + * a one-line {@code META-INF/services} registration. Everything else — the Compose lifecycle, port + * discovery, control API calls, provider registration, event awaiting — belongs to the TCK. + * + *

To also cover in-process mode, copy this class, change the resolver, and register both; then + * select one per Surefire execution with {@code -Dopenfeature.tck.harness=}. + */ +public class FlagdTckTest extends AbstractProviderTckTest { + + private static final int RPC_PORT = 8013; + + @Override + public File composeFile() { + return new File("src/test/resources/tck/docker-compose.yaml"); + } + + @Override + public List backendPorts() { + return Collections.singletonList(RPC_PORT); + } + + @Override + public FeatureProvider createProvider(BackendEndpoint endpoint) { + return new FlagdProvider(FlagdOptions.builder() + .resolverType(Config.Resolver.RPC) + .host(endpoint.host()) + .port(endpoint.port(RPC_PORT)) + .deadline(1000) + .retryGracePeriod(2) + .retryBackoffMs(500) + .build()); + } + + /** + * {@inheritDoc} + * + *

Everything except {@link Capability#STRICT_NUMERIC_TYPING}. Evaluating {@code float-flag} + * (0.5) through the integer API returns {@code 0} with no error code rather than + * {@code TYPE_MISMATCH} with the code default — the value is silently truncated. That is a + * defect to fix, not a design choice; this line should be deleted once it is. + */ + @Override + public Set capabilities() { + return EnumSet.complementOf(EnumSet.of(Capability.STRICT_NUMERIC_TYPING)); + } + + @Override + public FeatureProvider createUnavailableProvider() { + return new FlagdProvider(FlagdOptions.builder() + .resolverType(Config.Resolver.RPC) + .host("localhost") + .port(9999) + .deadline(1000) + .build()); + } +} diff --git a/providers/flagd/src/test/resources/META-INF/services/dev.openfeature.contrib.tools.providertck.ProviderTckHarness b/providers/flagd/src/test/resources/META-INF/services/dev.openfeature.contrib.tools.providertck.ProviderTckHarness new file mode 100644 index 0000000000..37c5e9ef41 --- /dev/null +++ b/providers/flagd/src/test/resources/META-INF/services/dev.openfeature.contrib.tools.providertck.ProviderTckHarness @@ -0,0 +1 @@ +dev.openfeature.contrib.providers.flagd.e2e.FlagdTckTest diff --git a/providers/flagd/src/test/resources/tck/docker-compose.yaml b/providers/flagd/src/test/resources/tck/docker-compose.yaml new file mode 100644 index 0000000000..4cecaa1388 --- /dev/null +++ b/providers/flagd/src/test/resources/tck/docker-compose.yaml @@ -0,0 +1,15 @@ +# Backend stack for the OpenFeature Provider TCK, wrapping the unmodified flagd testbed image. +# +# The image already serves everything the TCK needs: flagd itself, and the "launchpad" control +# API on 8080 whose endpoints this TCK's control API contract was derived from. +# +# Note there are no host port bindings. The TCK requires dynamically mapped ports and discovers +# them after startup — a pinned host port would make the suite unrunnable in parallel and would +# collide with a developer's local flagd. +services: + backend: + image: ghcr.io/open-feature/flagd-testbed:v3.8.0 + ports: + - 8013 # flagd RPC evaluation (gRPC) + - 8015 # flagd in-process sync (gRPC) + - 8080 # launchpad control API diff --git a/release-please-config.json b/release-please-config.json index d3281c2d59..b67946170b 100644 --- a/release-please-config.json +++ b/release-please-config.json @@ -205,6 +205,17 @@ "README.md" ] }, + "tools/provider-tck": { + "package-name": "dev.openfeature.contrib.tools.providertck", + "release-type": "simple", + "bump-minor-pre-major": true, + "bump-patch-for-minor-pre-major": true, + "versioning": "default", + "extra-files": [ + "pom.xml", + "README.md" + ] + }, "tools/flagd-api-testkit": { "package-name": "dev.openfeature.contrib.tools.flagdapitestkit", "release-type": "simple", diff --git a/tools/provider-tck/README.md b/tools/provider-tck/README.md new file mode 100644 index 0000000000..a8d767ba50 --- /dev/null +++ b/tools/provider-tck/README.md @@ -0,0 +1,323 @@ +# OpenFeature Provider TCK + +A conformance test suite that any OpenFeature provider can adopt to verify it implements the +provider contract of the [OpenFeature specification](https://openfeature.dev/specification/). + +OpenFeature's central promise is that swapping providers does not change application behaviour. +Today nothing verifies that — every provider tests differently, so "implements the provider +contract" is an unverified claim. This is the shared suite that makes it checkable. + +> **Status: proof of concept.** The scenario set is a representative subset covering each +> architectural mechanism once, not exhaustive coverage. See [Known gaps](#known-gaps). + +## Installation + + +```xml + + dev.openfeature.contrib.tools + provider-tck + 0.0.1 + test + +``` + + +Requires Java 11+, JUnit 5, and a working Docker daemon. + +### OpenFeature SDK compatibility + +The TCK declares `dev.openfeature:sdk` as a **`provided` version range** (`[1.21.0,1.99999)`), +inherited from this repository's parent POM. It never pins an SDK version. + +That is deliberate. A conformance suite that forces an SDK upgrade before you can run it is a +conformance suite nobody runs. Your build keeps whatever SDK version it already resolves; the TCK +uses only long-stable API — `OpenFeatureAPI`, `Client`, typed evaluation, `ProviderEvent`, +`ProviderState`. + +## What it tests, and what it does not + +**In scope — the provider contract:** + +- mapping backend responses onto typed resolution details (value, variant, reason, error code) +- keeping the integer and float types distinct +- error handling: type mismatch and unknown flag return the code default, report the right error + code, and never throw +- lifecycle: reaching `READY`, and settling into `ERROR` against an unreachable backend +- events: `PROVIDER_READY`, `PROVIDER_ERROR`, `PROVIDER_STALE`, `PROVIDER_CONFIGURATION_CHANGED` +- that a signalled configuration change is actually applied on re-evaluation + +**Out of scope — not the provider's contract:** + +- backend evaluation logic, targeting and bucketing correctness. Every flag in the canonical set + resolves to its default variant with no targeting, so what is under test is the provider's + mapping of a response, not the backend's decision. +- the provider↔backend wire protocol. How you talk to your backend is your business. +- SDK behaviour. That belongs to the SDK's own test suite. + +## Adopting it + +Four things to implement, then two small files. + +### 1. A Docker Compose stack + +```yaml +# src/test/resources/tck/docker-compose.yaml +services: + backend: + image: your-org/your-testbed:1.0.0 + ports: + - 8080 # control API (see below) + - 5000 # whatever your provider connects to +``` + +Conventions the TCK relies on — all overridable: + +| Convention | Default | Override | +|---|---|---| +| Service hosting the control API and backend | `backend` | `backendService()` | +| Container-internal control API port | `8080` | `controlPort()` | +| Extra services/ports to expose | none | `additionalExposedPorts()` | + +**Never pin host ports.** External ports are mapped dynamically and discovered after startup — +that is why the provider comes from a factory rather than a constant. Pinned ports make the suite +unrunnable in parallel with anything else and collide with a developer's local backend. + +The stack may contain any number of extra containers: a toxiproxy, an edge service, a sidecar. +The TCK only cares about the two conventions above. + +### 2. A control API on the backend + +Your stack must expose a small HTTP control API so the TCK can put the backend into specific +states. The full contract is in [`openapi/control-api.yaml`](src/main/resources/openapi/control-api.yaml), +packaged inside the JAR. Summary: + +| Endpoint | Status | Purpose | +|---|---|---| +| `POST /start?config={name}` | **required** | start the backend, seed flags to that config's baseline | +| `POST /stop` | **required** | make the backend unreachable | +| `POST /restart?seconds={n}` | **required** | bounded outage, flag state preserved | +| `POST /change` | **required** | change `changing-flag`'s resolved value | +| `POST /reset` | optional | restore baseline without an outage; falls back to `/start` | +| `GET /healthz` | optional | readiness; falls back to a TCP port check | + +Two normative requirements are worth repeating here because getting them wrong is subtle: + +> **Never stop or restart a container to simulate an outage.** Testcontainers cannot reliably +> preserve dynamically mapped host ports across a container restart, so a restart silently +> invalidates every provider already pointed at the old port — in some language bindings, and not +> in others, which makes it a portability trap rather than a bug you would catch locally. Simulate +> outages *inside* the running stack: kill the backend process, add a proxy toxic, block the +> socket. The [flagd testbed](https://github.com/open-feature/flagd-testbed) kills and restarts the +> flagd process inside a container that keeps running — that is the reference behaviour. + +> **`/start` resets flag state; `/restart` preserves it.** An outage must be observable as a change +> in availability, never as a change in flag values. The TCK relies on this split for scenario +> isolation. + +### 3. The canonical flag set + +Seed your backend with the flags in [`flags/canonical-flags.json`](src/main/resources/flags/canonical-flags.json). +It is expressed in the flagd flag-definition format because that is the only widely implemented +vendor-neutral format today — the format is not what matters, the keys, types, variants and +resolved values are. Seed them however your backend seeds flags. + +Two details are load-bearing: + +- **`missing-flag` must not exist.** Its absence is what the `FLAG_NOT_FOUND` scenario tests. +- **No flag has targeting rules.** Every scenario expects reason `STATIC`. + +### 4. The test class + +```java +public class MyProviderTckTest extends AbstractProviderTckTest { + + @Override + public File composeFile() { + return new File("src/test/resources/tck/docker-compose.yaml"); + } + + @Override + public List backendPorts() { + return Collections.singletonList(5000); + } + + @Override + public FeatureProvider createProvider(BackendEndpoint endpoint) { + return new MyProvider(endpoint.host(), endpoint.port(5000)); + } + + @Override + public FeatureProvider createUnavailableProvider() { + return new MyProvider("localhost", 9999); + } +} +``` + +Plus one line at +`src/test/resources/META-INF/services/dev.openfeature.contrib.tools.providertck.ProviderTckHarness`: + +``` +com.example.MyProviderTckTest +``` + +That is the whole adoption. The Compose lifecycle, port discovery, control API calls, provider +registration, event awaiting and teardown all belong to the TCK. **If you find yourself adding +test infrastructure to this class, that is a bug in the TCK — please open an issue rather than +working around it.** + +`createUnavailableProvider()` should point at a closed port on localhost, not at your stack — the +stack must stay up, and simulated outages belong to the control API. Give it a short connection +deadline; the scenario allows a bounded time for the error event and a 30-second connect timeout +will not make it. + +#### Several provider modes + +Providers with more than one transport (remote evaluation vs. in-process, say) register one harness +class per mode and select between them with a system property, typically one Surefire execution +each: + +``` +-Dopenfeature.tck.harness=MyProviderRpcTckTest +``` + +With a single registered harness the property is not needed. + +## Declaring capabilities + +Not every provider implements every optional part of the spec. Scenarios that exercise an optional +capability carry a tag; declare which ones you support and the rest are reported as **skipped**, +with the reason printed. They are never silently passed — a conformance suite that quietly goes +green on scenarios it did not run is worse than no suite at all. + +| Capability | Tag | Meaning | +|---|---|---| +| `EVENTS` | `@events` | emits lifecycle events at all | +| `STALE` | `@stale` | enters `STALE` and emits `PROVIDER_STALE` on backend loss | +| `CONFIGURATION_CHANGE` | `@configuration-change` | detects config changes, emits `PROVIDER_CONFIGURATION_CHANGED` | +| `OBJECT` | `@object` | supports structured flag values | +| `UNAVAILABLE_INIT` | `@unavailable` | reports an error state instead of hanging on a dead backend | +| `STRICT_NUMERIC_TYPING` | `@strict-numeric-typing` | does not coerce between integer and float | +| `TARGETING` | `@targeting` | reserved, no scenarios yet | +| `CACHING` | `@caching` | reserved, no scenarios yet | + +The default is every capability. **Narrow it, do not widen it**: start from the default, run the +suite, and remove only what your provider genuinely cannot do. + +```java +@Override +public Set capabilities() { + return EnumSet.complementOf(EnumSet.of(Capability.STALE, Capability.CACHING)); +} +``` + +A note on `STRICT_NUMERIC_TYPING`: unlike the others it is not an optional feature. The spec +requires `TYPE_MISMATCH` when the requested type cannot be satisfied, and narrowing `0.5` to `0` +loses information silently — the worst failure mode for a feature flag, because the application +sees a plausible value and no error. It is a capability only so a provider with this defect can +adopt the TCK today and see the gap reported explicitly. Not declaring it is an admission of a +known bug. **The flagd provider currently does not declare it** — see +[`FlagdTckTest`](../../providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/e2e/FlagdTckTest.java). + +## Tuning timeouts + +How fast a provider notices a backend change differs by orders of magnitude between transports: a +streaming provider sees a configuration change in milliseconds, a provider polling every 30 seconds +needs most of a poll interval. Every await timeout is therefore overridable. + +| Method | Default | What it bounds | +|---|---|---| +| `eventTimeout()` | 12s | waiting for a provider event | +| `readyTimeout()` | 30s | waiting for a provider to reach a lifecycle state | +| `startupTimeout()` | 60s | bringing the Compose stack up | +| `settleTime()` | 50ms | pause after a control API call | + +```java +@Override +public Duration eventTimeout() { + return Duration.ofSeconds(45); // we poll every 30s +} +``` + +Set `eventTimeout()` to comfortably exceed your worst-case detection latency, or the suite reports +timeouts that are really just impatience. Scenarios that assert promptness as part of their point +use the explicit `within {int}ms` step, which always wins. + +## Running it + +```bash +mvn test -Dtest=MyProviderTckTest +``` + +Scenarios run **serially** and the suite enforces this, overriding any +`cucumber.execution.parallel.enabled=true` in your module's `junit-platform.properties`. Control API +state is global to the Compose stack, so concurrent scenarios corrupt each other — one scenario's +`/start` restarts the backend underneath another's disconnect assertion. The symptom looks like a +flaky provider rather than a broken test, which is exactly why it is enforced rather than +documented. + +The Compose stack starts once per suite and is never restarted. Scenario isolation comes from the +control API. + +## Relationship to the flagd test harness + +The step vocabulary is inherited from the +[flagd test harness](https://github.com/open-feature/test-harness) wherever it was already +provider-neutral, so flagd's existing feature files port with a near-zero diff and the step +definitions stay familiar. Only genuinely flagd-specific wording was renamed: + +| flagd test harness | Provider TCK | Why | +|---|---|---| +| `Given a stable flagd provider` | `Given a stable provider` | drops the vendor name | +| `Given a unavailable flagd provider` | `Given a unavailable provider` | drops the vendor name | + +Everything else is unchanged: `a -flag with key ... and a default value ...`, +`the flag was evaluated with details`, `the resolved details value should be "..."`, +`the reason should be ...`, `the variant should be ...`, `the error-code should be ...`, +`a event handler`, `the event handler should have been executed[ within ms]`, +`the connection is lost[ for s]`, `the flag was modified`, +`the flag should be part of the event payload`, `the client should be in state`. + +Three steps are new: + +| Step | Why it was added | +|---|---| +| `When the connection is restored` | the flagd harness only has the self-healing `lost for {int}s` form, which cannot express "assert stale, *then* reconnect" — the reconnect races the assertion | +| `When the resolved value is remembered` / `Then the resolved details value should have changed` | the control API only requires that `/change` changes `changing-flag`'s value, not which value it changes to; asserting a delta keeps the scenario vendor-neutral | +| `Then no exception should have been thrown` | makes the "never throws" half of the error contract explicit rather than implicit in a step failure | + +## Where these artifacts should live + +The feature files, the control API spec and the canonical flag set are **not Java artifacts**. They +are language-agnostic definitions of the provider contract that every language's TCK must agree on +byte for byte, and that backend vendors implement in whatever language their testbed is written in. + +They belong in the OpenFeature [spec repository](https://github.com/open-feature/spec), with this +module as their Java delivery vehicle. The three travel together by necessity: a feature file that +evaluates `boolean-flag` is meaningless without the flag definition, and a disconnect scenario is +meaningless without the endpoint that produces the disconnect. + +They live here for now only because the PoC had to start somewhere. Moving them changes nothing for +consumers — the features stay on the classpath and stay inside the JAR. + +## Known gaps + +- **Evaluation context passthrough.** The TCK builds evaluation contexts but cannot assert the + context *reached* the backend intact. That needs an echo operation on the control API — something + like `GET /last-evaluation` returning the request the backend last received. Until then, a + provider that silently drops the context passes. +- **Targeting and bucketing.** Out of scope by design: that is backend evaluation logic. The + `@targeting` tag is reserved for context-passthrough scenarios once the gap above is closed. +- **Caching.** Whether a stale provider keeps serving last-known values during an outage depends on + whether it holds a local copy of the ruleset. The `@caching` tag is reserved; no scenarios yet. +- **Hooks.** Not covered. +- **Flag metadata.** The flagd harness has metadata scenarios; they are not yet ported. +- **Multi-suite JVMs.** `TckRuntime` is static, so one TCK suite may run per JVM fork at a time. +- **Scenario coverage is a representative subset**, covering each architectural mechanism once + rather than exhaustively. + +## Contributing + +See the repository [CONTRIBUTING.md](../../CONTRIBUTING.md). New scenarios should be portable +across providers: if a scenario can only pass against one vendor's backend semantics, it belongs in +that provider's own suite, not here. diff --git a/tools/provider-tck/lombok.config b/tools/provider-tck/lombok.config new file mode 100644 index 0000000000..df71bb6a0f --- /dev/null +++ b/tools/provider-tck/lombok.config @@ -0,0 +1,2 @@ +config.stopBubbling = true +lombok.addLombokGeneratedAnnotation = true diff --git a/tools/provider-tck/pom.xml b/tools/provider-tck/pom.xml new file mode 100644 index 0000000000..8b2ba525e4 --- /dev/null +++ b/tools/provider-tck/pom.xml @@ -0,0 +1,176 @@ + + + 4.0.0 + + dev.openfeature.contrib + parent + [1.0,2.0) + ../../pom.xml + + dev.openfeature.contrib.tools + provider-tck + 0.0.1 + + + ${groupId}.providertck + 3.27.7 + 4.3.0 + 2.22.1 + 2.0.17 + 2.0.4 + 1.3.0 + + + provider-tck + + Language-agnostic conformance test suite (TCK) for OpenFeature providers. + Bundles the canonical Gherkin feature files, Cucumber step definitions and an + abstract JUnit Platform Suite base class that owns the full test lifecycle: + starting the vendor-supplied Docker Compose stack, discovering dynamically + mapped ports, driving the standardised control API and awaiting provider + events. Provider authors implement a single factory interface. + + https://openfeature.dev + + + + aepfli + Simon Schrottner + OpenFeature + https://openfeature.dev/ + + + + + + + + + + + io.cucumber + cucumber-java + + + + + + io.cucumber + cucumber-junit-platform-engine + + + + + io.cucumber + cucumber-picocontainer + compile + + + + + org.junit.platform + junit-platform-suite + compile + + + + + org.opentest4j + opentest4j + ${opentest4j.version} + compile + + + + + org.assertj + assertj-core + ${assertj.version} + compile + + + + + org.awaitility + awaitility + ${awaitility.version} + compile + + + + + org.testcontainers + testcontainers + ${testcontainers.version} + compile + + + + + com.fasterxml.jackson.core + jackson-databind + ${jackson-databind.version} + + + + org.slf4j + slf4j-api + ${slf4j.version} + + + + diff --git a/tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/AbstractProviderTckTest.java b/tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/AbstractProviderTckTest.java new file mode 100644 index 0000000000..4f3304d94d --- /dev/null +++ b/tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/AbstractProviderTckTest.java @@ -0,0 +1,46 @@ +package dev.openfeature.contrib.tools.providertck; + +import io.cucumber.junit.platform.engine.Constants; +import org.junit.platform.suite.api.ConfigurationParameter; +import org.junit.platform.suite.api.IncludeEngines; +import org.junit.platform.suite.api.SelectClasspathResource; +import org.junit.platform.suite.api.Suite; + +/** + * Base JUnit Platform Suite for the OpenFeature Provider TCK. + * + *

Carries all Cucumber runner configuration so that a provider author writes no test + * infrastructure at all. The canonical feature files are packaged inside this JAR and selected from + * the classpath, so consumers need no git submodule of their own. + * + *

To adopt the TCK, extend this class, implement the four abstract methods of + * {@link ProviderTckHarness}, and register the concrete class in + * {@code src/test/resources/META-INF/services/dev.openfeature.contrib.tools.providertck.ProviderTckHarness}. + * + *

Scenarios run serially, and this class enforces that rather than merely + * asking for it. Control API state — which flags are seeded, whether the backend is reachable — is + * global to the Compose stack, so concurrent scenarios corrupt each other: one scenario's + * {@code /start} restarts the backend underneath another's disconnect assertion. The failure looks + * like a flaky provider rather than a broken test, which makes it expensive to diagnose. + * + *

The suite therefore pins {@code cucumber.execution.parallel.enabled=false} here, where it + * overrides any {@code junit-platform.properties} the consuming module happens to ship. Several + * providers already enable Cucumber parallelism for their own suites, and inheriting that setting + * silently breaks the TCK. + * + *

Note this class carries no lifecycle code. The Compose stack, the control API client, provider + * registration and event awaiting are all owned by the step definitions in + * {@code dev.openfeature.contrib.tools.providertck.steps}, which reach the harness through + * {@link TckRuntime}. + * + * @see ProviderTckHarness + */ +@Suite +@IncludeEngines("cucumber") +@SelectClasspathResource("features") +@ConfigurationParameter(key = Constants.PLUGIN_PROPERTY_NAME, value = "summary") +@ConfigurationParameter(key = Constants.PARALLEL_EXECUTION_ENABLED_PROPERTY_NAME, value = "false") +@ConfigurationParameter(key = Constants.EXECUTION_MODE_FEATURE_PROPERTY_NAME, value = "same_thread") +@ConfigurationParameter(key = Constants.GLUE_PROPERTY_NAME, value = "dev.openfeature.contrib.tools.providertck.steps") +@ConfigurationParameter(key = Constants.OBJECT_FACTORY_PROPERTY_NAME, value = "io.cucumber.picocontainer.PicoFactory") +public abstract class AbstractProviderTckTest implements ProviderTckHarness {} diff --git a/tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/BackendEndpoint.java b/tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/BackendEndpoint.java new file mode 100644 index 0000000000..40f7799cf9 --- /dev/null +++ b/tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/BackendEndpoint.java @@ -0,0 +1,77 @@ +package dev.openfeature.contrib.tools.providertck; + +import org.testcontainers.containers.ComposeContainer; + +/** + * Addresses of the running backend stack, handed to + * {@link ProviderTckHarness#createProvider(BackendEndpoint)}. + * + *

This type exists because external ports are only known after the Compose stack has + * started. Compose stacks under test must not pin host ports — Docker assigns them dynamically, so + * a provider cannot be configured until the stack is up. That is the whole reason the harness + * exposes a factory method rather than a pre-built provider instance. + * + *

The port mapping is stable for the lifetime of the suite: the stack is started once and never + * restarted, so a provider built from this endpoint stays valid across every scenario. See the + * no-container-restart invariant in {@code openapi/control-api.yaml}. + */ +public final class BackendEndpoint { + + private final ComposeContainer compose; + private final String defaultService; + + BackendEndpoint(ComposeContainer compose, String defaultService) { + this.compose = compose; + this.defaultService = defaultService; + } + + /** + * Returns the host the stack is reachable on. + * + *

This is not necessarily {@code localhost}: with a remote Docker daemon, Docker Desktop on + * some platforms, or a rootless setup, it can be an arbitrary address. Always use this value + * rather than hard-coding a host. + * + * @return the Docker host serving the backend stack + */ + public String host() { + return compose.getServiceHost(defaultService, null); + } + + /** + * Returns the host the named service is reachable on. + * + * @param service the Compose service name + * @return the Docker host serving that service + */ + public String host(String service) { + return compose.getServiceHost(service, null); + } + + /** + * Resolves the dynamically mapped host port for a container-internal port on the default + * backend service. + * + * @param internalPort the container-internal port, as declared by + * {@link ProviderTckHarness#backendPorts()} + * @return the host port the service is reachable on + */ + public int port(int internalPort) { + return port(defaultService, internalPort); + } + + /** + * Resolves the dynamically mapped host port for a container-internal port on a named service. + * + *

Use this for multi-service stacks — a proxy, an edge service, a sidecar. The service and + * port must have been declared via {@link ProviderTckHarness#additionalExposedPorts()}, + * otherwise Testcontainers has not exposed it and this call fails. + * + * @param service the Compose service name + * @param internalPort the container-internal port + * @return the host port the service is reachable on + */ + public int port(String service, int internalPort) { + return compose.getServicePort(service, internalPort); + } +} diff --git a/tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/Capability.java b/tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/Capability.java new file mode 100644 index 0000000000..8203db0d84 --- /dev/null +++ b/tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/Capability.java @@ -0,0 +1,95 @@ +package dev.openfeature.contrib.tools.providertck; + +import java.util.Arrays; +import java.util.Optional; + +/** + * An optional part of the OpenFeature provider contract that a provider may or may not support. + * + *

Not every provider implements every spec feature — a provider backed by a static file has no + * meaningful notion of going stale, and a provider without a streaming transport cannot emit + * configuration-change events. Rather than forcing such providers to fail scenarios they were never + * going to satisfy, the TCK lets each one declare what it supports via + * {@link ProviderTckHarness#capabilities()}. + * + *

Every capability corresponds to exactly one Gherkin tag. Scenarios carrying a tag whose + * capability was not declared are aborted before they run and are reported as skipped — + * never as passed. Silently green scenarios would make a conformance suite worthless. + * + *

Scenarios with no capability tag are considered mandatory and always run. + */ +public enum Capability { + + /** Provider emits lifecycle events at all ({@code PROVIDER_READY}, {@code PROVIDER_ERROR}). */ + EVENTS("@events"), + + /** Provider enters {@code STALE} and emits {@code PROVIDER_STALE} when the backend is lost. */ + STALE("@stale"), + + /** Provider detects flag configuration changes and emits {@code PROVIDER_CONFIGURATION_CHANGED}. */ + CONFIGURATION_CHANGE("@configuration-change"), + + /** Provider supports structured (object) flag values. */ + OBJECT("@object"), + + /** Provider reports an error state rather than hanging when initialised against a dead backend. */ + UNAVAILABLE_INIT("@unavailable"), + + /** + * Provider keeps the integer and float types distinct instead of coercing between them. + * + *

Unlike the other entries here this is not an optional spec feature. The + * specification requires a provider to report {@code TYPE_MISMATCH} when the requested type + * cannot be satisfied, and narrowing {@code 0.5} to {@code 0} to satisfy an integer request + * loses information silently — the worst possible failure mode for a feature flag, because the + * application sees a plausible value and no error. + * + *

It is a capability only so that a provider with this defect can adopt the TCK today and + * see the gap reported as an explicit skip, rather than being unable to adopt at all. Not + * declaring it is an admission of a known bug, not a design choice. Declare it as soon as the + * provider is fixed. + */ + STRICT_NUMERIC_TYPING("@strict-numeric-typing"), + + /** + * Provider supports targeting rules driven by evaluation context. + * + *

Reserved. No scenario in the current suite carries this tag — targeting is backend + * evaluation logic, which the TCK deliberately does not test. It exists so the tag vocabulary + * stays aligned with the flagd test harness and so context-passthrough scenarios have a home + * once the control API grows an echo endpoint. + */ + TARGETING("@targeting"), + + /** + * Provider caches evaluation results and invalidates them on configuration change. + * + *

Reserved; no scenario carries this tag yet. + */ + CACHING("@caching"); + + private final String tag; + + Capability(String tag) { + this.tag = tag; + } + + /** + * Returns the Gherkin tag, including the leading {@code @}, that gates this capability. + * + * @return the Gherkin tag for this capability + */ + public String tag() { + return tag; + } + + /** + * Looks up the capability gated by a Gherkin tag. + * + * @param tag a Gherkin tag including the leading {@code @} + * @return the matching capability, or empty if the tag does not gate a capability + */ + public static Optional fromTag(String tag) { + return Arrays.stream(values()).filter(c -> c.tag.equals(tag)).findFirst(); + } +} diff --git a/tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/ControlApiClient.java b/tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/ControlApiClient.java new file mode 100644 index 0000000000..6c0a19e57d --- /dev/null +++ b/tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/ControlApiClient.java @@ -0,0 +1,228 @@ +package dev.openfeature.contrib.tools.providertck; + +import java.io.IOException; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.time.Duration; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Client for the standardised backend control API. + * + *

Implements the contract in {@code openapi/control-api.yaml}, including the documented fallback + * for the optional {@code /reset} operation. Uses the JDK HTTP client so that adopting the TCK does + * not drag an HTTP library onto a provider's test classpath. + * + *

Every operation here manipulates the backend process or its flag state. None of them + * touch containers — that is the no-container-restart invariant, and it is the reason a provider + * built once at suite start stays valid for every scenario. + */ +public final class ControlApiClient { + + private static final Logger log = LoggerFactory.getLogger(ControlApiClient.class); + + private final HttpClient http; + private final String baseUrl; + private final Duration settleTime; + + /** + * Tri-state cache of whether the backend implements the optional {@code /reset} operation. + * {@code null} until the first {@link #reset(String)} call probes it. + */ + private Boolean resetSupported; + + /** + * Whether the backend was last known to be unreachable. Conservative: {@code restart} sets it + * even though the backend comes back on its own, because a scenario may end before it does. + */ + private boolean backendStopped; + + ControlApiClient(String baseUrl, Duration settleTime) { + this.baseUrl = baseUrl; + this.settleTime = settleTime; + this.http = + HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(5)).build(); + } + + /** + * Returns the base URL of the control API, for diagnostics. + * + * @return the control API base URL + */ + public String baseUrl() { + return baseUrl; + } + + /** + * Starts the backend with a named configuration, seeding flag state to that configuration's + * baseline. + * + * @param config the configuration name + */ + public void start(String config) { + post("/start?config=" + config); + backendStopped = false; + } + + /** + * Makes the backend unreachable without stopping its container. + */ + public void stop() { + post("/stop"); + backendStopped = true; + } + + /** + * Makes the backend unreachable for a bounded duration, then starts it again. + * + *

Flag state is preserved across the outage, so a provider observes an availability change + * and not a configuration change. + * + * @param seconds how long the backend stays unreachable + */ + public void restart(int seconds) { + post("/restart?seconds=" + seconds); + backendStopped = true; + } + + /** + * Puts the backend into the state every scenario starts from: running, with flag state at the + * baseline of the default configuration. + * + *

Prefers {@link #reset(String)} when the backend is already running, because restoring the + * baseline without an availability blip means the previous scenario's teardown cannot leak a + * spurious lifecycle event into the next scenario. When the previous scenario left the backend + * unreachable, {@code /reset} alone would not bring it back, so this falls through to + * {@link #start(String)}. + * + * @param defaultConfig the configuration name defining the baseline + */ + public void prepareScenario(String defaultConfig) { + if (backendStopped) { + start(defaultConfig); + } else { + reset(defaultConfig); + } + } + + /** + * Mutates flag configuration so that a conforming provider observes a configuration change and + * resolves a different value for {@code changing-flag} afterwards. + */ + public void change() { + post("/change"); + } + + /** + * Restores flag state to the seeded baseline for scenario isolation. + * + *

Prefers the optional {@code POST /reset}, which causes no availability blip. When the + * backend answers {@code 404} or {@code 501} the result is cached and every subsequent call + * falls back to {@code POST /start?config=...}, which resets state at the cost of a process + * restart. Both paths are conformant; see {@code openapi/control-api.yaml}. + * + * @param defaultConfig the configuration name to fall back to + */ + public void reset(String defaultConfig) { + if (Boolean.FALSE.equals(resetSupported)) { + start(defaultConfig); + return; + } + HttpResponse response = send("/reset"); + if (response.statusCode() == 404 || response.statusCode() == 501) { + if (resetSupported == null) { + log.info( + "Control API at {} does not implement POST /reset (HTTP {}); " + + "falling back to POST /start?config={} for scenario isolation.", + baseUrl, + response.statusCode(), + defaultConfig); + } + resetSupported = false; + start(defaultConfig); + return; + } + expectSuccess("/reset", response); + resetSupported = true; + settle(); + } + + /** + * Waits until the control API accepts commands. + * + *

Probes the optional {@code GET /healthz}. A {@code 404} is a conformant answer meaning "not + * implemented", in which case readiness has already been established by the Testcontainers + * listening-port wait strategy and this returns immediately. + * + * @param timeout how long to keep probing + */ + public void awaitReady(Duration timeout) { + long deadline = System.nanoTime() + timeout.toNanos(); + RuntimeException last = null; + while (System.nanoTime() < deadline) { + try { + HttpResponse response = http.send( + HttpRequest.newBuilder(URI.create(baseUrl + "/healthz")) + .GET() + .timeout(Duration.ofSeconds(5)) + .build(), + HttpResponse.BodyHandlers.discarding()); + if (response.statusCode() == 200 || response.statusCode() == 404) { + return; + } + last = new IllegalStateException("control API not ready, HTTP " + response.statusCode()); + } catch (IOException e) { + last = new IllegalStateException("control API not reachable at " + baseUrl, e); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IllegalStateException("interrupted while waiting for the control API", e); + } + sleep(Duration.ofMillis(200)); + } + throw new IllegalStateException("control API at " + baseUrl + " did not become ready within " + timeout, last); + } + + private void post(String path) { + expectSuccess(path, send(path)); + settle(); + } + + private HttpResponse send(String path) { + HttpRequest request = HttpRequest.newBuilder(URI.create(baseUrl + path)) + .POST(HttpRequest.BodyPublishers.noBody()) + .timeout(Duration.ofSeconds(30)) + .build(); + try { + return http.send(request, HttpResponse.BodyHandlers.discarding()); + } catch (IOException e) { + throw new IllegalStateException("control API call POST " + baseUrl + path + " failed", e); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IllegalStateException("interrupted during control API call POST " + path, e); + } + } + + private void expectSuccess(String path, HttpResponse response) { + if (response.statusCode() != 200) { + throw new IllegalStateException( + "control API call POST " + baseUrl + path + " returned HTTP " + response.statusCode() + + ", expected 200. See openapi/control-api.yaml for the expected contract."); + } + } + + private void settle() { + sleep(settleTime); + } + + private static void sleep(Duration duration) { + try { + Thread.sleep(duration.toMillis()); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IllegalStateException("interrupted while waiting for the backend to settle", e); + } + } +} diff --git a/tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/FlagUnderTest.java b/tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/FlagUnderTest.java new file mode 100644 index 0000000000..b339dc2827 --- /dev/null +++ b/tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/FlagUnderTest.java @@ -0,0 +1,57 @@ +package dev.openfeature.contrib.tools.providertck; + +/** + * The flag a scenario is currently exercising: its key, its declared type, and the code default + * passed to the evaluation call. + * + *

The declared type is what makes the integer/float distinction testable. The TCK dispatches to + * {@code getIntegerDetails} or {@code getDoubleDetails} purely on this value, so a provider that + * silently widens an integer to a double is caught rather than accommodated. + */ +public final class FlagUnderTest { + + private final String key; + private final String type; + private final Object defaultValue; + + /** + * Creates a flag under test. + * + * @param key the flag key + * @param type the declared type, one of {@code Boolean}, {@code String}, {@code Integer}, + * {@code Float} or {@code Object} + * @param defaultValue the code default passed to the evaluation call + */ + public FlagUnderTest(String key, String type, Object defaultValue) { + this.key = key; + this.type = type; + this.defaultValue = defaultValue; + } + + /** + * Returns the flag key. + * + * @return the flag key + */ + public String key() { + return key; + } + + /** + * Returns the declared flag type. + * + * @return the declared flag type + */ + public String type() { + return type; + } + + /** + * Returns the code default passed to the evaluation call. + * + * @return the code default + */ + public Object defaultValue() { + return defaultValue; + } +} diff --git a/tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/ProviderEventRecord.java b/tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/ProviderEventRecord.java new file mode 100644 index 0000000000..20b0c6957f --- /dev/null +++ b/tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/ProviderEventRecord.java @@ -0,0 +1,47 @@ +package dev.openfeature.contrib.tools.providertck; + +import dev.openfeature.sdk.EventDetails; +import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; + +/** + * A provider event observed by a scenario's event handler, tagged with the Gherkin word that + * registered the handler ({@code ready}, {@code error}, {@code stale}, {@code change}). + */ +@SuppressFBWarnings( + value = "EI_EXPOSE_REP", + justification = "The SDK's event payload is held and handed on as-is; copying it would " + + "hide exactly the object under assertion") +public final class ProviderEventRecord { + + private final String type; + private final EventDetails details; + + /** + * Records an observed event. + * + * @param type the Gherkin event word the handler was registered under + * @param details the event payload delivered by the SDK + */ + public ProviderEventRecord(String type, EventDetails details) { + this.type = type; + this.details = details; + } + + /** + * Returns the Gherkin event word. + * + * @return the event type word + */ + public String type() { + return type; + } + + /** + * Returns the event payload delivered by the SDK. + * + * @return the event details + */ + public EventDetails details() { + return details; + } +} diff --git a/tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/ProviderTckHarness.java b/tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/ProviderTckHarness.java new file mode 100644 index 0000000000..1cc671faf5 --- /dev/null +++ b/tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/ProviderTckHarness.java @@ -0,0 +1,217 @@ +package dev.openfeature.contrib.tools.providertck; + +import dev.openfeature.sdk.FeatureProvider; +import java.io.File; +import java.time.Duration; +import java.util.Collections; +import java.util.EnumSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * The complete contract a provider author implements to run the OpenFeature Provider TCK. + * + *

Four methods have no default and must be supplied. Everything else is a convention with a + * working default. If you find yourself needing to add lifecycle code, container handling or event + * plumbing to your implementation, that is a bug in the TCK's base class rather than something to + * work around here. + * + *

Implementations are discovered through {@link java.util.ServiceLoader}. Extend + * {@link AbstractProviderTckTest} — which implements this interface and carries all the Cucumber + * configuration — and register the concrete class in + * {@code META-INF/services/dev.openfeature.contrib.tools.providertck.ProviderTckHarness}. + * + *

Example — the entire adoption for a provider: + * + *

{@code
+ * public class MyProviderTckTest extends AbstractProviderTckTest {
+ *
+ *     @Override
+ *     public File composeFile() {
+ *         return new File("src/test/resources/tck/docker-compose.yaml");
+ *     }
+ *
+ *     @Override
+ *     public List backendPorts() {
+ *         return Collections.singletonList(8013);
+ *     }
+ *
+ *     @Override
+ *     public FeatureProvider createProvider(BackendEndpoint endpoint) {
+ *         return new MyProvider(endpoint.host(), endpoint.port(8013));
+ *     }
+ *
+ *     @Override
+ *     public FeatureProvider createUnavailableProvider() {
+ *         return new MyProvider("localhost", 9999);
+ *     }
+ * }
+ * }
+ */ +public interface ProviderTckHarness { + + /** + * Returns the Docker Compose file describing the backend stack under test. + * + *

The path is resolved relative to the Maven module directory, so + * {@code new File("src/test/resources/tck/docker-compose.yaml")} is the idiomatic form. + * + *

The stack is started once before the first scenario and stopped after the last one. It is + * never restarted in between — see {@link #createProvider(BackendEndpoint)} and the + * no-container-restart invariant documented in {@code openapi/control-api.yaml}. The stack must + * not pin host ports. + * + * @return the Compose file describing the backend stack + */ + File composeFile(); + + /** + * Returns the container-internal ports on {@link #backendService()} that the provider connects + * to, so Testcontainers can expose and map them. + * + *

The control API port from {@link #controlPort()} is exposed automatically and does not + * need to be listed here. + * + * @return container-internal ports the provider connects to + */ + List backendPorts(); + + /** + * Creates the provider under test, configured against the running backend. + * + *

Called after the Compose stack is up and the control API has seeded the canonical flag + * set. The endpoint carries the dynamically mapped host ports, which is why this is a factory + * rather than a field: the ports do not exist until the stack has started. + * + *

The TCK owns the provider lifecycle from here — it registers the provider with the + * OpenFeature API under a scenario-scoped domain, waits for it to become ready, and shuts it + * down afterwards. Do not call {@code setProvider} or {@code initialize} yourself. + * + * @param endpoint host and mapped ports of the running backend stack + * @return a configured, uninitialised provider + */ + FeatureProvider createProvider(BackendEndpoint endpoint); + + /** + * Creates a provider pointed at a backend that does not exist. + * + *

Used by the initialisation-failure scenarios, which assert that a provider that cannot + * reach its backend settles into {@code ERROR} and emits {@code PROVIDER_ERROR} rather than + * hanging or throwing out of {@code setProvider}. + * + *

Point this at a closed port on localhost. Do not point it at the Compose stack — the stack + * must stay up and reachable, and simulated outages belong to the control API. + * + *

Configure a short connection deadline. The scenario allows a bounded time for the error + * event to arrive, and a provider with a 30-second connect timeout will not make it. + * + * @return a configured provider that cannot reach a backend + */ + FeatureProvider createUnavailableProvider(); + + /** + * Declares which optional parts of the provider contract this provider supports. + * + *

Scenarios tagged with a capability that is not in this set are reported as + * skipped. They are never silently passed. + * + *

Defaults to every capability. Narrow it rather than widening it: start from the default, + * run the suite, and remove only what your provider genuinely cannot do. + * + * @return the capabilities this provider supports + */ + default Set capabilities() { + return EnumSet.allOf(Capability.class); + } + + /** + * Returns the Compose service name that hosts the control API and the backend the provider + * connects to. + * + * @return the Compose service name, {@code backend} by default + */ + default String backendService() { + return "backend"; + } + + /** + * Returns the container-internal port the control API listens on. + * + * @return the control API port, {@code 8080} by default + */ + default int controlPort() { + return 8080; + } + + /** + * Returns extra services and container-internal ports to expose, for stacks that contain more + * than the backend service. + * + *

Keys are Compose service names, values are container-internal ports. Resolve the mapped + * ports with {@link BackendEndpoint#port(String, int)}. + * + * @return additional services and ports to expose, empty by default + */ + default Map> additionalExposedPorts() { + return Collections.emptyMap(); + } + + /** + * Returns the control API configuration name used to seed the canonical flag set. + * + * @return the configuration name passed to {@code POST /start}, {@code default} by default + */ + default String defaultConfig() { + return "default"; + } + + /** + * Returns how long to wait for the Compose stack to become reachable. + * + * @return the stack startup timeout, 60 seconds by default + */ + default Duration startupTimeout() { + return Duration.ofSeconds(60); + } + + /** + * Returns how long to wait for a provider event to arrive. + * + *

This is the single most important knob for a provider author, because providers observe + * backend changes on wildly different timescales. A streaming provider sees a configuration + * change in milliseconds; a provider that polls every 30 seconds may need most of a poll + * interval before it notices. Set this to comfortably exceed your worst-case detection latency, + * or the suite will report timeouts that are really just impatience. + * + *

Individual scenarios can tighten this with the explicit + * {@code within {int}ms} step, which always wins over this value. + * + * @return the default event await timeout, 12 seconds by default + */ + default Duration eventTimeout() { + return Duration.ofSeconds(12); + } + + /** + * Returns how long to wait for a provider to reach {@code READY} during initialisation. + * + * @return the readiness timeout, 30 seconds by default + */ + default Duration readyTimeout() { + return Duration.ofSeconds(30); + } + + /** + * Returns how long to pause after a control API call before continuing. + * + *

Covers the gap between the control API acknowledging a command and the backend actually + * having acted on it. Raise it if you see flakiness immediately after + * {@code the flag was modified} or a provider setup step. + * + * @return the settle time, 50 milliseconds by default + */ + default Duration settleTime() { + return Duration.ofMillis(50); + } +} diff --git a/tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/TckRuntime.java b/tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/TckRuntime.java new file mode 100644 index 0000000000..12eb84e66b --- /dev/null +++ b/tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/TckRuntime.java @@ -0,0 +1,175 @@ +package dev.openfeature.contrib.tools.providertck; + +import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; +import java.io.File; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.ServiceLoader; +import java.util.stream.Collectors; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.testcontainers.containers.ComposeContainer; +import org.testcontainers.containers.wait.strategy.Wait; + +/** + * Suite-scoped runtime: discovers the provider's harness, owns the Compose stack, and exposes the + * control API client to the step definitions. + * + *

The Compose stack is started once, before the first scenario, and stopped + * after the last one. It is never stopped or restarted in between. Testcontainers cannot reliably + * preserve dynamically mapped host ports across a container restart, so a restart would silently + * invalidate every provider already pointed at the old port. Backend unavailability is therefore + * always simulated inside the running stack through the control API. See the normative statement of + * this invariant in {@code openapi/control-api.yaml}. + * + *

State is static because Cucumber's {@code @BeforeAll} / {@code @AfterAll} hooks are static and + * the stack must outlive individual scenarios. Consequently only one TCK suite may run per JVM fork + * at a time. + */ +@SuppressFBWarnings( + value = "EI_EXPOSE_REP", + justification = "The harness and control API client are shared collaborators by design; " + + "step definitions must act on the same instances the suite started") +public final class TckRuntime { + + private static final Logger log = LoggerFactory.getLogger(TckRuntime.class); + + /** System property selecting a harness by simple class name when several are registered. */ + public static final String HARNESS_SELECTOR_PROPERTY = "openfeature.tck.harness"; + + private static TckRuntime instance; + + private final ProviderTckHarness harness; + private final ComposeContainer compose; + private final ControlApiClient controlApi; + private final BackendEndpoint endpoint; + + private TckRuntime(ProviderTckHarness harness, ComposeContainer compose) { + this.harness = harness; + this.compose = compose; + this.endpoint = new BackendEndpoint(compose, harness.backendService()); + String baseUrl = "http://" + compose.getServiceHost(harness.backendService(), null) + ":" + + compose.getServicePort(harness.backendService(), harness.controlPort()); + this.controlApi = new ControlApiClient(baseUrl, harness.settleTime()); + } + + /** + * Starts the Compose stack if it is not already running, and returns the shared runtime. + * + * @return the suite-scoped runtime + */ + public static synchronized TckRuntime startIfNeeded() { + if (instance == null) { + ProviderTckHarness harness = discoverHarness(); + log.info("Provider TCK harness: {}", harness.getClass().getName()); + instance = new TckRuntime(harness, startCompose(harness)); + instance.controlApi.awaitReady(harness.startupTimeout()); + log.info("Control API ready at {}", instance.controlApi.baseUrl()); + } + return instance; + } + + /** + * Stops the Compose stack and releases the shared runtime. + */ + public static synchronized void stop() { + if (instance != null) { + instance.compose.stop(); + instance = null; + } + } + + /** + * Returns the running runtime. + * + * @return the suite-scoped runtime + * @throws IllegalStateException if the stack has not been started + */ + public static synchronized TckRuntime get() { + if (instance == null) { + throw new IllegalStateException("TCK runtime has not been started"); + } + return instance; + } + + /** + * Returns the provider author's harness. + * + * @return the discovered harness + */ + public ProviderTckHarness harness() { + return harness; + } + + /** + * Returns the client for the backend's control API. + * + * @return the control API client + */ + public ControlApiClient controlApi() { + return controlApi; + } + + /** + * Returns the host and mapped ports of the running stack. + * + * @return the backend endpoint + */ + public BackendEndpoint endpoint() { + return endpoint; + } + + private static ComposeContainer startCompose(ProviderTckHarness harness) { + File composeFile = harness.composeFile(); + if (!composeFile.isFile()) { + throw new IllegalStateException("Compose file not found: " + composeFile.getAbsolutePath() + + ". ProviderTckHarness.composeFile() is resolved relative to the module directory."); + } + ComposeContainer compose = new ComposeContainer(composeFile); + + compose.withExposedService(harness.backendService(), harness.controlPort(), Wait.forListeningPort()); + for (Integer port : harness.backendPorts()) { + compose.withExposedService(harness.backendService(), port, Wait.forListeningPort()); + } + for (Map.Entry> service : + harness.additionalExposedPorts().entrySet()) { + for (Integer port : service.getValue()) { + compose.withExposedService(service.getKey(), port, Wait.forListeningPort()); + } + } + compose.withStartupTimeout(harness.startupTimeout()); + + log.info("Starting Compose stack {} (started once per suite, never restarted)", composeFile.getAbsolutePath()); + compose.start(); + return compose; + } + + private static ProviderTckHarness discoverHarness() { + List found = new ArrayList<>(); + ServiceLoader.load(ProviderTckHarness.class).forEach(found::add); + + if (found.isEmpty()) { + throw new IllegalStateException("No ProviderTckHarness found. Extend AbstractProviderTckTest and register " + + "the concrete class in src/test/resources/META-INF/services/" + + ProviderTckHarness.class.getName()); + } + if (found.size() == 1) { + return found.get(0); + } + + String selector = System.getProperty(HARNESS_SELECTOR_PROPERTY); + if (selector == null) { + throw new IllegalStateException("Several ProviderTckHarness implementations are registered (" + + found.stream().map(h -> h.getClass().getSimpleName()).collect(Collectors.joining(", ")) + + "). Select one with -D" + HARNESS_SELECTOR_PROPERTY + "=, " + + "typically via a separate Surefire execution per provider variant."); + } + return found.stream() + .filter(h -> h.getClass().getSimpleName().equals(selector)) + .findFirst() + .orElseThrow(() -> new IllegalStateException("No registered ProviderTckHarness named '" + selector + + "'. Registered: " + + found.stream().map(h -> h.getClass().getSimpleName()).collect(Collectors.joining(", ")))); + } +} diff --git a/tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/TckState.java b/tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/TckState.java new file mode 100644 index 0000000000..7f911cc1f3 --- /dev/null +++ b/tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/TckState.java @@ -0,0 +1,64 @@ +package dev.openfeature.contrib.tools.providertck; + +import dev.openfeature.sdk.Client; +import dev.openfeature.sdk.FeatureProvider; +import dev.openfeature.sdk.FlagEvaluationDetails; +import dev.openfeature.sdk.MutableContext; +import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; +import java.util.Optional; +import java.util.concurrent.ConcurrentLinkedQueue; + +/** + * Scenario-scoped mutable state, injected into every step definition class by PicoContainer. + * + *

One instance per scenario. Anything that must survive across scenarios — the Compose stack, + * the control API client, the discovered harness — lives in {@link TckRuntime} instead. + */ +@SuppressFBWarnings( + value = "URF_UNREAD_PUBLIC_OR_PROTECTED_FIELD", + justification = "Intentional mutable state sharing required by Cucumber PicoContainer DI") +public class TckState { + + /** Client bound to the domain the provider under test is registered under. */ + public Client client; + + /** The provider under test. */ + public FeatureProvider provider; + + /** Scenario-scoped OpenFeature domain, so scenarios cannot see each other's providers. */ + public String domain; + + /** The flag the current scenario is exercising. */ + public FlagUnderTest flag; + + /** Evaluation context accumulated by the context steps. */ + public MutableContext context = new MutableContext(); + + /** Result of the most recent evaluation. */ + public FlagEvaluationDetails evaluation; + + /** + * A previously resolved value, captured so a later evaluation can be asserted to differ. + * + *

Used by the configuration-change scenario. Asserting "the value changed" rather than "the + * value is now X" keeps the scenario portable: the control API only requires that + * {@code POST /change} changes the resolved value of {@code changing-flag}, not which concrete + * value it changes to. + */ + public Object rememberedValue; + + /** + * Any exception thrown out of the most recent evaluation call. + * + *

The SDK contract is that typed evaluation never throws — errors surface as an error code + * and the code default. The evaluation step records rather than propagates, so a scenario can + * assert this explicitly instead of a thrown exception merely showing up as a step failure. + */ + public RuntimeException evaluationException; + + /** Events observed by handlers registered in this scenario. */ + public final ConcurrentLinkedQueue events = new ConcurrentLinkedQueue<>(); + + /** The event most recently matched by an await step. */ + public Optional lastEvent = Optional.empty(); +} diff --git a/tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/TckValues.java b/tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/TckValues.java new file mode 100644 index 0000000000..7386e977cb --- /dev/null +++ b/tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/TckValues.java @@ -0,0 +1,58 @@ +package dev.openfeature.contrib.tools.providertck; + +import com.fasterxml.jackson.databind.ObjectMapper; +import dev.openfeature.sdk.Value; +import java.io.IOException; + +/** + * Converts the string values written in feature files into the typed Java values the SDK expects. + * + *

Gherkin has no type system — every cell in an Examples table is a string. The declared flag + * type in the step is therefore the only thing that distinguishes an integer flag from a float + * flag, and this class is where that distinction is made real. {@code Integer} produces an + * {@link Integer}; {@code Float} produces a {@link Double}. Nothing widens one into the other. + */ +public final class TckValues { + + private static final ObjectMapper MAPPER = new ObjectMapper(); + + private TckValues() {} + + /** + * Converts a feature-file string to a typed value. + * + * @param value the raw string from the feature file; the literal {@code null} yields + * {@code null} + * @param type the declared type, one of {@code Boolean}, {@code String}, {@code Integer}, + * {@code Float} or {@code Object} + * @return the converted value + */ + public static Object convert(String value, String type) { + if ("null".equals(value)) { + return null; + } + switch (type) { + case "Boolean": + return Boolean.parseBoolean(value); + case "String": + return value; + case "Integer": + return Integer.parseInt(value); + case "Float": + return Double.parseDouble(value); + case "Object": + return toValue(value); + default: + throw new IllegalArgumentException("Unknown flag type '" + type + + "'. Supported types are Boolean, String, Integer, Float and Object."); + } + } + + private static Value toValue(String json) { + try { + return Value.objectToValue(MAPPER.readValue(json, Object.class)); + } catch (IOException e) { + throw new IllegalArgumentException("Could not parse '" + json + "' as an Object flag value", e); + } + } +} diff --git a/tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/steps/AbstractSteps.java b/tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/steps/AbstractSteps.java new file mode 100644 index 0000000000..526068cb4f --- /dev/null +++ b/tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/steps/AbstractSteps.java @@ -0,0 +1,39 @@ +package dev.openfeature.contrib.tools.providertck.steps; + +import dev.openfeature.contrib.tools.providertck.ProviderTckHarness; +import dev.openfeature.contrib.tools.providertck.TckRuntime; +import dev.openfeature.contrib.tools.providertck.TckState; + +/** + * Base for the TCK step definition classes. + * + *

Holds the PicoContainer-injected scenario state and gives subclasses convenience access to the + * suite-scoped runtime. + */ +public abstract class AbstractSteps { + + /** Scenario-scoped state, shared across all step classes in a scenario. */ + protected final TckState state; + + protected AbstractSteps(TckState state) { + this.state = state; + } + + /** + * Returns the suite-scoped runtime that owns the Compose stack and control API. + * + * @return the running TCK runtime + */ + protected TckRuntime runtime() { + return TckRuntime.get(); + } + + /** + * Returns the provider author's harness. + * + * @return the discovered harness + */ + protected ProviderTckHarness harness() { + return TckRuntime.get().harness(); + } +} diff --git a/tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/steps/ContextSteps.java b/tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/steps/ContextSteps.java new file mode 100644 index 0000000000..b706086b2d --- /dev/null +++ b/tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/steps/ContextSteps.java @@ -0,0 +1,71 @@ +package dev.openfeature.contrib.tools.providertck.steps; + +import dev.openfeature.contrib.tools.providertck.TckState; +import dev.openfeature.sdk.MutableStructure; +import io.cucumber.java.en.Given; + +/** + * Steps that build the evaluation context passed to the evaluation call. + * + *

Step vocabulary is inherited verbatim from the flagd test harness. + * + *

Note the TCK cannot currently assert that the context reached the backend intact. + * Doing so needs an echo operation on the control API — something like + * {@code GET /last-evaluation} returning the request the backend last received — which the control + * API does not yet define. Context passthrough is therefore a known gap rather than a covered case. + */ +public class ContextSteps extends AbstractSteps { + + public ContextSteps(TckState state) { + super(state); + } + + /** + * Adds a typed entry to the evaluation context. + * + * @param key the context key + * @param type one of {@code Boolean}, {@code String}, {@code Integer} or {@code Float} + * @param value the value, as written in the feature file + */ + @Given("a context containing a key {string}, with type {string} and with value {string}") + public void contextContainingKeyWithTypeAndValue(String key, String type, String value) { + switch (type) { + case "Boolean": + state.context.add(key, Boolean.parseBoolean(value)); + break; + case "Integer": + state.context.add(key, Integer.parseInt(value)); + break; + case "Float": + state.context.add(key, Double.parseDouble(value)); + break; + case "String": + state.context.add(key, value); + break; + default: + throw new IllegalArgumentException("Unknown context value type '" + type + "'"); + } + } + + /** + * Sets the targeting key on the evaluation context. + * + * @param targetingKey the targeting key + */ + @Given("a context containing a targeting key with value {string}") + public void contextContainingTargetingKey(String targetingKey) { + state.context.setTargetingKey(targetingKey); + } + + /** + * Adds a nested structure entry to the evaluation context. + * + * @param outerKey the outer key + * @param innerKey the key inside the nested structure + * @param value the string value stored under the inner key + */ + @Given("a context containing a nested property with outer key {string} and inner key {string}, with value {string}") + public void contextContainingNestedProperty(String outerKey, String innerKey, String value) { + state.context.add(outerKey, new MutableStructure().add(innerKey, value)); + } +} diff --git a/tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/steps/EventSteps.java b/tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/steps/EventSteps.java new file mode 100644 index 0000000000..6bc28b6c81 --- /dev/null +++ b/tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/steps/EventSteps.java @@ -0,0 +1,119 @@ +package dev.openfeature.contrib.tools.providertck.steps; + +import static java.util.concurrent.TimeUnit.MILLISECONDS; +import static org.awaitility.Awaitility.await; + +import dev.openfeature.contrib.tools.providertck.ProviderEventRecord; +import dev.openfeature.contrib.tools.providertck.ProviderTckHarness; +import dev.openfeature.contrib.tools.providertck.TckState; +import dev.openfeature.sdk.ProviderEvent; +import io.cucumber.java.en.Given; +import io.cucumber.java.en.Then; +import io.cucumber.java.en.When; +import java.util.Optional; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Registration of provider event handlers and awaiting the events they observe. + * + *

Step vocabulary is inherited verbatim from the flagd test harness. The one behavioural change + * is that the default await timeout comes from {@link ProviderTckHarness#eventTimeout()} instead of + * being a hard-coded constant, because how fast a provider notices a backend change differs by + * orders of magnitude between streaming and polling transports. + */ +public class EventSteps extends AbstractSteps { + + private static final Logger log = LoggerFactory.getLogger(EventSteps.class); + + public EventSteps(TckState state) { + super(state); + } + + /** + * Registers a handler for one kind of provider event. + * + * @param eventType one of {@code ready}, {@code error}, {@code stale} or {@code change} + */ + @Given("a {} event handler") + public void registerEventHandler(String eventType) { + state.client.on(mapEventType(eventType), details -> { + log.info("{} event observed", eventType); + state.events.add(new ProviderEventRecord(eventType, details)); + }); + } + + /** + * Awaits an event of the given kind, using the provider's configured timeout. + * + * @param eventType the event kind + */ + @When("a {} event was fired") + public void eventWasFired(String eventType) { + awaitEvent(eventType, harness().eventTimeout().toMillis()); + } + + /** + * Awaits an event of the given kind, using the provider's configured timeout. + * + * @param eventType the event kind + */ + @Then("the {} event handler should have been executed") + public void theEventHandlerShouldHaveBeenExecuted(String eventType) { + awaitEvent(eventType, harness().eventTimeout().toMillis()); + } + + /** + * Awaits an event of the given kind within an explicit deadline. + * + *

Use this where the deadline is part of what the scenario asserts — for instance that a + * provider initialised against a dead backend reports the failure promptly rather than hanging. + * The explicit value always wins over {@link ProviderTckHarness#eventTimeout()}. + * + * @param eventType the event kind + * @param milliseconds the deadline + */ + @Then("the {} event handler should have been executed within {int}ms") + public void theEventHandlerShouldHaveBeenExecutedWithin(String eventType, int milliseconds) { + awaitEvent(eventType, milliseconds); + } + + private void awaitEvent(String eventType, long milliseconds) { + log.info("Awaiting {} event (timeout {}ms)", eventType, milliseconds); + await().alias("provider event " + eventType) + .atMost(milliseconds, MILLISECONDS) + .pollInterval(10, MILLISECONDS) + .until(() -> + state.events.stream().anyMatch(event -> event.type().equals(eventType))); + + // Drain up to and including the first match. Without this, a READY recorded before a + // disconnect would satisfy a later assertion expecting a *new* READY after reconnect, + // and the reconnect scenarios would pass without the provider ever reconnecting. + // Events that arrived after the match are preserved for subsequent steps. + ProviderEventRecord matched = null; + while (!state.events.isEmpty()) { + ProviderEventRecord head = state.events.poll(); + if (head != null && head.type().equals(eventType)) { + matched = head; + break; + } + } + state.lastEvent = Optional.ofNullable(matched); + } + + private static ProviderEvent mapEventType(String eventType) { + switch (eventType) { + case "ready": + return ProviderEvent.PROVIDER_READY; + case "error": + return ProviderEvent.PROVIDER_ERROR; + case "stale": + return ProviderEvent.PROVIDER_STALE; + case "change": + return ProviderEvent.PROVIDER_CONFIGURATION_CHANGED; + default: + throw new IllegalArgumentException( + "Unknown event type '" + eventType + "'. The TCK recognises ready, error, stale and change."); + } + } +} diff --git a/tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/steps/FlagSteps.java b/tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/steps/FlagSteps.java new file mode 100644 index 0000000000..e8df0b0fe5 --- /dev/null +++ b/tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/steps/FlagSteps.java @@ -0,0 +1,255 @@ +package dev.openfeature.contrib.tools.providertck.steps; + +import static org.assertj.core.api.Assertions.assertThat; + +import dev.openfeature.contrib.tools.providertck.FlagUnderTest; +import dev.openfeature.contrib.tools.providertck.ProviderEventRecord; +import dev.openfeature.contrib.tools.providertck.TckState; +import dev.openfeature.contrib.tools.providertck.TckValues; +import dev.openfeature.sdk.ErrorCode; +import dev.openfeature.sdk.Structure; +import dev.openfeature.sdk.Value; +import io.cucumber.datatable.DataTable; +import io.cucumber.java.en.Given; +import io.cucumber.java.en.Then; +import io.cucumber.java.en.When; +import java.util.Map; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Flag evaluation steps and assertions on the resulting resolution details. + * + *

Step vocabulary is inherited verbatim from the flagd test harness, which was already + * provider-neutral here. + */ +public class FlagSteps extends AbstractSteps { + + private static final Logger log = LoggerFactory.getLogger(FlagSteps.class); + + public FlagSteps(TckState state) { + super(state); + } + + /** + * Declares the flag the scenario will evaluate. + * + * @param type the declared type: {@code Boolean}, {@code String}, {@code Integer}, + * {@code Float} or {@code Object} + * @param key the flag key + * @param defaultValue the code default, as written in the feature file + */ + @Given("a {}-flag with key {string} and a default value {string}") + public void flagWithKeyAndDefaultValue(String type, String key, String defaultValue) { + state.flag = new FlagUnderTest(key, type, TckValues.convert(defaultValue, type)); + } + + /** + * Evaluates the declared flag through the typed API matching its declared type. + * + *

Dispatch is on the declared type alone, which is what makes the integer/float distinction + * observable: an {@code Integer} flag goes through {@code getIntegerDetails} and a {@code Float} + * flag through {@code getDoubleDetails}, with no widening in between. A provider that returns a + * double for an integer flag fails here rather than being quietly accommodated. + * + *

Exceptions are recorded rather than propagated. The SDK contract is that typed evaluation + * never throws — errors surface as an error code plus the code default — so + * {@code no exception should have been thrown} can assert that explicitly instead of the + * scenario merely erroring out. + */ + @When("the flag was evaluated with details") + public void theFlagWasEvaluatedWithDetails() { + FlagUnderTest flag = state.flag; + try { + switch (flag.type()) { + case "Boolean": + state.evaluation = + state.client.getBooleanDetails(flag.key(), (Boolean) flag.defaultValue(), state.context); + break; + case "String": + state.evaluation = + state.client.getStringDetails(flag.key(), (String) flag.defaultValue(), state.context); + break; + case "Integer": + state.evaluation = + state.client.getIntegerDetails(flag.key(), (Integer) flag.defaultValue(), state.context); + break; + case "Float": + state.evaluation = + state.client.getDoubleDetails(flag.key(), (Double) flag.defaultValue(), state.context); + break; + case "Object": + state.evaluation = + state.client.getObjectDetails(flag.key(), (Value) flag.defaultValue(), state.context); + break; + default: + throw new IllegalArgumentException("Unknown flag type '" + flag.type() + "'"); + } + } catch (RuntimeException e) { + log.warn("Evaluation of '{}' threw, which violates the SDK contract", flag.key(), e); + state.evaluationException = e; + } + } + + /** + * Asserts the resolved value, converted according to the flag's declared type. + * + * @param value the expected value, as written in the feature file + */ + @Then("the resolved details value should be \"{}\"") + public void theResolvedDetailsValueShouldBe(String value) { + requireEvaluation(); + if (state.evaluation.getErrorCode() != null) { + log.info( + "Evaluation of '{}' carries error code {}: {}", + state.flag.key(), + state.evaluation.getErrorCode(), + state.evaluation.getErrorMessage()); + } + assertThat(state.evaluation.getValue()).isEqualTo(TckValues.convert(value, state.flag.type())); + } + + /** + * Asserts the resolution reason. + * + * @param reason the expected reason + */ + @Then("the reason should be {string}") + public void theReasonShouldBe(String reason) { + requireEvaluation(); + assertThat(state.evaluation.getReason()).isEqualTo(reason); + } + + /** + * Asserts the resolved variant. + * + * @param variant the expected variant + */ + @Then("the variant should be {string}") + public void theVariantShouldBe(String variant) { + requireEvaluation(); + assertThat(state.evaluation.getVariant()).isEqualTo(variant); + } + + /** + * Asserts the error code, where an empty string means no error. + * + * @param errorCode the expected {@link ErrorCode} name, or an empty string + */ + @Then("the error-code should be {string}") + public void theErrorCodeShouldBe(String errorCode) { + requireEvaluation(); + if (errorCode == null || errorCode.isEmpty()) { + assertThat(state.evaluation.getErrorCode()).isNull(); + } else { + assertThat(state.evaluation.getErrorCode()).isEqualTo(ErrorCode.valueOf(errorCode)); + } + } + + /** + * Captures the current resolved value so a later evaluation can be asserted to differ. + * + *

Added by the TCK, for the configuration-change scenario. The control API only requires + * that {@code POST /change} changes the resolved value of {@code changing-flag}; which concrete + * value it changes to is vendor-defined. Asserting a delta rather than an absolute keeps the + * scenario portable and independent of how many times it has run against the same stack. + */ + @When("the resolved value is remembered") + public void theResolvedValueIsRemembered() { + requireEvaluation(); + state.rememberedValue = state.evaluation.getValue(); + } + + /** + * Asserts that re-evaluation produced a different value than the remembered one. + */ + @Then("the resolved details value should have changed") + public void theResolvedDetailsValueShouldHaveChanged() { + requireEvaluation(); + assertThat(state.evaluation.getValue()) + .withFailMessage( + "Expected the value of '%s' to differ after the configuration change, " + + "but it is still %s. The provider signalled the change but did not apply it.", + state.flag.key(), state.rememberedValue) + .isNotEqualTo(state.rememberedValue); + } + + /** + * Asserts that a resolved structure contains the given entries. + * + *

Table columns are {@code key}, {@code type} and {@code value}, mirroring the shape of the + * flagd harness's metadata table. Asserting individual entries rather than a whole JSON blob + * keeps the step readable and avoids quoting a JSON document inside a Gherkin cell. + * + * @param expected a table of expected entries + */ + @Then("the resolved object value should contain") + public void theResolvedObjectValueShouldContain(DataTable expected) { + requireEvaluation(); + assertThat(state.evaluation.getValue()) + .as("resolved value of '%s' is a structure", state.flag.key()) + .isInstanceOf(Value.class); + Structure structure = ((Value) state.evaluation.getValue()).asStructure(); + assertThat(structure) + .as("resolved value of '%s' is a structure", state.flag.key()) + .isNotNull(); + + for (Map row : expected.asMaps()) { + String key = row.get("key"); + Value actual = structure.getValue(key); + assertThat(actual).as("structure entry '%s'", key).isNotNull(); + + Object expectedValue = TckValues.convert(row.get("value"), row.get("type")); + Object actualValue = actual.asObject(); + + // Numbers nested inside a structure are compared by value rather than by Java type. + // Structures arrive as JSON, and JSON has a single number type — whether 100 comes + // back as an Integer or a Double is an artefact of the provider's JSON library, not + // an observable part of the provider contract. The integer/float distinction that + // *is* part of the contract applies to top-level typed evaluation, and is asserted + // by the dedicated scenarios in evaluation.feature and errors.feature. + if (expectedValue instanceof Number && actualValue instanceof Number) { + assertThat(((Number) actualValue).doubleValue()) + .as("structure entry '%s'", key) + .isEqualTo(((Number) expectedValue).doubleValue()); + } else { + assertThat(actualValue).as("structure entry '%s'", key).isEqualTo(expectedValue); + } + } + } + + /** + * Asserts that the evaluation returned normally. + * + *

Added by the TCK. The spec requires typed evaluation to absorb every error into the + * returned details, so an error scenario must prove both halves: the right error code, and no + * exception escaping to the caller. + */ + @Then("no exception should have been thrown") + public void noExceptionShouldHaveBeenThrown() { + assertThat(state.evaluationException) + .withFailMessage( + "Evaluation threw %s, but typed evaluation must never throw — " + + "errors belong in the resolution details.", + state.evaluationException) + .isNull(); + } + + /** + * Asserts the flag under test appears in the payload of the most recently matched event. + */ + @Then("the flag should be part of the event payload") + public void theFlagShouldBePartOfTheEventPayload() { + ProviderEventRecord event = state.lastEvent.orElseThrow( + () -> new AssertionError("No event has been matched yet; await an event before asserting its payload")); + assertThat(event.details().getFlagsChanged()).contains(state.flag.key()); + } + + private void requireEvaluation() { + if (state.evaluation == null) { + throw new AssertionError("No evaluation has been performed. " + + "Did the scenario forget 'When the flag was evaluated with details'?" + + (state.evaluationException == null ? "" : " Evaluation threw: " + state.evaluationException)); + } + } +} diff --git a/tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/steps/ProviderSteps.java b/tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/steps/ProviderSteps.java new file mode 100644 index 0000000000..8fe2394143 --- /dev/null +++ b/tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/steps/ProviderSteps.java @@ -0,0 +1,216 @@ +package dev.openfeature.contrib.tools.providertck.steps; + +import static java.util.concurrent.TimeUnit.MILLISECONDS; +import static org.awaitility.Awaitility.await; + +import dev.openfeature.contrib.tools.providertck.Capability; +import dev.openfeature.contrib.tools.providertck.ProviderTckHarness; +import dev.openfeature.contrib.tools.providertck.TckRuntime; +import dev.openfeature.contrib.tools.providertck.TckState; +import dev.openfeature.sdk.FeatureProvider; +import dev.openfeature.sdk.NoOpProvider; +import dev.openfeature.sdk.OpenFeatureAPI; +import dev.openfeature.sdk.ProviderState; +import io.cucumber.java.After; +import io.cucumber.java.AfterAll; +import io.cucumber.java.Before; +import io.cucumber.java.BeforeAll; +import io.cucumber.java.Scenario; +import io.cucumber.java.en.Given; +import io.cucumber.java.en.Then; +import io.cucumber.java.en.When; +import java.util.Optional; +import java.util.Set; +import java.util.UUID; +import org.opentest4j.TestAbortedException; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Lifecycle and control API steps: bringing the Compose stack up, gating scenarios on declared + * capabilities, creating and registering the provider under test, and simulating backend outages. + * + *

Step vocabulary is inherited from the flagd test harness so that existing feature files port + * with a near-zero diff. The only change is dropping the word {@code flagd} from the provider setup + * step: {@code a stable flagd provider} becomes {@code a stable provider}. + */ +public class ProviderSteps extends AbstractSteps { + + private static final Logger log = LoggerFactory.getLogger(ProviderSteps.class); + + public ProviderSteps(TckState state) { + super(state); + } + + /** + * Starts the Compose stack once, before the first scenario. + */ + @BeforeAll + public static void beforeAll() { + TckRuntime.startIfNeeded(); + } + + /** + * Stops the Compose stack after the last scenario. + */ + @AfterAll + public static void afterAll() { + TckRuntime.stop(); + } + + /** + * Skips scenarios that exercise a capability the provider did not declare. + * + *

Aborting rather than failing means the scenario is reported as skipped by the + * JUnit Platform. That distinction is the whole point: a provider that does not support + * configuration-change events should see those scenarios visibly excluded, never silently + * green. + * + * @param scenario the scenario about to run + */ + @Before(order = 0) + public void gateOnCapabilities(Scenario scenario) { + Set supported = harness().capabilities(); + for (String tag : scenario.getSourceTagNames()) { + Optional capability = Capability.fromTag(tag); + if (capability.isPresent() && !supported.contains(capability.get())) { + throw new TestAbortedException("Skipped: provider does not declare capability " + + capability.get().name() + " (tag " + tag + "). Declared capabilities: " + supported); + } + } + } + + /** + * Restores the backend to a running, freshly seeded state before each scenario. + * + *

Scenario isolation is achieved here, through the control API, and never by restarting + * containers — see the no-container-restart invariant in {@code openapi/control-api.yaml}. + */ + @Before(order = 10) + public void prepareBackend() { + ProviderTckHarness harness = harness(); + runtime().controlApi().prepareScenario(harness.defaultConfig()); + } + + /** + * Tears the provider down without disturbing the Compose stack. + * + *

Replaces the domain's provider with a {@link NoOpProvider} through the SDK lifecycle rather + * than calling {@code shutdown()} directly, because only the former makes the SDK detach the + * event provider and shut down its emitter executor. Skipping this leaks an emitter thread per + * scenario and lets events from a finished scenario surface in the next one. + */ + @After + public void tearDown() { + if (state.domain != null) { + OpenFeatureAPI.getInstance().setProvider(state.domain, new NoOpProvider()); + } + } + + /** + * Creates the provider under test and registers it under a scenario-scoped domain. + * + *

Two provider flavours are recognised. A {@code stable} provider is built by the harness + * against the running stack and registered with {@code setProviderAndWait}, so the step does not + * return until the provider is ready. An {@code unavailable} provider points at a dead backend + * and is registered with {@code setProvider}, deliberately without waiting — the scenario's + * whole point is that readiness never arrives. + * + * @param flavour either {@code stable} or {@code unavailable} + */ + @Given("a {} provider") + public void createProvider(String flavour) { + ProviderTckHarness harness = harness(); + FeatureProvider provider; + boolean waitForReady; + + switch (flavour) { + case "stable": + provider = harness.createProvider(runtime().endpoint()); + waitForReady = true; + break; + case "unavailable": + provider = harness.createUnavailableProvider(); + waitForReady = false; + break; + default: + throw new IllegalArgumentException( + "Unknown provider flavour '" + flavour + "'. The TCK recognises 'stable' and 'unavailable'."); + } + + String domain = "tck-" + UUID.randomUUID(); + OpenFeatureAPI api = OpenFeatureAPI.getInstance(); + if (waitForReady) { + api.setProviderAndWait(domain, provider); + } else { + api.setProvider(domain, provider); + } + + state.provider = provider; + state.domain = domain; + state.client = api.getClient(domain); + log.info( + "Registered {} provider {} under domain {}", + flavour, + provider.getMetadata().getName(), + domain); + } + + /** + * Makes the backend unreachable for the rest of the scenario. + */ + @When("the connection is lost") + public void theConnectionIsLost() { + runtime().controlApi().stop(); + } + + /** + * Makes the backend unreachable for a bounded period, then brings it back. + * + * @param seconds how long the backend stays unreachable + */ + @When("the connection is lost for {int}s") + public void theConnectionIsLostFor(int seconds) { + runtime().controlApi().restart(seconds); + } + + /** + * Brings the backend back after {@code the connection is lost}. + * + *

Added by the TCK. The flagd harness only has the self-healing + * {@code the connection is lost for {int}s} form, which cannot express "assert the provider is + * stale, and only then reconnect" — the reconnect races the assertion. Splitting the outage + * into an explicit start and end makes the stale-then-ready transition deterministic. + */ + @When("the connection is restored") + public void theConnectionIsRestored() { + runtime().controlApi().start(harness().defaultConfig()); + } + + /** + * Mutates flag configuration so a conforming provider observes a configuration change. + */ + @When("the flag was modified") + public void theFlagWasModified() { + runtime().controlApi().change(); + } + + /** + * Asserts the provider settles into the expected lifecycle state. + * + *

Awaits rather than asserting immediately. State transitions are asynchronous in every + * provider, and how quickly one notices a backend change varies by orders of magnitude between + * streaming and polling transports, so the timeout comes from + * {@link ProviderTckHarness#readyTimeout()}. + * + * @param expected the expected {@link ProviderState}, case-insensitive + */ + @Then("the client should be in {} state") + public void theClientShouldBeInState(String expected) { + ProviderState target = ProviderState.valueOf(expected.toUpperCase()); + await().alias("provider state " + target) + .atMost(harness().readyTimeout().toMillis(), MILLISECONDS) + .pollInterval(10, MILLISECONDS) + .until(() -> state.client.getProviderState() == target); + } +} diff --git a/tools/provider-tck/src/main/resources/features/errors.feature b/tools/provider-tck/src/main/resources/features/errors.feature new file mode 100644 index 0000000000..13045d4a28 --- /dev/null +++ b/tools/provider-tck/src/main/resources/features/errors.feature @@ -0,0 +1,42 @@ +Feature: Provider error handling + + # Every scenario here asserts the same three-part contract, because all three parts matter and + # providers routinely get one of them wrong: + # + # 1. the code default is returned — an application must keep working, + # 2. the correct error code is reported — an application must be able to tell what went wrong, + # 3. nothing is thrown — an unhandled exception from a flag evaluation is never acceptable. + # + # Requires the backend to be seeded with the canonical flag set — see flags/canonical-flags.json. + + Background: + Given a stable provider + + Scenario: Requesting the wrong type returns the code default + # 'wrong-flag' is a string flag. Asking for a boolean cannot be satisfied. + Given a Boolean-flag with key "wrong-flag" and a default value "false" + When the flag was evaluated with details + Then the resolved details value should be "false" + And the reason should be "ERROR" + And the error-code should be "TYPE_MISMATCH" + And no exception should have been thrown + + @strict-numeric-typing + Scenario: A float flag is not silently narrowed to an integer + # 'float-flag' resolves to 0.5. Narrowing that to an integer would lose information + # silently, so it must be reported as a type mismatch rather than rounded. + Given a Integer-flag with key "float-flag" and a default value "1" + When the flag was evaluated with details + Then the resolved details value should be "1" + And the reason should be "ERROR" + And the error-code should be "TYPE_MISMATCH" + And no exception should have been thrown + + Scenario: An unknown flag key returns the code default + # 'missing-flag' is deliberately absent from the canonical flag set. + Given a String-flag with key "missing-flag" and a default value "fallback" + When the flag was evaluated with details + Then the resolved details value should be "fallback" + And the reason should be "ERROR" + And the error-code should be "FLAG_NOT_FOUND" + And no exception should have been thrown diff --git a/tools/provider-tck/src/main/resources/features/evaluation.feature b/tools/provider-tck/src/main/resources/features/evaluation.feature new file mode 100644 index 0000000000..e89f174a51 --- /dev/null +++ b/tools/provider-tck/src/main/resources/features/evaluation.feature @@ -0,0 +1,59 @@ +Feature: Provider flag evaluation + + # Verifies that a provider maps backend responses onto typed resolution details correctly. + # + # This does NOT test the backend's evaluation logic. Every flag in the canonical set resolves + # to its default variant with no targeting involved, so what is under test is purely the + # provider's mapping of a backend response to a value, a variant and a reason. + # + # Requires the backend to be seeded with the canonical flag set — see flags/canonical-flags.json. + + Background: + Given a stable provider + + Scenario Outline: Resolve values with variant and reason + Given a -flag with key "" and a default value "" + When the flag was evaluated with details + Then the resolved details value should be "" + And the variant should be "" + And the reason should be "" + And the error-code should be "" + And no exception should have been thrown + + Examples: + | key | type | default | value | variant | reason | + | boolean-flag | Boolean | false | true | on | STATIC | + | string-flag | String | bye | hi | greeting | STATIC | + | integer-flag | Integer | 1 | 10 | ten | STATIC | + | float-flag | Float | 0.1 | 0.5 | half | STATIC | + + Scenario: An integer flag resolves as an integer + # Paired with the float scenario below and with the narrowing scenario in errors.feature. + # Together they pin down that the two numeric types stay distinct rather than both being + # funnelled through one numeric representation. + Given a Integer-flag with key "integer-flag" and a default value "1" + When the flag was evaluated with details + Then the resolved details value should be "10" + And the error-code should be "" + And no exception should have been thrown + + Scenario: A float flag resolves as a float + Given a Float-flag with key "float-flag" and a default value "0.1" + When the flag was evaluated with details + Then the resolved details value should be "0.5" + And the error-code should be "" + And no exception should have been thrown + + @object + Scenario: Resolve a structured value + Given a Object-flag with key "object-flag" and a default value "{}" + When the flag was evaluated with details + Then the variant should be "template" + And the reason should be "STATIC" + And the error-code should be "" + And no exception should have been thrown + And the resolved object value should contain + | key | type | value | + | showImages | Boolean | true | + | title | String | Check out these pics! | + | imagesPerPage | Integer | 100 | diff --git a/tools/provider-tck/src/main/resources/features/events.feature b/tools/provider-tck/src/main/resources/features/events.feature new file mode 100644 index 0000000000..00e7e5ef6f --- /dev/null +++ b/tools/provider-tck/src/main/resources/features/events.feature @@ -0,0 +1,42 @@ +@events +Feature: Provider events + + # Verifies that a provider notices changes in its backend and both signals them and acts on + # them. Signalling alone is not enough: a configuration-change event that is not followed by + # a changed evaluation result is a lie, so each scenario asserts the event AND the behaviour. + # + # Outages here are simulated inside the running stack via the control API. No container is + # ever stopped or restarted — see the invariant in openapi/control-api.yaml. + + Background: + Given a stable provider + + @configuration-change + Scenario: A configuration change is signalled and applied + Given a String-flag with key "changing-flag" and a default value "unset" + And a change event handler + When the flag was evaluated with details + And the resolved value is remembered + And the flag was modified + Then the change event handler should have been executed + And the flag should be part of the event payload + When the flag was evaluated with details + Then the resolved details value should have changed + And no exception should have been thrown + + @stale + Scenario: Losing the backend makes the provider stale, regaining it makes it ready again + Given a ready event handler + And a stale event handler + When a ready event was fired + And the connection is lost + Then the stale event handler should have been executed + And the client should be in stale state + When the connection is restored + Then the ready event handler should have been executed + And the client should be in ready state + + # Deliberately NOT covered here: whether a stale provider keeps serving last-known values + # during the outage. That is caching behaviour, which depends on whether the provider holds a + # local copy of the ruleset, and it belongs behind the @caching capability once those + # scenarios are written. See the "Known gaps" section of the README. diff --git a/tools/provider-tck/src/main/resources/features/lifecycle.feature b/tools/provider-tck/src/main/resources/features/lifecycle.feature new file mode 100644 index 0000000000..2561641060 --- /dev/null +++ b/tools/provider-tck/src/main/resources/features/lifecycle.feature @@ -0,0 +1,33 @@ +@events +Feature: Provider lifecycle + + # Verifies the two terminal outcomes of provider initialisation: reaching READY against a + # healthy backend, and settling into ERROR against one that cannot be reached. + # + # The failure case matters more than it looks. A provider that blocks forever, or throws out + # of provider registration, takes the host application down with it — so the requirement is + # not merely that initialisation fails, but that it fails observably and promptly. + + Scenario: A provider reaching its backend becomes ready + Given a stable provider + And a ready event handler + Then the ready event handler should have been executed + And the client should be in ready state + + @unavailable + Scenario: A provider that cannot reach its backend reports an error + Given a unavailable provider + And a error event handler + Then the error event handler should have been executed within 10000ms + And the client should be in error state + + @unavailable + Scenario: A provider that cannot reach its backend still returns code defaults + Given a unavailable provider + And a error event handler + And a Boolean-flag with key "boolean-flag" and a default value "false" + Then the error event handler should have been executed within 10000ms + When the flag was evaluated with details + Then the resolved details value should be "false" + And the reason should be "ERROR" + And no exception should have been thrown diff --git a/tools/provider-tck/src/main/resources/flags/canonical-flags.json b/tools/provider-tck/src/main/resources/flags/canonical-flags.json new file mode 100644 index 0000000000..343b3ae525 --- /dev/null +++ b/tools/provider-tck/src/main/resources/flags/canonical-flags.json @@ -0,0 +1,82 @@ +{ + "$comment": [ + "The canonical flag set the TCK's feature files assume. A backend under test MUST serve an", + "equivalent set under the configuration named 'default'.", + "", + "Expressed in the flagd flag-definition format because that is the only widely implemented", + "vendor-neutral format today. The format is not what matters — the keys, types, variant", + "names and resolved values are. Seed them however your backend seeds flags.", + "", + "Two things are load-bearing and easy to get wrong:", + " * 'missing-flag' MUST NOT exist. Its absence is what the FLAG_NOT_FOUND scenario tests.", + " * No flag here has targeting rules. Every scenario expects reason STATIC, because the TCK", + " tests the provider's mapping of a response, not the backend's evaluation logic." + ], + "flags": { + "boolean-flag": { + "state": "ENABLED", + "variants": { + "on": true, + "off": false + }, + "defaultVariant": "on" + }, + "string-flag": { + "state": "ENABLED", + "variants": { + "greeting": "hi", + "parting": "bye" + }, + "defaultVariant": "greeting" + }, + "integer-flag": { + "state": "ENABLED", + "variants": { + "one": 1, + "ten": 10 + }, + "defaultVariant": "ten" + }, + "float-flag": { + "state": "ENABLED", + "variants": { + "tenth": 0.1, + "half": 0.5 + }, + "defaultVariant": "half" + }, + "object-flag": { + "state": "ENABLED", + "variants": { + "empty": {}, + "template": { + "showImages": true, + "title": "Check out these pics!", + "imagesPerPage": 100 + } + }, + "defaultVariant": "template" + }, + "wrong-flag": { + "$comment": "A string flag, evaluated as a boolean by the TYPE_MISMATCH scenario.", + "state": "ENABLED", + "variants": { + "one": "uno", + "two": "dos" + }, + "defaultVariant": "one" + }, + "changing-flag": { + "$comment": [ + "The flag POST /change mutates. The TCK asserts only that its resolved value differs", + "after the change, so which of the two variants you start from does not matter." + ], + "state": "ENABLED", + "variants": { + "foo": "foo", + "bar": "bar" + }, + "defaultVariant": "foo" + } + } +} diff --git a/tools/provider-tck/src/main/resources/openapi/control-api.yaml b/tools/provider-tck/src/main/resources/openapi/control-api.yaml new file mode 100644 index 0000000000..fd9bc7000d --- /dev/null +++ b/tools/provider-tck/src/main/resources/openapi/control-api.yaml @@ -0,0 +1,368 @@ +openapi: 3.0.3 + +info: + title: OpenFeature Provider TCK — Backend Control API + version: 0.0.1 + description: | + The control API that a **backend under test** must expose so the OpenFeature + Provider TCK can drive it. + + The TCK verifies the *provider contract*: how a provider maps backend + responses to typed resolution details, lifecycle states and events. To do + that it must be able to put the backend into specific states on demand — + running, unreachable, reconfigured. This document standardises how. + + This specification is derived from the control endpoints already implemented + by [`flagd-testbed`](https://github.com/open-feature/flagd-testbed)'s + "launchpad" server, which is the reference implementation. + + ## Where this document should live + + This file currently ships inside the Java `provider-tck` artifact, but it is + not a Java artifact: it is a language-agnostic contract that every language's + TCK must implement identically, and that backend vendors implement in + whatever language their testbed is written in (Go, for flagd). + + It therefore belongs in the OpenFeature **spec** repository + (`open-feature/spec`), alongside the canonical Gherkin feature files and the + canonical flag set. Those three artifacts are a single unit — a feature file + that evaluates `boolean-flag` is meaningless without the flag definition, and + a disconnect scenario is meaningless without the endpoint that produces the + disconnect. Splitting them across repositories would let them drift. + + Each language's TCK then vendors the spec repo (git submodule or equivalent) + and packages these files into its own distribution format, so that adopting a + TCK never requires a consumer to check out a submodule of their own. + + ## Conformance language + + The key words MUST, MUST NOT, REQUIRED, SHOULD, SHOULD NOT and MAY are to be + interpreted as described in RFC 2119. + + Each operation below is tagged **REQUIRED** or **OPTIONAL**. A backend that + implements every REQUIRED operation can run the full TCK. OPTIONAL operations + have a defined fallback that the TCK applies automatically, so omitting them + costs nothing but precision. + + --- + + ## Normative requirement 1 — the no-container-restart invariant + + > **Container lifecycle operations MUST NOT be used to simulate backend + > unavailability. Backend unavailability MUST be simulated from inside the + > running stack.** + + The TCK starts the vendor's Docker Compose stack **once per test suite** and + reads the dynamically mapped host ports. Testcontainers cannot reliably + preserve mapped ports across a container stop/start in all language + bindings — a restarted container generally comes back on a *different* host + port, which silently invalidates every provider instance already pointed at + the old one. Any TCK implementation in any language hits this, so the + constraint is part of the contract rather than a Java detail. + + Therefore an implementation of `/stop`, `/restart` or any other outage + simulation MUST achieve the outage by one of: + + * killing or suspending the backend **process** inside its container + (the reference behaviour — this is what flagd-testbed does); + * a proxy in the stack refusing or blackholing connections + (e.g. a toxiproxy toxic, an envoy `direct_response`); + * an in-container firewall or socket-level block. + + An implementation MUST NOT `docker stop`, `docker kill`, `docker rm` or + recreate any container in the stack while the suite is running. The stack is + brought up before the first scenario and torn down after the last one, and + the mapped ports MUST remain stable for that entire window. + + --- + + ## Normative requirement 2 — flag state semantics across outages + + Outage simulation and flag-state seeding are orthogonal, and the TCK relies + on that separation for scenario isolation: + + * `POST /start` **MUST** (re)seed flag state to the baseline defined by the + named configuration. Any mutation previously applied by `POST /change` + MUST be discarded. This is what makes `/start` usable as a reset. + * `POST /restart` and a `POST /stop` followed by a `POST /start` **of the + same configuration** MUST leave the backend serving the same baseline + flag state it served before the outage. An outage MUST NOT be observable + as a change in flag *values* — only as a change in *availability*. + * `POST /change` mutations persist until the next `/start` or `/reset`. + + --- + + ## Normative requirement 3 — compose stack conventions + + The backend under test is delivered as a **Docker Compose stack**, not a + single image, so vendors can compose proxies, edge services or several + containers. The TCK only relies on these conventions: + + * One service — by default named `backend`, overridable by the provider + author — exposes the control API on container-internal port `8080` + (also overridable). + * The same stack exposes whatever port(s) the provider connects to. + * **All external ports are dynamically mapped.** A stack MUST NOT pin host + ports; the TCK discovers them after startup and hands them to the + provider factory. + * The stack MAY contain any number of additional services. + + --- + + ## Known gap — evaluation context passthrough + + There is currently no operation for asserting that an evaluation context sent + by the provider actually reached the backend intact. Verifying that requires + an echo mechanism (e.g. `GET /last-evaluation` returning the most recent + request the backend received). Until such an operation exists, context + passthrough is out of scope for the TCK. + + license: + name: Apache 2.0 + url: https://www.apache.org/licenses/LICENSE-2.0 + +servers: + - url: http://{host}:{port} + description: | + Resolved at runtime from the Compose stack. `host` is the Docker host and + `port` is the dynamically mapped host port for the control service's + internal port 8080. + variables: + host: + default: localhost + port: + default: "8080" + +tags: + - name: lifecycle + description: Start and stop the backend process. + - name: availability + description: Simulate outages without touching containers. + - name: flags + description: Seed and mutate flag configuration. + - name: health + description: Readiness of the control API itself. + +paths: + + /start: + post: + tags: [lifecycle] + operationId: start + summary: "[REQUIRED] Start the backend and seed flags to a named baseline" + description: | + Starts the backend process using the named configuration and seeds flag + state to that configuration's baseline. + + MUST be idempotent in the sense that calling it while the backend is + already running is not an error: the implementation restarts the process + (or otherwise ensures it is running) with the requested configuration. + + Because this operation resets flag state, the TCK uses it as its default + scenario-isolation mechanism when `/reset` is not implemented. + + The set of valid configuration names is vendor-defined. Every + implementation MUST support the name `default`, which MUST serve the + canonical flag set the TCK's feature files assume. + + Reference implementation: flagd-testbed launches the `flagd` binary with + the config file of that name from `launchpad/configs` and rewrites + `/flags/allFlags.json`. + parameters: + - name: config + in: query + required: false + description: | + Name of the configuration to start with. Defaults to `default`. + schema: + type: string + default: default + example: default + responses: + "200": + description: Backend started and flag state seeded. + "400": + description: Unknown configuration name. + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + + /stop: + post: + tags: [availability] + operationId: stop + summary: "[REQUIRED] Make the backend unreachable" + description: | + Makes the backend unreachable to the provider, simulating an outage. + + **MUST NOT stop the container.** See normative requirement 1. The + reference implementation kills the flagd process while its container + keeps running. + + The backend stays unreachable until a subsequent `POST /start`. Calling + `/stop` when the backend is already stopped MUST succeed. + + The TCK uses this to drive providers into `STALE` and `ERROR` states and + to assert `PROVIDER_STALE` / `PROVIDER_ERROR` events. + responses: + "200": + description: Backend is now unreachable; container still running. + + /restart: + post: + tags: [availability] + operationId: restart + summary: "[REQUIRED] Simulate an outage of a bounded duration" + description: | + Makes the backend unreachable, waits `seconds`, then starts it again with + the configuration currently in effect. + + Flag state MUST be preserved across the outage — see normative + requirement 2. This is what distinguishes `/restart` from + `/stop` + `/start`: the former is an availability event, the latter is + also a reset. + + This operation MAY return as soon as the outage has begun rather than + blocking for the full duration; the TCK does not rely on the response + being delayed. It awaits provider events instead. + + The TCK uses this for the disconnect/reconnect scenarios: `STALE` → + `PROVIDER_STALE`, then back to `READY` → `PROVIDER_READY`. + parameters: + - name: seconds + in: query + required: false + description: | + How long the backend stays unreachable. Defaults to 5. + + Providers differ enormously in how fast they notice an outage — + a streaming provider may see it in milliseconds while a polling + provider needs up to a full poll interval. Feature files therefore + parameterise this value and provider authors tune the matching + await timeouts. + schema: + type: integer + format: int32 + minimum: 0 + default: 5 + example: 5 + responses: + "200": + description: Outage started (and, for blocking implementations, ended). + + /change: + post: + tags: [flags] + operationId: change + summary: "[REQUIRED] Mutate flag configuration so the provider observes a change" + description: | + Mutates the flag configuration such that a conforming provider observes a + configuration change and, on re-evaluation, resolves a **different value** + for the affected flag. + + The implementation MUST: + + * change the resolved value of the flag with key `changing-flag`; + * do so without restarting the backend process, so that a provider sees + a configuration-change signal rather than a reconnect; + * make the change durable until the next `/start` or `/reset`. + + The implementation SHOULD toggle between exactly two known values so that + repeated calls are meaningful and the test remains deterministic + regardless of how many times it has run against the same stack. The + reference implementation toggles `changing-flag`'s `defaultVariant` + between `foo` and `bar`. + + The TCK uses this to assert `PROVIDER_CONFIGURATION_CHANGED`, that the + changed flag key appears in the event payload, and that a subsequent + evaluation returns the new value. + responses: + "200": + description: Flag configuration mutated. + + /reset: + post: + tags: [flags] + operationId: reset + summary: "[OPTIONAL] Restore the seeded baseline without an outage" + description: | + Restores flag state to the baseline of the configuration currently in + effect, discarding any mutation applied by `/change`, **without** making + the backend unreachable at any point. + + This is the preferred scenario-isolation primitive: unlike `/start` it + causes no availability blip, so it cannot inject spurious lifecycle + events into the next scenario. + + **Scope.** This operation resets flag state only. It MUST NOT be + expected to start a backend that is currently stopped — that is what + `/start` is for. A TCK therefore uses `/reset` only when the backend is + known to be running, and `/start` otherwise. The reference client tracks + this: `/stop` and `/restart` mark the backend as possibly-unreachable, so + the scenario that follows either of them is prepared with `/start`. + + **Fallback when not implemented.** A backend that does not implement this + operation MUST respond `404` or `501`. The TCK then falls back to + `POST /start?config={defaultConfig}`, which resets flag state at the cost + of a process restart. The fallback is detected once per suite and cached. + + Implementing `/reset` is RECOMMENDED for providers whose reconnect + behaviour makes the `/start` blip hard to distinguish from a real event. + responses: + "200": + description: Flag state restored to the baseline. + "404": + description: Not implemented; the TCK falls back to `/start`. + "501": + description: Not implemented; the TCK falls back to `/start`. + + /healthz: + get: + tags: [health] + operationId: health + summary: "[OPTIONAL] Readiness of the control API" + description: | + Reports whether the control API is ready to accept commands. + + **Fallback when not implemented.** Readiness defaults to "the control + port accepts a TCP connection", which the TCK establishes with a + Testcontainers listening-port wait strategy before the first scenario. A + `404` here is therefore not a failure, and the reference implementation + does not serve this path. + + Note this reports the health of the **control API**, not of the backend. + The backend is deliberately unhealthy during outage scenarios while the + control API must stay reachable — otherwise the TCK could not end the + outage. + responses: + "200": + description: Control API ready. + content: + application/json: + schema: + $ref: "#/components/schemas/Health" + "404": + description: Not implemented; readiness falls back to a TCP port check. + "503": + description: Control API not ready yet. + +components: + schemas: + + Health: + type: object + properties: + status: + type: string + enum: [ok] + description: Present and equal to `ok` when the control API is ready. + required: [status] + + Error: + type: object + properties: + message: + type: string + description: Human-readable explanation. Never interpreted by the TCK. + required: [message] diff --git a/tools/provider-tck/version.txt b/tools/provider-tck/version.txt new file mode 100644 index 0000000000..8acdd82b76 --- /dev/null +++ b/tools/provider-tck/version.txt @@ -0,0 +1 @@ +0.0.1 From c257f21ea29f7f77b9e3f2d599f0ca5ffbfe3181 Mon Sep 17 00:00:00 2001 From: Simon Schrottner Date: Mon, 27 Jul 2026 11:39:50 +0200 Subject: [PATCH 02/55] feat(provider-tck): cover both flagd resolver modes, expand type mismatch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds flagd in-process alongside RPC, and broadens type-mismatch coverage from a single case to the full non-numeric matrix. Covering two modes exposed a leak in the adoption surface. Harness discovery went through ServiceLoader, which becomes ambiguous the moment a provider registers a second harness, and resolving it needed a system property plus one Surefire execution per mode in every adopter's POM. Fixed in the base class rather than absorbed as boilerplate: a concrete suite class already IS a ProviderTckHarness, so TckSuiteListener (a TestExecutionListener auto-registered from this JAR) reports which suite the JUnit Platform is running and TckRuntime instantiates that class. Adding a mode is now one class and nothing else — no registration file, no system property, no build configuration. Adoption drops to a single file; the META-INF/services registration is gone, retained only as a documented fallback for launchers that disable listener auto-registration. flagd's two modes therefore become a shared AbstractFlagdTckTest plus a four-line subclass each. In-process needs a longer initialisation deadline than RPC because it syncs the whole ruleset before reporting ready, while the unavailable provider keeps a short deadline so the init-failure scenarios still assert promptness. Type-mismatch coverage now spans every non-numeric combination — string, boolean, integer, float and object requested as each incompatible type, 15 cases — each asserting the full three-part contract: the code default is returned, TYPE_MISMATCH is reported, and nothing is thrown. All pass in both modes. Numeric coercion stays separate under @strict-numeric-typing, because "is 0.5 an integer" has a defensible wrong answer whereas "is a string a boolean" does not. Both resolvers narrow float to int identically (0, no error code), which places that defect in the shared provider layer rather than in either transport, so the capability is withheld on the shared base class. Verified: 29 scenarios per mode, 28 passed and 1 visibly skipped in each; existing flagd e2e suites unaffected (RunFileTest 151, RunInProcessTest 223, RunRpcTest 213). Refs #1829 Signed-off-by: Simon Schrottner --- .../flagd/e2e/AbstractFlagdTckTest.java | 112 ++++++++++++++++++ .../flagd/e2e/FlagdInProcessTckTest.java | 17 +++ .../providers/flagd/e2e/FlagdRpcTckTest.java | 17 +++ .../providers/flagd/e2e/FlagdTckTest.java | 74 ------------ ...ntrib.tools.providertck.ProviderTckHarness | 1 - tools/provider-tck/README.md | 67 +++++++---- tools/provider-tck/pom.xml | 9 ++ .../contrib/tools/providertck/TckRuntime.java | 39 +++++- .../tools/providertck/TckSuiteListener.java | 86 ++++++++++++++ ...it.platform.launcher.TestExecutionListener | 1 + .../main/resources/features/errors.feature | 46 ++++++- 11 files changed, 365 insertions(+), 104 deletions(-) create mode 100644 providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/e2e/AbstractFlagdTckTest.java create mode 100644 providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/e2e/FlagdInProcessTckTest.java create mode 100644 providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/e2e/FlagdRpcTckTest.java delete mode 100644 providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/e2e/FlagdTckTest.java delete mode 100644 providers/flagd/src/test/resources/META-INF/services/dev.openfeature.contrib.tools.providertck.ProviderTckHarness create mode 100644 tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/TckSuiteListener.java create mode 100644 tools/provider-tck/src/main/resources/META-INF/services/org.junit.platform.launcher.TestExecutionListener diff --git a/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/e2e/AbstractFlagdTckTest.java b/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/e2e/AbstractFlagdTckTest.java new file mode 100644 index 0000000000..8c3f833e2c --- /dev/null +++ b/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/e2e/AbstractFlagdTckTest.java @@ -0,0 +1,112 @@ +package dev.openfeature.contrib.providers.flagd.e2e; + +import dev.openfeature.contrib.providers.flagd.Config; +import dev.openfeature.contrib.providers.flagd.FlagdOptions; +import dev.openfeature.contrib.providers.flagd.FlagdProvider; +import dev.openfeature.contrib.tools.providertck.AbstractProviderTckTest; +import dev.openfeature.contrib.tools.providertck.BackendEndpoint; +import dev.openfeature.contrib.tools.providertck.Capability; +import dev.openfeature.sdk.FeatureProvider; +import java.io.File; +import java.util.Collections; +import java.util.EnumSet; +import java.util.List; +import java.util.Set; + +/** + * Shared configuration for running the OpenFeature Provider TCK against the flagd provider. + * + *

flagd resolves flags in two quite different ways, and both are worth conforming: RPC evaluates + * remotely over gRPC, while in-process syncs the ruleset and evaluates locally. They share a backend + * stack and differ only in resolver and port, so the modes are two small subclasses. + * + *

Each concrete subclass is its own JUnit suite and its own TCK harness; the TCK works out which + * one is running from the JUnit test plan, so adding a mode needs no registration or build + * configuration. + */ +abstract class AbstractFlagdTckTest extends AbstractProviderTckTest { + + /** + * A port nothing listens on, for the initialisation-failure scenarios. + * + *

Deliberately not a port on the Compose stack: the stack must stay up for the whole suite, + * and simulated outages belong to the control API. + */ + private static final int UNAVAILABLE_PORT = 9999; + + /** + * gRPC deadline for a provider that is expected to connect. + * + *

Generous on purpose. flagd derives its initialisation deadline from this value, and the + * in-process resolver must sync the entire ruleset before it reports ready — which intermittently + * takes longer than a deadline tuned for a single RPC round trip. + */ + private static final int CONNECTED_DEADLINE_MS = 5000; + + /** + * gRPC deadline for a provider pointed at a dead port. + * + *

Short on purpose, and deliberately not the same as {@link #CONNECTED_DEADLINE_MS}: the + * initialisation-failure scenarios assert that the failure is reported promptly, so a + * provider that takes as long to give up as it does to connect would defeat the point. + */ + private static final int UNAVAILABLE_DEADLINE_MS = 1000; + + /** The resolver under test. */ + protected abstract Config.Resolver resolver(); + + /** The container-internal port that resolver connects to. */ + protected abstract int backendPort(); + + @Override + public File composeFile() { + return new File("src/test/resources/tck/docker-compose.yaml"); + } + + @Override + public List backendPorts() { + return Collections.singletonList(backendPort()); + } + + @Override + public FeatureProvider createProvider(BackendEndpoint endpoint) { + return new FlagdProvider(baseOptions() + .deadline(CONNECTED_DEADLINE_MS) + .host(endpoint.host()) + .port(endpoint.port(backendPort())) + .build()); + } + + @Override + public FeatureProvider createUnavailableProvider() { + return new FlagdProvider(baseOptions() + .deadline(UNAVAILABLE_DEADLINE_MS) + .host("localhost") + .port(UNAVAILABLE_PORT) + .build()); + } + + /** + * {@inheritDoc} + * + *

Everything except {@link Capability#STRICT_NUMERIC_TYPING}. Evaluating {@code float-flag} + * (0.5) through the integer API returns {@code 0} with no error code rather than + * {@code TYPE_MISMATCH} with the code default — the value is silently truncated. That is a + * defect to fix, not a design choice; this override should be deleted once it is. + * + *

Declared here rather than per mode because both resolvers behave identically, which places + * the defect in the shared provider layer rather than in either transport. Every other + * capability, including the full non-numeric type-mismatch matrix, holds in both modes. + */ + @Override + public Set capabilities() { + return EnumSet.complementOf(EnumSet.of(Capability.STRICT_NUMERIC_TYPING)); + } + + private FlagdOptions.FlagdOptionsBuilder baseOptions() { + return FlagdOptions.builder() + .resolverType(resolver()) + .retryGracePeriod(2) + .retryBackoffMs(500); + } +} diff --git a/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/e2e/FlagdInProcessTckTest.java b/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/e2e/FlagdInProcessTckTest.java new file mode 100644 index 0000000000..1ce5b1dc71 --- /dev/null +++ b/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/e2e/FlagdInProcessTckTest.java @@ -0,0 +1,17 @@ +package dev.openfeature.contrib.providers.flagd.e2e; + +import dev.openfeature.contrib.providers.flagd.Config; + +/** Runs the OpenFeature Provider TCK against the flagd provider in in-process mode. */ +public class FlagdInProcessTckTest extends AbstractFlagdTckTest { + + @Override + protected Config.Resolver resolver() { + return Config.Resolver.IN_PROCESS; + } + + @Override + protected int backendPort() { + return 8015; + } +} diff --git a/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/e2e/FlagdRpcTckTest.java b/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/e2e/FlagdRpcTckTest.java new file mode 100644 index 0000000000..30ee6db57c --- /dev/null +++ b/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/e2e/FlagdRpcTckTest.java @@ -0,0 +1,17 @@ +package dev.openfeature.contrib.providers.flagd.e2e; + +import dev.openfeature.contrib.providers.flagd.Config; + +/** Runs the OpenFeature Provider TCK against the flagd provider in RPC mode. */ +public class FlagdRpcTckTest extends AbstractFlagdTckTest { + + @Override + protected Config.Resolver resolver() { + return Config.Resolver.RPC; + } + + @Override + protected int backendPort() { + return 8013; + } +} diff --git a/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/e2e/FlagdTckTest.java b/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/e2e/FlagdTckTest.java deleted file mode 100644 index 2d1d6ef7ee..0000000000 --- a/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/e2e/FlagdTckTest.java +++ /dev/null @@ -1,74 +0,0 @@ -package dev.openfeature.contrib.providers.flagd.e2e; - -import dev.openfeature.contrib.providers.flagd.Config; -import dev.openfeature.contrib.providers.flagd.FlagdOptions; -import dev.openfeature.contrib.providers.flagd.FlagdProvider; -import dev.openfeature.contrib.tools.providertck.AbstractProviderTckTest; -import dev.openfeature.contrib.tools.providertck.BackendEndpoint; -import dev.openfeature.contrib.tools.providertck.Capability; -import dev.openfeature.sdk.FeatureProvider; -import java.io.File; -import java.util.Collections; -import java.util.EnumSet; -import java.util.List; -import java.util.Set; - -/** - * Runs the OpenFeature Provider TCK against the flagd provider in RPC mode. - * - *

The entire adoption is this class plus {@code src/test/resources/tck/docker-compose.yaml} and - * a one-line {@code META-INF/services} registration. Everything else — the Compose lifecycle, port - * discovery, control API calls, provider registration, event awaiting — belongs to the TCK. - * - *

To also cover in-process mode, copy this class, change the resolver, and register both; then - * select one per Surefire execution with {@code -Dopenfeature.tck.harness=}. - */ -public class FlagdTckTest extends AbstractProviderTckTest { - - private static final int RPC_PORT = 8013; - - @Override - public File composeFile() { - return new File("src/test/resources/tck/docker-compose.yaml"); - } - - @Override - public List backendPorts() { - return Collections.singletonList(RPC_PORT); - } - - @Override - public FeatureProvider createProvider(BackendEndpoint endpoint) { - return new FlagdProvider(FlagdOptions.builder() - .resolverType(Config.Resolver.RPC) - .host(endpoint.host()) - .port(endpoint.port(RPC_PORT)) - .deadline(1000) - .retryGracePeriod(2) - .retryBackoffMs(500) - .build()); - } - - /** - * {@inheritDoc} - * - *

Everything except {@link Capability#STRICT_NUMERIC_TYPING}. Evaluating {@code float-flag} - * (0.5) through the integer API returns {@code 0} with no error code rather than - * {@code TYPE_MISMATCH} with the code default — the value is silently truncated. That is a - * defect to fix, not a design choice; this line should be deleted once it is. - */ - @Override - public Set capabilities() { - return EnumSet.complementOf(EnumSet.of(Capability.STRICT_NUMERIC_TYPING)); - } - - @Override - public FeatureProvider createUnavailableProvider() { - return new FlagdProvider(FlagdOptions.builder() - .resolverType(Config.Resolver.RPC) - .host("localhost") - .port(9999) - .deadline(1000) - .build()); - } -} diff --git a/providers/flagd/src/test/resources/META-INF/services/dev.openfeature.contrib.tools.providertck.ProviderTckHarness b/providers/flagd/src/test/resources/META-INF/services/dev.openfeature.contrib.tools.providertck.ProviderTckHarness deleted file mode 100644 index 37c5e9ef41..0000000000 --- a/providers/flagd/src/test/resources/META-INF/services/dev.openfeature.contrib.tools.providertck.ProviderTckHarness +++ /dev/null @@ -1 +0,0 @@ -dev.openfeature.contrib.providers.flagd.e2e.FlagdTckTest diff --git a/tools/provider-tck/README.md b/tools/provider-tck/README.md index a8d767ba50..6c14dd0b2e 100644 --- a/tools/provider-tck/README.md +++ b/tools/provider-tck/README.md @@ -154,17 +154,11 @@ public class MyProviderTckTest extends AbstractProviderTckTest { } ``` -Plus one line at -`src/test/resources/META-INF/services/dev.openfeature.contrib.tools.providertck.ProviderTckHarness`: - -``` -com.example.MyProviderTckTest -``` - -That is the whole adoption. The Compose lifecycle, port discovery, control API calls, provider -registration, event awaiting and teardown all belong to the TCK. **If you find yourself adding -test infrastructure to this class, that is a bug in the TCK — please open an issue rather than -working around it.** +That is the whole adoption — one file, no registration. The class is simultaneously the JUnit suite +and the harness, and the TCK works out which suite is running from the JUnit test plan. The Compose +lifecycle, port discovery, control API calls, provider registration, event awaiting and teardown all +belong to the TCK. **If you find yourself adding test infrastructure to this class, that is a bug in +the TCK — please open an issue rather than working around it.** `createUnavailableProvider()` should point at a closed port on localhost, not at your stack — the stack must stay up, and simulated outages belong to the control API. Give it a short connection @@ -173,15 +167,46 @@ will not make it. #### Several provider modes -Providers with more than one transport (remote evaluation vs. in-process, say) register one harness -class per mode and select between them with a system property, typically one Surefire execution -each: +A provider with more than one transport writes **one class per mode and nothing else** — no +registration, no system property, no build configuration. Each class is its own suite, each gets its +own Compose stack, and they can share a base class: +```java +abstract class AbstractMyProviderTckTest extends AbstractProviderTckTest { + protected abstract Mode mode(); + // composeFile(), createProvider(), capabilities() ... shared here +} + +public class MyProviderRemoteTckTest extends AbstractMyProviderTckTest { + @Override protected Mode mode() { return Mode.REMOTE; } +} + +public class MyProviderInProcessTckTest extends AbstractMyProviderTckTest { + @Override protected Mode mode() { return Mode.IN_PROCESS; } +} ``` --Dopenfeature.tck.harness=MyProviderRpcTckTest -``` -With a single registered harness the property is not needed. +This is how flagd covers RPC and in-process — see +[`AbstractFlagdTckTest`](../../providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/e2e/AbstractFlagdTckTest.java). +Abstract classes are not run, so an intermediate base is safe. + +Note that per-mode differences may include timing, not just wiring: flagd's in-process resolver +syncs the whole ruleset before reporting ready, so it needs a longer initialisation deadline than +its RPC mode. Give a connecting provider a generous deadline and an intentionally unreachable one a +short deadline — the failure scenarios assert that failure is reported *promptly*. + +

+Fallback: ServiceLoader registration + +Suite discovery relies on the JUnit Platform auto-registering `TckSuiteListener` (declared in this +JAR's `META-INF/services/org.junit.platform.launcher.TestExecutionListener`), which Surefire, Gradle +and IDEs all do by default. If your launcher disables listener auto-registration, register the +harness explicitly instead at +`src/test/resources/META-INF/services/dev.openfeature.contrib.tools.providertck.ProviderTckHarness`, +and if you register more than one, select between them with +`-Dopenfeature.tck.harness=MyProviderRemoteTckTest`. + +
## Declaring capabilities @@ -216,8 +241,8 @@ requires `TYPE_MISMATCH` when the requested type cannot be satisfied, and narrow loses information silently — the worst failure mode for a feature flag, because the application sees a plausible value and no error. It is a capability only so a provider with this defect can adopt the TCK today and see the gap reported explicitly. Not declaring it is an admission of a -known bug. **The flagd provider currently does not declare it** — see -[`FlagdTckTest`](../../providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/e2e/FlagdTckTest.java). +known bug. **The flagd provider currently does not declare it**, in either RPC or in-process mode — +see [`AbstractFlagdTckTest`](../../providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/e2e/AbstractFlagdTckTest.java). ## Tuning timeouts @@ -312,7 +337,9 @@ consumers — the features stay on the classpath and stay inside the JAR. whether it holds a local copy of the ruleset. The `@caching` tag is reserved; no scenarios yet. - **Hooks.** Not covered. - **Flag metadata.** The flagd harness has metadata scenarios; they are not yet ported. -- **Multi-suite JVMs.** `TckRuntime` is static, so one TCK suite may run per JVM fork at a time. +- **Multi-suite JVMs.** `TckRuntime` is static, so TCK suites run one at a time within a JVM fork. + Several suites in one fork is fine — they run sequentially, each with its own Compose stack — but + they cannot run concurrently. - **Scenario coverage is a representative subset**, covering each architectural mechanism once rather than exhaustively. diff --git a/tools/provider-tck/pom.xml b/tools/provider-tck/pom.xml index 8b2ba525e4..3de834808a 100644 --- a/tools/provider-tck/pom.xml +++ b/tools/provider-tck/pom.xml @@ -118,6 +118,15 @@ compile
+ + + org.junit.platform + junit-platform-launcher + compile + + + OBJECT_FACTORY_PROPERTY_NAME constants used in ProviderTckTest --> io.cucumber cucumber-junit-platform-engine @@ -110,7 +112,7 @@ compile - org.junit.platform diff --git a/tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/BackendControl.java b/tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/BackendControl.java new file mode 100644 index 0000000000..820ffdaa8e --- /dev/null +++ b/tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/BackendControl.java @@ -0,0 +1,118 @@ +package dev.openfeature.contrib.tools.providertck; + +import java.time.Duration; + +/** + * The single seam between the TCK's step definitions and whatever manipulates the backend. + * + *

Step definitions never talk to a backend directly. They talk to this interface, which is why + * the same Gherkin can run unchanged against any backend an implementation can drive — + * a containerised one over HTTP ({@link HttpBackendControl}) being the first. Nothing below this + * line knows about ports, containers or transports. + * + *

Which implementation is right for your provider

+ * + *

If your provider talks to a backend — a server, a service, anything out of process — use + * {@link HttpBackendControl} by extending {@link ContainerizedProviderTckTest}. The HTTP control + * API in {@code openapi/control-api.yaml} is the normative contract for those providers, and it is + * what makes a conformance claim portable: another language's TCK drives the same endpoints against + * the same stack and must get the same answers. + * + *

Do not write a custom in-JVM {@code BackendControl} that reaches into an + * external backend through a side channel — a test-only admin client, a shared database handle, a + * static hook inside the provider. It will pass, and it will prove nothing, because the thing it + * exercised is not the thing the contract describes. + * + *

An in-JVM implementation is legitimate only for providers that have no backend to + * contract with: in-memory, environment-variable and file-based providers, where "the backend" is a + * data structure in the same JVM. + * + *

Operations a backend may not support

+ * + *

{@link #prepareScenario()} and {@link #changeFlag()} are mandatory: a backend that cannot reset + * itself or change a flag cannot run the suite at all. + * + *

The three connection operations are not. A provider with nothing to disconnect from leaves + * them at their defaults, which throw {@link UnsupportedOperationException}. That exception is a + * test-configuration bug, never a skip — the scenarios that need connection + * control are gated behind {@link Capability#STALE} and {@link Capability#UNAVAILABLE_INIT}, so + * reaching one of these defaults means a capability was declared that the backend cannot back up. + * Failing loudly there is deliberate: a silent no-op would report the scenario as passed. + * + * @see Capability + * @see ProviderTckTest + */ +public interface BackendControl { + + /** + * Brings the backend to the state every scenario starts from: reachable, with flag state at the + * baseline of the canonical flag set. + * + *

Called once before each scenario. This is the TCK's only isolation mechanism — scenarios + * share one backend for the whole suite, and containers are never restarted between them. + */ + void prepareScenario(); + + /** + * Mutates flag configuration so that a conforming provider observes a configuration change and + * resolves a different value for {@code changing-flag} afterwards. + * + *

Which value it changes to is deliberately unspecified; the suite asserts only that the + * resolved value differs from what it was before. + */ + void changeFlag(); + + /** + * Makes the backend unreachable for the rest of the scenario, without stopping any container. + * + * @throws UnsupportedOperationException if this backend has no connection to lose + */ + default void disconnect() { + throw unsupported("disconnect"); + } + + /** + * Makes the backend reachable again after {@link #disconnect()}, preserving flag state so the + * provider observes an availability change rather than a configuration change. + * + * @throws UnsupportedOperationException if this backend has no connection to restore + */ + default void reconnect() { + throw unsupported("reconnect"); + } + + /** + * Makes the backend unreachable for a bounded period, after which it comes back on its own. + * + * @param outage how long the backend stays unreachable + * @throws UnsupportedOperationException if this backend has no connection to lose + */ + default void disconnectFor(Duration outage) { + throw unsupported("disconnectFor"); + } + + /** + * Returns a short description of what is being controlled, for startup logging and for the + * failure messages of unsupported operations. + * + * @return a human-readable description of this backend control + */ + default String description() { + return getClass().getSimpleName(); + } + + /** + * Builds the exception the connection-control defaults throw. + * + * @param operation the operation that is not supported + * @return the exception to throw + */ + default UnsupportedOperationException unsupported(String operation) { + return new UnsupportedOperationException(description() + " does not support '" + operation + + "'. This is a test-configuration bug rather than a provider defect: a scenario " + + "needing connection control ran, so the harness declared Capability.STALE or " + + "Capability.UNAVAILABLE_INIT for a backend that cannot simulate an outage. " + + "Remove those capabilities from the harness, or supply a BackendControl that " + + "implements them."); + } +} diff --git a/tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/BackendEndpoint.java b/tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/BackendEndpoint.java index 40f7799cf9..b3d89d5873 100644 --- a/tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/BackendEndpoint.java +++ b/tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/BackendEndpoint.java @@ -4,7 +4,7 @@ /** * Addresses of the running backend stack, handed to - * {@link ProviderTckHarness#createProvider(BackendEndpoint)}. + * {@link ContainerizedProviderTckTest#createProvider(BackendEndpoint)}. * *

This type exists because external ports are only known after the Compose stack has * started. Compose stacks under test must not pin host ports — Docker assigns them dynamically, so @@ -53,7 +53,7 @@ public String host(String service) { * backend service. * * @param internalPort the container-internal port, as declared by - * {@link ProviderTckHarness#backendPorts()} + * {@link ContainerizedProviderTckTest#backendPorts()} * @return the host port the service is reachable on */ public int port(int internalPort) { @@ -64,7 +64,7 @@ public int port(int internalPort) { * Resolves the dynamically mapped host port for a container-internal port on a named service. * *

Use this for multi-service stacks — a proxy, an edge service, a sidecar. The service and - * port must have been declared via {@link ProviderTckHarness#additionalExposedPorts()}, + * port must have been declared via {@link ContainerizedProviderTckTest#additionalExposedPorts()}, * otherwise Testcontainers has not exposed it and this call fails. * * @param service the Compose service name diff --git a/tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/ContainerizedProviderTckTest.java b/tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/ContainerizedProviderTckTest.java new file mode 100644 index 0000000000..63bfefe13b --- /dev/null +++ b/tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/ContainerizedProviderTckTest.java @@ -0,0 +1,272 @@ +package dev.openfeature.contrib.tools.providertck; + +import dev.openfeature.sdk.FeatureProvider; +import java.io.File; +import java.time.Duration; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.testcontainers.containers.ComposeContainer; +import org.testcontainers.containers.wait.strategy.Wait; + +/** + * Base JUnit Platform Suite for providers that talk to an external backend. + * + *

Adds everything {@link ProviderTckTest} deliberately leaves out: the Docker Compose lifecycle, + * discovery of dynamically mapped host ports, and construction of an {@link HttpBackendControl} + * against the backend's control API. This is the base class for the overwhelming majority of + * providers. + * + *

The HTTP control API described in {@code openapi/control-api.yaml} is the normative contract + * here, and that is the point: another language's TCK drives the same endpoints against the same + * stack and must get the same answers. Substituting a custom in-JVM {@link BackendControl} that + * manipulates an external backend through a side channel bypasses that contract — see + * {@link BackendControl} for why that is not an acceptable adoption path. + * + *

Provider authors implement three methods, optionally a fourth, and override the defaults their + * stack needs. The Compose stack is started once, before the first scenario, and + * stopped after the last one. It is never stopped or restarted in between: Testcontainers cannot + * reliably preserve dynamically mapped host ports across a container restart, so a restart would + * silently invalidate every provider already pointed at the old port. Backend unavailability is + * therefore always simulated inside the running stack through the control API. + * + *

Example — the entire adoption for a provider with one transport: + * + *

{@code
+ * public class MyProviderTckTest extends ContainerizedProviderTckTest {
+ *
+ *     @Override
+ *     public File composeFile() {
+ *         return new File("src/test/resources/tck/docker-compose.yaml");
+ *     }
+ *
+ *     @Override
+ *     public List backendPorts() {
+ *         return Collections.singletonList(8013);
+ *     }
+ *
+ *     @Override
+ *     public FeatureProvider createProvider(BackendEndpoint endpoint) {
+ *         return new MyProvider(endpoint.host(), endpoint.port(8013));
+ *     }
+ *
+ *     @Override
+ *     public FeatureProvider createUnavailableProvider() {
+ *         return new MyProvider("localhost", 9999);
+ *     }
+ * }
+ * }
+ * + * @see ProviderTckTest + * @see HttpBackendControl + */ +public abstract class ContainerizedProviderTckTest extends ProviderTckTest { + + private static final Logger log = LoggerFactory.getLogger(ContainerizedProviderTckTest.class); + + private ComposeContainer compose; + private BackendEndpoint endpoint; + private HttpBackendControl control; + + // --------------------------------------------------------------------------------------- + // What a provider author supplies + // --------------------------------------------------------------------------------------- + + /** + * Returns the Docker Compose file describing the backend stack under test. + * + *

The path is resolved relative to the Maven module directory, so + * {@code new File("src/test/resources/tck/docker-compose.yaml")} is the idiomatic form. + * + *

The stack must not pin host ports — Docker assigns them dynamically and the TCK discovers + * them after startup. + * + * @return the Compose file describing the backend stack + */ + public abstract File composeFile(); + + /** + * Returns the container-internal ports on {@link #backendService()} that the provider connects + * to, so Testcontainers can expose and map them. + * + *

The control API port from {@link #controlPort()} is exposed automatically and does not + * need to be listed here. + * + * @return container-internal ports the provider connects to + */ + public abstract List backendPorts(); + + /** + * Creates the provider under test, configured against the running backend. + * + *

Called after the Compose stack is up and the control API has seeded the canonical flag + * set. The endpoint carries the dynamically mapped host ports, which is why this is a factory + * rather than a field: the ports do not exist until the stack has started. + * + *

The TCK owns the provider lifecycle from here. Do not call {@code setProvider} or + * {@code initialize} yourself. + * + * @param endpoint host and mapped ports of the running backend stack + * @return a configured, uninitialised provider + */ + public abstract FeatureProvider createProvider(BackendEndpoint endpoint); + + /** + * {@inheritDoc} + * + *

Delegates to {@link #createProvider(BackendEndpoint)} with the running stack's endpoint. + */ + @Override + public final FeatureProvider createProvider() { + return createProvider(endpoint()); + } + + /** + * {@inheritDoc} + * + *

Abstract here rather than defaulted: a provider with a real backend can always be pointed + * at a closed port, so there is no reason for one not to cover the initialisation-failure + * scenarios. + */ + @Override + public abstract FeatureProvider createUnavailableProvider(); + + /** + * Returns the Compose service name that hosts the control API and the backend the provider + * connects to. + * + * @return the Compose service name, {@code backend} by default + */ + public String backendService() { + return "backend"; + } + + /** + * Returns the container-internal port the control API listens on. + * + * @return the control API port, {@code 8080} by default + */ + public int controlPort() { + return 8080; + } + + /** + * Returns extra services and container-internal ports to expose, for stacks that contain more + * than the backend service. + * + *

Keys are Compose service names, values are container-internal ports. Resolve the mapped + * ports with {@link BackendEndpoint#port(String, int)}. + * + * @return additional services and ports to expose, empty by default + */ + public Map> additionalExposedPorts() { + return Collections.emptyMap(); + } + + /** + * Returns the control API configuration name used to seed the canonical flag set. + * + * @return the configuration name passed to {@code POST /start}, {@code default} by default + */ + public String defaultConfig() { + return "default"; + } + + /** + * Returns how long to wait for the Compose stack and its control API to become reachable. + * + * @return the stack startup timeout, 60 seconds by default + */ + public Duration startupTimeout() { + return Duration.ofSeconds(60); + } + + /** + * Returns how long to pause after a control API call before continuing. + * + *

Covers the gap between the control API acknowledging a command and the backend actually + * having acted on it. Raise it if you see flakiness immediately after + * {@code the flag was modified} or a provider setup step. + * + * @return the settle time, 50 milliseconds by default + */ + public Duration settleTime() { + return Duration.ofMillis(50); + } + + // --------------------------------------------------------------------------------------- + // The lifecycle-agnostic contract, implemented in terms of the Compose stack + // --------------------------------------------------------------------------------------- + + /** + * {@inheritDoc} + * + *

Starts the Compose stack, resolves the control API's mapped port and waits for it to + * accept commands. + */ + @Override + public final void startSuite() { + compose = startCompose(); + endpoint = new BackendEndpoint(compose, backendService()); + control = new HttpBackendControl( + "http://" + endpoint.host() + ":" + endpoint.port(controlPort()), defaultConfig(), settleTime()); + control.awaitReady(startupTimeout()); + log.info("Control API ready at {}", control.baseUrl()); + } + + /** {@inheritDoc} */ + @Override + public final void stopSuite() { + if (compose != null) { + compose.stop(); + } + compose = null; + endpoint = null; + control = null; + } + + /** {@inheritDoc} */ + @Override + public final BackendControl backendControl() { + return control; + } + + /** + * Returns the host and mapped ports of the running stack. + * + * @return the backend endpoint + * @throws IllegalStateException if the stack has not been started + */ + protected final BackendEndpoint endpoint() { + if (endpoint == null) { + throw new IllegalStateException("The Compose stack has not been started yet."); + } + return endpoint; + } + + private ComposeContainer startCompose() { + File composeFile = composeFile(); + if (!composeFile.isFile()) { + throw new IllegalStateException("Compose file not found: " + composeFile.getAbsolutePath() + + ". ContainerizedProviderTckTest.composeFile() is resolved relative to the module directory."); + } + ComposeContainer stack = new ComposeContainer(composeFile); + + stack.withExposedService(backendService(), controlPort(), Wait.forListeningPort()); + for (Integer port : backendPorts()) { + stack.withExposedService(backendService(), port, Wait.forListeningPort()); + } + for (Map.Entry> service : additionalExposedPorts().entrySet()) { + for (Integer port : service.getValue()) { + stack.withExposedService(service.getKey(), port, Wait.forListeningPort()); + } + } + stack.withStartupTimeout(startupTimeout()); + + log.info("Starting Compose stack {} (started once per suite, never restarted)", composeFile.getAbsolutePath()); + stack.start(); + return stack; + } +} diff --git a/tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/ControlApiClient.java b/tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/HttpBackendControl.java similarity index 63% rename from tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/ControlApiClient.java rename to tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/HttpBackendControl.java index 6c0a19e57d..42183b52c9 100644 --- a/tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/ControlApiClient.java +++ b/tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/HttpBackendControl.java @@ -10,38 +10,52 @@ import org.slf4j.LoggerFactory; /** - * Client for the standardised backend control API. + * {@link BackendControl} backed by the standardised HTTP control API. * - *

Implements the contract in {@code openapi/control-api.yaml}, including the documented fallback - * for the optional {@code /reset} operation. Uses the JDK HTTP client so that adopting the TCK does - * not drag an HTTP library onto a provider's test classpath. + *

This is the normative implementation for every provider that talks to an external backend. It + * implements the contract in {@code openapi/control-api.yaml}, including the documented fallback + * for the optional {@code /reset} operation. It uses the JDK HTTP client so that adopting the TCK + * does not drag an HTTP library onto a provider's test classpath. * *

Every operation here manipulates the backend process or its flag state. None of them * touch containers — that is the no-container-restart invariant, and it is the reason a provider * built once at suite start stays valid for every scenario. + * + *

Constructed by {@link ContainerizedProviderTckTest} once the Compose stack is up and the + * control API host port is known. Provider authors do not build one themselves. */ -public final class ControlApiClient { +public final class HttpBackendControl implements BackendControl { - private static final Logger log = LoggerFactory.getLogger(ControlApiClient.class); + private static final Logger log = LoggerFactory.getLogger(HttpBackendControl.class); private final HttpClient http; private final String baseUrl; + private final String defaultConfig; private final Duration settleTime; /** * Tri-state cache of whether the backend implements the optional {@code /reset} operation. - * {@code null} until the first {@link #reset(String)} call probes it. + * {@code null} until the first {@link #reset()} call probes it. */ private Boolean resetSupported; /** - * Whether the backend was last known to be unreachable. Conservative: {@code restart} sets it - * even though the backend comes back on its own, because a scenario may end before it does. + * Whether the backend was last known to be unreachable. Conservative: {@link #disconnectFor} + * sets it even though the backend comes back on its own, because a scenario may end before it + * does. */ private boolean backendStopped; - ControlApiClient(String baseUrl, Duration settleTime) { + /** + * Creates a control client for a running backend. + * + * @param baseUrl the control API base URL, without a trailing slash + * @param defaultConfig the configuration name defining the canonical baseline + * @param settleTime how long to pause after a command before continuing + */ + HttpBackendControl(String baseUrl, String defaultConfig, Duration settleTime) { this.baseUrl = baseUrl; + this.defaultConfig = defaultConfig; this.settleTime = settleTime; this.http = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(5)).build(); @@ -56,105 +70,80 @@ public String baseUrl() { return baseUrl; } - /** - * Starts the backend with a named configuration, seeding flag state to that configuration's - * baseline. - * - * @param config the configuration name - */ - public void start(String config) { - post("/start?config=" + config); - backendStopped = false; + @Override + public String description() { + return "HTTP control API at " + baseUrl; } /** - * Makes the backend unreachable without stopping its container. + * {@inheritDoc} + * + *

Prefers {@code POST /reset} when the backend is already running, because restoring the + * baseline without an availability blip means the previous scenario teardown cannot leak a + * spurious lifecycle event into the next scenario. When the previous scenario left the backend + * unreachable, {@code /reset} alone would not bring it back, so this falls through to + * {@code POST /start?config=...}. */ - public void stop() { - post("/stop"); - backendStopped = true; + @Override + public void prepareScenario() { + if (backendStopped) { + start(); + } else { + reset(); + } } /** - * Makes the backend unreachable for a bounded duration, then starts it again. - * - *

Flag state is preserved across the outage, so a provider observes an availability change - * and not a configuration change. + * {@inheritDoc} * - * @param seconds how long the backend stays unreachable + *

Issues {@code POST /change}. */ - public void restart(int seconds) { - post("/restart?seconds=" + seconds); - backendStopped = true; + @Override + public void changeFlag() { + post("/change"); } /** - * Puts the backend into the state every scenario starts from: running, with flag state at the - * baseline of the default configuration. + * {@inheritDoc} * - *

Prefers {@link #reset(String)} when the backend is already running, because restoring the - * baseline without an availability blip means the previous scenario's teardown cannot leak a - * spurious lifecycle event into the next scenario. When the previous scenario left the backend - * unreachable, {@code /reset} alone would not bring it back, so this falls through to - * {@link #start(String)}. - * - * @param defaultConfig the configuration name defining the baseline + *

Issues {@code POST /stop}, which makes the backend unreachable without stopping its + * container. */ - public void prepareScenario(String defaultConfig) { - if (backendStopped) { - start(defaultConfig); - } else { - reset(defaultConfig); - } + @Override + public void disconnect() { + post("/stop"); + backendStopped = true; } /** - * Mutates flag configuration so that a conforming provider observes a configuration change and - * resolves a different value for {@code changing-flag} afterwards. + * {@inheritDoc} + * + *

Issues {@code POST /start?config=...}, which also restores the baseline flag state. */ - public void change() { - post("/change"); + @Override + public void reconnect() { + start(); } /** - * Restores flag state to the seeded baseline for scenario isolation. - * - *

Prefers the optional {@code POST /reset}, which causes no availability blip. When the - * backend answers {@code 404} or {@code 501} the result is cached and every subsequent call - * falls back to {@code POST /start?config=...}, which resets state at the cost of a process - * restart. Both paths are conformant; see {@code openapi/control-api.yaml}. + * {@inheritDoc} * - * @param defaultConfig the configuration name to fall back to + *

Issues {@code POST /restart?seconds=...}. Flag state is preserved across the outage, so a + * provider observes an availability change and not a configuration change. The control API + * takes whole seconds, so a sub-second outage is rounded up to one second. */ - public void reset(String defaultConfig) { - if (Boolean.FALSE.equals(resetSupported)) { - start(defaultConfig); - return; - } - HttpResponse response = send("/reset"); - if (response.statusCode() == 404 || response.statusCode() == 501) { - if (resetSupported == null) { - log.info( - "Control API at {} does not implement POST /reset (HTTP {}); " - + "falling back to POST /start?config={} for scenario isolation.", - baseUrl, - response.statusCode(), - defaultConfig); - } - resetSupported = false; - start(defaultConfig); - return; - } - expectSuccess("/reset", response); - resetSupported = true; - settle(); + @Override + public void disconnectFor(Duration outage) { + int seconds = (int) Math.max(1, Math.ceil(outage.toMillis() / 1000.0)); + post("/restart?seconds=" + seconds); + backendStopped = true; } /** * Waits until the control API accepts commands. * - *

Probes the optional {@code GET /healthz}. A {@code 404} is a conformant answer meaning "not - * implemented", in which case readiness has already been established by the Testcontainers + *

Probes the optional {@code GET /healthz}. A {@code 404} is a conformant answer meaning + * "not implemented", in which case readiness has already been established by the Testcontainers * listening-port wait strategy and this returns immediately. * * @param timeout how long to keep probing @@ -185,6 +174,47 @@ public void awaitReady(Duration timeout) { throw new IllegalStateException("control API at " + baseUrl + " did not become ready within " + timeout, last); } + /** + * Starts the backend with the default configuration, seeding flag state to that configuration + * baseline. + */ + private void start() { + post("/start?config=" + defaultConfig); + backendStopped = false; + } + + /** + * Restores flag state to the seeded baseline without an availability blip. + * + *

{@code POST /reset} is optional. When the backend answers {@code 404} or {@code 501} the + * result is cached and every subsequent call falls back to {@code POST /start?config=...}, + * which resets state at the cost of a process restart. Both paths are conformant; see + * {@code openapi/control-api.yaml}. + */ + private void reset() { + if (Boolean.FALSE.equals(resetSupported)) { + start(); + return; + } + HttpResponse response = send("/reset"); + if (response.statusCode() == 404 || response.statusCode() == 501) { + if (resetSupported == null) { + log.info( + "Control API at {} does not implement POST /reset (HTTP {}); " + + "falling back to POST /start?config={} for scenario isolation.", + baseUrl, + response.statusCode(), + defaultConfig); + } + resetSupported = false; + start(); + return; + } + expectSuccess("/reset", response); + resetSupported = true; + settle(); + } + private void post(String path) { expectSuccess(path, send(path)); settle(); diff --git a/tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/ProviderTckHarness.java b/tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/ProviderTckHarness.java index fd28c9c2b9..a11f201627 100644 --- a/tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/ProviderTckHarness.java +++ b/tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/ProviderTckHarness.java @@ -1,96 +1,85 @@ package dev.openfeature.contrib.tools.providertck; import dev.openfeature.sdk.FeatureProvider; -import java.io.File; import java.time.Duration; -import java.util.Collections; -import java.util.List; -import java.util.Map; import java.util.Set; /** - * The complete contract a provider author implements to run the OpenFeature Provider TCK. + * The lifecycle-agnostic contract a provider author implements to run the OpenFeature Provider TCK. * - *

Four methods have no default and must be supplied. Everything else is a convention with a - * working default. If you find yourself needing to add lifecycle code, container handling or event - * plumbing to your implementation, that is a bug in the TCK's base class rather than something to - * work around here. + *

Two methods have no default: what provider to test, and what manipulates the backend it reads + * from. Everything else is a convention with a working default. Nothing here mentions containers, + * ports or HTTP — that belongs to {@link ContainerizedProviderTckTest}, which implements this + * interface in terms of a Compose stack. * - *

Implementations are discovered through {@link java.util.ServiceLoader}. Extend - * {@link AbstractProviderTckTest} — which implements this interface and carries all the Cucumber - * configuration — and register the concrete class in - * {@code META-INF/services/dev.openfeature.contrib.tools.providertck.ProviderTckHarness}. + *

Which base class to extend: * - *

Example — the entire adoption for a provider: + *

    + *
  • Your provider talks to an external backend — extend {@link ContainerizedProviderTckTest}. + * It brings the Compose lifecycle, port discovery and {@link HttpBackendControl}, and the + * HTTP control API stays the normative contract for your conformance claim. + *
  • Your provider has no backend (in-memory, environment variables, a local file) — extend + * {@link ProviderTckTest} directly and supply an in-process {@link BackendControl}. + *
+ * + *

Implementations are discovered through the executing JUnit suite, and through + * {@link java.util.ServiceLoader} as a fallback. Extend one of the two base classes — each is both + * the JUnit suite and the harness — and no registration is needed. + * + *

Example — the entire adoption for a backend-less provider: * *

{@code
- * public class MyProviderTckTest extends AbstractProviderTckTest {
+ * public class MyProviderTckTest extends ProviderTckTest {
  *
- *     @Override
- *     public File composeFile() {
- *         return new File("src/test/resources/tck/docker-compose.yaml");
- *     }
+ *     private final MyInProcessControl control = new MyInProcessControl();
  *
  *     @Override
- *     public List backendPorts() {
- *         return Collections.singletonList(8013);
+ *     public BackendControl backendControl() {
+ *         return control;
  *     }
  *
  *     @Override
- *     public FeatureProvider createProvider(BackendEndpoint endpoint) {
- *         return new MyProvider(endpoint.host(), endpoint.port(8013));
+ *     public FeatureProvider createProvider() {
+ *         return control.createProvider();
  *     }
  *
  *     @Override
- *     public FeatureProvider createUnavailableProvider() {
- *         return new MyProvider("localhost", 9999);
+ *     public Set capabilities() {
+ *         return EnumSet.of(Capability.EVENTS, Capability.CONFIGURATION_CHANGE, Capability.OBJECT);
  *     }
  * }
  * }
+ * + * @see ProviderTckTest + * @see ContainerizedProviderTckTest */ public interface ProviderTckHarness { /** - * Returns the Docker Compose file describing the backend stack under test. + * Creates the provider under test, configured against a backend that is already running and + * seeded with the canonical flag set. * - *

The path is resolved relative to the Maven module directory, so - * {@code new File("src/test/resources/tck/docker-compose.yaml")} is the idiomatic form. - * - *

The stack is started once before the first scenario and stopped after the last one. It is - * never restarted in between — see {@link #createProvider(BackendEndpoint)} and the - * no-container-restart invariant documented in {@code openapi/control-api.yaml}. The stack must - * not pin host ports. - * - * @return the Compose file describing the backend stack - */ - File composeFile(); - - /** - * Returns the container-internal ports on {@link #backendService()} that the provider connects - * to, so Testcontainers can expose and map them. + *

Called once per scenario. This is a factory rather than a field because a provider cannot + * always be configured before the suite starts — a Compose stack's host ports do not exist + * until it is up — and because each scenario gets its own provider instance. * - *

The control API port from {@link #controlPort()} is exposed automatically and does not - * need to be listed here. + *

The TCK owns the provider lifecycle from here: it registers the provider with the + * OpenFeature API under a scenario-scoped domain, waits for it to become ready, and shuts it + * down afterwards. Do not call {@code setProvider} or {@code initialize} yourself. * - * @return container-internal ports the provider connects to + * @return a configured, uninitialised provider */ - List backendPorts(); + FeatureProvider createProvider(); /** - * Creates the provider under test, configured against the running backend. + * Returns the seam through which the TCK manipulates the backend. * - *

Called after the Compose stack is up and the control API has seeded the canonical flag - * set. The endpoint carries the dynamically mapped host ports, which is why this is a factory - * rather than a field: the ports do not exist until the stack has started. - * - *

The TCK owns the provider lifecycle from here — it registers the provider with the - * OpenFeature API under a scenario-scoped domain, waits for it to become ready, and shuts it - * down afterwards. Do not call {@code setProvider} or {@code initialize} yourself. + *

Called after {@link #startSuite()}, so an implementation may build it there and return the + * same instance on every call. It must not be {@code null}. * - * @param endpoint host and mapped ports of the running backend stack - * @return a configured, uninitialised provider + * @return the backend control for this suite */ - FeatureProvider createProvider(BackendEndpoint endpoint); + BackendControl backendControl(); /** * Creates a provider pointed at a backend that does not exist. @@ -99,15 +88,26 @@ public interface ProviderTckHarness { * reach its backend settles into {@code ERROR} and emits {@code PROVIDER_ERROR} rather than * hanging or throwing out of {@code setProvider}. * - *

Point this at a closed port on localhost. Do not point it at the Compose stack — the stack - * must stay up and reachable, and simulated outages belong to the control API. + *

Point this at a closed port on localhost. Do not point it at the backend under test — that + * must stay up and reachable, and simulated outages belong to {@link BackendControl}. * *

Configure a short connection deadline. The scenario allows a bounded time for the error * event to arrive, and a provider with a 30-second connect timeout will not make it. * + *

Defaults to throwing, because a provider with no backend has no way to be unreachable. + * Such a harness leaves {@link Capability#UNAVAILABLE_INIT} undeclared and the scenarios that + * would call this are reported as skipped, so the default is never reached. Reaching it means a + * capability was declared that the harness cannot back up. + * * @return a configured provider that cannot reach a backend */ - FeatureProvider createUnavailableProvider(); + default FeatureProvider createUnavailableProvider() { + throw new UnsupportedOperationException(getClass().getName() + " does not implement " + + "createUnavailableProvider(). This is a test-configuration bug rather than a provider " + + "defect: an @unavailable scenario ran, so the harness declared " + + "Capability.UNAVAILABLE_INIT without supplying a provider that cannot reach its " + + "backend. Remove that capability, or implement this method."); + } /** * Declares which optional parts of the provider contract this provider supports. @@ -131,53 +131,27 @@ default Set capabilities() { } /** - * Returns the Compose service name that hosts the control API and the backend the provider - * connects to. - * - * @return the Compose service name, {@code backend} by default - */ - default String backendService() { - return "backend"; - } - - /** - * Returns the container-internal port the control API listens on. - * - * @return the control API port, {@code 8080} by default - */ - default int controlPort() { - return 8080; - } - - /** - * Returns extra services and container-internal ports to expose, for stacks that contain more - * than the backend service. - * - *

Keys are Compose service names, values are container-internal ports. Resolve the mapped - * ports with {@link BackendEndpoint#port(String, int)}. + * Prepares whatever must exist before the first scenario — a container stack, a temporary + * directory, a local server. * - * @return additional services and ports to expose, empty by default - */ - default Map> additionalExposedPorts() { - return Collections.emptyMap(); - } - - /** - * Returns the control API configuration name used to seed the canonical flag set. + *

Called once, before any scenario, and always paired with {@link #stopSuite()}. Defaults to + * doing nothing, which is right for a harness whose backend is a data structure in this JVM. * - * @return the configuration name passed to {@code POST /start}, {@code default} by default + *

{@link #backendControl()} is called immediately afterwards, so this is where to build it + * if it needs something that only exists once the suite has started. */ - default String defaultConfig() { - return "default"; + default void startSuite() { + // Nothing to start by default. } /** - * Returns how long to wait for the Compose stack to become reachable. + * Releases whatever {@link #startSuite()} created. * - * @return the stack startup timeout, 60 seconds by default + *

Called once, after the last scenario, and also if suite startup fails partway through, so + * it must tolerate being called when startup did not complete. */ - default Duration startupTimeout() { - return Duration.ofSeconds(60); + default void stopSuite() { + // Nothing to stop by default. } /** @@ -189,8 +163,8 @@ default Duration startupTimeout() { * interval before it notices. Set this to comfortably exceed your worst-case detection latency, * or the suite will report timeouts that are really just impatience. * - *

Individual scenarios can tighten this with the explicit - * {@code within {int}ms} step, which always wins over this value. + *

Individual scenarios can tighten this with the explicit {@code within {int}ms} step, which + * always wins over this value. * * @return the default event await timeout, 12 seconds by default */ @@ -206,17 +180,4 @@ default Duration eventTimeout() { default Duration readyTimeout() { return Duration.ofSeconds(30); } - - /** - * Returns how long to pause after a control API call before continuing. - * - *

Covers the gap between the control API acknowledging a command and the backend actually - * having acted on it. Raise it if you see flakiness immediately after - * {@code the flag was modified} or a provider setup step. - * - * @return the settle time, 50 milliseconds by default - */ - default Duration settleTime() { - return Duration.ofMillis(50); - } } diff --git a/tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/AbstractProviderTckTest.java b/tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/ProviderTckTest.java similarity index 52% rename from tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/AbstractProviderTckTest.java rename to tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/ProviderTckTest.java index 4f3304d94d..6083afc56f 100644 --- a/tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/AbstractProviderTckTest.java +++ b/tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/ProviderTckTest.java @@ -13,27 +13,38 @@ * infrastructure at all. The canonical feature files are packaged inside this JAR and selected from * the classpath, so consumers need no git submodule of their own. * - *

To adopt the TCK, extend this class, implement the four abstract methods of - * {@link ProviderTckHarness}, and register the concrete class in - * {@code src/test/resources/META-INF/services/dev.openfeature.contrib.tools.providertck.ProviderTckHarness}. + *

Which base class to extend

+ * + *

This one is lifecycle-agnostic: it starts nothing and knows nothing about how the backend is + * reached. Extend it directly when your provider has no backend — an in-memory, + * environment-variable or file-based provider — and supply an in-process {@link BackendControl}. + * + *

When your provider talks to an external backend, extend {@link ContainerizedProviderTckTest} + * instead. It adds the Compose stack lifecycle, port discovery and {@link HttpBackendControl}, and + * the HTTP control API in {@code openapi/control-api.yaml} remains the normative contract for that + * conformance claim. In-process control is for backend-less providers only; an external backend + * driven through a custom in-JVM {@code BackendControl} bypasses that contract and proves nothing. + * + *

Serial execution

* *

Scenarios run serially, and this class enforces that rather than merely - * asking for it. Control API state — which flags are seeded, whether the backend is reachable — is - * global to the Compose stack, so concurrent scenarios corrupt each other: one scenario's - * {@code /start} restarts the backend underneath another's disconnect assertion. The failure looks - * like a flaky provider rather than a broken test, which makes it expensive to diagnose. + * asking for it. Backend state — which flags are seeded, whether the backend is reachable — is + * global to the suite, so concurrent scenarios corrupt each other: one scenario's reconnect + * restarts the backend underneath another's disconnect assertion. The failure looks like a flaky + * provider rather than a broken test, which makes it expensive to diagnose. * *

The suite therefore pins {@code cucumber.execution.parallel.enabled=false} here, where it * overrides any {@code junit-platform.properties} the consuming module happens to ship. Several * providers already enable Cucumber parallelism for their own suites, and inheriting that setting * silently breaks the TCK. * - *

Note this class carries no lifecycle code. The Compose stack, the control API client, provider - * registration and event awaiting are all owned by the step definitions in - * {@code dev.openfeature.contrib.tools.providertck.steps}, which reach the harness through - * {@link TckRuntime}. + *

Note this class carries no lifecycle code of its own. Provider registration, event awaiting + * and backend manipulation are owned by the step definitions in + * {@code dev.openfeature.contrib.tools.providertck.steps}, which reach the harness and its + * {@link BackendControl} through {@link TckRuntime}. * * @see ProviderTckHarness + * @see ContainerizedProviderTckTest */ @Suite @IncludeEngines("cucumber") @@ -43,4 +54,4 @@ @ConfigurationParameter(key = Constants.EXECUTION_MODE_FEATURE_PROPERTY_NAME, value = "same_thread") @ConfigurationParameter(key = Constants.GLUE_PROPERTY_NAME, value = "dev.openfeature.contrib.tools.providertck.steps") @ConfigurationParameter(key = Constants.OBJECT_FACTORY_PROPERTY_NAME, value = "io.cucumber.picocontainer.PicoFactory") -public abstract class AbstractProviderTckTest implements ProviderTckHarness {} +public abstract class ProviderTckTest implements ProviderTckHarness {} diff --git a/tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/TckRuntime.java b/tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/TckRuntime.java index 3755dc0c95..e5f5e7feeb 100644 --- a/tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/TckRuntime.java +++ b/tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/TckRuntime.java @@ -1,37 +1,31 @@ package dev.openfeature.contrib.tools.providertck; -import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; -import java.io.File; import java.util.ArrayList; import java.util.List; -import java.util.Map; import java.util.Optional; import java.util.ServiceLoader; import java.util.stream.Collectors; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import org.testcontainers.containers.ComposeContainer; -import org.testcontainers.containers.wait.strategy.Wait; /** - * Suite-scoped runtime: discovers the provider's harness, owns the Compose stack, and exposes the - * control API client to the step definitions. + * Suite-scoped runtime: discovers the provider's harness, drives its suite lifecycle, and exposes + * its {@link BackendControl} to the step definitions. * - *

The Compose stack is started once, before the first scenario, and stopped - * after the last one. It is never stopped or restarted in between. Testcontainers cannot reliably - * preserve dynamically mapped host ports across a container restart, so a restart would silently - * invalidate every provider already pointed at the old port. Backend unavailability is therefore - * always simulated inside the running stack through the control API. See the normative statement of - * this invariant in {@code openapi/control-api.yaml}. + *

This class knows nothing about containers, ports or transports. Whatever must exist before the + * first scenario is created by {@link ProviderTckHarness#startSuite()} and released by + * {@link ProviderTckHarness#stopSuite()} — a Compose stack for + * {@link ContainerizedProviderTckTest}, nothing at all for a harness whose backend is a data + * structure in this JVM. + * + *

The lifecycle runs once: started before the first scenario, stopped after the + * last one, never cycled in between. Scenario isolation is achieved through + * {@link BackendControl#prepareScenario()} instead. * *

State is static because Cucumber's {@code @BeforeAll} / {@code @AfterAll} hooks are static and - * the stack must outlive individual scenarios. Consequently only one TCK suite may run per JVM fork - * at a time. + * the runtime must outlive individual scenarios. Consequently only one TCK suite may run per JVM + * fork at a time. */ -@SuppressFBWarnings( - value = "EI_EXPOSE_REP", - justification = "The harness and control API client are shared collaborators by design; " - + "step definitions must act on the same instances the suite started") public final class TckRuntime { private static final Logger log = LoggerFactory.getLogger(TckRuntime.class); @@ -42,46 +36,57 @@ public final class TckRuntime { private static TckRuntime instance; private final ProviderTckHarness harness; - private final ComposeContainer compose; - private final ControlApiClient controlApi; - private final BackendEndpoint endpoint; + private final BackendControl backendControl; - private TckRuntime(ProviderTckHarness harness, ComposeContainer compose) { + private TckRuntime(ProviderTckHarness harness, BackendControl backendControl) { this.harness = harness; - this.compose = compose; - this.endpoint = new BackendEndpoint(compose, harness.backendService()); - String baseUrl = "http://" + compose.getServiceHost(harness.backendService(), null) + ":" - + compose.getServicePort(harness.backendService(), harness.controlPort()); - this.controlApi = new ControlApiClient(baseUrl, harness.settleTime()); + this.backendControl = backendControl; } /** - * Starts the Compose stack if it is not already running, and returns the shared runtime. + * Starts the suite lifecycle if it is not already running, and returns the shared runtime. * * @return the suite-scoped runtime */ public static synchronized TckRuntime startIfNeeded() { - if (instance == null) { - ProviderTckHarness harness = discoverHarness(); - log.info("Provider TCK harness: {}", harness.getClass().getName()); - // Checked before the Compose stack goes up, rather than only where the declaration - // reaches the report: an adopter should not wait for Docker to be told about a - // one-line mistake in capabilities(). - Capability.requireDeclarable(harness.capabilities()); - instance = new TckRuntime(harness, startCompose(harness)); - instance.controlApi.awaitReady(harness.startupTimeout()); - log.info("Control API ready at {}", instance.controlApi.baseUrl()); + if (instance != null) { + return instance; + } + ProviderTckHarness harness = discoverHarness(); + log.info("Provider TCK harness: {}", harness.getClass().getName()); + + // Checked before the suite lifecycle starts, rather than only where the declaration + // reaches the report: an adopter should not wait for Docker to be told about a + // one-line mistake in capabilities(). + Capability.requireDeclarable(harness.capabilities()); + + harness.startSuite(); + try { + BackendControl control = harness.backendControl(); + if (control == null) { + throw new IllegalStateException(harness.getClass().getName() + + ".backendControl() returned null. Every harness must supply the seam through " + + "which the TCK manipulates the backend — HttpBackendControl for an external " + + "backend, an in-process implementation for a provider that has none."); + } + log.info("Backend control: {}", control.description()); + instance = new TckRuntime(harness, control); + } catch (RuntimeException e) { + // startSuite() may have allocated a container stack before this failed. + harness.stopSuite(); + throw e; } return instance; } /** - * Stops the Compose stack and releases the shared runtime. + * Runs the harness's suite teardown and releases the shared runtime. */ public static synchronized void stop() { if (instance != null) { - instance.compose.stop(); + ProviderTckHarness harness = instance.harness; instance = null; + harness.stopSuite(); } } @@ -89,7 +94,7 @@ public static synchronized void stop() { * Returns the running runtime. * * @return the suite-scoped runtime - * @throws IllegalStateException if the stack has not been started + * @throws IllegalStateException if the suite has not been started */ public static synchronized TckRuntime get() { if (instance == null) { @@ -108,46 +113,12 @@ public ProviderTckHarness harness() { } /** - * Returns the client for the backend's control API. + * Returns the seam through which the TCK manipulates the backend. * - * @return the control API client + * @return the backend control for this suite */ - public ControlApiClient controlApi() { - return controlApi; - } - - /** - * Returns the host and mapped ports of the running stack. - * - * @return the backend endpoint - */ - public BackendEndpoint endpoint() { - return endpoint; - } - - private static ComposeContainer startCompose(ProviderTckHarness harness) { - File composeFile = harness.composeFile(); - if (!composeFile.isFile()) { - throw new IllegalStateException("Compose file not found: " + composeFile.getAbsolutePath() - + ". ProviderTckHarness.composeFile() is resolved relative to the module directory."); - } - ComposeContainer compose = new ComposeContainer(composeFile); - - compose.withExposedService(harness.backendService(), harness.controlPort(), Wait.forListeningPort()); - for (Integer port : harness.backendPorts()) { - compose.withExposedService(harness.backendService(), port, Wait.forListeningPort()); - } - for (Map.Entry> service : - harness.additionalExposedPorts().entrySet()) { - for (Integer port : service.getValue()) { - compose.withExposedService(service.getKey(), port, Wait.forListeningPort()); - } - } - compose.withStartupTimeout(harness.startupTimeout()); - - log.info("Starting Compose stack {} (started once per suite, never restarted)", composeFile.getAbsolutePath()); - compose.start(); - return compose; + public BackendControl backendControl() { + return backendControl; } /** @@ -173,7 +144,8 @@ private static ProviderTckHarness discoverHarness() { if (found.isEmpty()) { throw new IllegalStateException("No ProviderTckHarness found. Write a test class extending " - + "AbstractProviderTckTest; it is both the JUnit suite and the harness."); + + "ContainerizedProviderTckTest (external backend) or ProviderTckTest (no backend); " + + "it is both the JUnit suite and the harness."); } if (found.size() == 1) { return found.get(0); diff --git a/tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/steps/AbstractSteps.java b/tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/steps/AbstractSteps.java index 526068cb4f..6a3ff9cf66 100644 --- a/tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/steps/AbstractSteps.java +++ b/tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/steps/AbstractSteps.java @@ -1,5 +1,6 @@ package dev.openfeature.contrib.tools.providertck.steps; +import dev.openfeature.contrib.tools.providertck.BackendControl; import dev.openfeature.contrib.tools.providertck.ProviderTckHarness; import dev.openfeature.contrib.tools.providertck.TckRuntime; import dev.openfeature.contrib.tools.providertck.TckState; @@ -7,8 +8,13 @@ /** * Base for the TCK step definition classes. * - *

Holds the PicoContainer-injected scenario state and gives subclasses convenience access to the - * suite-scoped runtime. + *

Holds the PicoContainer-injected scenario state and gives subclasses the only two collaborators + * a step is allowed to reach: the provider author's harness, and the {@link BackendControl} that + * manipulates the backend. + * + *

Deliberately no accessor for the runtime itself. Steps must not know whether the backend is a + * container reached over HTTP or a map in this JVM — that is exactly what {@link BackendControl} + * exists to hide, and it is what lets the same Gherkin run in both modes. */ public abstract class AbstractSteps { @@ -20,20 +26,20 @@ protected AbstractSteps(TckState state) { } /** - * Returns the suite-scoped runtime that owns the Compose stack and control API. + * Returns the provider author's harness. * - * @return the running TCK runtime + * @return the discovered harness */ - protected TckRuntime runtime() { - return TckRuntime.get(); + protected ProviderTckHarness harness() { + return TckRuntime.get().harness(); } /** - * Returns the provider author's harness. + * Returns the seam through which the backend is manipulated. * - * @return the discovered harness + * @return the backend control for this suite */ - protected ProviderTckHarness harness() { - return TckRuntime.get().harness(); + protected BackendControl backend() { + return TckRuntime.get().backendControl(); } } diff --git a/tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/steps/ProviderSteps.java b/tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/steps/ProviderSteps.java index 8fe2394143..35652f943c 100644 --- a/tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/steps/ProviderSteps.java +++ b/tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/steps/ProviderSteps.java @@ -19,6 +19,7 @@ import io.cucumber.java.en.Given; import io.cucumber.java.en.Then; import io.cucumber.java.en.When; +import java.time.Duration; import java.util.Optional; import java.util.Set; import java.util.UUID; @@ -27,9 +28,13 @@ import org.slf4j.LoggerFactory; /** - * Lifecycle and control API steps: bringing the Compose stack up, gating scenarios on declared + * Lifecycle and backend-control steps: bringing the suite up, gating scenarios on declared * capabilities, creating and registering the provider under test, and simulating backend outages. * + *

Every step that touches the backend goes through {@link #backend()}. Nothing here knows whether + * that is a container driven over HTTP or an in-memory provider manipulated directly, which is what + * lets one set of feature files cover both. + * *

Step vocabulary is inherited from the flagd test harness so that existing feature files port * with a near-zero diff. The only change is dropping the word {@code flagd} from the provider setup * step: {@code a stable flagd provider} becomes {@code a stable provider}. @@ -43,7 +48,7 @@ public ProviderSteps(TckState state) { } /** - * Starts the Compose stack once, before the first scenario. + * Runs the harness's suite startup once, before the first scenario. */ @BeforeAll public static void beforeAll() { @@ -51,7 +56,7 @@ public static void beforeAll() { } /** - * Stops the Compose stack after the last scenario. + * Runs the harness's suite teardown after the last scenario. */ @AfterAll public static void afterAll() { @@ -66,6 +71,11 @@ public static void afterAll() { * configuration-change events should see those scenarios visibly excluded, never silently * green. * + *

This is also how a backend with no connection to lose stays honest. A harness whose + * {@link dev.openfeature.contrib.tools.providertck.BackendControl} cannot simulate an outage + * leaves {@link Capability#STALE} and {@link Capability#UNAVAILABLE_INIT} undeclared, and the + * scenarios needing them are skipped here — before any step can reach an unsupported operation. + * * @param scenario the scenario about to run */ @Before(order = 0) @@ -81,19 +91,18 @@ public void gateOnCapabilities(Scenario scenario) { } /** - * Restores the backend to a running, freshly seeded state before each scenario. + * Restores the backend to the state every scenario starts from. * - *

Scenario isolation is achieved here, through the control API, and never by restarting - * containers — see the no-container-restart invariant in {@code openapi/control-api.yaml}. + *

Scenario isolation is achieved here and nowhere else — never by restarting containers, and + * never by relying on scenarios happening not to interfere. */ @Before(order = 10) public void prepareBackend() { - ProviderTckHarness harness = harness(); - runtime().controlApi().prepareScenario(harness.defaultConfig()); + backend().prepareScenario(); } /** - * Tears the provider down without disturbing the Compose stack. + * Tears the provider down without disturbing the backend. * *

Replaces the domain's provider with a {@link NoOpProvider} through the SDK lifecycle rather * than calling {@code shutdown()} directly, because only the former makes the SDK detach the @@ -111,10 +120,10 @@ public void tearDown() { * Creates the provider under test and registers it under a scenario-scoped domain. * *

Two provider flavours are recognised. A {@code stable} provider is built by the harness - * against the running stack and registered with {@code setProviderAndWait}, so the step does not - * return until the provider is ready. An {@code unavailable} provider points at a dead backend - * and is registered with {@code setProvider}, deliberately without waiting — the scenario's - * whole point is that readiness never arrives. + * against the running backend and registered with {@code setProviderAndWait}, so the step does + * not return until the provider is ready. An {@code unavailable} provider points at a dead + * backend and is registered with {@code setProvider}, deliberately without waiting — the + * scenario's whole point is that readiness never arrives. * * @param flavour either {@code stable} or {@code unavailable} */ @@ -126,7 +135,7 @@ public void createProvider(String flavour) { switch (flavour) { case "stable": - provider = harness.createProvider(runtime().endpoint()); + provider = harness.createProvider(); waitForReady = true; break; case "unavailable": @@ -161,7 +170,7 @@ public void createProvider(String flavour) { */ @When("the connection is lost") public void theConnectionIsLost() { - runtime().controlApi().stop(); + backend().disconnect(); } /** @@ -171,7 +180,7 @@ public void theConnectionIsLost() { */ @When("the connection is lost for {int}s") public void theConnectionIsLostFor(int seconds) { - runtime().controlApi().restart(seconds); + backend().disconnectFor(Duration.ofSeconds(seconds)); } /** @@ -184,7 +193,7 @@ public void theConnectionIsLostFor(int seconds) { */ @When("the connection is restored") public void theConnectionIsRestored() { - runtime().controlApi().start(harness().defaultConfig()); + backend().reconnect(); } /** @@ -192,7 +201,7 @@ public void theConnectionIsRestored() { */ @When("the flag was modified") public void theFlagWasModified() { - runtime().controlApi().change(); + backend().changeFlag(); } /** From 8b02b84322ae4e492225f6179f4dc77461315a9b Mon Sep 17 00:00:00 2001 From: Simon Schrottner Date: Mon, 24 Aug 2026 11:19:18 +0200 Subject: [PATCH 06/55] feat(provider-tck): in-process backend control and an in-memory self-test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Providers without an external backend — in-memory, environment-variable, file-based — could not run the TCK: every path to the backend went through Docker, Compose and HTTP. Add the in-process control path so they can, and use it to give the TCK a self-test. InProcessBackendControl manipulates the SDK's InMemoryProvider directly. Flag operations are map updates and a configuration change is updateFlag(), so the event the suite awaits is the provider's own PROVIDER_CONFIGURATION_CHANGED rather than one the TCK synthesised. It is deliberately bound to InMemoryProvider and deliberately not a general-purpose escape hatch: an external backend driven through a side channel bypasses the HTTP control API, which is the only thing that makes a conformance claim portable across languages. The README and the BackendControl javadoc say so explicitly. Connection control is modelled through the existing capability mechanism rather than no-op stubs. disconnect(), reconnect() and disconnectFor() are left at their throwing defaults, and the harness leaves STALE and UNAVAILABLE_INIT undeclared, so those scenarios are reported as skipped-with-reason. Over-declaring a capability the control cannot back fails loudly with a message naming the fix — an UnsupportedOperationException reached from a live scenario is a test-configuration bug, never a skip. InProcessBackendControlTest pins that, because a scenario that never runs cannot prove it would have failed. InMemoryProviderTckTest runs the full applicable suite against InMemoryProvider: 26 passed, 3 skipped by capability, no Docker, under a second. It is both the reference adoption for a backend-less provider and a CI canary that reports a broken step definition or capability gate in seconds — wired as its own Docker-free job alongside the existing matrix, which is unchanged. Signed-off-by: Simon Schrottner --- .github/workflows/ci.yml | 36 +++ tools/provider-tck/README.md | 116 ++++++++- tools/provider-tck/pom.xml | 14 ++ .../tools/providertck/BackendControl.java | 13 +- .../contrib/tools/providertck/Capability.java | 15 ++ .../providertck/InProcessBackendControl.java | 226 ++++++++++++++++++ .../tools/providertck/ProviderTckHarness.java | 2 +- .../tools/providertck/ProviderTckTest.java | 3 +- .../contrib/tools/providertck/TckRuntime.java | 2 +- .../providertck/InMemoryProviderTckTest.java | 81 +++++++ .../InProcessBackendControlTest.java | 104 ++++++++ 11 files changed, 596 insertions(+), 16 deletions(-) create mode 100644 tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/InProcessBackendControl.java create mode 100644 tools/provider-tck/src/test/java/dev/openfeature/contrib/tools/providertck/InMemoryProviderTckTest.java create mode 100644 tools/provider-tck/src/test/java/dev/openfeature/contrib/tools/providertck/InProcessBackendControlTest.java diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2fbdc4604a..769d4216de 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,6 +9,42 @@ on: - main jobs: + # Fast canary for the Provider TCK: runs the full applicable conformance suite against the + # SDK's InMemoryProvider with no Docker, no Compose stack and no network. It finishes in + # seconds, so a broken step definition, a mis-wired capability gate or a regression in the + # shared harness is reported long before the containerised provider suites in `main` get + # there — and it points at the TCK rather than at whichever provider noticed first. + # + # Deliberately not a gate on `main`: the two run in parallel so a green run is not delayed. + # The same suite also runs inside `main` as part of the reactor build; this job exists to + # report it fast and in isolation. + provider-tck: + name: Provider TCK (no Docker) + runs-on: ubuntu-latest + steps: + - name: Checkout Repository + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5 + # No submodules: this module's feature files, flags and control-API spec are in-repo. + + - name: Set up JDK 21 + uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5 + with: + java-version: 21 + distribution: 'temurin' + cache: maven + + - name: Cache local Maven repository + uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4 + with: + path: ~/.m2/repository + key: ${{ runner.os }}21-maven-${{ hashFiles('**/pom.xml') }} + restore-keys: | + ${{ runner.os }}21-maven- + + - name: Verify the TCK against the in-memory provider + # No `e2e` profile and no Docker: the in-memory suite is not gated behind either. + run: mvn --batch-mode --activate-profiles codequality -pl tools/provider-tck -am clean verify + main: strategy: matrix: diff --git a/tools/provider-tck/README.md b/tools/provider-tck/README.md index 497d574e9d..ed88aff5d9 100644 --- a/tools/provider-tck/README.md +++ b/tools/provider-tck/README.md @@ -23,7 +23,8 @@ contract" is an unverified claim. This is the shared suite that makes it checkab ``` -Requires Java 11+, JUnit 5, and a working Docker daemon. +Requires Java 11+ and JUnit 5. A working Docker daemon is needed only for providers with an +external backend — see [Which base class to extend](#which-base-class-to-extend). ### OpenFeature SDK compatibility @@ -55,8 +56,94 @@ uses only long-stable API — `OpenFeatureAPI`, `Client`, typed evaluation, `Pro - the provider↔backend wire protocol. How you talk to your backend is your business. - SDK behaviour. That belongs to the SDK's own test suite. +## Which base class to extend + +Two, and the choice is made by one question: **does your provider talk to something outside the +JVM?** + +| | Extend | Backend control | You supply | +|---|---|---|---| +| Provider has an external backend | `ContainerizedProviderTckTest` | `HttpBackendControl`, over the HTTP control API | a Compose stack, a control API, a test class | +| Provider has no backend — in-memory, environment variables, a local file | `ProviderTckTest` | an in-process `BackendControl` | a test class | + +`ContainerizedProviderTckTest` is the normal case and everything in [Adopting it](#adopting-it) +below describes it. It extends `ProviderTckTest` and adds the Compose lifecycle, port discovery and +control API client on top. + +### In-process control is for backend-less providers only + +Step definitions never touch a backend directly. They go through one interface, `BackendControl`, +which is what lets the same Gherkin run against a container over HTTP and against an in-memory +provider manipulated in the same JVM. + +That seam is not an invitation to skip the control API. **If your provider has an external backend, +use `HttpBackendControl` via `ContainerizedProviderTckTest`.** The control API described in +[`openapi/control-api.yaml`](src/main/resources/openapi/control-api.yaml) is the normative contract +for those providers, and it is the whole basis of a portable conformance claim: another language's +TCK drives the same endpoints against the same stack and must get the same answers. + +A custom in-JVM `BackendControl` that reaches an external backend through a side channel — a +test-only admin client, a shared database handle, a static hook inside the provider — bypasses that +contract. It will pass, and it will prove nothing, because the path it exercised is not the path the +contract describes. + +In-process control exists for providers that have **nothing to contract with**, where "the backend" +is a data structure in the same JVM. For those, flag operations are map updates and a configuration +change is the provider's own update mechanism emitting its own event. + +### Adopting it without a backend + +`InProcessBackendControl` implements this for the SDK's `InMemoryProvider`, seeded with the +canonical flag set. The entire adoption is three methods: + +```java +public class MyProviderTckTest extends ProviderTckTest { + + private final InProcessBackendControl control = new InProcessBackendControl(); + + @Override + public BackendControl backendControl() { + return control; + } + + @Override + public FeatureProvider createProvider() { + return control.createProvider(); + } + + @Override + public Set capabilities() { + return EnumSet.of( + Capability.EVENTS, + Capability.CONFIGURATION_CHANGE, + Capability.OBJECT, + Capability.NUMERIC_COERCION); + } +} +``` + +One object backs both factory methods because in-process the flag store and the provider are the +same thing: `changeFlag()` has to reach the live provider instance to emit an event from it. + +**Connection control does not apply**, and the capability declaration is where you say so rather +than stubbing it out. An in-memory provider has no connection to lose, so +`InProcessBackendControl` leaves `disconnect()`, `reconnect()` and `disconnectFor()` unimplemented — +they throw. Leaving `STALE` and `UNAVAILABLE_INIT` out of `capabilities()` is what keeps that +honest: the scenarios needing them are skipped before any step can reach an unsupported operation. + +Get that pairing wrong — declare `STALE` against a control that cannot disconnect — and you get an +`UnsupportedOperationException` naming the fix, not a silent pass. That is deliberate. A +`BackendControl` may throw `UnsupportedOperationException` for operations it does not support, and +reaching one from a scenario that actually ran is a **test-configuration bug**, never a skip. + +The TCK's own self-test is exactly this class: see +[`InMemoryProviderTckTest`](src/test/java/dev/openfeature/contrib/tools/providertck/InMemoryProviderTckTest.java), +which runs the full applicable suite against `InMemoryProvider` with no Docker in well under a +second. It doubles as the reference adoption and as the Docker-free CI canary. + ## Adopting it +This section describes a provider with an external backend — the common case. Four things to implement, then two small files. ### 1. A Docker Compose stack @@ -130,7 +217,7 @@ Two details are load-bearing: ### 4. The test class ```java -public class MyProviderTckTest extends AbstractProviderTckTest { +public class MyProviderTckTest extends ContainerizedProviderTckTest { @Override public File composeFile() { @@ -172,7 +259,7 @@ registration, no system property, no build configuration. Each class is its own own Compose stack, and they can share a base class: ```java -abstract class AbstractMyProviderTckTest extends AbstractProviderTckTest { +abstract class AbstractMyProviderTckTest extends ContainerizedProviderTckTest { protected abstract Mode mode(); // composeFile(), createProvider(), capabilities() ... shared here } @@ -219,10 +306,10 @@ green on scenarios it did not run is worse than no suite at all. |---|---|---| | `LIFECYCLE` | `@lifecycle` | performs an initialisation that reaches its backend, with an observable outcome | | `EVENTS` | `@events` | emits lifecycle events at all | -| `STALE` | `@stale` | enters `STALE` and emits `PROVIDER_STALE` on backend loss | +| `STALE` | `@stale` | enters `STALE` and emits `PROVIDER_STALE` on backend loss — *needs connection control* | | `CONFIGURATION_CHANGE` | `@configuration-change` | detects config changes, emits `PROVIDER_CONFIGURATION_CHANGED` | | `OBJECT` | `@object` | supports structured flag values | -| `UNAVAILABLE_INIT` | `@unavailable` | reports an error state instead of hanging on a dead backend | +| `UNAVAILABLE_INIT` | `@unavailable` | reports an error state instead of hanging on a dead backend — *needs connection control* | | `NUMERIC_COERCION` | `@numeric-coercion` | coerces between integer and float only when lossless, else `TYPE_MISMATCH` | | `TARGETING` | `@targeting` | reserved, **not declarable** — no scenarios yet | | `CACHING` | `@caching` | reserved, **not declarable** — no scenarios yet | @@ -230,6 +317,12 @@ green on scenarios it did not run is worse than no suite at all. The default is every *declarable* capability. **Narrow it, do not widen it**: start from the default, run the suite, and remove only what your provider genuinely cannot do. +`STALE` and `UNAVAILABLE_INIT` are the two that need a backend the provider can be cut off from. +They are what a backend-less provider leaves undeclared — see +[In-process control is for backend-less providers only](#in-process-control-is-for-backend-less-providers-only). +Declaring one against a `BackendControl` that cannot simulate an outage fails the scenario with an +`UnsupportedOperationException` naming the fix, rather than passing it. + ```java @Override public Set capabilities() { @@ -290,8 +383,8 @@ needs most of a poll interval. Every await timeout is therefore overridable. |---|---|---| | `eventTimeout()` | 12s | waiting for a provider event | | `readyTimeout()` | 30s | waiting for a provider to reach a lifecycle state | -| `startupTimeout()` | 60s | bringing the Compose stack up | -| `settleTime()` | 50ms | pause after a control API call | +| `startupTimeout()` | 60s | bringing the Compose stack up (`ContainerizedProviderTckTest` only) | +| `settleTime()` | 50ms | pause after a control API call (`ContainerizedProviderTckTest` only) | ```java @Override @@ -310,6 +403,9 @@ use the explicit `within {int}ms` step, which always wins. mvn test -Dtest=MyProviderTckTest ``` +A suite extending `ProviderTckTest` with in-process control needs no Docker and no network. A suite +extending `ContainerizedProviderTckTest` needs a working Docker daemon for its Compose stack. + Scenarios run **serially** and the suite enforces this, overriding any `cucumber.execution.parallel.enabled=true` in your module's `junit-platform.properties`. Control API state is global to the Compose stack, so concurrent scenarios corrupt each other — one scenario's @@ -380,6 +476,12 @@ consumers — the features stay on the classpath and stay inside the JAR. - **Integer accessor width.** flagd's numeric coercion ADR distinguishes a 64-bit integer accessor from a 32-bit one, and tags the latter `@int32-bounded` in its own testbed. Neither this suite nor Appendix F models width at all, and it is a real source of cross-language disagreement. +- **Setting and removing individual flags.** `BackendControl` exposes `prepareScenario()` and + `changeFlag()` — reset to the canonical baseline, and mutate `changing-flag` — because those are + what the Gherkin needs and what the control API defines. Finer-grained `setFlag(key, value)` / + `removeFlag(key)` operations would need control API endpoints that do not exist yet, so adding + them to the interface would produce methods `HttpBackendControl` could not implement. They belong + to a control API revision, not to the Java seam. - **Hooks.** Not covered. - **Flag metadata.** The flagd harness has metadata scenarios; they are not yet ported. - **Multi-suite JVMs.** `TckRuntime` is static, so TCK suites run one at a time within a JVM fork. diff --git a/tools/provider-tck/pom.xml b/tools/provider-tck/pom.xml index a92c7c715a..ec59f7a086 100644 --- a/tools/provider-tck/pom.xml +++ b/tools/provider-tck/pom.xml @@ -182,6 +182,20 @@ slf4j-api ${slf4j.version} + + + + org.junit.jupiter + junit-jupiter + + test + diff --git a/tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/BackendControl.java b/tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/BackendControl.java index 820ffdaa8e..427f80d36a 100644 --- a/tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/BackendControl.java +++ b/tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/BackendControl.java @@ -6,9 +6,10 @@ * The single seam between the TCK's step definitions and whatever manipulates the backend. * *

Step definitions never talk to a backend directly. They talk to this interface, which is why - * the same Gherkin can run unchanged against any backend an implementation can drive — - * a containerised one over HTTP ({@link HttpBackendControl}) being the first. Nothing below this - * line knows about ports, containers or transports. + * the same Gherkin runs unchanged against a containerised backend driven over HTTP + * ({@link HttpBackendControl}) and against a provider manipulated in-process + * ({@link InProcessBackendControl}). Nothing below this line knows about ports, containers or + * transports. * *

Which implementation is right for your provider

* @@ -23,9 +24,9 @@ * static hook inside the provider. It will pass, and it will prove nothing, because the thing it * exercised is not the thing the contract describes. * - *

An in-JVM implementation is legitimate only for providers that have no backend to - * contract with: in-memory, environment-variable and file-based providers, where "the backend" is a - * data structure in the same JVM. + *

In-process control exists for providers that have no backend to contract with: + * in-memory, environment-variable and file-based providers, where "the backend" is a data structure + * in the same JVM. See {@link InProcessBackendControl}. * *

Operations a backend may not support

* diff --git a/tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/Capability.java b/tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/Capability.java index b5e1f558e9..976990055f 100644 --- a/tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/Capability.java +++ b/tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/Capability.java @@ -29,6 +29,21 @@ * {@link #declarable()}, or {@link #declarableExcept} for "everything except", rather than * {@code EnumSet.allOf} or {@code EnumSet.complementOf}: both of the latter sweep up every reserved * tag on the way past, which is how a report comes to claim a capability nobody examined. + * + *

The connection-dependent capabilities

+ * + *

{@link #STALE} and {@link #UNAVAILABLE_INIT} are the two that require a backend the provider + * can be cut off from. They are what a harness leaves undeclared when its {@link BackendControl} + * has no connection to control — an in-memory, environment-variable or file-based provider, where + * the backend is a data structure in the same JVM. Every step that would call + * {@link BackendControl#disconnect()}, {@link BackendControl#reconnect()} or + * {@link ProviderTckHarness#createUnavailableProvider()} lives in a scenario carrying one of these + * two tags, so undeclaring them skips those scenarios before an unsupported operation can be + * reached. + * + *

Getting that pairing wrong surfaces as an {@link UnsupportedOperationException} rather than a + * skip, which is deliberate: it means a capability was declared that the harness cannot back up, + * and that is a test-configuration bug. */ public enum Capability { diff --git a/tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/InProcessBackendControl.java b/tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/InProcessBackendControl.java new file mode 100644 index 0000000000..91ef2fb6f0 --- /dev/null +++ b/tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/InProcessBackendControl.java @@ -0,0 +1,226 @@ +package dev.openfeature.contrib.tools.providertck; + +import dev.openfeature.sdk.MutableStructure; +import dev.openfeature.sdk.Value; +import dev.openfeature.sdk.providers.memory.Flag; +import dev.openfeature.sdk.providers.memory.InMemoryProvider; +import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; +import java.util.Collections; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * {@link BackendControl} that manipulates the SDK's {@link InMemoryProvider} directly, with no + * backend, no container and no HTTP. + * + *

This exists so that providers with nothing to connect to — in-memory, environment-variable and + * file-based providers — can run the TCK. For those, "the backend" is a data structure in the same + * JVM: seeding flags is building a map, and changing one is + * {@link InMemoryProvider#updateFlag(String, Flag)}, which emits + * {@code PROVIDER_CONFIGURATION_CHANGED} through the provider's own event mechanism rather than + * through a simulated one. + * + *

This is not a shortcut for providers that do have a backend. Reaching into an + * external backend from inside the JVM — a test-only admin client, a shared database handle, a + * static hook in the provider — produces a suite that passes while proving nothing, because the + * path it exercised is not the path the contract describes. Those providers use + * {@link HttpBackendControl} via {@link ContainerizedProviderTckTest}, and the control API in + * {@code openapi/control-api.yaml} stays the normative contract. See {@link BackendControl}. + * + *

Connection control

+ * + *

{@link #disconnect()}, {@link #reconnect()} and {@link #disconnectFor} are not implemented, so + * they inherit the interface defaults and throw. An in-memory provider has no connection to lose, + * and pretending otherwise with a no-op would report {@code @stale} scenarios as passed. The + * harness instead leaves {@link Capability#STALE} and {@link Capability#UNAVAILABLE_INIT} + * undeclared, and those scenarios are reported as skipped. + * + *

Ownership of the provider

+ * + *

This class both seeds the flags and creates the provider that serves them, because in-process + * they are the same object: {@link #changeFlag()} has to reach the live provider instance to emit + * an event from it. A harness therefore wires both of its factory methods to one instance: + * + *

{@code
+ * private final InProcessBackendControl control = new InProcessBackendControl();
+ *
+ * @Override
+ * public BackendControl backendControl() {
+ *     return control;
+ * }
+ *
+ * @Override
+ * public FeatureProvider createProvider() {
+ *     return control.createProvider();
+ * }
+ * }
+ */ +public final class InProcessBackendControl implements BackendControl { + + /** The flag {@link #changeFlag()} mutates, as defined by {@code flags/canonical-flags.json}. */ + private static final String CHANGING_FLAG = "changing-flag"; + + private static final String CHANGING_BASELINE = "foo"; + private static final String CHANGING_CHANGED = "bar"; + + /** + * The canonical flag set, never mutated after construction. + * + *

Scenario isolation depends on that: {@link InMemoryProvider} copies the map it is given, + * and {@code updateFlag} writes only to the provider's copy, so every provider handed out by + * {@link #createProvider()} starts from an untouched baseline. + */ + private final Map> baseline = canonicalFlags(); + + /** The provider serving the current scenario, or {@code null} between scenarios. */ + private InMemoryProvider current; + + /** Which variant {@code changing-flag} currently resolves to. */ + private String changingVariant = CHANGING_BASELINE; + + /** + * Creates the provider for the scenario about to run, seeded with the canonical flag set. + * + *

Each call returns a fresh instance over a fresh copy of the baseline, which is what makes + * {@link #prepareScenario()} nothing more than dropping the previous reference. + * + * @return a configured, uninitialised in-memory provider + */ + @SuppressFBWarnings( + value = "EI_EXPOSE_REP", + justification = "Handing out the live provider is the contract, not a leak: in-process " + + "the flag store and the provider are one object, and changeFlag() must reach " + + "the same instance the TCK registered in order to emit an event from it") + public InMemoryProvider createProvider() { + changingVariant = CHANGING_BASELINE; + current = new InMemoryProvider(new HashMap<>(baseline)); + return current; + } + + @Override + public String description() { + return "in-process control of " + InMemoryProvider.class.getSimpleName(); + } + + /** + * {@inheritDoc} + * + *

Drops the reference to the previous scenario's provider. That is the whole reset: the + * baseline map is never mutated, so the {@link #createProvider()} call that follows produces a + * provider already at the baseline. Clearing the reference rather than leaving it dangling + * means a scenario that manipulates flags without creating a provider fails with a clear + * message instead of mutating a provider that has already been shut down. + */ + @Override + public void prepareScenario() { + current = null; + } + + /** + * {@inheritDoc} + * + *

Flips {@code changing-flag} between its two variants through + * {@link InMemoryProvider#updateFlag(String, Flag)}, so the event the suite awaits is the + * provider's own {@code PROVIDER_CONFIGURATION_CHANGED} — carrying {@code changing-flag} in + * {@code flagsChanged} — and not a signal the TCK synthesised. + * + *

Alternating rather than assigning a fixed variant keeps repeated calls within one scenario + * meaningful; the suite asserts that the resolved value differs, not what it became. + */ + @Override + public void changeFlag() { + changingVariant = CHANGING_CHANGED.equals(changingVariant) ? CHANGING_BASELINE : CHANGING_CHANGED; + requireProvider().updateFlag(CHANGING_FLAG, changingFlag(changingVariant)); + } + + private InMemoryProvider requireProvider() { + if (current == null) { + throw new IllegalStateException("No in-memory provider exists for this scenario. In-process backend " + + "control manipulates the provider itself, so the scenario must create one — with " + + "'Given a stable provider' — before any step that changes flag state."); + } + return current; + } + + /** + * Builds the canonical flag set as {@link InMemoryProvider} flags. + * + *

Mirrors {@code flags/canonical-flags.json} entry for entry. The two load-bearing details + * from that file hold here too: {@code missing-flag} is absent, which is what the + * {@code FLAG_NOT_FOUND} scenario tests, and no flag carries a + * {@link dev.openfeature.sdk.providers.memory.ContextEvaluator}, so every evaluation reports + * reason {@code STATIC} as the feature files expect. + * + * @return the canonical flag set + */ + private static Map> canonicalFlags() { + Map> flags = new LinkedHashMap<>(); + + flags.put( + "boolean-flag", + Flag.builder() + .variant("on", true) + .variant("off", false) + .defaultVariant("on") + .build()); + + flags.put( + "string-flag", + Flag.builder() + .variant("greeting", "hi") + .variant("parting", "bye") + .defaultVariant("greeting") + .build()); + + flags.put( + "integer-flag", + Flag.builder() + .variant("one", 1) + .variant("ten", 10) + .defaultVariant("ten") + .build()); + + flags.put( + "float-flag", + Flag.builder() + .variant("tenth", 0.1) + .variant("half", 0.5) + .defaultVariant("half") + .build()); + + flags.put( + "object-flag", + Flag.builder() + .variant("empty", new Value(new MutableStructure())) + .variant( + "template", + new Value(new MutableStructure() + .add("showImages", true) + .add("title", "Check out these pics!") + .add("imagesPerPage", 100))) + .defaultVariant("template") + .build()); + + // A string flag, evaluated as a boolean by the TYPE_MISMATCH scenario. + flags.put( + "wrong-flag", + Flag.builder() + .variant("one", "uno") + .variant("two", "dos") + .defaultVariant("one") + .build()); + + flags.put(CHANGING_FLAG, changingFlag(CHANGING_BASELINE)); + + return Collections.unmodifiableMap(flags); + } + + private static Flag changingFlag(String defaultVariant) { + return Flag.builder() + .variant(CHANGING_BASELINE, CHANGING_BASELINE) + .variant(CHANGING_CHANGED, CHANGING_CHANGED) + .defaultVariant(defaultVariant) + .build(); + } +} diff --git a/tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/ProviderTckHarness.java b/tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/ProviderTckHarness.java index a11f201627..bab8d6f053 100644 --- a/tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/ProviderTckHarness.java +++ b/tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/ProviderTckHarness.java @@ -31,7 +31,7 @@ *

{@code
  * public class MyProviderTckTest extends ProviderTckTest {
  *
- *     private final MyInProcessControl control = new MyInProcessControl();
+ *     private final InProcessBackendControl control = new InProcessBackendControl();
  *
  *     @Override
  *     public BackendControl backendControl() {
diff --git a/tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/ProviderTckTest.java b/tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/ProviderTckTest.java
index 6083afc56f..f3ac3f43aa 100644
--- a/tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/ProviderTckTest.java
+++ b/tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/ProviderTckTest.java
@@ -17,7 +17,8 @@
  *
  * 

This one is lifecycle-agnostic: it starts nothing and knows nothing about how the backend is * reached. Extend it directly when your provider has no backend — an in-memory, - * environment-variable or file-based provider — and supply an in-process {@link BackendControl}. + * environment-variable or file-based provider — and supply an in-process {@link BackendControl} + * such as {@link InProcessBackendControl}. * *

When your provider talks to an external backend, extend {@link ContainerizedProviderTckTest} * instead. It adds the Compose stack lifecycle, port discovery and {@link HttpBackendControl}, and diff --git a/tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/TckRuntime.java b/tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/TckRuntime.java index e5f5e7feeb..983b1c161f 100644 --- a/tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/TckRuntime.java +++ b/tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/TckRuntime.java @@ -67,7 +67,7 @@ public static synchronized TckRuntime startIfNeeded() { throw new IllegalStateException(harness.getClass().getName() + ".backendControl() returned null. Every harness must supply the seam through " + "which the TCK manipulates the backend — HttpBackendControl for an external " - + "backend, an in-process implementation for a provider that has none."); + + "backend, InProcessBackendControl for a provider that has none."); } log.info("Backend control: {}", control.description()); instance = new TckRuntime(harness, control); diff --git a/tools/provider-tck/src/test/java/dev/openfeature/contrib/tools/providertck/InMemoryProviderTckTest.java b/tools/provider-tck/src/test/java/dev/openfeature/contrib/tools/providertck/InMemoryProviderTckTest.java new file mode 100644 index 0000000000..3774e6d269 --- /dev/null +++ b/tools/provider-tck/src/test/java/dev/openfeature/contrib/tools/providertck/InMemoryProviderTckTest.java @@ -0,0 +1,81 @@ +package dev.openfeature.contrib.tools.providertck; + +import dev.openfeature.sdk.FeatureProvider; +import dev.openfeature.sdk.providers.memory.InMemoryProvider; +import java.util.EnumSet; +import java.util.Set; + +/** + * Runs the OpenFeature Provider TCK against the SDK's own {@link InMemoryProvider}. + * + *

This is the TCK's self-test, and it earns its keep twice over. + * + *

It is the reference adoption for a provider with no backend. Everything a + * file-based or environment-variable provider needs to write is here, and it is three methods: hand + * over a {@link BackendControl}, hand over a provider, and say which capabilities hold. + * + *

It is also the Docker-free canary. Because it needs no container, no Compose + * stack and no network, it runs in seconds on any machine and in any CI job, which makes it the + * fast check that catches a broken step definition, a mis-wired capability gate or a regression in + * the shared harness long before the containerised suites get a chance to. When a change breaks + * both this and the flagd suite, this one tells you within seconds and points at the TCK rather + * than at a provider. + * + *

Note what it does not do: it is not a licence for providers that have a backend to + * test themselves this way. See {@link BackendControl} for why. + */ +public class InMemoryProviderTckTest extends ProviderTckTest { + + /** + * Both the flag store and the factory for the provider that serves it. + * + *

In-process the two are the same thing — {@code changeFlag()} has to reach the live provider + * instance to emit an event from it — so one instance backs both harness methods below. + */ + private final InProcessBackendControl control = new InProcessBackendControl(); + + @Override + public BackendControl backendControl() { + return control; + } + + @Override + public FeatureProvider createProvider() { + return control.createProvider(); + } + + /** + * {@inheritDoc} + * + *

Four capabilities, and each omission is a fact about {@link InMemoryProvider} rather than a + * convenience: + * + *

    + *
  • {@link Capability#LIFECYCLE} — omitted. Initialisation reaches no backend here, so the + * readiness scenario would pass without demonstrating anything, which is exactly what that + * capability exists to distinguish. + *
  • {@link Capability#STALE} — omitted. There is no connection to lose, so the provider can + * never go {@code STALE}. {@link InProcessBackendControl} leaves + * {@link BackendControl#disconnect()} unimplemented for the same reason, and this omission + * is what keeps the two consistent: the scenario is skipped before any step can reach the + * unsupported operation. + *
  • {@link Capability#UNAVAILABLE_INIT} — omitted. Initialisation cannot fail when there is + * nothing to connect to, so + * {@link ProviderTckHarness#createUnavailableProvider()} is left at its throwing default. + *
  • {@link Capability#TARGETING} and {@link Capability#CACHING} — omitted because no + * scenario carries their tags yet. Nothing is skipped by leaving them out today. + *
+ * + *

{@link Capability#NUMERIC_COERCION} is declared, and that is worth stating + * plainly: {@link InMemoryProvider} refuses to narrow {@code float-flag} (0.5) to an integer and + * reports {@code TYPE_MISMATCH} instead. It is the reference behaviour the capability describes. + */ + @Override + public Set capabilities() { + return EnumSet.of( + Capability.EVENTS, + Capability.CONFIGURATION_CHANGE, + Capability.OBJECT, + Capability.NUMERIC_COERCION); + } +} diff --git a/tools/provider-tck/src/test/java/dev/openfeature/contrib/tools/providertck/InProcessBackendControlTest.java b/tools/provider-tck/src/test/java/dev/openfeature/contrib/tools/providertck/InProcessBackendControlTest.java new file mode 100644 index 0000000000..14df30bb37 --- /dev/null +++ b/tools/provider-tck/src/test/java/dev/openfeature/contrib/tools/providertck/InProcessBackendControlTest.java @@ -0,0 +1,104 @@ +package dev.openfeature.contrib.tools.providertck; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.openfeature.sdk.ImmutableContext; +import dev.openfeature.sdk.ProviderEvaluation; +import dev.openfeature.sdk.providers.memory.InMemoryProvider; +import java.time.Duration; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** + * Guards the two properties of {@link InProcessBackendControl} that the Gherkin cannot assert + * about itself. + * + *

The first is that unsupported operations fail loudly. The whole + * skipped-by-capability design collapses into false confidence if a connection operation quietly + * does nothing, and a scenario that never runs cannot prove that it would have failed. These tests + * call the operations directly. + * + *

The second is that scenario isolation actually isolates. {@link InMemoryProviderTckTest} would + * still pass if {@code changeFlag()} leaked into the next scenario, because no scenario evaluates + * {@code changing-flag} before modifying it. + */ +class InProcessBackendControlTest { + + @Test + @DisplayName("connection operations throw rather than silently doing nothing") + void connectionOperationsThrow() { + InProcessBackendControl control = new InProcessBackendControl(); + + // The message has to name the fix, because whoever hits this is looking at a red scenario + // that reads like a provider defect and is in fact a capability declared in error. + assertThatThrownBy(control::disconnect) + .isInstanceOf(UnsupportedOperationException.class) + .hasMessageContaining("does not support 'disconnect'") + .hasMessageContaining("test-configuration bug") + .hasMessageContaining("Capability.STALE"); + + assertThatThrownBy(control::reconnect) + .isInstanceOf(UnsupportedOperationException.class) + .hasMessageContaining("does not support 'reconnect'"); + + assertThatThrownBy(() -> control.disconnectFor(Duration.ofSeconds(1))) + .isInstanceOf(UnsupportedOperationException.class) + .hasMessageContaining("does not support 'disconnectFor'"); + } + + @Test + @DisplayName("changing a flag without a provider fails instead of being lost") + void changeFlagWithoutProviderThrows() { + InProcessBackendControl control = new InProcessBackendControl(); + control.prepareScenario(); + + assertThatThrownBy(control::changeFlag) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("Given a stable provider"); + } + + @Test + @DisplayName("changeFlag changes the resolved value and the next scenario starts from baseline") + void changeFlagIsVisibleAndDoesNotLeak() throws Exception { + InProcessBackendControl control = new InProcessBackendControl(); + + control.prepareScenario(); + InMemoryProvider first = control.createProvider(); + first.initialize(new ImmutableContext()); + assertThat(resolveChangingFlag(first)).isEqualTo("foo"); + + control.changeFlag(); + assertThat(resolveChangingFlag(first)) + .as("changeFlag must actually change what the provider resolves, not merely emit an event") + .isEqualTo("bar"); + + // The next scenario must not inherit that change. The baseline map is shared between every + // provider this control hands out, so a mutation that reached it would leak forwards. + control.prepareScenario(); + InMemoryProvider second = control.createProvider(); + second.initialize(new ImmutableContext()); + assertThat(resolveChangingFlag(second)) + .as("each scenario starts from the canonical baseline") + .isEqualTo("foo"); + } + + @Test + @DisplayName("the canonical flag set omits missing-flag") + void missingFlagIsAbsent() throws Exception { + InProcessBackendControl control = new InProcessBackendControl(); + InMemoryProvider provider = control.createProvider(); + provider.initialize(new ImmutableContext()); + + // Absence is what the FLAG_NOT_FOUND scenario tests, so seeding it by accident would turn + // that scenario green for the wrong reason. + assertThatThrownBy(() -> provider.getStringEvaluation("missing-flag", "fallback", new ImmutableContext())) + .hasMessageContaining("missing-flag"); + } + + private static String resolveChangingFlag(InMemoryProvider provider) { + ProviderEvaluation evaluation = + provider.getStringEvaluation("changing-flag", "unset", new ImmutableContext()); + return evaluation.getValue(); + } +} From 35db474096b4b4fdd8a2c240bab64e5a5f5ce5ae Mon Sep 17 00:00:00 2001 From: Simon Schrottner Date: Mon, 24 Aug 2026 11:47:42 +0200 Subject: [PATCH 07/55] test(provider-tck): also run the self-test against MultiProvider MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A provider that delegates is still a provider, and delegation is where the contract is easiest to drop on the floor: a variant that does not survive the hop, a reason rewritten, an error code flattened, an event that never arrives. MultiProviderTckTest runs the suite against the SDK's MultiProvider wrapping exactly one InMemoryProvider. One child is the interesting configuration rather than a degenerate one — the correct answer is then precisely what InMemoryProviderTckTest already asserts, so any difference between the two suites is attributable to MultiProvider and nothing else. This is not a test of aggregation; it is a test that delegation is transparent. It found something on the first run. MultiProvider extends EventProvider but never subscribes to its children, so a child's PROVIDER_CONFIGURATION_CHANGED — along with its PROVIDER_ERROR and PROVIDER_STALE — is swallowed and never reaches the client. Wrapping a provider in a multi-provider silently costs you those events, with nothing in the API to hint at it. That is a known gap, open-feature/java-sdk#1882 (gap 1, "child provider event aggregation and status tracking", High), originally found by hand-comparing implementations against the js-sdk reference. Reproducing it from the outside, without knowing it was there, is a fair advertisement for what the TCK is for. CONFIGURATION_CHANGE is therefore left undeclared, so the scenario is reported as skipped-with-reason rather than passing on a provider that cannot satisfy it — the same treatment flagd's STRICT_NUMERIC_TYPING gets. Delete the omission once #1882 is fixed. Everything else survives delegation unchanged: 25 passed, 4 skipped. Signed-off-by: Simon Schrottner --- .github/workflows/ci.yml | 9 ++- tools/provider-tck/README.md | 28 ++++++- tools/provider-tck/pom.xml | 11 +-- .../providertck/MultiProviderTckTest.java | 78 +++++++++++++++++++ 4 files changed, 113 insertions(+), 13 deletions(-) create mode 100644 tools/provider-tck/src/test/java/dev/openfeature/contrib/tools/providertck/MultiProviderTckTest.java diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 769d4216de..de0eaf2e49 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,10 +10,11 @@ on: jobs: # Fast canary for the Provider TCK: runs the full applicable conformance suite against the - # SDK's InMemoryProvider with no Docker, no Compose stack and no network. It finishes in - # seconds, so a broken step definition, a mis-wired capability gate or a regression in the - # shared harness is reported long before the containerised provider suites in `main` get - # there — and it points at the TCK rather than at whichever provider noticed first. + # SDK's InMemoryProvider, and again against MultiProvider wrapping one of them, with no + # Docker, no Compose stack and no network. It finishes in seconds, so a broken step + # definition, a mis-wired capability gate or a regression in the shared harness is reported + # long before the containerised provider suites in `main` get there — and it points at the + # TCK rather than at whichever provider noticed first. # # Deliberately not a gate on `main`: the two run in parallel so a green run is not delayed. # The same suite also runs inside `main` as part of the reactor build; this job exists to diff --git a/tools/provider-tck/README.md b/tools/provider-tck/README.md index ed88aff5d9..bb071d5dde 100644 --- a/tools/provider-tck/README.md +++ b/tools/provider-tck/README.md @@ -136,10 +136,30 @@ Get that pairing wrong — declare `STALE` against a control that cannot disconn `BackendControl` may throw `UnsupportedOperationException` for operations it does not support, and reaching one from a scenario that actually ran is a **test-configuration bug**, never a skip. -The TCK's own self-test is exactly this class: see -[`InMemoryProviderTckTest`](src/test/java/dev/openfeature/contrib/tools/providertck/InMemoryProviderTckTest.java), -which runs the full applicable suite against `InMemoryProvider` with no Docker in well under a -second. It doubles as the reference adoption and as the Docker-free CI canary. +### The TCK's own self-tests + +Two suites in this module are exactly the class above, and both run with no Docker in well under a +second. They are the reference adoption, and they are the fast CI canary. + +[`InMemoryProviderTckTest`](src/test/java/dev/openfeature/contrib/tools/providertck/InMemoryProviderTckTest.java) +runs the full applicable suite against the SDK's `InMemoryProvider` — 26 passed, 3 skipped by +capability. + +[`MultiProviderTckTest`](src/test/java/dev/openfeature/contrib/tools/providertck/MultiProviderTckTest.java) +runs it against `MultiProvider` wrapping **one** `InMemoryProvider`. A provider that delegates is +still a provider, and delegation is where the contract is easiest to drop: a variant that does not +survive the hop, a reason rewritten, an error code flattened, an event that never arrives. With a +single child the correct answer is precisely what the in-memory suite already asserts, so any +difference between the two suites is attributable to `MultiProvider` and nothing else. + +That suite has already paid for itself. It does **not** declare `CONFIGURATION_CHANGE`, because +`MultiProvider` extends `EventProvider` but never subscribes to its children — a child's +`PROVIDER_CONFIGURATION_CHANGED`, `PROVIDER_ERROR` and `PROVIDER_STALE` are all swallowed. Wrapping +a provider in a multi-provider silently costs you those events, with nothing in the API to hint at +it. That is a known SDK gap, +[open-feature/java-sdk#1882](https://github.com/open-feature/java-sdk/issues/1882) (gap 1, High), +which the suite reproduced from the outside — the gap was originally found by hand-comparing +implementations against the js-sdk reference. Everything else survives delegation unchanged. ## Adopting it diff --git a/tools/provider-tck/pom.xml b/tools/provider-tck/pom.xml index ec59f7a086..ee5d7ce9ee 100644 --- a/tools/provider-tck/pom.xml +++ b/tools/provider-tck/pom.xml @@ -184,11 +184,12 @@ org.junit.jupiter diff --git a/tools/provider-tck/src/test/java/dev/openfeature/contrib/tools/providertck/MultiProviderTckTest.java b/tools/provider-tck/src/test/java/dev/openfeature/contrib/tools/providertck/MultiProviderTckTest.java new file mode 100644 index 0000000000..ed2587e2f7 --- /dev/null +++ b/tools/provider-tck/src/test/java/dev/openfeature/contrib/tools/providertck/MultiProviderTckTest.java @@ -0,0 +1,78 @@ +package dev.openfeature.contrib.tools.providertck; + +import dev.openfeature.sdk.FeatureProvider; +import dev.openfeature.sdk.multiprovider.MultiProvider; +import dev.openfeature.sdk.providers.memory.InMemoryProvider; +import java.util.Collections; +import java.util.EnumSet; +import java.util.Set; + +/** + * Runs the OpenFeature Provider TCK against the SDK's {@link MultiProvider}, wrapping a single + * {@link InMemoryProvider}. + * + *

A provider that delegates is still a provider, and delegation is where the contract is easiest + * to drop on the floor: a variant that does not survive the hop, a reason rewritten to + * {@code DEFAULT}, an error code flattened to {@code GENERAL}, an event that never reaches the + * client. Wrapping exactly one child makes every one of those observable, because the correct + * answer is precisely what {@link InMemoryProviderTckTest} already asserts. Any difference between + * these two suites is attributable to {@link MultiProvider} and nothing else. + * + *

That framing is the point of running it here rather than in the SDK: this is not a test of + * aggregation across several backends, it is a test that delegation is transparent. + * + *

It costs one class, needs no Docker, and it has already earned its place — see the capability + * note below. + */ +public class MultiProviderTckTest extends ProviderTckTest { + + private final InProcessBackendControl control = new InProcessBackendControl(); + + @Override + public BackendControl backendControl() { + return control; + } + + /** + * {@inheritDoc} + * + *

Exactly one child. See the class javadoc for why that is the interesting configuration + * rather than a degenerate one. + */ + @Override + public FeatureProvider createProvider() { + return new MultiProvider(Collections.singletonList(control.createProvider())); + } + + /** + * {@inheritDoc} + * + *

{@link Capability#CONFIGURATION_CHANGE} is not declared, and that is a + * finding rather than a configuration choice. + * + *

{@link MultiProvider} extends {@code EventProvider} but never subscribes to its children, + * so a child's {@code PROVIDER_CONFIGURATION_CHANGED} — along with its {@code PROVIDER_ERROR} + * and {@code PROVIDER_STALE} — is swallowed and never reaches the client. Wrapping an + * in-memory provider in a multi-provider therefore silently costs you configuration-change + * events, with nothing in the API to suggest it. + * + *

This is a known gap, tracked as + * open-feature/java-sdk#1882 + * (gap 1, "child provider event aggregation and status tracking", High). The suite reproduced + * it from the outside, which is a reasonable advertisement for what the TCK is for: the gap was + * originally found by hand-comparing implementations against the js-sdk reference. + * + *

Delete this omission once #1882 is fixed. Until then the + * {@code @configuration-change} scenario is reported as skipped-with-reason rather than passing + * on a provider that cannot satisfy it. + * + *

Everything else holds. Values, variants, reasons, the full type-mismatch matrix, + * {@code FLAG_NOT_FOUND}, structured values and numeric coercion all survive the delegation hop + * unchanged. {@link Capability#LIFECYCLE} is omitted for the same reason as in + * {@link InMemoryProviderTckTest}: nothing here reaches a backend during initialisation. + */ + @Override + public Set capabilities() { + return EnumSet.of(Capability.EVENTS, Capability.OBJECT, Capability.NUMERIC_COERCION); + } +} From cb8b4be5700c2d05e32648676f495ec474952499 Mon Sep 17 00:00:00 2001 From: Simon Schrottner Date: Mon, 24 Aug 2026 11:30:41 +0200 Subject: [PATCH 08/55] feat(provider-tck): source the spec artifacts from the open-feature/spec submodule MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Gherkin, the canonical flag set and the control API document are not Java artifacts. They are language-agnostic definitions of the provider contract that every language's TCK must agree on byte for byte, and they only lived in this module because the proof of concept had to start somewhere. They now live in open-feature/spec as Appendix F, under specification/assets/provider-tck/, and are copied in from the `spec` git submodule at generate-resources — the same mechanism tools/flagd-api-testkit already uses for the flagd test harness. The copies are git-ignored and carry a do-not-edit note; changes belong in the spec repo and arrive here by bumping the submodule. Consumers are unaffected: the artifacts are still packaged into the release JAR, @SelectClasspathResource("features") still resolves, and nobody needs a submodule of their own. Verified byte-identical after the round trip. The in-memory CI job now checks out submodules, since without them there is no suite to run. DEPENDS ON open-feature/spec#423. The submodule is pinned to that PR's branch commit rather than to a commit on the spec repo's main branch. That is reachable, so CI can fetch it, but it must be re-pinned to main once #423 merges and before this lands. The pin is the branch tip rather than the first commit of that PR, so the copied assets carry the `@numeric-coercion` vocabulary and the reserved-capability control API this branch already uses. Verified byte-identical against the artifacts this commit deletes. Signed-off-by: Simon Schrottner --- .github/workflows/ci.yml | 5 +- .gitmodules | 3 + tools/provider-tck/.gitignore | 7 + tools/provider-tck/README.md | 37 +- tools/provider-tck/pom.xml | 149 +++++-- tools/provider-tck/spec | 1 + .../main/resources/features/errors.feature | 86 ---- .../resources/features/evaluation.feature | 59 --- .../main/resources/features/events.feature | 42 -- .../main/resources/features/lifecycle.feature | 40 -- .../main/resources/flags/canonical-flags.json | 82 ---- .../main/resources/openapi/control-api.yaml | 388 ------------------ 12 files changed, 162 insertions(+), 737 deletions(-) create mode 100644 tools/provider-tck/.gitignore create mode 160000 tools/provider-tck/spec delete mode 100644 tools/provider-tck/src/main/resources/features/errors.feature delete mode 100644 tools/provider-tck/src/main/resources/features/evaluation.feature delete mode 100644 tools/provider-tck/src/main/resources/features/events.feature delete mode 100644 tools/provider-tck/src/main/resources/features/lifecycle.feature delete mode 100644 tools/provider-tck/src/main/resources/flags/canonical-flags.json delete mode 100644 tools/provider-tck/src/main/resources/openapi/control-api.yaml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index de0eaf2e49..ad34cb8c8d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -25,7 +25,10 @@ jobs: steps: - name: Checkout Repository uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5 - # No submodules: this module's feature files, flags and control-API spec are in-repo. + with: + # The feature files, canonical flag set and control-API document are copied in from + # the open-feature/spec submodule at generate-resources; without it there is no suite. + submodules: recursive - name: Set up JDK 21 uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5 diff --git a/.gitmodules b/.gitmodules index fcfa3cf548..cb1cd9a414 100644 --- a/.gitmodules +++ b/.gitmodules @@ -18,3 +18,6 @@ path = tools/flagd-api-testkit/test-harness url = https://github.com/open-feature/test-harness.git branch = v3.10.1 +[submodule "tools/provider-tck/spec"] + path = tools/provider-tck/spec + url = https://github.com/open-feature/spec.git diff --git a/tools/provider-tck/.gitignore b/tools/provider-tck/.gitignore new file mode 100644 index 0000000000..4148e2b63d --- /dev/null +++ b/tools/provider-tck/.gitignore @@ -0,0 +1,7 @@ +# Copied from the `spec` submodule at build time (mvn generate-resources). +# Do not edit these files directly — they are the language-agnostic definition of the +# provider contract and live in open-feature/spec, under +# specification/assets/provider-tck/ (Appendix F). +src/main/resources/features/ +src/main/resources/flags/ +src/main/resources/openapi/ diff --git a/tools/provider-tck/README.md b/tools/provider-tck/README.md index bb071d5dde..a1b86591ac 100644 --- a/tools/provider-tck/README.md +++ b/tools/provider-tck/README.md @@ -463,19 +463,36 @@ Three steps are new: | `When the resolved value is remembered` / `Then the resolved details value should have changed` | the control API only requires that `/change` changes `changing-flag`'s value, not which value it changes to; asserting a delta keeps the scenario vendor-neutral | | `Then no exception should have been thrown` | makes the "never throws" half of the error contract explicit rather than implicit in a step failure | -## Where these artifacts should live +## Where these artifacts come from -The feature files, the control API spec and the canonical flag set are **not Java artifacts**. They -are language-agnostic definitions of the provider contract that every language's TCK must agree on -byte for byte, and that backend vendors implement in whatever language their testbed is written in. +The feature files, the canonical flag set and the control API document are **not Java artifacts**. +They are language-agnostic definitions of the provider contract that every language's TCK must agree +on byte for byte, and that backend vendors implement in whatever language their testbed is written +in. -They belong in the OpenFeature [spec repository](https://github.com/open-feature/spec), with this -module as their Java delivery vehicle. The three travel together by necessity: a feature file that -evaluates `boolean-flag` is meaningless without the flag definition, and a disconnect scenario is -meaningless without the endpoint that produces the disconnect. +They live in the OpenFeature [spec repository](https://github.com/open-feature/spec) as +[Appendix F: Provider Conformance](https://github.com/open-feature/spec/blob/main/specification/appendix-f-provider-conformance.md), +under `specification/assets/provider-tck/`. This module is their Java delivery vehicle: the `spec` +git submodule is updated at `initialize`, the three directories are copied into +`src/main/resources/` at `generate-resources`, and from there they are packaged into the release +JAR. Consumers see no difference — the features stay on the classpath and need no submodule of their +own. -They live here for now only because the PoC had to start somewhere. Moving them changes nothing for -consumers — the features stay on the classpath and stay inside the JAR. +The three travel together by necessity: a feature file that evaluates `boolean-flag` is meaningless +without the flag definition, and a disconnect scenario is meaningless without the control endpoint +that produces the disconnect. + +> **Do not edit `src/main/resources/features/`, `flags/` or `openapi/`.** They are generated and +> git-ignored. Changes belong in `open-feature/spec` and arrive here by bumping the submodule. + +Building this module therefore needs the submodule: + +```bash +git submodule update --init tools/provider-tck/spec +``` + +Maven does this itself at `initialize`, so a plain `mvn verify` works from a fresh clone; the +explicit command is only useful when working offline or inspecting the sources by hand. ## Known gaps diff --git a/tools/provider-tck/pom.xml b/tools/provider-tck/pom.xml index ee5d7ce9ee..5d350dd6b2 100644 --- a/tools/provider-tck/pom.xml +++ b/tools/provider-tck/pom.xml @@ -45,39 +45,32 @@ @@ -199,4 +192,102 @@ + + + + + org.codehaus.mojo + exec-maven-plugin + 3.6.3 + + + update-spec-submodule + initialize + + exec + + + git + + submodule + update + --init + spec + + + + + + + + + maven-resources-plugin + 3.5.0 + + + copy-provider-tck-gherkin + generate-resources + + copy-resources + + + ${basedir}/src/main/resources/features/ + + + ${basedir}/spec/specification/assets/provider-tck/gherkin/ + + **/*.feature + + + + + + + copy-provider-tck-flags + generate-resources + + copy-resources + + + ${basedir}/src/main/resources/flags/ + + + ${basedir}/spec/specification/assets/provider-tck/flags/ + + **/*.json + + + + + + + copy-provider-tck-openapi + generate-resources + + copy-resources + + + ${basedir}/src/main/resources/openapi/ + + + ${basedir}/spec/specification/assets/provider-tck/openapi/ + + **/*.yaml + + + + + + + + + + diff --git a/tools/provider-tck/spec b/tools/provider-tck/spec new file mode 160000 index 0000000000..0bedacc224 --- /dev/null +++ b/tools/provider-tck/spec @@ -0,0 +1 @@ +Subproject commit 0bedacc22489697ccfbe1f5f4c5ae7649a5be457 diff --git a/tools/provider-tck/src/main/resources/features/errors.feature b/tools/provider-tck/src/main/resources/features/errors.feature deleted file mode 100644 index 0efbe57261..0000000000 --- a/tools/provider-tck/src/main/resources/features/errors.feature +++ /dev/null @@ -1,86 +0,0 @@ -Feature: Provider error handling - - # Every scenario here asserts the same three-part contract, because all three parts matter and - # providers routinely get one of them wrong: - # - # 1. the code default is returned — an application must keep working, - # 2. the correct error code is reported — an application must be able to tell what went wrong, - # 3. nothing is thrown — an unhandled exception from a flag evaluation is never acceptable. - # - # Requires the backend to be seeded with the canonical flag set — see flags/canonical-flags.json. - - Background: - Given a stable provider - - Scenario Outline: Requesting the wrong type returns the code default - # The full non-numeric mismatch matrix. Numeric coercion is a separate question and is covered - # by the @numeric-coercion scenarios below, because "is 0.5 an integer?" has a defensible - # wrong answer whereas "is a string a boolean?" does not. - Given a -flag with key "" and a default value "" - When the flag was evaluated with details - Then the resolved details value should be "" - And the reason should be "ERROR" - And the error-code should be "TYPE_MISMATCH" - And no exception should have been thrown - - Examples: a string flag requested as something else - | key | requested | default | - | string-flag | Boolean | false | - | string-flag | Integer | 1 | - | string-flag | Float | 0.1 | - | wrong-flag | Boolean | false | - - Examples: a boolean flag requested as something else - | key | requested | default | - | boolean-flag | String | fallback | - | boolean-flag | Integer | 1 | - | boolean-flag | Float | 0.1 | - - Examples: a numeric flag requested as a non-numeric type - | key | requested | default | - | integer-flag | Boolean | false | - | integer-flag | String | fallback | - | float-flag | Boolean | false | - | float-flag | String | fallback | - - @object - Scenario Outline: Requesting a structured flag as a scalar returns the code default - Given a -flag with key "object-flag" and a default value "" - When the flag was evaluated with details - Then the resolved details value should be "" - And the reason should be "ERROR" - And the error-code should be "TYPE_MISMATCH" - And no exception should have been thrown - - Examples: - | requested | default | - | Boolean | false | - | String | fallback | - | Integer | 1 | - | Float | 0.1 | - - @numeric-coercion - Scenario: A float flag is not silently narrowed to an integer - # 'float-flag' resolves to 0.5. Narrowing that to an integer would lose information - # silently, so it must be reported as a type mismatch rather than rounded. - # - # This is the lossy half of the coercion contract. The lossless half -- that an - # integral float such as 10.0 requested as an integer MUST succeed -- has no scenario - # yet, because the canonical flag set has no integral float to ask it of. Adding one - # is a change to the flag set and so to every language at once; see the tag's entry in - # Appendix F. - Given a Integer-flag with key "float-flag" and a default value "1" - When the flag was evaluated with details - Then the resolved details value should be "1" - And the reason should be "ERROR" - And the error-code should be "TYPE_MISMATCH" - And no exception should have been thrown - - Scenario: An unknown flag key returns the code default - # 'missing-flag' is deliberately absent from the canonical flag set. - Given a String-flag with key "missing-flag" and a default value "fallback" - When the flag was evaluated with details - Then the resolved details value should be "fallback" - And the reason should be "ERROR" - And the error-code should be "FLAG_NOT_FOUND" - And no exception should have been thrown diff --git a/tools/provider-tck/src/main/resources/features/evaluation.feature b/tools/provider-tck/src/main/resources/features/evaluation.feature deleted file mode 100644 index e89f174a51..0000000000 --- a/tools/provider-tck/src/main/resources/features/evaluation.feature +++ /dev/null @@ -1,59 +0,0 @@ -Feature: Provider flag evaluation - - # Verifies that a provider maps backend responses onto typed resolution details correctly. - # - # This does NOT test the backend's evaluation logic. Every flag in the canonical set resolves - # to its default variant with no targeting involved, so what is under test is purely the - # provider's mapping of a backend response to a value, a variant and a reason. - # - # Requires the backend to be seeded with the canonical flag set — see flags/canonical-flags.json. - - Background: - Given a stable provider - - Scenario Outline: Resolve values with variant and reason - Given a -flag with key "" and a default value "" - When the flag was evaluated with details - Then the resolved details value should be "" - And the variant should be "" - And the reason should be "" - And the error-code should be "" - And no exception should have been thrown - - Examples: - | key | type | default | value | variant | reason | - | boolean-flag | Boolean | false | true | on | STATIC | - | string-flag | String | bye | hi | greeting | STATIC | - | integer-flag | Integer | 1 | 10 | ten | STATIC | - | float-flag | Float | 0.1 | 0.5 | half | STATIC | - - Scenario: An integer flag resolves as an integer - # Paired with the float scenario below and with the narrowing scenario in errors.feature. - # Together they pin down that the two numeric types stay distinct rather than both being - # funnelled through one numeric representation. - Given a Integer-flag with key "integer-flag" and a default value "1" - When the flag was evaluated with details - Then the resolved details value should be "10" - And the error-code should be "" - And no exception should have been thrown - - Scenario: A float flag resolves as a float - Given a Float-flag with key "float-flag" and a default value "0.1" - When the flag was evaluated with details - Then the resolved details value should be "0.5" - And the error-code should be "" - And no exception should have been thrown - - @object - Scenario: Resolve a structured value - Given a Object-flag with key "object-flag" and a default value "{}" - When the flag was evaluated with details - Then the variant should be "template" - And the reason should be "STATIC" - And the error-code should be "" - And no exception should have been thrown - And the resolved object value should contain - | key | type | value | - | showImages | Boolean | true | - | title | String | Check out these pics! | - | imagesPerPage | Integer | 100 | diff --git a/tools/provider-tck/src/main/resources/features/events.feature b/tools/provider-tck/src/main/resources/features/events.feature deleted file mode 100644 index 00e7e5ef6f..0000000000 --- a/tools/provider-tck/src/main/resources/features/events.feature +++ /dev/null @@ -1,42 +0,0 @@ -@events -Feature: Provider events - - # Verifies that a provider notices changes in its backend and both signals them and acts on - # them. Signalling alone is not enough: a configuration-change event that is not followed by - # a changed evaluation result is a lie, so each scenario asserts the event AND the behaviour. - # - # Outages here are simulated inside the running stack via the control API. No container is - # ever stopped or restarted — see the invariant in openapi/control-api.yaml. - - Background: - Given a stable provider - - @configuration-change - Scenario: A configuration change is signalled and applied - Given a String-flag with key "changing-flag" and a default value "unset" - And a change event handler - When the flag was evaluated with details - And the resolved value is remembered - And the flag was modified - Then the change event handler should have been executed - And the flag should be part of the event payload - When the flag was evaluated with details - Then the resolved details value should have changed - And no exception should have been thrown - - @stale - Scenario: Losing the backend makes the provider stale, regaining it makes it ready again - Given a ready event handler - And a stale event handler - When a ready event was fired - And the connection is lost - Then the stale event handler should have been executed - And the client should be in stale state - When the connection is restored - Then the ready event handler should have been executed - And the client should be in ready state - - # Deliberately NOT covered here: whether a stale provider keeps serving last-known values - # during the outage. That is caching behaviour, which depends on whether the provider holds a - # local copy of the ruleset, and it belongs behind the @caching capability once those - # scenarios are written. See the "Known gaps" section of the README. diff --git a/tools/provider-tck/src/main/resources/features/lifecycle.feature b/tools/provider-tck/src/main/resources/features/lifecycle.feature deleted file mode 100644 index 3e168565ab..0000000000 --- a/tools/provider-tck/src/main/resources/features/lifecycle.feature +++ /dev/null @@ -1,40 +0,0 @@ -@lifecycle -Feature: Provider lifecycle - - # Verifies the two terminal outcomes of provider initialisation: reaching READY against a - # healthy backend, and settling into ERROR against one that cannot be reached. - # - # Gated by @lifecycle rather than @events, and the distinction is load-bearing. Every SDK - # synthesises PROVIDER_READY for a provider that has no initialisation step, so a provider - # without a lifecycle passes the readiness scenario below without demonstrating anything -- - # a NoOpProvider passes it identically. @lifecycle asserts that the provider actually reaches - # its backend during initialisation and that the outcome is observable; a provider that merely - # emits events does not necessarily do that. - # - # The failure case matters more than it looks. A provider that blocks forever, or throws out - # of provider registration, takes the host application down with it — so the requirement is - # not merely that initialisation fails, but that it fails observably and promptly. - - Scenario: A provider that successfully initializes becomes ready - Given a stable provider - And a ready event handler - Then the ready event handler should have been executed - And the client should be in ready state - - @unavailable - Scenario: A provider that cannot reach its backend reports an error - Given a unavailable provider - And a error event handler - Then the error event handler should have been executed within 10000ms - And the client should be in error state - - @unavailable - Scenario: A provider that cannot reach its backend still returns code defaults - Given a unavailable provider - And a error event handler - And a Boolean-flag with key "boolean-flag" and a default value "false" - Then the error event handler should have been executed within 10000ms - When the flag was evaluated with details - Then the resolved details value should be "false" - And the reason should be "ERROR" - And no exception should have been thrown diff --git a/tools/provider-tck/src/main/resources/flags/canonical-flags.json b/tools/provider-tck/src/main/resources/flags/canonical-flags.json deleted file mode 100644 index 343b3ae525..0000000000 --- a/tools/provider-tck/src/main/resources/flags/canonical-flags.json +++ /dev/null @@ -1,82 +0,0 @@ -{ - "$comment": [ - "The canonical flag set the TCK's feature files assume. A backend under test MUST serve an", - "equivalent set under the configuration named 'default'.", - "", - "Expressed in the flagd flag-definition format because that is the only widely implemented", - "vendor-neutral format today. The format is not what matters — the keys, types, variant", - "names and resolved values are. Seed them however your backend seeds flags.", - "", - "Two things are load-bearing and easy to get wrong:", - " * 'missing-flag' MUST NOT exist. Its absence is what the FLAG_NOT_FOUND scenario tests.", - " * No flag here has targeting rules. Every scenario expects reason STATIC, because the TCK", - " tests the provider's mapping of a response, not the backend's evaluation logic." - ], - "flags": { - "boolean-flag": { - "state": "ENABLED", - "variants": { - "on": true, - "off": false - }, - "defaultVariant": "on" - }, - "string-flag": { - "state": "ENABLED", - "variants": { - "greeting": "hi", - "parting": "bye" - }, - "defaultVariant": "greeting" - }, - "integer-flag": { - "state": "ENABLED", - "variants": { - "one": 1, - "ten": 10 - }, - "defaultVariant": "ten" - }, - "float-flag": { - "state": "ENABLED", - "variants": { - "tenth": 0.1, - "half": 0.5 - }, - "defaultVariant": "half" - }, - "object-flag": { - "state": "ENABLED", - "variants": { - "empty": {}, - "template": { - "showImages": true, - "title": "Check out these pics!", - "imagesPerPage": 100 - } - }, - "defaultVariant": "template" - }, - "wrong-flag": { - "$comment": "A string flag, evaluated as a boolean by the TYPE_MISMATCH scenario.", - "state": "ENABLED", - "variants": { - "one": "uno", - "two": "dos" - }, - "defaultVariant": "one" - }, - "changing-flag": { - "$comment": [ - "The flag POST /change mutates. The TCK asserts only that its resolved value differs", - "after the change, so which of the two variants you start from does not matter." - ], - "state": "ENABLED", - "variants": { - "foo": "foo", - "bar": "bar" - }, - "defaultVariant": "foo" - } - } -} diff --git a/tools/provider-tck/src/main/resources/openapi/control-api.yaml b/tools/provider-tck/src/main/resources/openapi/control-api.yaml deleted file mode 100644 index d21191067d..0000000000 --- a/tools/provider-tck/src/main/resources/openapi/control-api.yaml +++ /dev/null @@ -1,388 +0,0 @@ -openapi: 3.0.3 - -info: - title: OpenFeature Provider TCK — Backend Control API - version: 0.0.1 - description: | - The control API that a **backend under test** must expose so the OpenFeature - Provider TCK can drive it. - - The TCK verifies the *provider contract*: how a provider maps backend - responses to typed resolution details, lifecycle states and events. To do - that it must be able to put the backend into specific states on demand — - running, unreachable, reconfigured. This document standardises how. - - This specification is derived from the control endpoints already implemented - by [`flagd-testbed`](https://github.com/open-feature/flagd-testbed)'s - "launchpad" server, which is the reference implementation. - - ## Where this document should live - - This file currently ships inside the Java `provider-tck` artifact, but it is - not a Java artifact: it is a language-agnostic contract that every language's - TCK must implement identically, and that backend vendors implement in - whatever language their testbed is written in (Go, for flagd). - - It therefore belongs in the OpenFeature **spec** repository - (`open-feature/spec`), alongside the canonical Gherkin feature files and the - canonical flag set. Those three artifacts are a single unit — a feature file - that evaluates `boolean-flag` is meaningless without the flag definition, and - a disconnect scenario is meaningless without the endpoint that produces the - disconnect. Splitting them across repositories would let them drift. - - Each language's TCK then vendors the spec repo (git submodule or equivalent) - and packages these files into its own distribution format, so that adopting a - TCK never requires a consumer to check out a submodule of their own. - - ## Conformance language - - The key words MUST, MUST NOT, REQUIRED, SHOULD, SHOULD NOT and MAY are to be - interpreted as described in RFC 2119. - - Each operation below is tagged **REQUIRED** or **OPTIONAL**. A backend that - implements every REQUIRED operation can run the full TCK. OPTIONAL operations - have a defined fallback that the TCK applies automatically, so omitting them - costs nothing but precision. - - --- - - ## Normative requirement 1 — the no-container-restart invariant - - > **Container lifecycle operations MUST NOT be used to simulate backend - > unavailability. Backend unavailability MUST be simulated from inside the - > running stack.** - - The TCK starts the vendor's Docker Compose stack **once per test suite** and - reads the dynamically mapped host ports. Testcontainers cannot reliably - preserve mapped ports across a container stop/start in all language - bindings — a restarted container generally comes back on a *different* host - port, which silently invalidates every provider instance already pointed at - the old one. Any TCK implementation in any language hits this, so the - constraint is part of the contract rather than a Java detail. - - Therefore an implementation of `/stop`, `/restart` or any other outage - simulation MUST achieve the outage by one of: - - * killing or suspending the backend **process** inside its container - (the reference behaviour — this is what flagd-testbed does); - * a proxy in the stack refusing or blackholing connections - (e.g. a toxiproxy toxic, an envoy `direct_response`); - * an in-container firewall or socket-level block. - - An implementation MUST NOT `docker stop`, `docker kill`, `docker rm` or - recreate any container in the stack while the suite is running. The stack is - brought up before the first scenario and torn down after the last one, and - the mapped ports MUST remain stable for that entire window. - - --- - - ## Normative requirement 2 — flag state semantics across outages - - Outage simulation and flag-state seeding are orthogonal, and the TCK relies - on that separation for scenario isolation: - - * `POST /start` **MUST** (re)seed flag state to the baseline defined by the - named configuration. Any mutation previously applied by `POST /change` - MUST be discarded. This is what makes `/start` usable as a reset. - * `POST /restart` and a `POST /stop` followed by a `POST /start` **of the - same configuration** MUST leave the backend serving the same baseline - flag state it served before the outage. An outage MUST NOT be observable - as a change in flag *values* — only as a change in *availability*. - * `POST /change` mutations persist until the next `/start` or `/reset`. - - --- - - ## Normative requirement 3 — compose stack conventions - - The backend under test is delivered as a **Docker Compose stack**, not a - single image, so vendors can compose proxies, edge services or several - containers. The TCK only relies on these conventions: - - * One service — by default named `backend`, overridable by the provider - author — exposes the control API on container-internal port `8080` - (also overridable). - * The same stack exposes whatever port(s) the provider connects to. - * **All external ports are dynamically mapped.** A stack MUST NOT pin host - ports; the TCK discovers them after startup and hands them to the - provider factory. - * The stack MAY contain any number of additional services. - - --- - - ## Known gap — evaluation context passthrough - - There is currently no operation for asserting that an evaluation context sent - by the provider actually reached the backend intact. Verifying that requires - an echo mechanism (e.g. `GET /last-evaluation` returning the most recent - request the backend received). Until such an operation exists, context - passthrough is out of scope for the TCK. - - license: - name: Apache 2.0 - url: https://www.apache.org/licenses/LICENSE-2.0 - -servers: - - url: http://{host}:{port} - description: | - Resolved at runtime from the Compose stack. `host` is the Docker host and - `port` is the dynamically mapped host port for the control service's - internal port 8080. - variables: - host: - default: localhost - port: - default: "8080" - -tags: - - name: lifecycle - description: Start and stop the backend process. - - name: availability - description: Simulate outages without touching containers. - - name: flags - description: Seed and mutate flag configuration. - - name: health - description: Readiness of the control API itself. - -paths: - - /start: - post: - tags: [lifecycle] - operationId: start - summary: "[REQUIRED] Start the backend and seed flags to a named baseline" - description: | - Starts the backend process using the named configuration and seeds flag - state to that configuration's baseline. - - MUST be idempotent in the sense that calling it while the backend is - already running is not an error: the implementation restarts the process - (or otherwise ensures it is running) with the requested configuration. - - **MUST NOT return until the seeded flag state is actually being served.** - A 200 is a promise that the very next evaluation will resolve against the - new baseline. Returning as soon as the process reports healthy is not - enough: a backend can accept connections and answer a readiness probe - while its flag store is still empty, and an evaluation in that window - gets `FLAG_NOT_FOUND` for a flag the configuration plainly defines. - - This is easy to get wrong and easy to miss. A provider that blocks during - initialisation -- streaming, or syncing a ruleset -- absorbs the window - and never sees it. A **stateless** provider, which evaluates over HTTP - with no initialisation at all, has nothing to hide it behind and fails - essentially every scenario, which reads as a catastrophically broken - provider rather than as a racing testbed. The reference implementation - exhibits this: its `/start` returns roughly 40ms before flagd's file - sources reach the flag store. - - A TCK MAY defensively probe after `/start`, but it should not have to, - and requiring every stateless adopter to reimplement that probe is worse - than stating the requirement here. - - Because this operation resets flag state, the TCK uses it as its default - scenario-isolation mechanism when `/reset` is not implemented. - - The set of valid configuration names is vendor-defined. Every - implementation MUST support the name `default`, which MUST serve the - canonical flag set the TCK's feature files assume. - - Reference implementation: flagd-testbed launches the `flagd` binary with - the config file of that name from `launchpad/configs` and rewrites - `/flags/allFlags.json`. - parameters: - - name: config - in: query - required: false - description: | - Name of the configuration to start with. Defaults to `default`. - schema: - type: string - default: default - example: default - responses: - "200": - description: Backend started and flag state seeded. - "400": - description: Unknown configuration name. - content: - application/json: - schema: - $ref: "#/components/schemas/Error" - - /stop: - post: - tags: [availability] - operationId: stop - summary: "[REQUIRED] Make the backend unreachable" - description: | - Makes the backend unreachable to the provider, simulating an outage. - - **MUST NOT stop the container.** See normative requirement 1. The - reference implementation kills the flagd process while its container - keeps running. - - The backend stays unreachable until a subsequent `POST /start`. Calling - `/stop` when the backend is already stopped MUST succeed. - - The TCK uses this to drive providers into `STALE` and `ERROR` states and - to assert `PROVIDER_STALE` / `PROVIDER_ERROR` events. - responses: - "200": - description: Backend is now unreachable; container still running. - - /restart: - post: - tags: [availability] - operationId: restart - summary: "[REQUIRED] Simulate an outage of a bounded duration" - description: | - Makes the backend unreachable, waits `seconds`, then starts it again with - the configuration currently in effect. - - Flag state MUST be preserved across the outage — see normative - requirement 2. This is what distinguishes `/restart` from - `/stop` + `/start`: the former is an availability event, the latter is - also a reset. - - This operation MAY return as soon as the outage has begun rather than - blocking for the full duration; the TCK does not rely on the response - being delayed. It awaits provider events instead. - - The TCK uses this for the disconnect/reconnect scenarios: `STALE` → - `PROVIDER_STALE`, then back to `READY` → `PROVIDER_READY`. - parameters: - - name: seconds - in: query - required: false - description: | - How long the backend stays unreachable. Defaults to 5. - - Providers differ enormously in how fast they notice an outage — - a streaming provider may see it in milliseconds while a polling - provider needs up to a full poll interval. Feature files therefore - parameterise this value and provider authors tune the matching - await timeouts. - schema: - type: integer - format: int32 - minimum: 0 - default: 5 - example: 5 - responses: - "200": - description: Outage started (and, for blocking implementations, ended). - - /change: - post: - tags: [flags] - operationId: change - summary: "[REQUIRED] Mutate flag configuration so the provider observes a change" - description: | - Mutates the flag configuration such that a conforming provider observes a - configuration change and, on re-evaluation, resolves a **different value** - for the affected flag. - - The implementation MUST: - - * change the resolved value of the flag with key `changing-flag`; - * do so without restarting the backend process, so that a provider sees - a configuration-change signal rather than a reconnect; - * make the change durable until the next `/start` or `/reset`. - - The implementation SHOULD toggle between exactly two known values so that - repeated calls are meaningful and the test remains deterministic - regardless of how many times it has run against the same stack. The - reference implementation toggles `changing-flag`'s `defaultVariant` - between `foo` and `bar`. - - The TCK uses this to assert `PROVIDER_CONFIGURATION_CHANGED`, that the - changed flag key appears in the event payload, and that a subsequent - evaluation returns the new value. - responses: - "200": - description: Flag configuration mutated. - - /reset: - post: - tags: [flags] - operationId: reset - summary: "[OPTIONAL] Restore the seeded baseline without an outage" - description: | - Restores flag state to the baseline of the configuration currently in - effect, discarding any mutation applied by `/change`, **without** making - the backend unreachable at any point. - - This is the preferred scenario-isolation primitive: unlike `/start` it - causes no availability blip, so it cannot inject spurious lifecycle - events into the next scenario. - - **Scope.** This operation resets flag state only. It MUST NOT be - expected to start a backend that is currently stopped — that is what - `/start` is for. A TCK therefore uses `/reset` only when the backend is - known to be running, and `/start` otherwise. The reference client tracks - this: `/stop` and `/restart` mark the backend as possibly-unreachable, so - the scenario that follows either of them is prepared with `/start`. - - **Fallback when not implemented.** A backend that does not implement this - operation MUST respond `404` or `501`. The TCK then falls back to - `POST /start?config={defaultConfig}`, which resets flag state at the cost - of a process restart. The fallback is detected once per suite and cached. - - Implementing `/reset` is RECOMMENDED for providers whose reconnect - behaviour makes the `/start` blip hard to distinguish from a real event. - responses: - "200": - description: Flag state restored to the baseline. - "404": - description: Not implemented; the TCK falls back to `/start`. - "501": - description: Not implemented; the TCK falls back to `/start`. - - /healthz: - get: - tags: [health] - operationId: health - summary: "[OPTIONAL] Readiness of the control API" - description: | - Reports whether the control API is ready to accept commands. - - **Fallback when not implemented.** Readiness defaults to "the control - port accepts a TCP connection", which the TCK establishes with a - Testcontainers listening-port wait strategy before the first scenario. A - `404` here is therefore not a failure, and the reference implementation - does not serve this path. - - Note this reports the health of the **control API**, not of the backend. - The backend is deliberately unhealthy during outage scenarios while the - control API must stay reachable — otherwise the TCK could not end the - outage. - responses: - "200": - description: Control API ready. - content: - application/json: - schema: - $ref: "#/components/schemas/Health" - "404": - description: Not implemented; readiness falls back to a TCP port check. - "503": - description: Control API not ready yet. - -components: - schemas: - - Health: - type: object - properties: - status: - type: string - enum: [ok] - description: Present and equal to `ok` when the control API is ready. - required: [status] - - Error: - type: object - properties: - message: - type: string - description: Human-readable explanation. Never interpreted by the TCK. - required: [message] From 5cb4fbbcf5d3dd12c83833726d1d626341d9ed97 Mon Sep 17 00:00:00 2001 From: Simon Schrottner Date: Fri, 11 Sep 2026 11:56:35 +0200 Subject: [PATCH 09/55] style(provider-tck): reformat the in-memory self-test to what spotless produces A capability list committed with one entry per line, which the formatter joins onto one. Left as it was, spotless:check fails the module before any test runs. Signed-off-by: Simon Schrottner --- .../contrib/tools/providertck/InMemoryProviderTckTest.java | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/tools/provider-tck/src/test/java/dev/openfeature/contrib/tools/providertck/InMemoryProviderTckTest.java b/tools/provider-tck/src/test/java/dev/openfeature/contrib/tools/providertck/InMemoryProviderTckTest.java index 3774e6d269..2c6b4fa6d4 100644 --- a/tools/provider-tck/src/test/java/dev/openfeature/contrib/tools/providertck/InMemoryProviderTckTest.java +++ b/tools/provider-tck/src/test/java/dev/openfeature/contrib/tools/providertck/InMemoryProviderTckTest.java @@ -73,9 +73,6 @@ public FeatureProvider createProvider() { @Override public Set capabilities() { return EnumSet.of( - Capability.EVENTS, - Capability.CONFIGURATION_CHANGE, - Capability.OBJECT, - Capability.NUMERIC_COERCION); + Capability.EVENTS, Capability.CONFIGURATION_CHANGE, Capability.OBJECT, Capability.NUMERIC_COERCION); } } From 1f5f7861c4554567b351c21a7847e1285d8aefea Mon Sep 17 00:00:00 2001 From: Simon Schrottner Date: Fri, 11 Sep 2026 11:56:55 +0200 Subject: [PATCH 10/55] feat(provider-tck): let adopters add scenarios by convention A vendor with provider-specific features -- flagd's fractional targeting, a proprietary evaluation mode -- had no way to test them inside this suite. The only option was a second Cucumber runner of their own, which means a second backend lifecycle to start and a second copy of this suite's configuration to keep in step with it. Answers @toddbaert's review request on open-feature/spec#423. The suite now also selects the classpath directory tck-extensions/ and the glue package openfeature.tck.extensions. An adopter writes two files and no annotations: src/test/resources/tck-extensions/fractional.feature src/test/java/openfeature/tck/extensions/FractionalSteps.java Their scenarios are discovered into the same suite and the same Cucumber engine, and therefore the same @BeforeAll -- one backend lifecycle, one BackendControl. The canonical steps are on the glue path too, so an extension scenario can open with `Given a stable provider` and continue with whatever is specific to that provider. The extension directory is deliberately not features/ and not a subdirectory of it. Measured on this module: two classpath roots holding the same directory are scanned additively, but two holding the same directory *and* the same file name are not -- one wins silently and the other file is never read, with test-classes beating the jar. An adopter who put features/errors.feature in their test resources would replace a canonical file with their own and watch the suite report success having run theirs. A distinct name makes that collision unreachable rather than documented. The directory ships inside the jar holding nothing but a README, because a @SelectClasspathResource naming a resource that exists on no classpath root is a hard discovery error rather than an empty selection -- so an adopter who extends nothing must still resolve it. Cucumber ignores files that are not .feature, and tolerates a glue package that does not exist, so the unused extension point costs an adopter nothing. Also adds ProviderTck, which names every value the suite's annotations carry. An annotation value has to be a compile-time constant, so an adopter who writes a @ConfigurationParameter of their own cannot compute one; without the constants they would restate our package name or our object factory as a string literal that nothing keeps in step. Constant concatenation is legal in an annotation value, so ProviderTck.ALL_GLUE + ",com.vendor.steps" is what they write instead. The TCK's own fixture -- tck-extensions/extension-selftest.feature and a step class in openfeature.tck.extensions -- is test-scoped, so it is not in the released jar and cannot reach an adopter's run. It sits exactly where an adopter's would, which is the only way to check the convention rather than assert it about a path no build uses. Signed-off-by: Simon Schrottner --- tools/provider-tck/README.md | 55 ++++++ .../tools/providertck/ProviderTck.java | 98 ++++++++++ .../tools/providertck/ProviderTckTest.java | 47 ++++- .../tools/providertck/TckSuiteListener.java | 8 +- .../main/resources/tck-extensions/README.md | 43 ++++ .../tools/providertck/ExtensionPointTest.java | 184 ++++++++++++++++++ .../tools/providertck/TckSuiteFixture.java | 43 ++++ .../extensions/ExtensionSelfTestSteps.java | 40 ++++ .../tck-extensions/extension-selftest.feature | 14 ++ 9 files changed, 525 insertions(+), 7 deletions(-) create mode 100644 tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/ProviderTck.java create mode 100644 tools/provider-tck/src/main/resources/tck-extensions/README.md create mode 100644 tools/provider-tck/src/test/java/dev/openfeature/contrib/tools/providertck/ExtensionPointTest.java create mode 100644 tools/provider-tck/src/test/java/dev/openfeature/contrib/tools/providertck/TckSuiteFixture.java create mode 100644 tools/provider-tck/src/test/java/openfeature/tck/extensions/ExtensionSelfTestSteps.java create mode 100644 tools/provider-tck/src/test/resources/tck-extensions/extension-selftest.feature diff --git a/tools/provider-tck/README.md b/tools/provider-tck/README.md index a1b86591ac..1feae7cb8a 100644 --- a/tools/provider-tck/README.md +++ b/tools/provider-tck/README.md @@ -315,6 +315,61 @@ and if you register more than one, select between them with +## Adding your own scenarios + +A provider with features of its own — flagd's `fractional` targeting, a vendor's proprietary +evaluation mode — extends the suite rather than maintaining a second one. Two files, no annotations: + +``` +src/test/resources/tck-extensions/fractional.feature +src/test/java/openfeature/tck/extensions/FractionalSteps.java // package openfeature.tck.extensions +``` + +That is the whole extension point. Both are already selected by `ProviderTckTest`, so your scenarios +run **inside** the suite: same backend lifecycle, same `@BeforeAll`, same `BackendControl`. Step +classes may take `TckState` as a constructor argument exactly as the canonical steps do, and reach +the backend control and the backend endpoint through `TckRuntime.get()`. Canonical steps are on the +glue path too, so an extension scenario can open with `Given a stable provider` and go on to whatever +is specific to your provider. + +The alternative — your own Cucumber runner — is a second backend lifecycle to start and a second copy +of this suite's configuration to keep in step with it. + +**Why `tck-extensions/` and not `features/`.** Two classpath roots holding the same directory are +scanned additively; two holding the same directory *and* the same file name are not — one wins +silently and the other file is never read. A `features/errors.feature` in your test resources would +therefore *replace* the canonical file, and the suite would report success having run yours. The +extension directory has a different name so that collision cannot be reached by accident. `features/` +is the canonical set and belongs to the specification; extensions are yours. If a scenario is +portable across providers, send it to the TCK rather than keeping it as an extension. + +The directory is shipped in this JAR containing only a README, because a classpath resource selector +naming a resource that exists on no classpath root is a hard discovery error rather than an empty +selection. An adopter who extends nothing therefore still resolves it, and pays nothing for the glue +package either — Cucumber tolerates a glue package that does not exist. + +### The suite's configuration as constants + +`ProviderTck` names every value the suite's annotations carry, so that an adopter who does write a +`@ConfigurationParameter` composes rather than copies: + +```java +@ConfigurationParameter(key = Constants.GLUE_PROPERTY_NAME, value = ProviderTck.ALL_GLUE + ",com.vendor.steps") +``` + +| Constant | Value | +|---|---| +| `ProviderTck.FEATURES` | `features` — the canonical set, reserved | +| `ProviderTck.EXTENSIONS` | `tck-extensions` — where yours go | +| `ProviderTck.GLUE` | the canonical step definitions package | +| `ProviderTck.EXTENSION_GLUE` | `openfeature.tck.extensions` | +| `ProviderTck.ALL_GLUE` | both, comma-separated — what the suite runs with | +| `ProviderTck.PLUGINS`, `PARALLEL_EXECUTION_ENABLED`, `FEATURE_EXECUTION_MODE`, `OBJECT_FACTORY` | the rest of the Cucumber configuration | + +An annotation value has to be a compile-time constant, so a method call would not compile there; +constant concatenation does. If you add a glue package this way, keep `ProviderTck.GLUE` in the +value — dropping it makes every canonical step undefined. + ## Declaring capabilities Not every provider implements every optional part of the spec. Scenarios that exercise an optional diff --git a/tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/ProviderTck.java b/tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/ProviderTck.java new file mode 100644 index 0000000000..59e0dcd061 --- /dev/null +++ b/tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/ProviderTck.java @@ -0,0 +1,98 @@ +package dev.openfeature.contrib.tools.providertck; + +/** + * The values {@link ProviderTckTest} configures Cucumber with, as compile-time constants. + * + *

Every one of these is already implied by the suite's annotations. They are named here because + * an annotation value has to be a compile-time constant, so an adopter who adds a + * {@code @ConfigurationParameter} of their own cannot compute one — they would otherwise have to + * restate our package name, our resource directory or our object factory as a string literal, and a + * literal copy of someone else's configuration is a copy that goes stale without anything failing. + * Annotation values permit constant concatenation, so {@code ProviderTck.ALL_GLUE + ",com.vendor.steps"} + * is legal where a method call is not. + * + *

A class of its own rather than constants on the suite. The values describe the TCK's classpath + * conventions rather than the behaviour of a suite, and things that are not suites read them: a build + * check, a custom launcher, a test that asserts the extension point still works. Putting them on + * {@link ProviderTckTest} would also inherit the whole namespace into every adopter's suite class, + * where {@code GLUE} would show up as a member of their own type. + * + *

Nothing here is a setting. Changing what the suite passes to Cucumber means changing the + * annotations on {@link ProviderTckTest}; these constants follow that, they do not drive it. + */ +public final class ProviderTck { + + /** + * Classpath directory holding the canonical feature files, packaged inside this JAR. + * + *

Reserved for the conformance suite. A feature file an adopter adds here does not extend the + * canonical set, and one that collides with a canonical file name silently replaces it — see + * {@link #EXTENSIONS}. + */ + public static final String FEATURES = "features"; + + /** + * Classpath directory an adopter puts their own feature files in. + * + *

Deliberately not a subdirectory of {@link #FEATURES}, and deliberately a different name. + * The same directory name in two classpath roots is scanned additively, but the same directory + * and file name is not: one root wins and the other file is never read, with no warning. + * An adopter who dropped {@code features/errors.feature} beside ours would therefore replace a + * canonical file with their own and watch the suite go green having run theirs — the worst + * outcome available to a conformance suite. A separate directory makes that collision impossible + * to reach by accident. + * + *

Shipped in this JAR containing only a README, because + * {@link org.junit.platform.suite.api.SelectClasspathResource} on a resource that exists nowhere + * on the classpath is a discovery error rather than an empty selection. Cucumber ignores files + * that are not {@code .feature}, so the README costs nothing. + */ + public static final String EXTENSIONS = "tck-extensions"; + + /** Package holding the canonical step definitions. */ + public static final String GLUE = "dev.openfeature.contrib.tools.providertck.steps"; + + /** + * Package an adopter puts their own step definitions in. + * + *

Outside this artifact's package namespace on purpose: it is the adopter's package, not + * ours, and it lives in their source tree. A glue package that does not exist is tolerated + * silently by Cucumber, so an adopter who writes no extensions pays nothing for it being on the + * glue path. + */ + public static final String EXTENSION_GLUE = "openfeature.tck.extensions"; + + /** + * The glue path the suite runs with: the canonical steps and the extension package. + * + *

Concatenate to add more, as {@code ProviderTck.ALL_GLUE + ",com.vendor.steps"}. Note that + * an adopter adding a package this way has to keep the canonical one, or every canonical step + * becomes undefined. + */ + public static final String ALL_GLUE = GLUE + "," + EXTENSION_GLUE; + + /** + * The Cucumber plugins the suite registers. + * + *

Just Cucumber's own summary. The suite runs the scenarios and reports them through JUnit; + * turning a run into a machine-readable conformance report is a separate concern that registers + * its own plugin here. + */ + public static final String PLUGINS = "summary"; + + /** + * Whether scenarios may run in parallel: never. + * + *

Backend state is global to the suite, so concurrent scenarios corrupt each other. The suite + * pins this where it overrides a consuming module's {@code junit-platform.properties}. + */ + public static final String PARALLEL_EXECUTION_ENABLED = "false"; + + /** Execution mode for the scenarios of one feature, matching {@link #PARALLEL_EXECUTION_ENABLED}. */ + public static final String FEATURE_EXECUTION_MODE = "same_thread"; + + /** Object factory that injects {@link TckState} into every step class. */ + public static final String OBJECT_FACTORY = "io.cucumber.picocontainer.PicoFactory"; + + private ProviderTck() {} +} diff --git a/tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/ProviderTckTest.java b/tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/ProviderTckTest.java index f3ac3f43aa..94bf6373a1 100644 --- a/tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/ProviderTckTest.java +++ b/tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/ProviderTckTest.java @@ -44,15 +44,50 @@ * {@code dev.openfeature.contrib.tools.providertck.steps}, which reach the harness and its * {@link BackendControl} through {@link TckRuntime}. * + *

Adding your own scenarios

+ * + *

A provider with features of its own — flagd's {@code fractional} targeting, a vendor's + * proprietary evaluation mode — puts feature files in {@code src/test/resources/tck-extensions/} and + * step definitions in the package {@code openfeature.tck.extensions}, and writes no annotations. + * Both are selected here, so the extra scenarios run inside this suite: same backend lifecycle, same + * {@code @BeforeAll}, same {@link BackendControl}. The alternative — a second suite of one's own — + * is a second backend lifecycle to start and a second set of runner configuration to keep in step + * with this one. + * + *

The extension directory is not {@code features/} and is not a subdirectory of it, for + * a measured reason. Two classpath roots that contain the same directory are scanned additively, but + * two that contain the same directory and the same file name are not: one wins silently and + * the other file is never read. An adopter who put {@code features/errors.feature} in their test + * resources would replace a canonical feature with their own and see the suite pass — a conformance + * suite reporting success for questions it never asked. A distinct directory name removes the + * collision rather than documenting it. + * + *

The directory is shipped inside this JAR holding nothing but a README, because + * {@link SelectClasspathResource} on a resource that exists on no classpath root is a discovery + * error, not an empty selection. An adopter who adds nothing therefore still resolves it. The + * extension glue package costs nothing when unused either: Cucumber tolerates a glue package that + * does not exist. + * + *

Every value these annotations carry is named in {@link ProviderTck}. An adopter who does write a + * {@code @ConfigurationParameter} of their own composes from those constants — + * {@code ProviderTck.ALL_GLUE + ",com.vendor.steps"} — rather than restating this configuration as a + * string literal that nothing would keep in step. + * * @see ProviderTckHarness * @see ContainerizedProviderTckTest + * @see ProviderTck */ @Suite @IncludeEngines("cucumber") -@SelectClasspathResource("features") -@ConfigurationParameter(key = Constants.PLUGIN_PROPERTY_NAME, value = "summary") -@ConfigurationParameter(key = Constants.PARALLEL_EXECUTION_ENABLED_PROPERTY_NAME, value = "false") -@ConfigurationParameter(key = Constants.EXECUTION_MODE_FEATURE_PROPERTY_NAME, value = "same_thread") -@ConfigurationParameter(key = Constants.GLUE_PROPERTY_NAME, value = "dev.openfeature.contrib.tools.providertck.steps") -@ConfigurationParameter(key = Constants.OBJECT_FACTORY_PROPERTY_NAME, value = "io.cucumber.picocontainer.PicoFactory") +@SelectClasspathResource(ProviderTck.FEATURES) +@SelectClasspathResource(ProviderTck.EXTENSIONS) +@ConfigurationParameter(key = Constants.PLUGIN_PROPERTY_NAME, value = ProviderTck.PLUGINS) +@ConfigurationParameter( + key = Constants.PARALLEL_EXECUTION_ENABLED_PROPERTY_NAME, + value = ProviderTck.PARALLEL_EXECUTION_ENABLED) +@ConfigurationParameter( + key = Constants.EXECUTION_MODE_FEATURE_PROPERTY_NAME, + value = ProviderTck.FEATURE_EXECUTION_MODE) +@ConfigurationParameter(key = Constants.GLUE_PROPERTY_NAME, value = ProviderTck.ALL_GLUE) +@ConfigurationParameter(key = Constants.OBJECT_FACTORY_PROPERTY_NAME, value = ProviderTck.OBJECT_FACTORY) public abstract class ProviderTckTest implements ProviderTckHarness {} diff --git a/tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/TckSuiteListener.java b/tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/TckSuiteListener.java index 31251ed3fc..0d688057e7 100644 --- a/tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/TckSuiteListener.java +++ b/tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/TckSuiteListener.java @@ -62,7 +62,13 @@ static Optional> currentSuite() { return Optional.ofNullable(current); } - private static Optional> harnessClassOf(TestIdentifier testIdentifier) { + /** + * Returns the TCK suite class a test identifier stands for, if it is one. + * + * @param testIdentifier the identifier to inspect + * @return the concrete suite class, or empty when the identifier is not a TCK suite + */ + static Optional> harnessClassOf(TestIdentifier testIdentifier) { return testIdentifier .getSource() .filter(ClassSource.class::isInstance) diff --git a/tools/provider-tck/src/main/resources/tck-extensions/README.md b/tools/provider-tck/src/main/resources/tck-extensions/README.md new file mode 100644 index 0000000000..84da3d306b --- /dev/null +++ b/tools/provider-tck/src/main/resources/tck-extensions/README.md @@ -0,0 +1,43 @@ +# Provider TCK extension point + +Feature files placed on the classpath under `tck-extensions/` run inside the TCK suite, alongside +the canonical conformance scenarios. + +This file is here so that the directory exists on the classpath even when nobody has extended +anything. `ProviderTckTest` selects `tck-extensions` unconditionally, and a classpath resource +selector naming a resource that exists on no classpath root is a discovery error rather than an +empty selection. Cucumber ignores files that are not `.feature`, so the README itself is never read +as a scenario. + +## Adding scenarios + +Write nothing but the two files: + +``` +src/test/resources/tck-extensions/fractional.feature +src/test/java/openfeature/tck/extensions/FractionalSteps.java // package openfeature.tck.extensions +``` + +No annotations, no second suite, no runner configuration. The scenarios are discovered by the same +suite as the canonical set, so they share its backend lifecycle: one `@BeforeAll`, one backend, the +same `BackendControl`. + +Your step classes may take `dev.openfeature.contrib.tools.providertck.TckState` as a constructor +argument to reach the client and the last evaluation, exactly as the canonical steps do, and +`TckRuntime.get()` for the backend control and the backend endpoint. + +## Why this directory rather than `features/` + +Two classpath roots containing the same directory are scanned additively. Two containing the same +directory *and* the same file name are not: one silently wins. A feature file added to `features/` +under a canonical name would therefore replace a canonical file, and the suite would report success +having run the replacement. The extension directory has a different name so that collision cannot +be reached by accident. + +`features/` is the canonical set and belongs to the specification. Extensions are yours. + +## What extensions are not + +An extension scenario is not conformance. It does not appear in the canonical set, it cannot make +the canonical set smaller, and a conformance claim is not a claim about it. If a scenario is +portable across providers it belongs in `features/` — send it to the TCK. diff --git a/tools/provider-tck/src/test/java/dev/openfeature/contrib/tools/providertck/ExtensionPointTest.java b/tools/provider-tck/src/test/java/dev/openfeature/contrib/tools/providertck/ExtensionPointTest.java new file mode 100644 index 0000000000..afc80003e6 --- /dev/null +++ b/tools/provider-tck/src/test/java/dev/openfeature/contrib/tools/providertck/ExtensionPointTest.java @@ -0,0 +1,184 @@ +package dev.openfeature.contrib.tools.providertck; + +import static org.assertj.core.api.Assertions.assertThat; + +import io.cucumber.junit.platform.engine.Constants; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.function.Predicate; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.platform.engine.TestSource; +import org.junit.platform.engine.discovery.DiscoverySelectors; +import org.junit.platform.engine.support.descriptor.ClasspathResourceSource; +import org.junit.platform.launcher.TestIdentifier; +import org.junit.platform.launcher.TestPlan; +import org.junit.platform.launcher.core.LauncherDiscoveryRequestBuilder; +import org.junit.platform.launcher.core.LauncherFactory; +import org.junit.platform.launcher.listeners.SummaryGeneratingListener; +import org.junit.platform.suite.api.ConfigurationParameter; + +/** + * An adopter extends the suite by convention, writing no annotations. + * + *

What that promise decomposes into, and what each test here checks: + * + *

    + *
  • a feature file under {@code tck-extensions/} on the adopter's test classpath is discovered by + * the suite, into the same Cucumber engine as the canonical set — which is what "the same + * backend lifecycle phase" means, since {@code @BeforeAll} is scoped to exactly that; + *
  • a step class in {@code openfeature.tck.extensions} is resolved from the glue path; + *
  • the extension directory shipped in this artifact keeps the selector resolvable for an adopter + * who extends nothing. + *
+ * + *

The fixture feature and steps are test-scoped, so they are not in the released JAR. They live + * where an adopter's would live, which is the only way to check that the convention holds without + * asserting it about a path that no build actually uses. + */ +class ExtensionPointTest { + + private static final String EXTENSION_FEATURE = ProviderTck.EXTENSIONS + "/extension-selftest.feature"; + + @Test + @DisplayName("an extension feature is discovered into the same suite and engine as the canonical set") + void extensionsJoinTheCanonicalSuite() { + TestPlan plan = discover(LauncherDiscoveryRequestBuilder.request() + .selectors(DiscoverySelectors.selectClass(TckSuiteFixture.class))); + + TestIdentifier suite = only( + plan, identifier -> TckSuiteListener.harnessClassOf(identifier).isPresent()); + TestIdentifier cucumber = only( + plan, identifier -> "cucumber".equals(engineIdOf(identifier)) && isDescendant(plan, identifier, suite)); + + List resources = new ArrayList<>(); + for (TestIdentifier test : testsUnder(plan, cucumber)) { + resourceOf(test).ifPresent(resources::add); + } + + assertThat(resources) + .as("the extension scenario and the canonical scenarios are children of one Cucumber engine, " + + "under one suite — so they share its @BeforeAll and its backend") + .contains(EXTENSION_FEATURE) + .contains(ProviderTck.FEATURES + "/errors.feature"); + } + + @Test + @DisplayName("the extension glue package is resolved, so an adopter's steps need no registration") + void theExtensionGluePackageIsResolved() { + // Executed rather than discovered, because an unresolved glue package is not a discovery + // failure — it produces undefined steps at execution time, which is what this rules out. + // Only the extension glue is on the path here: the canonical steps would start a Compose + // stack in their @BeforeAll, and Docker is not this module's test dependency. + SummaryGeneratingListener summary = new SummaryGeneratingListener(); + LauncherFactory.create() + .execute( + LauncherDiscoveryRequestBuilder.request() + .selectors(DiscoverySelectors.selectClasspathResource(EXTENSION_FEATURE)) + .configurationParameter(Constants.GLUE_PROPERTY_NAME, ProviderTck.EXTENSION_GLUE) + .configurationParameter( + Constants.PARALLEL_EXECUTION_ENABLED_PROPERTY_NAME, + ProviderTck.PARALLEL_EXECUTION_ENABLED) + .configurationParameter( + Constants.OBJECT_FACTORY_PROPERTY_NAME, ProviderTck.OBJECT_FACTORY) + .build(), + summary); + + assertThat(summary.getSummary().getTestsSucceededCount()) + .as("the fixture scenario ran with its steps resolved from %s", ProviderTck.EXTENSION_GLUE) + .isEqualTo(1); + assertThat(summary.getSummary().getTotalFailureCount()).isZero(); + } + + @Test + @DisplayName("the extension directory ships in this artifact, so the selector resolves with no adopter files") + void theExtensionDirectoryIsShipped() { + // A @SelectClasspathResource naming a resource on no classpath root is a hard discovery + // error, so an adopter who extends nothing depends on this file existing in the JAR. + assertThat(getClass().getClassLoader().getResource(ProviderTck.EXTENSIONS + "/README.md")) + .as( + "%s/README.md keeps the extension selector resolvable for an adopter who adds nothing", + ProviderTck.EXTENSIONS) + .isNotNull(); + } + + @Test + @DisplayName("the glue constant composes in an annotation value") + void theGlueConstantComposesInAnAnnotationValue() { + // The assertion is a formality; the compilation of VendorGlue is the actual evidence, since + // an annotation value has to be a compile-time constant and a method call would not compile. + ConfigurationParameter parameter = VendorGlue.class.getAnnotation(ConfigurationParameter.class); + + assertThat(parameter.value()) + .isEqualTo("dev.openfeature.contrib.tools.providertck.steps," + + "openfeature.tck.extensions,com.vendor.steps"); + } + + /** An adopter who wants a third glue package writes this, and does not restate our package. */ + @ConfigurationParameter(key = Constants.GLUE_PROPERTY_NAME, value = ProviderTck.ALL_GLUE + ",com.vendor.steps") + private static final class VendorGlue {} + + private static TestPlan discover(LauncherDiscoveryRequestBuilder request) { + return LauncherFactory.create().discover(request.build()); + } + + private static Optional resourceOf(TestIdentifier identifier) { + Optional source = identifier.getSource(); + if (!source.isPresent() || !(source.get() instanceof ClasspathResourceSource)) { + return Optional.empty(); + } + return Optional.of(((ClasspathResourceSource) source.get()).getClasspathResourceName()); + } + + private static String engineIdOf(TestIdentifier identifier) { + return identifier.getUniqueIdObject().getLastSegment().getType().equals("engine") + ? identifier.getUniqueIdObject().getLastSegment().getValue() + : null; + } + + private static boolean isDescendant(TestPlan plan, TestIdentifier identifier, TestIdentifier ancestor) { + Optional parent = plan.getParent(identifier); + while (parent.isPresent()) { + if (parent.get().equals(ancestor)) { + return true; + } + parent = plan.getParent(parent.get()); + } + return false; + } + + private static List testsUnder(TestPlan plan, TestIdentifier root) { + List tests = new ArrayList<>(); + collectTests(plan, root, tests); + return tests; + } + + private static void collectTests(TestPlan plan, TestIdentifier identifier, List into) { + if (identifier.isTest()) { + into.add(identifier); + } + for (TestIdentifier child : plan.getChildren(identifier)) { + collectTests(plan, child, into); + } + } + + private static TestIdentifier only(TestPlan plan, Predicate predicate) { + List matching = new ArrayList<>(); + for (TestIdentifier root : plan.getRoots()) { + collectMatching(plan, root, predicate, matching); + } + assertThat(matching).hasSize(1); + return matching.get(0); + } + + private static void collectMatching( + TestPlan plan, TestIdentifier identifier, Predicate predicate, List into) { + if (predicate.test(identifier)) { + into.add(identifier); + } + for (TestIdentifier child : plan.getChildren(identifier)) { + collectMatching(plan, child, predicate, into); + } + } +} diff --git a/tools/provider-tck/src/test/java/dev/openfeature/contrib/tools/providertck/TckSuiteFixture.java b/tools/provider-tck/src/test/java/dev/openfeature/contrib/tools/providertck/TckSuiteFixture.java new file mode 100644 index 0000000000..0237a6b122 --- /dev/null +++ b/tools/provider-tck/src/test/java/dev/openfeature/contrib/tools/providertck/TckSuiteFixture.java @@ -0,0 +1,43 @@ +package dev.openfeature.contrib.tools.providertck; + +import dev.openfeature.sdk.FeatureProvider; +import dev.openfeature.sdk.NoOpProvider; +import java.io.File; +import java.util.Collections; +import java.util.List; + +/** + * A concrete TCK suite, used by these tests for what the JUnit Platform makes of it. + * + *

Only ever discovered, never executed: discovery needs the annotations on + * {@link ProviderTckTest} and nothing else, so these tests exercise the real suite configuration — + * the real selectors, the real glue, the real engines — without Docker. + * + *

Extends {@link ContainerizedProviderTckTest} rather than {@link ProviderTckTest} directly, so + * that the suite under discovery is shaped like the one an adopter with a real backend writes. + * + *

Deliberately not named {@code *Test}, so Surefire does not find it and try to run it. Running it + * would start a Compose stack that does not exist. + */ +public class TckSuiteFixture extends ContainerizedProviderTckTest { + + @Override + public File composeFile() { + return new File("src/test/resources/there-is-no-stack.yaml"); + } + + @Override + public List backendPorts() { + return Collections.singletonList(8013); + } + + @Override + public FeatureProvider createProvider(BackendEndpoint endpoint) { + return new NoOpProvider(); + } + + @Override + public FeatureProvider createUnavailableProvider() { + return new NoOpProvider(); + } +} diff --git a/tools/provider-tck/src/test/java/openfeature/tck/extensions/ExtensionSelfTestSteps.java b/tools/provider-tck/src/test/java/openfeature/tck/extensions/ExtensionSelfTestSteps.java new file mode 100644 index 0000000000..ead0e3f133 --- /dev/null +++ b/tools/provider-tck/src/test/java/openfeature/tck/extensions/ExtensionSelfTestSteps.java @@ -0,0 +1,40 @@ +package openfeature.tck.extensions; + +import static org.assertj.core.api.Assertions.assertThat; + +import io.cucumber.java.en.Given; +import io.cucumber.java.en.Then; + +/** + * Step definitions for the TCK's own extension fixture, in the package an adopter would use. + * + *

Test-scoped, so it is not in the released JAR. It exists to demonstrate the claim the extension + * point makes: a step class in {@code openfeature.tck.extensions} is on the suite's glue path + * without an annotation, a runner or a line of configuration written for it. + * + *

The steps deliberately need nothing from {@link + * dev.openfeature.contrib.tools.providertck.TckRuntime}. An extension scenario in a real suite runs + * after the canonical {@code @BeforeAll} and has the started backend and its control — but asserting + * that here would make the TCK's own unit tests need Docker, which is a worse trade than proving the + * lifecycle structurally: the extension features are discovered into the same suite and the same + * Cucumber engine descriptor as the canonical ones, which is what a shared {@code @BeforeAll} + * is. + */ +public class ExtensionSelfTestSteps { + + private boolean ran; + + /** A step Cucumber can only find if the extension glue package is on the glue path. */ + @Given("a step defined in the extension glue package") + public void aStepDefinedInTheExtensionGluePackage() { + ran = true; + } + + /** Asserts the step above ran, so that a missing glue package fails rather than passes. */ + @Then("the extension step ran") + public void theExtensionStepRan() { + assertThat(ran) + .as("the extension glue package was resolved and its steps executed") + .isTrue(); + } +} diff --git a/tools/provider-tck/src/test/resources/tck-extensions/extension-selftest.feature b/tools/provider-tck/src/test/resources/tck-extensions/extension-selftest.feature new file mode 100644 index 0000000000..c43aa0ce7c --- /dev/null +++ b/tools/provider-tck/src/test/resources/tck-extensions/extension-selftest.feature @@ -0,0 +1,14 @@ +@extension-selftest +Feature: An adopter's own scenarios run inside the TCK suite + + This file is the TCK's own proof of its extension point. It sits exactly where an adopter's + extension features sit — on the test classpath under tck-extensions/ — and is picked up with no + annotation, no selector and no runner configuration written anywhere for it. + + It is test-scoped, so it is not packaged in the released JAR and cannot reach an adopter's run. + It carries no canonical scenario and cannot stand in for one: the canonical set is what features/ + contains, and this file is not in it. + + Scenario: A step class in the adopter's own glue package is on the suite's glue path + Given a step defined in the extension glue package + Then the extension step ran From ea39f67b9746b598b3439c230f2846fb0bbc2713 Mon Sep 17 00:00:00 2001 From: Simon Schrottner Date: Fri, 11 Sep 2026 11:59:10 +0200 Subject: [PATCH 11/55] feat(provider-tck): own the declaration vocabulary here, so reporting widens no API An adopter declares conformance in a vocabulary: which capabilities they support, which gaps are defects rather than choices, what the configuration under test is called, and which of the two control contracts drove the backend. All of that is something a provider author writes, or something a reader of a run needs. None of it is reporting. Three pieces of it were sitting on the reporting branch, which put an adopter in the position of needing the report to declare a thing the suite already asks them to declare. Moved here: - KnownDeviation, with ProviderTckHarness.knownDeviations(). A withheld capability and a bug are the same absence from the outside -- scenarios skipped either way -- so only the provider author can say which it was. That is a declaration, and it is worth making whether or not anything machine- readable consumes it. - ProviderTckHarness.configuration(), with the derivation behind its default. Which of a provider's modes was exercised. flagd's RPC and in-process resolvers produce results that are not interchangeable and the name is what keeps them apart; that is true of the run, not of a report about it. - BackendControl.controlApi(), as CONTROL_API_HTTP and CONTROL_API_IN_PROCESS. Which contract a run was conducted under. HttpBackendControl answers http; the default is in-process, the narrow allowance for a provider that has no backend. A custom BackendControl now states this rather than leaving whatever reads the result to guess, and the conservative value is the default. Also extracts CapabilityGate from the step definitions, unchanged in behaviour. The rule that an undeclared capability must be reported as skipped and never as passed is the one the whole suite rests on, and a gate inlined into a @Before can only be tested through a scenario -- a self-test could show that some abort becomes a skip, not that this one does. The seam this settles: the base owns what an adopter declares, and reporting is additive over it. Matches go-sdk-contrib 271f6d4, which exported the tag lookup for the same reason. DeclarationApiTest exercises the whole vocabulary with nothing downstream of it -- no report, no emitter -- which is what "usable on the base alone" means. Signed-off-by: Simon Schrottner --- tools/provider-tck/README.md | 38 ++++++ .../tools/providertck/BackendControl.java | 26 ++++ .../tools/providertck/CapabilityGate.java | 43 +++++++ .../tools/providertck/HttpBackendControl.java | 11 ++ .../tools/providertck/KnownDeviation.java | 70 +++++++++++ .../tools/providertck/ProviderTckHarness.java | 38 ++++++ .../tools/providertck/ReportNames.java | 46 +++++++ .../providertck/steps/ProviderSteps.java | 13 +- .../tools/providertck/DeclarationApiTest.java | 113 ++++++++++++++++++ 9 files changed, 387 insertions(+), 11 deletions(-) create mode 100644 tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/CapabilityGate.java create mode 100644 tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/KnownDeviation.java create mode 100644 tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/ReportNames.java create mode 100644 tools/provider-tck/src/test/java/dev/openfeature/contrib/tools/providertck/DeclarationApiTest.java diff --git a/tools/provider-tck/README.md b/tools/provider-tck/README.md index 1feae7cb8a..c259ed1923 100644 --- a/tools/provider-tck/README.md +++ b/tools/provider-tck/README.md @@ -448,6 +448,44 @@ Two things the tag does not cover, both open in Appendix F rather than fixed her distinguishes a 64-bit integer accessor from a 32-bit one — flagd's own testbed tags the latter `@int32-bounded` — and neither Appendix F nor this suite has anything equivalent. +### Saying that a withheld capability is a defect + +Narrowing `capabilities()` reads the same way in the results whether you did it to describe a +limitation or to work around a bug: the scenarios are skipped either way, and nothing in the run can +tell the two apart. Declare a `KnownDeviation` when it is the latter. + +```java +@Override +public List knownDeviations() { + return List.of(KnownDeviation.tracked( + Capability.NUMERIC_COERCION, + "https://github.com/open-feature/java-sdk-contrib/issues/1234", + "float-flag through the integer API returns 0 with no error code")); +} +``` + +Use `KnownDeviation.untracked(...)` when there is no issue to point at yet. That is still worth +declaring — naming the defect is what separates it from a choice — but an issue link is better. +Empty is the default, and it is silence rather than a claim of having none. + +### Naming the configuration under test + +`configuration()` is the provider's *configuration*, not its identity: which of its modes this suite +exercised. A provider with two materially different modes — flagd's RPC and in-process resolvers — +runs two suites whose results are not interchangeable, and the name is what keeps them apart. + +It defaults to the suite class name, hyphenated and with the JUnit suffix dropped, so +`FlagdInProcessTckTest` becomes `flagd-in-process`. Override it when that does not read well. + +### How the backend was driven + +`BackendControl.controlApi()` says which of the two contracts a run was conducted under: `http`, the +normative control API, or `in-process`, the narrow allowance for a provider with no backend at all. +`HttpBackendControl` answers `http` and everything else defaults to `in-process`, so a custom +`BackendControl` states it rather than leaving a reader to guess. A claim of `in-process` for a +provider that does have a backend should be treated with suspicion — see [In-process control is for +backend-less providers only](#in-process-control-is-for-backend-less-providers-only). + ## Tuning timeouts How fast a provider notices a backend change differs by orders of magnitude between transports: a diff --git a/tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/BackendControl.java b/tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/BackendControl.java index 427f80d36a..8fe2da69e9 100644 --- a/tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/BackendControl.java +++ b/tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/BackendControl.java @@ -45,6 +45,12 @@ */ public interface BackendControl { + /** The normative control API: a real backend driven over the HTTP control endpoints. */ + String CONTROL_API_HTTP = "http"; + + /** Control of a provider with no backend, exercised inside this JVM. */ + String CONTROL_API_IN_PROCESS = "in-process"; + /** * Brings the backend to the state every scenario starts from: reachable, with flag state at the * baseline of the canonical flag set. @@ -102,6 +108,26 @@ default String description() { return getClass().getSimpleName(); } + /** + * Returns how the backend is driven, as one of the two kinds the provider contract recognises. + * + *

{@code http} is the normative control API: the backend is a real one and it is driven over + * the endpoints in {@code openapi/control-api.yaml}, which is what makes a conformance claim + * portable between languages. {@code in-process} is the narrow allowance for a provider with no + * backend at all, where "the backend" is a data structure in this JVM — a claim of + * {@code in-process} for a provider that does have a backend should be treated with suspicion. + * + *

Part of the declaration vocabulary rather than of any one consumer of it: it says which of + * the two contracts a run was conducted under, which anyone reading the result needs whether or + * not a machine-readable report is being produced. A custom {@code BackendControl} states it + * here and nothing downstream has to guess. + * + * @return {@link #CONTROL_API_HTTP} or {@link #CONTROL_API_IN_PROCESS} + */ + default String controlApi() { + return CONTROL_API_IN_PROCESS; + } + /** * Builds the exception the connection-control defaults throw. * diff --git a/tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/CapabilityGate.java b/tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/CapabilityGate.java new file mode 100644 index 0000000000..2ff1d208c1 --- /dev/null +++ b/tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/CapabilityGate.java @@ -0,0 +1,43 @@ +package dev.openfeature.contrib.tools.providertck; + +import java.util.Collection; +import java.util.Optional; +import java.util.Set; +import org.opentest4j.TestAbortedException; + +/** + * Skips a scenario that needs a capability the provider did not declare. + * + *

One implementation, deliberately. This is the rule the whole suite rests on — a scenario + * skipped for an undeclared capability must be reported as skipped and never as passed — so the + * gate that produces the skip and the tests that prove the skip survives into the results have to + * be looking at the same code. Inlined into the step definitions, a self-test could only + * demonstrate that some abort becomes a skip, not that this abort does. + * + *

Aborting rather than failing is what makes the outcome a skip: {@link TestAbortedException} maps + * to {@code SKIPPED} in Cucumber's step results, which is what reaches the results. + */ +public final class CapabilityGate { + + private CapabilityGate() {} + + /** + * Aborts the running scenario if any of its tags gates a capability that was not declared. + * + *

Tags that gate nothing are ignored, so a scenario with no capability tag is mandatory and + * always runs. + * + * @param tags the scenario's Gherkin tags, including the leading at-sign + * @param declared the capabilities the provider declares + * @throws TestAbortedException if a tag gates an undeclared capability + */ + public static void requireDeclared(Collection tags, Set declared) { + for (String tag : tags) { + Optional capability = Capability.fromTag(tag); + if (capability.isPresent() && !declared.contains(capability.get())) { + throw new TestAbortedException("Skipped: provider does not declare capability " + + capability.get().name() + " (tag " + tag + "). Declared capabilities: " + declared); + } + } + } +} diff --git a/tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/HttpBackendControl.java b/tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/HttpBackendControl.java index 42183b52c9..d63bd01293 100644 --- a/tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/HttpBackendControl.java +++ b/tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/HttpBackendControl.java @@ -75,6 +75,17 @@ public String description() { return "HTTP control API at " + baseUrl; } + /** + * {@inheritDoc} + * + *

Always {@link BackendControl#CONTROL_API_HTTP}: this is the normative control API, and a + * run conducted through it is the portable kind of conformance claim. + */ + @Override + public String controlApi() { + return CONTROL_API_HTTP; + } + /** * {@inheritDoc} * diff --git a/tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/KnownDeviation.java b/tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/KnownDeviation.java new file mode 100644 index 0000000000..7e08477257 --- /dev/null +++ b/tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/KnownDeviation.java @@ -0,0 +1,70 @@ +package dev.openfeature.contrib.tools.providertck; + +import com.fasterxml.jackson.annotation.JsonInclude; + +/** + * A gap the provider is known to have against something the specification does not treat as + * optional. + * + *

Distinct from an undeclared capability, which is a choice. A provider that does not + * declare {@code @configuration-change} has no streaming transport and is not pretending otherwise; + * a provider that does not declare {@code @numeric-coercion} has a bug. Both look identical in + * the results — scenarios skipped, reason recoverable from the declaration — so the difference has + * to be stated, or a consumer cannot tell a design decision from a defect. + * + *

Declared by the provider author through {@link ProviderTckHarness#knownDeviations()}, which is + * the only place that knows the difference. The TCK cannot infer it: from the outside, a capability + * the provider chose to withhold and one it withheld because it is broken are the same absence. + * + *

Part of the declaration vocabulary rather than of any one consumer of it. This is something an + * adopter writes, alongside {@link ProviderTckHarness#capabilities()}, so it belongs to the + * suite an adopter adopts. Whatever reads the declaration — a machine-readable conformance report, + * a build check, a human — is downstream of it and does not widen it. + */ +@JsonInclude(JsonInclude.Include.NON_NULL) +public final class KnownDeviation { + + /** The capability tag the deviation concerns, or {@code null} when it maps to none. */ + public final String capability; + + /** Where the gap is tracked, or {@code null} when it is not tracked anywhere. */ + public final String issue; + + /** What the gap is, in a form someone comparing providers can use. */ + public final String summary; + + private KnownDeviation(String capability, String issue, String summary) { + this.capability = capability; + this.issue = issue; + this.summary = summary; + } + + /** + * Records a deviation that is tracked somewhere. + * + * @param capability the capability withheld because of the gap, or {@code null} when the gap is + * against a mandatory scenario and so belongs to no capability + * @param issue a URI where the gap is tracked + * @param summary what the gap is + * @return the deviation, ready to declare + */ + public static KnownDeviation tracked(Capability capability, String issue, String summary) { + return new KnownDeviation(capability == null ? null : capability.tag(), issue, summary); + } + + /** + * Records a deviation that is not tracked anywhere yet. + * + *

Worth declaring even so. Naming the defect is what separates it from a capability the + * provider chose to withhold, and a declaration that merely omits the tag cannot say which of + * the two happened. Prefer {@link #tracked} as soon as there is an issue to point at. + * + * @param capability the capability withheld because of the gap, or {@code null} when the gap is + * against a mandatory scenario and so belongs to no capability + * @param summary what the gap is + * @return the deviation, ready to declare + */ + public static KnownDeviation untracked(Capability capability, String summary) { + return new KnownDeviation(capability == null ? null : capability.tag(), null, summary); + } +} diff --git a/tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/ProviderTckHarness.java b/tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/ProviderTckHarness.java index bab8d6f053..c72338ea14 100644 --- a/tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/ProviderTckHarness.java +++ b/tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/ProviderTckHarness.java @@ -2,6 +2,8 @@ import dev.openfeature.sdk.FeatureProvider; import java.time.Duration; +import java.util.Collections; +import java.util.List; import java.util.Set; /** @@ -130,6 +132,42 @@ default Set capabilities() { return Capability.declarable(); } + /** + * Declares gaps this provider is known to have against parts of the contract the specification + * does not treat as optional. + * + *

Declared so that a consumer can tell a design decision from a defect. Withholding a + * capability and having a bug look identical in the results — scenarios skipped, either way — + * and the TCK cannot tell them apart from the outside. Only the provider author can, so only + * the provider author can say. + * + *

Empty by default, which is silence rather than a claim. Declare an entry when you have + * narrowed {@link #capabilities()} to work around a defect rather than to describe a limitation, + * and delete it when the defect is fixed. + * + * @return the deviations this provider acknowledges, empty by default + */ + default List knownDeviations() { + return Collections.emptyList(); + } + + /** + * Returns the name of the provider configuration this suite exercises. + * + *

The provider's configuration rather than its identity. The identity is what the provider + * says through its own metadata; this is which of its modes was tested, and a provider with two + * materially different modes — flagd's RPC and in-process resolvers, say — produces two runs + * that are not interchangeable and must not be labelled the same. + * + *

Derived from the suite class name by default: {@code FlagdInProcessTckTest} becomes + * {@code flagd-in-process}. Override it when that does not read well. + * + * @return a short name for this configuration + */ + default String configuration() { + return ReportNames.configurationOf(getClass()); + } + /** * Prepares whatever must exist before the first scenario — a container stack, a temporary * directory, a local server. diff --git a/tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/ReportNames.java b/tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/ReportNames.java new file mode 100644 index 0000000000..6858398bb1 --- /dev/null +++ b/tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/ReportNames.java @@ -0,0 +1,46 @@ +package dev.openfeature.contrib.tools.providertck; + +import java.util.Locale; + +/** + * Derives the names a run of the suite is identified by. + * + *

Kept apart from any one consumer so that the default + * {@link ProviderTckHarness#configuration()} and anything that files a run's output under that name + * agree on one derivation. + */ +final class ReportNames { + + /** Suffixes a suite class name carries for JUnit's benefit rather than the report's. */ + private static final String[] SUITE_SUFFIXES = {"TckTest", "TCKTest", "TckSuite", "Test", "IT"}; + + /** Used when a name sanitises away to nothing, which an anonymous class manages. */ + private static final String FALLBACK = "provider-tck"; + + private ReportNames() {} + + /** + * Derives a configuration name from a suite class. + * + *

{@code FlagdInProcessTckTest} becomes {@code flagd-in-process}: the suffix that exists only + * so JUnit picks the class up is dropped, and the rest is hyphenated. A provider whose modes do + * not read well this way overrides {@link ProviderTckHarness#configuration()} and says so + * directly. + * + * @param suite the concrete suite class + * @return a hyphenated, lower-case configuration name + */ + static String configurationOf(Class suite) { + String simple = suite.getSimpleName(); + for (String suffix : SUITE_SUFFIXES) { + if (simple.length() > suffix.length() && simple.endsWith(suffix)) { + simple = simple.substring(0, simple.length() - suffix.length()); + break; + } + } + String hyphenated = simple.replaceAll("([a-z0-9])([A-Z])", "$1-$2") + .replaceAll("([A-Z]+)([A-Z][a-z])", "$1-$2") + .toLowerCase(Locale.ROOT); + return hyphenated.isEmpty() ? FALLBACK : hyphenated; + } +} diff --git a/tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/steps/ProviderSteps.java b/tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/steps/ProviderSteps.java index 35652f943c..505c0783ba 100644 --- a/tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/steps/ProviderSteps.java +++ b/tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/steps/ProviderSteps.java @@ -4,6 +4,7 @@ import static org.awaitility.Awaitility.await; import dev.openfeature.contrib.tools.providertck.Capability; +import dev.openfeature.contrib.tools.providertck.CapabilityGate; import dev.openfeature.contrib.tools.providertck.ProviderTckHarness; import dev.openfeature.contrib.tools.providertck.TckRuntime; import dev.openfeature.contrib.tools.providertck.TckState; @@ -20,10 +21,7 @@ import io.cucumber.java.en.Then; import io.cucumber.java.en.When; import java.time.Duration; -import java.util.Optional; -import java.util.Set; import java.util.UUID; -import org.opentest4j.TestAbortedException; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -80,14 +78,7 @@ public static void afterAll() { */ @Before(order = 0) public void gateOnCapabilities(Scenario scenario) { - Set supported = harness().capabilities(); - for (String tag : scenario.getSourceTagNames()) { - Optional capability = Capability.fromTag(tag); - if (capability.isPresent() && !supported.contains(capability.get())) { - throw new TestAbortedException("Skipped: provider does not declare capability " - + capability.get().name() + " (tag " + tag + "). Declared capabilities: " + supported); - } - } + CapabilityGate.requireDeclared(scenario.getSourceTagNames(), harness().capabilities()); } /** diff --git a/tools/provider-tck/src/test/java/dev/openfeature/contrib/tools/providertck/DeclarationApiTest.java b/tools/provider-tck/src/test/java/dev/openfeature/contrib/tools/providertck/DeclarationApiTest.java new file mode 100644 index 0000000000..50e8f2be3f --- /dev/null +++ b/tools/provider-tck/src/test/java/dev/openfeature/contrib/tools/providertck/DeclarationApiTest.java @@ -0,0 +1,113 @@ +package dev.openfeature.contrib.tools.providertck; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.api.Assertions.catchThrowableOfType; + +import java.util.Arrays; +import java.util.Collections; +import java.util.EnumSet; +import java.util.Set; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.opentest4j.TestAbortedException; + +/** + * The vocabulary an adopter declares conformance in, exercised where it is defined. + * + *

Everything here is something a provider author writes or a reader of a run + * needs: which capabilities are declarable, which gaps are defects rather than choices, + * what the configuration under test is called, and which of the two control contracts the run was + * conducted under. It is all usable with nothing downstream of it — no report, no emitter — which is + * the point of it living here. + */ +class DeclarationApiTest { + + @Test + @DisplayName("a reserved capability is not declarable and declaring one fails the run") + void reservedCapabilitiesAreNotDeclarable() { + assertThat(Capability.declarable()) + .as("declarable() is every capability some scenario gates") + .doesNotContain(Capability.TARGETING, Capability.CACHING) + .contains(Capability.EVENTS, Capability.OBJECT); + + assertThat(Capability.declarableExcept(Capability.STALE)) + .doesNotContain(Capability.STALE, Capability.TARGETING) + .contains(Capability.EVENTS); + + assertThatThrownBy(() -> Capability.requireDeclarable(EnumSet.allOf(Capability.class))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("TARGETING") + .hasMessageContaining("declares reserved"); + } + + @Test + @DisplayName("a tag maps back to the capability it gates") + void tagsMapBackToCapabilities() { + assertThat(Capability.fromTag("@numeric-coercion")).contains(Capability.NUMERIC_COERCION); + assertThat(Capability.fromTag("@not-a-capability")).isEmpty(); + } + + @Test + @DisplayName("the gate skips an undeclared capability and lets an untagged scenario run") + void theGateSkipsUndeclaredCapabilities() { + Set declared = EnumSet.of(Capability.EVENTS); + + TestAbortedException aborted = catchThrowableOfType( + () -> CapabilityGate.requireDeclared(Arrays.asList("@object"), declared), TestAbortedException.class); + assertThat(aborted) + .as("an undeclared capability aborts, which is what the JUnit Platform reports as skipped") + .isNotNull(); + assertThat(aborted).hasMessageContaining("OBJECT").hasMessageContaining("@object"); + + CapabilityGate.requireDeclared(Arrays.asList("@events", "@not-a-capability"), declared); + CapabilityGate.requireDeclared(Collections.emptyList(), declared); + } + + @Test + @DisplayName("a deviation records the capability it withholds, tracked or not") + void deviationsRecordTheCapabilityTheyWithhold() { + KnownDeviation tracked = KnownDeviation.tracked( + Capability.NUMERIC_COERCION, "https://example.invalid/1234", "0.5 as an integer returns 0"); + assertThat(tracked.capability).isEqualTo("@numeric-coercion"); + assertThat(tracked.issue).isEqualTo("https://example.invalid/1234"); + assertThat(tracked.summary).isEqualTo("0.5 as an integer returns 0"); + + KnownDeviation untracked = KnownDeviation.untracked(null, "a gap against a mandatory scenario"); + assertThat(untracked.capability).isNull(); + assertThat(untracked.issue).isNull(); + } + + @Test + @DisplayName("a configuration name is derived from the suite class, and is overridable") + void configurationNamesAreDerivedFromTheSuiteClass() { + assertThat(new TckSuiteFixture().configuration()).isEqualTo("tck-suite-fixture"); + assertThat(new NamedConfiguration().configuration()).isEqualTo("a-name-of-my-own"); + } + + @Test + @DisplayName("an adopter declares nothing by default, which is silence rather than a claim") + void theDefaultsAreSilence() { + assertThat(new TckSuiteFixture().knownDeviations()).isEmpty(); + assertThat(new TckSuiteFixture().capabilities()).isEqualTo(Capability.declarable()); + } + + @Test + @DisplayName("a backend says which of the two control contracts drove it") + void backendsSayHowTheyWereDriven() { + assertThat(new InProcessBackendControl().controlApi()) + .as("a provider with no backend is controlled in-process, the narrow allowance") + .isEqualTo(BackendControl.CONTROL_API_IN_PROCESS); + + assertThat(BackendControl.CONTROL_API_HTTP).isEqualTo("http"); + assertThat(BackendControl.CONTROL_API_IN_PROCESS).isEqualTo("in-process"); + } + + /** A suite that names its configuration rather than taking the derived name. */ + private static final class NamedConfiguration extends TckSuiteFixture { + @Override + public String configuration() { + return "a-name-of-my-own"; + } + } +} From 9588e3587f4a5ee075e5bf412d41436067952ae3 Mon Sep 17 00:00:00 2001 From: Simon Schrottner Date: Fri, 11 Sep 2026 12:00:16 +0200 Subject: [PATCH 12/55] refactor(provider-tck): move the flagd adoption to its own change The suite and its first adopter were landing together, which made one review cover two unrelated questions: whether the TCK is the right contract, and whether flagd satisfies it. They also progress differently -- the contract is the thing other languages have to match, the adoption is one provider's usage of it -- so they are now a base and a follow-up. Nothing about the adoption changes; it is the same five files against the same API, reopened on feat/provider-tck-flagd. This branch is back to exactly main under providers/flagd, so the base is the suite and nothing else. Verified the five files are the whole of it: every other file in the flagd e2e directory traces to #1752, #1582 or #1835 and predates this work. Signed-off-by: Simon Schrottner --- providers/flagd/pom.xml | 13 -- .../flagd/e2e/AbstractFlagdTckTest.java | 122 ------------------ .../flagd/e2e/FlagdInProcessTckTest.java | 17 --- .../providers/flagd/e2e/FlagdRpcTckTest.java | 17 --- .../test/resources/tck/docker-compose.yaml | 15 --- 5 files changed, 184 deletions(-) delete mode 100644 providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/e2e/AbstractFlagdTckTest.java delete mode 100644 providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/e2e/FlagdInProcessTckTest.java delete mode 100644 providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/e2e/FlagdRpcTckTest.java delete mode 100644 providers/flagd/src/test/resources/tck/docker-compose.yaml diff --git a/providers/flagd/pom.xml b/providers/flagd/pom.xml index 15001f4bbc..8980e8f090 100644 --- a/providers/flagd/pom.xml +++ b/providers/flagd/pom.xml @@ -22,8 +22,6 @@ 1.2.28 [2.0.0,3.0.0) - - [0.0.1,) flagd @@ -100,17 +98,6 @@ 5.14.3 test - - - dev.openfeature.contrib.tools - provider-tck - ${provider-tck.version} - test - org.testcontainers testcontainers diff --git a/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/e2e/AbstractFlagdTckTest.java b/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/e2e/AbstractFlagdTckTest.java deleted file mode 100644 index a762a4afd9..0000000000 --- a/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/e2e/AbstractFlagdTckTest.java +++ /dev/null @@ -1,122 +0,0 @@ -package dev.openfeature.contrib.providers.flagd.e2e; - -import dev.openfeature.contrib.providers.flagd.Config; -import dev.openfeature.contrib.providers.flagd.FlagdOptions; -import dev.openfeature.contrib.providers.flagd.FlagdProvider; -import dev.openfeature.contrib.tools.providertck.BackendEndpoint; -import dev.openfeature.contrib.tools.providertck.Capability; -import dev.openfeature.contrib.tools.providertck.ContainerizedProviderTckTest; -import dev.openfeature.sdk.FeatureProvider; -import java.io.File; -import java.util.Collections; -import java.util.List; -import java.util.Set; - -/** - * Shared configuration for running the OpenFeature Provider TCK against the flagd provider. - * - *

flagd resolves flags in two quite different ways, and both are worth conforming: RPC evaluates - * remotely over gRPC, while in-process syncs the ruleset and evaluates locally. They share a backend - * stack and differ only in resolver and port, so the modes are two small subclasses. - * - *

Each concrete subclass is its own JUnit suite and its own TCK harness; the TCK works out which - * one is running from the JUnit test plan, so adding a mode needs no registration or build - * configuration. - */ -abstract class AbstractFlagdTckTest extends ContainerizedProviderTckTest { - - /** - * A port nothing listens on, for the initialisation-failure scenarios. - * - *

Deliberately not a port on the Compose stack: the stack must stay up for the whole suite, - * and simulated outages belong to the control API. - */ - private static final int UNAVAILABLE_PORT = 9999; - - /** - * gRPC deadline for a provider that is expected to connect. - * - *

Generous on purpose. flagd derives its initialisation deadline from this value, and the - * in-process resolver must sync the entire ruleset before it reports ready — which intermittently - * takes longer than a deadline tuned for a single RPC round trip. - */ - private static final int CONNECTED_DEADLINE_MS = 5000; - - /** - * gRPC deadline for a provider pointed at a dead port. - * - *

Short on purpose, and deliberately not the same as {@link #CONNECTED_DEADLINE_MS}: the - * initialisation-failure scenarios assert that the failure is reported promptly, so a - * provider that takes as long to give up as it does to connect would defeat the point. - */ - private static final int UNAVAILABLE_DEADLINE_MS = 1000; - - /** The resolver under test. */ - protected abstract Config.Resolver resolver(); - - /** The container-internal port that resolver connects to. */ - protected abstract int backendPort(); - - @Override - public File composeFile() { - return new File("src/test/resources/tck/docker-compose.yaml"); - } - - @Override - public List backendPorts() { - return Collections.singletonList(backendPort()); - } - - @Override - public FeatureProvider createProvider(BackendEndpoint endpoint) { - return new FlagdProvider(baseOptions() - .deadline(CONNECTED_DEADLINE_MS) - .host(endpoint.host()) - .port(endpoint.port(backendPort())) - .build()); - } - - @Override - public FeatureProvider createUnavailableProvider() { - return new FlagdProvider(baseOptions() - .deadline(UNAVAILABLE_DEADLINE_MS) - .host("localhost") - .port(UNAVAILABLE_PORT) - .build()); - } - - /** - * {@inheritDoc} - * - *

Everything declarable except {@link Capability#NUMERIC_COERCION}. Evaluating - * {@code float-flag} (0.5) through the integer API returns {@code 0} with no error code - * rather than {@code TYPE_MISMATCH} with the code default — the value is silently truncated. - * Coercion as such is permitted, and the capability says so: the rule is that a lossless - * coercion must succeed and a lossy one must fail. It is the lossy case being accepted that is a - * defect to fix, not a design choice; this override should be deleted once it is. - * - *

Declared here rather than per mode because both resolvers behave identically, which places - * the defect in the shared provider layer rather than in either transport. Every other - * capability, including the full non-numeric type-mismatch matrix, holds in both modes. - * - *

That includes {@link Capability#LIFECYCLE}, and legitimately so: flagd reaches its backend - * during initialisation in both modes — an RPC round trip, or a full ruleset sync — so the - * lifecycle scenarios assert something real here rather than passing vacuously. - * - *

{@link Capability#declarableExcept} rather than {@code EnumSet.complementOf}, which is what - * this used to be. The complement of one capability is every other enum constant, - * including {@code @targeting} and {@code @caching} — reserved tags no scenario carries — so a - * report emitted from here claimed two capabilities nothing had examined. - */ - @Override - public Set capabilities() { - return Capability.declarableExcept(Capability.NUMERIC_COERCION); - } - - private FlagdOptions.FlagdOptionsBuilder baseOptions() { - return FlagdOptions.builder() - .resolverType(resolver()) - .retryGracePeriod(2) - .retryBackoffMs(500); - } -} diff --git a/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/e2e/FlagdInProcessTckTest.java b/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/e2e/FlagdInProcessTckTest.java deleted file mode 100644 index 1ce5b1dc71..0000000000 --- a/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/e2e/FlagdInProcessTckTest.java +++ /dev/null @@ -1,17 +0,0 @@ -package dev.openfeature.contrib.providers.flagd.e2e; - -import dev.openfeature.contrib.providers.flagd.Config; - -/** Runs the OpenFeature Provider TCK against the flagd provider in in-process mode. */ -public class FlagdInProcessTckTest extends AbstractFlagdTckTest { - - @Override - protected Config.Resolver resolver() { - return Config.Resolver.IN_PROCESS; - } - - @Override - protected int backendPort() { - return 8015; - } -} diff --git a/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/e2e/FlagdRpcTckTest.java b/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/e2e/FlagdRpcTckTest.java deleted file mode 100644 index 30ee6db57c..0000000000 --- a/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/e2e/FlagdRpcTckTest.java +++ /dev/null @@ -1,17 +0,0 @@ -package dev.openfeature.contrib.providers.flagd.e2e; - -import dev.openfeature.contrib.providers.flagd.Config; - -/** Runs the OpenFeature Provider TCK against the flagd provider in RPC mode. */ -public class FlagdRpcTckTest extends AbstractFlagdTckTest { - - @Override - protected Config.Resolver resolver() { - return Config.Resolver.RPC; - } - - @Override - protected int backendPort() { - return 8013; - } -} diff --git a/providers/flagd/src/test/resources/tck/docker-compose.yaml b/providers/flagd/src/test/resources/tck/docker-compose.yaml deleted file mode 100644 index 4cecaa1388..0000000000 --- a/providers/flagd/src/test/resources/tck/docker-compose.yaml +++ /dev/null @@ -1,15 +0,0 @@ -# Backend stack for the OpenFeature Provider TCK, wrapping the unmodified flagd testbed image. -# -# The image already serves everything the TCK needs: flagd itself, and the "launchpad" control -# API on 8080 whose endpoints this TCK's control API contract was derived from. -# -# Note there are no host port bindings. The TCK requires dynamically mapped ports and discovers -# them after startup — a pinned host port would make the suite unrunnable in parallel and would -# collide with a developer's local flagd. -services: - backend: - image: ghcr.io/open-feature/flagd-testbed:v3.8.0 - ports: - - 8013 # flagd RPC evaluation (gRPC) - - 8015 # flagd in-process sync (gRPC) - - 8080 # launchpad control API From ade878e773bb888aef3b6e30cec3b0cb2dae5895 Mon Sep 17 00:00:00 2001 From: Simon Schrottner Date: Fri, 11 Sep 2026 12:00:41 +0200 Subject: [PATCH 13/55] docs(provider-tck): drop the links to the flagd adoption, which is a follow-up now Two links pointed at AbstractFlagdTckTest, which this branch no longer contains. The prose they supported stands on its own; the flagd numeric-coercion gap keeps its link to flagd#1996, which is the tracking issue rather than a file here. Signed-off-by: Simon Schrottner --- tools/provider-tck/README.md | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/tools/provider-tck/README.md b/tools/provider-tck/README.md index c259ed1923..d427ea2009 100644 --- a/tools/provider-tck/README.md +++ b/tools/provider-tck/README.md @@ -293,9 +293,8 @@ public class MyProviderInProcessTckTest extends AbstractMyProviderTckTest { } ``` -This is how flagd covers RPC and in-process — see -[`AbstractFlagdTckTest`](../../providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/e2e/AbstractFlagdTckTest.java). -Abstract classes are not run, so an intermediate base is safe. +This is how the flagd provider covers RPC and in-process. Abstract classes are not run, so an +intermediate base is safe. Note that per-mode differences may include timing, not just wiring: flagd's in-process resolver syncs the whole ruleset before reporting ready, so it needs a longer initialisation deadline than @@ -434,8 +433,7 @@ flag, because the application sees a plausible value and no error. It is a capab provider with this defect can adopt the TCK today and see the gap reported explicitly. Not declaring it is an admission of a known bug. **The flagd provider currently does not declare it**, in either RPC or in-process mode — see -[`AbstractFlagdTckTest`](../../providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/e2e/AbstractFlagdTckTest.java) -and [flagd#1996](https://github.com/open-feature/flagd/issues/1996). +[flagd#1996](https://github.com/open-feature/flagd/issues/1996). Two things the tag does not cover, both open in Appendix F rather than fixed here: From b1602c6885a43caaecf5ee4506aaf2961bcb5801 Mon Sep 17 00:00:00 2001 From: Simon Schrottner Date: Fri, 11 Sep 2026 12:19:41 +0200 Subject: [PATCH 14/55] feat(provider-tck): implement the shutdown, metadata and error-message steps Bump the spec assets to feat/provider-tck-appendix@15fe861, which adds metadata.feature, three lifecycle scenarios around shutdown, and an error-message assertion on every success path, and implement the five steps they introduce. `the provider is shut down` and `the provider is initialized again` call the provider's own shutdown() and initialize() directly rather than replacing it through the SDK: replacing it would test the SDK's bookkeeping as much as the provider's, and Appendix B already covers the SDK. The SDK is not told, so the evaluation that follows reaches the re-initialised provider through the scenario's client. Both record a thrown exception into the slot the evaluation step already uses, so `no exception should have been thrown` covers a repeated shutdown and a re-initialisation without a second mechanism; the slot is widened from RuntimeException to Exception because initialize() throws checked, and it now names the call that threw. Shutdown is timed so that `the shutdown should have completed within {int}ms` can assert a shutdown against a dead backend returns rather than blocking. Signed-off-by: Simon Schrottner --- tools/provider-tck/spec | 2 +- .../contrib/tools/providertck/TckState.java | 27 ++++- .../tools/providertck/steps/FlagSteps.java | 36 +++++-- .../providertck/steps/ProviderSteps.java | 101 +++++++++++++++++- 4 files changed, 151 insertions(+), 15 deletions(-) diff --git a/tools/provider-tck/spec b/tools/provider-tck/spec index 0bedacc224..15fe861170 160000 --- a/tools/provider-tck/spec +++ b/tools/provider-tck/spec @@ -1 +1 @@ -Subproject commit 0bedacc22489697ccfbe1f5f4c5ae7649a5be457 +Subproject commit 15fe861170f463c20743f5fdb6f7ea083f405f80 diff --git a/tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/TckState.java b/tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/TckState.java index 7f911cc1f3..32cd75e24c 100644 --- a/tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/TckState.java +++ b/tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/TckState.java @@ -5,6 +5,7 @@ import dev.openfeature.sdk.FlagEvaluationDetails; import dev.openfeature.sdk.MutableContext; import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; +import java.time.Duration; import java.util.Optional; import java.util.concurrent.ConcurrentLinkedQueue; @@ -48,13 +49,29 @@ public class TckState { public Object rememberedValue; /** - * Any exception thrown out of the most recent evaluation call. + * Any exception thrown out of the most recent call the scenario made on the provider, with + * {@link #thrownBy} naming the call. * - *

The SDK contract is that typed evaluation never throws — errors surface as an error code - * and the code default. The evaluation step records rather than propagates, so a scenario can - * assert this explicitly instead of a thrown exception merely showing up as a step failure. + *

Evaluation, shutdown and re-initialisation all record here rather than propagate. The SDK + * contract is that typed evaluation never throws — errors surface as an error code and the code + * default — and the lifecycle scenarios make the same demand of a repeated {@code shutdown()} + * and of an {@code initialize()} against a reachable backend. One slot, asserted by one step, + * {@code no exception should have been thrown}, so a scenario states the expectation explicitly + * instead of a thrown exception merely showing up as a step failure. */ - public RuntimeException evaluationException; + public Exception thrown; + + /** The call {@link #thrown} came out of, for the failure message; {@code null} when none did. */ + public String thrownBy; + + /** + * How long the most recent direct {@code shutdown()} call took, or {@code null} before one was + * made in this scenario. + * + *

Recorded so a scenario can assert that shutdown against a backend that will never answer + * returns promptly instead of blocking on a graceful close. + */ + public Duration shutdownDuration; /** Events observed by handlers registered in this scenario. */ public final ConcurrentLinkedQueue events = new ConcurrentLinkedQueue<>(); diff --git a/tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/steps/FlagSteps.java b/tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/steps/FlagSteps.java index e8df0b0fe5..b3fd099cd6 100644 --- a/tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/steps/FlagSteps.java +++ b/tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/steps/FlagSteps.java @@ -87,7 +87,8 @@ public void theFlagWasEvaluatedWithDetails() { } } catch (RuntimeException e) { log.warn("Evaluation of '{}' threw, which violates the SDK contract", flag.key(), e); - state.evaluationException = e; + state.thrown = e; + state.thrownBy = "evaluation of '" + flag.key() + "'"; } } @@ -219,19 +220,38 @@ public void theResolvedObjectValueShouldContain(DataTable expected) { } /** - * Asserts that the evaluation returned normally. + * Asserts that no error message accompanies the evaluation. + * + *

Requirement 2.3.2: a provider that reports a value and an error message is sending + * two contradictory signals, and an application reading the message believes the wrong one. + * Every success path asserts this alongside the empty error code. + */ + @Then("the error message should be empty") + public void theErrorMessageShouldBeEmpty() { + requireEvaluation(); + assertThat(state.evaluation.getErrorMessage()) + .as("error message of a successful evaluation of '%s'", state.flag.key()) + .isNullOrEmpty(); + } + + /** + * Asserts that every call the scenario made on the provider returned normally. * *

Added by the TCK. The spec requires typed evaluation to absorb every error into the * returned details, so an error scenario must prove both halves: the right error code, and no - * exception escaping to the caller. + * exception escaping to the caller. The lifecycle scenarios reuse it for a repeated + * {@code shutdown()} and for {@code initialize()} against a reachable backend, which record into + * the same slot as an evaluation does. */ @Then("no exception should have been thrown") public void noExceptionShouldHaveBeenThrown() { - assertThat(state.evaluationException) + assertThat(state.thrown) .withFailMessage( - "Evaluation threw %s, but typed evaluation must never throw — " - + "errors belong in the resolution details.", - state.evaluationException) + "%s threw %s, but the scenario expected it to return normally: typed evaluation " + + "must never throw (errors belong in the resolution details), a repeated " + + "shutdown must have no further effect, and initialisation against a " + + "reachable backend must succeed.", + state.thrownBy, state.thrown) .isNull(); } @@ -249,7 +269,7 @@ private void requireEvaluation() { if (state.evaluation == null) { throw new AssertionError("No evaluation has been performed. " + "Did the scenario forget 'When the flag was evaluated with details'?" - + (state.evaluationException == null ? "" : " Evaluation threw: " + state.evaluationException)); + + (state.thrown == null ? "" : " " + state.thrownBy + " threw: " + state.thrown)); } } } diff --git a/tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/steps/ProviderSteps.java b/tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/steps/ProviderSteps.java index 505c0783ba..4db67c71c9 100644 --- a/tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/steps/ProviderSteps.java +++ b/tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/steps/ProviderSteps.java @@ -1,6 +1,7 @@ package dev.openfeature.contrib.tools.providertck.steps; import static java.util.concurrent.TimeUnit.MILLISECONDS; +import static org.assertj.core.api.Assertions.assertThat; import static org.awaitility.Awaitility.await; import dev.openfeature.contrib.tools.providertck.Capability; @@ -9,6 +10,7 @@ import dev.openfeature.contrib.tools.providertck.TckRuntime; import dev.openfeature.contrib.tools.providertck.TckState; import dev.openfeature.sdk.FeatureProvider; +import dev.openfeature.sdk.Metadata; import dev.openfeature.sdk.NoOpProvider; import dev.openfeature.sdk.OpenFeatureAPI; import dev.openfeature.sdk.ProviderState; @@ -27,7 +29,8 @@ /** * Lifecycle and backend-control steps: bringing the suite up, gating scenarios on declared - * capabilities, creating and registering the provider under test, and simulating backend outages. + * capabilities, creating and registering the provider under test, shutting it down and initialising + * it again, and simulating backend outages. * *

Every step that touches the backend goes through {@link #backend()}. Nothing here knows whether * that is a container driven over HTTP or an in-memory provider manipulated directly, which is what @@ -156,6 +159,94 @@ public void createProvider(String flavour) { domain); } + /** + * Asserts that the provider under test identifies itself by name. + * + *

Requirement 2.1.1. Too small to test, until a conformance report keyed on the provider's + * metadata name turned an empty name into a report nobody can attribute. + */ + @Then("the provider metadata name should not be empty") + public void theProviderMetadataNameShouldNotBeEmpty() { + Metadata metadata = requireProvider().getMetadata(); + assertThat(metadata).as("provider metadata").isNotNull(); + assertThat(metadata.getName()).as("provider metadata name").isNotBlank(); + } + + /** + * Shuts the provider under test down by calling its own {@code shutdown()} directly. + * + *

Directly, and not by replacing it through the SDK. {@code setProvider} would shut the old + * provider down too, but wrapping that in a scenario tests the SDK's bookkeeping as much as the + * provider's, and Appendix B already covers the SDK. The provider stays registered and the SDK is + * not told, which is what lets {@code the provider is initialized again} be observed through the + * same client afterwards. + * + *

Timed, because one scenario asserts that shutdown against a backend that will never answer + * returns at all rather than blocking on a graceful close. Exceptions are recorded rather than + * propagated, exactly as an evaluation's are, so that {@code no exception should have been + * thrown} covers the double-shutdown case explicitly. + */ + @When("the provider is shut down") + public void theProviderIsShutDown() { + FeatureProvider provider = requireProvider(); + long started = System.nanoTime(); + try { + provider.shutdown(); + } catch (RuntimeException e) { + log.warn("shutdown() of provider {} threw", provider.getMetadata().getName(), e); + state.thrown = e; + state.thrownBy = "shutdown()"; + } finally { + state.shutdownDuration = Duration.ofNanos(System.nanoTime() - started); + } + } + + /** + * Initialises the provider under test again by calling its own {@code initialize()} directly. + * + *

Requirement 2.5.2: after shutdown the provider reverts to its uninitialised state, which is + * observable as exactly one thing — it can be initialised again and then serves flags. The SDK + * still holds the provider as {@code READY}, because it was never told about the shutdown, so + * the evaluation that follows this step reaches the re-initialised provider through the + * scenario's client with nothing in between. + * + *

The scenario's evaluation context is passed, which is empty unless a context step added to + * it. Exceptions are recorded rather than propagated, the same way an evaluation's are. + */ + @When("the provider is initialized again") + public void theProviderIsInitializedAgain() { + FeatureProvider provider = requireProvider(); + try { + provider.initialize(state.context); + } catch (Exception e) { + log.warn("initialize() of provider {} threw after shutdown", provider.getMetadata().getName(), e); + state.thrown = e; + state.thrownBy = "initialize()"; + } + } + + /** + * Asserts that the most recent {@code the provider is shut down} returned within a bound. + * + *

A shutdown that waits for a graceful close of a connection that will never answer hangs the + * host application's own shutdown. The bound in the feature file is generous; what is asserted + * is that shutdown returns at all rather than blocking on the backend. + * + * @param milliseconds the bound + */ + @Then("the shutdown should have completed within {int}ms") + public void theShutdownShouldHaveCompletedWithin(int milliseconds) { + assertThat(state.shutdownDuration) + .as("a shutdown was recorded; did the scenario forget 'When the provider is shut down'?") + .isNotNull(); + assertThat(state.shutdownDuration.toMillis()) + .withFailMessage( + "shutdown() took %dms, over the %dms bound. A shutdown must not block on a backend " + + "that will never answer; release what initialisation acquired and return.", + state.shutdownDuration.toMillis(), milliseconds) + .isLessThanOrEqualTo(milliseconds); + } + /** * Makes the backend unreachable for the rest of the scenario. */ @@ -213,4 +304,12 @@ public void theClientShouldBeInState(String expected) { .pollInterval(10, MILLISECONDS) .until(() -> state.client.getProviderState() == target); } + + private FeatureProvider requireProvider() { + if (state.provider == null) { + throw new AssertionError("No provider has been created. " + + "Did the scenario forget 'Given a stable provider' or 'Given a unavailable provider'?"); + } + return state.provider; + } } From ca2f27cb740ac8159215f05a9e7d930c7fafb723 Mon Sep 17 00:00:00 2001 From: Simon Schrottner Date: Fri, 11 Sep 2026 12:19:42 +0200 Subject: [PATCH 15/55] feat(provider-tck): model @large-integers as not applicable and seed the new canonical flags The bumped assets add a @large-integers tag for 2^53 - 1, which Java cannot ask for: Client.getIntegerDetails is a 32-bit Integer, a property of the SDK rather than of any provider. Capability grows a not-applicable state beside reserved: LARGE_INTEGERS is excluded from declarable(), declaring it fails the run the way a reserved tag does, and the gate reports its scenario as skipped with a reason that names the SDK's accessor rather than the provider, on every Java run. @numeric-coercion now requires the lossless direction too, and the SDK's InMemoryProvider keeps the two numeric types strictly apart in both directions, so the in-memory and multi-provider self-tests stop declaring it. The rule is borrowed from flagd's ADR rather than from the specification, so the Capability, KnownDeviation and README text that called withholding it an admission of a bug is corrected: narrowing 0.5 to 0 with no error code is a defect to declare, strict typing is a choice. InProcessBackendControl mirrors the six new canonical flags: 10.0 stays a Double, 2^53 - 1 is a Long so nothing parses it as an Integer, and false, 0 and "" are seeded as values. A unit test pins those types, and TckValues names the 32-bit accessor when a cell does not fit an Integer. Signed-off-by: Simon Schrottner --- tools/provider-tck/README.md | 88 ++++++------ .../contrib/tools/providertck/Capability.java | 128 ++++++++++++++---- .../tools/providertck/CapabilityGate.java | 18 ++- .../providertck/InProcessBackendControl.java | 76 ++++++++++- .../tools/providertck/KnownDeviation.java | 7 +- .../tools/providertck/ProviderTckHarness.java | 3 +- .../contrib/tools/providertck/TckValues.java | 9 +- .../tools/providertck/DeclarationApiTest.java | 30 ++++ .../providertck/InMemoryProviderTckTest.java | 21 ++- .../InProcessBackendControlTest.java | 29 ++++ .../providertck/MultiProviderTckTest.java | 11 +- 11 files changed, 329 insertions(+), 91 deletions(-) diff --git a/tools/provider-tck/README.md b/tools/provider-tck/README.md index d427ea2009..cee60ab18e 100644 --- a/tools/provider-tck/README.md +++ b/tools/provider-tck/README.md @@ -40,13 +40,17 @@ uses only long-stable API — `OpenFeatureAPI`, `Client`, typed evaluation, `Pro **In scope — the provider contract:** -- mapping backend responses onto typed resolution details (value, variant, reason, error code) -- keeping the integer and float types distinct +- mapping backend responses onto typed resolution details (value, variant, reason, error code), with + no error message on a success path +- keeping the integer and float types distinct; that `false`, `0` and `""` are values, not absences; + integer precision to 2^31 − 1 - error handling: type mismatch and unknown flag return the code default, report the right error code, and never throw -- lifecycle: reaching `READY`, and settling into `ERROR` against an unreachable backend +- lifecycle: reaching `READY`, settling into `ERROR` against an unreachable backend, and a shutdown + that can be repeated, returns promptly when the backend is gone, and is undone by initialising again - events: `PROVIDER_READY`, `PROVIDER_ERROR`, `PROVIDER_STALE`, `PROVIDER_CONFIGURATION_CHANGED` - that a signalled configuration change is actually applied on re-evaluation +- that the provider identifies itself by a non-empty metadata name **Out of scope — not the provider's contract:** @@ -113,11 +117,7 @@ public class MyProviderTckTest extends ProviderTckTest { @Override public Set capabilities() { - return EnumSet.of( - Capability.EVENTS, - Capability.CONFIGURATION_CHANGE, - Capability.OBJECT, - Capability.NUMERIC_COERCION); + return EnumSet.of(Capability.EVENTS, Capability.CONFIGURATION_CHANGE, Capability.OBJECT); } } ``` @@ -142,8 +142,13 @@ Two suites in this module are exactly the class above, and both run with no Dock second. They are the reference adoption, and they are the fast CI canary. [`InMemoryProviderTckTest`](src/test/java/dev/openfeature/contrib/tools/providertck/InMemoryProviderTckTest.java) -runs the full applicable suite against the SDK's `InMemoryProvider` — 26 passed, 3 skipped by -capability. +runs the full applicable suite against the SDK's `InMemoryProvider` — of the 40 scenarios (outline +rows counted individually), 29 pass and 11 are skipped by capability: the six `@lifecycle` ones, the +`@stale` one, the three `@numeric-coercion` ones and the `@large-integers` one. It does not declare +`NUMERIC_COERCION`, because `InMemoryProvider` keeps the two numeric types strictly apart in both +directions — it refuses `10.0` as an integer and `10` as a float exactly as it refuses `0.5` — and the +tag requires the lossless direction too. That is a choice the SDK's reference provider is entitled to, +not a defect; see the class javadoc. [`MultiProviderTckTest`](src/test/java/dev/openfeature/contrib/tools/providertck/MultiProviderTckTest.java) runs it against `MultiProvider` wrapping **one** `InMemoryProvider`. A provider that delegates is @@ -229,10 +234,16 @@ It is expressed in the flagd flag-definition format because that is the only wid vendor-neutral format today — the format is not what matters, the keys, types, variants and resolved values are. Seed them however your backend seeds flags. -Two details are load-bearing: +Four details are load-bearing: - **`missing-flag` must not exist.** Its absence is what the `FLAG_NOT_FOUND` scenario tests. - **No flag has targeting rules.** Every scenario expects reason `STATIC`. +- **`false-flag`, `zero-flag` and `empty-string-flag` resolve to `false`, `0` and `""` on purpose.** + A seeding step that treats them as unset and drops them turns the falsy-value scenarios into + `FLAG_NOT_FOUND` failures that look like provider defects. +- **`integral-float-flag` is a float and `huge-integer-flag` is an integer.** Seeding `10.0` as `10` + makes the lossless-coercion scenario pass without coercing anything; seeding `9007199254740991` + through a float rounds it. ### 4. The test class @@ -384,7 +395,8 @@ green on scenarios it did not run is worse than no suite at all. | `CONFIGURATION_CHANGE` | `@configuration-change` | detects config changes, emits `PROVIDER_CONFIGURATION_CHANGED` | | `OBJECT` | `@object` | supports structured flag values | | `UNAVAILABLE_INIT` | `@unavailable` | reports an error state instead of hanging on a dead backend — *needs connection control* | -| `NUMERIC_COERCION` | `@numeric-coercion` | coerces between integer and float only when lossless, else `TYPE_MISMATCH` | +| `NUMERIC_COERCION` | `@numeric-coercion` | coerces between integer and float only when lossless, else `TYPE_MISMATCH` — both directions tested | +| `LARGE_INTEGERS` | `@large-integers` | resolves integers up to 2^53 − 1 exactly; **not applicable in Java, not declarable** — the SDK's integer accessor is a 32-bit `Integer`, so the scenario is skipped with that reason on every run | | `TARGETING` | `@targeting` | reserved, **not declarable** — no scenarios yet | | `CACHING` | `@caching` | reserved, **not declarable** — no scenarios yet | @@ -425,26 +437,27 @@ while emitting no events of its own, and would have been excluded. Declare `LIFE initialisation actually talks to the backend; a provider with nothing to reach — an in-memory provider, or a facade over other providers — should not declare it however many events it emits. -A note on `NUMERIC_COERCION`: unlike the others it is not an optional feature. The rule is that -coercion between integer and float is permitted **when it is lossless** and must fail with -`TYPE_MISMATCH` **when it is not** — `10.0` requested as an integer must succeed, `0.5` must not. -Narrowing `0.5` to `0` loses information silently, which is the worst failure mode for a feature -flag, because the application sees a plausible value and no error. It is a capability only so a -provider with this defect can adopt the TCK today and see the gap reported explicitly. Not -declaring it is an admission of a known bug. **The flagd provider currently does not declare it**, -in either RPC or in-process mode — see +A note on `NUMERIC_COERCION`: the rule it tests is **borrowed, not normative**. Coercion between +integer and float is permitted **when it is lossless** and must fail with `TYPE_MISMATCH` **when it +is not** — `10.0` requested as an integer must succeed, `10` requested as a float must succeed, and +`0.5` requested as an integer must not. All three have scenarios and a provider declaring the tag +must satisfy all three; rejecting every float passes the lossy one and fails the other two. The rule +comes from flagd's [numeric coercion +ADR](https://github.com/open-feature/flagd/blob/main/docs/architecture-decisions/numeric-coercion.md); +the specification has a single numeric type and says nothing about a value that does not fit the +accessor it was asked through ([spec#430](https://github.com/open-feature/spec/issues/430)), so a +provider that behaves differently is not violating it. It is still worth saying which kind of +difference it is: narrowing `0.5` to `0` with no error code hands an application a plausible value and +no signal, which is a defect to declare as a `KnownDeviation`, whereas keeping the two types strictly +apart — what `InMemoryProvider` does — is a choice. **The flagd provider does not declare it**, in +either mode, for the first reason — see [flagd#1996](https://github.com/open-feature/flagd/issues/1996). -Two things the tag does not cover, both open in Appendix F rather than fixed here: - -- **The lossless case has no scenario.** Only the lossy half is tested, because the canonical flag - set contains no integral float to ask the other half of, and adding one changes the flag set for - every language at once. A provider that wrongly rejects `10.0` as an integer declares this - capability and passes. -- **Accessor width is unmodelled.** The [numeric coercion - ADR](https://github.com/open-feature/flagd/blob/main/docs/architecture-decisions/numeric-coercion.md) - distinguishes a 64-bit integer accessor from a 32-bit one — flagd's own testbed tags the latter - `@int32-bounded` — and neither Appendix F nor this suite has anything equivalent. +A note on `LARGE_INTEGERS`: accessor width is a property of the SDK, not of the provider, and Java's +is 32 bits — `Client.getIntegerDetails` takes and returns an `Integer`. The tag is therefore neither +declarable nor declared here, and its one scenario is reported as skipped with that reason on every +Java run, whatever the provider could do. Declaring it fails the run, as declaring a reserved tag +does. The 32-bit precision scenario (`large-integer-flag`, 2^31 − 1) is untagged and always runs. ### Saying that a withheld capability is a defect @@ -546,13 +559,17 @@ Everything else is unchanged: `a -flag with key ... and a default value .. `the connection is lost[ for s]`, `the flag was modified`, `the flag should be part of the event payload`, `the client should be in state`. -Three steps are new: +The steps the TCK added: | Step | Why it was added | |---|---| | `When the connection is restored` | the flagd harness only has the self-healing `lost for {int}s` form, which cannot express "assert stale, *then* reconnect" — the reconnect races the assertion | | `When the resolved value is remembered` / `Then the resolved details value should have changed` | the control API only requires that `/change` changes `changing-flag`'s value, not which value it changes to; asserting a delta keeps the scenario vendor-neutral | -| `Then no exception should have been thrown` | makes the "never throws" half of the error contract explicit rather than implicit in a step failure | +| `Then no exception should have been thrown` | makes the "never throws" half of the error contract explicit rather than implicit in a step failure; also covers a repeated `shutdown()` and an `initialize()` after it | +| `Then the error message should be empty` | a value *and* an error message are two contradictory signals (requirement 2.3.2); asserted on every success path | +| `Then the provider metadata name should not be empty` | a conformance report keyed on the provider's name cannot be attributed if the name is empty (requirement 2.1.1) | +| `When the provider is shut down` / `When the provider is initialized again` | call the provider's own `shutdown()` and `initialize()` directly, not through the SDK — replacing the provider would test the SDK's bookkeeping, which Appendix B covers; the SDK is not told, so the next evaluation through the same client reaches the re-initialised provider | +| `Then the shutdown should have completed within {int}ms` | a shutdown that waits for a graceful close of a connection that will never answer hangs the host application's own shutdown | ## Where these artifacts come from @@ -597,13 +614,6 @@ explicit command is only useful when working offline or inspecting the sources b - **Caching.** Whether a stale provider keeps serving last-known values during an outage depends on whether it holds a local copy of the ruleset. The `@caching` tag is reserved; no scenarios yet, and so not declarable. -- **Lossless numeric coercion.** `@numeric-coercion` tests only the lossy half of its rule. The - canonical flag set holds no integral float, so there is nothing to ask "must `10.0` resolve as an - integer?" of, and a provider that wrongly answers no still passes. Closing it means adding a flag - to the canonical set, which changes it for every language at once. -- **Integer accessor width.** flagd's numeric coercion ADR distinguishes a 64-bit integer accessor - from a 32-bit one, and tags the latter `@int32-bounded` in its own testbed. Neither this suite nor - Appendix F models width at all, and it is a real source of cross-language disagreement. - **Setting and removing individual flags.** `BackendControl` exposes `prepareScenario()` and `changeFlag()` — reset to the canonical baseline, and mutate `changing-flag` — because those are what the Gherkin needs and what the control API defines. Finer-grained `setFlag(key, value)` / diff --git a/tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/Capability.java b/tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/Capability.java index 976990055f..5fa86b69ea 100644 --- a/tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/Capability.java +++ b/tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/Capability.java @@ -30,6 +30,11 @@ * {@code EnumSet.allOf} or {@code EnumSet.complementOf}: both of the latter sweep up every reserved * tag on the way past, which is how a report comes to claim a capability nobody examined. * + *

One entry is {@linkplain #notApplicable() not applicable} in Java: a scenario carries its tag, + * but what the tag asks for is a property of the SDK rather than of any provider, and the Java SDK + * cannot supply it. Such a capability is not declarable either — the claim could never be true of a + * Java provider — and its scenarios are skipped with a reason that names the SDK, on every run. + * *

The connection-dependent capabilities

* *

{@link #STALE} and {@link #UNAVAILABLE_INIT} are the two that require a backend the provider @@ -98,25 +103,45 @@ public enum Capability { * numeric * coercion ADR, and this capability is named after it. * - *

Unlike the other entries here this is not an optional spec feature. The - * specification requires a provider to report {@code TYPE_MISMATCH} when the requested type - * cannot be satisfied, and narrowing {@code 0.5} to {@code 0} to satisfy an integer request - * loses information silently — the worst possible failure mode for a feature flag, because the - * application sees a plausible value and no error. - * - *

It is a capability only so that a provider with this defect can adopt the TCK today and - * see the gap reported as an explicit skip, rather than being unable to adopt at all. Not - * declaring it is an admission of a known bug, not a design choice. Declare it as soon as the - * provider is fixed. - * - *

Only the lossy half is tested. The canonical flag set contains no integral - * float, so there is nothing to ask the lossless half of, and a provider that wrongly rejects - * {@code 10.0} as an integer declares this and passes. Closing that gap means adding a flag to - * the canonical set, which changes it for every language at once; Appendix F records it as open - * rather than pretending it is covered. + *

The rule is borrowed, not normative. OpenFeature has a single numeric type + * and lets a typed language split it into two accessors "as idioms dictate", and nothing in the + * specification says what a provider owes a value that does not fit the accessor it was asked + * through — that is open-feature/spec#430. + * A provider that behaves differently is not violating the specification, and a report must + * not be read as saying it is. Withholding the capability is still worth a word: narrowing + * {@code 0.5} to {@code 0} with no error code hands an application a plausible value and no + * signal, so a provider that does that should say whether it is a choice or a tracked defect, + * and {@link KnownDeviation} is where the second is said. + * + *

Both halves are tested. The lossy half asks for {@code float-flag} (0.5) + * as an integer and expects {@code TYPE_MISMATCH}; the lossless half asks for + * {@code integral-float-flag} (10.0) as an integer and for {@code integer-flag} (10) as a float + * and expects both to succeed. A provider declaring this must satisfy all three — rejecting + * every float is an easy way to pass the first, and the other two are what stop it. A provider + * that keeps the two numeric types strictly apart in both directions, as the SDK's own + * {@code InMemoryProvider} does, therefore cannot declare it. */ NUMERIC_COERCION("@numeric-coercion"), + /** + * Provider resolves integers up to 2^53 − 1 exactly. + * + *

{@linkplain #notApplicable() Not applicable} in Java, and so not declarable. Whether the + * value can be asked for at all is a property of the SDK's integer accessor rather than of the + * provider: {@code Client.getIntegerDetails} takes and returns a 32-bit {@link Integer}, so a + * Java provider has nowhere to put {@code 9007199254740991} however faithfully its backend + * serves it. Go's accessor is {@code int64} and JavaScript's number reaches 2^53 − 1 exactly, so + * their suites run the scenario; here it is reported as skipped, with that reason, on every run. + * + *

The 32-bit precision scenario — {@code large-integer-flag}, 2^31 − 1 — is untagged and + * always runs. What a provider owes a value that does not fit the requested accessor is the + * open question in open-feature/spec#430. + */ + LARGE_INTEGERS( + "@large-integers", + "the Java SDK's integer accessor is a 32-bit Integer, so a Java provider cannot resolve an " + + "integer beyond 2^31 - 1 through it whatever its backend serves"), + /** * Provider supports targeting rules driven by evaluation context. * @@ -136,14 +161,24 @@ public enum Capability { private final String tag; private final boolean reserved; + private final String notApplicableReason; Capability(String tag) { - this(tag, false); + this(tag, false, null); } Capability(String tag, boolean reserved) { + this(tag, reserved, null); + } + + Capability(String tag, String notApplicableReason) { + this(tag, false, notApplicableReason); + } + + Capability(String tag, boolean reserved, String notApplicableReason) { this.tag = tag; this.reserved = reserved; + this.notApplicableReason = notApplicableReason; } /** @@ -169,6 +204,30 @@ public boolean reserved() { return reserved; } + /** + * Returns whether this capability is one no Java provider can have, and so must not be declared. + * + *

Not applicable means a scenario carries the tag, but what it asks for is a property of the + * SDK rather than of the provider and the Java SDK cannot supply it. Unlike a + * {@linkplain #reserved() reserved} capability there is something to gate: the scenario + * runs the gate and is reported as skipped with {@link #notApplicableReason()}, so a reader sees + * why it was not examined rather than a bare omission. + * + * @return {@code true} if the Java SDK cannot satisfy this capability + */ + public boolean notApplicable() { + return notApplicableReason != null; + } + + /** + * Returns why this capability is not applicable in Java, when it is not. + * + * @return the reason, or empty for a capability a Java provider may declare + */ + public Optional notApplicableReason() { + return Optional.ofNullable(notApplicableReason); + } + /** * Looks up the capability gated by a Gherkin tag. * @@ -180,16 +239,18 @@ public static Optional fromTag(String tag) { } /** - * Returns every capability that may be declared, which is every capability some scenario gates. + * Returns every capability that may be declared: every capability some scenario gates and a + * Java provider can have. * *

This, not {@code EnumSet.allOf(Capability.class)}, is what "everything" means for a - * declaration. + * declaration. {@linkplain #reserved() Reserved} and {@linkplain #notApplicable() not + * applicable} capabilities are left out. * * @return the declarable capabilities, as a fresh mutable set */ public static EnumSet declarable() { EnumSet declarable = EnumSet.allOf(Capability.class); - declarable.removeIf(Capability::reserved); + declarable.removeIf(capability -> capability.reserved() || capability.notApplicable()); return declarable; } @@ -198,9 +259,11 @@ public static EnumSet declarable() { * *

The counterpart to {@code EnumSet.complementOf}, and the reason it exists: a provider * saying "everything except the one thing I cannot do" wants everything declarable - * except that thing, whereas {@code complementOf} hands back the reserved tags as well. + * except that thing, whereas {@code complementOf} hands back the reserved and not-applicable + * tags as well. * - * @param excluded capabilities to withhold; reserved capabilities are absent regardless + * @param excluded capabilities to withhold; reserved and not-applicable capabilities are absent + * regardless * @return the declarable capabilities minus {@code excluded}, as a fresh mutable set */ public static EnumSet declarableExcept(Capability... excluded) { @@ -212,23 +275,27 @@ public static EnumSet declarableExcept(Capability... excluded) { } /** - * Rejects a declaration that names a reserved capability. + * Rejects a declaration that names a reserved or a not-applicable capability. * *

Fails the run rather than warning and dropping it. The declaration is the one part of a * conformance report that no result can check — everything else in it was observed, this is * asserted by the provider author — so a claim that cannot possibly be true is worth stopping - * for. There is nothing to lose by refusing, either: no scenario carries a reserved tag, so no - * coverage depends on the claim, and the fix is to call {@link #declarable()} or - * {@link #declarableExcept}. + * for. There is nothing to lose by refusing, either: no scenario carries a reserved tag, and a + * not-applicable one is skipped whatever is declared, so no coverage depends on the claim, and + * the fix is to call {@link #declarable()} or {@link #declarableExcept}. * * @param declared the capabilities a harness declares - * @throws IllegalArgumentException if any of them is reserved + * @throws IllegalArgumentException if any of them is reserved or not applicable */ public static void requireDeclarable(Collection declared) { List reservedTags = new ArrayList<>(); + List notApplicableTags = new ArrayList<>(); for (Capability capability : declared) { if (capability.reserved()) { reservedTags.add(capability.name() + " (" + capability.tag() + ")"); + } else if (capability.notApplicable()) { + notApplicableTags.add( + capability.name() + " (" + capability.tag() + "): " + capability.notApplicableReason); } } if (!reservedTags.isEmpty()) { @@ -239,5 +306,12 @@ public static void requireDeclarable(Collection declared) { + "\"everything except\" — EnumSet.allOf and EnumSet.complementOf pick reserved " + "capabilities up on the way past."); } + if (!notApplicableTags.isEmpty()) { + throw new IllegalArgumentException("capabilities() declares " + notApplicableTags + + ", which no Java provider can satisfy: the limit is the SDK's, not the " + + "provider's, and the scenario is skipped with that reason whatever is declared. " + + "Leave it out — Capability.declarable() and Capability.declarableExcept(...) " + + "already do."); + } } } diff --git a/tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/CapabilityGate.java b/tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/CapabilityGate.java index 2ff1d208c1..16898fe991 100644 --- a/tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/CapabilityGate.java +++ b/tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/CapabilityGate.java @@ -22,19 +22,29 @@ public final class CapabilityGate { private CapabilityGate() {} /** - * Aborts the running scenario if any of its tags gates a capability that was not declared. + * Aborts the running scenario if any of its tags gates a capability that was not declared, or + * one that is {@linkplain Capability#notApplicable() not applicable} in Java. * *

Tags that gate nothing are ignored, so a scenario with no capability tag is mandatory and - * always runs. + * always runs. A not-applicable capability is checked before the declaration, and its skip + * names the SDK rather than the provider: the provider did not decline it, the language did. * * @param tags the scenario's Gherkin tags, including the leading at-sign * @param declared the capabilities the provider declares - * @throws TestAbortedException if a tag gates an undeclared capability + * @throws TestAbortedException if a tag gates an undeclared or a not-applicable capability */ public static void requireDeclared(Collection tags, Set declared) { for (String tag : tags) { Optional capability = Capability.fromTag(tag); - if (capability.isPresent() && !declared.contains(capability.get())) { + if (!capability.isPresent()) { + continue; + } + Optional notApplicable = capability.get().notApplicableReason(); + if (notApplicable.isPresent()) { + throw new TestAbortedException("Skipped: capability " + capability.get().name() + " (tag " + tag + + ") is not applicable to a Java provider — " + notApplicable.get() + "."); + } + if (!declared.contains(capability.get())) { throw new TestAbortedException("Skipped: provider does not declare capability " + capability.get().name() + " (tag " + tag + "). Declared capabilities: " + declared); } diff --git a/tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/InProcessBackendControl.java b/tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/InProcessBackendControl.java index 91ef2fb6f0..9090b0995d 100644 --- a/tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/InProcessBackendControl.java +++ b/tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/InProcessBackendControl.java @@ -146,11 +146,20 @@ private InMemoryProvider requireProvider() { /** * Builds the canonical flag set as {@link InMemoryProvider} flags. * - *

Mirrors {@code flags/canonical-flags.json} entry for entry. The two load-bearing details - * from that file hold here too: {@code missing-flag} is absent, which is what the - * {@code FLAG_NOT_FOUND} scenario tests, and no flag carries a - * {@link dev.openfeature.sdk.providers.memory.ContextEvaluator}, so every evaluation reports - * reason {@code STATIC} as the feature files expect. + *

Mirrors {@code flags/canonical-flags.json} entry for entry. The load-bearing details from + * that file hold here too: + * + *

    + *
  • {@code missing-flag} is absent, which is what the {@code FLAG_NOT_FOUND} scenario tests; + *
  • no flag carries a {@link dev.openfeature.sdk.providers.memory.ContextEvaluator}, so + * every evaluation reports reason {@code STATIC} as the feature files expect; + *
  • {@code false-flag}, {@code zero-flag} and {@code empty-string-flag} resolve to + * {@code false}, {@code 0} and {@code ""} — values, not absences; + *
  • {@code integral-float-flag} is a {@link Double} holding {@code 10.0}, never the + * {@link Integer} {@code 10}, or the lossless-coercion scenario would pass without + * anything being coerced; {@code huge-integer-flag} is a {@link Long}, because + * 2^53 − 1 does not fit an {@link Integer}. + *
* * @return the canonical flag set */ @@ -189,6 +198,63 @@ private static Map> canonicalFlags() { .defaultVariant("half") .build()); + // 2^31 - 1: the largest value every language's integer accessor can ask for, and one a + // float32 round trip does not keep. + flags.put( + "large-integer-flag", + Flag.builder() + .variant("one", 1) + .variant("max-int32", 2147483647) + .defaultVariant("max-int32") + .build()); + + // 2^53 - 1, which does not fit an Integer and so is a Long. Only asked for under + // @large-integers, which is not applicable in Java, so no scenario reaches it; it is here + // so that the set mirrors the JSON entry for entry, seeded as an integer and not rounded. + flags.put( + "huge-integer-flag", + Flag.builder() + .variant("one", 1L) + .variant("max-safe", 9007199254740991L) + .defaultVariant("max-safe") + .build()); + + // A float with no fractional part, for the lossless half of @numeric-coercion. The literal + // 10.0 is a double, so the variant is a Double and stays one. + flags.put( + "integral-float-flag", + Flag.builder() + .variant("tenth", 0.1) + .variant("ten", 10.0) + .defaultVariant("ten") + .build()); + + // The three falsy values. Each scenario's default differs from the resolved value, so a + // provider that treats false, 0 or "" as "nothing came back" is caught. + flags.put( + "false-flag", + Flag.builder() + .variant("on", true) + .variant("off", false) + .defaultVariant("off") + .build()); + + flags.put( + "zero-flag", + Flag.builder() + .variant("one", 1) + .variant("zero", 0) + .defaultVariant("zero") + .build()); + + flags.put( + "empty-string-flag", + Flag.builder() + .variant("greeting", "hi") + .variant("empty", "") + .defaultVariant("empty") + .build()); + flags.put( "object-flag", Flag.builder() diff --git a/tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/KnownDeviation.java b/tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/KnownDeviation.java index 7e08477257..caa59d16fa 100644 --- a/tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/KnownDeviation.java +++ b/tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/KnownDeviation.java @@ -8,9 +8,10 @@ * *

Distinct from an undeclared capability, which is a choice. A provider that does not * declare {@code @configuration-change} has no streaming transport and is not pretending otherwise; - * a provider that does not declare {@code @numeric-coercion} has a bug. Both look identical in - * the results — scenarios skipped, reason recoverable from the declaration — so the difference has - * to be stated, or a consumer cannot tell a design decision from a defect. + * a provider that does not declare {@code @numeric-coercion} because it narrows {@code 0.5} to + * {@code 0} with no error code has a bug. Both look identical in the results — scenarios skipped, + * reason recoverable from the declaration — so the difference has to be stated, or a consumer + * cannot tell a design decision from a defect. * *

Declared by the provider author through {@link ProviderTckHarness#knownDeviations()}, which is * the only place that knows the difference. The TCK cannot infer it: from the outside, a capability diff --git a/tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/ProviderTckHarness.java b/tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/ProviderTckHarness.java index c72338ea14..c23baffda1 100644 --- a/tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/ProviderTckHarness.java +++ b/tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/ProviderTckHarness.java @@ -124,7 +124,8 @@ default FeatureProvider createUnavailableProvider() { * *

Do not build the set with {@code EnumSet.allOf} or {@code EnumSet.complementOf}. Both * include the {@linkplain Capability#reserved() reserved} capabilities, which no scenario - * carries, and declaring one of those fails the run. + * carries, and the {@linkplain Capability#notApplicable() not applicable} one, which no Java + * provider can have; declaring either fails the run. * * @return the capabilities this provider supports */ diff --git a/tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/TckValues.java b/tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/TckValues.java index 7386e977cb..4cb707736c 100644 --- a/tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/TckValues.java +++ b/tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/TckValues.java @@ -37,7 +37,14 @@ public static Object convert(String value, String type) { case "String": return value; case "Integer": - return Integer.parseInt(value); + try { + return Integer.parseInt(value); + } catch (NumberFormatException e) { + throw new IllegalArgumentException("'" + value + "' is not an Integer the Java SDK can ask for: " + + "Client.getIntegerDetails takes a 32-bit Integer. A scenario needing more than " + + "2^31 - 1 must carry @large-integers, which is not applicable in Java and is " + + "skipped before any value is converted.", e); + } case "Float": return Double.parseDouble(value); case "Object": diff --git a/tools/provider-tck/src/test/java/dev/openfeature/contrib/tools/providertck/DeclarationApiTest.java b/tools/provider-tck/src/test/java/dev/openfeature/contrib/tools/providertck/DeclarationApiTest.java index 50e8f2be3f..17c237ba3c 100644 --- a/tools/provider-tck/src/test/java/dev/openfeature/contrib/tools/providertck/DeclarationApiTest.java +++ b/tools/provider-tck/src/test/java/dev/openfeature/contrib/tools/providertck/DeclarationApiTest.java @@ -41,6 +41,36 @@ void reservedCapabilitiesAreNotDeclarable() { .hasMessageContaining("declares reserved"); } + @Test + @DisplayName("a capability the Java SDK cannot satisfy is not declarable, and is skipped with that reason") + void notApplicableCapabilitiesAreNotDeclarable() { + assertThat(Capability.LARGE_INTEGERS.notApplicable()).isTrue(); + assertThat(Capability.LARGE_INTEGERS.notApplicableReason()) + .as("the reason names the SDK's accessor, which is the limit, rather than the provider") + .hasValueSatisfying(reason -> assertThat(reason).contains("32-bit")); + assertThat(Capability.LARGE_INTEGERS.reserved()) + .as("not applicable is distinct from reserved: a scenario does carry the tag") + .isFalse(); + + assertThat(Capability.declarable()).doesNotContain(Capability.LARGE_INTEGERS); + assertThat(Capability.declarableExcept(Capability.STALE)).doesNotContain(Capability.LARGE_INTEGERS); + + assertThatThrownBy(() -> Capability.requireDeclarable(EnumSet.of(Capability.EVENTS, Capability.LARGE_INTEGERS))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("LARGE_INTEGERS") + .hasMessageContaining("no Java provider can satisfy"); + + // Skipped whatever is declared, and the skip blames the SDK rather than the provider. + TestAbortedException aborted = catchThrowableOfType( + () -> CapabilityGate.requireDeclared(Arrays.asList("@large-integers"), Capability.declarable()), + TestAbortedException.class); + assertThat(aborted).isNotNull(); + assertThat(aborted) + .hasMessageContaining("not applicable") + .hasMessageContaining("32-bit") + .hasMessageNotContaining("does not declare"); + } + @Test @DisplayName("a tag maps back to the capability it gates") void tagsMapBackToCapabilities() { diff --git a/tools/provider-tck/src/test/java/dev/openfeature/contrib/tools/providertck/InMemoryProviderTckTest.java b/tools/provider-tck/src/test/java/dev/openfeature/contrib/tools/providertck/InMemoryProviderTckTest.java index 2c6b4fa6d4..9779c33a6e 100644 --- a/tools/provider-tck/src/test/java/dev/openfeature/contrib/tools/providertck/InMemoryProviderTckTest.java +++ b/tools/provider-tck/src/test/java/dev/openfeature/contrib/tools/providertck/InMemoryProviderTckTest.java @@ -47,10 +47,20 @@ public FeatureProvider createProvider() { /** * {@inheritDoc} * - *

Four capabilities, and each omission is a fact about {@link InMemoryProvider} rather than a + *

Three capabilities, and each omission is a fact about {@link InMemoryProvider} rather than a * convenience: * *

    + *
  • {@link Capability#NUMERIC_COERCION} — omitted. {@link InMemoryProvider} keeps the two + * numeric types strictly apart in both directions: a variant satisfies a request only if + * it is an instance of the requested type. That passes the lossy half of the rule — 0.5 + * requested as an integer is {@code TYPE_MISMATCH} — and fails the lossless half, because + * {@code integral-float-flag} (10.0) requested as an integer and {@code integer-flag} (10) + * requested as a float are refused just the same, and the tag requires all three. The + * rule is borrowed from flagd's ADR rather than from the specification, so strict typing + * is a choice the SDK's reference provider is entitled to, not a defect to declare; the + * capability is withheld and the three scenarios are skipped with that reason. Declare it + * again if the SDK ever adopts the coercion rule. *
  • {@link Capability#LIFECYCLE} — omitted. Initialisation reaches no backend here, so the * readiness scenario would pass without demonstrating anything, which is exactly what that * capability exists to distinguish. @@ -64,15 +74,12 @@ public FeatureProvider createProvider() { * {@link ProviderTckHarness#createUnavailableProvider()} is left at its throwing default. *
  • {@link Capability#TARGETING} and {@link Capability#CACHING} — omitted because no * scenario carries their tags yet. Nothing is skipped by leaving them out today. + *
  • {@link Capability#LARGE_INTEGERS} — not declarable by any Java provider, this one + * included; its scenario is skipped with the SDK's 32-bit accessor as the reason. *
- * - *

{@link Capability#NUMERIC_COERCION} is declared, and that is worth stating - * plainly: {@link InMemoryProvider} refuses to narrow {@code float-flag} (0.5) to an integer and - * reports {@code TYPE_MISMATCH} instead. It is the reference behaviour the capability describes. */ @Override public Set capabilities() { - return EnumSet.of( - Capability.EVENTS, Capability.CONFIGURATION_CHANGE, Capability.OBJECT, Capability.NUMERIC_COERCION); + return EnumSet.of(Capability.EVENTS, Capability.CONFIGURATION_CHANGE, Capability.OBJECT); } } diff --git a/tools/provider-tck/src/test/java/dev/openfeature/contrib/tools/providertck/InProcessBackendControlTest.java b/tools/provider-tck/src/test/java/dev/openfeature/contrib/tools/providertck/InProcessBackendControlTest.java index 14df30bb37..31e530a72e 100644 --- a/tools/provider-tck/src/test/java/dev/openfeature/contrib/tools/providertck/InProcessBackendControlTest.java +++ b/tools/provider-tck/src/test/java/dev/openfeature/contrib/tools/providertck/InProcessBackendControlTest.java @@ -5,6 +5,7 @@ import dev.openfeature.sdk.ImmutableContext; import dev.openfeature.sdk.ProviderEvaluation; +import dev.openfeature.sdk.exceptions.TypeMismatchError; import dev.openfeature.sdk.providers.memory.InMemoryProvider; import java.time.Duration; import org.junit.jupiter.api.DisplayName; @@ -96,6 +97,34 @@ void missingFlagIsAbsent() throws Exception { .hasMessageContaining("missing-flag"); } + @Test + @DisplayName("the canonical flag set keeps the values and types the scenarios depend on") + void canonicalFlagsKeepTheirValuesAndTypes() throws Exception { + InMemoryProvider provider = new InProcessBackendControl().createProvider(); + provider.initialize(new ImmutableContext()); + ImmutableContext context = new ImmutableContext(); + + // Seeded as the integer 10, the lossless-coercion scenario would pass without coercing. + assertThat(provider.getDoubleEvaluation("integral-float-flag", 0.1, context).getValue()) + .as("integral-float-flag is a Double") + .isEqualTo(10.0); + // Which is also why the self-tests withhold NUMERIC_COERCION: the SDK's provider keeps the + // two numeric types strictly apart and refuses the lossless direction along with the lossy one. + assertThatThrownBy(() -> provider.getIntegerEvaluation("integral-float-flag", 1, context)) + .isInstanceOf(TypeMismatchError.class); + + assertThat(provider.getIntegerEvaluation("large-integer-flag", 1, context).getValue()) + .isEqualTo(2147483647); + + // Values, not absences: each default differs from what the flag resolves to. + assertThat(provider.getBooleanEvaluation("false-flag", true, context).getValue()) + .isFalse(); + assertThat(provider.getIntegerEvaluation("zero-flag", 1, context).getValue()) + .isZero(); + assertThat(provider.getStringEvaluation("empty-string-flag", "fallback", context).getValue()) + .isEmpty(); + } + private static String resolveChangingFlag(InMemoryProvider provider) { ProviderEvaluation evaluation = provider.getStringEvaluation("changing-flag", "unset", new ImmutableContext()); diff --git a/tools/provider-tck/src/test/java/dev/openfeature/contrib/tools/providertck/MultiProviderTckTest.java b/tools/provider-tck/src/test/java/dev/openfeature/contrib/tools/providertck/MultiProviderTckTest.java index ed2587e2f7..18c0d94c74 100644 --- a/tools/provider-tck/src/test/java/dev/openfeature/contrib/tools/providertck/MultiProviderTckTest.java +++ b/tools/provider-tck/src/test/java/dev/openfeature/contrib/tools/providertck/MultiProviderTckTest.java @@ -67,12 +67,15 @@ public FeatureProvider createProvider() { * on a provider that cannot satisfy it. * *

Everything else holds. Values, variants, reasons, the full type-mismatch matrix, - * {@code FLAG_NOT_FOUND}, structured values and numeric coercion all survive the delegation hop - * unchanged. {@link Capability#LIFECYCLE} is omitted for the same reason as in - * {@link InMemoryProviderTckTest}: nothing here reaches a backend during initialisation. + * {@code FLAG_NOT_FOUND}, falsy values, 32-bit integer precision and structured values all + * survive the delegation hop unchanged. {@link Capability#LIFECYCLE} and + * {@link Capability#NUMERIC_COERCION} are omitted for the same reasons as in + * {@link InMemoryProviderTckTest}: nothing here reaches a backend during initialisation, and the + * child refuses the lossless coercions the tag now requires — a facade cannot declare what its + * only child does not have. */ @Override public Set capabilities() { - return EnumSet.of(Capability.EVENTS, Capability.OBJECT, Capability.NUMERIC_COERCION); + return EnumSet.of(Capability.EVENTS, Capability.OBJECT); } } From e41ea6e7f48902f00e8f283ee2032aa66384c4fa Mon Sep 17 00:00:00 2001 From: Simon Schrottner Date: Fri, 11 Sep 2026 12:41:21 +0200 Subject: [PATCH 16/55] fix(provider-tck): drop the suppression TckState no longer needs Implementing the shutdown and metadata steps gave every field on TckState a reader, which makes @SuppressFBWarnings(URF_UNREAD_PUBLIC_OR_PROTECTED_FIELD) unnecessary -- and spotbugs fails a build on a useless suppression, so `mvn -pl tools/provider-tck verify` was red on the base. Removed rather than excluded. The suppression existed because the fields were written by one step class and read by another, and they are now genuinely read; keeping it would hide the warning coming back if that stopped being true. Signed-off-by: Simon Schrottner --- .../dev/openfeature/contrib/tools/providertck/TckState.java | 4 ---- 1 file changed, 4 deletions(-) diff --git a/tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/TckState.java b/tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/TckState.java index 32cd75e24c..01475033f2 100644 --- a/tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/TckState.java +++ b/tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/TckState.java @@ -4,7 +4,6 @@ import dev.openfeature.sdk.FeatureProvider; import dev.openfeature.sdk.FlagEvaluationDetails; import dev.openfeature.sdk.MutableContext; -import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; import java.time.Duration; import java.util.Optional; import java.util.concurrent.ConcurrentLinkedQueue; @@ -15,9 +14,6 @@ *

One instance per scenario. Anything that must survive across scenarios — the Compose stack, * the control API client, the discovered harness — lives in {@link TckRuntime} instead. */ -@SuppressFBWarnings( - value = "URF_UNREAD_PUBLIC_OR_PROTECTED_FIELD", - justification = "Intentional mutable state sharing required by Cucumber PicoContainer DI") public class TckState { /** Client bound to the domain the provider under test is registered under. */ From 756334eeb724531c517b674c47092d3d30132a78 Mon Sep 17 00:00:00 2001 From: Simon Schrottner Date: Fri, 11 Sep 2026 12:44:34 +0200 Subject: [PATCH 17/55] style(provider-tck): reformat the not-applicable paths to what spotless produces Four files carrying the @large-integers work were committed unwrapped, so spotless:check failed the module before any test ran. Formatting only. Signed-off-by: Simon Schrottner --- .../contrib/tools/providertck/CapabilityGate.java | 5 +++-- .../contrib/tools/providertck/TckValues.java | 10 ++++++---- .../contrib/tools/providertck/steps/ProviderSteps.java | 5 ++++- .../tools/providertck/InProcessBackendControlTest.java | 9 ++++++--- 4 files changed, 19 insertions(+), 10 deletions(-) diff --git a/tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/CapabilityGate.java b/tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/CapabilityGate.java index 16898fe991..e50c0ea877 100644 --- a/tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/CapabilityGate.java +++ b/tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/CapabilityGate.java @@ -41,8 +41,9 @@ public static void requireDeclared(Collection tags, Set decl } Optional notApplicable = capability.get().notApplicableReason(); if (notApplicable.isPresent()) { - throw new TestAbortedException("Skipped: capability " + capability.get().name() + " (tag " + tag - + ") is not applicable to a Java provider — " + notApplicable.get() + "."); + throw new TestAbortedException( + "Skipped: capability " + capability.get().name() + " (tag " + tag + + ") is not applicable to a Java provider — " + notApplicable.get() + "."); } if (!declared.contains(capability.get())) { throw new TestAbortedException("Skipped: provider does not declare capability " diff --git a/tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/TckValues.java b/tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/TckValues.java index 4cb707736c..274f1f139d 100644 --- a/tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/TckValues.java +++ b/tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/TckValues.java @@ -40,10 +40,12 @@ public static Object convert(String value, String type) { try { return Integer.parseInt(value); } catch (NumberFormatException e) { - throw new IllegalArgumentException("'" + value + "' is not an Integer the Java SDK can ask for: " - + "Client.getIntegerDetails takes a 32-bit Integer. A scenario needing more than " - + "2^31 - 1 must carry @large-integers, which is not applicable in Java and is " - + "skipped before any value is converted.", e); + throw new IllegalArgumentException( + "'" + value + "' is not an Integer the Java SDK can ask for: " + + "Client.getIntegerDetails takes a 32-bit Integer. A scenario needing more than " + + "2^31 - 1 must carry @large-integers, which is not applicable in Java and is " + + "skipped before any value is converted.", + e); } case "Float": return Double.parseDouble(value); diff --git a/tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/steps/ProviderSteps.java b/tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/steps/ProviderSteps.java index 4db67c71c9..d8202bb022 100644 --- a/tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/steps/ProviderSteps.java +++ b/tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/steps/ProviderSteps.java @@ -219,7 +219,10 @@ public void theProviderIsInitializedAgain() { try { provider.initialize(state.context); } catch (Exception e) { - log.warn("initialize() of provider {} threw after shutdown", provider.getMetadata().getName(), e); + log.warn( + "initialize() of provider {} threw after shutdown", + provider.getMetadata().getName(), + e); state.thrown = e; state.thrownBy = "initialize()"; } diff --git a/tools/provider-tck/src/test/java/dev/openfeature/contrib/tools/providertck/InProcessBackendControlTest.java b/tools/provider-tck/src/test/java/dev/openfeature/contrib/tools/providertck/InProcessBackendControlTest.java index 31e530a72e..545d93eed7 100644 --- a/tools/provider-tck/src/test/java/dev/openfeature/contrib/tools/providertck/InProcessBackendControlTest.java +++ b/tools/provider-tck/src/test/java/dev/openfeature/contrib/tools/providertck/InProcessBackendControlTest.java @@ -105,7 +105,8 @@ void canonicalFlagsKeepTheirValuesAndTypes() throws Exception { ImmutableContext context = new ImmutableContext(); // Seeded as the integer 10, the lossless-coercion scenario would pass without coercing. - assertThat(provider.getDoubleEvaluation("integral-float-flag", 0.1, context).getValue()) + assertThat(provider.getDoubleEvaluation("integral-float-flag", 0.1, context) + .getValue()) .as("integral-float-flag is a Double") .isEqualTo(10.0); // Which is also why the self-tests withhold NUMERIC_COERCION: the SDK's provider keeps the @@ -113,7 +114,8 @@ void canonicalFlagsKeepTheirValuesAndTypes() throws Exception { assertThatThrownBy(() -> provider.getIntegerEvaluation("integral-float-flag", 1, context)) .isInstanceOf(TypeMismatchError.class); - assertThat(provider.getIntegerEvaluation("large-integer-flag", 1, context).getValue()) + assertThat(provider.getIntegerEvaluation("large-integer-flag", 1, context) + .getValue()) .isEqualTo(2147483647); // Values, not absences: each default differs from what the flag resolves to. @@ -121,7 +123,8 @@ void canonicalFlagsKeepTheirValuesAndTypes() throws Exception { .isFalse(); assertThat(provider.getIntegerEvaluation("zero-flag", 1, context).getValue()) .isZero(); - assertThat(provider.getStringEvaluation("empty-string-flag", "fallback", context).getValue()) + assertThat(provider.getStringEvaluation("empty-string-flag", "fallback", context) + .getValue()) .isEmpty(); } From 0afde031b2c19ac6189082429e5899b11a4f1cf2 Mon Sep 17 00:00:00 2001 From: Simon Schrottner Date: Fri, 11 Sep 2026 14:16:40 +0200 Subject: [PATCH 18/55] fix(provider-tck): name the falsy flags what the SDK suite already names them Moves the spec submodule to ba002ce8, which renames the three falsy canonical flags -- false-flag, zero-flag and empty-string-flag become boolean-zero-flag, integer-zero-flag and string-zero-flag -- and renames their variants to zero/non-zero. Nothing conceptual changes; these are the names Appendix B's SDK suite and flagd-testbed already use, so a backend that serves that flag set now serves this one too. The scenarios assert the variant, not just the value, so the variants had to move with the keys. The in-process fixture mirrors canonical-flags.json entry for entry, so it moves in the same commit: left behind, it would serve the old keys while the feature files ask for the new ones and every falsy scenario would fail FLAG_NOT_FOUND -- the same failure the falsy flags exist to catch, pointing the other way. Signed-off-by: Simon Schrottner --- tools/provider-tck/README.md | 8 ++++--- tools/provider-tck/spec | 2 +- .../providertck/InProcessBackendControl.java | 24 +++++++++---------- .../InProcessBackendControlTest.java | 8 ++++--- 4 files changed, 23 insertions(+), 19 deletions(-) diff --git a/tools/provider-tck/README.md b/tools/provider-tck/README.md index cee60ab18e..07eda3cf9a 100644 --- a/tools/provider-tck/README.md +++ b/tools/provider-tck/README.md @@ -238,9 +238,11 @@ Four details are load-bearing: - **`missing-flag` must not exist.** Its absence is what the `FLAG_NOT_FOUND` scenario tests. - **No flag has targeting rules.** Every scenario expects reason `STATIC`. -- **`false-flag`, `zero-flag` and `empty-string-flag` resolve to `false`, `0` and `""` on purpose.** - A seeding step that treats them as unset and drops them turns the falsy-value scenarios into - `FLAG_NOT_FOUND` failures that look like provider defects. +- **`boolean-zero-flag`, `integer-zero-flag` and `string-zero-flag` resolve to `false`, `0` and + `""` on purpose.** A seeding step that treats them as unset and drops them turns the falsy-value + scenarios into `FLAG_NOT_FOUND` failures that look like provider defects. These names, and their + `zero`/`non-zero` variants, are the ones Appendix B's SDK suite already uses, so a backend that + serves that flag set already serves these. - **`integral-float-flag` is a float and `huge-integer-flag` is an integer.** Seeding `10.0` as `10` makes the lossless-coercion scenario pass without coercing anything; seeding `9007199254740991` through a float rounds it. diff --git a/tools/provider-tck/spec b/tools/provider-tck/spec index 15fe861170..ba002ce8e8 160000 --- a/tools/provider-tck/spec +++ b/tools/provider-tck/spec @@ -1 +1 @@ -Subproject commit 15fe861170f463c20743f5fdb6f7ea083f405f80 +Subproject commit ba002ce8e807ca97920a5ebd8b9303a556f15d29 diff --git a/tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/InProcessBackendControl.java b/tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/InProcessBackendControl.java index 9090b0995d..f537aef975 100644 --- a/tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/InProcessBackendControl.java +++ b/tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/InProcessBackendControl.java @@ -153,8 +153,8 @@ private InMemoryProvider requireProvider() { *

  • {@code missing-flag} is absent, which is what the {@code FLAG_NOT_FOUND} scenario tests; *
  • no flag carries a {@link dev.openfeature.sdk.providers.memory.ContextEvaluator}, so * every evaluation reports reason {@code STATIC} as the feature files expect; - *
  • {@code false-flag}, {@code zero-flag} and {@code empty-string-flag} resolve to - * {@code false}, {@code 0} and {@code ""} — values, not absences; + *
  • {@code boolean-zero-flag}, {@code integer-zero-flag} and {@code string-zero-flag} + * resolve to {@code false}, {@code 0} and {@code ""} — values, not absences; *
  • {@code integral-float-flag} is a {@link Double} holding {@code 10.0}, never the * {@link Integer} {@code 10}, or the lossless-coercion scenario would pass without * anything being coerced; {@code huge-integer-flag} is a {@link Long}, because @@ -232,27 +232,27 @@ private static Map> canonicalFlags() { // The three falsy values. Each scenario's default differs from the resolved value, so a // provider that treats false, 0 or "" as "nothing came back" is caught. flags.put( - "false-flag", + "boolean-zero-flag", Flag.builder() - .variant("on", true) - .variant("off", false) - .defaultVariant("off") + .variant("zero", false) + .variant("non-zero", true) + .defaultVariant("zero") .build()); flags.put( - "zero-flag", + "integer-zero-flag", Flag.builder() - .variant("one", 1) .variant("zero", 0) + .variant("non-zero", 1) .defaultVariant("zero") .build()); flags.put( - "empty-string-flag", + "string-zero-flag", Flag.builder() - .variant("greeting", "hi") - .variant("empty", "") - .defaultVariant("empty") + .variant("zero", "") + .variant("non-zero", "str") + .defaultVariant("zero") .build()); flags.put( diff --git a/tools/provider-tck/src/test/java/dev/openfeature/contrib/tools/providertck/InProcessBackendControlTest.java b/tools/provider-tck/src/test/java/dev/openfeature/contrib/tools/providertck/InProcessBackendControlTest.java index 545d93eed7..110be3abbf 100644 --- a/tools/provider-tck/src/test/java/dev/openfeature/contrib/tools/providertck/InProcessBackendControlTest.java +++ b/tools/provider-tck/src/test/java/dev/openfeature/contrib/tools/providertck/InProcessBackendControlTest.java @@ -119,11 +119,13 @@ void canonicalFlagsKeepTheirValuesAndTypes() throws Exception { .isEqualTo(2147483647); // Values, not absences: each default differs from what the flag resolves to. - assertThat(provider.getBooleanEvaluation("false-flag", true, context).getValue()) + assertThat(provider.getBooleanEvaluation("boolean-zero-flag", true, context) + .getValue()) .isFalse(); - assertThat(provider.getIntegerEvaluation("zero-flag", 1, context).getValue()) + assertThat(provider.getIntegerEvaluation("integer-zero-flag", 1, context) + .getValue()) .isZero(); - assertThat(provider.getStringEvaluation("empty-string-flag", "fallback", context) + assertThat(provider.getStringEvaluation("string-zero-flag", "fallback", context) .getValue()) .isEmpty(); } From f2832f7441ddcbd1b5cd05c61cecf34ecbc3c87d Mon Sep 17 00:00:00 2001 From: Simon Schrottner Date: Fri, 11 Sep 2026 15:04:58 +0200 Subject: [PATCH 19/55] feat(provider-tck): gate reinitialisation, which 2.5.2 permits rather than requires Moves the spec submodule to fc99d5ac, which tags "A provider that was shut down can be initialized again" with @reinitialization, and adds the capability to the vocabulary here: the enum constant, the declarable set it falls into on its own, and the README table. The scenario was untagged, and so mandatory. Its own comment justified that: reverting to the uninitialized state "is observable as exactly one thing -- it can be initialized again and then serves flags". That inference does not hold. Requirement 2.5.2 says a provider SHOULD revert to its uninitialized state, and its supporting text says "some providers MAY allow reinitialization from this state". Reuse is permitted, not required, so a provider that releases its client on shutdown and declines to start again is taking an option the specification offers it -- and withholding the tag is a choice that needs no KnownDeviation. The cost of the stronger reading was not theoretical. FlagdProviderSyncResources keeps isInitialized and isShutDown as separate volatile flags and initialize() refuses when either is set, so a shut-down flagd provider is terminally shut down. Run against it, the scenario failed, was recorded as a known deviation, and was one step from being filed as a defect against a provider doing nothing wrong. A false failure is the mirror image of a vacuous pass, and this suite had had a great deal more to say about the latter. Keeping the scenario rather than deleting it is deliberate: a provider that does offer reuse has somewhere to be held to it, and "shutdown() releases the client, initialize() returns early because an initialised flag was never cleared" is easy to write and leaves the provider evaluating against a closed connection rather than failing outright. Reverting the state is not separately observable -- a provider that reverts but refuses reuse presents exactly as one that did neither -- so a gated reuse scenario is the only assertion the requirement admits. Adopter-facing, so it belongs here rather than on a branch above: the enum is what a harness writes, and declarable() is what declarableExcept(...) narrows. Every provider using declarableExcept now claims @reinitialization unless it says otherwise, which is the documented direction -- start from the default, run the suite, remove what the provider cannot do. ProviderSteps' javadoc for the step repeated the inference that made the scenario mandatory, so it says what the requirement says instead. The README states the general rule the episode taught, because it generalises past this tag: never withhold a capability or record a deviation because a scenario failed without first finding the numbered requirement and checking whether the specification asks for the behaviour at all. This is the third rule in the suite found asserted more strongly than the spec states it. Signed-off-by: Simon Schrottner --- tools/provider-tck/README.md | 28 +++++++++++++-- tools/provider-tck/spec | 2 +- .../contrib/tools/providertck/Capability.java | 35 +++++++++++++++++++ .../providertck/steps/ProviderSteps.java | 14 +++++--- .../tools/providertck/DeclarationApiTest.java | 28 +++++++++++++++ .../providertck/InMemoryProviderTckTest.java | 4 +++ 6 files changed, 103 insertions(+), 8 deletions(-) diff --git a/tools/provider-tck/README.md b/tools/provider-tck/README.md index 07eda3cf9a..706baf4895 100644 --- a/tools/provider-tck/README.md +++ b/tools/provider-tck/README.md @@ -143,8 +143,9 @@ second. They are the reference adoption, and they are the fast CI canary. [`InMemoryProviderTckTest`](src/test/java/dev/openfeature/contrib/tools/providertck/InMemoryProviderTckTest.java) runs the full applicable suite against the SDK's `InMemoryProvider` — of the 40 scenarios (outline -rows counted individually), 29 pass and 11 are skipped by capability: the six `@lifecycle` ones, the -`@stale` one, the three `@numeric-coercion` ones and the `@large-integers` one. It does not declare +rows counted individually), 29 pass and 11 are skipped by capability: the six `@lifecycle` ones — one +of which also carries `@reinitialization`, and is skipped for the first of the two — the `@stale` +one, the three `@numeric-coercion` ones and the `@large-integers` one. It does not declare `NUMERIC_COERCION`, because `InMemoryProvider` keeps the two numeric types strictly apart in both directions — it refuses `10.0` as an integer and `10` as a float exactly as it refuses `0.5` — and the tag requires the lossless direction too. That is a choice the SDK's reference provider is entitled to, @@ -392,6 +393,7 @@ green on scenarios it did not run is worse than no suite at all. | Capability | Tag | Meaning | |---|---|---| | `LIFECYCLE` | `@lifecycle` | performs an initialisation that reaches its backend, with an observable outcome | +| `REINITIALIZATION` | `@reinitialization` | can be initialised again after `shutdown` — [Requirement 2.5.2](https://github.com/open-feature/spec/blob/main/specification/sections/02-providers.md) *permits* this rather than requiring it | | `EVENTS` | `@events` | emits lifecycle events at all | | `STALE` | `@stale` | enters `STALE` and emits `PROVIDER_STALE` on backend loss — *needs connection control* | | `CONFIGURATION_CHANGE` | `@configuration-change` | detects config changes, emits `PROVIDER_CONFIGURATION_CHANGED` | @@ -439,6 +441,28 @@ while emitting no events of its own, and would have been excluded. Declare `LIFE initialisation actually talks to the backend; a provider with nothing to reach — an in-memory provider, or a facade over other providers — should not declare it however many events it emits. +A note on `REINITIALIZATION`, which is separate from `LIFECYCLE` for a different reason and is worth +reading before you withhold anything else. +[Requirement 2.5.2](https://github.com/open-feature/spec/blob/main/specification/sections/02-providers.md) +says a provider **SHOULD** revert to its uninitialized state after `shutdown`, and its supporting +text adds that *"some providers **may** allow reinitialization from this state"*. Reuse is +**permitted, not required**: a provider that releases its client on shutdown and refuses to be +started again is exercising a choice the specification offers it, so withholding the tag needs no +`KnownDeviation`. The scenario it gates was originally untagged, and therefore mandatory, on the +reading that reverting to the uninitialized state is observable as exactly one thing — being +initialisable again. That inference does not hold, and it cost something: run against the flagd +provider, which keeps `isInitialized` and `isShutDown` as separate flags and refuses `initialize()` +when either is set, the scenario failed and was one step from being filed as a defect against a +provider doing nothing wrong. **A false failure is the mirror image of a vacuous pass.** The tag +still earns its keep in the other direction, for the providers that do offer reuse: releasing the +client on shutdown while leaving an initialised flag set is easy to write, and it leaves the provider +evaluating against a closed connection rather than failing outright. + +The general rule behind that, which is worth more than the tag: **never withhold a capability, or +record a deviation, because a scenario failed — first find the numbered requirement and check +whether the specification asks for that behaviour at all.** Three rules in this suite have now been +found asserted more strongly than the spec states them. + A note on `NUMERIC_COERCION`: the rule it tests is **borrowed, not normative**. Coercion between integer and float is permitted **when it is lossless** and must fail with `TYPE_MISMATCH` **when it is not** — `10.0` requested as an integer must succeed, `10` requested as a float must succeed, and diff --git a/tools/provider-tck/spec b/tools/provider-tck/spec index ba002ce8e8..fc99d5ace4 160000 --- a/tools/provider-tck/spec +++ b/tools/provider-tck/spec @@ -1 +1 @@ -Subproject commit ba002ce8e807ca97920a5ebd8b9303a556f15d29 +Subproject commit fc99d5ace4da472a5fea0595fa4db8034bbbc769 diff --git a/tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/Capability.java b/tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/Capability.java index 5fa86b69ea..cc0542acef 100644 --- a/tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/Capability.java +++ b/tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/Capability.java @@ -78,6 +78,41 @@ public enum Capability { */ LIFECYCLE("@lifecycle"), + /** + * Provider can be initialised again after {@code shutdown} and serves flags afterwards. + * + *

    Gates exactly one scenario, "A provider that was shut down can be initialized again", and + * it is gated because the specification permits reuse rather than requiring it. + * Requirement + * 2.5.2 says a provider SHOULD revert to its uninitialized state after + * {@code shutdown}, and its supporting text adds that "some providers MAY allow + * reinitialization from this state". A provider that releases its client on shutdown and + * declines to start again is taking an option the specification offers it, so withholding this + * capability is a choice and needs no {@link KnownDeviation}. + * + *

    Why this is not {@link #LIFECYCLE}. The scenario was originally untagged + * — and therefore mandatory — on the reading that reverting to the uninitialized state is + * observable as exactly one thing, being initialisable again. That inference does not hold, and + * the cost of it was concrete: run against the flagd provider, whose + * {@code FlagdProviderSyncResources} keeps {@code isInitialized} and {@code isShutDown} as + * separate flags and refuses {@code initialize()} when either is set, the scenario failed and + * was one step from being filed as a defect against a provider doing nothing wrong. A false + * failure is the mirror image of a vacuous pass. + * + *

    What the tag buys is the other direction. A provider that does offer reuse has + * somewhere to be held to it, because "shutdown() releases the client and initialize() returns + * early because an initialised flag was never cleared" is easy to write and leaves the provider + * evaluating against a closed connection rather than failing outright. Reverting the state is + * not separately observable — a provider that reverts but refuses reuse presents exactly as one + * that did neither — so a gated reuse scenario is the only assertion the requirement admits. + * + *

    Declaring {@code LIFECYCLE} and withholding this one is the expected combination for a + * provider whose initialisation reaches a backend it does not reopen. The scenario carries both + * tags, so a provider that declares neither sees it skipped for {@code @lifecycle} and loses + * nothing by the second omission. + */ + REINITIALIZATION("@reinitialization"), + /** Provider emits lifecycle events at all ({@code PROVIDER_READY}, {@code PROVIDER_ERROR}). */ EVENTS("@events"), diff --git a/tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/steps/ProviderSteps.java b/tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/steps/ProviderSteps.java index d8202bb022..996f5767b0 100644 --- a/tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/steps/ProviderSteps.java +++ b/tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/steps/ProviderSteps.java @@ -204,11 +204,15 @@ public void theProviderIsShutDown() { /** * Initialises the provider under test again by calling its own {@code initialize()} directly. * - *

    Requirement 2.5.2: after shutdown the provider reverts to its uninitialised state, which is - * observable as exactly one thing — it can be initialised again and then serves flags. The SDK - * still holds the provider as {@code READY}, because it was never told about the shutdown, so - * the evaluation that follows this step reaches the re-initialised provider through the - * scenario's client with nothing in between. + *

    Requirement 2.5.2 says a provider SHOULD revert to its uninitialised state after + * shutdown, and its supporting text says some providers MAY allow reinitialisation from + * it. Reuse is therefore permitted rather than required, and the one scenario using this step is + * gated on {@link dev.openfeature.contrib.tools.providertck.Capability#REINITIALIZATION} + * accordingly — a provider that discards its client on shutdown and never rebuilds it is making + * a choice the specification offers, not exhibiting a defect. The SDK still holds the provider + * as {@code READY}, because it was never told about the shutdown, so the evaluation that follows + * this step reaches the re-initialised provider through the scenario's client with nothing in + * between. * *

    The scenario's evaluation context is passed, which is empty unless a context step added to * it. Exceptions are recorded rather than propagated, the same way an evaluation's are. diff --git a/tools/provider-tck/src/test/java/dev/openfeature/contrib/tools/providertck/DeclarationApiTest.java b/tools/provider-tck/src/test/java/dev/openfeature/contrib/tools/providertck/DeclarationApiTest.java index 17c237ba3c..f5ad6fde38 100644 --- a/tools/provider-tck/src/test/java/dev/openfeature/contrib/tools/providertck/DeclarationApiTest.java +++ b/tools/provider-tck/src/test/java/dev/openfeature/contrib/tools/providertck/DeclarationApiTest.java @@ -78,6 +78,34 @@ void tagsMapBackToCapabilities() { assertThat(Capability.fromTag("@not-a-capability")).isEmpty(); } + @Test + @DisplayName("reinitialisation is a declarable choice, and withholding it skips only its own scenario") + void reinitialisationIsADeclarableChoice() { + // Requirement 2.5.2 permits reuse after shutdown rather than requiring it, so a provider + // that refuses it withholds the tag instead of recording a deviation. That makes it an + // ordinary declarable capability: neither reserved nor not-applicable. + assertThat(Capability.fromTag("@reinitialization")).contains(Capability.REINITIALIZATION); + assertThat(Capability.REINITIALIZATION.reserved()).isFalse(); + assertThat(Capability.REINITIALIZATION.notApplicable()).isFalse(); + assertThat(Capability.declarable()).contains(Capability.REINITIALIZATION); + + // The scenario carries @lifecycle too. A provider that initialises against a backend it + // does not reopen declares the first and withholds the second, and only the one scenario + // is skipped -- the rest of the lifecycle set still runs. + Set reachesBackendButNoReuse = EnumSet.of(Capability.LIFECYCLE); + CapabilityGate.requireDeclared(Arrays.asList("@lifecycle"), reachesBackendButNoReuse); + + TestAbortedException aborted = catchThrowableOfType( + () -> CapabilityGate.requireDeclared( + Arrays.asList("@lifecycle", "@reinitialization"), reachesBackendButNoReuse), + TestAbortedException.class); + assertThat(aborted).isNotNull(); + assertThat(aborted) + .hasMessageContaining("REINITIALIZATION") + .hasMessageContaining("@reinitialization") + .hasMessageContaining("does not declare"); + } + @Test @DisplayName("the gate skips an undeclared capability and lets an untagged scenario run") void theGateSkipsUndeclaredCapabilities() { diff --git a/tools/provider-tck/src/test/java/dev/openfeature/contrib/tools/providertck/InMemoryProviderTckTest.java b/tools/provider-tck/src/test/java/dev/openfeature/contrib/tools/providertck/InMemoryProviderTckTest.java index 9779c33a6e..72dc55d330 100644 --- a/tools/provider-tck/src/test/java/dev/openfeature/contrib/tools/providertck/InMemoryProviderTckTest.java +++ b/tools/provider-tck/src/test/java/dev/openfeature/contrib/tools/providertck/InMemoryProviderTckTest.java @@ -64,6 +64,10 @@ public FeatureProvider createProvider() { *

  • {@link Capability#LIFECYCLE} — omitted. Initialisation reaches no backend here, so the * readiness scenario would pass without demonstrating anything, which is exactly what that * capability exists to distinguish. + *
  • {@link Capability#REINITIALIZATION} — omitted, and nothing turns on it here: the + * scenario it gates carries {@code @lifecycle} as well, so it is already skipped for the + * omission above. Named anyway, because {@link Capability#declarable()} would have claimed + * it and this suite never examined it. *
  • {@link Capability#STALE} — omitted. There is no connection to lose, so the provider can * never go {@code STALE}. {@link InProcessBackendControl} leaves * {@link BackendControl#disconnect()} unimplemented for the same reason, and this omission From bc8d13e34e85955fbc4fe89bf0aed896d0a49cac Mon Sep 17 00:00:00 2001 From: Simon Schrottner Date: Fri, 11 Sep 2026 17:56:50 +0200 Subject: [PATCH 20/55] fix(provider-tck): report the feature paths Appendix F names Cucumber reports a scenario's feature by the classpath resource it was selected from, so the directory the resources live in is the directory a consumer sees. Ours were `features/` and `tck-extensions/`; Appendix F identifies a canonical feature by its path relative to the asset directory -- `gherkin/errors.feature` -- and reserves `extensions/` for an adopter's own. Java therefore emitted `classpath:features/errors.feature` where Go and Python emit `gherkin/errors.feature`, and a consumer joining two languages' results on the URI plus the scenario name got no join at all. Renaming the classpath resource directories fixes it upstream of the report: `ProviderTck.FEATURES` and `EXTENSIONS` are the compile-time constants fed to `@SelectClasspathResource`, so Cucumber's own MessageFormatter reports the new paths with nothing post-processing its stream. Both names change, which keeps the property that made them differ. The extension directory is still not a subdirectory of the canonical one and still has a different name, because two classpath roots sharing a directory *and* a file name means one silently wins -- an adopter dropping `gherkin/errors.feature` beside ours would replace a canonical file and watch the suite go green having run theirs. `gherkin/` and `extensions/` are two distinct directories, so that collision stays unreachable by accident. The `classpath:` scheme in front of the path is Cucumber's and stays: Appendix F compares on the path component after any URI scheme, and says stripping it is the consumer's job. The spec assets did not move, so the submodule pin is untouched. Signed-off-by: Simon Schrottner --- tools/provider-tck/.gitignore | 2 +- tools/provider-tck/README.md | 28 ++++++++++++------- tools/provider-tck/pom.xml | 6 ++-- .../tools/providertck/ProviderTck.java | 20 +++++++++---- .../tools/providertck/ProviderTckTest.java | 15 ++++++---- .../{tck-extensions => extensions}/README.md | 21 ++++++++------ .../tools/providertck/ExtensionPointTest.java | 2 +- .../extension-selftest.feature | 4 +-- 8 files changed, 62 insertions(+), 36 deletions(-) rename tools/provider-tck/src/main/resources/{tck-extensions => extensions}/README.md (63%) rename tools/provider-tck/src/test/resources/{tck-extensions => extensions}/extension-selftest.feature (82%) diff --git a/tools/provider-tck/.gitignore b/tools/provider-tck/.gitignore index 4148e2b63d..b472ee8dd3 100644 --- a/tools/provider-tck/.gitignore +++ b/tools/provider-tck/.gitignore @@ -2,6 +2,6 @@ # Do not edit these files directly — they are the language-agnostic definition of the # provider contract and live in open-feature/spec, under # specification/assets/provider-tck/ (Appendix F). -src/main/resources/features/ +src/main/resources/gherkin/ src/main/resources/flags/ src/main/resources/openapi/ diff --git a/tools/provider-tck/README.md b/tools/provider-tck/README.md index 706baf4895..d0b5ac9aec 100644 --- a/tools/provider-tck/README.md +++ b/tools/provider-tck/README.md @@ -334,7 +334,7 @@ A provider with features of its own — flagd's `fractional` targeting, a vendor evaluation mode — extends the suite rather than maintaining a second one. Two files, no annotations: ``` -src/test/resources/tck-extensions/fractional.feature +src/test/resources/extensions/fractional.feature src/test/java/openfeature/tck/extensions/FractionalSteps.java // package openfeature.tck.extensions ``` @@ -348,13 +348,21 @@ is specific to your provider. The alternative — your own Cucumber runner — is a second backend lifecycle to start and a second copy of this suite's configuration to keep in step with it. -**Why `tck-extensions/` and not `features/`.** Two classpath roots holding the same directory are +**Why `extensions/` and not `gherkin/`.** Two classpath roots holding the same directory are scanned additively; two holding the same directory *and* the same file name are not — one wins -silently and the other file is never read. A `features/errors.feature` in your test resources would -therefore *replace* the canonical file, and the suite would report success having run yours. The -extension directory has a different name so that collision cannot be reached by accident. `features/` -is the canonical set and belongs to the specification; extensions are yours. If a scenario is -portable across providers, send it to the TCK rather than keeping it as an extension. +silently and the other file is never read. A `gherkin/errors.feature` in your test resources would +therefore *replace* the canonical file, and the suite would report success having run yours. +`gherkin/` and `extensions/` being two distinct directories means that collision cannot be reached +by accident. `gherkin/` is the canonical set and belongs to the specification; extensions are yours. +If a scenario is portable across providers, send it to the TCK rather than keeping it as an +extension. + +Both names are Appendix F's. It identifies a canonical feature by its path relative to the spec's +asset directory — `gherkin/errors.feature` — and reserves the prefix `extensions/` for an adopter's +own, so the URIs a run reports (`classpath:gherkin/errors.feature`, +`classpath:extensions/fractional.feature`) partition the same way here as in every other language's +TCK. Comparison is on the path after the URI scheme; the `classpath:` prefix is this runner's and is +not part of the identity. The directory is shipped in this JAR containing only a README, because a classpath resource selector naming a resource that exists on no classpath root is a hard discovery error rather than an empty @@ -372,8 +380,8 @@ package either — Cucumber tolerates a glue package that does not exist. | Constant | Value | |---|---| -| `ProviderTck.FEATURES` | `features` — the canonical set, reserved | -| `ProviderTck.EXTENSIONS` | `tck-extensions` — where yours go | +| `ProviderTck.FEATURES` | `gherkin` — the canonical set, reserved | +| `ProviderTck.EXTENSIONS` | `extensions` — where yours go | | `ProviderTck.GLUE` | the canonical step definitions package | | `ProviderTck.EXTENSION_GLUE` | `openfeature.tck.extensions` | | `ProviderTck.ALL_GLUE` | both, comma-separated — what the suite runs with | @@ -616,7 +624,7 @@ The three travel together by necessity: a feature file that evaluates `boolean-f without the flag definition, and a disconnect scenario is meaningless without the control endpoint that produces the disconnect. -> **Do not edit `src/main/resources/features/`, `flags/` or `openapi/`.** They are generated and +> **Do not edit `src/main/resources/gherkin/`, `flags/` or `openapi/`.** They are generated and > git-ignored. Changes belong in `open-feature/spec` and arrive here by bumping the submodule. Building this module therefore needs the submodule: diff --git a/tools/provider-tck/pom.xml b/tools/provider-tck/pom.xml index 5d350dd6b2..0c5ef0cd3f 100644 --- a/tools/provider-tck/pom.xml +++ b/tools/provider-tck/pom.xml @@ -52,7 +52,7 @@ every language's TCK must agree on them byte for byte or "conformance" means nothing: - features/ the canonical Gherkin — the test cases + gherkin/ the canonical Gherkin — the test cases flags/canonical-flags.json the flag set those test cases assume openapi/control-api.yaml what a backend under test must expose @@ -69,7 +69,7 @@ bumping the submodule. Consumers are unaffected: the copies are packaged into the release JAR, - @SelectClasspathResource("features") keeps working, and nobody needs a + @SelectClasspathResource("gherkin") keeps working, and nobody needs a submodule of their own. --> @@ -238,7 +238,7 @@ copy-resources - ${basedir}/src/main/resources/features/ + ${basedir}/src/main/resources/gherkin/ ${basedir}/spec/specification/assets/provider-tck/gherkin/ diff --git a/tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/ProviderTck.java b/tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/ProviderTck.java index 59e0dcd061..226eb6489f 100644 --- a/tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/ProviderTck.java +++ b/tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/ProviderTck.java @@ -28,8 +28,14 @@ public final class ProviderTck { *

    Reserved for the conformance suite. A feature file an adopter adds here does not extend the * canonical set, and one that collides with a canonical file name silently replaces it — see * {@link #EXTENSIONS}. + * + *

    Named for the directory the assets have in Appendix F rather than for Cucumber's habit of + * calling them features. Appendix F identifies a canonical feature by its path relative to the + * asset directory — {@code gherkin/errors.feature} — and a consumer joining results from several + * languages keys on that path, so the directory a runner reports has to be this one. Cucumber's + * {@code classpath:} scheme in front of it is the runner's and is compared past, not stripped. */ - public static final String FEATURES = "features"; + public static final String FEATURES = "gherkin"; /** * Classpath directory an adopter puts their own feature files in. @@ -37,17 +43,21 @@ public final class ProviderTck { *

    Deliberately not a subdirectory of {@link #FEATURES}, and deliberately a different name. * The same directory name in two classpath roots is scanned additively, but the same directory * and file name is not: one root wins and the other file is never read, with no warning. - * An adopter who dropped {@code features/errors.feature} beside ours would therefore replace a + * An adopter who dropped {@code gherkin/errors.feature} beside ours would therefore replace a * canonical file with their own and watch the suite go green having run theirs — the worst - * outcome available to a conformance suite. A separate directory makes that collision impossible - * to reach by accident. + * outcome available to a conformance suite. Two distinct directories, {@code gherkin/} and + * {@code extensions/}, make that collision impossible to reach by accident. + * + *

    {@code extensions/} is also the prefix Appendix F reserves for exactly this, so the path a + * runner reports for an adopter's scenario is one any consumer can tell apart from a canonical + * one without knowing anything about this implementation. * *

    Shipped in this JAR containing only a README, because * {@link org.junit.platform.suite.api.SelectClasspathResource} on a resource that exists nowhere * on the classpath is a discovery error rather than an empty selection. Cucumber ignores files * that are not {@code .feature}, so the README costs nothing. */ - public static final String EXTENSIONS = "tck-extensions"; + public static final String EXTENSIONS = "extensions"; /** Package holding the canonical step definitions. */ public static final String GLUE = "dev.openfeature.contrib.tools.providertck.steps"; diff --git a/tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/ProviderTckTest.java b/tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/ProviderTckTest.java index 94bf6373a1..ca1e738630 100644 --- a/tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/ProviderTckTest.java +++ b/tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/ProviderTckTest.java @@ -47,20 +47,25 @@ *

    Adding your own scenarios

    * *

    A provider with features of its own — flagd's {@code fractional} targeting, a vendor's - * proprietary evaluation mode — puts feature files in {@code src/test/resources/tck-extensions/} and + * proprietary evaluation mode — puts feature files in {@code src/test/resources/extensions/} and * step definitions in the package {@code openfeature.tck.extensions}, and writes no annotations. * Both are selected here, so the extra scenarios run inside this suite: same backend lifecycle, same * {@code @BeforeAll}, same {@link BackendControl}. The alternative — a second suite of one's own — * is a second backend lifecycle to start and a second set of runner configuration to keep in step * with this one. * - *

    The extension directory is not {@code features/} and is not a subdirectory of it, for + *

    The extension directory is not {@code gherkin/} and is not a subdirectory of it, for * a measured reason. Two classpath roots that contain the same directory are scanned additively, but * two that contain the same directory and the same file name are not: one wins silently and - * the other file is never read. An adopter who put {@code features/errors.feature} in their test + * the other file is never read. An adopter who put {@code gherkin/errors.feature} in their test * resources would replace a canonical feature with their own and see the suite pass — a conformance - * suite reporting success for questions it never asked. A distinct directory name removes the - * collision rather than documenting it. + * suite reporting success for questions it never asked. {@code gherkin/} and {@code extensions/} + * being two distinct directories removes the collision rather than documenting it. + * + *

    Both names come from Appendix F, which identifies a canonical feature by its path relative to + * the asset directory and reserves {@code extensions/} for an adopter's own. That is what makes the + * URIs this suite reports — {@code classpath:gherkin/errors.feature}, + * {@code classpath:extensions/fractional.feature} — partition the same way in every language. * *

    The directory is shipped inside this JAR holding nothing but a README, because * {@link SelectClasspathResource} on a resource that exists on no classpath root is a discovery diff --git a/tools/provider-tck/src/main/resources/tck-extensions/README.md b/tools/provider-tck/src/main/resources/extensions/README.md similarity index 63% rename from tools/provider-tck/src/main/resources/tck-extensions/README.md rename to tools/provider-tck/src/main/resources/extensions/README.md index 84da3d306b..ab09dbcd64 100644 --- a/tools/provider-tck/src/main/resources/tck-extensions/README.md +++ b/tools/provider-tck/src/main/resources/extensions/README.md @@ -1,10 +1,10 @@ # Provider TCK extension point -Feature files placed on the classpath under `tck-extensions/` run inside the TCK suite, alongside +Feature files placed on the classpath under `extensions/` run inside the TCK suite, alongside the canonical conformance scenarios. This file is here so that the directory exists on the classpath even when nobody has extended -anything. `ProviderTckTest` selects `tck-extensions` unconditionally, and a classpath resource +anything. `ProviderTckTest` selects `extensions` unconditionally, and a classpath resource selector naming a resource that exists on no classpath root is a discovery error rather than an empty selection. Cucumber ignores files that are not `.feature`, so the README itself is never read as a scenario. @@ -14,7 +14,7 @@ as a scenario. Write nothing but the two files: ``` -src/test/resources/tck-extensions/fractional.feature +src/test/resources/extensions/fractional.feature src/test/java/openfeature/tck/extensions/FractionalSteps.java // package openfeature.tck.extensions ``` @@ -26,18 +26,21 @@ Your step classes may take `dev.openfeature.contrib.tools.providertck.TckState` argument to reach the client and the last evaluation, exactly as the canonical steps do, and `TckRuntime.get()` for the backend control and the backend endpoint. -## Why this directory rather than `features/` +## Why this directory rather than `gherkin/` Two classpath roots containing the same directory are scanned additively. Two containing the same -directory *and* the same file name are not: one silently wins. A feature file added to `features/` +directory *and* the same file name are not: one silently wins. A feature file added to `gherkin/` under a canonical name would therefore replace a canonical file, and the suite would report success -having run the replacement. The extension directory has a different name so that collision cannot -be reached by accident. +having run the replacement. `gherkin/` and `extensions/` being two distinct directories means that +collision cannot be reached by accident. -`features/` is the canonical set and belongs to the specification. Extensions are yours. +`gherkin/` is the canonical set and belongs to the specification. Extensions are yours. Both names +are Appendix F's: a canonical feature is identified by its path relative to the spec's asset +directory, and `extensions/` is the prefix reserved for yours. A consumer reading the results of a +run tells the two apart by that prefix, in any language. ## What extensions are not An extension scenario is not conformance. It does not appear in the canonical set, it cannot make the canonical set smaller, and a conformance claim is not a claim about it. If a scenario is -portable across providers it belongs in `features/` — send it to the TCK. +portable across providers it belongs in `gherkin/` — send it to the TCK. diff --git a/tools/provider-tck/src/test/java/dev/openfeature/contrib/tools/providertck/ExtensionPointTest.java b/tools/provider-tck/src/test/java/dev/openfeature/contrib/tools/providertck/ExtensionPointTest.java index afc80003e6..5332134323 100644 --- a/tools/provider-tck/src/test/java/dev/openfeature/contrib/tools/providertck/ExtensionPointTest.java +++ b/tools/provider-tck/src/test/java/dev/openfeature/contrib/tools/providertck/ExtensionPointTest.java @@ -25,7 +25,7 @@ *

    What that promise decomposes into, and what each test here checks: * *

      - *
    • a feature file under {@code tck-extensions/} on the adopter's test classpath is discovered by + *
    • a feature file under {@code extensions/} on the adopter's test classpath is discovered by * the suite, into the same Cucumber engine as the canonical set — which is what "the same * backend lifecycle phase" means, since {@code @BeforeAll} is scoped to exactly that; *
    • a step class in {@code openfeature.tck.extensions} is resolved from the glue path; diff --git a/tools/provider-tck/src/test/resources/tck-extensions/extension-selftest.feature b/tools/provider-tck/src/test/resources/extensions/extension-selftest.feature similarity index 82% rename from tools/provider-tck/src/test/resources/tck-extensions/extension-selftest.feature rename to tools/provider-tck/src/test/resources/extensions/extension-selftest.feature index c43aa0ce7c..627b76b67d 100644 --- a/tools/provider-tck/src/test/resources/tck-extensions/extension-selftest.feature +++ b/tools/provider-tck/src/test/resources/extensions/extension-selftest.feature @@ -2,11 +2,11 @@ Feature: An adopter's own scenarios run inside the TCK suite This file is the TCK's own proof of its extension point. It sits exactly where an adopter's - extension features sit — on the test classpath under tck-extensions/ — and is picked up with no + extension features sit — on the test classpath under extensions/ — and is picked up with no annotation, no selector and no runner configuration written anywhere for it. It is test-scoped, so it is not packaged in the released JAR and cannot reach an adopter's run. - It carries no canonical scenario and cannot stand in for one: the canonical set is what features/ + It carries no canonical scenario and cannot stand in for one: the canonical set is what gherkin/ contains, and this file is not in it. Scenario: A step class in the adopter's own glue package is on the suite's glue path From c2b6388633a5360a0be199c8534c8e2afff5261c Mon Sep 17 00:00:00 2001 From: Simon Schrottner Date: Fri, 11 Sep 2026 18:41:36 +0200 Subject: [PATCH 21/55] fix(provider-tck): seed the in-process control from the packaged flag set InProcessBackendControl wrote the canonical flag set out as Java literals and kept a comment saying it mirrored flags/canonical-flags.json. It is now decoded from that file, which this artifact already packages, so there is one definition rather than two that have to be kept in step. The asset was already on the classpath -- the copy-provider-tck-flags execution copies it out of the spec submodule into src/main/resources/flags/ and it ships in the release JAR -- so nothing new is packaged here. Only the reading of it is new. Appendix F exposes the canonical set so that an adopting provider can seed a backend "rather than transcribing it, transcription being the usual way the two drift apart". A transcription inside the TCK is that same drift with a shorter fuse, because the in-memory self-tests are what the suite is checked against: a drifted fixture makes them pass against a baseline of our own while reporting green, and nothing in the run says which flag set was actually tested. Today three flags were renamed and the same thirteen-entry set had to be edited by hand in four languages, one of which needed a second fix because a second transcription had been missed. Go, the reference implementation, decodes the file and has neither problem. What the decoding has to preserve is the types, because InMemoryProvider matches a variant against the type of the accessor it was asked through. Jackson keeps a literal's kind and width: 10 is an Integer, 10.0 a Double, 2147483647 an Integer and 2^53 - 1 a Long. The file's own comment warns that a loader which turns 10.0 back into 10 lets the lossless-coercion scenario pass without coercing anything, so CanonicalFlags.number splits on the literal rather than on the value. changing-flag keeps its behaviour and loses its literals. Its key stays named here because POST /change in the control API names it too and the two have to agree, but its variants are read from the definition and changeFlag() switches to whichever one the flag does not currently resolve to. A rename in the spec can no longer leave it flipping between a variant the file defines and one it does not. CanonicalFlagsTest is the test that makes this worth having. It reads the packaged file independently of the decoder, checks that the decoded set defines exactly the keys the file does, and resolves each flag through an InMemoryProvider using the accessor its packaged type calls for -- so a decoder that widened or narrowed a number fails with a type mismatch rather than passing a value comparison. No value table is written out here: a table is another transcription, and a test comparing two copies of the same mistake passes. Signed-off-by: Simon Schrottner --- tools/provider-tck/README.md | 9 + .../tools/providertck/CanonicalFlags.java | 227 ++++++++++++++++++ .../providertck/InProcessBackendControl.java | 220 +++++------------ .../tools/providertck/CanonicalFlagsTest.java | 189 +++++++++++++++ 4 files changed, 483 insertions(+), 162 deletions(-) create mode 100644 tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/CanonicalFlags.java create mode 100644 tools/provider-tck/src/test/java/dev/openfeature/contrib/tools/providertck/CanonicalFlagsTest.java diff --git a/tools/provider-tck/README.md b/tools/provider-tck/README.md index d0b5ac9aec..e0ca010c34 100644 --- a/tools/provider-tck/README.md +++ b/tools/provider-tck/README.md @@ -248,6 +248,15 @@ Four details are load-bearing: makes the lossless-coercion scenario pass without coercing anything; seeding `9007199254740991` through a float rounds it. +Read the file rather than retyping it. `$comment` members are documentation and may be ignored +wherever they appear; everything else is the contract. This is what +[`InProcessBackendControl`](src/main/java/dev/openfeature/contrib/tools/providertck/InProcessBackendControl.java) +does — it decodes the packaged copy through +[`CanonicalFlags`](src/main/java/dev/openfeature/contrib/tools/providertck/CanonicalFlags.java) +rather than restating the set in Java, because a second copy inside the TCK drifts from the spec the +same way an adopter's would, and when it does the in-memory self-tests go green against the wrong +baseline. + ### 4. The test class ```java diff --git a/tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/CanonicalFlags.java b/tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/CanonicalFlags.java new file mode 100644 index 0000000000..76226e1278 --- /dev/null +++ b/tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/CanonicalFlags.java @@ -0,0 +1,227 @@ +package dev.openfeature.contrib.tools.providertck; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import dev.openfeature.sdk.Value; +import dev.openfeature.sdk.providers.memory.Flag; +import java.io.IOException; +import java.io.InputStream; +import java.io.UncheckedIOException; +import java.util.Collections; +import java.util.Iterator; +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * The canonical flag set, decoded from the definition this artifact packages. + * + *

      {@code flags/canonical-flags.json} is one of the three language-agnostic conformance artifacts. + * It is not owned here: it lives in open-feature/spec under + * {@code specification/assets/provider-tck/}, is copied in from the {@code spec} submodule at build + * time and is packaged into the release JAR, which is why this class reads it off the classpath. + * + *

      Why decoded rather than transcribed. Appendix F exposes the canonical set so + * that an adopting provider can seed a backend directly from the canonical definition rather than + * transcribing it, transcription being the usual way the two drift apart. A hand-written copy inside + * the TCK is the same drift with a shorter fuse: the in-process self-tests would then verify the + * suite against a second baseline of our own, so a rename in the spec makes them pass against the + * wrong flags while reporting green. Go's TCK decodes the same file for the same reason, and this + * follows it. + * + *

      The file is flagd's flag-definition format — + * {"flags": {"<key>": {"state", "variants", "defaultVariant"}}} — because that is + * the only widely implemented vendor-neutral format today. {@code $comment} members are + * documentation and are ignored wherever they appear. + * + *

      What the decoding has to preserve

      + * + *

      A loader that "cleans up" values destroys exactly what the scenarios test, so three properties + * of the file survive it deliberately: + * + *

        + *
      • {@code missing-flag} is absent, which is what the {@code FLAG_NOT_FOUND} scenario tests. + * Nothing here adds flags the file does not define. + *
      • no flag carries a {@link dev.openfeature.sdk.providers.memory.ContextEvaluator}, so every + * evaluation reports reason {@code STATIC} as the feature files expect. The TCK tests a + * provider's mapping of a response, not a backend's evaluation logic. + *
      • a number keeps the width and the kind it was written with. {@code 10} becomes an + * {@link Integer} and {@code 10.0} a {@link Double}, because + * {@link dev.openfeature.sdk.providers.memory.InMemoryProvider} matches a variant by type: an + * {@code integral-float-flag} decoded as the integer {@code 10} would let the + * lossless-coercion scenario pass without anything being coerced. {@code 2147483647} fits an + * {@link Integer} and stays one, so {@code getIntegerDetails} can ask for it; 2^53 − 1 does + * not and becomes a {@link Long}. See {@link #number}. + *
      + */ +final class CanonicalFlags { + + /** + * Classpath location of the canonical flag definition, packaged by the {@code + * copy-provider-tck-flags} execution in this module's POM. + */ + static final String RESOURCE = "flags/canonical-flags.json"; + + /** Documentation member, ignored wherever it appears. */ + private static final String COMMENT = "$comment"; + + private static final String ENABLED = "ENABLED"; + private static final String DISABLED = "DISABLED"; + + private static final ObjectMapper MAPPER = new ObjectMapper(); + + private CanonicalFlags() {} + + /** + * Reads the packaged canonical flag definition. + * + * @return the raw JSON bytes + * @throws IllegalStateException if the definition is not on the classpath + */ + static byte[] definition() { + ClassLoader loader = CanonicalFlags.class.getClassLoader(); + try (InputStream in = loader.getResourceAsStream(RESOURCE)) { + if (in == null) { + throw new IllegalStateException("The canonical flag definition " + RESOURCE + " is not on the " + + "classpath. It is copied in from the spec submodule by the copy-provider-tck-flags " + + "execution and packaged into this artifact, so a run without it is a build problem " + + "rather than a provider defect: run 'mvn generate-resources' on tools/provider-tck, " + + "having checked the spec submodule out."); + } + return in.readAllBytes(); + } catch (IOException e) { + throw new UncheckedIOException("Could not read the canonical flag definition " + RESOURCE, e); + } + } + + /** + * Decodes the packaged canonical flag definition into {@code InMemoryProvider} flags. + * + * @return the canonical flag set, in the order the file defines it, unmodifiable + * @throws IllegalStateException if the definition is missing or is not the shape this decoder + * expects, which for a pinned spec revision means the pin moved under it + */ + static Map> flagSet() { + return decode(definition()); + } + + /** + * Decodes a canonical flag definition. + * + * @param json the definition, in flagd's flag-definition format + * @return the flag set, in the order the document defines it, unmodifiable + * @throws IllegalStateException if the document is not the shape this decoder expects + */ + static Map> decode(byte[] json) { + JsonNode root; + try { + root = MAPPER.readTree(json); + } catch (IOException e) { + throw new IllegalStateException("The canonical flag definition " + RESOURCE + " is not valid JSON", e); + } + + JsonNode flags = root.path("flags"); + if (!flags.isObject()) { + throw new IllegalStateException("The canonical flag definition " + RESOURCE + + " has no 'flags' object. The format is {\"flags\": {\"\": {...}}}."); + } + + Map> decoded = new LinkedHashMap<>(); + for (Iterator> it = flags.fields(); it.hasNext(); ) { + Map.Entry entry = it.next(); + if (COMMENT.equals(entry.getKey())) { + continue; + } + decoded.put(entry.getKey(), flag(entry.getKey(), entry.getValue())); + } + if (decoded.isEmpty()) { + throw new IllegalStateException( + "The canonical flag definition " + RESOURCE + " defines no flags, so there is nothing to seed."); + } + return Collections.unmodifiableMap(decoded); + } + + /** Decodes one flag definition. */ + private static Flag flag(String key, JsonNode definition) { + String state = definition.path("state").asText(null); + if (!ENABLED.equals(state) && !DISABLED.equals(state)) { + throw new IllegalStateException("Canonical flag '" + key + "' has state '" + state + "', which is neither " + + ENABLED + " nor " + DISABLED + "."); + } + + String defaultVariant = definition.path("defaultVariant").asText(null); + if (defaultVariant == null) { + throw new IllegalStateException("Canonical flag '" + key + "' names no defaultVariant."); + } + + JsonNode variants = definition.path("variants"); + if (!variants.isObject()) { + throw new IllegalStateException("Canonical flag '" + key + "' has no 'variants' object."); + } + + Map values = new LinkedHashMap<>(); + for (Iterator> it = variants.fields(); it.hasNext(); ) { + Map.Entry variant = it.next(); + if (COMMENT.equals(variant.getKey())) { + continue; + } + values.put(variant.getKey(), value(key, variant.getKey(), variant.getValue())); + } + if (!values.containsKey(defaultVariant)) { + throw new IllegalStateException("Canonical flag '" + key + "' resolves to variant '" + defaultVariant + + "', which it does not define. Its variants are " + values.keySet() + "."); + } + + return Flag.builder() + .variants(values) + .defaultVariant(defaultVariant) + .disabled(DISABLED.equals(state)) + .build(); + } + + /** Converts one variant value to what {@code InMemoryProvider}'s type matching expects. */ + private static Object value(String key, String variant, JsonNode node) { + switch (node.getNodeType()) { + case BOOLEAN: + return node.booleanValue(); + case STRING: + return node.textValue(); + case NUMBER: + return number(key, variant, node); + case OBJECT: + case ARRAY: + // The same conversion the feature files go through for an Object value, so a seeded + // structure and an expected one are comparable: see TckValues. + return Value.objectToValue(MAPPER.convertValue(node, Object.class)); + case NULL: + return null; + default: + throw new IllegalStateException("Canonical flag '" + key + "', variant '" + variant + "' is a " + + node.getNodeType() + ", which is not a flag value."); + } + } + + /** + * Splits a JSON number on how it was written, and on whether it fits. + * + *

      This is the load-bearing half of the decoding. A literal with a fraction or an exponent is a + * {@link Double} and an integral one is an {@link Integer} or, where 32 bits have no room for it, + * a {@link Long}. {@code InMemoryProvider} matches a variant against the requested type, so it is + * the decoded type that decides whether {@code integer-flag} is an integer flag and + * {@code integral-float-flag} a float one — and the file's own comment warns that a loader which + * turns {@code 10.0} back into {@code 10} lets the lossless-coercion scenario pass without + * coercing anything. + */ + private static Object number(String key, String variant, JsonNode node) { + if (!node.isIntegralNumber()) { + return node.doubleValue(); + } + if (node.canConvertToInt()) { + return node.intValue(); + } + if (node.canConvertToLong()) { + return node.longValue(); + } + throw new IllegalStateException("Canonical flag '" + key + "', variant '" + variant + "' is " + node.asText() + + ", which does not fit a Long. No canonical value exceeds 2^53 - 1."); + } +} diff --git a/tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/InProcessBackendControl.java b/tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/InProcessBackendControl.java index f537aef975..74942593aa 100644 --- a/tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/InProcessBackendControl.java +++ b/tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/InProcessBackendControl.java @@ -1,13 +1,9 @@ package dev.openfeature.contrib.tools.providertck; -import dev.openfeature.sdk.MutableStructure; -import dev.openfeature.sdk.Value; import dev.openfeature.sdk.providers.memory.Flag; import dev.openfeature.sdk.providers.memory.InMemoryProvider; import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; -import java.util.Collections; import java.util.HashMap; -import java.util.LinkedHashMap; import java.util.Map; /** @@ -58,26 +54,37 @@ */ public final class InProcessBackendControl implements BackendControl { - /** The flag {@link #changeFlag()} mutates, as defined by {@code flags/canonical-flags.json}. */ + /** + * The flag {@link #changeFlag()} mutates, as named by the control API and by the canonical flag + * definition. + * + *

      The key is the one thing about this flag that is not read out of the definition, because + * {@code POST /change} in {@code openapi/control-api.yaml} names it too: the two have to agree, + * and a key discovered from the file could not be checked against the contract that uses it. Its + * variants are read, and {@link #changingFlag} rebuilds it from them. + */ private static final String CHANGING_FLAG = "changing-flag"; - private static final String CHANGING_BASELINE = "foo"; - private static final String CHANGING_CHANGED = "bar"; - /** * The canonical flag set, never mutated after construction. * - *

      Scenario isolation depends on that: {@link InMemoryProvider} copies the map it is given, - * and {@code updateFlag} writes only to the provider's copy, so every provider handed out by - * {@link #createProvider()} starts from an untouched baseline. + *

      Decoded from the packaged {@code flags/canonical-flags.json} rather than restated here — + * see {@link CanonicalFlags} for why a transcription is the failure mode this guards against. + * + *

      Scenario isolation depends on the map not being mutated: {@link InMemoryProvider} copies the + * map it is given, and {@code updateFlag} writes only to the provider's copy, so every provider + * handed out by {@link #createProvider()} starts from an untouched baseline. */ - private final Map> baseline = canonicalFlags(); + private final Map> baseline = CanonicalFlags.flagSet(); + + /** {@code changing-flag} as the definition ships it, the source of its variants. */ + private final Flag changingBaseline = requireChangingFlag(); /** The provider serving the current scenario, or {@code null} between scenarios. */ private InMemoryProvider current; /** Which variant {@code changing-flag} currently resolves to. */ - private String changingVariant = CHANGING_BASELINE; + private String changingVariant = changingBaseline.getDefaultVariant(); /** * Creates the provider for the scenario about to run, seeded with the canonical flag set. @@ -93,7 +100,7 @@ public final class InProcessBackendControl implements BackendControl { + "the flag store and the provider are one object, and changeFlag() must reach " + "the same instance the TCK registered in order to emit an event from it") public InMemoryProvider createProvider() { - changingVariant = CHANGING_BASELINE; + changingVariant = changingBaseline.getDefaultVariant(); current = new InMemoryProvider(new HashMap<>(baseline)); return current; } @@ -130,163 +137,52 @@ public void prepareScenario() { */ @Override public void changeFlag() { - changingVariant = CHANGING_CHANGED.equals(changingVariant) ? CHANGING_BASELINE : CHANGING_CHANGED; + changingVariant = otherVariant(changingVariant); requireProvider().updateFlag(CHANGING_FLAG, changingFlag(changingVariant)); } - private InMemoryProvider requireProvider() { - if (current == null) { - throw new IllegalStateException("No in-memory provider exists for this scenario. In-process backend " - + "control manipulates the provider itself, so the scenario must create one — with " - + "'Given a stable provider' — before any step that changes flag state."); - } - return current; - } - /** - * Builds the canonical flag set as {@link InMemoryProvider} flags. - * - *

      Mirrors {@code flags/canonical-flags.json} entry for entry. The load-bearing details from - * that file hold here too: - * - *

        - *
      • {@code missing-flag} is absent, which is what the {@code FLAG_NOT_FOUND} scenario tests; - *
      • no flag carries a {@link dev.openfeature.sdk.providers.memory.ContextEvaluator}, so - * every evaluation reports reason {@code STATIC} as the feature files expect; - *
      • {@code boolean-zero-flag}, {@code integer-zero-flag} and {@code string-zero-flag} - * resolve to {@code false}, {@code 0} and {@code ""} — values, not absences; - *
      • {@code integral-float-flag} is a {@link Double} holding {@code 10.0}, never the - * {@link Integer} {@code 10}, or the lossless-coercion scenario would pass without - * anything being coerced; {@code huge-integer-flag} is a {@link Long}, because - * 2^53 − 1 does not fit an {@link Integer}. - *
      + * Returns a variant of {@code changing-flag} other than the given one. * - * @return the canonical flag set + *

      Read out of the definition rather than named here, so that renaming either variant in the + * spec cannot leave this switching between a name the file no longer defines and one it does. */ - private static Map> canonicalFlags() { - Map> flags = new LinkedHashMap<>(); - - flags.put( - "boolean-flag", - Flag.builder() - .variant("on", true) - .variant("off", false) - .defaultVariant("on") - .build()); - - flags.put( - "string-flag", - Flag.builder() - .variant("greeting", "hi") - .variant("parting", "bye") - .defaultVariant("greeting") - .build()); - - flags.put( - "integer-flag", - Flag.builder() - .variant("one", 1) - .variant("ten", 10) - .defaultVariant("ten") - .build()); - - flags.put( - "float-flag", - Flag.builder() - .variant("tenth", 0.1) - .variant("half", 0.5) - .defaultVariant("half") - .build()); - - // 2^31 - 1: the largest value every language's integer accessor can ask for, and one a - // float32 round trip does not keep. - flags.put( - "large-integer-flag", - Flag.builder() - .variant("one", 1) - .variant("max-int32", 2147483647) - .defaultVariant("max-int32") - .build()); - - // 2^53 - 1, which does not fit an Integer and so is a Long. Only asked for under - // @large-integers, which is not applicable in Java, so no scenario reaches it; it is here - // so that the set mirrors the JSON entry for entry, seeded as an integer and not rounded. - flags.put( - "huge-integer-flag", - Flag.builder() - .variant("one", 1L) - .variant("max-safe", 9007199254740991L) - .defaultVariant("max-safe") - .build()); - - // A float with no fractional part, for the lossless half of @numeric-coercion. The literal - // 10.0 is a double, so the variant is a Double and stays one. - flags.put( - "integral-float-flag", - Flag.builder() - .variant("tenth", 0.1) - .variant("ten", 10.0) - .defaultVariant("ten") - .build()); - - // The three falsy values. Each scenario's default differs from the resolved value, so a - // provider that treats false, 0 or "" as "nothing came back" is caught. - flags.put( - "boolean-zero-flag", - Flag.builder() - .variant("zero", false) - .variant("non-zero", true) - .defaultVariant("zero") - .build()); - - flags.put( - "integer-zero-flag", - Flag.builder() - .variant("zero", 0) - .variant("non-zero", 1) - .defaultVariant("zero") - .build()); - - flags.put( - "string-zero-flag", - Flag.builder() - .variant("zero", "") - .variant("non-zero", "str") - .defaultVariant("zero") - .build()); - - flags.put( - "object-flag", - Flag.builder() - .variant("empty", new Value(new MutableStructure())) - .variant( - "template", - new Value(new MutableStructure() - .add("showImages", true) - .add("title", "Check out these pics!") - .add("imagesPerPage", 100))) - .defaultVariant("template") - .build()); - - // A string flag, evaluated as a boolean by the TYPE_MISMATCH scenario. - flags.put( - "wrong-flag", - Flag.builder() - .variant("one", "uno") - .variant("two", "dos") - .defaultVariant("one") - .build()); - - flags.put(CHANGING_FLAG, changingFlag(CHANGING_BASELINE)); - - return Collections.unmodifiableMap(flags); + private String otherVariant(String resolved) { + for (String variant : changingBaseline.getVariants().keySet()) { + if (!variant.equals(resolved)) { + return variant; + } + } + throw new IllegalStateException("The canonical definition of '" + CHANGING_FLAG + "' has only the variant '" + + resolved + "'. changeFlag() has to switch to a different one, so the flag needs at least two."); } - private static Flag changingFlag(String defaultVariant) { - return Flag.builder() - .variant(CHANGING_BASELINE, CHANGING_BASELINE) - .variant(CHANGING_CHANGED, CHANGING_CHANGED) + /** Rebuilds {@code changing-flag} with a different variant as the one it resolves to. */ + private Flag changingFlag(String defaultVariant) { + return Flag.builder() + .variants(changingBaseline.getVariants()) .defaultVariant(defaultVariant) + .disabled(changingBaseline.isDisabled()) .build(); } + + /** The canonical definition of {@code changing-flag}, which the suite cannot do without. */ + private Flag requireChangingFlag() { + Flag flag = baseline.get(CHANGING_FLAG); + if (flag == null) { + throw new IllegalStateException("The canonical flag definition " + CanonicalFlags.RESOURCE + + " does not define '" + CHANGING_FLAG + "', which is the flag POST /change mutates and the " + + "@configuration-change scenarios evaluate."); + } + return flag; + } + + private InMemoryProvider requireProvider() { + if (current == null) { + throw new IllegalStateException("No in-memory provider exists for this scenario. In-process backend " + + "control manipulates the provider itself, so the scenario must create one — with " + + "'Given a stable provider' — before any step that changes flag state."); + } + return current; + } } diff --git a/tools/provider-tck/src/test/java/dev/openfeature/contrib/tools/providertck/CanonicalFlagsTest.java b/tools/provider-tck/src/test/java/dev/openfeature/contrib/tools/providertck/CanonicalFlagsTest.java new file mode 100644 index 0000000000..4599e5513d --- /dev/null +++ b/tools/provider-tck/src/test/java/dev/openfeature/contrib/tools/providertck/CanonicalFlagsTest.java @@ -0,0 +1,189 @@ +package dev.openfeature.contrib.tools.providertck; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.fail; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import dev.openfeature.sdk.ImmutableContext; +import dev.openfeature.sdk.Value; +import dev.openfeature.sdk.providers.memory.InMemoryProvider; +import java.io.IOException; +import java.io.InputStream; +import java.util.ArrayList; +import java.util.Iterator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** + * Pins the in-process flag set to the definition this artifact packages. + * + *

      This is the test that makes {@link CanonicalFlags} worth having. The flag set the in-memory + * self-tests run against used to be written out as Java literals, and the failure mode of that was + * quiet: a rename or a retyped value in {@code flags/canonical-flags.json} left the suite verifying + * itself against a second, private baseline, so it reported green having tested the wrong flags. The + * only way to catch that is to read the packaged file independently of the decoder and hold + * the decoded set against it, which is what happens below. + * + *

      The comparison is deliberately not a value table written out here. A table is another + * transcription, drifts the same way, and a test that compares two copies of the same mistake passes. + * Every expectation comes out of the file instead: the keys it defines, and for each the value of the + * variant it says the flag resolves to. + * + *

      The values are read back through an {@link InMemoryProvider} rather than off the decoded map, + * because the provider matches a variant against the type of the accessor it was asked through. That + * is what makes the types load-bearing and what this asserts: a JSON float is fetched through + * {@code getDoubleEvaluation} and a JSON integer through {@code getIntegerEvaluation}, so a decoder + * that turned {@code 10.0} into the integer {@code 10} — the mistake the file's own comment warns + * about, because it lets the lossless-coercion scenario pass without coercing anything — fails here + * with a type mismatch rather than sailing through a value comparison. + */ +class CanonicalFlagsTest { + + private static final ObjectMapper MAPPER = new ObjectMapper(); + + /** The {@code flags} object of the packaged definition, read without going through the decoder. */ + private static Map packaged; + + @BeforeAll + static void readThePackagedDefinition() throws IOException { + try (InputStream in = CanonicalFlagsTest.class.getClassLoader().getResourceAsStream(CanonicalFlags.RESOURCE)) { + assertThat(in) + .as( + "%s must be packaged on the classpath by the copy-provider-tck-flags execution", + CanonicalFlags.RESOURCE) + .isNotNull(); + + JsonNode flags = MAPPER.readTree(in).path("flags"); + assertThat(flags.isObject()).isTrue(); + + packaged = new LinkedHashMap<>(); + for (Iterator> it = flags.fields(); it.hasNext(); ) { + Map.Entry entry = it.next(); + if (!"$comment".equals(entry.getKey())) { + packaged.put(entry.getKey(), entry.getValue()); + } + } + } + assertThat(packaged) + .as("the packaged definition has to define flags for any of this to mean anything") + .isNotEmpty(); + } + + @Test + @DisplayName("the decoded flag set defines exactly the keys the packaged definition defines") + void theDecodedSetHasExactlyThePackagedKeys() { + assertThat(CanonicalFlags.flagSet().keySet()) + .as("a key in one and not the other is the drift this replaced a transcription to prevent") + .containsExactlyInAnyOrderElementsOf(packaged.keySet()) + .as("$comment is documentation, not a flag") + .doesNotContain("$comment"); + } + + @Test + @DisplayName("every flag resolves to the value of the variant the packaged definition names") + void everyFlagResolvesToItsPackagedDefaultVariant() throws Exception { + InMemoryProvider provider = new InProcessBackendControl().createProvider(); + provider.initialize(new ImmutableContext()); + ImmutableContext context = new ImmutableContext(); + + List checked = new ArrayList<>(); + for (Map.Entry entry : packaged.entrySet()) { + String key = entry.getKey(); + String defaultVariant = entry.getValue().path("defaultVariant").asText(null); + assertThat(defaultVariant) + .as("%s names no defaultVariant, so the definition itself is broken", key) + .isNotNull(); + + JsonNode expected = entry.getValue().path("variants").path(defaultVariant); + assertThat(expected.isMissingNode()) + .as("%s resolves to variant '%s', which the definition does not define", key, defaultVariant) + .isFalse(); + + assertResolves(provider, context, key, defaultVariant, expected); + checked.add(key); + } + + assertThat(checked) + .as("the loop must actually have run over the packaged flags") + .hasSameSizeAs(packaged.keySet()); + } + + /** + * Resolves one flag through the accessor its packaged type calls for, and checks the value. + * + *

      The accessor is chosen from the JSON type rather than from the decoded one, so the decoding + * is being held against the file rather than asked to agree with itself. The default handed to + * the accessor is deliberately never the expected value: {@code boolean-zero-flag} resolves to + * {@code false} and {@code string-zero-flag} to {@code ""}, and a comparison whose fallback + * happened to equal the answer would pass on a flag that had gone missing. + */ + private static void assertResolves( + InMemoryProvider provider, ImmutableContext context, String key, String variant, JsonNode expected) { + + String where = key + " variant '" + variant + "'"; + switch (expected.getNodeType()) { + case BOOLEAN: + boolean bool = expected.booleanValue(); + assertThat(provider.getBooleanEvaluation(key, !bool, context).getValue()) + .as(where) + .isEqualTo(bool); + break; + case STRING: + String string = expected.textValue(); + assertThat(provider.getStringEvaluation(key, string + "-fallback", context) + .getValue()) + .as(where) + .isEqualTo(string); + break; + case NUMBER: + assertNumberResolves(provider, context, key, where, expected); + break; + case OBJECT: + case ARRAY: + Value structure = Value.objectToValue(MAPPER.convertValue(expected, Object.class)); + assertThat(provider.getObjectEvaluation(key, new Value("fallback"), context) + .getValue()) + .as(where) + .isEqualTo(structure); + break; + default: + fail("%s is a %s, which is not a flag value", where, expected.getNodeType()); + break; + } + } + + /** + * Resolves a numeric flag through the accessor its literal calls for. + * + *

      Which accessor that is is the assertion. An integral literal goes through the + * integer accessor and a fractional one through the float accessor, and the SDK's provider + * refuses a variant of the other type, so this is where a decoder that widened or narrowed a + * number fails. 2^53 − 1 has no room in an {@link Integer} and goes through the long accessor, + * which is also the reason a Java provider leaves {@code @large-integers} undeclared. + */ + private static void assertNumberResolves( + InMemoryProvider provider, ImmutableContext context, String key, String where, JsonNode expected) { + + if (!expected.isIntegralNumber()) { + double value = expected.doubleValue(); + assertThat(provider.getDoubleEvaluation(key, value + 1, context).getValue()) + .as(where) + .isEqualTo(value); + } else if (expected.canConvertToInt()) { + int value = expected.intValue(); + assertThat(provider.getIntegerEvaluation(key, value + 1, context).getValue()) + .as(where) + .isEqualTo(value); + } else { + long value = expected.longValue(); + assertThat(provider.getLongEvaluation(key, value + 1, context).getValue()) + .as(where) + .isEqualTo(value); + } + } +} From 4d836a3a518cf682076759359866b93eb0c2e487 Mon Sep 17 00:00:00 2001 From: Simon Schrottner Date: Fri, 11 Sep 2026 18:46:30 +0200 Subject: [PATCH 22/55] fix(provider-tck): one skip with a reason, not a second kind of declaration Capability carried a parallel "not applicable" representation -- notApplicable(), notApplicableReason(), a filter in declarable(), a branch in the gate ahead of the declaration check, and a guard in requireDeclarable refusing to let such a tag be declared. All of it is gone. @large-integers is now an ordinary declarable capability that a Java provider withholds. "Not declared" and "not applicable" are both skips. A second representation asks a reader to learn more vocabulary to be told what the declaration and the scenario's own tags already say: the tags say what was asked, the declaration says whether it was claimed, and the skip's reason says why it was skipped. Spec 7f03f672 dropped declaration.notApplicable from the report schema on that reasoning, and Appendix F at 600ef9fd states the rest. The case that motivated it is a property of an SDK rather than of a provider. Java's integer accessor is a 32-bit Integer, so 2^53 - 1 genuinely cannot be asked for -- but that is true of every provider written against this SDK, for as long as the accessor is what it is, so Appendix F records it once instead of every report restating a language fact on some provider's behalf. Four implementations built the field and no adoption in any of them populated it, so the provider-specific case it was reserved for has not arisen in four languages. The guard for reserved capabilities stays, and is a different rule: no scenario carries a reserved tag, so declaring one is a claim no result can verify or contradict. Declaring @large-integers is not that. The scenario runs and fails when TckValues cannot fit 9007199254740991 into an Integer, and a failure is a louder answer than a refused declaration -- so requireDeclarable now lets it through and TckValues' message names the fix. What this costs, and where it went. The skip message for a withheld @large-integers no longer names the 32-bit accessor; it is the ordinary "provider does not declare capability LARGE_INTEGERS (tag @large-integers)". The reason is now in the three places a reader looks: Appendix F, the LARGE_INTEGERS javadoc, and the README, which also tells a Java adopter to put it in declarableExcept(...). It is not a KnownDeviation -- withholding it is not a defect. declarable() consequently includes LARGE_INTEGERS, so a harness that starts from it has one more tag to remove. That is the documented workflow -- start from the default, run the suite, remove what the provider cannot do -- and it now fails loudly where it used to be silently corrected. Go's TCK, the reference, has always worked this way. Signed-off-by: Simon Schrottner --- tools/provider-tck/README.md | 22 +++- .../contrib/tools/providertck/Capability.java | 115 +++++++----------- .../tools/providertck/CapabilityGate.java | 21 ++-- .../tools/providertck/ProviderTckHarness.java | 7 +- .../contrib/tools/providertck/TckValues.java | 6 +- .../tools/providertck/DeclarationApiTest.java | 43 +++---- .../providertck/InMemoryProviderTckTest.java | 6 +- 7 files changed, 103 insertions(+), 117 deletions(-) diff --git a/tools/provider-tck/README.md b/tools/provider-tck/README.md index e0ca010c34..975d3d1baf 100644 --- a/tools/provider-tck/README.md +++ b/tools/provider-tck/README.md @@ -417,7 +417,7 @@ green on scenarios it did not run is worse than no suite at all. | `OBJECT` | `@object` | supports structured flag values | | `UNAVAILABLE_INIT` | `@unavailable` | reports an error state instead of hanging on a dead backend — *needs connection control* | | `NUMERIC_COERCION` | `@numeric-coercion` | coerces between integer and float only when lossless, else `TYPE_MISMATCH` — both directions tested | -| `LARGE_INTEGERS` | `@large-integers` | resolves integers up to 2^53 − 1 exactly; **not applicable in Java, not declarable** — the SDK's integer accessor is a 32-bit `Integer`, so the scenario is skipped with that reason on every run | +| `LARGE_INTEGERS` | `@large-integers` | resolves integers up to 2^53 − 1 exactly; **every Java provider withholds it** — the SDK's integer accessor is a 32-bit `Integer`, so the limit is the language's, not the provider's | | `TARGETING` | `@targeting` | reserved, **not declarable** — no scenarios yet | | `CACHING` | `@caching` | reserved, **not declarable** — no scenarios yet | @@ -497,10 +497,22 @@ either mode, for the first reason — see [flagd#1996](https://github.com/open-feature/flagd/issues/1996). A note on `LARGE_INTEGERS`: accessor width is a property of the SDK, not of the provider, and Java's -is 32 bits — `Client.getIntegerDetails` takes and returns an `Integer`. The tag is therefore neither -declarable nor declared here, and its one scenario is reported as skipped with that reason on every -Java run, whatever the provider could do. Declaring it fails the run, as declaring a reserved tag -does. The 32-bit precision scenario (`large-integer-flag`, 2^31 − 1) is untagged and always runs. +is 32 bits — `Client.getIntegerDetails` takes and returns an `Integer`, which has no room for +2^53 − 1. So **every Java provider withholds this tag**, and its one scenario is reported as skipped +for an undeclared capability like any other. Put it in your `declarableExcept(...)` list: + +```java +return Capability.declarableExcept(Capability.LARGE_INTEGERS, /* whatever else */); +``` + +That the impossibility is the language's rather than the provider's is recorded once, in +[Appendix F](https://github.com/open-feature/spec/blob/main/specification/appendix-f-provider-conformance.md), +rather than restated in every run: a report has one skip status, carrying its reason, and the +scenario's own tags say what was being asked. Withholding the tag therefore needs no +`KnownDeviation` — it is not a defect. Declaring it is not refused either; the scenario runs and +fails when `TckValues` cannot fit `9007199254740991` into an `Integer`, which is a louder answer than +a rejected declaration. The 32-bit precision scenario (`large-integer-flag`, 2^31 − 1) is untagged and +always runs. ### Saying that a withheld capability is a defect diff --git a/tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/Capability.java b/tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/Capability.java index cc0542acef..427617c1be 100644 --- a/tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/Capability.java +++ b/tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/Capability.java @@ -30,10 +30,13 @@ * {@code EnumSet.allOf} or {@code EnumSet.complementOf}: both of the latter sweep up every reserved * tag on the way past, which is how a report comes to claim a capability nobody examined. * - *

      One entry is {@linkplain #notApplicable() not applicable} in Java: a scenario carries its tag, - * but what the tag asks for is a property of the SDK rather than of any provider, and the Java SDK - * cannot supply it. Such a capability is not declarable either — the claim could never be true of a - * Java provider — and its scenarios are skipped with a reason that names the SDK, on every run. + *

      Some capabilities cannot hold in a language at all, as opposed to not holding for a particular + * provider: {@link #LARGE_INTEGERS} asks for a value the Java SDK's 32-bit integer accessor has no + * room for. That is a property of the SDK, true of every provider written against it, and + * Appendix + * F is where it is recorded — once, rather than restated in every run. Here it is an ordinary + * capability that a Java provider leaves undeclared, and its scenario is reported as skipped like + * any other undeclared one. * *

      The connection-dependent capabilities

      * @@ -161,21 +164,26 @@ public enum Capability { /** * Provider resolves integers up to 2^53 − 1 exactly. * - *

      {@linkplain #notApplicable() Not applicable} in Java, and so not declarable. Whether the - * value can be asked for at all is a property of the SDK's integer accessor rather than of the - * provider: {@code Client.getIntegerDetails} takes and returns a 32-bit {@link Integer}, so a - * Java provider has nowhere to put {@code 9007199254740991} however faithfully its backend - * serves it. Go's accessor is {@code int64} and JavaScript's number reaches 2^53 − 1 exactly, so - * their suites run the scenario; here it is reported as skipped, with that reason, on every run. + *

      A Java provider leaves this undeclared. Whether the value can be asked for + * at all is a property of the SDK's integer accessor rather than of the provider: + * {@code Client.getIntegerDetails} takes and returns a 32-bit {@link Integer}, so a Java + * provider has nowhere to put {@code 9007199254740991} however faithfully its backend serves it. + * Go's accessor is {@code int64} and JavaScript's number reaches 2^53 − 1 exactly, so their + * suites declare it and run the scenario. + * + *

      That the limit is the language's is recorded in + * Appendix + * F rather than in each run, so this is an ordinary declarable capability and withholding it + * needs no {@link KnownDeviation}: the scenario is skipped for an undeclared capability, as it + * would be in any language whose accessor was too narrow. Declaring it on a Java provider does + * not fail the run — the value is simply unaskable and the scenario fails when + * {@link TckValues} cannot convert it, which says the same thing louder. * *

      The 32-bit precision scenario — {@code large-integer-flag}, 2^31 − 1 — is untagged and * always runs. What a provider owes a value that does not fit the requested accessor is the * open question in open-feature/spec#430. */ - LARGE_INTEGERS( - "@large-integers", - "the Java SDK's integer accessor is a 32-bit Integer, so a Java provider cannot resolve an " - + "integer beyond 2^31 - 1 through it whatever its backend serves"), + LARGE_INTEGERS("@large-integers"), /** * Provider supports targeting rules driven by evaluation context. @@ -196,24 +204,14 @@ public enum Capability { private final String tag; private final boolean reserved; - private final String notApplicableReason; Capability(String tag) { - this(tag, false, null); + this(tag, false); } Capability(String tag, boolean reserved) { - this(tag, reserved, null); - } - - Capability(String tag, String notApplicableReason) { - this(tag, false, notApplicableReason); - } - - Capability(String tag, boolean reserved, String notApplicableReason) { this.tag = tag; this.reserved = reserved; - this.notApplicableReason = notApplicableReason; } /** @@ -239,30 +237,6 @@ public boolean reserved() { return reserved; } - /** - * Returns whether this capability is one no Java provider can have, and so must not be declared. - * - *

      Not applicable means a scenario carries the tag, but what it asks for is a property of the - * SDK rather than of the provider and the Java SDK cannot supply it. Unlike a - * {@linkplain #reserved() reserved} capability there is something to gate: the scenario - * runs the gate and is reported as skipped with {@link #notApplicableReason()}, so a reader sees - * why it was not examined rather than a bare omission. - * - * @return {@code true} if the Java SDK cannot satisfy this capability - */ - public boolean notApplicable() { - return notApplicableReason != null; - } - - /** - * Returns why this capability is not applicable in Java, when it is not. - * - * @return the reason, or empty for a capability a Java provider may declare - */ - public Optional notApplicableReason() { - return Optional.ofNullable(notApplicableReason); - } - /** * Looks up the capability gated by a Gherkin tag. * @@ -274,18 +248,20 @@ public static Optional fromTag(String tag) { } /** - * Returns every capability that may be declared: every capability some scenario gates and a - * Java provider can have. + * Returns every capability that may be declared: every capability some scenario gates. * *

      This, not {@code EnumSet.allOf(Capability.class)}, is what "everything" means for a - * declaration. {@linkplain #reserved() Reserved} and {@linkplain #notApplicable() not - * applicable} capabilities are left out. + * declaration. {@linkplain #reserved() Reserved} capabilities are left out. + * + *

      It is not a set any Java provider should declare unchanged. {@link #LARGE_INTEGERS} is in + * it — it is a real capability, gating a real scenario — and the Java SDK's integer accessor has + * no room for what it asks for, so withhold it with {@link #declarableExcept}. * * @return the declarable capabilities, as a fresh mutable set */ public static EnumSet declarable() { EnumSet declarable = EnumSet.allOf(Capability.class); - declarable.removeIf(capability -> capability.reserved() || capability.notApplicable()); + declarable.removeIf(Capability::reserved); return declarable; } @@ -294,11 +270,9 @@ public static EnumSet declarable() { * *

      The counterpart to {@code EnumSet.complementOf}, and the reason it exists: a provider * saying "everything except the one thing I cannot do" wants everything declarable - * except that thing, whereas {@code complementOf} hands back the reserved and not-applicable - * tags as well. + * except that thing, whereas {@code complementOf} hands back the reserved tags as well. * - * @param excluded capabilities to withhold; reserved and not-applicable capabilities are absent - * regardless + * @param excluded capabilities to withhold; reserved capabilities are absent regardless * @return the declarable capabilities minus {@code excluded}, as a fresh mutable set */ public static EnumSet declarableExcept(Capability... excluded) { @@ -310,27 +284,27 @@ public static EnumSet declarableExcept(Capability... excluded) { } /** - * Rejects a declaration that names a reserved or a not-applicable capability. + * Rejects a declaration that names a reserved capability. * *

      Fails the run rather than warning and dropping it. The declaration is the one part of a * conformance report that no result can check — everything else in it was observed, this is * asserted by the provider author — so a claim that cannot possibly be true is worth stopping - * for. There is nothing to lose by refusing, either: no scenario carries a reserved tag, and a - * not-applicable one is skipped whatever is declared, so no coverage depends on the claim, and - * the fix is to call {@link #declarable()} or {@link #declarableExcept}. + * for. There is nothing to lose by refusing, either: no scenario carries a reserved tag, so no + * coverage depends on the claim, and the fix is to call {@link #declarable()} or + * {@link #declarableExcept}. + * + *

      Only reserved capabilities are refused. A capability whose scenario the provider cannot + * satisfy is not a claim that cannot be checked — it is one the results contradict, which is + * what a conformance run is for. * * @param declared the capabilities a harness declares - * @throws IllegalArgumentException if any of them is reserved or not applicable + * @throws IllegalArgumentException if any of them is reserved */ public static void requireDeclarable(Collection declared) { List reservedTags = new ArrayList<>(); - List notApplicableTags = new ArrayList<>(); for (Capability capability : declared) { if (capability.reserved()) { reservedTags.add(capability.name() + " (" + capability.tag() + ")"); - } else if (capability.notApplicable()) { - notApplicableTags.add( - capability.name() + " (" + capability.tag() + "): " + capability.notApplicableReason); } } if (!reservedTags.isEmpty()) { @@ -341,12 +315,5 @@ public static void requireDeclarable(Collection declared) { + "\"everything except\" — EnumSet.allOf and EnumSet.complementOf pick reserved " + "capabilities up on the way past."); } - if (!notApplicableTags.isEmpty()) { - throw new IllegalArgumentException("capabilities() declares " + notApplicableTags - + ", which no Java provider can satisfy: the limit is the SDK's, not the " - + "provider's, and the scenario is skipped with that reason whatever is declared. " - + "Leave it out — Capability.declarable() and Capability.declarableExcept(...) " - + "already do."); - } } } diff --git a/tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/CapabilityGate.java b/tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/CapabilityGate.java index e50c0ea877..92f7beb7fb 100644 --- a/tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/CapabilityGate.java +++ b/tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/CapabilityGate.java @@ -22,16 +22,21 @@ public final class CapabilityGate { private CapabilityGate() {} /** - * Aborts the running scenario if any of its tags gates a capability that was not declared, or - * one that is {@linkplain Capability#notApplicable() not applicable} in Java. + * Aborts the running scenario if any of its tags gates a capability that was not declared. * *

      Tags that gate nothing are ignored, so a scenario with no capability tag is mandatory and - * always runs. A not-applicable capability is checked before the declaration, and its skip - * names the SDK rather than the provider: the provider did not decline it, the language did. + * always runs. + * + *

      One skip, carrying its reason, is the whole mechanism. A capability that cannot hold in a + * language at all — {@link Capability#LARGE_INTEGERS} on the Java SDK's 32-bit integer accessor + * — is undeclared like any other the provider does not offer, and is skipped the same way. + * Separating the two would ask a reader to learn a second vocabulary to be told what the + * declaration and the scenario's own tags already say; where the impossibility is the + * language's, Appendix F records it once instead. * * @param tags the scenario's Gherkin tags, including the leading at-sign * @param declared the capabilities the provider declares - * @throws TestAbortedException if a tag gates an undeclared or a not-applicable capability + * @throws TestAbortedException if a tag gates an undeclared capability */ public static void requireDeclared(Collection tags, Set declared) { for (String tag : tags) { @@ -39,12 +44,6 @@ public static void requireDeclared(Collection tags, Set decl if (!capability.isPresent()) { continue; } - Optional notApplicable = capability.get().notApplicableReason(); - if (notApplicable.isPresent()) { - throw new TestAbortedException( - "Skipped: capability " + capability.get().name() + " (tag " + tag - + ") is not applicable to a Java provider — " + notApplicable.get() + "."); - } if (!declared.contains(capability.get())) { throw new TestAbortedException("Skipped: provider does not declare capability " + capability.get().name() + " (tag " + tag + "). Declared capabilities: " + declared); diff --git a/tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/ProviderTckHarness.java b/tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/ProviderTckHarness.java index c23baffda1..19ae8ffbe0 100644 --- a/tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/ProviderTckHarness.java +++ b/tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/ProviderTckHarness.java @@ -122,10 +122,13 @@ default FeatureProvider createUnavailableProvider() { * provider genuinely cannot do — {@link Capability#declarableExcept} is the idiomatic way to say * "everything except". * + *

      {@link Capability#LARGE_INTEGERS} is one every Java provider removes. It asks for 2^53 − 1, + * and {@code Client.getIntegerDetails} is a 32-bit {@link Integer} with no room for it, so the + * limit is the SDK's rather than any provider's — see Appendix F, where that is recorded. + * *

      Do not build the set with {@code EnumSet.allOf} or {@code EnumSet.complementOf}. Both * include the {@linkplain Capability#reserved() reserved} capabilities, which no scenario - * carries, and the {@linkplain Capability#notApplicable() not applicable} one, which no Java - * provider can have; declaring either fails the run. + * carries; declaring one fails the run. * * @return the capabilities this provider supports */ diff --git a/tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/TckValues.java b/tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/TckValues.java index 274f1f139d..e5139b5504 100644 --- a/tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/TckValues.java +++ b/tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/TckValues.java @@ -43,8 +43,10 @@ public static Object convert(String value, String type) { throw new IllegalArgumentException( "'" + value + "' is not an Integer the Java SDK can ask for: " + "Client.getIntegerDetails takes a 32-bit Integer. A scenario needing more than " - + "2^31 - 1 must carry @large-integers, which is not applicable in Java and is " - + "skipped before any value is converted.", + + "2^31 - 1 carries @large-integers, which a Java provider leaves undeclared " + + "because the accessor is the limit rather than the provider — see Appendix F. " + + "Reaching this means the capability was declared: remove it with " + + "Capability.declarableExcept(Capability.LARGE_INTEGERS).", e); } case "Float": diff --git a/tools/provider-tck/src/test/java/dev/openfeature/contrib/tools/providertck/DeclarationApiTest.java b/tools/provider-tck/src/test/java/dev/openfeature/contrib/tools/providertck/DeclarationApiTest.java index f5ad6fde38..cd35ad9700 100644 --- a/tools/provider-tck/src/test/java/dev/openfeature/contrib/tools/providertck/DeclarationApiTest.java +++ b/tools/provider-tck/src/test/java/dev/openfeature/contrib/tools/providertck/DeclarationApiTest.java @@ -42,33 +42,35 @@ void reservedCapabilitiesAreNotDeclarable() { } @Test - @DisplayName("a capability the Java SDK cannot satisfy is not declarable, and is skipped with that reason") - void notApplicableCapabilitiesAreNotDeclarable() { - assertThat(Capability.LARGE_INTEGERS.notApplicable()).isTrue(); - assertThat(Capability.LARGE_INTEGERS.notApplicableReason()) - .as("the reason names the SDK's accessor, which is the limit, rather than the provider") - .hasValueSatisfying(reason -> assertThat(reason).contains("32-bit")); + @DisplayName("a capability a language cannot hold is an ordinary one, withheld and skipped like any other") + void aCapabilityTheLanguageCannotHoldIsWithheldRatherThanSetApart() { + // @large-integers asks for 2^53 - 1 and the Java SDK's accessor is a 32-bit Integer, so no + // Java provider can hold it. That is a property of the SDK, recorded once in Appendix F, + // and not a second kind of declaration: there is one skip and it carries its reason. assertThat(Capability.LARGE_INTEGERS.reserved()) - .as("not applicable is distinct from reserved: a scenario does carry the tag") + .as("a scenario does carry the tag, so there is something to gate") .isFalse(); + assertThat(Capability.declarable()) + .as("it is an ordinary declarable capability; a harness withholds it rather than being forbidden it") + .contains(Capability.LARGE_INTEGERS); + assertThat(Capability.declarableExcept(Capability.LARGE_INTEGERS)) + .as("declarableExcept is how a Java harness says so") + .doesNotContain(Capability.LARGE_INTEGERS); - assertThat(Capability.declarable()).doesNotContain(Capability.LARGE_INTEGERS); - assertThat(Capability.declarableExcept(Capability.STALE)).doesNotContain(Capability.LARGE_INTEGERS); - - assertThatThrownBy(() -> Capability.requireDeclarable(EnumSet.of(Capability.EVENTS, Capability.LARGE_INTEGERS))) - .isInstanceOf(IllegalArgumentException.class) - .hasMessageContaining("LARGE_INTEGERS") - .hasMessageContaining("no Java provider can satisfy"); + // Declaring it is not refused. The guard exists for a claim no result can contradict, and + // this is not one: the scenario runs and fails, which says more than a rejected declaration. + Capability.requireDeclarable(EnumSet.of(Capability.EVENTS, Capability.LARGE_INTEGERS)); - // Skipped whatever is declared, and the skip blames the SDK rather than the provider. + // Withheld, it is skipped exactly as any undeclared capability is. TestAbortedException aborted = catchThrowableOfType( - () -> CapabilityGate.requireDeclared(Arrays.asList("@large-integers"), Capability.declarable()), + () -> CapabilityGate.requireDeclared( + Arrays.asList("@large-integers"), Capability.declarableExcept(Capability.LARGE_INTEGERS)), TestAbortedException.class); assertThat(aborted).isNotNull(); assertThat(aborted) - .hasMessageContaining("not applicable") - .hasMessageContaining("32-bit") - .hasMessageNotContaining("does not declare"); + .hasMessageContaining("LARGE_INTEGERS") + .hasMessageContaining("@large-integers") + .hasMessageContaining("does not declare"); } @Test @@ -83,10 +85,9 @@ void tagsMapBackToCapabilities() { void reinitialisationIsADeclarableChoice() { // Requirement 2.5.2 permits reuse after shutdown rather than requiring it, so a provider // that refuses it withholds the tag instead of recording a deviation. That makes it an - // ordinary declarable capability: neither reserved nor not-applicable. + // ordinary declarable capability rather than a reserved one. assertThat(Capability.fromTag("@reinitialization")).contains(Capability.REINITIALIZATION); assertThat(Capability.REINITIALIZATION.reserved()).isFalse(); - assertThat(Capability.REINITIALIZATION.notApplicable()).isFalse(); assertThat(Capability.declarable()).contains(Capability.REINITIALIZATION); // The scenario carries @lifecycle too. A provider that initialises against a backend it diff --git a/tools/provider-tck/src/test/java/dev/openfeature/contrib/tools/providertck/InMemoryProviderTckTest.java b/tools/provider-tck/src/test/java/dev/openfeature/contrib/tools/providertck/InMemoryProviderTckTest.java index 72dc55d330..afe9f78e06 100644 --- a/tools/provider-tck/src/test/java/dev/openfeature/contrib/tools/providertck/InMemoryProviderTckTest.java +++ b/tools/provider-tck/src/test/java/dev/openfeature/contrib/tools/providertck/InMemoryProviderTckTest.java @@ -78,8 +78,10 @@ public FeatureProvider createProvider() { * {@link ProviderTckHarness#createUnavailableProvider()} is left at its throwing default. *

    • {@link Capability#TARGETING} and {@link Capability#CACHING} — omitted because no * scenario carries their tags yet. Nothing is skipped by leaving them out today. - *
    • {@link Capability#LARGE_INTEGERS} — not declarable by any Java provider, this one - * included; its scenario is skipped with the SDK's 32-bit accessor as the reason. + *
    • {@link Capability#LARGE_INTEGERS} — omitted, as every Java provider omits it. The tag + * asks for 2^53 − 1 and {@code Client.getIntegerDetails} is a 32-bit {@link Integer}, so + * the limit is the SDK's rather than this provider's; Appendix F is where that is + * recorded, and here it is simply undeclared and its scenario skipped. *
    */ @Override From 9759cf2544ec44a5ec73268308c816dd380d0536 Mon Sep 17 00:00:00 2001 From: Simon Schrottner Date: Fri, 11 Sep 2026 19:16:31 +0200 Subject: [PATCH 23/55] ci: run the provider-tck stacked pull requests The pull_request filter matches the BASE branch, so only the suite PR -- the one targeting main -- was ever checked. The report and adoption PRs stacked on it have never run CI, which is why their green ticks meant nothing: the checks on display belong to the base PR. One line, and temporary for the duration of review. The workflow is taken from the head branch, so it has to sit on the base and reach the children by rebase. Signed-off-by: Simon Schrottner --- .github/workflows/ci.yml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ad34cb8c8d..794087d34e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,6 +7,13 @@ on: - reopened branches: - main + # Temporary, for the duration of the provider conformance suite's review. + # Without it a stacked pull request gets no CI at all: this filter matches + # the pull request's BASE branch, so only the suite PR itself -- the one + # targeting main -- was ever checked, and the report and adoption PRs + # stacked on it were merged-in-theory and tested never. Remove once the + # chain has landed. See open-feature/spec#417. + - 'feat/provider-tck*' jobs: # Fast canary for the Provider TCK: runs the full applicable conformance suite against the From b685eac8473ede12ab876d10cf0d1ec8127792ec Mon Sep 17 00:00:00 2001 From: Simon Schrottner Date: Fri, 11 Sep 2026 23:34:00 +0200 Subject: [PATCH 24/55] feat(provider-tck): gate the variant, and make @targeting a real capability Follows the spec submodule to 26362f85. The pin and the code go together: the suite reads the Gherkin out of the pin, so either alone is a broken build. @variants is new, and it is the one that had to exist. Every evaluation scenario asserted a variant, which reads as obviously correct until a backend with no variant concept for a plain flag is put under test: its response carries no such key, the provider never receives one, and no seeding can produce one. Requirement 2.2.4 is a SHOULD and types.md types the field "variant (string, optional)", so the suite was asserting a MUST that neither states -- ten scenarios failing a conformant provider for something its author cannot fix, with nothing to record as a deviation because no capability existed to hang one on. The assertions are consolidated into one gated outline; value and reason stay untagged, because 2.2.3 makes the value a MUST. @targeting stops being reserved. targeting-key-flag carries the one rule in the canonical set and three scenarios now gate on it, so the claim can be contradicted by a result -- which is the whole test for whether a capability may be declared. @caching is the only reserved tag left, and declarable() and declarableExcept() still exclude it, which is the accident they exist to prevent. The new mandatory scenario closes 2.2.1's untested half. Nothing in the suite passed an evaluation context at all, so a provider that threw on any context, or serialised it into a malformed request, passed everything. The step definition it needs was already here and dead -- ContextSteps has carried "a context containing a targeting key with value" since the vocabulary was inherited from the flagd harness, and FlagSteps has always handed state.context to the accessor -- so the wiring was a scenario away. 52 scenario instances now, evaluation.feature 24. In-memory: 38 pass, 14 skipped (six @lifecycle, one @stale, three @numeric-coercion, one @large-integers, three @targeting). It declares VARIANTS on evidence -- InMemoryProvider does name the variant it served -- and withholds TARGETING, because it reads variants and defaultVariant and evaluates no rules, so the targeting member is inert and a matching context resolves miss like any other. Signed-off-by: Simon Schrottner --- tools/provider-tck/README.md | 69 ++++++++++++------- tools/provider-tck/spec | 2 +- .../contrib/tools/providertck/Capability.java | 58 +++++++++++++--- .../tools/providertck/steps/ContextSteps.java | 15 ++-- .../tools/providertck/DeclarationApiTest.java | 46 ++++++++++++- .../providertck/InMemoryProviderTckTest.java | 15 ++-- .../providertck/MultiProviderTckTest.java | 14 ++-- 7 files changed, 170 insertions(+), 49 deletions(-) diff --git a/tools/provider-tck/README.md b/tools/provider-tck/README.md index 975d3d1baf..8bbd11ae61 100644 --- a/tools/provider-tck/README.md +++ b/tools/provider-tck/README.md @@ -40,8 +40,9 @@ uses only long-stable API — `OpenFeatureAPI`, `Client`, typed evaluation, `Pro **In scope — the provider contract:** -- mapping backend responses onto typed resolution details (value, variant, reason, error code), with - no error message on a success path +- mapping backend responses onto typed resolution details (value, reason, error code), with no error + message on a success path — and the variant where the backend names one, which is gated on + `@variants` because Requirement 2.2.4 is a `SHOULD` and `types.md` types the field optional - keeping the integer and float types distinct; that `false`, `0` and `""` are values, not absences; integer precision to 2^31 − 1 - error handling: type mismatch and unknown flag return the code default, report the right error @@ -51,12 +52,16 @@ uses only long-stable API — `OpenFeatureAPI`, `Client`, typed evaluation, `Pro - events: `PROVIDER_READY`, `PROVIDER_ERROR`, `PROVIDER_STALE`, `PROVIDER_CONFIGURATION_CHANGED` - that a signalled configuration change is actually applied on re-evaluation - that the provider identifies itself by a non-empty metadata name +- that supplying an evaluation context does not disturb an untargeted resolution, and — gated on + `@targeting` — that a matching context resolves the targeted variant **Out of scope — not the provider's contract:** -- backend evaluation logic, targeting and bucketing correctness. Every flag in the canonical set - resolves to its default variant with no targeting, so what is under test is the provider's - mapping of a response, not the backend's decision. +- backend evaluation logic, bucketing and rule-language correctness. Every flag in the canonical set + except `targeting-key-flag` resolves to its default variant whatever the context, so what is under + test is the provider's mapping of a response, not the backend's decision. That one carries the one + rule, and it is there to prove the context reached the backend rather than to test how the backend + evaluated it. - the provider↔backend wire protocol. How you talk to your backend is your business. - SDK behaviour. That belongs to the SDK's own test suite. @@ -142,10 +147,14 @@ Two suites in this module are exactly the class above, and both run with no Dock second. They are the reference adoption, and they are the fast CI canary. [`InMemoryProviderTckTest`](src/test/java/dev/openfeature/contrib/tools/providertck/InMemoryProviderTckTest.java) -runs the full applicable suite against the SDK's `InMemoryProvider` — of the 40 scenarios (outline -rows counted individually), 29 pass and 11 are skipped by capability: the six `@lifecycle` ones — one +runs the full applicable suite against the SDK's `InMemoryProvider` — of the 52 scenarios (outline +rows counted individually), 38 pass and 14 are skipped by capability: the six `@lifecycle` ones — one of which also carries `@reinitialization`, and is skipped for the first of the two — the `@stale` -one, the three `@numeric-coercion` ones and the `@large-integers` one. It does not declare +one, the three `@numeric-coercion` ones, the `@large-integers` one and the three `@targeting` ones. +It declares `VARIANTS`, because `InMemoryProvider` does name the variant it served, so the gated +variant outline runs rather than being skipped. It does not declare `TARGETING`: the provider reads a +flag's `variants` and `defaultVariant` and evaluates no rules, so `targeting-key-flag`'s `targeting` +member is inert and a matching context resolves `miss` like any other. It does not declare `NUMERIC_COERCION`, because `InMemoryProvider` keeps the two numeric types strictly apart in both directions — it refuses `10.0` as an integer and `10` as a float exactly as it refuses `0.5` — and the tag requires the lossless direction too. That is a choice the SDK's reference provider is entitled to, @@ -238,7 +247,13 @@ resolved values are. Seed them however your backend seeds flags. Four details are load-bearing: - **`missing-flag` must not exist.** Its absence is what the `FLAG_NOT_FOUND` scenario tests. -- **No flag has targeting rules.** Every scenario expects reason `STATIC`. +- **Only `targeting-key-flag` has a targeting rule.** Every other flag resolves to its default + variant whatever the evaluation context, which is what lets the untargeted scenarios expect reason + `STATIC`; seeding targeting onto any other flag breaks them. Its rule is specified by behaviour — + resolve `hit` when the targeting key is exactly `5c3d8535-f81a-4478-a6d3-afaa4d51199e`, `miss` + otherwise — so express it however your backend expresses targeting. The flag, its variants and the + uuid are flagd-testbed's own, so a backend serving that harness already serves this one. A backend + that cannot carry a rule leaves `TARGETING` undeclared and the three scenarios are skipped. - **`boolean-zero-flag`, `integer-zero-flag` and `string-zero-flag` resolve to `false`, `0` and `""` on purpose.** A seeding step that treats them as unset and drops them turns the falsy-value scenarios into `FLAG_NOT_FOUND` failures that look like provider defects. These names, and their @@ -415,10 +430,11 @@ green on scenarios it did not run is worse than no suite at all. | `STALE` | `@stale` | enters `STALE` and emits `PROVIDER_STALE` on backend loss — *needs connection control* | | `CONFIGURATION_CHANGE` | `@configuration-change` | detects config changes, emits `PROVIDER_CONFIGURATION_CHANGED` | | `OBJECT` | `@object` | supports structured flag values | +| `VARIANTS` | `@variants` | names the variant it resolved — [Requirement 2.2.4](https://github.com/open-feature/spec/blob/main/specification/sections/02-providers.md) is a `SHOULD` and `types.md` types the field optional, so a backend with no variant concept withholds it | | `UNAVAILABLE_INIT` | `@unavailable` | reports an error state instead of hanging on a dead backend — *needs connection control* | | `NUMERIC_COERCION` | `@numeric-coercion` | coerces between integer and float only when lossless, else `TYPE_MISMATCH` — both directions tested | | `LARGE_INTEGERS` | `@large-integers` | resolves integers up to 2^53 − 1 exactly; **every Java provider withholds it** — the SDK's integer accessor is a 32-bit `Integer`, so the limit is the language's, not the provider's | -| `TARGETING` | `@targeting` | reserved, **not declarable** — no scenarios yet | +| `TARGETING` | `@targeting` | resolves `targeting-key-flag` differently for a matching evaluation context — *needs a backend that evaluates rules* | | `CACHING` | `@caching` | reserved, **not declarable** — no scenarios yet | The default is every *declarable* capability. **Narrow it, do not widen it**: start from the @@ -437,16 +453,19 @@ public Set capabilities() { } ``` -The reserved entries are part of the vocabulary so that every language's TCK spells the same -property the same way, but no scenario carries their tag — so declaring one cannot produce a skip, -cannot be contradicted by any result, and tells a reader a capability was verified when nothing -examined it. Declaring one **fails the run**, with a message naming the tag. +A reserved entry is part of the vocabulary so that every language's TCK spells the same property the +same way, but no scenario carries its tag — so declaring it cannot produce a skip, cannot be +contradicted by any result, and tells a reader a capability was verified when nothing examined it. +Declaring one **fails the run**, with a message naming the tag. `CACHING` is the only reserved entry +left: `TARGETING` was reserved until `targeting-key-flag`'s three scenarios arrived, and is an +ordinary declarable capability now. That is a rule about an accident rather than about intent: `EnumSet.complementOf(EnumSet.of(X))` reads as "everything except X" and in fact means "every other enum constant", reserved tags included. The flagd suite said exactly that and published `"declared": [..., "@targeting", -"@caching"]` for two capabilities nobody had claimed. `Capability.declarable()` and -`Capability.declarableExcept(...)` are the forms that mean what the first one looks like. +"@caching"]` for two capabilities nobody had claimed — back when both were reserved. +`Capability.declarable()` and `Capability.declarableExcept(...)` are the forms that mean what the +first one looks like, and they still exclude `@caching`. A note on `LIFECYCLE` vs `EVENTS`: they look like the same thing and are not. `EVENTS` says the provider emits events; `LIFECYCLE` says there is a real initialisation behind them. The SDK's @@ -659,13 +678,17 @@ explicit command is only useful when working offline or inspecting the sources b ## Known gaps -- **Evaluation context passthrough.** The TCK builds evaluation contexts but cannot assert the - context *reached* the backend intact. That needs an echo operation on the control API — something - like `GET /last-evaluation` returning the request the backend last received. Until then, a - provider that silently drops the context passes. -- **Targeting and bucketing.** Out of scope by design: that is backend evaluation logic. The - `@targeting` tag is reserved for context-passthrough scenarios once the gap above is closed, and - is not declarable until they exist. +- **Evaluation context passthrough, beyond the targeting key.** `targeting-key-flag` resolves + differently for a matching context, so a provider that drops the context is caught by the resolved + value itself — that is what the `@targeting` scenarios do, and no echo operation is needed for it. + What is still unverified is that the *whole* context arrives intact: a provider that forwards the + targeting key and silently discards every other attribute passes. Closing that needs either an + echo operation on the control API — something like `GET /last-evaluation` returning the request the + backend last received — or a canonical flag whose rule keys on a custom attribute. +- **Targeting and bucketing correctness.** Out of scope by design: that is backend evaluation logic. + `targeting-key-flag` carries the one rule in the canonical set, and it is there to prove the + context reached the backend rather than to test how the backend evaluated it — which is why its + rule is stated as behaviour and not as a syntax. - **Caching.** Whether a stale provider keeps serving last-known values during an outage depends on whether it holds a local copy of the ruleset. The `@caching` tag is reserved; no scenarios yet, and so not declarable. diff --git a/tools/provider-tck/spec b/tools/provider-tck/spec index fc99d5ace4..26362f85b7 160000 --- a/tools/provider-tck/spec +++ b/tools/provider-tck/spec @@ -1 +1 @@ -Subproject commit fc99d5ace4da472a5fea0595fa4db8034bbbc769 +Subproject commit 26362f85b7fcd59b35b969e6feebee80e206b24f diff --git a/tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/Capability.java b/tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/Capability.java index 427617c1be..d27065d235 100644 --- a/tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/Capability.java +++ b/tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/Capability.java @@ -22,8 +22,10 @@ * *

    Scenarios with no capability tag are considered mandatory and always run. * - *

    Some entries are {@linkplain #reserved() reserved}: they exist in the vocabulary so that every - * language's TCK spells the same property the same way, but no scenario carries their tag yet. A + *

    An entry may be {@linkplain #reserved() reserved}: it exists in the vocabulary so that every + * language's TCK spells the same property the same way, but no scenario carries its tag yet. + * {@link #CACHING} is the only one left — {@link #TARGETING} was reserved until the + * {@code targeting-key-flag} scenarios arrived, and is an ordinary declarable capability now. A * reserved capability must not be declared — there is nothing for it to gate, so * declaring it cannot produce a skip and cannot be contradicted by any result. Declare * {@link #declarable()}, or {@link #declarableExcept} for "everything except", rather than @@ -128,6 +130,31 @@ public enum Capability { /** Provider supports structured (object) flag values. */ OBJECT("@object"), + /** + * Provider names the variant it resolved. + * + *

    Gated, because a variant is optional rather than required. + * {@code types.md} + * declares the field "variant (string, optional)", and + * Requirement + * 2.2.4 is a {@code SHOULD}: in normal execution a provider "SHOULD populate the + * resolution details structure's variant field". The same section adds that the value + * "might only be meaningful in the context of the flag management system associated with + * the provider". + * + *

    Some backends have no variant concept for a plain flag at all. Their evaluation response + * carries no such key, so the provider never receives one and no amount of seeding can produce + * one. Asserting a variant in every evaluation scenario failed such a backend ten times over for + * something that is not a defect and that no provider author can fix — and left nothing to + * record as a {@link KnownDeviation}, because there was no capability to hang one on. + * + *

    A provider whose backend names its variants declares this and the {@code @variants} + * scenario outline runs. One whose backend does not leaves it undeclared, and those rows are + * skipped with that reason rather than passed. Either way the value and reason assertions are + * unaffected: they are untagged, and Requirement 2.2.3 makes the value a {@code MUST}. + */ + VARIANTS("@variants"), + /** Provider reports an error state rather than hanging when initialised against a dead backend. */ UNAVAILABLE_INIT("@unavailable"), @@ -186,14 +213,29 @@ public enum Capability { LARGE_INTEGERS("@large-integers"), /** - * Provider supports targeting rules driven by evaluation context. + * Provider resolves a flag differently for a matching evaluation context. + * + *

    Gates the three {@code targeting-key-flag} scenarios: a matching targeting key resolves + * {@code hit}, a non-matching one resolves {@code miss}, and no context at all resolves + * {@code miss} without erroring. + * + *

    This is what makes context passthrough observable. Every other flag in the canonical set + * resolves the same way whatever the context, so a provider that drops the context entirely + * passes them all; here a matching context resolves to a different value, so dropping it is + * caught by the resolved value itself rather than needing an echo endpoint on the control API. + * + *

    The flag's rule is specified by behaviour rather than by syntax — resolve {@code hit} when + * the targeting key is exactly {@code 5c3d8535-f81a-4478-a6d3-afaa4d51199e}, {@code miss} + * otherwise — so a backend expresses it however it expresses targeting. A provider whose backend + * has no targeting at all, or whose harness seeds a flag set that cannot carry a rule, leaves + * this undeclared and the three scenarios are skipped with that reason. * - *

    {@linkplain #reserved() Reserved}. No scenario in the current suite carries this tag — - * targeting is backend evaluation logic, which the TCK deliberately does not test. It exists so - * the tag vocabulary stays aligned with the flagd test harness and so context-passthrough - * scenarios have a home once the control API grows an echo endpoint. + *

    What is still not covered is that the whole context arrives intact: a provider + * that forwards the targeting key and silently discards every other attribute declares this and + * passes. That gap needs either an echo operation on the control API or a second flag keyed on a + * custom attribute. */ - TARGETING("@targeting", true), + TARGETING("@targeting"), /** * Provider caches evaluation results and invalidates them on configuration change. diff --git a/tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/steps/ContextSteps.java b/tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/steps/ContextSteps.java index b706086b2d..75a3ca1eb8 100644 --- a/tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/steps/ContextSteps.java +++ b/tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/steps/ContextSteps.java @@ -9,10 +9,17 @@ * *

    Step vocabulary is inherited verbatim from the flagd test harness. * - *

    Note the TCK cannot currently assert that the context reached the backend intact. - * Doing so needs an echo operation on the control API — something like - * {@code GET /last-evaluation} returning the request the backend last received — which the control - * API does not yet define. Context passthrough is therefore a known gap rather than a covered case. + *

    The context accumulated here is passed to every evaluation — {@code FlagSteps} hands + * {@code state.context} to the typed accessor it dispatches on — so the {@code @targeting} + * scenarios observe passthrough of the targeting key directly: {@code targeting-key-flag} resolves + * to a different value for a matching context, so a provider that drops the context is caught by + * the resolved value itself. + * + *

    What is still not asserted is that the whole context reached the backend intact. A + * provider that forwards the targeting key and silently discards every other attribute passes. + * Closing that needs either an echo operation on the control API — something like + * {@code GET /last-evaluation} returning the request the backend last received — or a canonical flag + * whose rule keys on a custom attribute. That remains a known gap. */ public class ContextSteps extends AbstractSteps { diff --git a/tools/provider-tck/src/test/java/dev/openfeature/contrib/tools/providertck/DeclarationApiTest.java b/tools/provider-tck/src/test/java/dev/openfeature/contrib/tools/providertck/DeclarationApiTest.java index cd35ad9700..b9ee5f7e98 100644 --- a/tools/provider-tck/src/test/java/dev/openfeature/contrib/tools/providertck/DeclarationApiTest.java +++ b/tools/provider-tck/src/test/java/dev/openfeature/contrib/tools/providertck/DeclarationApiTest.java @@ -28,19 +28,59 @@ class DeclarationApiTest { void reservedCapabilitiesAreNotDeclarable() { assertThat(Capability.declarable()) .as("declarable() is every capability some scenario gates") - .doesNotContain(Capability.TARGETING, Capability.CACHING) + .doesNotContain(Capability.CACHING) .contains(Capability.EVENTS, Capability.OBJECT); assertThat(Capability.declarableExcept(Capability.STALE)) - .doesNotContain(Capability.STALE, Capability.TARGETING) + .doesNotContain(Capability.STALE, Capability.CACHING) .contains(Capability.EVENTS); assertThatThrownBy(() -> Capability.requireDeclarable(EnumSet.allOf(Capability.class))) .isInstanceOf(IllegalArgumentException.class) - .hasMessageContaining("TARGETING") + .hasMessageContaining("CACHING") .hasMessageContaining("declares reserved"); } + @Test + @DisplayName("targeting is a declarable capability, not a reserved one") + void targetingIsDeclarable() { + // It was reserved while no scenario carried the tag. targeting-key-flag's three scenarios + // carry it now, so it gates something and the claim can be contradicted by a result -- + // which is the whole test for whether a capability may be declared. + assertThat(Capability.fromTag("@targeting")).contains(Capability.TARGETING); + assertThat(Capability.TARGETING.reserved()).isFalse(); + assertThat(Capability.declarable()).contains(Capability.TARGETING); + Capability.requireDeclarable(EnumSet.of(Capability.TARGETING)); + + // And the "declare everything" shortcut still excludes @caching, which is now the only + // reserved tag. That is the accident the shortcut exists to prevent, not a general one. + assertThat(Capability.CACHING.reserved()).isTrue(); + assertThat(Capability.declarable()).doesNotContain(Capability.CACHING); + assertThat(Capability.declarableExcept(Capability.TARGETING)) + .doesNotContain(Capability.TARGETING, Capability.CACHING); + } + + @Test + @DisplayName("variants is declarable, because 2.2.4 is a SHOULD and the field is optional") + void variantsIsADeclarableChoice() { + // Requirement 2.2.4 says a provider SHOULD populate the variant, and types.md types the + // field optional, so a backend with no variant concept withholds the tag rather than + // recording a deviation against a MUST that does not exist. + assertThat(Capability.fromTag("@variants")).contains(Capability.VARIANTS); + assertThat(Capability.VARIANTS.reserved()).isFalse(); + assertThat(Capability.declarable()).contains(Capability.VARIANTS); + + TestAbortedException aborted = catchThrowableOfType( + () -> CapabilityGate.requireDeclared( + Arrays.asList("@variants"), Capability.declarableExcept(Capability.VARIANTS)), + TestAbortedException.class); + assertThat(aborted).isNotNull(); + assertThat(aborted) + .hasMessageContaining("VARIANTS") + .hasMessageContaining("@variants") + .hasMessageContaining("does not declare"); + } + @Test @DisplayName("a capability a language cannot hold is an ordinary one, withheld and skipped like any other") void aCapabilityTheLanguageCannotHoldIsWithheldRatherThanSetApart() { diff --git a/tools/provider-tck/src/test/java/dev/openfeature/contrib/tools/providertck/InMemoryProviderTckTest.java b/tools/provider-tck/src/test/java/dev/openfeature/contrib/tools/providertck/InMemoryProviderTckTest.java index afe9f78e06..641214cf00 100644 --- a/tools/provider-tck/src/test/java/dev/openfeature/contrib/tools/providertck/InMemoryProviderTckTest.java +++ b/tools/provider-tck/src/test/java/dev/openfeature/contrib/tools/providertck/InMemoryProviderTckTest.java @@ -47,7 +47,9 @@ public FeatureProvider createProvider() { /** * {@inheritDoc} * - *

    Three capabilities, and each omission is a fact about {@link InMemoryProvider} rather than a + *

    Four capabilities. {@link Capability#VARIANTS} is the one that is not obvious: + * {@link InMemoryProvider} does name the variant it served, so the gated variant outline runs + * and passes here. Each omission below is a fact about {@link InMemoryProvider} rather than a * convenience: * *

      @@ -76,8 +78,13 @@ public FeatureProvider createProvider() { *
    • {@link Capability#UNAVAILABLE_INIT} — omitted. Initialisation cannot fail when there is * nothing to connect to, so * {@link ProviderTckHarness#createUnavailableProvider()} is left at its throwing default. - *
    • {@link Capability#TARGETING} and {@link Capability#CACHING} — omitted because no - * scenario carries their tags yet. Nothing is skipped by leaving them out today. + *
    • {@link Capability#TARGETING} — omitted. {@link InMemoryProvider} evaluates no rules: it + * reads a flag's {@code variants} and {@code defaultVariant} and returns the default one, + * so the {@code targeting} member of {@code targeting-key-flag} is inert here and a + * matching context resolves {@code miss} like any other. The three scenarios are skipped + * with that reason rather than failed, which is what the tag is for. + *
    • {@link Capability#CACHING} — reserved, so not declarable and nothing is skipped by + * leaving it out. *
    • {@link Capability#LARGE_INTEGERS} — omitted, as every Java provider omits it. The tag * asks for 2^53 − 1 and {@code Client.getIntegerDetails} is a 32-bit {@link Integer}, so * the limit is the SDK's rather than this provider's; Appendix F is where that is @@ -86,6 +93,6 @@ public FeatureProvider createProvider() { */ @Override public Set capabilities() { - return EnumSet.of(Capability.EVENTS, Capability.CONFIGURATION_CHANGE, Capability.OBJECT); + return EnumSet.of(Capability.EVENTS, Capability.CONFIGURATION_CHANGE, Capability.OBJECT, Capability.VARIANTS); } } diff --git a/tools/provider-tck/src/test/java/dev/openfeature/contrib/tools/providertck/MultiProviderTckTest.java b/tools/provider-tck/src/test/java/dev/openfeature/contrib/tools/providertck/MultiProviderTckTest.java index 18c0d94c74..55e88e9761 100644 --- a/tools/provider-tck/src/test/java/dev/openfeature/contrib/tools/providertck/MultiProviderTckTest.java +++ b/tools/provider-tck/src/test/java/dev/openfeature/contrib/tools/providertck/MultiProviderTckTest.java @@ -68,14 +68,16 @@ public FeatureProvider createProvider() { * *

      Everything else holds. Values, variants, reasons, the full type-mismatch matrix, * {@code FLAG_NOT_FOUND}, falsy values, 32-bit integer precision and structured values all - * survive the delegation hop unchanged. {@link Capability#LIFECYCLE} and - * {@link Capability#NUMERIC_COERCION} are omitted for the same reasons as in - * {@link InMemoryProviderTckTest}: nothing here reaches a backend during initialisation, and the - * child refuses the lossless coercions the tag now requires — a facade cannot declare what its - * only child does not have. + * survive the delegation hop unchanged — {@link Capability#VARIANTS} is declared for exactly + * that reason, and a variant lost in delegation is one of the likelier ways a facade breaks the + * contract. {@link Capability#LIFECYCLE} and {@link Capability#NUMERIC_COERCION} are omitted for + * the same reasons as in {@link InMemoryProviderTckTest}: nothing here reaches a backend during + * initialisation, and the child refuses the lossless coercions the tag now requires — a facade + * cannot declare what its only child does not have. {@link Capability#TARGETING} is omitted for + * the same reason again: the child evaluates no rules, so there is no targeting to delegate. */ @Override public Set capabilities() { - return EnumSet.of(Capability.EVENTS, Capability.OBJECT); + return EnumSet.of(Capability.EVENTS, Capability.OBJECT, Capability.VARIANTS); } } From a205248f9ecc7f9898920a43cd596a25f07ceb96 Mon Sep 17 00:00:00 2001 From: Simon Schrottner Date: Sat, 12 Sep 2026 12:22:58 +0200 Subject: [PATCH 25/55] feat(provider-tck): gate a disabled flag on @disabled-flags Follows the spec submodule to 009afe06, which adds one Scenario Outline and the four disabled-* flags it needs. The canonical set goes from 52 scenarios to 56, and the canonical flag set from 14 flags to 18. @disabled-flags is an ordinary declarable capability, not a reserved one, and it is gated for a reason that is not about provider quality: what a disabled flag resolves to depends on where the substitution happens. A provider that evaluates locally holds the caller's default and can return it; one whose backend decides never sends it, so the server has nothing to echo back. Neither is wrong, so withholding the tag needs no KnownDeviation. The scenarios assert the value and the absence of an error, never the reason. The value rests on Requirement 2.2.3, a MUST; reason DISABLED would rest on 2.2.5, a SHOULD that permits "some other string". No variant is asserted either -- a disabled flag resolved none -- so @disabled-flags and @variants do not compose. CanonicalFlagsTest needed teaching about state. It iterates the packaged definition and asserted that every flag resolves to the value of the variant it names, which four flags in the set now deliberately do not do. Rather than dropping the four, the assertion reads `state` and flips to the other half of the same comparison: the fallback it already had to pick to be distinct from the configured value is exactly what a disabled flag must resolve to. A decoder that dropped the state on the floor now fails there, rather than later in a scenario against a provider that did nothing wrong. Both self-test suites declare the capability, measured rather than assumed: the SDK's InMemoryProvider honours a flag's state and hands back the caller's default with no error code, so all four rows pass, and they survive MultiProvider's delegation hop unchanged. The in-memory suite goes from 38 passing of 52 to 42 of 56 with the same fourteen skips. Signed-off-by: Simon Schrottner --- tools/provider-tck/README.md | 31 +++++--- tools/provider-tck/spec | 2 +- .../contrib/tools/providertck/Capability.java | 33 ++++++++ .../tools/providertck/CanonicalFlagsTest.java | 76 ++++++++++++++----- .../providertck/InMemoryProviderTckTest.java | 17 ++++- .../providertck/MultiProviderTckTest.java | 9 ++- 6 files changed, 133 insertions(+), 35 deletions(-) diff --git a/tools/provider-tck/README.md b/tools/provider-tck/README.md index 8bbd11ae61..6c3c91e31f 100644 --- a/tools/provider-tck/README.md +++ b/tools/provider-tck/README.md @@ -54,14 +54,19 @@ uses only long-stable API — `OpenFeatureAPI`, `Client`, typed evaluation, `Pro - that the provider identifies itself by a non-empty metadata name - that supplying an evaluation context does not disturb an untargeted resolution, and — gated on `@targeting` — that a matching context resolves the targeted variant +- gated on `@disabled-flags`, that a flag disabled in the management system resolves to the code + default rather than to its configured value, and without an error. Gated because the answer depends + on where the substitution happens: a provider that evaluates locally holds the caller's default and + can return it, one whose backend decides never sends it and cannot **Out of scope — not the provider's contract:** -- backend evaluation logic, bucketing and rule-language correctness. Every flag in the canonical set - except `targeting-key-flag` resolves to its default variant whatever the context, so what is under - test is the provider's mapping of a response, not the backend's decision. That one carries the one - rule, and it is there to prove the context reached the backend rather than to test how the backend - evaluated it. +- backend evaluation logic, bucketing and rule-language correctness. Every enabled flag in the + canonical set except `targeting-key-flag` resolves to its default variant whatever the context, so + what is under test is the provider's mapping of a response, not the backend's decision. That one + carries the one rule, and it is there to prove the context reached the backend rather than to test + how the backend evaluated it. The four `disabled-*` flags are the only ones whose state is not + `ENABLED`; they resolve to nothing at all. - the provider↔backend wire protocol. How you talk to your backend is your business. - SDK behaviour. That belongs to the SDK's own test suite. @@ -147,15 +152,18 @@ Two suites in this module are exactly the class above, and both run with no Dock second. They are the reference adoption, and they are the fast CI canary. [`InMemoryProviderTckTest`](src/test/java/dev/openfeature/contrib/tools/providertck/InMemoryProviderTckTest.java) -runs the full applicable suite against the SDK's `InMemoryProvider` — of the 52 scenarios (outline -rows counted individually), 38 pass and 14 are skipped by capability: the six `@lifecycle` ones — one +runs the full applicable suite against the SDK's `InMemoryProvider` — of the 56 scenarios (outline +rows counted individually), 42 pass and 14 are skipped by capability: the six `@lifecycle` ones — one of which also carries `@reinitialization`, and is skipped for the first of the two — the `@stale` one, the three `@numeric-coercion` ones, the `@large-integers` one and the three `@targeting` ones. It declares `VARIANTS`, because `InMemoryProvider` does name the variant it served, so the gated -variant outline runs rather than being skipped. It does not declare `TARGETING`: the provider reads a -flag's `variants` and `defaultVariant` and evaluates no rules, so `targeting-key-flag`'s `targeting` -member is inert and a matching context resolves `miss` like any other. It does not declare -`NUMERIC_COERCION`, because `InMemoryProvider` keeps the two numeric types strictly apart in both +variant outline runs rather than being skipped. It declares `DISABLED_FLAGS` too, on the same kind of +evidence: the provider honours a flag's state, so the four `disabled-*` flags resolve to nothing, the +caller's default stands in with no error code, and all four rows of that outline pass. It does not +declare `TARGETING`: the provider reads a flag's `variants` and `defaultVariant` and evaluates no +rules, so `targeting-key-flag`'s `targeting` member is inert and a matching context resolves `miss` +like any other. It does not declare `NUMERIC_COERCION`, because `InMemoryProvider` keeps the two +numeric types strictly apart in both directions — it refuses `10.0` as an integer and `10` as a float exactly as it refuses `0.5` — and the tag requires the lossless direction too. That is a choice the SDK's reference provider is entitled to, not a defect; see the class javadoc. @@ -431,6 +439,7 @@ green on scenarios it did not run is worse than no suite at all. | `CONFIGURATION_CHANGE` | `@configuration-change` | detects config changes, emits `PROVIDER_CONFIGURATION_CHANGED` | | `OBJECT` | `@object` | supports structured flag values | | `VARIANTS` | `@variants` | names the variant it resolved — [Requirement 2.2.4](https://github.com/open-feature/spec/blob/main/specification/sections/02-providers.md) is a `SHOULD` and `types.md` types the field optional, so a backend with no variant concept withholds it | +| `DISABLED_FLAGS` | `@disabled-flags` | resolves a flag disabled in the management system to the code default — *needs the substitution to happen where the caller's default is, so a provider whose backend decides cannot hold it* | | `UNAVAILABLE_INIT` | `@unavailable` | reports an error state instead of hanging on a dead backend — *needs connection control* | | `NUMERIC_COERCION` | `@numeric-coercion` | coerces between integer and float only when lossless, else `TYPE_MISMATCH` — both directions tested | | `LARGE_INTEGERS` | `@large-integers` | resolves integers up to 2^53 − 1 exactly; **every Java provider withholds it** — the SDK's integer accessor is a 32-bit `Integer`, so the limit is the language's, not the provider's | diff --git a/tools/provider-tck/spec b/tools/provider-tck/spec index 26362f85b7..009afe0617 160000 --- a/tools/provider-tck/spec +++ b/tools/provider-tck/spec @@ -1 +1 @@ -Subproject commit 26362f85b7fcd59b35b969e6feebee80e206b24f +Subproject commit 009afe0617947121dcbebe4b66e0cc0c5cc4ada8 diff --git a/tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/Capability.java b/tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/Capability.java index d27065d235..70400aee7a 100644 --- a/tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/Capability.java +++ b/tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/Capability.java @@ -155,6 +155,39 @@ public enum Capability { */ VARIANTS("@variants"), + /** + * Provider resolves a flag disabled in the management system to the caller's default value. + * + *

      Gates one Scenario Outline, four rows: {@code disabled-boolean-flag}, + * {@code disabled-string-flag}, {@code disabled-integer-flag} and {@code disabled-float-flag}, + * each asked for with a default that differs from the value the flag is configured with. A + * provider that ignores the state serves the configured value and is caught on the value alone. + * + *

      Gated because the answer is a property of architecture rather than of quality. + * Where the substitution happens decides whether it can happen at all. A provider that evaluates + * locally — flagd's RPC and in-process resolvers, an in-memory provider — holds the caller's + * default in its own hands and can return it. A provider whose backend decides, one speaking + * OFREP for instance, cannot: the default never leaves the process, so the server has nothing to + * echo back and the provider has nothing to substitute. The same flag cannot behave the same way + * across those two designs, and neither of them is wrong, so withholding this needs no + * {@link KnownDeviation}. + * + *

      Nothing in the specification says what a provider owes a disabled flag. + * Requirement + * 1.4.7 is about the SDK propagating whatever reason arrived, and + * Requirement + * 2.2.5 only lists {@code DISABLED} among the reason strings a provider may use. So + * Appendix + * F states the behaviour, as it does for {@link #NUMERIC_COERCION}, and gates it. + * + *

      The value is asserted, not the reason. The value rests on Requirement + * 2.2.3, a {@code MUST}; pinning reason {@code DISABLED} would rest on 2.2.5, a {@code SHOULD} + * that explicitly permits "some other string". No variant is asserted either — a disabled flag + * resolved no variant, so there is none to name, and this capability and {@link #VARIANTS} + * deliberately do not compose. + */ + DISABLED_FLAGS("@disabled-flags"), + /** Provider reports an error state rather than hanging when initialised against a dead backend. */ UNAVAILABLE_INIT("@unavailable"), diff --git a/tools/provider-tck/src/test/java/dev/openfeature/contrib/tools/providertck/CanonicalFlagsTest.java b/tools/provider-tck/src/test/java/dev/openfeature/contrib/tools/providertck/CanonicalFlagsTest.java index 4599e5513d..696f4bc3c7 100644 --- a/tools/provider-tck/src/test/java/dev/openfeature/contrib/tools/providertck/CanonicalFlagsTest.java +++ b/tools/provider-tck/src/test/java/dev/openfeature/contrib/tools/providertck/CanonicalFlagsTest.java @@ -31,8 +31,11 @@ * *

      The comparison is deliberately not a value table written out here. A table is another * transcription, drifts the same way, and a test that compares two copies of the same mistake passes. - * Every expectation comes out of the file instead: the keys it defines, and for each the value of the - * variant it says the flag resolves to. + * Every expectation comes out of the file instead: the keys it defines, and for each the state it is + * in and the value of the variant it says the flag resolves to. The state matters as much as the + * value now that the set contains four {@code disabled-*} flags, which resolve to nothing at all — + * a decoder that ignored {@code state} would serve their configured values and look correct against + * a variant table. * *

      The values are read back through an {@link InMemoryProvider} rather than off the decoded map, * because the provider matches a variant against the type of the accessor it was asked through. That @@ -85,13 +88,14 @@ void theDecodedSetHasExactlyThePackagedKeys() { } @Test - @DisplayName("every flag resolves to the value of the variant the packaged definition names") + @DisplayName("every flag resolves as its packaged state and default variant say it should") void everyFlagResolvesToItsPackagedDefaultVariant() throws Exception { InMemoryProvider provider = new InProcessBackendControl().createProvider(); provider.initialize(new ImmutableContext()); ImmutableContext context = new ImmutableContext(); List checked = new ArrayList<>(); + List disabled = new ArrayList<>(); for (Map.Entry entry : packaged.entrySet()) { String key = entry.getKey(); String defaultVariant = entry.getValue().path("defaultVariant").asText(null); @@ -104,13 +108,27 @@ void everyFlagResolvesToItsPackagedDefaultVariant() throws Exception { .as("%s resolves to variant '%s', which the definition does not define", key, defaultVariant) .isFalse(); - assertResolves(provider, context, key, defaultVariant, expected); + String state = entry.getValue().path("state").asText(null); + assertThat(state) + .as("%s names no state, so the definition itself is broken", key) + .isIn("ENABLED", "DISABLED"); + + boolean enabled = "ENABLED".equals(state); + assertResolves(provider, context, key, defaultVariant, expected, enabled); checked.add(key); + if (!enabled) { + disabled.add(key); + } } assertThat(checked) .as("the loop must actually have run over the packaged flags") .hasSameSizeAs(packaged.keySet()); + + assertThat(disabled) + .as("the disabled half of the assertion has to have been exercised, or a decoder that " + + "dropped the state would pass here unnoticed") + .isNotEmpty(); } /** @@ -121,35 +139,50 @@ void everyFlagResolvesToItsPackagedDefaultVariant() throws Exception { * the accessor is deliberately never the expected value: {@code boolean-zero-flag} resolves to * {@code false} and {@code string-zero-flag} to {@code ""}, and a comparison whose fallback * happened to equal the answer would pass on a flag that had gone missing. + * + *

      That property is also what lets one dispatch serve both states. A flag the definition marks + * {@code DISABLED} resolves to nothing at all — the caller's default stands in — so the expected + * answer is precisely the fallback this already had to pick to be distinct, and {@code enabled} + * only chooses which of the two the resolution must equal. The four {@code disabled-*} flags are + * the whole reason the parameter exists: a decoder that dropped {@code state} on the floor would + * make them serve their configured values, and that is what fails here rather than + * later, in a scenario, against a provider that did nothing wrong. */ private static void assertResolves( - InMemoryProvider provider, ImmutableContext context, String key, String variant, JsonNode expected) { - - String where = key + " variant '" + variant + "'"; + InMemoryProvider provider, + ImmutableContext context, + String key, + String variant, + JsonNode expected, + boolean enabled) { + + String where = key + " variant '" + variant + "'" + (enabled ? "" : ", disabled so the default stands in"); switch (expected.getNodeType()) { case BOOLEAN: boolean bool = expected.booleanValue(); assertThat(provider.getBooleanEvaluation(key, !bool, context).getValue()) .as(where) - .isEqualTo(bool); + .isEqualTo(enabled ? bool : !bool); break; case STRING: String string = expected.textValue(); - assertThat(provider.getStringEvaluation(key, string + "-fallback", context) + String stringFallback = string + "-fallback"; + assertThat(provider.getStringEvaluation(key, stringFallback, context) .getValue()) .as(where) - .isEqualTo(string); + .isEqualTo(enabled ? string : stringFallback); break; case NUMBER: - assertNumberResolves(provider, context, key, where, expected); + assertNumberResolves(provider, context, key, where, expected, enabled); break; case OBJECT: case ARRAY: Value structure = Value.objectToValue(MAPPER.convertValue(expected, Object.class)); - assertThat(provider.getObjectEvaluation(key, new Value("fallback"), context) + Value objectFallback = new Value("fallback"); + assertThat(provider.getObjectEvaluation(key, objectFallback, context) .getValue()) .as(where) - .isEqualTo(structure); + .isEqualTo(enabled ? structure : objectFallback); break; default: fail("%s is a %s, which is not a flag value", where, expected.getNodeType()); @@ -165,25 +198,34 @@ private static void assertResolves( * refuses a variant of the other type, so this is where a decoder that widened or narrowed a * number fails. 2^53 − 1 has no room in an {@link Integer} and goes through the long accessor, * which is also the reason a Java provider leaves {@code @large-integers} undeclared. + * + *

      The accessor is still chosen by the literal for a disabled flag, so a {@code disabled-*} + * flag whose type was mangled by the decoder is caught the same way: the answer is then neither + * the configured value nor the fallback. */ private static void assertNumberResolves( - InMemoryProvider provider, ImmutableContext context, String key, String where, JsonNode expected) { + InMemoryProvider provider, + ImmutableContext context, + String key, + String where, + JsonNode expected, + boolean enabled) { if (!expected.isIntegralNumber()) { double value = expected.doubleValue(); assertThat(provider.getDoubleEvaluation(key, value + 1, context).getValue()) .as(where) - .isEqualTo(value); + .isEqualTo(enabled ? value : value + 1); } else if (expected.canConvertToInt()) { int value = expected.intValue(); assertThat(provider.getIntegerEvaluation(key, value + 1, context).getValue()) .as(where) - .isEqualTo(value); + .isEqualTo(enabled ? value : value + 1); } else { long value = expected.longValue(); assertThat(provider.getLongEvaluation(key, value + 1, context).getValue()) .as(where) - .isEqualTo(value); + .isEqualTo(enabled ? value : value + 1); } } } diff --git a/tools/provider-tck/src/test/java/dev/openfeature/contrib/tools/providertck/InMemoryProviderTckTest.java b/tools/provider-tck/src/test/java/dev/openfeature/contrib/tools/providertck/InMemoryProviderTckTest.java index 641214cf00..10fbcad890 100644 --- a/tools/provider-tck/src/test/java/dev/openfeature/contrib/tools/providertck/InMemoryProviderTckTest.java +++ b/tools/provider-tck/src/test/java/dev/openfeature/contrib/tools/providertck/InMemoryProviderTckTest.java @@ -47,10 +47,14 @@ public FeatureProvider createProvider() { /** * {@inheritDoc} * - *

      Four capabilities. {@link Capability#VARIANTS} is the one that is not obvious: + *

      Five capabilities, two of which are not obvious. {@link Capability#VARIANTS} holds because * {@link InMemoryProvider} does name the variant it served, so the gated variant outline runs - * and passes here. Each omission below is a fact about {@link InMemoryProvider} rather than a - * convenience: + * and passes here. {@link Capability#DISABLED_FLAGS} holds because it honours a flag's state: the + * four {@code disabled-*} flags resolve to nothing and the caller's default stands in, with no + * error code, so all four rows of that outline pass. That is not a given for an in-memory + * provider — the capability is gated precisely because whether the substitution can happen at all + * depends on where it happens — and it was measured rather than assumed. Each omission below is a + * fact about {@link InMemoryProvider} rather than a convenience: * *

        *
      • {@link Capability#NUMERIC_COERCION} — omitted. {@link InMemoryProvider} keeps the two @@ -93,6 +97,11 @@ public FeatureProvider createProvider() { */ @Override public Set capabilities() { - return EnumSet.of(Capability.EVENTS, Capability.CONFIGURATION_CHANGE, Capability.OBJECT, Capability.VARIANTS); + return EnumSet.of( + Capability.EVENTS, + Capability.CONFIGURATION_CHANGE, + Capability.OBJECT, + Capability.VARIANTS, + Capability.DISABLED_FLAGS); } } diff --git a/tools/provider-tck/src/test/java/dev/openfeature/contrib/tools/providertck/MultiProviderTckTest.java b/tools/provider-tck/src/test/java/dev/openfeature/contrib/tools/providertck/MultiProviderTckTest.java index 55e88e9761..b59c059b72 100644 --- a/tools/provider-tck/src/test/java/dev/openfeature/contrib/tools/providertck/MultiProviderTckTest.java +++ b/tools/provider-tck/src/test/java/dev/openfeature/contrib/tools/providertck/MultiProviderTckTest.java @@ -70,7 +70,12 @@ public FeatureProvider createProvider() { * {@code FLAG_NOT_FOUND}, falsy values, 32-bit integer precision and structured values all * survive the delegation hop unchanged — {@link Capability#VARIANTS} is declared for exactly * that reason, and a variant lost in delegation is one of the likelier ways a facade breaks the - * contract. {@link Capability#LIFECYCLE} and {@link Capability#NUMERIC_COERCION} are omitted for + * contract. {@link Capability#DISABLED_FLAGS} is declared on the same evidence and is the more + * interesting of the two: a disabled flag resolves to nothing, so the child hands back the + * caller's default and a facade that substituted a default of its own, or that read the absence + * as an error, would be caught on the value. All four rows pass, so the substitution survives the + * hop exactly as the child performs it. {@link Capability#LIFECYCLE} and + * {@link Capability#NUMERIC_COERCION} are omitted for * the same reasons as in {@link InMemoryProviderTckTest}: nothing here reaches a backend during * initialisation, and the child refuses the lossless coercions the tag now requires — a facade * cannot declare what its only child does not have. {@link Capability#TARGETING} is omitted for @@ -78,6 +83,6 @@ public FeatureProvider createProvider() { */ @Override public Set capabilities() { - return EnumSet.of(Capability.EVENTS, Capability.OBJECT, Capability.VARIANTS); + return EnumSet.of(Capability.EVENTS, Capability.OBJECT, Capability.VARIANTS, Capability.DISABLED_FLAGS); } } From 2a0bb81c1e195cf998f8d276b8b505b61b8f74d9 Mon Sep 17 00:00:00 2001 From: Simon Schrottner Date: Sat, 12 Sep 2026 13:52:08 +0200 Subject: [PATCH 26/55] refactor(tck): rename provider-tck to tck, and start at 0.1.0 Settled across all four languages on 2026-09-10, from erka's request on go-sdk-contrib#940: `provider-tck` names the thing after what it tests rather than what it is. A flat `tck` with an options-shaped entry point leaves room for a `tck` that tests something other than a provider later, instead of a second `hook-tck` duplicating the harness. tools/provider-tck -> tools/tck dev.openfeature.contrib.tools:provider-tck -> dev.openfeature.contrib.tools:tck dev.openfeature.contrib.tools.providertck -> dev.openfeature.contrib.tools.tck 0.0.1 -> 0.1.0 A module rename here touches more than the module: the reactor's module list, the release-please config and manifest, the component-owners entry, the CI job that runs the Docker-free canary, and the `spec` submodule's path and section name in .gitmodules. Nothing is published anywhere yet, so this is free now and expensive later. Not renamed, deliberately: - `openfeature.tck.extensions`, the test-scoped glue package an adopter writes step definitions in. It is already short and already `tck`, and it is named in Appendix F. - the class names -- ProviderTckTest, ContainerizedProviderTckTest, ProviderTckHarness. They name what they are a base class for, which is still a provider. - `specification/assets/provider-tck/`, which is the path in open-feature/spec and not ours to rename. The pom's copy-* execution ids keep naming the directory they copy from. - the `feat/provider-tck*` branch filter in ci.yml, which matches the branch names this stack actually uses. Signed-off-by: Simon Schrottner --- .github/component_owners.yml | 2 +- .github/workflows/ci.yml | 4 ++-- .gitmodules | 4 ++-- .release-please-manifest.json | 2 +- pom.xml | 2 +- release-please-config.json | 4 ++-- ...junit.platform.launcher.TestExecutionListener | 1 - tools/provider-tck/version.txt | 1 - tools/{provider-tck => tck}/.gitignore | 0 tools/{provider-tck => tck}/README.md | 16 ++++++++-------- tools/{provider-tck => tck}/lombok.config | 0 tools/{provider-tck => tck}/pom.xml | 10 +++++----- tools/{provider-tck => tck}/spec | 0 .../contrib/tools/tck}/BackendControl.java | 2 +- .../contrib/tools/tck}/BackendEndpoint.java | 2 +- .../contrib/tools/tck}/CanonicalFlags.java | 4 ++-- .../contrib/tools/tck}/Capability.java | 2 +- .../contrib/tools/tck}/CapabilityGate.java | 2 +- .../tools/tck}/ContainerizedProviderTckTest.java | 2 +- .../contrib/tools/tck}/FlagUnderTest.java | 2 +- .../contrib/tools/tck}/HttpBackendControl.java | 2 +- .../tools/tck}/InProcessBackendControl.java | 2 +- .../contrib/tools/tck}/KnownDeviation.java | 2 +- .../contrib/tools/tck}/ProviderEventRecord.java | 2 +- .../contrib/tools/tck}/ProviderTck.java | 4 ++-- .../contrib/tools/tck}/ProviderTckHarness.java | 2 +- .../contrib/tools/tck}/ProviderTckTest.java | 4 ++-- .../contrib/tools/tck}/ReportNames.java | 4 ++-- .../contrib/tools/tck}/TckRuntime.java | 2 +- .../openfeature/contrib/tools/tck}/TckState.java | 2 +- .../contrib/tools/tck}/TckSuiteListener.java | 2 +- .../contrib/tools/tck}/TckValues.java | 2 +- .../contrib/tools/tck}/steps/AbstractSteps.java | 10 +++++----- .../contrib/tools/tck}/steps/ContextSteps.java | 4 ++-- .../contrib/tools/tck}/steps/EventSteps.java | 8 ++++---- .../contrib/tools/tck}/steps/FlagSteps.java | 10 +++++----- .../contrib/tools/tck}/steps/ProviderSteps.java | 16 ++++++++-------- ...junit.platform.launcher.TestExecutionListener | 1 + .../src/main/resources/extensions/README.md | 2 +- .../contrib/tools/tck}/CanonicalFlagsTest.java | 2 +- .../contrib/tools/tck}/DeclarationApiTest.java | 2 +- .../contrib/tools/tck}/ExtensionPointTest.java | 5 ++--- .../tools/tck}/InMemoryProviderTckTest.java | 2 +- .../tools/tck}/InProcessBackendControlTest.java | 2 +- .../contrib/tools/tck}/MultiProviderTckTest.java | 2 +- .../contrib/tools/tck}/TckSuiteFixture.java | 2 +- .../tck/extensions/ExtensionSelfTestSteps.java | 2 +- .../extensions/extension-selftest.feature | 0 tools/tck/version.txt | 1 + 49 files changed, 81 insertions(+), 82 deletions(-) delete mode 100644 tools/provider-tck/src/main/resources/META-INF/services/org.junit.platform.launcher.TestExecutionListener delete mode 100644 tools/provider-tck/version.txt rename tools/{provider-tck => tck}/.gitignore (100%) rename tools/{provider-tck => tck}/README.md (99%) rename tools/{provider-tck => tck}/lombok.config (100%) rename tools/{provider-tck => tck}/pom.xml (98%) rename tools/{provider-tck => tck}/spec (100%) rename tools/{provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck => tck/src/main/java/dev/openfeature/contrib/tools/tck}/BackendControl.java (99%) rename tools/{provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck => tck/src/main/java/dev/openfeature/contrib/tools/tck}/BackendEndpoint.java (98%) rename tools/{provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck => tck/src/main/java/dev/openfeature/contrib/tools/tck}/CanonicalFlags.java (99%) rename tools/{provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck => tck/src/main/java/dev/openfeature/contrib/tools/tck}/Capability.java (99%) rename tools/{provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck => tck/src/main/java/dev/openfeature/contrib/tools/tck}/CapabilityGate.java (98%) rename tools/{provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck => tck/src/main/java/dev/openfeature/contrib/tools/tck}/ContainerizedProviderTckTest.java (99%) rename tools/{provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck => tck/src/main/java/dev/openfeature/contrib/tools/tck}/FlagUnderTest.java (96%) rename tools/{provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck => tck/src/main/java/dev/openfeature/contrib/tools/tck}/HttpBackendControl.java (99%) rename tools/{provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck => tck/src/main/java/dev/openfeature/contrib/tools/tck}/InProcessBackendControl.java (99%) rename tools/{provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck => tck/src/main/java/dev/openfeature/contrib/tools/tck}/KnownDeviation.java (98%) rename tools/{provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck => tck/src/main/java/dev/openfeature/contrib/tools/tck}/ProviderEventRecord.java (96%) rename tools/{provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck => tck/src/main/java/dev/openfeature/contrib/tools/tck}/ProviderTck.java (98%) rename tools/{provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck => tck/src/main/java/dev/openfeature/contrib/tools/tck}/ProviderTckHarness.java (99%) rename tools/{provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck => tck/src/main/java/dev/openfeature/contrib/tools/tck}/ProviderTckTest.java (97%) rename tools/{provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck => tck/src/main/java/dev/openfeature/contrib/tools/tck}/ReportNames.java (94%) rename tools/{provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck => tck/src/main/java/dev/openfeature/contrib/tools/tck}/TckRuntime.java (99%) rename tools/{provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck => tck/src/main/java/dev/openfeature/contrib/tools/tck}/TckState.java (98%) rename tools/{provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck => tck/src/main/java/dev/openfeature/contrib/tools/tck}/TckSuiteListener.java (98%) rename tools/{provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck => tck/src/main/java/dev/openfeature/contrib/tools/tck}/TckValues.java (98%) rename tools/{provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck => tck/src/main/java/dev/openfeature/contrib/tools/tck}/steps/AbstractSteps.java (79%) rename tools/{provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck => tck/src/main/java/dev/openfeature/contrib/tools/tck}/steps/ContextSteps.java (96%) rename tools/{provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck => tck/src/main/java/dev/openfeature/contrib/tools/tck}/steps/EventSteps.java (94%) rename tools/{provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck => tck/src/main/java/dev/openfeature/contrib/tools/tck}/steps/FlagSteps.java (97%) rename tools/{provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck => tck/src/main/java/dev/openfeature/contrib/tools/tck}/steps/ProviderSteps.java (95%) create mode 100644 tools/tck/src/main/resources/META-INF/services/org.junit.platform.launcher.TestExecutionListener rename tools/{provider-tck => tck}/src/main/resources/extensions/README.md (95%) rename tools/{provider-tck/src/test/java/dev/openfeature/contrib/tools/providertck => tck/src/test/java/dev/openfeature/contrib/tools/tck}/CanonicalFlagsTest.java (99%) rename tools/{provider-tck/src/test/java/dev/openfeature/contrib/tools/providertck => tck/src/test/java/dev/openfeature/contrib/tools/tck}/DeclarationApiTest.java (99%) rename tools/{provider-tck/src/test/java/dev/openfeature/contrib/tools/providertck => tck/src/test/java/dev/openfeature/contrib/tools/tck}/ExtensionPointTest.java (97%) rename tools/{provider-tck/src/test/java/dev/openfeature/contrib/tools/providertck => tck/src/test/java/dev/openfeature/contrib/tools/tck}/InMemoryProviderTckTest.java (99%) rename tools/{provider-tck/src/test/java/dev/openfeature/contrib/tools/providertck => tck/src/test/java/dev/openfeature/contrib/tools/tck}/InProcessBackendControlTest.java (99%) rename tools/{provider-tck/src/test/java/dev/openfeature/contrib/tools/providertck => tck/src/test/java/dev/openfeature/contrib/tools/tck}/MultiProviderTckTest.java (98%) rename tools/{provider-tck/src/test/java/dev/openfeature/contrib/tools/providertck => tck/src/test/java/dev/openfeature/contrib/tools/tck}/TckSuiteFixture.java (96%) rename tools/{provider-tck => tck}/src/test/java/openfeature/tck/extensions/ExtensionSelfTestSteps.java (94%) rename tools/{provider-tck => tck}/src/test/resources/extensions/extension-selftest.feature (100%) create mode 100644 tools/tck/version.txt diff --git a/.github/component_owners.yml b/.github/component_owners.yml index 90a018aa95..2d20b2b5ea 100644 --- a/.github/component_owners.yml +++ b/.github/component_owners.yml @@ -46,7 +46,7 @@ components: - toddbaert tools/flagd-http-connector: - liran2000 - tools/provider-tck: + tools/tck: - aepfli ignored-authors: diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 794087d34e..1d3ea4c334 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,7 +26,7 @@ jobs: # Deliberately not a gate on `main`: the two run in parallel so a green run is not delayed. # The same suite also runs inside `main` as part of the reactor build; this job exists to # report it fast and in isolation. - provider-tck: + tck: name: Provider TCK (no Docker) runs-on: ubuntu-latest steps: @@ -54,7 +54,7 @@ jobs: - name: Verify the TCK against the in-memory provider # No `e2e` profile and no Docker: the in-memory suite is not gated behind either. - run: mvn --batch-mode --activate-profiles codequality -pl tools/provider-tck -am clean verify + run: mvn --batch-mode --activate-profiles codequality -pl tools/tck -am clean verify main: strategy: diff --git a/.gitmodules b/.gitmodules index cb1cd9a414..d9b390dac7 100644 --- a/.gitmodules +++ b/.gitmodules @@ -18,6 +18,6 @@ path = tools/flagd-api-testkit/test-harness url = https://github.com/open-feature/test-harness.git branch = v3.10.1 -[submodule "tools/provider-tck/spec"] - path = tools/provider-tck/spec +[submodule "tools/tck/spec"] + path = tools/tck/spec url = https://github.com/open-feature/spec.git diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 4fbee09ec2..b5c49cb438 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -17,7 +17,7 @@ "tools/flagd-http-connector": "0.0.5", "tools/flagd-api": "1.0.0", "tools/flagd-api-testkit": "0.2.1", - "tools/provider-tck": "0.0.1", + "tools/tck": "0.1.0", "tools/flagd-core": "2.0.1", ".": "1.0.0", "providers/optimizely": "1.0.0" diff --git a/pom.xml b/pom.xml index ee48359381..bf7b0f7e58 100644 --- a/pom.xml +++ b/pom.xml @@ -28,7 +28,7 @@ - tools/provider-tck + tools/tck tools/flagd-api-testkit tools/flagd-api tools/flagd-core diff --git a/release-please-config.json b/release-please-config.json index b67946170b..54129737e7 100644 --- a/release-please-config.json +++ b/release-please-config.json @@ -205,8 +205,8 @@ "README.md" ] }, - "tools/provider-tck": { - "package-name": "dev.openfeature.contrib.tools.providertck", + "tools/tck": { + "package-name": "dev.openfeature.contrib.tools.tck", "release-type": "simple", "bump-minor-pre-major": true, "bump-patch-for-minor-pre-major": true, diff --git a/tools/provider-tck/src/main/resources/META-INF/services/org.junit.platform.launcher.TestExecutionListener b/tools/provider-tck/src/main/resources/META-INF/services/org.junit.platform.launcher.TestExecutionListener deleted file mode 100644 index 407c53b6a9..0000000000 --- a/tools/provider-tck/src/main/resources/META-INF/services/org.junit.platform.launcher.TestExecutionListener +++ /dev/null @@ -1 +0,0 @@ -dev.openfeature.contrib.tools.providertck.TckSuiteListener diff --git a/tools/provider-tck/version.txt b/tools/provider-tck/version.txt deleted file mode 100644 index 8acdd82b76..0000000000 --- a/tools/provider-tck/version.txt +++ /dev/null @@ -1 +0,0 @@ -0.0.1 diff --git a/tools/provider-tck/.gitignore b/tools/tck/.gitignore similarity index 100% rename from tools/provider-tck/.gitignore rename to tools/tck/.gitignore diff --git a/tools/provider-tck/README.md b/tools/tck/README.md similarity index 99% rename from tools/provider-tck/README.md rename to tools/tck/README.md index 6c3c91e31f..d90f0151a9 100644 --- a/tools/provider-tck/README.md +++ b/tools/tck/README.md @@ -16,8 +16,8 @@ contract" is an unverified claim. This is the shared suite that makes it checkab ```xml dev.openfeature.contrib.tools - provider-tck - 0.0.1 + tck + 0.1.0 test ``` @@ -151,7 +151,7 @@ reaching one from a scenario that actually ran is a **test-configuration bug**, Two suites in this module are exactly the class above, and both run with no Docker in well under a second. They are the reference adoption, and they are the fast CI canary. -[`InMemoryProviderTckTest`](src/test/java/dev/openfeature/contrib/tools/providertck/InMemoryProviderTckTest.java) +[`InMemoryProviderTckTest`](src/test/java/dev/openfeature/contrib/tools/tck/InMemoryProviderTckTest.java) runs the full applicable suite against the SDK's `InMemoryProvider` — of the 56 scenarios (outline rows counted individually), 42 pass and 14 are skipped by capability: the six `@lifecycle` ones — one of which also carries `@reinitialization`, and is skipped for the first of the two — the `@stale` @@ -168,7 +168,7 @@ directions — it refuses `10.0` as an integer and `10` as a float exactly as it tag requires the lossless direction too. That is a choice the SDK's reference provider is entitled to, not a defect; see the class javadoc. -[`MultiProviderTckTest`](src/test/java/dev/openfeature/contrib/tools/providertck/MultiProviderTckTest.java) +[`MultiProviderTckTest`](src/test/java/dev/openfeature/contrib/tools/tck/MultiProviderTckTest.java) runs it against `MultiProvider` wrapping **one** `InMemoryProvider`. A provider that delegates is still a provider, and delegation is where the contract is easiest to drop: a variant that does not survive the hop, a reason rewritten, an error code flattened, an event that never arrives. With a @@ -273,9 +273,9 @@ Four details are load-bearing: Read the file rather than retyping it. `$comment` members are documentation and may be ignored wherever they appear; everything else is the contract. This is what -[`InProcessBackendControl`](src/main/java/dev/openfeature/contrib/tools/providertck/InProcessBackendControl.java) +[`InProcessBackendControl`](src/main/java/dev/openfeature/contrib/tools/tck/InProcessBackendControl.java) does — it decodes the packaged copy through -[`CanonicalFlags`](src/main/java/dev/openfeature/contrib/tools/providertck/CanonicalFlags.java) +[`CanonicalFlags`](src/main/java/dev/openfeature/contrib/tools/tck/CanonicalFlags.java) rather than restating the set in Java, because a second copy inside the TCK drifts from the spec the same way an adopter's would, and when it does the in-memory self-tests go green against the wrong baseline. @@ -354,7 +354,7 @@ Suite discovery relies on the JUnit Platform auto-registering `TckSuiteListener` JAR's `META-INF/services/org.junit.platform.launcher.TestExecutionListener`), which Surefire, Gradle and IDEs all do by default. If your launcher disables listener auto-registration, register the harness explicitly instead at -`src/test/resources/META-INF/services/dev.openfeature.contrib.tools.providertck.ProviderTckHarness`, +`src/test/resources/META-INF/services/dev.openfeature.contrib.tools.tck.ProviderTckHarness`, and if you register more than one, select between them with `-Dopenfeature.tck.harness=MyProviderRemoteTckTest`. @@ -679,7 +679,7 @@ that produces the disconnect. Building this module therefore needs the submodule: ```bash -git submodule update --init tools/provider-tck/spec +git submodule update --init tools/tck/spec ``` Maven does this itself at `initialize`, so a plain `mvn verify` works from a fresh clone; the diff --git a/tools/provider-tck/lombok.config b/tools/tck/lombok.config similarity index 100% rename from tools/provider-tck/lombok.config rename to tools/tck/lombok.config diff --git a/tools/provider-tck/pom.xml b/tools/tck/pom.xml similarity index 98% rename from tools/provider-tck/pom.xml rename to tools/tck/pom.xml index 0c5ef0cd3f..0a89cbc6c8 100644 --- a/tools/provider-tck/pom.xml +++ b/tools/tck/pom.xml @@ -9,11 +9,11 @@ ../../pom.xml dev.openfeature.contrib.tools - provider-tck - 0.0.1 + tck + 0.1.0 - ${groupId}.providertck + ${groupId}.tck 3.27.7 4.3.0 2.22.1 @@ -22,7 +22,7 @@ 1.3.0 - provider-tck + tck Language-agnostic conformance test suite (TCK) for OpenFeature providers. Bundles the canonical Gherkin feature files, Cucumber step definitions and @@ -196,7 +196,7 @@ org.codehaus.mojo diff --git a/tools/provider-tck/spec b/tools/tck/spec similarity index 100% rename from tools/provider-tck/spec rename to tools/tck/spec diff --git a/tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/BackendControl.java b/tools/tck/src/main/java/dev/openfeature/contrib/tools/tck/BackendControl.java similarity index 99% rename from tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/BackendControl.java rename to tools/tck/src/main/java/dev/openfeature/contrib/tools/tck/BackendControl.java index 8fe2da69e9..9c5c8d8287 100644 --- a/tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/BackendControl.java +++ b/tools/tck/src/main/java/dev/openfeature/contrib/tools/tck/BackendControl.java @@ -1,4 +1,4 @@ -package dev.openfeature.contrib.tools.providertck; +package dev.openfeature.contrib.tools.tck; import java.time.Duration; diff --git a/tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/BackendEndpoint.java b/tools/tck/src/main/java/dev/openfeature/contrib/tools/tck/BackendEndpoint.java similarity index 98% rename from tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/BackendEndpoint.java rename to tools/tck/src/main/java/dev/openfeature/contrib/tools/tck/BackendEndpoint.java index b3d89d5873..375697afcc 100644 --- a/tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/BackendEndpoint.java +++ b/tools/tck/src/main/java/dev/openfeature/contrib/tools/tck/BackendEndpoint.java @@ -1,4 +1,4 @@ -package dev.openfeature.contrib.tools.providertck; +package dev.openfeature.contrib.tools.tck; import org.testcontainers.containers.ComposeContainer; diff --git a/tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/CanonicalFlags.java b/tools/tck/src/main/java/dev/openfeature/contrib/tools/tck/CanonicalFlags.java similarity index 99% rename from tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/CanonicalFlags.java rename to tools/tck/src/main/java/dev/openfeature/contrib/tools/tck/CanonicalFlags.java index 76226e1278..fe53683771 100644 --- a/tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/CanonicalFlags.java +++ b/tools/tck/src/main/java/dev/openfeature/contrib/tools/tck/CanonicalFlags.java @@ -1,4 +1,4 @@ -package dev.openfeature.contrib.tools.providertck; +package dev.openfeature.contrib.tools.tck; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; @@ -84,7 +84,7 @@ static byte[] definition() { throw new IllegalStateException("The canonical flag definition " + RESOURCE + " is not on the " + "classpath. It is copied in from the spec submodule by the copy-provider-tck-flags " + "execution and packaged into this artifact, so a run without it is a build problem " - + "rather than a provider defect: run 'mvn generate-resources' on tools/provider-tck, " + + "rather than a provider defect: run 'mvn generate-resources' on tools/tck, " + "having checked the spec submodule out."); } return in.readAllBytes(); diff --git a/tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/Capability.java b/tools/tck/src/main/java/dev/openfeature/contrib/tools/tck/Capability.java similarity index 99% rename from tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/Capability.java rename to tools/tck/src/main/java/dev/openfeature/contrib/tools/tck/Capability.java index 70400aee7a..0a5a8faf12 100644 --- a/tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/Capability.java +++ b/tools/tck/src/main/java/dev/openfeature/contrib/tools/tck/Capability.java @@ -1,4 +1,4 @@ -package dev.openfeature.contrib.tools.providertck; +package dev.openfeature.contrib.tools.tck; import java.util.ArrayList; import java.util.Arrays; diff --git a/tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/CapabilityGate.java b/tools/tck/src/main/java/dev/openfeature/contrib/tools/tck/CapabilityGate.java similarity index 98% rename from tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/CapabilityGate.java rename to tools/tck/src/main/java/dev/openfeature/contrib/tools/tck/CapabilityGate.java index 92f7beb7fb..71074e795c 100644 --- a/tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/CapabilityGate.java +++ b/tools/tck/src/main/java/dev/openfeature/contrib/tools/tck/CapabilityGate.java @@ -1,4 +1,4 @@ -package dev.openfeature.contrib.tools.providertck; +package dev.openfeature.contrib.tools.tck; import java.util.Collection; import java.util.Optional; diff --git a/tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/ContainerizedProviderTckTest.java b/tools/tck/src/main/java/dev/openfeature/contrib/tools/tck/ContainerizedProviderTckTest.java similarity index 99% rename from tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/ContainerizedProviderTckTest.java rename to tools/tck/src/main/java/dev/openfeature/contrib/tools/tck/ContainerizedProviderTckTest.java index 63bfefe13b..75041d00ad 100644 --- a/tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/ContainerizedProviderTckTest.java +++ b/tools/tck/src/main/java/dev/openfeature/contrib/tools/tck/ContainerizedProviderTckTest.java @@ -1,4 +1,4 @@ -package dev.openfeature.contrib.tools.providertck; +package dev.openfeature.contrib.tools.tck; import dev.openfeature.sdk.FeatureProvider; import java.io.File; diff --git a/tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/FlagUnderTest.java b/tools/tck/src/main/java/dev/openfeature/contrib/tools/tck/FlagUnderTest.java similarity index 96% rename from tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/FlagUnderTest.java rename to tools/tck/src/main/java/dev/openfeature/contrib/tools/tck/FlagUnderTest.java index b339dc2827..609c348d3d 100644 --- a/tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/FlagUnderTest.java +++ b/tools/tck/src/main/java/dev/openfeature/contrib/tools/tck/FlagUnderTest.java @@ -1,4 +1,4 @@ -package dev.openfeature.contrib.tools.providertck; +package dev.openfeature.contrib.tools.tck; /** * The flag a scenario is currently exercising: its key, its declared type, and the code default diff --git a/tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/HttpBackendControl.java b/tools/tck/src/main/java/dev/openfeature/contrib/tools/tck/HttpBackendControl.java similarity index 99% rename from tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/HttpBackendControl.java rename to tools/tck/src/main/java/dev/openfeature/contrib/tools/tck/HttpBackendControl.java index d63bd01293..37da70a5f1 100644 --- a/tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/HttpBackendControl.java +++ b/tools/tck/src/main/java/dev/openfeature/contrib/tools/tck/HttpBackendControl.java @@ -1,4 +1,4 @@ -package dev.openfeature.contrib.tools.providertck; +package dev.openfeature.contrib.tools.tck; import java.io.IOException; import java.net.URI; diff --git a/tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/InProcessBackendControl.java b/tools/tck/src/main/java/dev/openfeature/contrib/tools/tck/InProcessBackendControl.java similarity index 99% rename from tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/InProcessBackendControl.java rename to tools/tck/src/main/java/dev/openfeature/contrib/tools/tck/InProcessBackendControl.java index 74942593aa..704cf83f94 100644 --- a/tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/InProcessBackendControl.java +++ b/tools/tck/src/main/java/dev/openfeature/contrib/tools/tck/InProcessBackendControl.java @@ -1,4 +1,4 @@ -package dev.openfeature.contrib.tools.providertck; +package dev.openfeature.contrib.tools.tck; import dev.openfeature.sdk.providers.memory.Flag; import dev.openfeature.sdk.providers.memory.InMemoryProvider; diff --git a/tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/KnownDeviation.java b/tools/tck/src/main/java/dev/openfeature/contrib/tools/tck/KnownDeviation.java similarity index 98% rename from tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/KnownDeviation.java rename to tools/tck/src/main/java/dev/openfeature/contrib/tools/tck/KnownDeviation.java index caa59d16fa..d0bee1682a 100644 --- a/tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/KnownDeviation.java +++ b/tools/tck/src/main/java/dev/openfeature/contrib/tools/tck/KnownDeviation.java @@ -1,4 +1,4 @@ -package dev.openfeature.contrib.tools.providertck; +package dev.openfeature.contrib.tools.tck; import com.fasterxml.jackson.annotation.JsonInclude; diff --git a/tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/ProviderEventRecord.java b/tools/tck/src/main/java/dev/openfeature/contrib/tools/tck/ProviderEventRecord.java similarity index 96% rename from tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/ProviderEventRecord.java rename to tools/tck/src/main/java/dev/openfeature/contrib/tools/tck/ProviderEventRecord.java index 20b0c6957f..6b240ed54b 100644 --- a/tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/ProviderEventRecord.java +++ b/tools/tck/src/main/java/dev/openfeature/contrib/tools/tck/ProviderEventRecord.java @@ -1,4 +1,4 @@ -package dev.openfeature.contrib.tools.providertck; +package dev.openfeature.contrib.tools.tck; import dev.openfeature.sdk.EventDetails; import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; diff --git a/tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/ProviderTck.java b/tools/tck/src/main/java/dev/openfeature/contrib/tools/tck/ProviderTck.java similarity index 98% rename from tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/ProviderTck.java rename to tools/tck/src/main/java/dev/openfeature/contrib/tools/tck/ProviderTck.java index 226eb6489f..30c99b56a0 100644 --- a/tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/ProviderTck.java +++ b/tools/tck/src/main/java/dev/openfeature/contrib/tools/tck/ProviderTck.java @@ -1,4 +1,4 @@ -package dev.openfeature.contrib.tools.providertck; +package dev.openfeature.contrib.tools.tck; /** * The values {@link ProviderTckTest} configures Cucumber with, as compile-time constants. @@ -60,7 +60,7 @@ public final class ProviderTck { public static final String EXTENSIONS = "extensions"; /** Package holding the canonical step definitions. */ - public static final String GLUE = "dev.openfeature.contrib.tools.providertck.steps"; + public static final String GLUE = "dev.openfeature.contrib.tools.tck.steps"; /** * Package an adopter puts their own step definitions in. diff --git a/tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/ProviderTckHarness.java b/tools/tck/src/main/java/dev/openfeature/contrib/tools/tck/ProviderTckHarness.java similarity index 99% rename from tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/ProviderTckHarness.java rename to tools/tck/src/main/java/dev/openfeature/contrib/tools/tck/ProviderTckHarness.java index 19ae8ffbe0..91d0cd0bc2 100644 --- a/tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/ProviderTckHarness.java +++ b/tools/tck/src/main/java/dev/openfeature/contrib/tools/tck/ProviderTckHarness.java @@ -1,4 +1,4 @@ -package dev.openfeature.contrib.tools.providertck; +package dev.openfeature.contrib.tools.tck; import dev.openfeature.sdk.FeatureProvider; import java.time.Duration; diff --git a/tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/ProviderTckTest.java b/tools/tck/src/main/java/dev/openfeature/contrib/tools/tck/ProviderTckTest.java similarity index 97% rename from tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/ProviderTckTest.java rename to tools/tck/src/main/java/dev/openfeature/contrib/tools/tck/ProviderTckTest.java index ca1e738630..2c86bcde36 100644 --- a/tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/ProviderTckTest.java +++ b/tools/tck/src/main/java/dev/openfeature/contrib/tools/tck/ProviderTckTest.java @@ -1,4 +1,4 @@ -package dev.openfeature.contrib.tools.providertck; +package dev.openfeature.contrib.tools.tck; import io.cucumber.junit.platform.engine.Constants; import org.junit.platform.suite.api.ConfigurationParameter; @@ -41,7 +41,7 @@ * *

        Note this class carries no lifecycle code of its own. Provider registration, event awaiting * and backend manipulation are owned by the step definitions in - * {@code dev.openfeature.contrib.tools.providertck.steps}, which reach the harness and its + * {@code dev.openfeature.contrib.tools.tck.steps}, which reach the harness and its * {@link BackendControl} through {@link TckRuntime}. * *

        Adding your own scenarios

        diff --git a/tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/ReportNames.java b/tools/tck/src/main/java/dev/openfeature/contrib/tools/tck/ReportNames.java similarity index 94% rename from tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/ReportNames.java rename to tools/tck/src/main/java/dev/openfeature/contrib/tools/tck/ReportNames.java index 6858398bb1..b1cc6bfdc9 100644 --- a/tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/ReportNames.java +++ b/tools/tck/src/main/java/dev/openfeature/contrib/tools/tck/ReportNames.java @@ -1,4 +1,4 @@ -package dev.openfeature.contrib.tools.providertck; +package dev.openfeature.contrib.tools.tck; import java.util.Locale; @@ -15,7 +15,7 @@ final class ReportNames { private static final String[] SUITE_SUFFIXES = {"TckTest", "TCKTest", "TckSuite", "Test", "IT"}; /** Used when a name sanitises away to nothing, which an anonymous class manages. */ - private static final String FALLBACK = "provider-tck"; + private static final String FALLBACK = "tck"; private ReportNames() {} diff --git a/tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/TckRuntime.java b/tools/tck/src/main/java/dev/openfeature/contrib/tools/tck/TckRuntime.java similarity index 99% rename from tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/TckRuntime.java rename to tools/tck/src/main/java/dev/openfeature/contrib/tools/tck/TckRuntime.java index 983b1c161f..613adf7a87 100644 --- a/tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/TckRuntime.java +++ b/tools/tck/src/main/java/dev/openfeature/contrib/tools/tck/TckRuntime.java @@ -1,4 +1,4 @@ -package dev.openfeature.contrib.tools.providertck; +package dev.openfeature.contrib.tools.tck; import java.util.ArrayList; import java.util.List; diff --git a/tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/TckState.java b/tools/tck/src/main/java/dev/openfeature/contrib/tools/tck/TckState.java similarity index 98% rename from tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/TckState.java rename to tools/tck/src/main/java/dev/openfeature/contrib/tools/tck/TckState.java index 01475033f2..e6d0637a9d 100644 --- a/tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/TckState.java +++ b/tools/tck/src/main/java/dev/openfeature/contrib/tools/tck/TckState.java @@ -1,4 +1,4 @@ -package dev.openfeature.contrib.tools.providertck; +package dev.openfeature.contrib.tools.tck; import dev.openfeature.sdk.Client; import dev.openfeature.sdk.FeatureProvider; diff --git a/tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/TckSuiteListener.java b/tools/tck/src/main/java/dev/openfeature/contrib/tools/tck/TckSuiteListener.java similarity index 98% rename from tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/TckSuiteListener.java rename to tools/tck/src/main/java/dev/openfeature/contrib/tools/tck/TckSuiteListener.java index 0d688057e7..8116deff05 100644 --- a/tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/TckSuiteListener.java +++ b/tools/tck/src/main/java/dev/openfeature/contrib/tools/tck/TckSuiteListener.java @@ -1,4 +1,4 @@ -package dev.openfeature.contrib.tools.providertck; +package dev.openfeature.contrib.tools.tck; import java.lang.reflect.Modifier; import java.util.Optional; diff --git a/tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/TckValues.java b/tools/tck/src/main/java/dev/openfeature/contrib/tools/tck/TckValues.java similarity index 98% rename from tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/TckValues.java rename to tools/tck/src/main/java/dev/openfeature/contrib/tools/tck/TckValues.java index e5139b5504..eefcf86a8d 100644 --- a/tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/TckValues.java +++ b/tools/tck/src/main/java/dev/openfeature/contrib/tools/tck/TckValues.java @@ -1,4 +1,4 @@ -package dev.openfeature.contrib.tools.providertck; +package dev.openfeature.contrib.tools.tck; import com.fasterxml.jackson.databind.ObjectMapper; import dev.openfeature.sdk.Value; diff --git a/tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/steps/AbstractSteps.java b/tools/tck/src/main/java/dev/openfeature/contrib/tools/tck/steps/AbstractSteps.java similarity index 79% rename from tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/steps/AbstractSteps.java rename to tools/tck/src/main/java/dev/openfeature/contrib/tools/tck/steps/AbstractSteps.java index 6a3ff9cf66..46434a3ca6 100644 --- a/tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/steps/AbstractSteps.java +++ b/tools/tck/src/main/java/dev/openfeature/contrib/tools/tck/steps/AbstractSteps.java @@ -1,9 +1,9 @@ -package dev.openfeature.contrib.tools.providertck.steps; +package dev.openfeature.contrib.tools.tck.steps; -import dev.openfeature.contrib.tools.providertck.BackendControl; -import dev.openfeature.contrib.tools.providertck.ProviderTckHarness; -import dev.openfeature.contrib.tools.providertck.TckRuntime; -import dev.openfeature.contrib.tools.providertck.TckState; +import dev.openfeature.contrib.tools.tck.BackendControl; +import dev.openfeature.contrib.tools.tck.ProviderTckHarness; +import dev.openfeature.contrib.tools.tck.TckRuntime; +import dev.openfeature.contrib.tools.tck.TckState; /** * Base for the TCK step definition classes. diff --git a/tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/steps/ContextSteps.java b/tools/tck/src/main/java/dev/openfeature/contrib/tools/tck/steps/ContextSteps.java similarity index 96% rename from tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/steps/ContextSteps.java rename to tools/tck/src/main/java/dev/openfeature/contrib/tools/tck/steps/ContextSteps.java index 75a3ca1eb8..8bb74b0571 100644 --- a/tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/steps/ContextSteps.java +++ b/tools/tck/src/main/java/dev/openfeature/contrib/tools/tck/steps/ContextSteps.java @@ -1,6 +1,6 @@ -package dev.openfeature.contrib.tools.providertck.steps; +package dev.openfeature.contrib.tools.tck.steps; -import dev.openfeature.contrib.tools.providertck.TckState; +import dev.openfeature.contrib.tools.tck.TckState; import dev.openfeature.sdk.MutableStructure; import io.cucumber.java.en.Given; diff --git a/tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/steps/EventSteps.java b/tools/tck/src/main/java/dev/openfeature/contrib/tools/tck/steps/EventSteps.java similarity index 94% rename from tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/steps/EventSteps.java rename to tools/tck/src/main/java/dev/openfeature/contrib/tools/tck/steps/EventSteps.java index 6bc28b6c81..d7434caa6a 100644 --- a/tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/steps/EventSteps.java +++ b/tools/tck/src/main/java/dev/openfeature/contrib/tools/tck/steps/EventSteps.java @@ -1,11 +1,11 @@ -package dev.openfeature.contrib.tools.providertck.steps; +package dev.openfeature.contrib.tools.tck.steps; import static java.util.concurrent.TimeUnit.MILLISECONDS; import static org.awaitility.Awaitility.await; -import dev.openfeature.contrib.tools.providertck.ProviderEventRecord; -import dev.openfeature.contrib.tools.providertck.ProviderTckHarness; -import dev.openfeature.contrib.tools.providertck.TckState; +import dev.openfeature.contrib.tools.tck.ProviderEventRecord; +import dev.openfeature.contrib.tools.tck.ProviderTckHarness; +import dev.openfeature.contrib.tools.tck.TckState; import dev.openfeature.sdk.ProviderEvent; import io.cucumber.java.en.Given; import io.cucumber.java.en.Then; diff --git a/tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/steps/FlagSteps.java b/tools/tck/src/main/java/dev/openfeature/contrib/tools/tck/steps/FlagSteps.java similarity index 97% rename from tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/steps/FlagSteps.java rename to tools/tck/src/main/java/dev/openfeature/contrib/tools/tck/steps/FlagSteps.java index b3fd099cd6..c6ce88af67 100644 --- a/tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/steps/FlagSteps.java +++ b/tools/tck/src/main/java/dev/openfeature/contrib/tools/tck/steps/FlagSteps.java @@ -1,11 +1,11 @@ -package dev.openfeature.contrib.tools.providertck.steps; +package dev.openfeature.contrib.tools.tck.steps; import static org.assertj.core.api.Assertions.assertThat; -import dev.openfeature.contrib.tools.providertck.FlagUnderTest; -import dev.openfeature.contrib.tools.providertck.ProviderEventRecord; -import dev.openfeature.contrib.tools.providertck.TckState; -import dev.openfeature.contrib.tools.providertck.TckValues; +import dev.openfeature.contrib.tools.tck.FlagUnderTest; +import dev.openfeature.contrib.tools.tck.ProviderEventRecord; +import dev.openfeature.contrib.tools.tck.TckState; +import dev.openfeature.contrib.tools.tck.TckValues; import dev.openfeature.sdk.ErrorCode; import dev.openfeature.sdk.Structure; import dev.openfeature.sdk.Value; diff --git a/tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/steps/ProviderSteps.java b/tools/tck/src/main/java/dev/openfeature/contrib/tools/tck/steps/ProviderSteps.java similarity index 95% rename from tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/steps/ProviderSteps.java rename to tools/tck/src/main/java/dev/openfeature/contrib/tools/tck/steps/ProviderSteps.java index 996f5767b0..464a918461 100644 --- a/tools/provider-tck/src/main/java/dev/openfeature/contrib/tools/providertck/steps/ProviderSteps.java +++ b/tools/tck/src/main/java/dev/openfeature/contrib/tools/tck/steps/ProviderSteps.java @@ -1,14 +1,14 @@ -package dev.openfeature.contrib.tools.providertck.steps; +package dev.openfeature.contrib.tools.tck.steps; import static java.util.concurrent.TimeUnit.MILLISECONDS; import static org.assertj.core.api.Assertions.assertThat; import static org.awaitility.Awaitility.await; -import dev.openfeature.contrib.tools.providertck.Capability; -import dev.openfeature.contrib.tools.providertck.CapabilityGate; -import dev.openfeature.contrib.tools.providertck.ProviderTckHarness; -import dev.openfeature.contrib.tools.providertck.TckRuntime; -import dev.openfeature.contrib.tools.providertck.TckState; +import dev.openfeature.contrib.tools.tck.Capability; +import dev.openfeature.contrib.tools.tck.CapabilityGate; +import dev.openfeature.contrib.tools.tck.ProviderTckHarness; +import dev.openfeature.contrib.tools.tck.TckRuntime; +import dev.openfeature.contrib.tools.tck.TckState; import dev.openfeature.sdk.FeatureProvider; import dev.openfeature.sdk.Metadata; import dev.openfeature.sdk.NoOpProvider; @@ -73,7 +73,7 @@ public static void afterAll() { * green. * *

        This is also how a backend with no connection to lose stays honest. A harness whose - * {@link dev.openfeature.contrib.tools.providertck.BackendControl} cannot simulate an outage + * {@link dev.openfeature.contrib.tools.tck.BackendControl} cannot simulate an outage * leaves {@link Capability#STALE} and {@link Capability#UNAVAILABLE_INIT} undeclared, and the * scenarios needing them are skipped here — before any step can reach an unsupported operation. * @@ -207,7 +207,7 @@ public void theProviderIsShutDown() { *

        Requirement 2.5.2 says a provider SHOULD revert to its uninitialised state after * shutdown, and its supporting text says some providers MAY allow reinitialisation from * it. Reuse is therefore permitted rather than required, and the one scenario using this step is - * gated on {@link dev.openfeature.contrib.tools.providertck.Capability#REINITIALIZATION} + * gated on {@link dev.openfeature.contrib.tools.tck.Capability#REINITIALIZATION} * accordingly — a provider that discards its client on shutdown and never rebuilds it is making * a choice the specification offers, not exhibiting a defect. The SDK still holds the provider * as {@code READY}, because it was never told about the shutdown, so the evaluation that follows diff --git a/tools/tck/src/main/resources/META-INF/services/org.junit.platform.launcher.TestExecutionListener b/tools/tck/src/main/resources/META-INF/services/org.junit.platform.launcher.TestExecutionListener new file mode 100644 index 0000000000..1fdb1d3e74 --- /dev/null +++ b/tools/tck/src/main/resources/META-INF/services/org.junit.platform.launcher.TestExecutionListener @@ -0,0 +1 @@ +dev.openfeature.contrib.tools.tck.TckSuiteListener diff --git a/tools/provider-tck/src/main/resources/extensions/README.md b/tools/tck/src/main/resources/extensions/README.md similarity index 95% rename from tools/provider-tck/src/main/resources/extensions/README.md rename to tools/tck/src/main/resources/extensions/README.md index ab09dbcd64..60f3c7a890 100644 --- a/tools/provider-tck/src/main/resources/extensions/README.md +++ b/tools/tck/src/main/resources/extensions/README.md @@ -22,7 +22,7 @@ No annotations, no second suite, no runner configuration. The scenarios are disc suite as the canonical set, so they share its backend lifecycle: one `@BeforeAll`, one backend, the same `BackendControl`. -Your step classes may take `dev.openfeature.contrib.tools.providertck.TckState` as a constructor +Your step classes may take `dev.openfeature.contrib.tools.tck.TckState` as a constructor argument to reach the client and the last evaluation, exactly as the canonical steps do, and `TckRuntime.get()` for the backend control and the backend endpoint. diff --git a/tools/provider-tck/src/test/java/dev/openfeature/contrib/tools/providertck/CanonicalFlagsTest.java b/tools/tck/src/test/java/dev/openfeature/contrib/tools/tck/CanonicalFlagsTest.java similarity index 99% rename from tools/provider-tck/src/test/java/dev/openfeature/contrib/tools/providertck/CanonicalFlagsTest.java rename to tools/tck/src/test/java/dev/openfeature/contrib/tools/tck/CanonicalFlagsTest.java index 696f4bc3c7..19ef5c796e 100644 --- a/tools/provider-tck/src/test/java/dev/openfeature/contrib/tools/providertck/CanonicalFlagsTest.java +++ b/tools/tck/src/test/java/dev/openfeature/contrib/tools/tck/CanonicalFlagsTest.java @@ -1,4 +1,4 @@ -package dev.openfeature.contrib.tools.providertck; +package dev.openfeature.contrib.tools.tck; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.fail; diff --git a/tools/provider-tck/src/test/java/dev/openfeature/contrib/tools/providertck/DeclarationApiTest.java b/tools/tck/src/test/java/dev/openfeature/contrib/tools/tck/DeclarationApiTest.java similarity index 99% rename from tools/provider-tck/src/test/java/dev/openfeature/contrib/tools/providertck/DeclarationApiTest.java rename to tools/tck/src/test/java/dev/openfeature/contrib/tools/tck/DeclarationApiTest.java index b9ee5f7e98..774a3c9c71 100644 --- a/tools/provider-tck/src/test/java/dev/openfeature/contrib/tools/providertck/DeclarationApiTest.java +++ b/tools/tck/src/test/java/dev/openfeature/contrib/tools/tck/DeclarationApiTest.java @@ -1,4 +1,4 @@ -package dev.openfeature.contrib.tools.providertck; +package dev.openfeature.contrib.tools.tck; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; diff --git a/tools/provider-tck/src/test/java/dev/openfeature/contrib/tools/providertck/ExtensionPointTest.java b/tools/tck/src/test/java/dev/openfeature/contrib/tools/tck/ExtensionPointTest.java similarity index 97% rename from tools/provider-tck/src/test/java/dev/openfeature/contrib/tools/providertck/ExtensionPointTest.java rename to tools/tck/src/test/java/dev/openfeature/contrib/tools/tck/ExtensionPointTest.java index 5332134323..8bfb0c7549 100644 --- a/tools/provider-tck/src/test/java/dev/openfeature/contrib/tools/providertck/ExtensionPointTest.java +++ b/tools/tck/src/test/java/dev/openfeature/contrib/tools/tck/ExtensionPointTest.java @@ -1,4 +1,4 @@ -package dev.openfeature.contrib.tools.providertck; +package dev.openfeature.contrib.tools.tck; import static org.assertj.core.api.Assertions.assertThat; @@ -111,8 +111,7 @@ void theGlueConstantComposesInAnAnnotationValue() { ConfigurationParameter parameter = VendorGlue.class.getAnnotation(ConfigurationParameter.class); assertThat(parameter.value()) - .isEqualTo("dev.openfeature.contrib.tools.providertck.steps," - + "openfeature.tck.extensions,com.vendor.steps"); + .isEqualTo("dev.openfeature.contrib.tools.tck.steps," + "openfeature.tck.extensions,com.vendor.steps"); } /** An adopter who wants a third glue package writes this, and does not restate our package. */ diff --git a/tools/provider-tck/src/test/java/dev/openfeature/contrib/tools/providertck/InMemoryProviderTckTest.java b/tools/tck/src/test/java/dev/openfeature/contrib/tools/tck/InMemoryProviderTckTest.java similarity index 99% rename from tools/provider-tck/src/test/java/dev/openfeature/contrib/tools/providertck/InMemoryProviderTckTest.java rename to tools/tck/src/test/java/dev/openfeature/contrib/tools/tck/InMemoryProviderTckTest.java index 10fbcad890..551ac76dfd 100644 --- a/tools/provider-tck/src/test/java/dev/openfeature/contrib/tools/providertck/InMemoryProviderTckTest.java +++ b/tools/tck/src/test/java/dev/openfeature/contrib/tools/tck/InMemoryProviderTckTest.java @@ -1,4 +1,4 @@ -package dev.openfeature.contrib.tools.providertck; +package dev.openfeature.contrib.tools.tck; import dev.openfeature.sdk.FeatureProvider; import dev.openfeature.sdk.providers.memory.InMemoryProvider; diff --git a/tools/provider-tck/src/test/java/dev/openfeature/contrib/tools/providertck/InProcessBackendControlTest.java b/tools/tck/src/test/java/dev/openfeature/contrib/tools/tck/InProcessBackendControlTest.java similarity index 99% rename from tools/provider-tck/src/test/java/dev/openfeature/contrib/tools/providertck/InProcessBackendControlTest.java rename to tools/tck/src/test/java/dev/openfeature/contrib/tools/tck/InProcessBackendControlTest.java index 110be3abbf..addd09e003 100644 --- a/tools/provider-tck/src/test/java/dev/openfeature/contrib/tools/providertck/InProcessBackendControlTest.java +++ b/tools/tck/src/test/java/dev/openfeature/contrib/tools/tck/InProcessBackendControlTest.java @@ -1,4 +1,4 @@ -package dev.openfeature.contrib.tools.providertck; +package dev.openfeature.contrib.tools.tck; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; diff --git a/tools/provider-tck/src/test/java/dev/openfeature/contrib/tools/providertck/MultiProviderTckTest.java b/tools/tck/src/test/java/dev/openfeature/contrib/tools/tck/MultiProviderTckTest.java similarity index 98% rename from tools/provider-tck/src/test/java/dev/openfeature/contrib/tools/providertck/MultiProviderTckTest.java rename to tools/tck/src/test/java/dev/openfeature/contrib/tools/tck/MultiProviderTckTest.java index b59c059b72..9c687adbe6 100644 --- a/tools/provider-tck/src/test/java/dev/openfeature/contrib/tools/providertck/MultiProviderTckTest.java +++ b/tools/tck/src/test/java/dev/openfeature/contrib/tools/tck/MultiProviderTckTest.java @@ -1,4 +1,4 @@ -package dev.openfeature.contrib.tools.providertck; +package dev.openfeature.contrib.tools.tck; import dev.openfeature.sdk.FeatureProvider; import dev.openfeature.sdk.multiprovider.MultiProvider; diff --git a/tools/provider-tck/src/test/java/dev/openfeature/contrib/tools/providertck/TckSuiteFixture.java b/tools/tck/src/test/java/dev/openfeature/contrib/tools/tck/TckSuiteFixture.java similarity index 96% rename from tools/provider-tck/src/test/java/dev/openfeature/contrib/tools/providertck/TckSuiteFixture.java rename to tools/tck/src/test/java/dev/openfeature/contrib/tools/tck/TckSuiteFixture.java index 0237a6b122..452345a25b 100644 --- a/tools/provider-tck/src/test/java/dev/openfeature/contrib/tools/providertck/TckSuiteFixture.java +++ b/tools/tck/src/test/java/dev/openfeature/contrib/tools/tck/TckSuiteFixture.java @@ -1,4 +1,4 @@ -package dev.openfeature.contrib.tools.providertck; +package dev.openfeature.contrib.tools.tck; import dev.openfeature.sdk.FeatureProvider; import dev.openfeature.sdk.NoOpProvider; diff --git a/tools/provider-tck/src/test/java/openfeature/tck/extensions/ExtensionSelfTestSteps.java b/tools/tck/src/test/java/openfeature/tck/extensions/ExtensionSelfTestSteps.java similarity index 94% rename from tools/provider-tck/src/test/java/openfeature/tck/extensions/ExtensionSelfTestSteps.java rename to tools/tck/src/test/java/openfeature/tck/extensions/ExtensionSelfTestSteps.java index ead0e3f133..2c14d36631 100644 --- a/tools/provider-tck/src/test/java/openfeature/tck/extensions/ExtensionSelfTestSteps.java +++ b/tools/tck/src/test/java/openfeature/tck/extensions/ExtensionSelfTestSteps.java @@ -13,7 +13,7 @@ * without an annotation, a runner or a line of configuration written for it. * *

        The steps deliberately need nothing from {@link - * dev.openfeature.contrib.tools.providertck.TckRuntime}. An extension scenario in a real suite runs + * dev.openfeature.contrib.tools.tck.TckRuntime}. An extension scenario in a real suite runs * after the canonical {@code @BeforeAll} and has the started backend and its control — but asserting * that here would make the TCK's own unit tests need Docker, which is a worse trade than proving the * lifecycle structurally: the extension features are discovered into the same suite and the same diff --git a/tools/provider-tck/src/test/resources/extensions/extension-selftest.feature b/tools/tck/src/test/resources/extensions/extension-selftest.feature similarity index 100% rename from tools/provider-tck/src/test/resources/extensions/extension-selftest.feature rename to tools/tck/src/test/resources/extensions/extension-selftest.feature diff --git a/tools/tck/version.txt b/tools/tck/version.txt new file mode 100644 index 0000000000..6e8bf73aa5 --- /dev/null +++ b/tools/tck/version.txt @@ -0,0 +1 @@ +0.1.0 From cbba5625c1ba1071bf6187ba8e038fe3587d180d Mon Sep 17 00:00:00 2001 From: Simon Schrottner Date: Sat, 12 Sep 2026 13:54:54 +0200 Subject: [PATCH 27/55] refactor(tck): drop the post-command settle, and keep the readiness probe `HttpBackendControl` slept 50ms after every control call and `ContainerizedProviderTckTest` exposed a `settleTime()` knob for it. No other language has ever had either. flagd-testbed#394 makes `POST /start` block until the flags are actually evaluable, so the sleep covers a window that no longer exists. More to the point, a fixed pause covers that window whether or not the control API keeps its promise, which is the difference between a suite that can detect a control API regression and one that hides it. If a step after a control call is racy, the defect is in the backend's control API and it belongs in that backend's issue tracker -- not in a pause repeated in four languages. The await-ready probe before the first command stays. That one is a real readiness check against the control API itself, bounded by `startupTimeout()`, and the other three languages are gaining it. The only `Thread.sleep` left in the class is the interval between two readiness probes. Signed-off-by: Simon Schrottner --- tools/tck/README.md | 11 ++++++++-- .../tck/ContainerizedProviderTckTest.java | 20 ++++++------------ .../contrib/tools/tck/HttpBackendControl.java | 21 +++++++++---------- 3 files changed, 25 insertions(+), 27 deletions(-) diff --git a/tools/tck/README.md b/tools/tck/README.md index d90f0151a9..39e6389847 100644 --- a/tools/tck/README.md +++ b/tools/tck/README.md @@ -590,8 +590,7 @@ needs most of a poll interval. Every await timeout is therefore overridable. |---|---|---| | `eventTimeout()` | 12s | waiting for a provider event | | `readyTimeout()` | 30s | waiting for a provider to reach a lifecycle state | -| `startupTimeout()` | 60s | bringing the Compose stack up (`ContainerizedProviderTckTest` only) | -| `settleTime()` | 50ms | pause after a control API call (`ContainerizedProviderTckTest` only) | +| `startupTimeout()` | 60s | bringing the Compose stack up, and its control API becoming reachable (`ContainerizedProviderTckTest` only) | ```java @Override @@ -604,6 +603,14 @@ Set `eventTimeout()` to comfortably exceed your worst-case detection latency, or timeouts that are really just impatience. Scenarios that assert promptness as part of their point use the explicit `within {int}ms` step, which always wins. +Every entry in that table is a **bound on an await**, and there is deliberately no entry that is a +**pause**. Nothing sleeps after a control API call: a control call returns when the backend has +acted, because that is what the control API promises — `POST /start` blocks until the flags are +evaluable. A fixed pause would cover that window whether or not the promise is kept, which is the +difference between a suite that can detect a control API regression and one that hides it. If a +step after a control call is racy on your stack, the defect is in the backend's control API and it +belongs in that backend's issue tracker; raising a pause in four languages is not the fix. + ## Running it ```bash diff --git a/tools/tck/src/main/java/dev/openfeature/contrib/tools/tck/ContainerizedProviderTckTest.java b/tools/tck/src/main/java/dev/openfeature/contrib/tools/tck/ContainerizedProviderTckTest.java index 75041d00ad..f05dffaa3e 100644 --- a/tools/tck/src/main/java/dev/openfeature/contrib/tools/tck/ContainerizedProviderTckTest.java +++ b/tools/tck/src/main/java/dev/openfeature/contrib/tools/tck/ContainerizedProviderTckTest.java @@ -183,19 +183,6 @@ public Duration startupTimeout() { return Duration.ofSeconds(60); } - /** - * Returns how long to pause after a control API call before continuing. - * - *

        Covers the gap between the control API acknowledging a command and the backend actually - * having acted on it. Raise it if you see flakiness immediately after - * {@code the flag was modified} or a provider setup step. - * - * @return the settle time, 50 milliseconds by default - */ - public Duration settleTime() { - return Duration.ofMillis(50); - } - // --------------------------------------------------------------------------------------- // The lifecycle-agnostic contract, implemented in terms of the Compose stack // --------------------------------------------------------------------------------------- @@ -205,13 +192,18 @@ public Duration settleTime() { * *

        Starts the Compose stack, resolves the control API's mapped port and waits for it to * accept commands. + * + *

        The await here is the only timing allowance the suite makes, and it is a readiness check + * against the control API itself rather than a guess at how long a backend takes: it probes + * until the control API answers, bounded by {@link #startupTimeout()}. Nothing sleeps after a + * control command — see {@link HttpBackendControl}. */ @Override public final void startSuite() { compose = startCompose(); endpoint = new BackendEndpoint(compose, backendService()); control = new HttpBackendControl( - "http://" + endpoint.host() + ":" + endpoint.port(controlPort()), defaultConfig(), settleTime()); + "http://" + endpoint.host() + ":" + endpoint.port(controlPort()), defaultConfig()); control.awaitReady(startupTimeout()); log.info("Control API ready at {}", control.baseUrl()); } diff --git a/tools/tck/src/main/java/dev/openfeature/contrib/tools/tck/HttpBackendControl.java b/tools/tck/src/main/java/dev/openfeature/contrib/tools/tck/HttpBackendControl.java index 37da70a5f1..3420e956c3 100644 --- a/tools/tck/src/main/java/dev/openfeature/contrib/tools/tck/HttpBackendControl.java +++ b/tools/tck/src/main/java/dev/openfeature/contrib/tools/tck/HttpBackendControl.java @@ -31,7 +31,6 @@ public final class HttpBackendControl implements BackendControl { private final HttpClient http; private final String baseUrl; private final String defaultConfig; - private final Duration settleTime; /** * Tri-state cache of whether the backend implements the optional {@code /reset} operation. @@ -49,14 +48,19 @@ public final class HttpBackendControl implements BackendControl { /** * Creates a control client for a running backend. * + *

        There is no post-command settle, deliberately. A control call returns when the backend has + * acted, because that is what the control API promises: {@code POST /start} blocks until the + * flags are evaluable. A fixed pause after every command would cover that window whether or not + * the promise is kept, which is the difference between a suite that can detect a control API + * regression and one that hides it. If a step after a control call is racy, the defect is in the + * backend's control API and belongs in its issue tracker. + * * @param baseUrl the control API base URL, without a trailing slash * @param defaultConfig the configuration name defining the canonical baseline - * @param settleTime how long to pause after a command before continuing */ - HttpBackendControl(String baseUrl, String defaultConfig, Duration settleTime) { + HttpBackendControl(String baseUrl, String defaultConfig) { this.baseUrl = baseUrl; this.defaultConfig = defaultConfig; - this.settleTime = settleTime; this.http = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(5)).build(); } @@ -223,12 +227,10 @@ private void reset() { } expectSuccess("/reset", response); resetSupported = true; - settle(); } private void post(String path) { expectSuccess(path, send(path)); - settle(); } private HttpResponse send(String path) { @@ -254,16 +256,13 @@ private void expectSuccess(String path, HttpResponse response) { } } - private void settle() { - sleep(settleTime); - } - + /** Pauses between two readiness probes. The only sleep left here, and it is a poll interval. */ private static void sleep(Duration duration) { try { Thread.sleep(duration.toMillis()); } catch (InterruptedException e) { Thread.currentThread().interrupt(); - throw new IllegalStateException("interrupted while waiting for the backend to settle", e); + throw new IllegalStateException("interrupted while waiting for the control API to become ready", e); } } } From c500ef622250be35f1921661efb7f21f9cf6ab01 Mon Sep 17 00:00:00 2001 From: Simon Schrottner Date: Sat, 12 Sep 2026 13:58:44 +0200 Subject: [PATCH 28/55] docs(tck): state both shapes a known deviation takes, and which to prefer `KnownDeviation`'s javadoc said "the capability withheld because of the gap", which states only one of the two legitimate shapes -- and the one that is not preferred. `knownDeviations()` was worse: "declare an entry when you have narrowed capabilities() to work around a defect" reads as an instruction to do the thing the field exists to discourage. Settled wording, the same in all four languages, now that a consumer may be comparing four reports and reading one field four ways: A `knownDeviations` entry says: this provider fails to do something it is required to do. The requirement must be a numbered MUST, or a rule the implementation bound itself to elsewhere. Where the specification permits the choice, withholding the capability IS the honest report and an entry would assert a defect that does not exist. It is legitimate in two shapes, which a run's results already tell apart: 1. The capability is declared, the scenario runs, and it fails. Prefer this -- the failure stays visible and the deviation says it is known and why. 2. The capability is withheld and its scenarios skip. Only where the provider cannot attempt the behaviour at all, so running the scenario would establish nothing. Withdrawing a capability in order to turn a failing scenario into a skip is the failure mode the field exists to prevent. Also settled and now stated here: `summary` is required, `issue` is optional with a tracked and an untracked form, a deviation may name no capability, and it may not name a reserved one. The field shape does not change. `Capability.NUMERIC_COERCION`'s javadoc gains the same note, because a provider that narrows 0.5 to 0 does attempt the coercion and so is shape 1, not shape 2. Signed-off-by: Simon Schrottner --- tools/tck/README.md | 34 ++++++-- .../contrib/tools/tck/Capability.java | 11 ++- .../contrib/tools/tck/KnownDeviation.java | 84 ++++++++++++++----- .../contrib/tools/tck/ProviderTckHarness.java | 21 +++-- .../contrib/tools/tck/DeclarationApiTest.java | 4 +- 5 files changed, 112 insertions(+), 42 deletions(-) diff --git a/tools/tck/README.md b/tools/tck/README.md index 39e6389847..d966e59932 100644 --- a/tools/tck/README.md +++ b/tools/tck/README.md @@ -542,11 +542,27 @@ fails when `TckValues` cannot fit `9007199254740991` into an `Integer`, which is a rejected declaration. The 32-bit precision scenario (`large-integer-flag`, 2^31 − 1) is untagged and always runs. -### Saying that a withheld capability is a defect +### Saying that a gap is a defect -Narrowing `capabilities()` reads the same way in the results whether you did it to describe a -limitation or to work around a bug: the scenarios are skipped either way, and nothing in the run can -tell the two apart. Declare a `KnownDeviation` when it is the latter. +A `knownDeviations` entry says one thing: **this provider fails to do something it is required to +do.** The requirement has to be a numbered `MUST`, or a rule the implementation bound itself to +elsewhere — flagd's numeric-coercion ADR, say. Where the specification *permits* the choice, +withholding the capability **is** the honest report, and a deviation entry would assert a defect +that does not exist. + +It is legitimate in two shapes, and a run's results already tell them apart: + +1. **The capability is declared, the scenario runs, and it fails.** *Prefer this.* The failure stays + visible and the deviation says it is known and why, so a reader sees both the assertion that + broke and your account of it. +2. **The capability is withheld, and its scenarios skip.** Legitimate only when the provider cannot + attempt the behaviour at all — there is no connection to lose, no structured value to return — so + running the scenario would establish nothing. The deviation explains the absence, so a reader can + tell a defect from a design decision. + +Withdrawing a capability *in order to* turn a failing scenario into a skip is the failure mode this +field exists to prevent. If the provider attempts the behaviour and gets it wrong, declare the +capability, let the scenario fail, and record the deviation beside the failure. ```java @Override @@ -558,9 +574,13 @@ public List knownDeviations() { } ``` -Use `KnownDeviation.untracked(...)` when there is no issue to point at yet. That is still worth -declaring — naming the defect is what separates it from a choice — but an issue link is better. -Empty is the default, and it is silence rather than a claim of having none. +`summary` is **required**: an entry with no summary records that something is wrong without saying +what, which is worth less than the bare skip or failure it accompanies. `issue` is **optional** — +use `KnownDeviation.untracked(...)` when there is nothing to point at yet. That is still worth +declaring, because naming the defect is what separates it from a choice, but an issue link is +better. The capability may be `null`, when the gap is against a mandatory, ungated scenario; it may +not be a [reserved](#declaring-capabilities) one, since no scenario carries the tag and so there is +nothing to deviate from. Empty is the default, and it is silence rather than a claim of having none. ### Naming the configuration under test diff --git a/tools/tck/src/main/java/dev/openfeature/contrib/tools/tck/Capability.java b/tools/tck/src/main/java/dev/openfeature/contrib/tools/tck/Capability.java index 0a5a8faf12..f4be859849 100644 --- a/tools/tck/src/main/java/dev/openfeature/contrib/tools/tck/Capability.java +++ b/tools/tck/src/main/java/dev/openfeature/contrib/tools/tck/Capability.java @@ -206,10 +206,13 @@ public enum Capability { * specification says what a provider owes a value that does not fit the accessor it was asked * through — that is open-feature/spec#430. * A provider that behaves differently is not violating the specification, and a report must - * not be read as saying it is. Withholding the capability is still worth a word: narrowing - * {@code 0.5} to {@code 0} with no error code hands an application a plausible value and no - * signal, so a provider that does that should say whether it is a choice or a tracked defect, - * and {@link KnownDeviation} is where the second is said. + * not be read as saying it is. The difference is still worth a word: narrowing {@code 0.5} to + * {@code 0} with no error code hands an application a plausible value and no signal, so a + * provider that does that should say whether it is a choice or a defect, and + * {@link KnownDeviation} is where the second is said. Note which shape that takes — such a + * provider does attempt the coercion and gets it wrong, so the honest report is to + * declare the tag, let the lossy scenario fail, and record the deviation beside the failure + * rather than withholding the tag to turn the failure into a skip. * *

        Both halves are tested. The lossy half asks for {@code float-flag} (0.5) * as an integer and expects {@code TYPE_MISMATCH}; the lossless half asks for diff --git a/tools/tck/src/main/java/dev/openfeature/contrib/tools/tck/KnownDeviation.java b/tools/tck/src/main/java/dev/openfeature/contrib/tools/tck/KnownDeviation.java index d0bee1682a..84b2a69631 100644 --- a/tools/tck/src/main/java/dev/openfeature/contrib/tools/tck/KnownDeviation.java +++ b/tools/tck/src/main/java/dev/openfeature/contrib/tools/tck/KnownDeviation.java @@ -3,19 +3,54 @@ import com.fasterxml.jackson.annotation.JsonInclude; /** - * A gap the provider is known to have against something the specification does not treat as - * optional. + * An entry that says: this provider fails to do something it is required to do. * - *

        Distinct from an undeclared capability, which is a choice. A provider that does not - * declare {@code @configuration-change} has no streaming transport and is not pretending otherwise; - * a provider that does not declare {@code @numeric-coercion} because it narrows {@code 0.5} to - * {@code 0} with no error code has a bug. Both look identical in the results — scenarios skipped, - * reason recoverable from the declaration — so the difference has to be stated, or a consumer - * cannot tell a design decision from a defect. + *

        The requirement has to be a numbered {@code MUST}, or a rule the implementation bound itself to + * elsewhere — flagd's numeric-coercion ADR, say. Where the specification permits the + * choice, withholding the capability is the honest report and a deviation entry would + * assert a defect that does not exist. A provider that does not declare + * {@code @configuration-change} has no streaming transport and is not pretending otherwise; a + * provider that narrows {@code 0.5} to {@code 0} with no error code, having said elsewhere that it + * coerces losslessly, has a defect. * - *

        Declared by the provider author through {@link ProviderTckHarness#knownDeviations()}, which is - * the only place that knows the difference. The TCK cannot infer it: from the outside, a capability - * the provider chose to withhold and one it withheld because it is broken are the same absence. + *

        The two legitimate shapes

        + * + *

        A run's results already distinguish them, so the entry does not have to say which: + * + *

          + *
        1. The capability is declared, the scenario runs, and it fails. + * Prefer this. The failure stays visible in the results and the deviation says it is + * known and why. A reader sees both the assertion that broke and the author's account of it. + *
        2. The capability is withheld, and its scenarios skip. Legitimate only when + * the provider cannot attempt the behaviour at all, so running the scenario would + * establish nothing — there is no connection to lose, no structured value to return. The + * deviation then explains the absence, so a reader can tell a defect from a design decision. + *
        + * + *

        Withdrawing a capability in order to turn a failing scenario into a skip is the + * failure mode this field exists to prevent. If the provider attempts the behaviour and gets it + * wrong, shape 1 is the honest report: declare the capability, let the scenario fail, and record the + * deviation beside the failure. + * + *

        What the three fields are for

        + * + *

        {@link #summary} is required. A deviation with no summary records that + * something is wrong without saying what, which is worth less than the bare skip or failure it + * accompanies. + * + *

        {@link #issue} is optional — see {@link #tracked} and {@link #untracked}. + * Naming an untracked defect is still what separates it from a choice; prefer the tracked form as + * soon as there is an issue to point at. + * + *

        {@link #capability} may be {@code null}, when the gap is against a mandatory, ungated scenario + * and so belongs to no capability. It may not name a + * {@linkplain Capability#reserved() reserved} capability: no scenario carries the tag, so there is + * nothing to deviate from. + * + *

        Declared by the provider author through {@link ProviderTckHarness#knownDeviations()}, because + * that is the only place that knows. The TCK cannot infer any of this: from the outside, a + * capability the provider chose not to offer and one it cannot honour are the same absence, and a + * failing scenario says nothing about whether its author already knows. * *

        Part of the declaration vocabulary rather than of any one consumer of it. This is something an * adopter writes, alongside {@link ProviderTckHarness#capabilities()}, so it belongs to the @@ -25,13 +60,18 @@ @JsonInclude(JsonInclude.Include.NON_NULL) public final class KnownDeviation { - /** The capability tag the deviation concerns, or {@code null} when it maps to none. */ + /** + * The capability tag the deviation concerns, or {@code null} when it maps to none. + * + *

        Set whether the capability was declared and its scenario failed, or withheld and its + * scenarios skipped. The results say which happened; this says which capability it was about. + */ public final String capability; /** Where the gap is tracked, or {@code null} when it is not tracked anywhere. */ public final String issue; - /** What the gap is, in a form someone comparing providers can use. */ + /** What the gap is, in a form someone comparing providers can use. Never {@code null}. */ public final String summary; private KnownDeviation(String capability, String issue, String summary) { @@ -41,12 +81,13 @@ private KnownDeviation(String capability, String issue, String summary) { } /** - * Records a deviation that is tracked somewhere. + * Records a deviation that is tracked somewhere. The preferred form. * - * @param capability the capability withheld because of the gap, or {@code null} when the gap is - * against a mandatory scenario and so belongs to no capability + * @param capability the capability the gap is about — declared and failing, or withheld and + * skipped — or {@code null} when the gap is against a mandatory, ungated scenario and so + * belongs to no capability. Must not be a {@linkplain Capability#reserved() reserved} one * @param issue a URI where the gap is tracked - * @param summary what the gap is + * @param summary what the gap is; required * @return the deviation, ready to declare */ public static KnownDeviation tracked(Capability capability, String issue, String summary) { @@ -57,12 +98,13 @@ public static KnownDeviation tracked(Capability capability, String issue, String * Records a deviation that is not tracked anywhere yet. * *

        Worth declaring even so. Naming the defect is what separates it from a capability the - * provider chose to withhold, and a declaration that merely omits the tag cannot say which of + * provider chose not to offer, and a declaration that merely omits the tag cannot say which of * the two happened. Prefer {@link #tracked} as soon as there is an issue to point at. * - * @param capability the capability withheld because of the gap, or {@code null} when the gap is - * against a mandatory scenario and so belongs to no capability - * @param summary what the gap is + * @param capability the capability the gap is about — declared and failing, or withheld and + * skipped — or {@code null} when the gap is against a mandatory, ungated scenario and so + * belongs to no capability. Must not be a {@linkplain Capability#reserved() reserved} one + * @param summary what the gap is; required * @return the deviation, ready to declare */ public static KnownDeviation untracked(Capability capability, String summary) { diff --git a/tools/tck/src/main/java/dev/openfeature/contrib/tools/tck/ProviderTckHarness.java b/tools/tck/src/main/java/dev/openfeature/contrib/tools/tck/ProviderTckHarness.java index 91d0cd0bc2..5d8d978953 100644 --- a/tools/tck/src/main/java/dev/openfeature/contrib/tools/tck/ProviderTckHarness.java +++ b/tools/tck/src/main/java/dev/openfeature/contrib/tools/tck/ProviderTckHarness.java @@ -140,14 +140,19 @@ default Set capabilities() { * Declares gaps this provider is known to have against parts of the contract the specification * does not treat as optional. * - *

        Declared so that a consumer can tell a design decision from a defect. Withholding a - * capability and having a bug look identical in the results — scenarios skipped, either way — - * and the TCK cannot tell them apart from the outside. Only the provider author can, so only - * the provider author can say. - * - *

        Empty by default, which is silence rather than a claim. Declare an entry when you have - * narrowed {@link #capabilities()} to work around a defect rather than to describe a limitation, - * and delete it when the defect is fixed. + *

        Declared so that a consumer can tell a design decision from a defect. The TCK cannot tell + * them apart from the outside: a capability the provider chose not to offer and one it cannot + * honour are the same absence, and a failing scenario says nothing about whether its author + * already knows. Only the provider author can, so only the provider author can say. + * + *

        Empty by default, which is silence rather than a claim. + * + *

        An entry is legitimate in two shapes, and the first is preferred: declare the + * capability, let the scenario fail, and record the deviation beside the failure. + * Withholding the capability so that its scenarios skip is for the case where the provider + * cannot attempt the behaviour at all — withdrawing one in order to turn a failure into + * a skip is the failure mode this method exists to prevent. See {@link KnownDeviation} for the + * full rule, including what counts as a requirement to deviate from. * * @return the deviations this provider acknowledges, empty by default */ diff --git a/tools/tck/src/test/java/dev/openfeature/contrib/tools/tck/DeclarationApiTest.java b/tools/tck/src/test/java/dev/openfeature/contrib/tools/tck/DeclarationApiTest.java index 774a3c9c71..01f504f0cc 100644 --- a/tools/tck/src/test/java/dev/openfeature/contrib/tools/tck/DeclarationApiTest.java +++ b/tools/tck/src/test/java/dev/openfeature/contrib/tools/tck/DeclarationApiTest.java @@ -164,8 +164,8 @@ void theGateSkipsUndeclaredCapabilities() { } @Test - @DisplayName("a deviation records the capability it withholds, tracked or not") - void deviationsRecordTheCapabilityTheyWithhold() { + @DisplayName("a deviation records the capability the gap is about, tracked or not") + void deviationsRecordTheCapabilityTheGapIsAbout() { KnownDeviation tracked = KnownDeviation.tracked( Capability.NUMERIC_COERCION, "https://example.invalid/1234", "0.5 as an integer returns 0"); assertThat(tracked.capability).isEqualTo("@numeric-coercion"); From a4620647ca65f2c2c7245c72952cf05b231ce1b7 Mon Sep 17 00:00:00 2001 From: Simon Schrottner Date: Sat, 12 Sep 2026 14:02:03 +0200 Subject: [PATCH 29/55] refactor(tck): name the compose concepts what the other three languages name them `additionalExposedPorts()` becomes `additionalPorts()`. "Exposed" is Testcontainers' vocabulary rather than the contract's, and the settled concept is "additional ports" -- Go spells it `WithAdditionalPorts`. No adoption overrides it yet, so this costs nothing. The README's compose section listed three of the eight concepts. It now lists all eight with their defaults, in the order the shared contract states them, because that table is what a parity check across four languages actually compares. Nothing else moved: composeFile(), backendService() = "backend", backendPorts(), controlPort() = 8080, defaultConfig() = "default", startupTimeout() = 60s and BackendEndpoint already matched. Signed-off-by: Simon Schrottner --- tools/tck/README.md | 18 ++++++++++++------ .../contrib/tools/tck/BackendEndpoint.java | 2 +- .../tck/ContainerizedProviderTckTest.java | 4 ++-- 3 files changed, 15 insertions(+), 9 deletions(-) diff --git a/tools/tck/README.md b/tools/tck/README.md index d966e59932..755efdb364 100644 --- a/tools/tck/README.md +++ b/tools/tck/README.md @@ -201,13 +201,19 @@ services: - 5000 # whatever your provider connects to ``` -Conventions the TCK relies on — all overridable: +The whole compose contract, which is the same eight concepts with the same defaults in every +language's TCK: -| Convention | Default | Override | -|---|---|---| -| Service hosting the control API and backend | `backend` | `backendService()` | -| Container-internal control API port | `8080` | `controlPort()` | -| Extra services/ports to expose | none | `additionalExposedPorts()` | +| Concept | Required | Default | Java | +|---|---|---|---| +| Compose file | yes | — | `File composeFile()` — resolved relative to the Maven module directory | +| Backend service | no | `backend` | `String backendService()` — the service hosting both the control API and the backend | +| Backend ports | yes | — | `List backendPorts()` — container-internal ports the *provider* connects to. Do not list the control port; it is exposed automatically | +| Control port | no | `8080` | `int controlPort()` | +| Additional ports | no | none | `Map> additionalPorts()` — extra service → ports, resolved through the endpoint by service name | +| Config | no | `default` | `String defaultConfig()` — the configuration name passed to `POST /start` | +| Startup timeout | no | 60s | `Duration startupTimeout()` — the stack and its control API becoming reachable | +| Endpoint | — | — | `BackendEndpoint` — `host()` and `port(internalPort)`, optionally qualified by service | **Never pin host ports.** External ports are mapped dynamically and discovered after startup — that is why the provider comes from a factory rather than a constant. Pinned ports make the suite diff --git a/tools/tck/src/main/java/dev/openfeature/contrib/tools/tck/BackendEndpoint.java b/tools/tck/src/main/java/dev/openfeature/contrib/tools/tck/BackendEndpoint.java index 375697afcc..22d1462339 100644 --- a/tools/tck/src/main/java/dev/openfeature/contrib/tools/tck/BackendEndpoint.java +++ b/tools/tck/src/main/java/dev/openfeature/contrib/tools/tck/BackendEndpoint.java @@ -64,7 +64,7 @@ public int port(int internalPort) { * Resolves the dynamically mapped host port for a container-internal port on a named service. * *

        Use this for multi-service stacks — a proxy, an edge service, a sidecar. The service and - * port must have been declared via {@link ContainerizedProviderTckTest#additionalExposedPorts()}, + * port must have been declared via {@link ContainerizedProviderTckTest#additionalPorts()}, * otherwise Testcontainers has not exposed it and this call fails. * * @param service the Compose service name diff --git a/tools/tck/src/main/java/dev/openfeature/contrib/tools/tck/ContainerizedProviderTckTest.java b/tools/tck/src/main/java/dev/openfeature/contrib/tools/tck/ContainerizedProviderTckTest.java index f05dffaa3e..0cf3120035 100644 --- a/tools/tck/src/main/java/dev/openfeature/contrib/tools/tck/ContainerizedProviderTckTest.java +++ b/tools/tck/src/main/java/dev/openfeature/contrib/tools/tck/ContainerizedProviderTckTest.java @@ -161,7 +161,7 @@ public int controlPort() { * * @return additional services and ports to expose, empty by default */ - public Map> additionalExposedPorts() { + public Map> additionalPorts() { return Collections.emptyMap(); } @@ -250,7 +250,7 @@ private ComposeContainer startCompose() { for (Integer port : backendPorts()) { stack.withExposedService(backendService(), port, Wait.forListeningPort()); } - for (Map.Entry> service : additionalExposedPorts().entrySet()) { + for (Map.Entry> service : additionalPorts().entrySet()) { for (Integer port : service.getValue()) { stack.withExposedService(service.getKey(), port, Wait.forListeningPort()); } From 915645115475c6ff7cca1526f504b8a82fa226d3 Mon Sep 17 00:00:00 2001 From: Simon Schrottner Date: Sat, 12 Sep 2026 14:12:58 +0200 Subject: [PATCH 30/55] test(tck): cover the lifecycle feature without Docker The six @lifecycle scenarios -- reaching READY, settling into ERROR, returning code defaults while in ERROR, double shutdown, shutdown against a dead backend, and initialise again -- were reachable only through a containerised provider adoption. A break in those step definitions, which were added a day ago, would have surfaced first inside a provider suite, where it reads as a provider defect rather than a TCK one. Go and Python already have a Docker-free suite that reaches them; Java did not. InMemoryProviderTckTest cannot provide it, and not for want of trying: the SDK's InMemoryProvider is handed its whole flag set by its constructor, so initialize() records a state and shutdown() releases nothing observable. Running the lifecycle scenarios against it would establish nothing, which is why that suite withholds LIFECYCLE and why it must keep withholding it. ControllableProvider is the smallest thing that has a lifecycle worth asserting: it starts owning nothing, initialize() acquires the flag set from a store that may refuse it, shutdown() drops what was acquired and is idempotent, and initialize() afterwards works again. The store is in this JVM rather than over a socket, and Capability.LIFECYCLE's javadoc now says what the actual test is -- whether initialisation acquires something it did not hold and can be refused, not whether the thing is across a socket. Composition rather than `extends InMemoryProvider`, because seeding a subclass's flags at initialize() time means calling updateFlags, which emits PROVIDER_CONFIGURATION_CHANGED. A double that emits events the thing it stands in for would not emit is worse than no double. ControllableProviderTckTest declares LIFECYCLE, REINITIALIZATION and UNAVAILABLE_INIT on top of the in-memory suite's five, and skips 8 of the 57 scenarios where that suite skips 14. The 8 are the three @numeric-coercion, the three @targeting, @large-integers and @stale -- @stale because an in-JVM store can refuse an initialisation but cannot take a connection away from a running provider and hand it back, so BackendControl.disconnect() stays at its throwing default. That is the one capability still without Docker-free cover. All of it is test-scoped. The published API is unchanged: InProcessBackendControl stays the one an adopter with no backend writes against, and InMemoryProviderTckTest stays the reference adoption to copy. Signed-off-by: Simon Schrottner --- .github/workflows/ci.yml | 14 +- tools/tck/README.md | 26 ++- tools/tck/pom.xml | 8 +- .../contrib/tools/tck/Capability.java | 11 +- .../tools/tck/ControllableBackendControl.java | 149 ++++++++++++++++ .../tools/tck/ControllableProvider.java | 166 ++++++++++++++++++ .../tck/ControllableProviderTckTest.java | 115 ++++++++++++ .../tools/tck/InMemoryProviderTckTest.java | 11 +- 8 files changed, 483 insertions(+), 17 deletions(-) create mode 100644 tools/tck/src/test/java/dev/openfeature/contrib/tools/tck/ControllableBackendControl.java create mode 100644 tools/tck/src/test/java/dev/openfeature/contrib/tools/tck/ControllableProvider.java create mode 100644 tools/tck/src/test/java/dev/openfeature/contrib/tools/tck/ControllableProviderTckTest.java diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1d3ea4c334..03974f0ced 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,12 +16,14 @@ on: - 'feat/provider-tck*' jobs: - # Fast canary for the Provider TCK: runs the full applicable conformance suite against the - # SDK's InMemoryProvider, and again against MultiProvider wrapping one of them, with no - # Docker, no Compose stack and no network. It finishes in seconds, so a broken step - # definition, a mis-wired capability gate or a regression in the shared harness is reported - # long before the containerised provider suites in `main` get there — and it points at the - # TCK rather than at whichever provider noticed first. + # Fast canary for the Provider TCK: runs the full applicable conformance suite three times -- + # against the SDK's InMemoryProvider, against MultiProvider wrapping one of them, and against + # the TCK's own controllable provider, which has a real initialisation and so is the only one + # of the three that covers the @lifecycle scenarios. No Docker, no Compose stack and no + # network. It finishes in seconds, so a broken step definition, a mis-wired capability gate or + # a regression in the shared harness is reported long before the containerised provider suites + # in `main` get there — and it points at the TCK rather than at whichever provider noticed + # first. # # Deliberately not a gate on `main`: the two run in parallel so a green run is not delayed. # The same suite also runs inside `main` as part of the reactor build; this job exists to diff --git a/tools/tck/README.md b/tools/tck/README.md index 755efdb364..06ffba3f43 100644 --- a/tools/tck/README.md +++ b/tools/tck/README.md @@ -148,8 +148,8 @@ reaching one from a scenario that actually ran is a **test-configuration bug**, ### The TCK's own self-tests -Two suites in this module are exactly the class above, and both run with no Docker in well under a -second. They are the reference adoption, and they are the fast CI canary. +Three suites in this module are exactly the class above, and all three run with no Docker in well +under a second. They are the reference adoption, and they are the fast CI canary. [`InMemoryProviderTckTest`](src/test/java/dev/openfeature/contrib/tools/tck/InMemoryProviderTckTest.java) runs the full applicable suite against the SDK's `InMemoryProvider` — of the 56 scenarios (outline @@ -168,6 +168,28 @@ directions — it refuses `10.0` as an integer and `10` as a float exactly as it tag requires the lossless direction too. That is a choice the SDK's reference provider is entitled to, not a defect; see the class javadoc. +[`ControllableProviderTckTest`](src/test/java/dev/openfeature/contrib/tools/tck/ControllableProviderTckTest.java) +runs it against a provider with a **real initialisation**, and it is the only Docker-free cover the +`@lifecycle` feature has. `InMemoryProvider` cannot provide it: its constructor is handed the whole +flag set, so `initialize()` records a state and `shutdown()` releases nothing observable, and +running those scenarios against it would establish nothing — which is exactly why the suite above +withholds `LIFECYCLE`. The consequence was that shutdown, double shutdown, shutdown against a dead +backend and initialise-again had coverage only inside a containerised provider suite, where a break +in them reads as a provider defect rather than a TCK one. `ControllableProvider` acquires its flag +store at `initialize()` time from a store that may refuse it, so it declares `LIFECYCLE`, +`REINITIALIZATION` and `UNAVAILABLE_INIT` and all six `@lifecycle` scenarios run. Of the 14 the +in-memory suite skips, only 8 remain: the three `@numeric-coercion`, the three `@targeting`, the +`@large-integers` one and the `@stale` one — `@stale` because an in-JVM store can refuse an +initialisation but cannot take a connection away from a running provider and hand it back, so +`disconnect()` stays at its throwing default. That is the one capability still without Docker-free +coverage. + +The in-JVM store is not a licence for a provider that does have a backend to test itself this way; +see [In-process control is for backend-less providers +only](#in-process-control-is-for-backend-less-providers-only). `InMemoryProviderTckTest` stays the +reference adoption an adopter copies, because it is written against the published +`InProcessBackendControl` and the SDK's own provider. + [`MultiProviderTckTest`](src/test/java/dev/openfeature/contrib/tools/tck/MultiProviderTckTest.java) runs it against `MultiProvider` wrapping **one** `InMemoryProvider`. A provider that delegates is still a provider, and delegation is where the contract is easiest to drop: a variant that does not diff --git a/tools/tck/pom.xml b/tools/tck/pom.xml index 0a89cbc6c8..5c716da5c5 100644 --- a/tools/tck/pom.xml +++ b/tools/tck/pom.xml @@ -177,9 +177,11 @@ org.testcontainers testcontainers ${testcontainers.version} - compile + provided + true From 8075b9746c47157124e1793e6ebee8a1254c4b12 Mon Sep 17 00:00:00 2001 From: Simon Schrottner Date: Sat, 12 Sep 2026 16:22:59 +0200 Subject: [PATCH 34/55] docs(tck): follow the renames, and write down the CI exclusion The README catches up with the three code changes before it, and gains the one thing it was missing. Renames and deletions: backendConfiguration() in the compose table, with a sentence saying why it is not configuration(); controlApi() documented as required and closed, with the three reasons; /restart demoted to optional in the endpoint table and marked as reached by no shipped scenario; disconnectFor and "the connection is lost for {int}s" removed from the step vocabulary; the "/start resets, /restart preserves" note replaced by the promise the spec now makes, which is that /start, /change and /reset must not return until the new state is being served. Testcontainers: a containerised adopter now adds the dependency itself, so the installation section says so and shows the coordinates. And the exclusion. A ContainerizedProviderTckTest subclass must be excluded from its module's default test run, because a default build that needs Docker fails on any machine without a daemon and the failure reads as a broken provider rather than a missing prerequisite. That was already true of providers/flagd and was nowhere written down, which is how providers/ofrep came to run a Docker-dependent suite in the default build unnoticed. The policy - exclusion plus a maintainer running the suites by hand before merge, not a scheduled or path-filtered workflow - is now stated with its reasoning, so it reads as a decision rather than an oversight. Signed-off-by: Simon Schrottner --- tools/tck/README.md | 112 ++++++++++++++++++++++++++++++++++++++------ 1 file changed, 98 insertions(+), 14 deletions(-) diff --git a/tools/tck/README.md b/tools/tck/README.md index 06ffba3f43..c2642b2a2e 100644 --- a/tools/tck/README.md +++ b/tools/tck/README.md @@ -26,6 +26,26 @@ contract" is an unverified claim. This is the shared suite that makes it checkab Requires Java 11+ and JUnit 5. A working Docker daemon is needed only for providers with an external backend — see [Which base class to extend](#which-base-class-to-extend). +### Testcontainers, for containerised adopters only + +`ContainerizedProviderTckTest` owns a `ComposeContainer`, so this artifact compiles against +Testcontainers — but it declares the dependency `provided` and `optional`, so **it is not +transitive**. A containerised adopter adds it itself: + +```xml + + org.testcontainers + testcontainers + 2.0.4 + test + +``` + +That is one line for the adopters that need it, and it keeps Testcontainers off the test classpath of +every backend-less adopter — in-memory, environment-variable, file-based — which would otherwise +resolve it for a class they never load. It also lets an adopter stay on the Testcontainers major its +other suites already use instead of inheriting ours. + ### OpenFeature SDK compatibility The TCK declares `dev.openfeature:sdk` as a **`provided` version range** (`[1.21.0,1.99999)`), @@ -137,7 +157,7 @@ same thing: `changeFlag()` has to reach the live provider instance to emit an ev **Connection control does not apply**, and the capability declaration is where you say so rather than stubbing it out. An in-memory provider has no connection to lose, so -`InProcessBackendControl` leaves `disconnect()`, `reconnect()` and `disconnectFor()` unimplemented — +`InProcessBackendControl` leaves `disconnect()` and `reconnect()` unimplemented — they throw. Leaving `STALE` and `UNAVAILABLE_INIT` out of `capabilities()` is what keeps that honest: the scenarios needing them are skipped before any step can reach an unsupported operation. @@ -233,10 +253,15 @@ language's TCK: | Backend ports | yes | — | `List backendPorts()` — container-internal ports the *provider* connects to. Do not list the control port; it is exposed automatically | | Control port | no | `8080` | `int controlPort()` | | Additional ports | no | none | `Map> additionalPorts()` — extra service → ports, resolved through the endpoint by service name | -| Config | no | `default` | `String defaultConfig()` — the configuration name passed to `POST /start` | +| Backend configuration | no | `default` | `String backendConfiguration()` — the backend configuration name passed to `POST /start` | | Startup timeout | no | 60s | `Duration startupTimeout()` — the stack and its control API becoming reachable | | Endpoint | — | — | `BackendEndpoint` — `host()` and `port(internalPort)`, optionally qualified by service | +`backendConfiguration()` names a configuration the **backend** understands. It is not +`configuration()`, which names the mode of the **provider** — see [Naming the configuration under +test](#naming-the-configuration-under-test). The two words were the same in three of the four +languages' first drafts, and telling them apart is the reason this one is spelled out. + **Never pin host ports.** External ports are mapped dynamically and discovered after startup — that is why the provider comes from a factory rather than a constant. Pinned ports make the suite unrunnable in parallel with anything else and collide with a developer's local backend. @@ -254,11 +279,18 @@ packaged inside the JAR. Summary: |---|---|---| | `POST /start?config={name}` | **required** | start the backend, seed flags to that config's baseline | | `POST /stop` | **required** | make the backend unreachable | -| `POST /restart?seconds={n}` | **required** | bounded outage, flag state preserved | | `POST /change` | **required** | change `changing-flag`'s resolved value | | `POST /reset` | optional | restore baseline without an outage; falls back to `/start` | +| `POST /restart?seconds={n}` | optional | bounded outage, flag state preserved — **no shipped scenario calls it** | | `GET /healthz` | optional | readiness; falls back to a TCP port check | +`/restart` is optional and this suite binds no method to it. The disconnect/reconnect scenario is +written as an *unbounded* outage — `the connection is lost`, then `the connection is restored` — +which is `/stop` followed by `/start`, because a self-healing outage cannot express "assert the +provider is stale, and only then reconnect". It stays specified because a future `@caching` scenario +asserting what a stale provider serves *during* an outage needs exactly its flag-state preservation, +which `/start`-on-reconnect does not give. + Two normative requirements are worth repeating here because getting them wrong is subtle: > **Never stop or restart a container to simulate an outage.** Testcontainers cannot reliably @@ -269,9 +301,12 @@ Two normative requirements are worth repeating here because getting them wrong i > socket. The [flagd testbed](https://github.com/open-feature/flagd-testbed) kills and restarts the > flagd process inside a container that keeps running — that is the reference behaviour. -> **`/start` resets flag state; `/restart` preserves it.** An outage must be observable as a change -> in availability, never as a change in flag values. The TCK relies on this split for scenario -> isolation. +> **`/start`, `/change` and `/reset` must not return until the new state is being served.** That +> promise is about the *backend*: a fresh evaluation against it must already resolve the new value +> when the call returns. How long the provider under test takes to notice is a property of its +> transport and is what `eventTimeout()` bounds. Confusing the two makes the provider's detection +> latency unmeasurable, because the clock starts before there is anything to detect — and it is why +> nothing in this suite sleeps after a control call. ### 3. The canonical flag set @@ -621,12 +656,25 @@ It defaults to the suite class name, hyphenated and with the JUnit suffix droppe ### How the backend was driven -`BackendControl.controlApi()` says which of the two contracts a run was conducted under: `http`, the -normative control API, or `in-process`, the narrow allowance for a provider with no backend at all. -`HttpBackendControl` answers `http` and everything else defaults to `in-process`, so a custom -`BackendControl` states it rather than leaving a reader to guess. A claim of `in-process` for a -provider that does have a backend should be treated with suspicion — see [In-process control is for -backend-less providers only](#in-process-control-is-for-backend-less-providers-only). +`BackendControl.controlApi()` says which of the two contracts a run was conducted under: +`ControlApi.HTTP`, the normative control API, or `ControlApi.IN_PROCESS`, the narrow allowance for a +provider with no backend at all. They serialise as `http` and `in-process`. A claim of `in-process` +for a provider that does have a backend should be treated with suspicion — see [In-process control is +for backend-less providers only](#in-process-control-is-for-backend-less-providers-only). + +It is **required and has no default**, which is a deliberate choice and not an oversight: + +- The two runs it distinguishes are not the same claim. The same scenarios passing over the control + API and passing through in-process manipulation of a provider that *does* have a backend prove + different things, and this is the only field that separates them. An unanswered value is therefore + not "no claim made" — it is an unfalsifiable one. +- It cannot be inferred from the control's concrete type. `HttpBackendControl` and + `InProcessBackendControl` answer it themselves, and an adopter with a real backend writes no + control at all. The only person who implements this interface by hand is the one writing a custom + control — precisely the case where nothing downstream can guess. +- A `String` would be wider than the report schema's enum, so an implementor could return `"HTTP"` + and produce a document that fails validation with no local error. `ControlApi` is closed for that + reason. ## Tuning timeouts @@ -668,6 +716,42 @@ mvn test -Dtest=MyProviderTckTest A suite extending `ProviderTckTest` with in-process control needs no Docker and no network. A suite extending `ContainerizedProviderTckTest` needs a working Docker daemon for its Compose stack. +### Containerised suites are excluded from the default build, on purpose + +A `ContainerizedProviderTckTest` subclass must be **excluded from the module's default test run**, +and the adopting module says so in its own POM. This repository's convention is the +`testExclusions` property the parent POM feeds to Surefire: + +```xml + + **/e2e/*.java + +``` + +This is a decision, not an omission. A default build that needs Docker fails on any machine or CI +job without a daemon, and the failure reads as a broken provider rather than a missing prerequisite. +The suites are instead **run locally by a maintainer before merge**, and a PR adopting the TCK is +expected to quote the result. Adding a scheduled or path-filtered workflow to run them was +considered and declined: a suite whose red is diagnosed by whoever happens to read the notification +is worse than one whose red is diagnosed by the person who caused it. + +**Check what your profiles do to that property.** This is the part that is easy to get wrong. If +your module has a profile that *clears* `testExclusions` in order to run some other Docker suite — +`` — and a CI job activates that profile, the TCK suite runs there too. In this +repository `ci.yml`'s `main` job activates `e2e` on every push, so `providers/flagd` narrows its +`e2e` profile to `**/e2e/*TckTest.java` instead of clearing it: the module's legacy suites keep +running and the TCK suites stay out. Resolve the property rather than reading the POM: + +```bash +mvn -Pe2e -pl providers/ help:evaluate -Dexpression=testExclusions -DforceStdout +``` + +Write the exclusion down where the adopter can find it. An exclusion nobody records is +indistinguishable from an oversight, and both halves of that went wrong here: one of the two +adoptions in this repository ran a Docker-dependent suite in its default build because it never +declared the property, and the other was reported as excluded from every job when a profile was +quietly putting it back. + Scenarios run **serially** and the suite enforces this, overriding any `cucumber.execution.parallel.enabled=true` in your module's `junit-platform.properties`. Control API state is global to the Compose stack, so concurrent scenarios corrupt each other — one scenario's @@ -694,14 +778,14 @@ Everything else is unchanged: `a -flag with key ... and a default value .. `the flag was evaluated with details`, `the resolved details value should be "..."`, `the reason should be ...`, `the variant should be ...`, `the error-code should be ...`, `a event handler`, `the event handler should have been executed[ within ms]`, -`the connection is lost[ for s]`, `the flag was modified`, +`the connection is lost`, `the flag was modified`, `the flag should be part of the event payload`, `the client should be in state`. The steps the TCK added: | Step | Why it was added | |---|---| -| `When the connection is restored` | the flagd harness only has the self-healing `lost for {int}s` form, which cannot express "assert stale, *then* reconnect" — the reconnect races the assertion | +| `When the connection is restored` | the flagd harness only has a self-healing `lost for {int}s` form, which cannot express "assert stale, *then* reconnect" — the reconnect races the assertion. Splitting it is why `POST /restart` is optional and why this suite binds no step to it | | `When the resolved value is remembered` / `Then the resolved details value should have changed` | the control API only requires that `/change` changes `changing-flag`'s value, not which value it changes to; asserting a delta keeps the scenario vendor-neutral | | `Then no exception should have been thrown` | makes the "never throws" half of the error contract explicit rather than implicit in a step failure; also covers a repeated `shutdown()` and an `initialize()` after it | | `Then the error message should be empty` | a value *and* an error message are two contradictory signals (requirement 2.3.2); asserted on every success path | From 7329afd84bec7aeb4efb2e7234e808ce53554f11 Mon Sep 17 00:00:00 2001 From: Simon Schrottner Date: Sat, 12 Sep 2026 23:27:59 +0200 Subject: [PATCH 35/55] test(tck): assert the control API request sequence without Docker Three rules in control-api.yaml are decided inside HttpBackendControl and are invisible from every scenario: /reset is the preferred isolation primitive and /start is the documented fallback, an unimplemented /reset is probed once per suite and the answer cached, and /reset is not specified to start a stopped backend so the scenario after a disconnect must use /start. Until now all three were reachable only with a Docker daemon and a real testbed, which is why Java and Go were the two languages with no cover for them at all. HttpBackendControlTest stubs the control API with the JDK's own com.sun.net.httpserver.HttpServer -- no Docker, no Testcontainers, nothing off loopback -- and asserts the requests actually sent, in order. Python's and JS's suites cover the same rules the same way; this brings the third into line. Both of the rules that could silently regress were checked by breaking them: making prepareScenario() always reset fails aDisconnectForcesStart, and dropping the resetSupported cache fails fallsBackToStartAndCachesTheAnswer. Neither mutation is caught by any existing test. /restart is asserted over the wire rather than by reflection -- no operation may reach it -- so reinstating a caller cannot slip past under a different name. Also records, in ControllableProviderTckTest's class javadoc, why ControllableProvider composes the SDK's in-memory provider rather than extending it: seeding a subclass's flags during initialize() calls updateFlags, which emits PROVIDER_CONFIGURATION_CHANGED at the scenarios that assert which events occur. That class is the shape the other languages are copying, so the constraint belongs where a porter reads it first. Signed-off-by: Simon Schrottner --- tools/tck/pom.xml | 8 +- .../tck/ControllableProviderTckTest.java | 7 + .../tools/tck/HttpBackendControlTest.java | 392 ++++++++++++++++++ 3 files changed, 404 insertions(+), 3 deletions(-) create mode 100644 tools/tck/src/test/java/dev/openfeature/contrib/tools/tck/HttpBackendControlTest.java diff --git a/tools/tck/pom.xml b/tools/tck/pom.xml index a40adbd4b1..58c0a10261 100644 --- a/tools/tck/pom.xml +++ b/tools/tck/pom.xml @@ -191,9 +191,11 @@ against the SDK's InMemoryProvider, against MultiProvider wrapping one, and against a provider with a real initialisation (the last being the only one of the three that covers the @lifecycle scenarios), all with no container and no network; - InProcessBackendControlTest pins the behaviour the Gherkin cannot assert about - itself. The SDK is inherited from the parent POM as `provided`, which puts it on the - test classpath already. + InProcessBackendControlTest and HttpBackendControlTest pin the behaviour the Gherkin + cannot assert about itself, the latter against a control API stubbed with the JDK's own + com.sun.net.httpserver.HttpServer so the normative HTTP request sequence is covered + without Docker. The SDK is inherited from the parent POM as `provided`, which puts it on + the test classpath already. --> org.junit.jupiter diff --git a/tools/tck/src/test/java/dev/openfeature/contrib/tools/tck/ControllableProviderTckTest.java b/tools/tck/src/test/java/dev/openfeature/contrib/tools/tck/ControllableProviderTckTest.java index 228071b738..f9aeed02c4 100644 --- a/tools/tck/src/test/java/dev/openfeature/contrib/tools/tck/ControllableProviderTckTest.java +++ b/tools/tck/src/test/java/dev/openfeature/contrib/tools/tck/ControllableProviderTckTest.java @@ -28,6 +28,13 @@ * it: that one stays the reference adoption for a provider with no backend, written against * the published {@link InProcessBackendControl} and the SDK's own provider, and it is the thing an * adopter copies. + * + *

        If you are porting this shape to another language, the one non-obvious constraint is that + * {@link ControllableProvider} composes the SDK's in-memory provider instead of + * extending it: seeding a subclass's flags during {@code initialize()} means calling + * {@code updateFlags}, which emits {@code PROVIDER_CONFIGURATION_CHANGED}, so every initialisation + * would fire a spurious configuration-change event at the very scenarios that assert which events + * occur. That class's javadoc has the full reasoning and the emission it avoids. */ public class ControllableProviderTckTest extends ProviderTckTest { diff --git a/tools/tck/src/test/java/dev/openfeature/contrib/tools/tck/HttpBackendControlTest.java b/tools/tck/src/test/java/dev/openfeature/contrib/tools/tck/HttpBackendControlTest.java new file mode 100644 index 0000000000..73a39e17a8 --- /dev/null +++ b/tools/tck/src/test/java/dev/openfeature/contrib/tools/tck/HttpBackendControlTest.java @@ -0,0 +1,392 @@ +package dev.openfeature.contrib.tools.tck; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpServer; +import java.io.IOException; +import java.io.OutputStream; +import java.io.UncheckedIOException; +import java.net.InetAddress; +import java.net.InetSocketAddress; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Deque; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +/** + * Guards the request sequence {@link HttpBackendControl} issues, which no scenario can assert. + * + *

        Every scenario's isolation rests on {@link HttpBackendControl#prepareScenario()} choosing the + * right endpoint against a backend that implements only part of the control API, and on a + * disconnect being remembered until something ends it. Both are invisible from inside the Gherkin: + * a control that reset nothing would leave each scenario running against whatever state the + * previous one left behind, and the suite would report those results as conformance. A control that + * reset a backend it had just stopped would register the next scenario's provider against a backend + * that is still down, and report the failure as a provider defect. + * + *

        So the control API is stubbed with the JDK's own {@link HttpServer} — no Docker, no + * Testcontainers, nothing off loopback — and the requests actually sent are asserted, in order. The + * three rules being pinned are normative in {@code openapi/control-api.yaml}: {@code /reset} is + * preferred and {@code /start} is the documented fallback; the fallback is detected once per suite + * and cached; and {@code /reset} is not specified to start a stopped backend, so the scenario after + * a disconnect must use {@code /start}. + */ +class HttpBackendControlTest { + + private static final Duration PROBE_BUDGET = Duration.ofSeconds(5); + + @Test + @DisplayName("prepareScenario uses /reset for every scenario when the backend implements it") + void prefersReset() throws Exception { + try (StubControlApi stub = StubControlApi.start()) { + HttpBackendControl control = new HttpBackendControl(stub.baseUrl(), "default"); + + control.prepareScenario(); + control.prepareScenario(); + + // The preferred primitive, because it causes no availability blip: a /start between + // scenarios restarts the backend, which the provider under test may legitimately + // report as a lifecycle event in the scenario that follows. + assertThat(stub.paths()).containsExactly("/reset", "/reset"); + } + } + + @Test + @DisplayName("an unimplemented /reset is probed once, then every scenario falls back to /start") + void fallsBackToStartAndCachesTheAnswer() throws Exception { + try (StubControlApi stub = StubControlApi.start().answering("/reset", 404)) { + HttpBackendControl control = new HttpBackendControl(stub.baseUrl(), "default"); + + control.prepareScenario(); + control.prepareScenario(); + control.prepareScenario(); + + // Once per suite, not once per scenario: a wasted 404 before every scenario is a slow + // suite, and never probing at all would mean a backend that grows /reset is never used + // properly. This is the path flagd-testbed actually takes — its launchpad has no /reset. + assertThat(stub.paths()).containsExactly("/reset", "/start", "/start", "/start"); + } + } + + @ParameterizedTest + @ValueSource(ints = {404, 501}) + @DisplayName("both documented not-implemented statuses trigger the fallback") + void bothNotImplementedStatusesFallBack(int status) throws Exception { + try (StubControlApi stub = StubControlApi.start().answering("/reset", status)) { + // control-api.yaml permits either, so neither may be treated as a failed control call. + new HttpBackendControl(stub.baseUrl(), "default").prepareScenario(); + + assertThat(stub.paths()).containsExactly("/reset", "/start"); + } + } + + @Test + @DisplayName("the scenario after a disconnect starts the backend rather than resetting it") + void aDisconnectForcesStart() throws Exception { + try (StubControlApi stub = StubControlApi.start()) { + HttpBackendControl control = new HttpBackendControl(stub.baseUrl(), "default"); + control.prepareScenario(); // settles on /reset, which this stub implements + stub.forget(); + + control.disconnect(); + control.prepareScenario(); + + // /reset restores flag state and is explicitly not specified to start a stopped + // backend. Without this the next scenario would prepare a backend that is still down. + assertThat(stub.paths()).containsExactly("/stop", "/start"); + } + } + + @Test + @DisplayName("reconnecting clears the disconnect, so the next scenario resets again") + void reconnectClearsTheDisconnect() throws Exception { + try (StubControlApi stub = StubControlApi.start()) { + HttpBackendControl control = new HttpBackendControl(stub.baseUrl(), "default"); + control.prepareScenario(); + control.disconnect(); + control.reconnect(); + stub.forget(); + + control.prepareScenario(); + + // A scenario that ended its own outage leaves the backend up, so the blip-free + // primitive is available again and the next scenario should not pay for a restart. + assertThat(stub.paths()).containsExactly("/reset"); + } + } + + @Test + @DisplayName("the fallback and the reconnect both name the configuration under test") + void startNamesTheBackendConfiguration() throws Exception { + try (StubControlApi stub = StubControlApi.start().answering("/reset", 404)) { + new HttpBackendControl(stub.baseUrl(), "ssl").prepareScenario(); + + assertThat(stub.requests()).containsExactly("POST /reset", "POST /start?config=ssl"); + } + } + + @Test + @DisplayName("no operation ever reaches /restart") + void nothingCallsRestart() throws Exception { + try (StubControlApi stub = StubControlApi.start()) { + HttpBackendControl control = new HttpBackendControl(stub.baseUrl(), "default"); + + control.prepareScenario(); + control.changeFlag(); + control.disconnect(); + control.reconnect(); + control.prepareScenario(); + + // /restart is optional in control-api.yaml and no shipped scenario reaches it: the + // disconnect/reconnect scenario is an unbounded outage asserted in two steps, because + // a self-healing restart races the stale assertion. Asserted over the wire rather than + // by reflection, so reinstating a caller cannot slip past by using a different name. + assertThat(stub.paths()).doesNotContain("/restart"); + } + } + + @Test + @DisplayName("changeFlag posts /change") + void changeFlagPostsChange() throws Exception { + try (StubControlApi stub = StubControlApi.start()) { + new HttpBackendControl(stub.baseUrl(), "default").changeFlag(); + + assertThat(stub.paths()).containsExactly("/change"); + } + } + + @Test + @DisplayName("an unexpected status fails loudly instead of passing silently") + void anUnexpectedStatusThrows() throws Exception { + try (StubControlApi stub = StubControlApi.start().answering("/change", 500)) { + HttpBackendControl control = new HttpBackendControl(stub.baseUrl(), "default"); + + // A control call that did nothing would leave the scenario in an unknown state and its + // assertions would then be measuring the previous scenario's backend. + assertThatThrownBy(control::changeFlag) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("HTTP 500") + .hasMessageContaining("control-api.yaml"); + } + } + + @Test + @DisplayName("an unreachable control API says which call failed and where") + void anUnreachableControlApiThrows() throws Exception { + String baseUrl = StubControlApi.addressOfAClosedServer(); + HttpBackendControl control = new HttpBackendControl(baseUrl, "default"); + + // The control API must stay reachable even while the backend is deliberately down, so this + // is a broken stack rather than an outage, and it has to read that way. + assertThatThrownBy(control::changeFlag) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("POST " + baseUrl + "/change"); + } + + @Test + @DisplayName("baseUrl is reportable without rebuilding it") + void baseUrlIsReportable() throws Exception { + try (StubControlApi stub = StubControlApi.start()) { + HttpBackendControl control = new HttpBackendControl(stub.baseUrl(), "default"); + + assertThat(control.baseUrl()).isEqualTo(stub.baseUrl()); + assertThat(control.description()).contains(stub.baseUrl()); + assertThat(control.controlApi()).isEqualTo(ControlApi.HTTP); + } + } + + // -- awaitReady -------------------------------------------------------------------------- + + @Test + @DisplayName("awaitReady returns as soon as /healthz answers, without sleeping first") + void awaitReadyReturnsOnTheFirstAnswer() throws Exception { + try (StubControlApi stub = StubControlApi.start()) { + new HttpBackendControl(stub.baseUrl(), "default").awaitReady(PROBE_BUDGET); + + // The readiness check is what replaced the old post-command settle: it probes the thing + // whose readiness is in question, so a slow control API is waited for and a dead one is + // reported, and neither costs a fixed pause. + assertThat(stub.requests()).containsExactly("GET /healthz"); + } + } + + @Test + @DisplayName("awaitReady treats an unimplemented /healthz as ready") + void awaitReadyAcceptsNotImplemented() throws Exception { + try (StubControlApi stub = StubControlApi.start().answering("/healthz", 404)) { + // 404 is "not implemented", which control-api.yaml defines as ready: readiness then + // rests on the control port accepting a connection, already established by the Compose + // wait strategy. flagd-testbed's launchpad serves no /healthz, so this is the normal + // path rather than an edge case. + new HttpBackendControl(stub.baseUrl(), "default").awaitReady(PROBE_BUDGET); + + assertThat(stub.paths()).containsExactly("/healthz"); + } + } + + @Test + @DisplayName("awaitReady keeps probing while the control API says not yet") + void awaitReadyRetriesNotReady() throws Exception { + try (StubControlApi stub = StubControlApi.start().scripting("/healthz", 503, 503, 200)) { + new HttpBackendControl(stub.baseUrl(), "default").awaitReady(Duration.ofSeconds(10)); + + // 503 is the control API saying "not ready", so it is retried rather than accepted. + assertThat(stub.paths()).containsExactly("/healthz", "/healthz", "/healthz"); + } + } + + @Test + @DisplayName("awaitReady gives up reporting what the last probe actually saw") + void awaitReadyReportsTheLastProbe() throws Exception { + try (StubControlApi stub = StubControlApi.start().answering("/healthz", 503)) { + HttpBackendControl control = new HttpBackendControl(stub.baseUrl(), "default"); + + // "did not become ready" alone sends an adopter to the wrong place: a refused + // connection is a stack that never came up, a 503 is one that is up and not finished. + assertThatThrownBy(() -> control.awaitReady(Duration.ofMillis(300))) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("did not become ready") + .hasRootCauseMessage("control API not ready, HTTP 503"); + } + } + + @Test + @DisplayName("awaitReady reports a control API that is not there at all") + void awaitReadyReportsAnAbsentControlApi() throws Exception { + String baseUrl = StubControlApi.addressOfAClosedServer(); + HttpBackendControl control = new HttpBackendControl(baseUrl, "default"); + + assertThatThrownBy(() -> control.awaitReady(Duration.ofMillis(300))) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("did not become ready") + .hasRootCauseInstanceOf(IOException.class); + } + + /** + * A control API that records every request it received and answers a scripted status. + * + *

        Bound to the loopback interface on an ephemeral port, so a test never contends for a + * fixed port and never leaves the machine. + */ + private static final class StubControlApi implements AutoCloseable { + + private final HttpServer server; + private final List requests = Collections.synchronizedList(new ArrayList<>()); + private final Map statuses = new HashMap<>(); + private final Map> scripted = new HashMap<>(); + + private StubControlApi(HttpServer server) { + this.server = server; + } + + static StubControlApi start() throws IOException { + HttpServer server = HttpServer.create(new InetSocketAddress(InetAddress.getLoopbackAddress(), 0), 0); + StubControlApi stub = new StubControlApi(server); + server.createContext("/", stub::answer); + server.start(); + return stub; + } + + /** + * Binds and immediately closes a server, so the returned address is almost certainly not + * listening. Used for the "the stack is not up" cases. + */ + static String addressOfAClosedServer() throws IOException { + try (StubControlApi stub = start()) { + return stub.baseUrl(); + } + } + + /** Answers {@code status} for every request to {@code path}. */ + StubControlApi answering(String path, int status) { + statuses.put(path, status); + return this; + } + + /** + * Answers the given statuses one per request to {@code path}, which is how "not ready, then + * ready" is expressed. The last one repeats once the script is exhausted. + */ + StubControlApi scripting(String path, int... sequence) { + Deque queue = new ArrayDeque<>(); + for (int status : sequence) { + queue.add(status); + } + scripted.put(path, queue); + return this; + } + + String baseUrl() { + return "http://" + server.getAddress().getHostString() + ":" + + server.getAddress().getPort(); + } + + /** Every request as {@code METHOD path[?query]}, in the order received. */ + List requests() { + synchronized (requests) { + return new ArrayList<>(requests); + } + } + + /** Every request's path, dropping the method and the query string. */ + List paths() { + return requests().stream() + .map(request -> request.substring(request.indexOf(' ') + 1)) + .map(target -> target.contains("?") ? target.substring(0, target.indexOf('?')) : target) + .collect(Collectors.toList()); + } + + /** Drops what has been recorded, so a test can assert only the part it set up. */ + void forget() { + requests.clear(); + } + + private void answer(HttpExchange exchange) { + try { + String path = exchange.getRequestURI().getPath(); + String query = exchange.getRequestURI().getQuery(); + requests.add(exchange.getRequestMethod() + " " + path + (query == null ? "" : "?" + query)); + + int status = nextStatus(path); + + byte[] body = "{\"status\":\"stub\"}".getBytes(StandardCharsets.UTF_8); + exchange.getResponseHeaders().add("Content-Type", "application/json"); + exchange.sendResponseHeaders(status, body.length); + try (OutputStream out = exchange.getResponseBody()) { + out.write(body); + } + } catch (IOException e) { + throw new UncheckedIOException(e); + } finally { + exchange.close(); + } + } + + /** The scripted status for this path, or the fixed one, defaulting to 200. */ + private synchronized int nextStatus(String path) { + Deque script = scripted.get(path); + if (script == null) { + return statuses.getOrDefault(path, 200); + } + // The last entry repeats, so a script cannot run dry and turn into a 200 by accident. + return script.size() > 1 ? script.poll() : script.peek(); + } + + @Override + public void close() { + server.stop(0); + } + } +} From 20de3273d0659529d35e29a40f89b3f9b5f09ec9 Mon Sep 17 00:00:00 2001 From: Simon Schrottner Date: Sat, 12 Sep 2026 23:27:59 +0200 Subject: [PATCH 36/55] docs(tck): point at Appendix F for why a suite is excluded from CI Re-pins the spec submodule to ccdb8879, which adds "Running the suite in CI" to Appendix F. The gherkin, flags and openapi assets are unchanged, so nothing in this module's behaviour moves. The CI-exclusion reasoning was promoted into the appendix precisely because four READMEs is where it drifted into three different answers. So this README now keeps only the mechanism -- the testExclusions property, the fact that the parent POM defines no default for it, that it is a Surefire exclusion and not a compiler one, what this repository's e2e profile does to it, and the help:evaluate command that resolves it -- and links to the appendix for the reasoning rather than restating it. The appendix names two mistakes, and both of them happened here: providers/ofrep never declared the property, and providers/flagd had an e2e profile clearing it while ci.yml's main job activated that profile on every push. Both are already fixed; what changes here is that the record points at the general statement instead of paraphrasing it. Signed-off-by: Simon Schrottner --- tools/tck/README.md | 54 ++++++++++++++++++++++++++++----------------- tools/tck/spec | 2 +- 2 files changed, 35 insertions(+), 21 deletions(-) diff --git a/tools/tck/README.md b/tools/tck/README.md index c2642b2a2e..b4f525e922 100644 --- a/tools/tck/README.md +++ b/tools/tck/README.md @@ -226,6 +226,19 @@ it. That is a known SDK gap, which the suite reproduced from the outside — the gap was originally found by hand-comparing implementations against the js-sdk reference. Everything else survives delegation unchanged. +Two more tests are not suites at all, because what they guard is invisible from inside a scenario. +[`InProcessBackendControlTest`](src/test/java/dev/openfeature/contrib/tools/tck/InProcessBackendControlTest.java) +calls the unsupported operations directly, so a connection operation that quietly did nothing cannot +pass as a skip. +[`HttpBackendControlTest`](src/test/java/dev/openfeature/contrib/tools/tck/HttpBackendControlTest.java) +stubs the control API with the JDK's own `com.sun.net.httpserver.HttpServer` — no Docker, nothing off +loopback — and asserts the request sequence in order: that `/reset` is preferred and `/start` is the +fallback, that an unimplemented `/reset` is probed **once per suite** and the answer cached, and that +the scenario after a `disconnect()` uses `/start` rather than `/reset`. All three are normative in +`openapi/control-api.yaml`, all three are decided in code no scenario can observe, and a control that +got any of them wrong would let scenarios run against the previous one's backend state and report the +results as conformance. + ## Adopting it This section describes a provider with an external backend — the common case. @@ -718,9 +731,15 @@ extending `ContainerizedProviderTckTest` needs a working Docker daemon for its C ### Containerised suites are excluded from the default build, on purpose -A `ContainerizedProviderTckTest` subclass must be **excluded from the module's default test run**, -and the adopting module says so in its own POM. This repository's convention is the -`testExclusions` property the parent POM feeds to Surefire: +**Why** an adoption suite is excluded rather than gating, and the two mistakes that exclusion +invites, are written down once for all four languages in +[Appendix F: Running the suite in CI](https://github.com/open-feature/spec/blob/main/specification/appendix-f-provider-conformance.md#running-the-suite-in-ci). +Read that first. What follows is only the Maven mechanism, which is this repository's and not the +appendix's business. + +A `ContainerizedProviderTckTest` subclass is **excluded from the module's default test run**, and +the adopting module says so in its own POM. This repository's convention is the `testExclusions` +property the parent POM feeds to Surefire: ```xml @@ -728,29 +747,24 @@ and the adopting module says so in its own POM. This repository's convention is ``` -This is a decision, not an omission. A default build that needs Docker fails on any machine or CI -job without a daemon, and the failure reads as a broken provider rather than a missing prerequisite. -The suites are instead **run locally by a maintainer before merge**, and a PR adopting the TCK is -expected to quote the result. Adding a scheduled or path-filtered workflow to run them was -considered and declined: a suite whose red is diagnosed by whoever happens to read the notification -is worse than one whose red is diagnosed by the person who caused it. +The parent POM defines no default for it, so a module that wants the gate must declare the property +itself. It is a **Surefire** exclusion, not a compiler one: the suite still compiles against the +harness in every build, which is what keeps an adoption from rotting unnoticed. -**Check what your profiles do to that property.** This is the part that is easy to get wrong. If -your module has a profile that *clears* `testExclusions` in order to run some other Docker suite — -`` — and a CI job activates that profile, the TCK suite runs there too. In this -repository `ci.yml`'s `main` job activates `e2e` on every push, so `providers/flagd` narrows its -`e2e` profile to `**/e2e/*TckTest.java` instead of clearing it: the module's legacy suites keep -running and the TCK suites stay out. Resolve the property rather than reading the POM: +**Then resolve the property under every profile your CI activates** — do not read the POM, which is +the mistake the appendix names first. Here, `ci.yml`'s `main` job activates `e2e` on every push, and +`providers/flagd` has an `e2e` profile for its legacy `Run*Test` suites; that profile therefore +narrows the exclusion to `**/e2e/*TckTest.java` rather than clearing it to ``, so +the legacy suites keep running and the TCK suites stay out. Both halves of the appendix's warning +happened in this repository — one adoption never declared the property, the other had a profile +putting it back — and both were found by running this, not by reading: ```bash mvn -Pe2e -pl providers/ help:evaluate -Dexpression=testExclusions -DforceStdout ``` -Write the exclusion down where the adopter can find it. An exclusion nobody records is -indistinguishable from an oversight, and both halves of that went wrong here: one of the two -adoptions in this repository ran a Docker-dependent suite in its default build because it never -declared the property, and the other was reported as excluded from every job when a profile was -quietly putting it back. +The single documented command that runs a suite deliberately, per the appendix, is the one in each +adoption's own README: `-DtestExclusions=` on the command line overrides the property for one run. Scenarios run **serially** and the suite enforces this, overriding any `cucumber.execution.parallel.enabled=true` in your module's `junit-platform.properties`. Control API diff --git a/tools/tck/spec b/tools/tck/spec index 93eb1a58d2..ccdb88790b 160000 --- a/tools/tck/spec +++ b/tools/tck/spec @@ -1 +1 @@ -Subproject commit 93eb1a58d2d2ec015acb298c914c5822e7a38dd2 +Subproject commit ccdb88790bb4f4beaef14a182d0c2592feab34b2 From a52be04ead8800efd0a9d4b196249036d3c88a60 Mon Sep 17 00:00:00 2001 From: Simon Schrottner Date: Sun, 13 Sep 2026 01:34:42 +0200 Subject: [PATCH 37/55] feat(tck): fail the run when a scenario carries a reserved tag Capability.requireDeclarable already refuses a declaration that names a reserved capability. Nothing refused the other direction: a scenario that carries one. The two rules meet badly. A reservation is a name held open for scenarios that do not exist yet, and it is only ever temporary -- the specification writes them, the tag starts gating something, and the capability becomes declarable. Until this package follows, the new scenario is gated on a capability no adopter is permitted to claim, so every run reports it as skipped and no run ever executes it. The report is well-formed, the suite is green, and a capability-gated skip is explicitly not a gap, so nothing else here notices. That is the unclaimable-capability failure Appendix F describes, and it has no local symptom at all: @targeting was reserved until spec revision 26362f85 gave it three scenarios. So CapabilityGate now fails such a scenario before it can be skipped, with a message that names the tag and says the reserved flag on that constant is the only thing to change. The order matters and is fixed inside requireDeclared rather than left to its caller: a reserved capability can never be declared, so a reserved tag examined after the declaration check is always a skip and the expiry is never reported. Both passes run over the whole tag list for the same reason -- @events @caching against a provider declaring neither would otherwise abort on the first tag. The tags are Cucumber's own parse, via Scenario#getSourceTagNames(). That is not incidental: gherkin/events.feature names @caching inside a Gherkin comment, explaining which stale-provider behaviour is deliberately not covered yet, so an implementation that scanned the feature files as text would fail every adoption on the day it shipped. Grepping the packaged canonical set for the string today returns exactly that comment line and nothing else. ReservedTagExpiryTest runs the rule rather than calling it. A fixture suite over a two-scenario feature file -- one tagged @caching, one untagged but carrying a comment that names it -- is executed through the JUnit Platform with the canonical glue, and the outcome each scenario got is asserted. It is written so that removing the check does not merely change a message: the failure count drops and the abort count rises, which is the silent outcome being guarded against, and both are pinned. Removing the call, and separately reordering it after the declaration check, each produce two failures here and none anywhere else in the module. The fixture's feature file sits outside extensions/ deliberately. Every other suite in this module selects that directory, and this one's second scenario is meant to fail. Signed-off-by: Simon Schrottner --- tools/tck/README.md | 12 ++ .../contrib/tools/tck/CapabilityGate.java | 79 ++++++++++++- .../tools/tck/steps/ProviderSteps.java | 5 + .../tools/tck/ReservedTagExpiryTest.java | 106 ++++++++++++++++++ .../tools/tck/ReservedTagSuiteFixture.java | 73 ++++++++++++ .../reserved-selftest/reserved-tag.feature | 20 ++++ 6 files changed, 293 insertions(+), 2 deletions(-) create mode 100644 tools/tck/src/test/java/dev/openfeature/contrib/tools/tck/ReservedTagExpiryTest.java create mode 100644 tools/tck/src/test/java/dev/openfeature/contrib/tools/tck/ReservedTagSuiteFixture.java create mode 100644 tools/tck/src/test/resources/reserved-selftest/reserved-tag.feature diff --git a/tools/tck/README.md b/tools/tck/README.md index b4f525e922..31367a52cb 100644 --- a/tools/tck/README.md +++ b/tools/tck/README.md @@ -545,6 +545,18 @@ Declaring one **fails the run**, with a message naming the tag. `CACHING` is the left: `TARGETING` was reserved until `targeting-key-flag`'s three scenarios arrived, and is an ordinary declarable capability now. +The other direction fails the run too, and it is the one an adopter will meet first. A reservation +expires the day the specification writes the scenarios it was held open for, and if this package has +not followed, the two rules meet in the worst possible place: the new scenario is skipped for a +capability nobody is permitted to declare — a question put and silently withdrawn, which +[Appendix F](https://github.com/open-feature/spec/blob/main/specification/appendix-f-provider-conformance.md) +calls the unclaimable capability. The report is well-formed and the run is +green, so nothing else would notice. So a scenario carrying a reserved tag **fails**, naming the tag +and saying that the reserved flag on that constant is now the only thing to change. The check reads +Cucumber's parsed tags rather than the feature files as text, which matters more than it sounds: +`gherkin/events.feature` names `@caching` inside a `#` comment explaining what is deliberately not +covered yet, so a text scan would fail every adoption on the day it shipped. + That is a rule about an accident rather than about intent: `EnumSet.complementOf(EnumSet.of(X))` reads as "everything except X" and in fact means "every other enum constant", reserved tags included. The flagd suite said exactly that and published `"declared": [..., "@targeting", diff --git a/tools/tck/src/main/java/dev/openfeature/contrib/tools/tck/CapabilityGate.java b/tools/tck/src/main/java/dev/openfeature/contrib/tools/tck/CapabilityGate.java index 71074e795c..9fa13c6225 100644 --- a/tools/tck/src/main/java/dev/openfeature/contrib/tools/tck/CapabilityGate.java +++ b/tools/tck/src/main/java/dev/openfeature/contrib/tools/tck/CapabilityGate.java @@ -1,12 +1,14 @@ package dev.openfeature.contrib.tools.tck; +import java.util.ArrayList; import java.util.Collection; +import java.util.List; import java.util.Optional; import java.util.Set; import org.opentest4j.TestAbortedException; /** - * Skips a scenario that needs a capability the provider did not declare. + * Decides what a scenario's tags mean for this run: skip it, fail it, or let it go ahead. * *

        One implementation, deliberately. This is the rule the whole suite rests on — a scenario * skipped for an undeclared capability must be reported as skipped and never as passed — so the @@ -16,13 +18,24 @@ * *

        Aborting rather than failing is what makes the outcome a skip: {@link TestAbortedException} maps * to {@code SKIPPED} in Cucumber's step results, which is what reaches the results. + * + *

        The second rule here is the mirror of the first and fails rather than skips: a tag this + * implementation still calls {@linkplain Capability#reserved() reserved} must never reach a + * scenario. See {@link #requireNoExpiredReservation}. */ public final class CapabilityGate { private CapabilityGate() {} /** - * Aborts the running scenario if any of its tags gates a capability that was not declared. + * Applies both gate rules to a scenario about to run. + * + *

        First {@link #requireNoExpiredReservation}, then the declaration check below. The order is + * not interchangeable and is fixed here rather than left to the caller: a reserved capability + * can never be declared, so a reserved tag examined second is always a skip for an undeclared + * capability and the expiry is never reported. Both passes are over the whole tag list for the + * same reason — a scenario tagged {@code @events @caching} against a provider that declares + * neither would otherwise abort on the first tag and never look at the second. * *

        Tags that gate nothing are ignored, so a scenario with no capability tag is mandatory and * always runs. @@ -36,9 +49,12 @@ private CapabilityGate() {} * * @param tags the scenario's Gherkin tags, including the leading at-sign * @param declared the capabilities the provider declares + * @throws IllegalStateException if a tag names a reserved capability * @throws TestAbortedException if a tag gates an undeclared capability */ public static void requireDeclared(Collection tags, Set declared) { + requireNoExpiredReservation(tags); + for (String tag : tags) { Optional capability = Capability.fromTag(tag); if (!capability.isPresent()) { @@ -50,4 +66,63 @@ public static void requireDeclared(Collection tags, Set decl } } } + + /** + * Fails the run if a scenario carries the tag of a capability this suite still calls reserved. + * + *

        This is the expiry check on {@link Capability#reserved()}, and it is the other half of + * {@link Capability#requireDeclarable}. That one refuses a declaration naming a reserved + * capability; this one refuses a scenario carrying its tag. A reservation is a name held + * open for scenarios that do not exist yet and is only ever temporary — the specification writes + * them, the tag starts gating something, and the capability becomes declarable. Until this + * implementation follows, the two halves meet in the worst possible place: the scenario is + * skipped for a capability no adopter is permitted to claim, a question put and silently + * withdrawn. That is the unclaimable-capability failure + * Appendix + * F describes. + * + *

        Nothing else in the suite would notice it. The report is well-formed, the run is green, and + * a capability-gated skip is explicitly not a gap — so the new scenario is executed by nobody and + * the results say only what they say about every undeclared capability. It has no local symptom + * at all, which is why it is checked rather than watched for: {@link Capability#TARGETING} was + * reserved until the {@code targeting-key-flag} scenarios arrived. + * + *

        Refused rather than worked around. Quietly treating the tag as declarable here would let a + * run claim a capability against an implementation that does not know the tag exists; the point + * of the check is that a human re-reads the reserved list against the specification. + * + *

        The tags are the parsed ones. They come from + * {@link io.cucumber.java.Scenario#getSourceTagNames()}, which is the same parse the run itself + * is driven by, so this cannot disagree with the run about which tags a scenario carries — + * including tags inherited from the feature and tags on an {@code Examples} block. A check that + * scanned the feature files as text instead would be wrong on the day it was written: + * {@code gherkin/events.feature} names {@code @caching} inside a Gherkin {@code #} comment, + * explaining which scenarios are deliberately not covered yet, and a text scan would fail every + * adoption over a sentence. + * + * @param tags the scenario's Gherkin tags, including the leading at-sign + * @throws IllegalStateException if a tag names a reserved capability + */ + public static void requireNoExpiredReservation(Collection tags) { + List expired = new ArrayList<>(); + for (String tag : tags) { + Optional capability = Capability.fromTag(tag); + if (capability.isPresent() && capability.get().reserved()) { + expired.add(capability.get().name() + " (" + capability.get().tag() + ")"); + } + } + if (expired.isEmpty()) { + return; + } + throw new IllegalStateException("This scenario carries reserved " + expired + + ", so the scenarios that reservation was held open for now exist. A reserved " + + "capability cannot be declared — Capability.requireDeclarable refuses it — so " + + "without this check the scenario would be reported as skipped for a capability no " + + "adopter is permitted to claim, which is the unclaimable-capability failure " + + "Appendix F describes and which nothing else in this suite would notice. If the " + + "tag arrived with the canonical feature files, drop the reserved flag from that " + + "constant in Capability so an adoption can declare it and be held to it. If it " + + "arrived from a feature file of your own under extensions/, pick a tag of your " + + "own: a reserved tag gates nothing and cannot be declared."); + } } diff --git a/tools/tck/src/main/java/dev/openfeature/contrib/tools/tck/steps/ProviderSteps.java b/tools/tck/src/main/java/dev/openfeature/contrib/tools/tck/steps/ProviderSteps.java index 1946985894..1fb5624017 100644 --- a/tools/tck/src/main/java/dev/openfeature/contrib/tools/tck/steps/ProviderSteps.java +++ b/tools/tck/src/main/java/dev/openfeature/contrib/tools/tck/steps/ProviderSteps.java @@ -77,6 +77,11 @@ public static void afterAll() { * leaves {@link Capability#STALE} and {@link Capability#UNAVAILABLE_INIT} undeclared, and the * scenarios needing them are skipped here — before any step can reach an unsupported operation. * + *

        The one tag that is failed rather than skipped is a + * {@linkplain Capability#reserved() reserved} one, which cannot be declared and so could only + * ever produce a skip nobody is able to clear. See + * {@link CapabilityGate#requireNoExpiredReservation}. + * * @param scenario the scenario about to run */ @Before(order = 0) diff --git a/tools/tck/src/test/java/dev/openfeature/contrib/tools/tck/ReservedTagExpiryTest.java b/tools/tck/src/test/java/dev/openfeature/contrib/tools/tck/ReservedTagExpiryTest.java new file mode 100644 index 0000000000..74bfff1d55 --- /dev/null +++ b/tools/tck/src/test/java/dev/openfeature/contrib/tools/tck/ReservedTagExpiryTest.java @@ -0,0 +1,106 @@ +package dev.openfeature.contrib.tools.tck; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.util.Arrays; +import java.util.Collections; +import java.util.EnumSet; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.platform.engine.discovery.DiscoverySelectors; +import org.junit.platform.launcher.core.LauncherDiscoveryRequestBuilder; +import org.junit.platform.launcher.core.LauncherFactory; +import org.junit.platform.launcher.listeners.SummaryGeneratingListener; +import org.junit.platform.launcher.listeners.TestExecutionSummary; +import org.opentest4j.TestAbortedException; + +/** + * A scenario carrying a {@linkplain Capability#reserved() reserved} tag fails the run. + * + *

        This is the expiry check on the reserved list, and it is here because the failure it catches is + * silent in both directions. A reserved capability cannot be declared, so the day the specification + * adds the first scenario for one, every adopter's run reports that scenario as skipped for a + * capability they are not permitted to claim. The report is well-formed and the suite is green, so + * the new scenario is executed by nobody — the unclaimable-capability failure of Appendix F. Nothing + * else in this module notices it: the scenario was collected and gated rather than dropped, and a + * capability-gated skip is explicitly not a gap. + * + *

        The first test runs {@link ReservedTagSuiteFixture}, a real suite over two real scenarios, and + * looks at the outcome each one got. Running it rather than calling {@link CapabilityGate} directly + * is what makes it a test of the rule as an adopter meets it: the tags are Cucumber's own parse, the + * hook is the one the suite installs, and the failure has to survive into the JUnit results the same + * way the skip does. + * + *

        It is also written so that removing the check does not merely change an error message. + * With the check gone, the tagged scenario is skipped for an undeclared capability instead — one + * fewer failure, one more abort — which is exactly the silent outcome the check exists to prevent, + * and both counts are asserted. + */ +class ReservedTagExpiryTest { + + @Test + @DisplayName("a reserved tag fails its scenario, and a comment naming one does not") + void aReservedTagFailsTheRun() { + SummaryGeneratingListener listener = new SummaryGeneratingListener(); + LauncherFactory.create() + .execute( + LauncherDiscoveryRequestBuilder.request() + .selectors(DiscoverySelectors.selectClass(ReservedTagSuiteFixture.class)) + .build(), + listener); + TestExecutionSummary summary = listener.getSummary(); + + assertThat(summary.getTestsFailedCount()) + .as("the scenario tagged %s fails the run rather than being quietly skipped", Capability.CACHING.tag()) + .isEqualTo(1); + assertThat(summary.getTestsAbortedCount()) + .as("and it is a failure, not an abort — an abort is the skip this check exists to " + + "prevent, and is what remains if the check is removed") + .isZero(); + assertThat(summary.getTestsSkippedCount()).isZero(); + + TestExecutionSummary.Failure failure = summary.getFailures().get(0); + assertThat(failure.getException()) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining(Capability.CACHING.name()) + .hasMessageContaining(Capability.CACHING.tag()); + assertThat(failure.getTestIdentifier().getDisplayName()) + .as("the tagged scenario is the one that failed") + .contains("still calls reserved"); + + assertThat(summary.getTestsSucceededCount()) + .as( + "the untagged scenario passes, though a Gherkin comment in it names %s — " + + "gherkin/events.feature carries exactly such a comment, so a check that " + + "scanned the feature files as text would fail every adoption", + Capability.CACHING.tag()) + .isEqualTo(1); + } + + @Test + @DisplayName("the reserved tag is reported even when another tag on the scenario is undeclared") + void theExpiryIsReportedBeforeTheSkip() { + assertThatThrownBy(() -> CapabilityGate.requireDeclared( + Arrays.asList(Capability.EVENTS.tag(), Capability.CACHING.tag()), + EnumSet.noneOf(Capability.class))) + .as("both tags are undeclared, and the expired reservation is the one worth saying — " + + "gating tag by tag would abort on @events and never reach @caching") + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining(Capability.CACHING.tag()); + } + + @Test + @DisplayName("a tag that gates nothing, and a declared capability, still pass the gate") + void ordinaryTagsAreUnaffected() { + assertThatCode(() -> CapabilityGate.requireDeclared( + Arrays.asList("@some-adopter-tag", Capability.EVENTS.tag()), EnumSet.of(Capability.EVENTS))) + .doesNotThrowAnyException(); + + assertThatThrownBy(() -> CapabilityGate.requireDeclared( + Collections.singletonList(Capability.EVENTS.tag()), EnumSet.noneOf(Capability.class))) + .as("an undeclared capability is still a skip, not a failure") + .isInstanceOf(TestAbortedException.class); + } +} diff --git a/tools/tck/src/test/java/dev/openfeature/contrib/tools/tck/ReservedTagSuiteFixture.java b/tools/tck/src/test/java/dev/openfeature/contrib/tools/tck/ReservedTagSuiteFixture.java new file mode 100644 index 0000000000..2188d16e30 --- /dev/null +++ b/tools/tck/src/test/java/dev/openfeature/contrib/tools/tck/ReservedTagSuiteFixture.java @@ -0,0 +1,73 @@ +package dev.openfeature.contrib.tools.tck; + +import dev.openfeature.sdk.FeatureProvider; +import io.cucumber.junit.platform.engine.Constants; +import java.util.EnumSet; +import java.util.Set; +import org.junit.platform.suite.api.ConfigurationParameter; +import org.junit.platform.suite.api.IncludeEngines; +import org.junit.platform.suite.api.SelectClasspathResource; +import org.junit.platform.suite.api.Suite; + +/** + * A real TCK suite over {@code reserved-selftest/}, used by {@link ReservedTagExpiryTest}. + * + *

        Executed rather than discovered: the rule under test lives in a {@code @Before} hook, so the + * only way to prove it is to run scenarios through it. The suite is otherwise ordinary — the + * canonical glue, the canonical object factory, an {@link InProcessBackendControl} over the SDK's + * in-memory provider — so what runs is the path an adopter's run takes, not a hand-built + * {@code Scenario}. + * + *

        It does not extend {@link ProviderTckTest}, and that is the point of writing + * the annotations out. The suite engine collects {@code @SelectClasspathResource} from the whole + * class hierarchy, so a subclass of {@link ProviderTckTest} would select {@code gherkin/} and + * {@code extensions/} as well and run the entire canonical set to observe two scenarios. Here the + * selection is exactly one directory, which is also why that directory is not under + * {@code extensions/}: every other suite in this module selects that one, and this fixture's second + * scenario is meant to fail. + * + *

        Deliberately not named {@code *Test}, so Surefire does not find it and run it as a suite of its + * own — which would fail the build, correctly, and for the reason this fixture exists. + */ +@Suite +@IncludeEngines("cucumber") +@SelectClasspathResource(ReservedTagSuiteFixture.FEATURES) +@ConfigurationParameter(key = Constants.PLUGIN_PROPERTY_NAME, value = ProviderTck.PLUGINS) +@ConfigurationParameter( + key = Constants.PARALLEL_EXECUTION_ENABLED_PROPERTY_NAME, + value = ProviderTck.PARALLEL_EXECUTION_ENABLED) +@ConfigurationParameter( + key = Constants.EXECUTION_MODE_FEATURE_PROPERTY_NAME, + value = ProviderTck.FEATURE_EXECUTION_MODE) +@ConfigurationParameter(key = Constants.GLUE_PROPERTY_NAME, value = ProviderTck.ALL_GLUE) +@ConfigurationParameter(key = Constants.OBJECT_FACTORY_PROPERTY_NAME, value = ProviderTck.OBJECT_FACTORY) +public class ReservedTagSuiteFixture implements ProviderTckHarness { + + /** The classpath directory holding this fixture's feature file. */ + public static final String FEATURES = "reserved-selftest"; + + private final InProcessBackendControl control = new InProcessBackendControl(); + + @Override + public BackendControl backendControl() { + return control; + } + + @Override + public FeatureProvider createProvider() { + return control.createProvider(); + } + + /** + * {@inheritDoc} + * + *

        Empty, and it has to be: {@code @caching} is reserved, so there is no declaration that + * would let the tagged scenario through. Every other scenario in the fixture is untagged and + * therefore mandatory, so nothing here rests on the declaration at all — which is what makes the + * failure this suite produces attributable to the reserved tag and to nothing else. + */ + @Override + public Set capabilities() { + return EnumSet.noneOf(Capability.class); + } +} diff --git a/tools/tck/src/test/resources/reserved-selftest/reserved-tag.feature b/tools/tck/src/test/resources/reserved-selftest/reserved-tag.feature new file mode 100644 index 0000000000..256b1668ad --- /dev/null +++ b/tools/tck/src/test/resources/reserved-selftest/reserved-tag.feature @@ -0,0 +1,20 @@ +Feature: A reserved capability tag on a scenario fails the run + + This file is the TCK's own proof of the expiry check on Capability.reserved(). It is driven by + ReservedTagExpiryTest through ReservedTagSuiteFixture, which selects this directory and nothing + else, so the two scenarios below run inside a real suite — real parse, real @Before hook, real + CapabilityGate — without affecting any other suite in this module. + + It lives outside extensions/ on purpose. A feature file under extensions/ is selected by every + suite this module runs, and the second scenario here is meant to fail. + + Scenario: A Gherkin comment naming a reserved tag is prose, not a tag + # This scenario is untagged. The line you are reading mentions @caching, exactly as + # gherkin/events.feature does where it explains which stale-provider behaviour is deliberately + # not covered yet. A check that scanned feature files as text rather than reading the parsed + # tags would fail this scenario, and would therefore fail every adoption on the day it shipped. + Given a stable provider + + @caching + Scenario: A tag this implementation still calls reserved fails the run + Given a stable provider From 39ae680ed96f55a1dd62b96be007e539f68cae0f Mon Sep 17 00:00:00 2001 From: Simon Schrottner Date: Sun, 13 Sep 2026 09:54:05 +0200 Subject: [PATCH 38/55] feat(tck): gate the resolution reason behind @standard-reasons Re-pins the spec submodule to c342461a and adds the capability that revision introduces. Unlike the last two re-pins this one moves real assets: gherkin/ gains a sixth file. Requirement 2.2.5 is a SHOULD, and it goes further than the others -- it lets a provider populate reason with one of the listed values "or some other string indicating the semantic reason for the returned flag value". The suite asserted an exact reason in thirteen places across evaluation.feature, errors.feature and lifecycle.feature, which narrowed that into a MUST for every adopter, and bought very little: every canonical flag resolves to a value distinct from the caller's default, so a provider that silently falls back was already caught by the value. Those thirteen are gone and the reasons live in gherkin/reason.feature, gated as a whole on @standard-reasons. The tag is a claim, not an exemption: a provider declaring it says it uses the standard vocabulary with the standard meanings, and that file is what checks the claim. A provider that does not declare it loses nothing, so withholding it needs no KnownDeviation -- values, variants and error codes are asserted everywhere else, on MUSTs. STANDARD_REASONS is therefore an ordinary declarable capability, and its javadoc mirrors Appendix F's wording rather than inventing a second account of the same tag. Two scenarios in the new file carry @targeting and @disabled-flags as well, because TARGETING_MATCH cannot be observed without targeting and DISABLED cannot be observed unless the backend distinguishes a disabled flag. Nothing here selects feature files by name -- @SelectClasspathResource names the gherkin directory and the copy-resources execution globs **/*.feature -- so the new file was collected without a code change. Verified by counting rather than assumed: each of the three self-test suites goes from 57 to 66 collected scenarios, which is 65 canonical plus the one extension scenario. All three self-tests declare the capability, on evidence from running it rather than from reading InMemoryProvider. Seven of the nine new scenarios pass in each: STATIC for a rule-less flag, ERROR beside FLAG_NOT_FOUND and TYPE_MISMATCH, and DISABLED for a disabled flag. The remaining two are skipped for the undeclared @targeting, which is the tag composition working rather than a gap. The multi provider suite is the interesting one -- "a reason rewritten in delegation" is one of the risks that class exists to catch, and the reasons survive the hop. 240 tests, 43 skipped, up from 213 and 37: 27 new scenarios across three suites, 6 of them skipped. Signed-off-by: Simon Schrottner --- tools/tck/README.md | 61 +++++++++++-- tools/tck/spec | 2 +- .../contrib/tools/tck/CanonicalFlags.java | 6 +- .../contrib/tools/tck/Capability.java | 86 +++++++++++++++++-- .../tck/ControllableProviderTckTest.java | 5 +- .../tools/tck/InMemoryProviderTckTest.java | 17 +++- .../tools/tck/MultiProviderTckTest.java | 14 ++- 7 files changed, 167 insertions(+), 24 deletions(-) diff --git a/tools/tck/README.md b/tools/tck/README.md index 31367a52cb..3fabc164c3 100644 --- a/tools/tck/README.md +++ b/tools/tck/README.md @@ -172,14 +172,19 @@ Three suites in this module are exactly the class above, and all three run with under a second. They are the reference adoption, and they are the fast CI canary. [`InMemoryProviderTckTest`](src/test/java/dev/openfeature/contrib/tools/tck/InMemoryProviderTckTest.java) -runs the full applicable suite against the SDK's `InMemoryProvider` — of the 56 scenarios (outline -rows counted individually), 42 pass and 14 are skipped by capability: the six `@lifecycle` ones — one +runs the full applicable suite against the SDK's `InMemoryProvider` — of the 65 scenarios (outline +rows counted individually), 49 pass and 16 are skipped by capability: the six `@lifecycle` ones — one of which also carries `@reinitialization`, and is skipped for the first of the two — the `@stale` -one, the three `@numeric-coercion` ones, the `@large-integers` one and the three `@targeting` ones. +one, the three `@numeric-coercion` ones, the `@large-integers` one and the five `@targeting` ones +(three in `evaluation.feature`, two more in `reason.feature`). It declares `VARIANTS`, because `InMemoryProvider` does name the variant it served, so the gated variant outline runs rather than being skipped. It declares `DISABLED_FLAGS` too, on the same kind of evidence: the provider honours a flag's state, so the four `disabled-*` flags resolve to nothing, the -caller's default stands in with no error code, and all four rows of that outline pass. It does not +caller's default stands in with no error code, and all four rows of that outline pass. It declares +`STANDARD_REASONS` on the same footing: the provider reports `STATIC` for a rule-less flag, `ERROR` +alongside `FLAG_NOT_FOUND` and `TYPE_MISMATCH`, and `DISABLED` for a disabled flag, so seven of +`reason.feature`'s nine scenarios run and pass — the two carrying `@targeting` are skipped because +that capability is withheld, which is the tag composition working as intended. It does not declare `TARGETING`: the provider reads a flag's `variants` and `defaultVariant` and evaluates no rules, so `targeting-key-flag`'s `targeting` member is inert and a matching context resolves `miss` like any other. It does not declare `NUMERIC_COERCION`, because `InMemoryProvider` keeps the two @@ -197,8 +202,8 @@ withholds `LIFECYCLE`. The consequence was that shutdown, double shutdown, shutd backend and initialise-again had coverage only inside a containerised provider suite, where a break in them reads as a provider defect rather than a TCK one. `ControllableProvider` acquires its flag store at `initialize()` time from a store that may refuse it, so it declares `LIFECYCLE`, -`REINITIALIZATION` and `UNAVAILABLE_INIT` and all six `@lifecycle` scenarios run. Of the 14 the -in-memory suite skips, only 8 remain: the three `@numeric-coercion`, the three `@targeting`, the +`REINITIALIZATION` and `UNAVAILABLE_INIT` and all six `@lifecycle` scenarios run. Of the 16 the +in-memory suite skips, only 10 remain: the three `@numeric-coercion`, the five `@targeting`, the `@large-integers` one and the `@stale` one — `@stale` because an in-JVM store can refuse an initialisation but cannot take a connection away from a running provider and hand it back, so `disconnect()` stays at its throwing default. That is the one capability still without Docker-free @@ -332,8 +337,9 @@ Four details are load-bearing: - **`missing-flag` must not exist.** Its absence is what the `FLAG_NOT_FOUND` scenario tests. - **Only `targeting-key-flag` has a targeting rule.** Every other flag resolves to its default - variant whatever the evaluation context, which is what lets the untargeted scenarios expect reason - `STATIC`; seeding targeting onto any other flag breaks them. Its rule is specified by behaviour — + variant whatever the evaluation context, which is what lets a provider declaring + `STANDARD_REASONS` expect `STATIC` rather than `TARGETING_MATCH` for them; seeding targeting onto + any other flag breaks them. Its rule is specified by behaviour — resolve `hit` when the targeting key is exactly `5c3d8535-f81a-4478-a6d3-afaa4d51199e`, `miss` otherwise — so express it however your backend expresses targeting. The flag, its variants and the uuid are flagd-testbed's own, so a backend serving that harness already serves this one. A backend @@ -520,6 +526,7 @@ green on scenarios it did not run is worse than no suite at all. | `NUMERIC_COERCION` | `@numeric-coercion` | coerces between integer and float only when lossless, else `TYPE_MISMATCH` — both directions tested | | `LARGE_INTEGERS` | `@large-integers` | resolves integers up to 2^53 − 1 exactly; **every Java provider withholds it** — the SDK's integer accessor is a 32-bit `Integer`, so the limit is the language's, not the provider's | | `TARGETING` | `@targeting` | resolves `targeting-key-flag` differently for a matching evaluation context — *needs a backend that evaluates rules* | +| `STANDARD_REASONS` | `@standard-reasons` | reports the standard resolution reasons, with the meanings [Appendix F](https://github.com/open-feature/spec/blob/main/specification/appendix-f-provider-conformance.md) gives them — a claim, not an exemption | | `CACHING` | `@caching` | reserved, **not declarable** — no scenarios yet | The default is every *declarable* capability. **Narrow it, do not widen it**: start from the @@ -630,6 +637,44 @@ fails when `TckValues` cannot fit `9007199254740991` into an `Integer`, which is a rejected declaration. The 32-bit precision scenario (`large-integer-flag`, 2^31 − 1) is untagged and always runs. +A note on `STANDARD_REASONS`, which is **a claim rather than an exemption** and is the one capability +whose absence costs a provider nothing. +[Requirement 2.2.5](https://github.com/open-feature/spec/blob/main/specification/sections/02-providers.md) +is a `SHOULD` that goes further than the others: it lets a provider populate `reason` with one of the +listed values *"or some other string indicating the semantic reason for the returned flag value"*. A +provider whose backend reports vendor-specific reasons is therefore conformant, and asserting an exact +reason against it would fail it for something the specification permits. An earlier revision of this +suite did exactly that, in thirteen places across three feature files, and it bought very little — +every canonical flag resolves to a value distinct from the caller's default, so a provider that +silently falls back is already caught by the value assertion. + +So the reasons live in `gherkin/reason.feature`, gated as a whole. Declaring the tag is a provider +saying *"I use the standard vocabulary with the standard meanings"*, and that file is what checks the +claim. A provider that does not declare it loses nothing: its values, variants and error codes are +asserted everywhere else, on `MUST` requirements. What the declaration adds is something a report's +reader can act on — anyone building telemetry, dashboards or debugging on `reason` can see that the +vocabulary was verified rather than assumed. Withholding it needs no `KnownDeviation`. + +| Situation | Reason | +|---|---| +| The flag was resolved from configuration and carries no targeting rule | `STATIC` | +| A targeting rule matched the evaluation context | `TARGETING_MATCH` | +| A targeting rule exists and did not match | `DEFAULT` | +| The flag is disabled in the management system | `DISABLED` | +| The evaluation failed, and an error code is reported with it | `ERROR` | + +`STATIC` for the first row is the call worth flagging. +[`types.md`](https://github.com/open-feature/spec/blob/main/specification/types.md) types `DEFAULT` as +*"no dynamic evaluation occurred **or** dynamic evaluation yielded no result"*, which a rule-less flag +satisfies as readily as `STATIC` does — two providers can disagree here and both conform. A provider +that answers `DEFAULT` for a rule-less flag is not defective; it does not use the standard meanings, +and should not declare the tag. + +**Tags compose, and here that is load-bearing.** `TARGETING_MATCH` cannot be observed without +targeting and `DISABLED` cannot be observed unless the backend distinguishes a disabled flag, so +those two scenarios carry `@targeting` and `@disabled-flags` as well. A provider declaring +`STANDARD_REASONS` alone runs the other six rows and skips those two with their reason. + ### Saying that a gap is a defect A `knownDeviations` entry says one thing: **this provider fails to do something it is required to diff --git a/tools/tck/spec b/tools/tck/spec index ccdb88790b..c342461aa9 160000 --- a/tools/tck/spec +++ b/tools/tck/spec @@ -1 +1 @@ -Subproject commit ccdb88790bb4f4beaef14a182d0c2592feab34b2 +Subproject commit c342461aa95df9e3b46320dbae65e88e5e8b815a diff --git a/tools/tck/src/main/java/dev/openfeature/contrib/tools/tck/CanonicalFlags.java b/tools/tck/src/main/java/dev/openfeature/contrib/tools/tck/CanonicalFlags.java index fe53683771..33dc696a88 100644 --- a/tools/tck/src/main/java/dev/openfeature/contrib/tools/tck/CanonicalFlags.java +++ b/tools/tck/src/main/java/dev/openfeature/contrib/tools/tck/CanonicalFlags.java @@ -42,8 +42,10 @@ *

      • {@code missing-flag} is absent, which is what the {@code FLAG_NOT_FOUND} scenario tests. * Nothing here adds flags the file does not define. *
      • no flag carries a {@link dev.openfeature.sdk.providers.memory.ContextEvaluator}, so every - * evaluation reports reason {@code STATIC} as the feature files expect. The TCK tests a - * provider's mapping of a response, not a backend's evaluation logic. + * evaluation resolves the flag's default variant whatever the context — which is what lets a + * provider declaring {@link Capability#STANDARD_REASONS} report {@code STATIC} rather than + * {@code TARGETING_MATCH} for them. The TCK tests a provider's mapping of a response, not a + * backend's evaluation logic. *
      • a number keeps the width and the kind it was written with. {@code 10} becomes an * {@link Integer} and {@code 10.0} a {@link Double}, because * {@link dev.openfeature.sdk.providers.memory.InMemoryProvider} matches a variant by type: an diff --git a/tools/tck/src/main/java/dev/openfeature/contrib/tools/tck/Capability.java b/tools/tck/src/main/java/dev/openfeature/contrib/tools/tck/Capability.java index 346e361c6b..70d3a45445 100644 --- a/tools/tck/src/main/java/dev/openfeature/contrib/tools/tck/Capability.java +++ b/tools/tck/src/main/java/dev/openfeature/contrib/tools/tck/Capability.java @@ -157,8 +157,10 @@ public enum Capability { * *

        A provider whose backend names its variants declares this and the {@code @variants} * scenario outline runs. One whose backend does not leaves it undeclared, and those rows are - * skipped with that reason rather than passed. Either way the value and reason assertions are - * unaffected: they are untagged, and Requirement 2.2.3 makes the value a {@code MUST}. + * skipped with that reason rather than passed. Either way the value assertions are unaffected: + * they are untagged, and Requirement 2.2.3 makes the value a {@code MUST}. The reason is the same + * shape of question one requirement further on, and it is gated the same way — see + * {@link #STANDARD_REASONS}. */ VARIANTS("@variants"), @@ -187,9 +189,12 @@ public enum Capability { * Appendix * F states the behaviour, as it does for {@link #NUMERIC_COERCION}, and gates it. * - *

        The value is asserted, not the reason. The value rests on Requirement - * 2.2.3, a {@code MUST}; pinning reason {@code DISABLED} would rest on 2.2.5, a {@code SHOULD} - * that explicitly permits "some other string". No variant is asserted either — a disabled flag + *

        The value is asserted here, not the reason. The value rests on Requirement + * 2.2.3, a {@code MUST}; pinning reason {@code DISABLED} on these rows would rest on 2.2.5, a + * {@code SHOULD} that explicitly permits "some other string", and would narrow it for every + * adopter. It is pinned in {@code gherkin/reason.feature} instead, on a scenario carrying both + * this tag and {@code @standard-reasons}, so a provider opts into that narrowing rather than + * inheriting it — see {@link #STANDARD_REASONS}. No variant is asserted either — a disabled flag * resolved no variant, so there is none to name, and this capability and {@link #VARIANTS} * deliberately do not compose. */ @@ -280,6 +285,77 @@ public enum Capability { */ TARGETING("@targeting"), + /** + * Provider reports the standard resolution reasons, with the meanings Appendix F gives them. + * + *

        Gates {@code gherkin/reason.feature} in its entirety — and it is a claim, not an + * exemption. + * Requirement + * 2.2.5 is a {@code SHOULD}, and it goes further than 2.2.4 does: it lets a provider populate + * {@code reason} with one of the listed values "or some other string indicating the semantic + * reason for the returned flag value". A provider whose backend reports vendor-specific + * reasons is therefore conformant, and asserting an exact reason against it would fail it for + * something the specification permits. + * + *

        An earlier revision of the suite asserted a reason in thirteen places across three feature + * files, which narrowed that {@code SHOULD} into a {@code MUST} for every adopter. It bought very + * little: every canonical flag resolves to a value distinct from the caller's default, so a + * provider that silently falls back is already caught by the value assertion, and the reason only + * said why it failed. + * + *

        So declaring this is a provider saying "I use the standard vocabulary with the standard + * meanings", and {@code reason.feature} is what checks the claim. A provider that does not + * declare it loses nothing: its values, variants and error codes are asserted everywhere else, on + * {@code MUST} requirements. What the declaration adds is something a report's reader can act on — + * anyone building telemetry, dashboards or debugging on {@code reason} can see that the vocabulary + * was verified rather than assumed. Withholding it therefore needs no {@link KnownDeviation}. + * + *

        The meanings are the content of the claim, and they constrain nobody who does not make it: + * + * + * + * + * + * + * + * + * + * + *
        The reason each situation is claimed to produce
        SituationReason
        The flag was resolved from configuration and carries no targeting rule{@code STATIC}
        A targeting rule matched the evaluation context{@code TARGETING_MATCH}
        A targeting rule exists and did not match{@code DEFAULT}
        The flag is disabled in the management system{@code DISABLED}
        The evaluation failed, and an error code is reported with it{@code ERROR}
        + * + *

        {@code STATIC} for the first row is the call worth flagging. + * {@code types.md} + * types {@code DEFAULT} as "no dynamic evaluation occurred or dynamic + * evaluation yielded no result", which a rule-less flag satisfies as readily as + * {@code STATIC} does — two providers can disagree here and both conform. A provider that answers + * {@code DEFAULT} for a rule-less flag is not defective; it does not use the standard meanings and + * should not declare the tag. + * + *

        {@code ERROR} is the row where the suite's subject is blurred, and it is asserted anyway. The + * other four rest on + * Requirement + * 1.4.7, which makes the SDK propagate the provider's reason — but only "in cases of normal + * execution". Abnormal execution is 1.4.9, a {@code SHOULD} on the SDK to + * indicate an error, and nothing requires the provider's reason to survive. So a passing + * {@code ERROR} scenario establishes that the value reaching the application is coherent, not that + * the provider produced it. It is still worth asserting: the error code alone is already covered + * ungated in {@code errors.feature}, the reason alone could have been written by the SDK, and an + * evaluation reporting {@code FLAG_NOT_FOUND} with reason {@code STATIC} is incoherent whoever + * wrote it. + * + *

        Tags compose, and here that is load-bearing. {@code TARGETING_MATCH} cannot + * be observed without targeting and {@code DISABLED} cannot be observed unless the backend + * distinguishes a disabled flag, so those scenarios carry {@link #TARGETING} and + * {@link #DISABLED_FLAGS} as well. A provider declaring this one alone runs the rest and skips + * those two with their reason. + * + *

        {@code SPLIT}, {@code UNKNOWN}, {@code CACHED} and {@code STALE} are not asserted. The first + * two have no scenario that produces them; {@code CACHED} belongs behind {@link #CACHING} and needs + * a repeat evaluation that nothing here performs, and {@code STALE} needs a scenario asserting what + * a provider serves during an outage, which is the same gap. + */ + STANDARD_REASONS("@standard-reasons"), + /** * Provider caches evaluation results and invalidates them on configuration change. * diff --git a/tools/tck/src/test/java/dev/openfeature/contrib/tools/tck/ControllableProviderTckTest.java b/tools/tck/src/test/java/dev/openfeature/contrib/tools/tck/ControllableProviderTckTest.java index f9aeed02c4..e9f8dbb275 100644 --- a/tools/tck/src/test/java/dev/openfeature/contrib/tools/tck/ControllableProviderTckTest.java +++ b/tools/tck/src/test/java/dev/openfeature/contrib/tools/tck/ControllableProviderTckTest.java @@ -66,7 +66,7 @@ public FeatureProvider createUnavailableProvider() { /** * {@inheritDoc} * - *

        {@link InMemoryProviderTckTest}'s five, plus the three this suite exists for. Each addition + *

        {@link InMemoryProviderTckTest}'s six, plus the three this suite exists for. Each addition * is a fact about {@link ControllableProvider} rather than a convenience: * *

          @@ -117,6 +117,7 @@ public Set capabilities() { Capability.CONFIGURATION_CHANGE, Capability.OBJECT, Capability.VARIANTS, - Capability.DISABLED_FLAGS); + Capability.DISABLED_FLAGS, + Capability.STANDARD_REASONS); } } diff --git a/tools/tck/src/test/java/dev/openfeature/contrib/tools/tck/InMemoryProviderTckTest.java b/tools/tck/src/test/java/dev/openfeature/contrib/tools/tck/InMemoryProviderTckTest.java index 9aed544e6b..61ededd1a5 100644 --- a/tools/tck/src/test/java/dev/openfeature/contrib/tools/tck/InMemoryProviderTckTest.java +++ b/tools/tck/src/test/java/dev/openfeature/contrib/tools/tck/InMemoryProviderTckTest.java @@ -47,14 +47,22 @@ public FeatureProvider createProvider() { /** * {@inheritDoc} * - *

          Five capabilities, two of which are not obvious. {@link Capability#VARIANTS} holds because + *

          Six capabilities, three of which are not obvious. {@link Capability#VARIANTS} holds because * {@link InMemoryProvider} does name the variant it served, so the gated variant outline runs * and passes here. {@link Capability#DISABLED_FLAGS} holds because it honours a flag's state: the * four {@code disabled-*} flags resolve to nothing and the caller's default stands in, with no * error code, so all four rows of that outline pass. That is not a given for an in-memory * provider — the capability is gated precisely because whether the substitution can happen at all - * depends on where it happens — and it was measured rather than assumed. Each omission below is a - * fact about {@link InMemoryProvider} rather than a convenience: + * depends on where it happens — and it was measured rather than assumed. + * + *

          {@link Capability#STANDARD_REASONS} is the third, and it was measured the same way rather + * than inferred from the provider's source. {@link InMemoryProvider} reports {@code STATIC} for a + * rule-less flag, {@code ERROR} beside {@code FLAG_NOT_FOUND} and {@code TYPE_MISMATCH}, and + * {@code DISABLED} for a disabled flag, so seven of {@code reason.feature}'s nine scenarios run + * and pass. The other two carry {@code @targeting} as well and are skipped for that omission — + * the tag composition doing its job, since a provider that evaluates no rules has no + * {@code TARGETING_MATCH} to report and failing it for the absence would say nothing. Each + * omission below is a fact about {@link InMemoryProvider} rather than a convenience: * *

            *
          • {@link Capability#NUMERIC_COERCION} — omitted. {@link InMemoryProvider} keeps the two @@ -105,6 +113,7 @@ public Set capabilities() { Capability.CONFIGURATION_CHANGE, Capability.OBJECT, Capability.VARIANTS, - Capability.DISABLED_FLAGS); + Capability.DISABLED_FLAGS, + Capability.STANDARD_REASONS); } } diff --git a/tools/tck/src/test/java/dev/openfeature/contrib/tools/tck/MultiProviderTckTest.java b/tools/tck/src/test/java/dev/openfeature/contrib/tools/tck/MultiProviderTckTest.java index 9c687adbe6..237866dc88 100644 --- a/tools/tck/src/test/java/dev/openfeature/contrib/tools/tck/MultiProviderTckTest.java +++ b/tools/tck/src/test/java/dev/openfeature/contrib/tools/tck/MultiProviderTckTest.java @@ -74,7 +74,12 @@ public FeatureProvider createProvider() { * interesting of the two: a disabled flag resolves to nothing, so the child hands back the * caller's default and a facade that substituted a default of its own, or that read the absence * as an error, would be caught on the value. All four rows pass, so the substitution survives the - * hop exactly as the child performs it. {@link Capability#LIFECYCLE} and + * hop exactly as the child performs it. {@link Capability#STANDARD_REASONS} is declared for the + * same kind of reason and answers one of the risks named at the top of this class: a reason + * rewritten in delegation. The child reports the standard vocabulary, and + * {@code reason.feature}'s seven applicable scenarios pass through {@code MultiProvider} + * unchanged, so {@code STATIC}, {@code ERROR} and {@code DISABLED} all survive the hop. {@link + * Capability#LIFECYCLE} and * {@link Capability#NUMERIC_COERCION} are omitted for * the same reasons as in {@link InMemoryProviderTckTest}: nothing here reaches a backend during * initialisation, and the child refuses the lossless coercions the tag now requires — a facade @@ -83,6 +88,11 @@ public FeatureProvider createProvider() { */ @Override public Set capabilities() { - return EnumSet.of(Capability.EVENTS, Capability.OBJECT, Capability.VARIANTS, Capability.DISABLED_FLAGS); + return EnumSet.of( + Capability.EVENTS, + Capability.OBJECT, + Capability.VARIANTS, + Capability.DISABLED_FLAGS, + Capability.STANDARD_REASONS); } } From a5b0ce8161c18ab188cc80144a29e96887fc18f6 Mon Sep 17 00:00:00 2001 From: Simon Schrottner Date: Sun, 13 Sep 2026 11:02:00 +0200 Subject: [PATCH 39/55] test(tck): fail the build when a declarable capability gates nothing CapabilityGate.requireNoExpiredReservation already fails a run where a scenario carries a reserved tag: a capability no adopter may declare, gating something, so the scenario is skipped forever and nothing notices. This is the other half of the same rule, and until now nothing asserted it -- a capability an adopter MAY declare that gates nothing. Such a claim produces no skip, cannot be contradicted by any result, and tells a report's reader that a capability was examined when nothing examined it. It has one realistic cause and it is a build accident, not a design mistake. The canonical assets are copied out of the spec submodule at generate-resources, and the submodule's gitlink and its working tree move by different commands: a rebase or a branch switch updates the gitlink, only `git submodule update` moves the checkout. Build in between and the copy step overwrites the new assets with the old ones. The result is internally consistent -- the old feature files agree with each other -- so counting scenarios does not catch it. A capability added in the same commit as the pin that gives it scenarios is then declarable, and gates nothing. That is not hypothetical: it happened in the Python suite on this exact re-pin. CanonicalTagCoverageTest reads gherkin/ out of this artifact's own code source -- the same rule the canonical set is read by, so a feature file shadowing ours on another classpath root cannot answer for it -- and asserts both directions: every Capability.declarable() tag is carried by some canonical scenario, and no reserved one is. The tags are parsed with GherkinParser rather than scanned, and the third test pins the reason rather than describing it: events.feature names @caching inside a Gherkin comment explaining what is deliberately uncovered, so a grep-shaped implementation would report an expired reservation forever. The test asserts both that the string is still there in prose and that nothing carries it as a tag. A test rather than a runtime check, deliberately. The reserved direction has to fail an adopter's run, because a reservation expires when the specification writes the scenarios it was held open for and this package may not have followed. This direction can only be introduced by a build of this artifact, so making every adopter parse six feature files at suite start would pay the cost in the wrong place. Verified by breaking it rather than by reading it. Checking the submodule out at ccdb8879 while leaving the gitlink at c342461a -- the exact failure mode above -- fails everyDeclarableCapabilityGatesSomething naming STANDARD_REASONS, and nothing else in the module notices: the three suites quietly collect 57 scenarios instead of 66 and stay green, and all 216 other tests pass. Marking TARGETING reserved fails noReservedCapabilityGatesAnything naming @targeting. 243 tests, was 240. Signed-off-by: Simon Schrottner --- tools/tck/README.md | 12 + .../tools/tck/CanonicalTagCoverageTest.java | 207 ++++++++++++++++++ 2 files changed, 219 insertions(+) create mode 100644 tools/tck/src/test/java/dev/openfeature/contrib/tools/tck/CanonicalTagCoverageTest.java diff --git a/tools/tck/README.md b/tools/tck/README.md index 3fabc164c3..0bcf8b9fb2 100644 --- a/tools/tck/README.md +++ b/tools/tck/README.md @@ -564,6 +564,18 @@ Cucumber's parsed tags rather than the feature files as text, which matters more `gherkin/events.feature` names `@caching` inside a `#` comment explaining what is deliberately not covered yet, so a text scan would fail every adoption on the day it shipped. +There is a third direction, and it is this module's own build that has to catch it: a capability that +is **declarable and gates nothing**. That is the same vacuous claim as a declared reserved tag — +nothing can produce a skip, no result can contradict it, and a report tells its reader a capability +was examined when nothing examined it. Its realistic cause is a build accident rather than a design +mistake: the canonical assets are copied out of the `spec` submodule, and the submodule's gitlink and +its working tree move by different commands, so a rebase followed by a build can overwrite the new +assets with the old ones. The result is internally consistent — the old feature files agree with each +other — so **counting scenarios does not catch it**. `CanonicalTagCoverageTest` asserts that every +declarable capability's tag is carried by at least one canonical scenario, and that no reserved one +is; it parses the packaged Gherkin for the same reason the runtime check does. If you re-pin the +submodule, run `git submodule update` before building, and let that test tell you if you forgot. + That is a rule about an accident rather than about intent: `EnumSet.complementOf(EnumSet.of(X))` reads as "everything except X" and in fact means "every other enum constant", reserved tags included. The flagd suite said exactly that and published `"declared": [..., "@targeting", diff --git a/tools/tck/src/test/java/dev/openfeature/contrib/tools/tck/CanonicalTagCoverageTest.java b/tools/tck/src/test/java/dev/openfeature/contrib/tools/tck/CanonicalTagCoverageTest.java new file mode 100644 index 0000000000..2ff44593db --- /dev/null +++ b/tools/tck/src/test/java/dev/openfeature/contrib/tools/tck/CanonicalTagCoverageTest.java @@ -0,0 +1,207 @@ +package dev.openfeature.contrib.tools.tck; + +import static org.assertj.core.api.Assertions.assertThat; + +import io.cucumber.gherkin.GherkinParser; +import io.cucumber.messages.types.Envelope; +import io.cucumber.messages.types.Examples; +import io.cucumber.messages.types.Feature; +import io.cucumber.messages.types.FeatureChild; +import io.cucumber.messages.types.GherkinDocument; +import io.cucumber.messages.types.Rule; +import io.cucumber.messages.types.RuleChild; +import io.cucumber.messages.types.Scenario; +import io.cucumber.messages.types.Tag; +import java.io.IOException; +import java.net.URISyntaxException; +import java.nio.charset.StandardCharsets; +import java.nio.file.DirectoryStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.security.CodeSource; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; +import java.util.stream.Stream; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** + * Every declarable capability is carried by at least one canonical scenario, and no reserved one is. + * + *

            {@link CapabilityGate#requireNoExpiredReservation} already fails a run where a scenario + * carries a reserved tag — a capability no adopter may declare, gating something, so the scenario is + * skipped forever and nothing notices. This is the other half of the same rule: a capability an + * adopter may declare that gates nothing. Such a declaration cannot be + * produced a skip, cannot be contradicted by any result, and tells a report's reader that a + * capability was examined when nothing examined it. That is the same vacuous claim, arrived at from + * the opposite direction. + * + *

            It has one realistic cause, and it is a build accident rather than a design mistake: the + * canonical assets are copied out of the {@code spec} submodule at {@code generate-resources}, and + * the submodule's working tree and the gitlink are moved by different commands. A rebase or a branch + * switch updates the gitlink; only {@code git submodule update} moves the checkout. Between the two, + * the copy step happily overwrites the new assets with the old ones, and the result is internally + * consistent — the old feature files agree with each other — so counting scenarios does not + * catch it. A capability added in the same commit as the pin that gives it scenarios is then + * declarable, and gates nothing. + * + *

            This is a test rather than a runtime check on purpose. The reserved direction has to fail an + * adopter's run, because a reservation expires when the specification writes scenarios for it and + * this package may not have followed. This direction can only be introduced by a build of + * this artifact, so it belongs in this artifact's own tests — and making every adopter parse + * six feature files at suite start to detect a mistake only this repository can make would be a cost + * paid in the wrong place. + * + *

            The tags are parsed, not scanned. {@code gherkin/events.feature} names + * {@code @caching} inside a Gherkin {@code #} comment, explaining which stale-provider behaviour is + * deliberately uncovered, so a text scan reports a reserved tag that no scenario carries. The third + * test below asserts exactly that, so the distinction is pinned rather than described. + */ +class CanonicalTagCoverageTest { + + /** Tags carried by the canonical feature files this artifact ships, as Gherkin parses them. */ + private static final Set CARRIED = readCarriedTags(); + + @Test + @DisplayName("every declarable capability is carried by at least one canonical scenario") + void everyDeclarableCapabilityGatesSomething() { + for (Capability capability : Capability.declarable()) { + assertThat(CARRIED) + .as( + "%s (%s) is declarable, so an adopter may claim it — but no canonical scenario " + + "carries its tag, so the claim gates nothing and no result can contradict " + + "it. Either the packaged gherkin/ is stale (check that the spec submodule " + + "working tree matches the gitlink: git -C tools/tck/spec rev-parse HEAD) " + + "or the capability was added ahead of its scenarios, in which case mark it " + + "reserved until they arrive.", + capability.name(), capability.tag()) + .contains(capability.tag()); + } + } + + @Test + @DisplayName("no reserved capability's tag is carried by a canonical scenario") + void noReservedCapabilityGatesAnything() { + for (Capability capability : Capability.values()) { + if (!capability.reserved()) { + continue; + } + assertThat(CARRIED) + .as( + "%s (%s) is still marked reserved, but a canonical scenario now carries its tag. " + + "The reservation has expired: drop the reserved flag so an adopter can " + + "declare it and be held to it. CapabilityGate fails such a scenario at " + + "run time; this says it at build time.", + capability.name(), capability.tag()) + .doesNotContain(capability.tag()); + } + } + + @Test + @DisplayName("a tag named only in a Gherkin comment is prose, not a tag") + void aTagInACommentIsNotCarried() { + // The trap that makes a text scan wrong on day one, asserted rather than described. + // events.feature explains what @caching would cover, inside a comment; a grep-shaped + // implementation of the test above would report CACHING as an expired reservation forever. + assertThat(rawCanonicalText()) + .as("events.feature still names @caching in prose, which is what makes this test worth having") + .contains("@caching"); + assertThat(CARRIED).as("but nothing carries it as a tag").doesNotContain(Capability.CACHING.tag()); + } + + private static Set readCarriedTags() { + Set tags = new LinkedHashSet<>(); + forEachCanonicalFeature((name, content) -> { + GherkinParser parser = GherkinParser.builder() + .includeSource(false) + .includePickles(false) + .includeGherkinDocument(true) + .build(); + try (Stream envelopes = parser.parse(name, content)) { + envelopes.forEach(envelope -> envelope.getGherkinDocument() + .flatMap(GherkinDocument::getFeature) + .ifPresent(feature -> collectFeature(feature, tags))); + } + }); + if (tags.isEmpty()) { + throw new IllegalStateException("No tags found in the packaged canonical feature files. The " + + "artifact is not intact, or gherkin/ was not copied from the spec submodule."); + } + return tags; + } + + private static String rawCanonicalText() { + StringBuilder all = new StringBuilder(); + forEachCanonicalFeature((name, content) -> all.append(new String(content, StandardCharsets.UTF_8))); + return all.toString(); + } + + private static void collectFeature(Feature feature, Set tags) { + addAll(feature.getTags(), tags); + for (FeatureChild child : feature.getChildren()) { + child.getScenario().ifPresent(scenario -> collectScenario(scenario, tags)); + child.getRule().ifPresent(rule -> collectRule(rule, tags)); + } + } + + private static void collectRule(Rule rule, Set tags) { + addAll(rule.getTags(), tags); + for (RuleChild child : rule.getChildren()) { + child.getScenario().ifPresent(scenario -> collectScenario(scenario, tags)); + } + } + + private static void collectScenario(Scenario scenario, Set tags) { + addAll(scenario.getTags(), tags); + for (Examples examples : scenario.getExamples()) { + addAll(examples.getTags(), tags); + } + } + + private static void addAll(List from, Set tags) { + for (Tag tag : from) { + tags.add(tag.getName()); + } + } + + /** + * Reads {@code gherkin/} out of this artifact's own code source, as {@link ProviderTck} was + * loaded from, rather than through the classloader — the same rule the canonical set is read by, + * and for the same reason: a feature file placed in {@code gherkin/} on another classpath root + * shadows the canonical one, and a check that read the shadowed copy would be checking the + * replacement against itself. + */ + private static void forEachCanonicalFeature(FeatureConsumer consumer) { + CodeSource codeSource = ProviderTck.class.getProtectionDomain().getCodeSource(); + if (codeSource == null || codeSource.getLocation() == null) { + throw new IllegalStateException("The tck code source is not visible to this JVM, so the packaged " + + "canonical feature files cannot be read."); + } + Path root; + try { + root = Paths.get(codeSource.getLocation().toURI()); + } catch (URISyntaxException | IllegalArgumentException e) { + throw new IllegalStateException("The tck code source is not a file: " + codeSource.getLocation(), e); + } + + Path features = root.resolve(ProviderTck.FEATURES); + if (!Files.isDirectory(features)) { + throw new IllegalStateException("No " + ProviderTck.FEATURES + "/ directory in the tck code source " + root + + ". The canonical assets were not copied from the spec submodule."); + } + try (DirectoryStream entries = Files.newDirectoryStream(features, "*.feature")) { + for (Path entry : entries) { + consumer.accept(ProviderTck.FEATURES + "/" + entry.getFileName(), Files.readAllBytes(entry)); + } + } catch (IOException e) { + throw new IllegalStateException("Could not read the canonical feature files from " + features, e); + } + } + + @FunctionalInterface + private interface FeatureConsumer { + void accept(String name, byte[] content); + } +} From 0e28f59b1f3ee5fcd2ca6afd2b48a6b7d64f6608 Mon Sep 17 00:00:00 2001 From: Simon Schrottner Date: Sun, 13 Sep 2026 15:08:45 +0200 Subject: [PATCH 40/55] chore(tck): re-pin the canonical assets to 89b1519a Two commits since the last pin. e616ff4d adds a fifth declaring rule to Appendix F -- a capability the language's SDK cannot express is refused by the implementation rather than left to adopters -- which the next commit implements. 89b1519a fixes the two $comment blocks in canonical-flags.json that this pass reported upstream: they still said every scenario expects reason STATIC, which stopped being the house rule when @standard-reasons made it a claim a provider declares. No scenario moves, so nothing about the collected set changes: three self-test suites at 66 collected scenarios, the same 16, 10 and 17 skips. A pin whose only change is content is exactly the pin that no count and no tag check in this module would have noticed, which is what the following commit is about. Signed-off-by: Simon Schrottner --- tools/tck/spec | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tools/tck/spec b/tools/tck/spec index c342461aa9..89b1519a08 160000 --- a/tools/tck/spec +++ b/tools/tck/spec @@ -1 +1 @@ -Subproject commit c342461aa95df9e3b46320dbae65e88e5e8b815a +Subproject commit 89b1519a08d81c46ba47fc2a54c44d40fdee845d From 7098d51dcf2823aab3133761fb47de9e559d9cfa Mon Sep 17 00:00:00 2001 From: Simon Schrottner Date: Sun, 13 Sep 2026 15:09:16 +0200 Subject: [PATCH 41/55] build(tck): fail the build when the packaged assets are not the pinned ones A re-pin moves two things by two different commands. A rebase, a branch switch or a checkout moves the gitlink; only `git submodule update` moves the submodule's working tree. Build in between and copy-resources packages the previous pin's assets under the new pin's name -- and nothing looks wrong, because the old feature files agree with each other and with the old flag set. Another language's suite ran an entire adoption that way; the only trace was that its totals matched the previous run exactly, and nothing failed or warned. Java has not hit it, but only because the operator ran `git submodule update` by hand after every rebase and checked the result with rev-parse. That is a procedure, not a property of the build. Three changes make it a property: The checkout at `initialize` gets a switch of its own, tck.spec.checkout.skip, instead of riding on exec-maven-plugin's generic exec.skip. Skipping it was previously reachable as a side effect of skipping something else, which is how every local run in this repository has skipped it without meaning to. copy-resources overwrites and never deletes, and its three target directories are git-ignored, so a file present in the old pin and absent from the new one survives a re-pin. Going forwards that is invisible; going backwards -- a baseline measurement, a bisect -- it produces an asset set that exists in no revision of the specification and a run against it that looks entirely plausible. That cost two wasted runs in the previous pass. A maven-clean-plugin execution now empties the three directories at generate-resources, ahead of the copy. CanonicalAssetDigestTest pins a SHA-256 over all three asset trees, read out of this artifact's own code source, with line endings normalised so a Windows checkout and a Linux one agree. This is what makes skipping the checkout safe, and it is the only one of the three that catches a pin whose only change is content: the re-pin it ships beside changed two $comment blocks in canonical-flags.json and not one scenario, which every count and every tag check in this module passes unmoved. Verified by breaking it -- the submodule working tree moved back to c342461a fails the digest test and nothing else in the module notices, and a planted leftover file under src/main/resources/gherkin/ is removed by the clean execution rather than packaged. CanonicalTagCoverageTest stays. It catches one symptom of the same accident with a far better message, and a digest can only say that something differs. Signed-off-by: Simon Schrottner --- tools/tck/README.md | 31 +++ tools/tck/pom.xml | 76 ++++++- .../tools/tck/CanonicalAssetDigestTest.java | 210 ++++++++++++++++++ 3 files changed, 315 insertions(+), 2 deletions(-) create mode 100644 tools/tck/src/test/java/dev/openfeature/contrib/tools/tck/CanonicalAssetDigestTest.java diff --git a/tools/tck/README.md b/tools/tck/README.md index 0bcf8b9fb2..66f58a40c0 100644 --- a/tools/tck/README.md +++ b/tools/tck/README.md @@ -907,6 +907,37 @@ git submodule update --init tools/tck/spec Maven does this itself at `initialize`, so a plain `mvn verify` works from a fresh clone; the explicit command is only useful when working offline or inspecting the sources by hand. +### Stale assets + +A re-pin moves two things by two different commands, and a build between them is silent. A rebase, a +branch switch or a `git checkout` moves the **gitlink**; only `git submodule update` moves the +**working tree**. Build in between and the copy step packages the previous pin's assets under the new +pin's name — and nothing looks wrong, because the old feature files agree with each other and with +the old flag set. One language's suite ran a whole adoption this way and the only trace was that its +totals matched the previous run exactly. + +Three things in the build make that unrepresentable rather than something to remember: + +1. **The checkout runs on every build**, at `initialize`. It is skippable, but only through + `-Dtck.spec.checkout.skip=true`, which is a switch of its own rather than a generic one — the + point being that you cannot turn it off as a side effect of turning something else off. There is + one good reason to use it: a checkout where `git` cannot read the repository at all. +2. **The three generated directories are emptied before anything is copied into them**, at + `generate-resources`. `copy-resources` overwrites and never deletes, and they are git-ignored, so + without this a file that exists in the old pin and not in the new one survives the re-pin. Going + forwards that is invisible; going *backwards* — a baseline measurement, a bisect — it produces an + asset set that exists in no revision of the specification, and a run against it that looks + entirely plausible. +3. **`CanonicalAssetDigestTest` fails the build if the packaged assets are not the pinned revision's**, + by digest, over all three directories and not just the Gherkin. This is what makes skipping the + checkout safe, and it is the only one of the three that catches a pin whose only change is + *content*: the re-pin it was written for changed two `$comment` blocks in `canonical-flags.json` + and not one scenario, which every count and every tag check in this module passes unmoved. + +Re-pinning is therefore one commit containing the gitlink, `PINNED_REVISION` and `PINNED_DIGEST` in +that test — which prints the value it wanted when it fails — and, on a branch that reports it, +`tck.spec.revision` in the POM. + ## Known gaps - **Evaluation context passthrough, beyond the targeting key.** `targeting-key-flag` resolves diff --git a/tools/tck/pom.xml b/tools/tck/pom.xml index 58c0a10261..99e5987db4 100644 --- a/tools/tck/pom.xml +++ b/tools/tck/pom.xml @@ -14,6 +14,17 @@ ${groupId}.tck + + + false + 3.27.7 4.3.0 2.22.1 @@ -208,8 +219,25 @@ org.codehaus.mojo @@ -223,6 +251,7 @@ exec + ${tck.spec.checkout.skip} git submodule @@ -236,7 +265,50 @@ + + maven-clean-plugin + 3.5.0 + + + clear-generated-spec-assets + generate-resources + + clean + + + true + + + ${basedir}/src/main/resources/gherkin + + + ${basedir}/src/main/resources/flags + + + ${basedir}/src/main/resources/openapi + + + + + + + + diff --git a/tools/tck/src/test/java/dev/openfeature/contrib/tools/tck/CanonicalAssetDigestTest.java b/tools/tck/src/test/java/dev/openfeature/contrib/tools/tck/CanonicalAssetDigestTest.java new file mode 100644 index 0000000000..19d5a6834c --- /dev/null +++ b/tools/tck/src/test/java/dev/openfeature/contrib/tools/tck/CanonicalAssetDigestTest.java @@ -0,0 +1,210 @@ +package dev.openfeature.contrib.tools.tck; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.net.URISyntaxException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.security.CodeSource; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Comparator; +import java.util.List; +import java.util.stream.Collectors; +import java.util.stream.Stream; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** + * The canonical assets in this build are the ones the pinned specification revision defines. + * + *

            They are copied out of the {@code spec} submodule at {@code generate-resources}, and the + * submodule's gitlink and its working tree are moved by different commands: a + * rebase, a branch switch or a checkout moves the first, and only {@code git submodule update} + * moves the second. Build in between and the copy step packages the previous pin's assets under + * the new pin's name. Nothing about the result looks wrong — the old feature files agree with each + * other and with the old flag set, so the suite runs, passes, and reports numbers that describe a + * revision nobody asked for. That is not hypothetical; one language's suite did exactly this for a + * whole adoption run and the only trace was that its totals matched the previous pass exactly. + * + *

            {@link CanonicalTagCoverageTest} catches one symptom of it — a declarable capability whose + * scenarios have not arrived — and catches it with a better message than this test could give. It + * only fires for that symptom, though. A pin that changes the wording of a scenario, the value of a + * canonical flag, or a field of the control API moves nothing it looks at, and those are the + * changes most pins actually make: the re-pin this test was written for changed two + * {@code $comment} blocks in {@code canonical-flags.json} and not one scenario. + * + *

            So this asserts the assets themselves, by digest. It is deliberately blunt: it cannot say + * what differs, only that what was packaged is not what the pin names. The build step it + * backs up is in {@code pom.xml}, and the division between them is the point — the build moves the + * checkout and empties the copy's target directory, and this fails the build if the assets are + * nevertheless wrong. Every route to stale assets ends here, including the one where the checkout + * was skipped by hand. + * + *

            Updating the pin

            + * + *

            Three things move together, in one commit: + * + *

              + *
            1. the {@code spec} submodule gitlink — {@code git -C tools/tck/spec checkout }; + *
            2. {@link #PINNED_REVISION} below; + *
            3. {@link #PINNED_DIGEST} below, which this test prints when it fails. + *
            + * + *

            On the report branch a fourth follows: {@code tck.spec.revision} in the POM, which is what a + * conformance report names as the source of its scenarios. That one is checked against + * {@link #PINNED_REVISION} where it is read back, so it cannot be forgotten. + * + *

            What the digest is over

            + * + *

            All three asset trees, not just the Gherkin. The feature files are meaningless without the + * flag set they evaluate and the control API that produces their outages, and it is the flag set + * that a Gherkin-only digest would have missed here. + * + *

            Line endings are normalised out of it. The assets are checked out through git, so a Windows + * clone with {@code core.autocrlf=true} holds bytes a Linux one does not, and a digest that + * disagreed with itself across platforms would be turned off within a week. Nothing else is + * normalised: trailing whitespace, ordering and encoding are all part of what is pinned. + */ +class CanonicalAssetDigestTest { + + /** + * The open-feature/spec commit the packaged assets come from. + * + *

            Must equal {@code git -C tools/tck/spec rev-parse HEAD}, and is the value the report + * branch publishes as the source of a run's scenarios. + */ + static final String PINNED_REVISION = "89b1519a08d81c46ba47fc2a54c44d40fdee845d"; + + /** SHA-256 of the three asset trees at {@link #PINNED_REVISION}, as {@link #digest} computes it. */ + static final String PINNED_DIGEST = "a7c74fbe178cf5a9937be8d26e098bc4d6f4b723991a77e0e73d0a330a299c94"; + + /** The generated resource directories, in the order they are digested. */ + private static final List ASSET_DIRECTORIES = Arrays.asList("flags", "gherkin", "openapi"); + + @Test + @DisplayName("the packaged canonical assets are the pinned revision's, byte for byte") + void thePackagedAssetsAreThePinnedRevisions() { + String actual = digest(); + assertThat(actual) + .as( + "The canonical assets packaged in this build are not the ones open-feature/spec %s " + + "defines.%n%n" + + " expected %s%n" + + " actual %s%n%n" + + "The usual cause is a spec submodule whose working tree has not caught up with " + + "its gitlink: a rebase or a checkout moves the gitlink, and only `git submodule " + + "update` moves the checkout. Run, from the repository root:%n%n" + + " git submodule update --init tools/tck/spec%n" + + " git -C tools/tck/spec rev-parse HEAD # must print %s%n%n" + + "then rebuild with `clean`, because the copies under src/main/resources are " + + "generated and gitignored.%n%n" + + "If you meant to move the pin, update PINNED_REVISION and PINNED_DIGEST in this " + + "class in the same commit as the gitlink, taking the digest from the `actual` " + + "line above.", + PINNED_REVISION, PINNED_DIGEST, actual, PINNED_REVISION) + .isEqualTo(PINNED_DIGEST); + } + + /** + * Digests the packaged assets. + * + *

            Every regular file under {@code flags/}, {@code gherkin/} and {@code openapi/}, ordered by + * its path relative to the code source root with {@code /} separators. Each contributes its path + * and then its content, both terminated by a zero byte so that no rename can be absorbed into a + * neighbouring file's bytes. Carriage returns are dropped from the content; see the class + * comment. + */ + private static String digest() { + MessageDigest sha256; + try { + sha256 = MessageDigest.getInstance("SHA-256"); + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException("This JVM does not provide SHA-256", e); + } + Path root = codeSourceRoot(); + for (Path file : assetFiles(root)) { + sha256.update(relativePath(root, file).getBytes(StandardCharsets.UTF_8)); + sha256.update((byte) 0); + sha256.update(withoutCarriageReturns(read(file))); + sha256.update((byte) 0); + } + StringBuilder hex = new StringBuilder(); + for (byte b : sha256.digest()) { + hex.append(String.format("%02x", b)); + } + return hex.toString(); + } + + private static List assetFiles(Path root) { + List files = new ArrayList<>(); + for (String directory : ASSET_DIRECTORIES) { + Path assets = root.resolve(directory); + if (!Files.isDirectory(assets)) { + throw new IllegalStateException("No " + directory + "/ directory in the tck code source " + root + + ". The canonical assets were not copied from the spec submodule; run the build " + + "through Maven rather than compiling the sources alone."); + } + try (Stream walk = Files.walk(assets)) { + files.addAll(walk.filter(Files::isRegularFile).collect(Collectors.toList())); + } catch (IOException e) { + throw new UncheckedIOException("Could not list the canonical assets under " + assets, e); + } + } + files.sort(Comparator.comparing(file -> relativePath(root, file))); + return files; + } + + private static String relativePath(Path root, Path file) { + return root.relativize(file).toString().replace('\\', '/'); + } + + private static byte[] read(Path file) { + try { + return Files.readAllBytes(file); + } catch (IOException e) { + throw new UncheckedIOException("Could not read the canonical asset " + file, e); + } + } + + private static byte[] withoutCarriageReturns(byte[] content) { + byte[] stripped = new byte[content.length]; + int length = 0; + for (byte b : content) { + if (b != '\r') { + stripped[length++] = b; + } + } + byte[] exact = new byte[length]; + System.arraycopy(stripped, 0, exact, 0, length); + return exact; + } + + /** + * The directory this artifact's classes and resources were loaded from. + * + *

            Read through the code source rather than the classloader, as {@link CanonicalTagCoverageTest} + * and {@code CanonicalScenarios} read it, and for the same reason: an asset placed at the same + * classpath path on another root shadows the packaged one, and a check that digested the + * shadowing copy would be comparing the replacement against itself. + */ + private static Path codeSourceRoot() { + CodeSource codeSource = ProviderTck.class.getProtectionDomain().getCodeSource(); + if (codeSource == null || codeSource.getLocation() == null) { + throw new IllegalStateException( + "The tck code source is not visible to this JVM, so the packaged canonical assets " + + "cannot be read."); + } + try { + return Paths.get(codeSource.getLocation().toURI()); + } catch (URISyntaxException | IllegalArgumentException e) { + throw new IllegalStateException("The tck code source is not a file: " + codeSource.getLocation(), e); + } + } +} From d9ca3dd97fbd5877600356e025588a1c69e23150 Mon Sep 17 00:00:00 2001 From: Simon Schrottner Date: Sun, 13 Sep 2026 15:09:46 +0200 Subject: [PATCH 42/55] feat(tck): refuse @large-integers, which no Java provider can be asked Client.getIntegerDetails takes and returns a 32-bit Integer, so 2^53 - 1 cannot be asked for by any Java provider, however faithfully its backend serves it. That says nothing about any provider and everything about the SDK it is written against, and it stays true until the SDK grows a wider accessor. Until now it was documentation, and every Java adopter was expected to act on it. They did, four times: InMemoryProviderTckTest, ControllableProviderTckTest, the flagd suite and the OFREP suite each left the capability undeclared, each with its own comment restating the same property of the language. A fact about Java remembered in four places and in every future adoption, where a single wrong one puts a claim in a report that no scenario could have verified -- which is the failure the reserved-capability rules exist to prevent, reached by another route. Appendix F states the rule at e616ff4d: a capability the language's SDK cannot express is refused by the implementation, not left to adopters. So Capability.LARGE_INTEGERS is inexpressible: absent from declarable(), refused by requireDeclarable() with a message that names the accessor rather than citing a rule, and skipped by CapabilityGate with a reason that names the SDK. The two refusals are kept apart, deliberately, because they are different facts. A reserved capability has no scenarios in any language and its reservation expires the moment the specification writes them. An inexpressible one has scenarios that run and pass in Go and JavaScript, and lasts until this SDK changes. Nothing collapses them: two fields, two branches in requireDeclarable that report separately so a declaration getting both wrong hears about both, and two skip reasons. A reader who sees a capability missing from a report has to be able to tell "this provider declined" from "no provider in this language can be asked", because only the first says anything about the provider -- and the surefire results now carry both sentences verbatim. The inexpressible skip is decided before the declaration is consulted rather than after. A declaration cannot contain the capability, so checking it second would make the right reason appear only by luck. Consequences elsewhere: - CanonicalTagCoverageTest's first test now runs over every capability that is not reserved, rather than over declarable(). @large-integers having scenarios is precisely what distinguishes it from a reservation, so checking only the declarable set would have stopped looking at the one capability whose whole justification is that the scenarios exist. - TckValues' "not an Integer the Java SDK can ask for" message can no longer be reached by the canonical scenario, which is now always skipped. It stays, for an extension feature file that asks for a value outside the accessor's range, and says so. - The two self-test suites lose their bullets about it, which is the point. No count moves. The scenario was already being skipped; only the reason changed. Signed-off-by: Simon Schrottner --- tools/tck/README.md | 80 +++++--- .../contrib/tools/tck/Capability.java | 183 +++++++++++++----- .../contrib/tools/tck/CapabilityGate.java | 45 +++-- .../contrib/tools/tck/ProviderTckHarness.java | 13 +- .../contrib/tools/tck/TckValues.java | 11 +- .../contrib/tools/tck/CanonicalFlagsTest.java | 4 +- .../tools/tck/CanonicalTagCoverageTest.java | 39 ++-- .../tck/ControllableProviderTckTest.java | 6 +- .../contrib/tools/tck/DeclarationApiTest.java | 99 +++++++--- .../tools/tck/InMemoryProviderTckTest.java | 10 +- 10 files changed, 353 insertions(+), 137 deletions(-) diff --git a/tools/tck/README.md b/tools/tck/README.md index 66f58a40c0..f543790e7d 100644 --- a/tools/tck/README.md +++ b/tools/tck/README.md @@ -175,7 +175,8 @@ under a second. They are the reference adoption, and they are the fast CI canary runs the full applicable suite against the SDK's `InMemoryProvider` — of the 65 scenarios (outline rows counted individually), 49 pass and 16 are skipped by capability: the six `@lifecycle` ones — one of which also carries `@reinitialization`, and is skipped for the first of the two — the `@stale` -one, the three `@numeric-coercion` ones, the `@large-integers` one and the five `@targeting` ones +one, the three `@numeric-coercion` ones, the `@large-integers` one — which no Java provider can +declare, so that skip is the SDK's rather than this suite's — and the five `@targeting` ones (three in `evaluation.feature`, two more in `reason.feature`). It declares `VARIANTS`, because `InMemoryProvider` does name the variant it served, so the gated variant outline runs rather than being skipped. It declares `DISABLED_FLAGS` too, on the same kind of @@ -524,7 +525,7 @@ green on scenarios it did not run is worse than no suite at all. | `DISABLED_FLAGS` | `@disabled-flags` | resolves a flag disabled in the management system to the code default — *needs the substitution to happen where the caller's default is, so a provider whose backend decides cannot hold it* | | `UNAVAILABLE_INIT` | `@unavailable` | reports an error state instead of hanging on a dead backend — *needs connection control* | | `NUMERIC_COERCION` | `@numeric-coercion` | coerces between integer and float only when lossless, else `TYPE_MISMATCH` — both directions tested | -| `LARGE_INTEGERS` | `@large-integers` | resolves integers up to 2^53 − 1 exactly; **every Java provider withholds it** — the SDK's integer accessor is a 32-bit `Integer`, so the limit is the language's, not the provider's | +| `LARGE_INTEGERS` | `@large-integers` | resolves integers up to 2^53 − 1 exactly — **not declarable in Java**, because `Client.getIntegerDetails` is a 32-bit `Integer` and no Java provider can be asked the question | | `TARGETING` | `@targeting` | resolves `targeting-key-flag` differently for a matching evaluation context — *needs a backend that evaluates rules* | | `STANDARD_REASONS` | `@standard-reasons` | reports the standard resolution reasons, with the meanings [Appendix F](https://github.com/open-feature/spec/blob/main/specification/appendix-f-provider-conformance.md) gives them — a claim, not an exemption | | `CACHING` | `@caching` | reserved, **not declarable** — no scenarios yet | @@ -532,6 +533,18 @@ green on scenarios it did not run is worse than no suite at all. The default is every *declarable* capability. **Narrow it, do not widen it**: start from the default, run the suite, and remove only what your provider genuinely cannot do. +Two rows above are not yours to decide and are refused if you name them, with different messages +because they are different facts: + +- **`CACHING` is reserved.** No scenario in any language carries the tag yet. The reservation + expires the moment the specification writes them, and then it becomes an ordinary capability — + `TARGETING` was reserved until `targeting-key-flag` arrived. +- **`LARGE_INTEGERS` is inexpressible in Java.** The scenario exists, and Go and JavaScript run it + and pass. What is missing is a way to ask for 2^53 − 1 through `Client.getIntegerDetails`, and + that lasts until the SDK grows a wider accessor. Its scenario is skipped with a reason that names + the SDK, so a reader of the report can tell *"this provider declined"* from *"no Java provider can + be asked"* — only the first says anything about the provider. + `STALE` and `UNAVAILABLE_INIT` are the two that need a backend the provider can be cut off from. They are what a backend-less provider leaves undeclared — see [In-process control is for backend-less providers only](#in-process-control-is-for-backend-less-providers-only). @@ -564,17 +577,22 @@ Cucumber's parsed tags rather than the feature files as text, which matters more `gherkin/events.feature` names `@caching` inside a `#` comment explaining what is deliberately not covered yet, so a text scan would fail every adoption on the day it shipped. -There is a third direction, and it is this module's own build that has to catch it: a capability that -is **declarable and gates nothing**. That is the same vacuous claim as a declared reserved tag — -nothing can produce a skip, no result can contradict it, and a report tells its reader a capability -was examined when nothing examined it. Its realistic cause is a build accident rather than a design -mistake: the canonical assets are copied out of the `spec` submodule, and the submodule's gitlink and -its working tree move by different commands, so a rebase followed by a build can overwrite the new -assets with the old ones. The result is internally consistent — the old feature files agree with each -other — so **counting scenarios does not catch it**. `CanonicalTagCoverageTest` asserts that every -declarable capability's tag is carried by at least one canonical scenario, and that no reserved one -is; it parses the packaged Gherkin for the same reason the runtime check does. If you re-pin the -submodule, run `git submodule update` before building, and let that test tell you if you forgot. +There is a third direction, and it is this module's own build that has to catch it: a capability the +suite says has scenarios that **gates nothing**. Declarable, that is the same vacuous claim as a +declared reserved tag — nothing can produce a skip, no result can contradict it, and a report tells +its reader a capability was examined when nothing examined it. Its realistic cause is a build +accident rather than a design mistake: the canonical assets are copied out of the `spec` submodule, +and the submodule's gitlink and its working tree move by different commands, so a rebase followed by +a build can package the previous pin's assets. The result is internally consistent — the old feature +files agree with each other, and with the old flag set — so **counting scenarios does not catch it**. +`CanonicalTagCoverageTest` asserts that every capability this suite does not call reserved is carried +by at least one canonical scenario, and that no reserved one is; it parses the packaged Gherkin for +the same reason the runtime check does. It is over every unreserved capability rather than every +declarable one on purpose, because `@large-integers` having scenarios is exactly what distinguishes +it from a reservation. + +That catches one symptom of a stale checkout. [Stale assets](#stale-assets) is how the build catches +the rest. That is a rule about an accident rather than about intent: `EnumSet.complementOf(EnumSet.of(X))` reads as "everything except X" and in fact means "every other enum constant", reserved tags @@ -631,23 +649,33 @@ apart — what `InMemoryProvider` does — is a choice. **The flagd provider doe either mode, for the first reason — see [flagd#1996](https://github.com/open-feature/flagd/issues/1996). -A note on `LARGE_INTEGERS`: accessor width is a property of the SDK, not of the provider, and Java's -is 32 bits — `Client.getIntegerDetails` takes and returns an `Integer`, which has no room for -2^53 − 1. So **every Java provider withholds this tag**, and its one scenario is reported as skipped -for an undeclared capability like any other. Put it in your `declarableExcept(...)` list: +A note on `LARGE_INTEGERS`, which **you do not have to know anything about**: accessor width is a +property of the SDK rather than of the provider, and Java's is 32 bits — `Client.getIntegerDetails` +takes and returns an `Integer`, which has no room for 2^53 − 1. So no Java provider can be asked the +question, now or ever, until the SDK grows a wider accessor. + +That used to be documentation, and every Java adopter was expected to act on it by naming the +capability in `declarableExcept(...)`. It is refused here instead: ```java -return Capability.declarableExcept(Capability.LARGE_INTEGERS, /* whatever else */); +// Capability.declarable() does not contain it, and this fails with a message naming the accessor +return EnumSet.of(Capability.EVENTS, Capability.LARGE_INTEGERS); ``` -That the impossibility is the language's rather than the provider's is recorded once, in -[Appendix F](https://github.com/open-feature/spec/blob/main/specification/appendix-f-provider-conformance.md), -rather than restated in every run: a report has one skip status, carrying its reason, and the -scenario's own tags say what was being asked. Withholding the tag therefore needs no -`KnownDeviation` — it is not a defect. Declaring it is not refused either; the scenario runs and -fails when `TckValues` cannot fit `9007199254740991` into an `Integer`, which is a louder answer than -a rejected declaration. The 32-bit precision scenario (`large-integer-flag`, 2^31 − 1) is untagged and -always runs. +Two suites in this module and both adoptions in this repository each withheld it by hand, each with +its own comment restating the paragraph above. That is a fact about Java remembered in four places, +and one of them being wrong would put a claim in a report that no scenario could have verified — +which is exactly what the reserved-capability rules exist to prevent, reached by another route. +[Appendix F](https://github.com/open-feature/spec/blob/main/specification/appendix-f-provider-conformance.md) +states the rule: *a capability the language's SDK cannot express is refused by the implementation, +not left to adopters.* + +**It is not a reservation, and the two must not be read as the same thing.** `@caching` has no +scenarios anywhere and expires when the specification writes some; `@large-integers` has scenarios +that run and pass in Go and JavaScript. So its scenario is skipped with a reason that names the SDK +and says the provider had no say, rather than the ordinary *"provider does not declare"*. Neither +needs a `KnownDeviation` — neither is a defect. The 32-bit precision scenario +(`large-integer-flag`, 2^31 − 1) is untagged and always runs. A note on `STANDARD_REASONS`, which is **a claim rather than an exemption** and is the one capability whose absence costs a provider nothing. diff --git a/tools/tck/src/main/java/dev/openfeature/contrib/tools/tck/Capability.java b/tools/tck/src/main/java/dev/openfeature/contrib/tools/tck/Capability.java index 70d3a45445..4b613cb5d3 100644 --- a/tools/tck/src/main/java/dev/openfeature/contrib/tools/tck/Capability.java +++ b/tools/tck/src/main/java/dev/openfeature/contrib/tools/tck/Capability.java @@ -22,23 +22,38 @@ * *

            Scenarios with no capability tag are considered mandatory and always run. * + *

            Two kinds of capability nobody may declare

            + * + *

            Most entries here are an ordinary choice: declare it if your provider does it, leave it out if + * it does not, and the results say which. Two are not a choice at all, and both are refused by + * {@link #requireDeclarable} rather than left to an adopter to remember. They are refused for + * different reasons, they are said differently, and they must not be confused with each other — + * a reader who sees a capability missing from a report has to be able to tell "this provider + * declined" from "no provider in this language can be asked", because only the first + * says anything about the provider. + * *

            An entry may be {@linkplain #reserved() reserved}: it exists in the vocabulary so that every - * language's TCK spells the same property the same way, but no scenario carries its tag yet. - * {@link #CACHING} is the only one left — {@link #TARGETING} was reserved until the - * {@code targeting-key-flag} scenarios arrived, and is an ordinary declarable capability now. A - * reserved capability must not be declared — there is nothing for it to gate, so - * declaring it cannot produce a skip and cannot be contradicted by any result. Declare - * {@link #declarable()}, or {@link #declarableExcept} for "everything except", rather than - * {@code EnumSet.allOf} or {@code EnumSet.complementOf}: both of the latter sweep up every reserved - * tag on the way past, which is how a report comes to claim a capability nobody examined. + * language's TCK spells the same property the same way, but no scenario anywhere carries + * its tag yet. {@link #CACHING} is the only one left — {@link #TARGETING} was reserved + * until the {@code targeting-key-flag} scenarios arrived, and is an ordinary declarable capability + * now. A reservation is global and temporary: every language has it, and it expires the moment the + * specification writes the scenarios. * - *

            Some capabilities cannot hold in a language at all, as opposed to not holding for a particular - * provider: {@link #LARGE_INTEGERS} asks for a value the Java SDK's 32-bit integer accessor has no - * room for. That is a property of the SDK, true of every provider written against it, and + *

            An entry may instead be {@linkplain #inexpressible() inexpressible}: the scenarios + * exist and are asked in other languages, but this SDK cannot put the question. {@link + * #LARGE_INTEGERS} is the only one — {@code Client.getIntegerDetails} takes and returns a 32-bit + * {@link Integer}, so 2^53 − 1 cannot be asked for by any Java provider whatever its backend serves. + * That is one language's and permanent: it lasts until the SDK grows a wider accessor, and no + * provider author can do anything about it. Refusing it centrally is what stops every Java adopter + * having to know a fact about Java and act on it, and stops a single wrong one putting a claim in a + * report that no scenario could have verified. * Appendix - * F is where it is recorded — once, rather than restated in every run. Here it is an ordinary - * capability that a Java provider leaves undeclared, and its scenario is reported as skipped like - * any other undeclared one. + * F states the rule. + * + *

            Declare {@link #declarable()}, or {@link #declarableExcept} for "everything except", rather + * than {@code EnumSet.allOf} or {@code EnumSet.complementOf}: both of the latter sweep up the + * reserved and inexpressible tags on the way past, which is how a report comes to claim a capability + * nobody examined. * *

            The connection-dependent capabilities

            * @@ -239,26 +254,34 @@ public enum Capability { /** * Provider resolves integers up to 2^53 − 1 exactly. * - *

            A Java provider leaves this undeclared. Whether the value can be asked for - * at all is a property of the SDK's integer accessor rather than of the provider: - * {@code Client.getIntegerDetails} takes and returns a 32-bit {@link Integer}, so a Java - * provider has nowhere to put {@code 9007199254740991} however faithfully its backend serves it. - * Go's accessor is {@code int64} and JavaScript's number reaches 2^53 − 1 exactly, so their - * suites declare it and run the scenario. - * - *

            That the limit is the language's is recorded in + *

            {@linkplain #inexpressible() Inexpressible} in Java, so no Java provider may + * declare it and {@link #requireDeclarable} refuses one that tries. Whether the value + * can be asked for at all is a property of the SDK's integer accessor rather than of any + * provider: {@code Client.getIntegerDetails} takes and returns a 32-bit {@link Integer}, so a + * Java provider has nowhere to put {@code 9007199254740991} however faithfully its backend + * serves it. Go's accessor is {@code int64} and JavaScript's number reaches 2^53 − 1 exactly, so + * their suites declare it and run the scenario. + * + *

            This is not a reservation. The scenario exists, is asked, and passes + * elsewhere; what is missing is a way to ask it here, and that will be missing until the Java + * SDK grows a wider accessor. So the scenario is skipped, with a reason that says the SDK cannot + * ask the question rather than that the provider declined — {@link CapabilityGate} keeps the two + * apart, because only the second describes the provider under test. + * + *

            Withholding it needs no {@link KnownDeviation}, and there is no longer anything for an + * adopter to withhold: the refusal is here, once, instead of in each adoption's + * {@code capabilities()} with a comment restating this paragraph. * Appendix - * F rather than in each run, so this is an ordinary declarable capability and withholding it - * needs no {@link KnownDeviation}: the scenario is skipped for an undeclared capability, as it - * would be in any language whose accessor was too narrow. Declaring it on a Java provider does - * not fail the run — the value is simply unaskable and the scenario fails when - * {@link TckValues} cannot convert it, which says the same thing louder. + * F states the rule that puts it here. * *

            The 32-bit precision scenario — {@code large-integer-flag}, 2^31 − 1 — is untagged and * always runs. What a provider owes a value that does not fit the requested accessor is the * open question in open-feature/spec#430. */ - LARGE_INTEGERS("@large-integers"), + LARGE_INTEGERS( + "@large-integers", + "Client.getIntegerDetails takes and returns a 32-bit Integer, so 9007199254740991 cannot be " + + "asked for by any Java provider, however faithfully its backend serves it"), /** * Provider resolves a flag differently for a matching evaluation context. @@ -365,14 +388,24 @@ public enum Capability { private final String tag; private final boolean reserved; + private final String inexpressibleBecause; Capability(String tag) { - this(tag, false); + this.tag = tag; + this.reserved = false; + this.inexpressibleBecause = null; } Capability(String tag, boolean reserved) { this.tag = tag; this.reserved = reserved; + this.inexpressibleBecause = null; + } + + Capability(String tag, String inexpressibleBecause) { + this.tag = tag; + this.reserved = false; + this.inexpressibleBecause = inexpressibleBecause; } /** @@ -398,6 +431,38 @@ public boolean reserved() { return reserved; } + /** + * Returns whether this capability is one the Java SDK cannot express, and so must not be + * declared by any provider written against it. + * + *

            The opposite case to {@link #reserved()}, and kept apart from it deliberately. A reserved + * capability has no scenarios in any language and its reservation expires when the specification + * writes them. An inexpressible one has scenarios that run and pass in other languages; what is + * missing is a way to put the question through this SDK's API, and that lasts until the SDK + * changes. Both are refused by {@link #requireDeclarable}, with different messages, and their + * scenarios are skipped with different reasons. + * + *

            It is the implementation that refuses it, rather than each adopter remembering to withhold + * it. A property of the language is then recorded once, where it is true, instead of in every + * suite that adopts the TCK — and an adopter cannot get it wrong in the one direction that + * matters, which is claiming a capability no scenario could have verified. + * + * @return {@code true} if no provider written against this SDK can be asked this capability's + * scenarios + */ + public boolean inexpressible() { + return inexpressibleBecause != null; + } + + /** + * Returns why this SDK cannot express this capability, for the messages that have to say so. + * + * @return the reason, or {@code null} if this capability is expressible + */ + String inexpressibleBecause() { + return inexpressibleBecause; + } + /** * Looks up the capability gated by a Gherkin tag. * @@ -409,20 +474,22 @@ public static Optional fromTag(String tag) { } /** - * Returns every capability that may be declared: every capability some scenario gates. + * Returns every capability a provider written against this SDK may declare. * *

            This, not {@code EnumSet.allOf(Capability.class)}, is what "everything" means for a - * declaration. {@linkplain #reserved() Reserved} capabilities are left out. + * declaration. {@linkplain #reserved() Reserved} capabilities are left out because no scenario + * carries their tag; {@linkplain #inexpressible() inexpressible} ones because this SDK cannot + * ask what they ask, so no Java provider could be held to them. * - *

            It is not a set any Java provider should declare unchanged. {@link #LARGE_INTEGERS} is in - * it — it is a real capability, gating a real scenario — and the Java SDK's integer accessor has - * no room for what it asks for, so withhold it with {@link #declarableExcept}. + *

            It is a set a Java provider may declare unchanged, and the default. Narrow it only for + * things this provider cannot do — what no provider in this language can do has already + * been taken out. * * @return the declarable capabilities, as a fresh mutable set */ public static EnumSet declarable() { EnumSet declarable = EnumSet.allOf(Capability.class); - declarable.removeIf(Capability::reserved); + declarable.removeIf(capability -> capability.reserved() || capability.inexpressible()); return declarable; } @@ -431,9 +498,15 @@ public static EnumSet declarable() { * *

            The counterpart to {@code EnumSet.complementOf}, and the reason it exists: a provider * saying "everything except the one thing I cannot do" wants everything declarable - * except that thing, whereas {@code complementOf} hands back the reserved tags as well. + * except that thing, whereas {@code complementOf} hands back the reserved and inexpressible tags + * as well. + * + *

            What belongs in {@code excluded} is a fact about this provider. A fact about Java + * does not: {@link #LARGE_INTEGERS} is already absent, and naming it here is harmless but says + * nothing, because no Java provider could have declared it. * - * @param excluded capabilities to withhold; reserved capabilities are absent regardless + * @param excluded capabilities to withhold; reserved and inexpressible capabilities are absent + * regardless * @return the declarable capabilities minus {@code excluded}, as a fresh mutable set */ public static EnumSet declarableExcept(Capability... excluded) { @@ -445,27 +518,39 @@ public static EnumSet declarableExcept(Capability... excluded) { } /** - * Rejects a declaration that names a reserved capability. + * Rejects a declaration that claims something no result could check. * *

            Fails the run rather than warning and dropping it. The declaration is the one part of a * conformance report that no result can check — everything else in it was observed, this is * asserted by the provider author — so a claim that cannot possibly be true is worth stopping - * for. There is nothing to lose by refusing, either: no scenario carries a reserved tag, so no - * coverage depends on the claim, and the fix is to call {@link #declarable()} or + * for. There is nothing to lose by refusing, either: in neither case below does any coverage + * depend on the claim, and the fix is to call {@link #declarable()} or * {@link #declarableExcept}. * - *

            Only reserved capabilities are refused. A capability whose scenario the provider cannot - * satisfy is not a claim that cannot be checked — it is one the results contradict, which is - * what a conformance run is for. + *

            Two claims are refused, for different reasons, and they are reported separately. + * A {@linkplain #reserved() reserved} capability has no scenarios in any language; an + * {@linkplain #inexpressible() inexpressible} one has scenarios that pass in other languages and + * no way to ask them here. Collapsing them into one message would tell an adopter the two facts + * are the same fact, and they behave differently: the first expires when the specification + * writes the scenarios, the second when the SDK changes. Both lists are gathered before either + * is thrown, so a declaration that gets both wrong hears about both. + * + *

            Nothing else is refused. A capability whose scenario the provider cannot satisfy is not a + * claim that cannot be checked — it is one the results contradict, which is what a conformance + * run is for. * * @param declared the capabilities a harness declares - * @throws IllegalArgumentException if any of them is reserved + * @throws IllegalArgumentException if any of them is reserved or inexpressible */ public static void requireDeclarable(Collection declared) { List reservedTags = new ArrayList<>(); + List inexpressibleTags = new ArrayList<>(); for (Capability capability : declared) { if (capability.reserved()) { reservedTags.add(capability.name() + " (" + capability.tag() + ")"); + } else if (capability.inexpressible()) { + inexpressibleTags.add( + capability.name() + " (" + capability.tag() + "): " + capability.inexpressibleBecause()); } } if (!reservedTags.isEmpty()) { @@ -476,5 +561,15 @@ public static void requireDeclarable(Collection declared) { + "\"everything except\" — EnumSet.allOf and EnumSet.complementOf pick reserved " + "capabilities up on the way past."); } + if (!inexpressibleTags.isEmpty()) { + throw new IllegalArgumentException("capabilities() declares " + inexpressibleTags + + ", which the Java SDK cannot express. This is not a reserved capability: the " + + "scenarios exist and are asked in languages whose API is wide enough, so they " + + "say nothing about your provider and everything about the SDK it is written " + + "against. No Java provider can satisfy them until the SDK changes, so none may " + + "claim them — and you do not have to know that: Capability.declarable() " + + "already leaves them out, and their scenarios are skipped with a reason that " + + "names the SDK rather than your provider. Remove them from capabilities()."); + } } } diff --git a/tools/tck/src/main/java/dev/openfeature/contrib/tools/tck/CapabilityGate.java b/tools/tck/src/main/java/dev/openfeature/contrib/tools/tck/CapabilityGate.java index 9fa13c6225..1013bbad80 100644 --- a/tools/tck/src/main/java/dev/openfeature/contrib/tools/tck/CapabilityGate.java +++ b/tools/tck/src/main/java/dev/openfeature/contrib/tools/tck/CapabilityGate.java @@ -22,6 +22,11 @@ *

            The second rule here is the mirror of the first and fails rather than skips: a tag this * implementation still calls {@linkplain Capability#reserved() reserved} must never reach a * scenario. See {@link #requireNoExpiredReservation}. + * + *

            The third produces a skip like the first but for a reason that has nothing to do with the + * provider: a capability this SDK {@linkplain Capability#inexpressible() cannot express}. Its skip + * reason is deliberately different from an undeclared capability's, because a report's reader has + * to be able to tell them apart. */ public final class CapabilityGate { @@ -40,29 +45,40 @@ private CapabilityGate() {} *

            Tags that gate nothing are ignored, so a scenario with no capability tag is mandatory and * always runs. * - *

            One skip, carrying its reason, is the whole mechanism. A capability that cannot hold in a - * language at all — {@link Capability#LARGE_INTEGERS} on the Java SDK's 32-bit integer accessor - * — is undeclared like any other the provider does not offer, and is skipped the same way. - * Separating the two would ask a reader to learn a second vocabulary to be told what the - * declaration and the scenario's own tags already say; where the impossibility is the - * language's, Appendix F records it once instead. + *

            Two skips, and they do not say the same thing. The ordinary one is a + * capability the provider did not declare, and it names the provider. The other is a capability + * this SDK {@linkplain Capability#inexpressible() cannot express} — {@link + * Capability#LARGE_INTEGERS} on the Java SDK's 32-bit integer accessor — where the provider had + * no say: no Java provider can be asked that scenario, and {@link Capability#requireDeclarable} + * refuses a declaration that pretends otherwise. Reporting both as "the provider does not + * declare it" would read as a decision the provider took, and a reader of the report would + * believe it. So the reason names the SDK instead, and is checked before the declaration, which + * makes it the reason every time rather than only when the provider happens to have withheld it. * * @param tags the scenario's Gherkin tags, including the leading at-sign * @param declared the capabilities the provider declares * @throws IllegalStateException if a tag names a reserved capability - * @throws TestAbortedException if a tag gates an undeclared capability + * @throws TestAbortedException if a tag gates an inexpressible or an undeclared capability */ public static void requireDeclared(Collection tags, Set declared) { requireNoExpiredReservation(tags); for (String tag : tags) { - Optional capability = Capability.fromTag(tag); - if (!capability.isPresent()) { + Optional found = Capability.fromTag(tag); + if (!found.isPresent()) { continue; } - if (!declared.contains(capability.get())) { - throw new TestAbortedException("Skipped: provider does not declare capability " - + capability.get().name() + " (tag " + tag + "). Declared capabilities: " + declared); + Capability capability = found.get(); + if (capability.inexpressible()) { + throw new TestAbortedException("Skipped: the Java SDK cannot express capability " + + capability.name() + " (tag " + tag + ") — " + capability.inexpressibleBecause() + + ". This scenario exists and is asked in languages whose API is wide enough, so " + + "this is not a reservation and not the provider under test declining: no Java " + + "provider can be asked it, and none may declare it."); + } + if (!declared.contains(capability)) { + throw new TestAbortedException("Skipped: provider does not declare capability " + capability.name() + + " (tag " + tag + "). Declared capabilities: " + declared); } } } @@ -72,7 +88,10 @@ public static void requireDeclared(Collection tags, Set decl * *

            This is the expiry check on {@link Capability#reserved()}, and it is the other half of * {@link Capability#requireDeclarable}. That one refuses a declaration naming a reserved - * capability; this one refuses a scenario carrying its tag. A reservation is a name held + * capability; this one refuses a scenario carrying its tag. There is no equivalent for + * an {@linkplain Capability#inexpressible() inexpressible} capability and there could not be: a + * scenario carrying its tag is exactly what is expected, since the scenarios are what the other + * languages run. A reservation is a name held * open for scenarios that do not exist yet and is only ever temporary — the specification writes * them, the tag starts gating something, and the capability becomes declarable. Until this * implementation follows, the two halves meet in the worst possible place: the scenario is diff --git a/tools/tck/src/main/java/dev/openfeature/contrib/tools/tck/ProviderTckHarness.java b/tools/tck/src/main/java/dev/openfeature/contrib/tools/tck/ProviderTckHarness.java index 5d8d978953..416bf9d09e 100644 --- a/tools/tck/src/main/java/dev/openfeature/contrib/tools/tck/ProviderTckHarness.java +++ b/tools/tck/src/main/java/dev/openfeature/contrib/tools/tck/ProviderTckHarness.java @@ -122,13 +122,16 @@ default FeatureProvider createUnavailableProvider() { * provider genuinely cannot do — {@link Capability#declarableExcept} is the idiomatic way to say * "everything except". * - *

            {@link Capability#LARGE_INTEGERS} is one every Java provider removes. It asks for 2^53 − 1, - * and {@code Client.getIntegerDetails} is a 32-bit {@link Integer} with no room for it, so the - * limit is the SDK's rather than any provider's — see Appendix F, where that is recorded. + *

            Remove only what your provider cannot do. What no Java provider can do is already + * gone: {@link Capability#LARGE_INTEGERS} asks for 2^53 − 1 and {@code Client.getIntegerDetails} + * is a 32-bit {@link Integer} with no room for it, so it is + * {@linkplain Capability#inexpressible() inexpressible} here, absent from + * {@link Capability#declarable()}, and refused if you name it. You do not have to know that, and + * that is the point of it being refused rather than documented. * *

            Do not build the set with {@code EnumSet.allOf} or {@code EnumSet.complementOf}. Both - * include the {@linkplain Capability#reserved() reserved} capabilities, which no scenario - * carries; declaring one fails the run. + * include the {@linkplain Capability#reserved() reserved} and inexpressible capabilities, which + * no provider may claim; declaring one fails the run. * * @return the capabilities this provider supports */ diff --git a/tools/tck/src/main/java/dev/openfeature/contrib/tools/tck/TckValues.java b/tools/tck/src/main/java/dev/openfeature/contrib/tools/tck/TckValues.java index eefcf86a8d..79bc477cca 100644 --- a/tools/tck/src/main/java/dev/openfeature/contrib/tools/tck/TckValues.java +++ b/tools/tck/src/main/java/dev/openfeature/contrib/tools/tck/TckValues.java @@ -42,11 +42,12 @@ public static Object convert(String value, String type) { } catch (NumberFormatException e) { throw new IllegalArgumentException( "'" + value + "' is not an Integer the Java SDK can ask for: " - + "Client.getIntegerDetails takes a 32-bit Integer. A scenario needing more than " - + "2^31 - 1 carries @large-integers, which a Java provider leaves undeclared " - + "because the accessor is the limit rather than the provider — see Appendix F. " - + "Reaching this means the capability was declared: remove it with " - + "Capability.declarableExcept(Capability.LARGE_INTEGERS).", + + "Client.getIntegerDetails takes a 32-bit Integer. The canonical scenario " + + "that needs more than 2^31 - 1 carries @large-integers, and cannot reach " + + "here: Capability.LARGE_INTEGERS is inexpressible in Java, so no provider " + + "can declare it and CapabilityGate skips the scenario. Reaching this means " + + "an extension feature file of your own asked for a value outside the " + + "accessor's range, which the Java SDK has no way to request.", e); } case "Float": diff --git a/tools/tck/src/test/java/dev/openfeature/contrib/tools/tck/CanonicalFlagsTest.java b/tools/tck/src/test/java/dev/openfeature/contrib/tools/tck/CanonicalFlagsTest.java index 19ef5c796e..c9ee37f19b 100644 --- a/tools/tck/src/test/java/dev/openfeature/contrib/tools/tck/CanonicalFlagsTest.java +++ b/tools/tck/src/test/java/dev/openfeature/contrib/tools/tck/CanonicalFlagsTest.java @@ -197,7 +197,9 @@ private static void assertResolves( * integer accessor and a fractional one through the float accessor, and the SDK's provider * refuses a variant of the other type, so this is where a decoder that widened or narrowed a * number fails. 2^53 − 1 has no room in an {@link Integer} and goes through the long accessor, - * which is also the reason a Java provider leaves {@code @large-integers} undeclared. + * which is also why {@code @large-integers} is {@linkplain Capability#inexpressible() + * inexpressible} in Java — a flag definition can hold the value, and the client API cannot ask + * for it. * *

            The accessor is still chosen by the literal for a disabled flag, so a {@code disabled-*} * flag whose type was mangled by the decoder is caught the same way: the answer is then neither diff --git a/tools/tck/src/test/java/dev/openfeature/contrib/tools/tck/CanonicalTagCoverageTest.java b/tools/tck/src/test/java/dev/openfeature/contrib/tools/tck/CanonicalTagCoverageTest.java index 2ff44593db..58da46e286 100644 --- a/tools/tck/src/test/java/dev/openfeature/contrib/tools/tck/CanonicalTagCoverageTest.java +++ b/tools/tck/src/test/java/dev/openfeature/contrib/tools/tck/CanonicalTagCoverageTest.java @@ -28,15 +28,22 @@ import org.junit.jupiter.api.Test; /** - * Every declarable capability is carried by at least one canonical scenario, and no reserved one is. + * Every capability this suite does not call reserved is carried by at least one canonical scenario, + * and every reserved one is carried by none. * *

            {@link CapabilityGate#requireNoExpiredReservation} already fails a run where a scenario * carries a reserved tag — a capability no adopter may declare, gating something, so the scenario is - * skipped forever and nothing notices. This is the other half of the same rule: a capability an - * adopter may declare that gates nothing. Such a declaration cannot be - * produced a skip, cannot be contradicted by any result, and tells a report's reader that a - * capability was examined when nothing examined it. That is the same vacuous claim, arrived at from - * the opposite direction. + * skipped forever and nothing notices. This is the other half of the same rule: a capability this + * suite says has scenarios that gates nothing. Declarable, that is a claim no result + * can contradict, which tells a report's reader that a capability was examined when nothing examined + * it. That is the same vacuous claim, arrived at from the opposite direction. + * + *

            The first test is over every capability that is not reserved, rather than over + * {@link Capability#declarable()}, and the difference matters. An + * {@linkplain Capability#inexpressible() inexpressible} capability is not declarable here, but its + * scenarios are precisely what distinguish it from a reservation: they exist, and other languages + * run them. Checking only the declarable set would stop looking at the one capability whose whole + * justification is that the scenarios are there. * *

            It has one realistic cause, and it is a build accident rather than a design mistake: the * canonical assets are copied out of the {@code spec} submodule at {@code generate-resources}, and @@ -65,17 +72,19 @@ class CanonicalTagCoverageTest { private static final Set CARRIED = readCarriedTags(); @Test - @DisplayName("every declarable capability is carried by at least one canonical scenario") - void everyDeclarableCapabilityGatesSomething() { - for (Capability capability : Capability.declarable()) { + @DisplayName("every capability that is not reserved is carried by at least one canonical scenario") + void everyUnreservedCapabilityGatesSomething() { + for (Capability capability : Capability.values()) { + if (capability.reserved()) { + continue; + } assertThat(CARRIED) .as( - "%s (%s) is declarable, so an adopter may claim it — but no canonical scenario " - + "carries its tag, so the claim gates nothing and no result can contradict " - + "it. Either the packaged gherkin/ is stale (check that the spec submodule " - + "working tree matches the gitlink: git -C tools/tck/spec rev-parse HEAD) " - + "or the capability was added ahead of its scenarios, in which case mark it " - + "reserved until they arrive.", + "%s (%s) is not reserved, so this suite says scenarios for it exist — but no " + + "canonical scenario carries its tag. Either the packaged gherkin/ is stale " + + "(check that the spec submodule working tree matches the gitlink: git -C " + + "tools/tck/spec rev-parse HEAD) or the capability was added ahead of its " + + "scenarios, in which case mark it reserved until they arrive.", capability.name(), capability.tag()) .contains(capability.tag()); } diff --git a/tools/tck/src/test/java/dev/openfeature/contrib/tools/tck/ControllableProviderTckTest.java b/tools/tck/src/test/java/dev/openfeature/contrib/tools/tck/ControllableProviderTckTest.java index e9f8dbb275..a3cfa88dc0 100644 --- a/tools/tck/src/test/java/dev/openfeature/contrib/tools/tck/ControllableProviderTckTest.java +++ b/tools/tck/src/test/java/dev/openfeature/contrib/tools/tck/ControllableProviderTckTest.java @@ -101,11 +101,13 @@ public FeatureProvider createUnavailableProvider() { * capability still without Docker-free coverage. *

          • {@link Capability#TARGETING} — the delegate evaluates no rules, so * {@code targeting-key-flag} resolves its {@code miss} variant whatever the context. - *
          • {@link Capability#LARGE_INTEGERS} — omitted, as every Java provider omits it: the limit - * is {@code Client.getIntegerDetails}'s 32 bits rather than this provider's. *
          • {@link Capability#CACHING} — reserved, so not declarable, and nothing is skipped by * leaving it out. *
          + * + *

          {@link Capability#LARGE_INTEGERS} is absent from both lists because it is not a decision + * this suite takes: it is {@linkplain Capability#inexpressible() inexpressible} in Java and + * refused centrally. */ @Override public Set capabilities() { diff --git a/tools/tck/src/test/java/dev/openfeature/contrib/tools/tck/DeclarationApiTest.java b/tools/tck/src/test/java/dev/openfeature/contrib/tools/tck/DeclarationApiTest.java index 8e26038702..8f091b3894 100644 --- a/tools/tck/src/test/java/dev/openfeature/contrib/tools/tck/DeclarationApiTest.java +++ b/tools/tck/src/test/java/dev/openfeature/contrib/tools/tck/DeclarationApiTest.java @@ -82,35 +82,90 @@ void variantsIsADeclarableChoice() { } @Test - @DisplayName("a capability a language cannot hold is an ordinary one, withheld and skipped like any other") - void aCapabilityTheLanguageCannotHoldIsWithheldRatherThanSetApart() { + @DisplayName("a capability the Java SDK cannot express is refused here, not left to every adopter") + void aCapabilityTheSdkCannotExpressIsRefusedCentrally() { // @large-integers asks for 2^53 - 1 and the Java SDK's accessor is a 32-bit Integer, so no - // Java provider can hold it. That is a property of the SDK, recorded once in Appendix F, - // and not a second kind of declaration: there is one skip and it carries its reason. - assertThat(Capability.LARGE_INTEGERS.reserved()) - .as("a scenario does carry the tag, so there is something to gate") - .isFalse(); + // Java provider can be asked it -- ever, until the SDK changes. Three suites in this + // repository used to withhold it by hand, each with its own comment restating this + // paragraph. The implementation refuses it instead, so an adopter neither has to know it + // nor can get it wrong. + assertThat(Capability.LARGE_INTEGERS.inexpressible()).isTrue(); assertThat(Capability.declarable()) - .as("it is an ordinary declarable capability; a harness withholds it rather than being forbidden it") - .contains(Capability.LARGE_INTEGERS); - assertThat(Capability.declarableExcept(Capability.LARGE_INTEGERS)) - .as("declarableExcept is how a Java harness says so") + .as("no Java provider may claim it, so \"everything\" does not include it") .doesNotContain(Capability.LARGE_INTEGERS); + assertThat(Capability.declarableExcept(Capability.EVENTS)) + .as("nor does \"everything except\", which is what the adoptions call") + .doesNotContain(Capability.LARGE_INTEGERS); + + assertThatThrownBy(() -> Capability.requireDeclarable(EnumSet.of(Capability.EVENTS, Capability.LARGE_INTEGERS))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("LARGE_INTEGERS") + .hasMessageContaining("@large-integers") + .as("the message says why, naming the SDK property rather than citing a rule") + .hasMessageContaining("Client.getIntegerDetails") + .hasMessageContaining("cannot express"); + } - // Declaring it is not refused. The guard exists for a claim no result can contradict, and - // this is not one: the scenario runs and fails, which says more than a rejected declaration. - Capability.requireDeclarable(EnumSet.of(Capability.EVENTS, Capability.LARGE_INTEGERS)); + @Test + @DisplayName("the two refusals are different facts, and neither message could be mistaken for the other") + void reservedAndInexpressibleAreToldApart() { + // A reader who sees a capability missing from a report has to be able to tell "no scenario + // anywhere carries this tag yet" from "the scenarios exist and this SDK cannot ask them". + // Only the second is permanent, and neither says anything about the provider under test -- + // which is the third thing they must not be mistaken for. + assertThat(Capability.CACHING.reserved()).isTrue(); + assertThat(Capability.CACHING.inexpressible()) + .as("a reservation is global and expires; it is not a language's limit") + .isFalse(); + assertThat(Capability.LARGE_INTEGERS.reserved()) + .as("scenarios do carry @large-integers, which is what makes it not a reservation") + .isFalse(); - // Withheld, it is skipped exactly as any undeclared capability is. - TestAbortedException aborted = catchThrowableOfType( + String reserved = catchThrowableOfType( + () -> Capability.requireDeclarable(EnumSet.of(Capability.CACHING)), + IllegalArgumentException.class) + .getMessage(); + String inexpressible = catchThrowableOfType( + () -> Capability.requireDeclarable(EnumSet.of(Capability.LARGE_INTEGERS)), + IllegalArgumentException.class) + .getMessage(); + + assertThat(reserved) + .as("the reserved refusal says there is nothing to gate yet") + .contains("no scenario in the suite carries"); + assertThat(inexpressible) + .as("the inexpressible refusal says the opposite: the scenarios exist elsewhere") + .contains("the scenarios exist and are asked in languages whose API is wide enough") + .contains("This is not a reserved capability") + .doesNotContain("no scenario in the suite carries"); + + // And the same distinction survives into the skip, which is where a report's reader meets + // it. An undeclared capability names the provider; an inexpressible one must not, because + // the provider had no say. + TestAbortedException undeclared = catchThrowableOfType( () -> CapabilityGate.requireDeclared( - Arrays.asList("@large-integers"), Capability.declarableExcept(Capability.LARGE_INTEGERS)), + Arrays.asList("@variants"), Capability.declarableExcept(Capability.VARIANTS)), TestAbortedException.class); - assertThat(aborted).isNotNull(); - assertThat(aborted) - .hasMessageContaining("LARGE_INTEGERS") - .hasMessageContaining("@large-integers") - .hasMessageContaining("does not declare"); + TestAbortedException unaskable = catchThrowableOfType( + () -> CapabilityGate.requireDeclared(Arrays.asList("@large-integers"), Capability.declarable()), + TestAbortedException.class); + + assertThat(undeclared).hasMessageContaining("provider does not declare capability"); + assertThat(unaskable) + .hasMessageContaining("the Java SDK cannot express capability LARGE_INTEGERS") + .hasMessageContaining("Client.getIntegerDetails") + .as("not the provider's decision, and the reason has to say so") + .hasMessageContaining("not the provider under test declining"); + assertThat(unaskable.getMessage()).doesNotContain("provider does not declare"); + + // The inexpressible reason is reached whatever the declaration says, because a declaration + // cannot contain it. Checking it after the declaration would make the right reason appear + // only by luck. + TestAbortedException evenIfSomehowDeclared = catchThrowableOfType( + () -> CapabilityGate.requireDeclared( + Arrays.asList("@large-integers"), EnumSet.of(Capability.LARGE_INTEGERS)), + TestAbortedException.class); + assertThat(evenIfSomehowDeclared).hasMessageContaining("the Java SDK cannot express capability"); } @Test diff --git a/tools/tck/src/test/java/dev/openfeature/contrib/tools/tck/InMemoryProviderTckTest.java b/tools/tck/src/test/java/dev/openfeature/contrib/tools/tck/InMemoryProviderTckTest.java index 61ededd1a5..2867da32e3 100644 --- a/tools/tck/src/test/java/dev/openfeature/contrib/tools/tck/InMemoryProviderTckTest.java +++ b/tools/tck/src/test/java/dev/openfeature/contrib/tools/tck/InMemoryProviderTckTest.java @@ -100,11 +100,13 @@ public FeatureProvider createProvider() { * with that reason rather than failed, which is what the tag is for. *

        • {@link Capability#CACHING} — reserved, so not declarable and nothing is skipped by * leaving it out. - *
        • {@link Capability#LARGE_INTEGERS} — omitted, as every Java provider omits it. The tag - * asks for 2^53 − 1 and {@code Client.getIntegerDetails} is a 32-bit {@link Integer}, so - * the limit is the SDK's rather than this provider's; Appendix F is where that is - * recorded, and here it is simply undeclared and its scenario skipped. *
        + * + *

        {@link Capability#LARGE_INTEGERS} is not in that list and is not this suite's to omit: + * it is {@linkplain Capability#inexpressible() inexpressible} in Java, so + * {@link Capability#requireDeclarable} refuses it and its scenario is skipped for a reason that + * names the SDK. That used to be a bullet here, and an identical one in every other suite in + * this repository. */ @Override public Set capabilities() { From 5b99b69cfff969bc70cfc12ba3bb2bef6a528e53 Mon Sep 17 00:00:00 2001 From: Simon Schrottner Date: Sun, 13 Sep 2026 15:55:32 +0200 Subject: [PATCH 43/55] docs(tck): stop naming a report-branch class from the base CanonicalAssetDigestTest's javadoc mentioned CanonicalScenarios, which lives only on the report branch. Harmless as code -- it is prose in {@code}, not a {@link} and not an import -- but the flagd branch's fourth invariant is checked by grepping the report-only class names across the whole branch, and this made that check report a hit that then has to be investigated and dismissed by hand. The neighbouring CanonicalTagCoverageTest deliberately describes the same rule without naming the class, for the same reason. Match it. Signed-off-by: Simon Schrottner --- .../contrib/tools/tck/CanonicalAssetDigestTest.java | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tools/tck/src/test/java/dev/openfeature/contrib/tools/tck/CanonicalAssetDigestTest.java b/tools/tck/src/test/java/dev/openfeature/contrib/tools/tck/CanonicalAssetDigestTest.java index 19d5a6834c..13d3cae8db 100644 --- a/tools/tck/src/test/java/dev/openfeature/contrib/tools/tck/CanonicalAssetDigestTest.java +++ b/tools/tck/src/test/java/dev/openfeature/contrib/tools/tck/CanonicalAssetDigestTest.java @@ -190,9 +190,9 @@ private static byte[] withoutCarriageReturns(byte[] content) { * The directory this artifact's classes and resources were loaded from. * *

        Read through the code source rather than the classloader, as {@link CanonicalTagCoverageTest} - * and {@code CanonicalScenarios} read it, and for the same reason: an asset placed at the same - * classpath path on another root shadows the packaged one, and a check that digested the - * shadowing copy would be comparing the replacement against itself. + * reads it, and for the same reason: an asset placed at the same classpath path on another root + * shadows the packaged one, and a check that digested the shadowing copy would be comparing the + * replacement against itself. */ private static Path codeSourceRoot() { CodeSource codeSource = ProviderTck.class.getProtectionDomain().getCodeSource(); From 85e17271696242de7afa07351d8e18704fb0add0 Mon Sep 17 00:00:00 2001 From: Simon Schrottner Date: Sun, 13 Sep 2026 16:22:14 +0200 Subject: [PATCH 44/55] feat(tck): refuse a deviation against a capability nothing asked A knownDeviations entry says the provider fails to do something it is required to do, so it has to be about a question that was actually asked. Two are never asked, and until now the rule was documented rather than enforced: KnownDeviation's javadoc said the capability "may not be a reserved one" and nothing refused it. Both are refused where the deviation is constructed, with separate messages. A reserved capability has no scenarios in any language, so nothing was skipped for it and nothing failed. An inexpressible one has scenarios that run and pass elsewhere and no way to put them through this SDK, so they were never asked of this provider at all. The second is the more damaging of the two and is the reason this is being closed now rather than left as prose. Declaring @large-integers is already refused; a deviation against it is the same unverifiable claim reaching the report by another route, and it reads worse -- a deviation is an admission of fault, and this fault would belong to nobody and be fixable by nobody. The obvious way for an adopter to react to "you may not declare this" is to record a deviation explaining why, which is exactly the wrong move, so the refusal has to meet them there. The messages are pinned apart by test: the reserved one must not mention the SDK, because a reservation is every language's, and the inexpressible one must not read as a reservation. Same assertions as on the declaration side, which is where the two would otherwise converge the next time someone edits them. Null still means a gap against a mandatory, ungated scenario, and an ordinary capability is still accepted in both of the shapes the class documents. Signed-off-by: Simon Schrottner --- tools/tck/README.md | 11 ++- .../contrib/tools/tck/KnownDeviation.java | 71 ++++++++++++++++--- .../contrib/tools/tck/DeclarationApiTest.java | 34 ++++++++- 3 files changed, 103 insertions(+), 13 deletions(-) diff --git a/tools/tck/README.md b/tools/tck/README.md index f543790e7d..52972a1c54 100644 --- a/tools/tck/README.md +++ b/tools/tck/README.md @@ -751,9 +751,14 @@ public List knownDeviations() { what, which is worth less than the bare skip or failure it accompanies. `issue` is **optional** — use `KnownDeviation.untracked(...)` when there is nothing to point at yet. That is still worth declaring, because naming the defect is what separates it from a choice, but an issue link is -better. The capability may be `null`, when the gap is against a mandatory, ungated scenario; it may -not be a [reserved](#declaring-capabilities) one, since no scenario carries the tag and so there is -nothing to deviate from. Empty is the default, and it is silence rather than a claim of having none. +better. The capability may be `null`, when the gap is against a mandatory, ungated scenario. It may +not be either of the two whose scenarios were never put to your provider, and both are refused where +you write them, with different messages: a +[reserved](#declaring-capabilities) one, since no scenario carries the tag in any language, and an +[inexpressible](#declaring-capabilities) one, since the scenarios exist and this SDK cannot ask them. +Neither leaves anything to deviate from, and a deviation reads as an admission of fault — here it +would be a fault nobody committed and nobody could fix. Empty is the default, and it is silence +rather than a claim of having none. ### Naming the configuration under test diff --git a/tools/tck/src/main/java/dev/openfeature/contrib/tools/tck/KnownDeviation.java b/tools/tck/src/main/java/dev/openfeature/contrib/tools/tck/KnownDeviation.java index 84b2a69631..258c3be164 100644 --- a/tools/tck/src/main/java/dev/openfeature/contrib/tools/tck/KnownDeviation.java +++ b/tools/tck/src/main/java/dev/openfeature/contrib/tools/tck/KnownDeviation.java @@ -43,9 +43,12 @@ * soon as there is an issue to point at. * *

        {@link #capability} may be {@code null}, when the gap is against a mandatory, ungated scenario - * and so belongs to no capability. It may not name a - * {@linkplain Capability#reserved() reserved} capability: no scenario carries the tag, so there is - * nothing to deviate from. + * and so belongs to no capability. It may not name a capability whose scenarios + * were never put to this provider, and there are two of those, refused with different messages: a + * {@linkplain Capability#reserved() reserved} one, where no scenario carries the tag in any + * language, and an {@linkplain Capability#inexpressible() inexpressible} one, where the scenarios + * exist and this SDK cannot ask them. Neither leaves anything to deviate from, and a deviation reads + * as an admission of fault — here it would be a fault nobody committed and nobody could fix. * *

        Declared by the provider author through {@link ProviderTckHarness#knownDeviations()}, because * that is the only place that knows. The TCK cannot infer any of this: from the outside, a @@ -74,24 +77,71 @@ public final class KnownDeviation { /** What the gap is, in a form someone comparing providers can use. Never {@code null}. */ public final String summary; - private KnownDeviation(String capability, String issue, String summary) { - this.capability = capability; + private KnownDeviation(Capability capability, String issue, String summary) { + requireDeviable(capability); + this.capability = capability == null ? null : capability.tag(); this.issue = issue; this.summary = summary; } + /** + * Refuses a deviation against a capability whose scenarios were never put to this provider. + * + *

        The same rule as {@link Capability#requireDeclarable}, one step along: a deviation asserts + * that the provider fails to do something it is required to do, so it has to be about a question + * that was actually asked. Two are not, and they are refused separately because they are + * different facts. + * + *

        A {@linkplain Capability#reserved() reserved} capability has no scenarios in any language, + * so there is nothing to deviate from. An {@linkplain Capability#inexpressible() inexpressible} + * one has scenarios that run elsewhere and no way to put them through this SDK — so they were + * never asked of this provider, and a deviation would assert a defect that could not have been + * observed. Declaring it was already refused; recording a deviation against it is the same claim + * by another route, and it is the more dangerous of the two, because a deviation reads as an + * admission of fault and the fault here would belong to nobody. + * + *

        Checked when the deviation is constructed rather than when it is read, so an adopter is told + * at the point they wrote it and whether or not anything downstream ever reads the declaration. + * + * @param capability the capability the deviation names, or {@code null} + * @throws IllegalArgumentException if the capability is reserved or inexpressible + */ + private static void requireDeviable(Capability capability) { + if (capability == null) { + return; + } + if (capability.reserved()) { + throw new IllegalArgumentException("knownDeviations() records a deviation against reserved " + + capability.name() + " (" + capability.tag() + "), which no scenario in the suite " + + "carries. There is nothing to deviate from: no scenario was skipped for it and " + + "none failed. Use null for a gap against a mandatory, ungated scenario."); + } + if (capability.inexpressible()) { + throw new IllegalArgumentException("knownDeviations() records a deviation against " + + capability.name() + " (" + capability.tag() + "), which the Java SDK cannot " + + "express: " + capability.inexpressibleBecause() + ". This is not a reserved " + + "capability — the scenarios exist and are asked in languages whose API is wide " + + "enough — but they were never put to your provider, so a deviation here asserts " + + "a defect that could not have been observed and that nobody could fix. The " + + "scenario's skip already says the SDK is the limit."); + } + } + /** * Records a deviation that is tracked somewhere. The preferred form. * * @param capability the capability the gap is about — declared and failing, or withheld and * skipped — or {@code null} when the gap is against a mandatory, ungated scenario and so - * belongs to no capability. Must not be a {@linkplain Capability#reserved() reserved} one + * belongs to no capability. Must not be {@linkplain Capability#reserved() reserved} or + * {@linkplain Capability#inexpressible() inexpressible}: neither's scenarios were put to + * this provider * @param issue a URI where the gap is tracked * @param summary what the gap is; required * @return the deviation, ready to declare + * @throws IllegalArgumentException if the capability is reserved or inexpressible */ public static KnownDeviation tracked(Capability capability, String issue, String summary) { - return new KnownDeviation(capability == null ? null : capability.tag(), issue, summary); + return new KnownDeviation(capability, issue, summary); } /** @@ -103,11 +153,14 @@ public static KnownDeviation tracked(Capability capability, String issue, String * * @param capability the capability the gap is about — declared and failing, or withheld and * skipped — or {@code null} when the gap is against a mandatory, ungated scenario and so - * belongs to no capability. Must not be a {@linkplain Capability#reserved() reserved} one + * belongs to no capability. Must not be {@linkplain Capability#reserved() reserved} or + * {@linkplain Capability#inexpressible() inexpressible}: neither's scenarios were put to + * this provider * @param summary what the gap is; required * @return the deviation, ready to declare + * @throws IllegalArgumentException if the capability is reserved or inexpressible */ public static KnownDeviation untracked(Capability capability, String summary) { - return new KnownDeviation(capability == null ? null : capability.tag(), null, summary); + return new KnownDeviation(capability, null, summary); } } diff --git a/tools/tck/src/test/java/dev/openfeature/contrib/tools/tck/DeclarationApiTest.java b/tools/tck/src/test/java/dev/openfeature/contrib/tools/tck/DeclarationApiTest.java index 8f091b3894..b498ec7f41 100644 --- a/tools/tck/src/test/java/dev/openfeature/contrib/tools/tck/DeclarationApiTest.java +++ b/tools/tck/src/test/java/dev/openfeature/contrib/tools/tck/DeclarationApiTest.java @@ -132,7 +132,9 @@ void reservedAndInexpressibleAreToldApart() { assertThat(reserved) .as("the reserved refusal says there is nothing to gate yet") - .contains("no scenario in the suite carries"); + .contains("no scenario in the suite carries") + .as("and never blames the SDK, because a reservation is every language's") + .doesNotContain("SDK"); assertThat(inexpressible) .as("the inexpressible refusal says the opposite: the scenarios exist elsewhere") .contains("the scenarios exist and are asked in languages whose API is wide enough") @@ -232,6 +234,36 @@ void deviationsRecordTheCapabilityTheGapIsAbout() { assertThat(untracked.issue).isNull(); } + @Test + @DisplayName("a deviation cannot name a capability whose scenarios were never put to the provider") + void deviationsCannotNameAnUnaskedCapability() { + // A deviation asserts that the provider fails something it is required to do, so it has to + // be about a question that was actually asked. Two never are, and they are refused apart + // for the same reason the declaration refuses them apart -- this is the same claim reaching + // the report by a second route, and the more damaging one, because a deviation reads as an + // admission of fault. + assertThatThrownBy(() -> KnownDeviation.untracked(Capability.CACHING, "no scenario carries it")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("reserved CACHING") + .hasMessageContaining("no scenario in the suite carries") + .hasMessageNotContaining("SDK"); + + assertThatThrownBy(() -> KnownDeviation.tracked( + Capability.LARGE_INTEGERS, "https://example.invalid/1", "cannot be asked")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("LARGE_INTEGERS") + .hasMessageContaining("the Java SDK cannot express") + .hasMessageContaining("Client.getIntegerDetails") + .as("it is not a reservation, and the message must not read as one") + .hasMessageNotContaining("no scenario in the suite carries"); + + // Null still means "a gap against a mandatory, ungated scenario", and an ordinary capability + // is still allowed in both shapes. + assertThat(KnownDeviation.untracked(null, "a mandatory gap").capability).isNull(); + assertThat(KnownDeviation.untracked(Capability.DISABLED_FLAGS, "withheld and skipped").capability) + .isEqualTo("@disabled-flags"); + } + @Test @DisplayName("a configuration name is derived from the suite class, and is overridable") void configurationNamesAreDerivedFromTheSuiteClass() { From c1397bf60f5dfdae9933afbca34ba66591c55dde Mon Sep 17 00:00:00 2001 From: Simon Schrottner Date: Sun, 13 Sep 2026 19:42:44 +0200 Subject: [PATCH 45/55] docs(tck): one tag withheld twice, and the rule that decides which absence is which The example this package taught was "a provider that does not declare @numeric-coercion because it narrows 0.5 to 0 with no error code has a bug" -- in KnownDeviation's javadoc, in Capability.NUMERIC_COERCION's and in the README. The distinction it draws is right: an undeclared capability can be a choice or a defect, and those are not the same thing. The illustration is not, because it reaches that distinction through withhold-plus-deviate, the one combination the field exists to discourage. It traces back to a paragraph of Appendix F that has since been corrected. Replaced with one already in this repository, which shows the same thing without endorsing the shape: one capability withheld twice for two different reasons. A provider with no streaming transport declines @configuration-change and is not pretending otherwise; MultiProviderTckTest withholds that same tag because MultiProvider never subscribes to its children and swallows their events, java-sdk#1882. One skip, two meanings. Where a provider does attempt a behaviour and get it wrong, the report is to declare the capability and let the scenario fail -- which is what the flagd adoption here does for the narrowing, and what Capability.NUMERIC_COERCION and the README now say. The README also still claimed that adoption withholds the tag; it declared it two passes ago. Two rules Appendix F has gained since are reflected where this package documents declaring. Once a provider is attempting a capability, the unit of the decision is the scenario rather than the tag -- with the opening clause carried across as the condition it is, because the rule decides whether a question is askable and not whether the provider owes an answer. That distinction is load-bearing here: the self-tests withhold @numeric-coercion because the SDK's provider does not coerce by design, which no requirement forbids, and reading the rule without its condition would manufacture a failure out of a permitted choice. Its two consequences are stated with it: a fixture-gap failure is not a provider defect, and a capability withheld for a backend gap is temporary and needs a note. And the self-test carve-out, which is the one place withholding for a defect is allowed, on the condition that the defect is pinned by a test of its own. Named where it is used rather than in the abstract: MultiProviderTckTest's omission is the only one in this module that rests on it, and it meets the condition only partly, because nothing here asserts the swallowed event directly. Said plainly in the javadoc and the README rather than left for a reader to work out. The other two self-test suites omit only properties and need no licence. @large-integers is withheld here because the SDK cannot express it, which is a third kind of absence now that the rules tell a backend gap from a choice. Appendix F illustrates the backend-gap rule with that very tag, so the prose says why the Java answer does not come from it: no fixture arriving anywhere would change it, only a wider accessor would. Signed-off-by: Simon Schrottner --- tools/tck/README.md | 73 ++++++++++++++++++- .../contrib/tools/tck/Capability.java | 28 +++++-- .../contrib/tools/tck/KnownDeviation.java | 27 ++++++- .../contrib/tools/tck/ProviderTckHarness.java | 31 ++++++++ .../tck/ControllableProviderTckTest.java | 3 +- .../tools/tck/InMemoryProviderTckTest.java | 9 ++- .../tools/tck/MultiProviderTckTest.java | 15 ++++ 7 files changed, 169 insertions(+), 17 deletions(-) diff --git a/tools/tck/README.md b/tools/tck/README.md index 52972a1c54..060fae062e 100644 --- a/tools/tck/README.md +++ b/tools/tck/README.md @@ -232,6 +232,25 @@ it. That is a known SDK gap, which the suite reproduced from the outside — the gap was originally found by hand-comparing implementations against the js-sdk reference. Everything else survives delegation unchanged. +**A self-test is allowed one thing an adoption is not: withholding a capability for a defect.** +[Appendix F](https://github.com/open-feature/spec/blob/main/specification/appendix-f-provider-conformance.md) +carves these suites out of the rule that a provider which attempts a behaviour and gets it wrong must +declare the capability and let the scenario fail. A self-test runs the scenarios against an SDK +provider as a fixture for the harness, reports on nobody, and runs in the ordinary build, where a +permanently failing scenario is a broken build rather than a finding anyone downstream can act on — +the fix is an SDK release away. The licence carries one condition: **the defect must be pinned by a +test of its own**, so the skip is not the only record of it. An adoption has no such licence: it +exists to report on a provider, and a skip there is a claim about that provider. + +Two of the three suites here do not need the carve-out. Every omission in `InMemoryProviderTckTest` +and `ControllableProviderTckTest` is a property of the provider rather than a defect: strict numeric +typing, no rule evaluation, no connection to lose, a flag set handed over by the constructor. +`MultiProviderTckTest`'s missing `CONFIGURATION_CHANGE` is the one that does use it — java-sdk#1882 +is a defect, not a design choice — and today it meets the carve-out's condition only partly: the +class javadoc names the issue and says what to delete when it is fixed, but no test in this module +asserts the swallowed event directly, so the skip is still the only executable record. That is the +open item against this section. + Two more tests are not suites at all, because what they guard is invisible from inside a scenario. [`InProcessBackendControlTest`](src/test/java/dev/openfeature/contrib/tools/tck/InProcessBackendControlTest.java) calls the unsupported operations directly, so a connection operation that quietly did nothing cannot @@ -533,6 +552,33 @@ green on scenarios it did not run is worse than no suite at all. The default is every *declarable* capability. **Narrow it, do not widen it**: start from the default, run the suite, and remove only what your provider genuinely cannot do. +**Once your provider is attempting a capability, the unit of that decision is the scenario, not the +tag.** +[Appendix F](https://github.com/open-feature/spec/blob/main/specification/appendix-f-provider-conformance.md) +states it as: declare a capability when at least one scenario gating it can actually be put to your +provider, and withhold it only when none can. `@numeric-coercion` has three scenarios, so a backend +that cannot serve the flag one of them asks for still has two answers to give, and withholding the tag +hides both of them to avoid one failure. + +**The opening clause is a condition, not throat-clearing.** That rule decides whether the question can +be *asked*; whether your provider owes an answer is the earlier question, and [Saying that a gap is a +defect](#saying-that-a-gap-is-a-defect) is where that one is settled. Where the specification permits +declining — `@numeric-coercion` rests on no requirement, so a provider may simply not coerce — +withholding is the honest report however askable the scenarios are, and applying the scenario rule +there manufactures a failure out of a permitted choice. The self-tests in this module withhold that +tag on exactly those grounds, and are right to. + +Two consequences follow from the rule itself, and they go wrong in opposite directions: + +- **A scenario that fails because the backend cannot serve its fixture is not a provider defect.** Say + so in the deviation's `summary` beside it, or the report accuses your provider of the stack's gap. +- **A capability withheld for a backend gap is temporary**, in a way one withheld by choice is not. + Say why it is withheld and what would change the answer, or it outlives its reason and no later + reader can tell that it was meant to be revisited. + +Neither applies to `LARGE_INTEGERS`, which is refused here for a reason upstream of any backend — see +below. + Two rows above are not yours to decide and are refused if you name them, with different messages because they are different facts: @@ -644,10 +690,20 @@ the specification has a single numeric type and says nothing about a value that accessor it was asked through ([spec#430](https://github.com/open-feature/spec/issues/430)), so a provider that behaves differently is not violating it. It is still worth saying which kind of difference it is: narrowing `0.5` to `0` with no error code hands an application a plausible value and -no signal, which is a defect to declare as a `KnownDeviation`, whereas keeping the two types strictly -apart — what `InMemoryProvider` does — is a choice. **The flagd provider does not declare it**, in -either mode, for the first reason — see -[flagd#1996](https://github.com/open-feature/flagd/issues/1996). +no signal, which is a defect, whereas keeping the two types strictly apart — what `InMemoryProvider` +does — is a choice. + +**The two are reported differently, and that is the point of the distinction.** A provider that +narrows **declares** the tag, lets the lossy scenario fail, and records a `KnownDeviation` beside the +failure — it does attempt the coercion and gets one direction wrong, which is exactly what a skip +cannot express. A provider that cannot attempt it at all — a single numeric type, or strict typing in +both directions — **withholds** the tag, and needs no deviation, because there is no distinction +there to get wrong. Do not read the defect half as a reason to withhold: withholding a capability *in +order to* turn a failure into a skip is the one use [Saying that a gap is a +defect](#saying-that-a-gap-is-a-defect) rules out. The flagd adoption in this repository is the first +kind — it declares `@numeric-coercion` in both modes and carries a tracked deviation for the +narrowing, see [flagd#1996](https://github.com/open-feature/flagd/issues/1996) — and the OFREP +adoption is the second. A note on `LARGE_INTEGERS`, which **you do not have to know anything about**: accessor width is a property of the SDK rather than of the provider, and Java's is 32 bits — `Client.getIntegerDetails` @@ -677,6 +733,15 @@ and says the provider had no say, rather than the ordinary *"provider does not d needs a `KnownDeviation` — neither is a defect. The 32-bit precision scenario (`large-integer-flag`, 2^31 − 1) is untagged and always runs. +**It is not a backend gap either**, which is the third thing it could be mistaken for now that the +declaring rules distinguish them. A capability withheld because the backend serves no flag for a +scenario is temporary, needs a note saying what would change the answer, and is revisited when the +backend gains the fixture — Appendix F illustrates that rule with this very tag, because the +reference backend serves no flag for its one scenario. Here the question never reaches a backend: no +`Integer` can carry 2^53 − 1 however the stack is provisioned, so no fixture arriving anywhere would +change the answer and no Java suite reaches the declaring rules for this tag at all. Only a wider SDK +accessor would. + A note on `STANDARD_REASONS`, which is **a claim rather than an exemption** and is the one capability whose absence costs a provider nothing. [Requirement 2.2.5](https://github.com/open-feature/spec/blob/main/specification/sections/02-providers.md) diff --git a/tools/tck/src/main/java/dev/openfeature/contrib/tools/tck/Capability.java b/tools/tck/src/main/java/dev/openfeature/contrib/tools/tck/Capability.java index 4b613cb5d3..3467710e6d 100644 --- a/tools/tck/src/main/java/dev/openfeature/contrib/tools/tck/Capability.java +++ b/tools/tck/src/main/java/dev/openfeature/contrib/tools/tck/Capability.java @@ -234,12 +234,16 @@ public enum Capability { * through — that is open-feature/spec#430. * A provider that behaves differently is not violating the specification, and a report must * not be read as saying it is. The difference is still worth a word: narrowing {@code 0.5} to - * {@code 0} with no error code hands an application a plausible value and no signal, so a - * provider that does that should say whether it is a choice or a defect, and - * {@link KnownDeviation} is where the second is said. Note which shape that takes — such a - * provider does attempt the coercion and gets it wrong, so the honest report is to - * declare the tag, let the lossy scenario fail, and record the deviation beside the failure - * rather than withholding the tag to turn the failure into a skip. + * {@code 0} with no error code hands an application a plausible value and no signal, and that is + * a defect rather than a choice. + * + *

        A provider in that position declares the tag. It does attempt the + * coercion and gets one direction wrong, which is precisely what a skip cannot express, so the + * honest report is to declare, let the lossy scenario fail, and record a {@link KnownDeviation} + * beside the failure. Withholding is for a provider that cannot attempt the behaviour + * at all — one whose SDK has a single numeric type, or one that keeps the two types strictly + * apart in both directions as {@code InMemoryProvider} does, where the distinction does not + * exist to get wrong. That is the choice half of the same rule, and it needs no deviation. * *

        Both halves are tested. The lossy half asks for {@code float-flag} (0.5) * as an integer and expects {@code TYPE_MISMATCH}; the lossless half asks for @@ -274,6 +278,18 @@ public enum Capability { * Appendix * F states the rule that puts it here. * + *

        It is also not the same kind of decision as a capability withheld because a backend + * cannot serve a scenario's flag, and the two are now formally different. Appendix F's + * declaring rule — once a provider is attempting a capability, declare it when at least one + * scenario gating it can actually be put to the provider, and withhold only when none can — is + * answered per scenario, with a backend in + * view, and a capability withheld on those grounds is temporary: it needs a note saying + * why, and it is revisited when the backend gains the fixture. The appendix illustrates that + * rule with this very tag, because the reference backend serves no flag for its one scenario. + * In Java the question never reaches a backend. The accessor cannot carry {@code 2^53 − 1} + * however the backend is provisioned, so no Java suite gets as far as that rule for this tag, + * and no fixture arriving anywhere would change the answer. Only a wider SDK accessor would. + * *

        The 32-bit precision scenario — {@code large-integer-flag}, 2^31 − 1 — is untagged and * always runs. What a provider owes a value that does not fit the requested accessor is the * open question in open-feature/spec#430. diff --git a/tools/tck/src/main/java/dev/openfeature/contrib/tools/tck/KnownDeviation.java b/tools/tck/src/main/java/dev/openfeature/contrib/tools/tck/KnownDeviation.java index 258c3be164..84de4a966c 100644 --- a/tools/tck/src/main/java/dev/openfeature/contrib/tools/tck/KnownDeviation.java +++ b/tools/tck/src/main/java/dev/openfeature/contrib/tools/tck/KnownDeviation.java @@ -8,10 +8,29 @@ *

        The requirement has to be a numbered {@code MUST}, or a rule the implementation bound itself to * elsewhere — flagd's numeric-coercion ADR, say. Where the specification permits the * choice, withholding the capability is the honest report and a deviation entry would - * assert a defect that does not exist. A provider that does not declare - * {@code @configuration-change} has no streaming transport and is not pretending otherwise; a - * provider that narrows {@code 0.5} to {@code 0} with no error code, having said elsewhere that it - * coerces losslessly, has a defect. + * assert a defect that does not exist. + * + *

        The clearest illustration is one capability withheld twice, for two different + * reasons. A provider with no streaming transport does not declare + * {@code @configuration-change}: it has no way to notice a change and is not pretending otherwise, + * so the skip is the whole report and there is nothing to deviate from. This module's own + * {@code MultiProviderTckTest} withholds that same tag because {@code MultiProvider} extends + * {@code EventProvider} and never subscribes to its children, so a child's + * {@code PROVIDER_CONFIGURATION_CHANGED} is swallowed — + * open-feature/java-sdk#1882, + * a defect with a fix pending rather than a design. One skip, two meanings, and only the second is + * something a reader has to be told. + * + *

        That is the distinction. It is not a rule about which absences may carry a deviation: + * a provider that attempts a behaviour and gets it wrong declares the capability and lets the + * scenario fail — shape 1 below — rather than withholding it. flagd narrowing {@code 0.5} to + * {@code 0} with no error code is that case, and the adoption in this repository declares + * {@code @numeric-coercion} for exactly that reason. An earlier revision of + * Appendix + * F illustrated the choice-against-defect distinction with a provider that withheld + * {@code @numeric-coercion} because it narrows, and two of the four implementations followed it into + * the withhold-plus-deviate combination this class exists to discourage. The appendix has since been + * corrected and so has this paragraph. * *

        The two legitimate shapes

        * diff --git a/tools/tck/src/main/java/dev/openfeature/contrib/tools/tck/ProviderTckHarness.java b/tools/tck/src/main/java/dev/openfeature/contrib/tools/tck/ProviderTckHarness.java index 416bf9d09e..2a216dd078 100644 --- a/tools/tck/src/main/java/dev/openfeature/contrib/tools/tck/ProviderTckHarness.java +++ b/tools/tck/src/main/java/dev/openfeature/contrib/tools/tck/ProviderTckHarness.java @@ -122,6 +122,37 @@ default FeatureProvider createUnavailableProvider() { * provider genuinely cannot do — {@link Capability#declarableExcept} is the idiomatic way to say * "everything except". * + *

        Once your provider is attempting a capability, the unit of that decision is the + * scenario, not the tag. + * Appendix + * F states it as: declare a capability when at least one scenario gating it can actually be + * put to your provider, and withhold it only when none can. A tag with three scenarios whose + * backend cannot serve the flag one of them asks for still has two answers to give, and + * withholding it hides both to avoid one failure. + * + *

        The opening clause is a condition, not throat-clearing. This rule decides + * whether the question can be asked; whether your provider owes an answer is the earlier + * question, and {@link KnownDeviation} is where that one is settled. Where the specification + * permits declining — {@code @numeric-coercion} rests on no requirement, so a provider may + * simply not coerce — withholding is the honest report however askable its scenarios are, and + * applying this rule there manufactures a failure out of a permitted choice. The self-tests in + * this module withhold that tag on exactly those grounds. + * + *

        Two consequences follow from the rule itself, and they are easy to get wrong in opposite + * directions: + * + *

          + *
        • A scenario that fails because the backend cannot serve its fixture is not + * a provider defect. Say so in the {@link KnownDeviation#summary} beside it, or the report + * accuses your provider of the stack's gap. + *
        • A capability withheld for a backend gap is temporary in a way one + * withheld by choice is not. Note why it is withheld and what would change the answer, or + * it outlives its reason and no later reader can tell that it should have been revisited. + *
        + * + *

        This does not reach {@link Capability#LARGE_INTEGERS}, which is refused here for a reason + * upstream of any backend — see that constant. + * *

        Remove only what your provider cannot do. What no Java provider can do is already * gone: {@link Capability#LARGE_INTEGERS} asks for 2^53 − 1 and {@code Client.getIntegerDetails} * is a 32-bit {@link Integer} with no room for it, so it is diff --git a/tools/tck/src/test/java/dev/openfeature/contrib/tools/tck/ControllableProviderTckTest.java b/tools/tck/src/test/java/dev/openfeature/contrib/tools/tck/ControllableProviderTckTest.java index a3cfa88dc0..5b338fde2a 100644 --- a/tools/tck/src/test/java/dev/openfeature/contrib/tools/tck/ControllableProviderTckTest.java +++ b/tools/tck/src/test/java/dev/openfeature/contrib/tools/tck/ControllableProviderTckTest.java @@ -87,7 +87,8 @@ public FeatureProvider createUnavailableProvider() { * *

        And the omissions, which are the same as {@link InMemoryProviderTckTest}'s because every * resolution decision here is still the SDK provider's — this class adds a lifecycle and - * delegates all evaluation: + * delegates all evaluation. Each is a property of the delegate rather than a defect, so none of + * them rests on the self-test carve-out: * *

          *
        • {@link Capability#NUMERIC_COERCION} — the delegate type-checks rather than coerces, so diff --git a/tools/tck/src/test/java/dev/openfeature/contrib/tools/tck/InMemoryProviderTckTest.java b/tools/tck/src/test/java/dev/openfeature/contrib/tools/tck/InMemoryProviderTckTest.java index 2867da32e3..9b03cdc249 100644 --- a/tools/tck/src/test/java/dev/openfeature/contrib/tools/tck/InMemoryProviderTckTest.java +++ b/tools/tck/src/test/java/dev/openfeature/contrib/tools/tck/InMemoryProviderTckTest.java @@ -62,7 +62,10 @@ public FeatureProvider createProvider() { * and pass. The other two carry {@code @targeting} as well and are skipped for that omission — * the tag composition doing its job, since a provider that evaluates no rules has no * {@code TARGETING_MATCH} to report and failing it for the absence would say nothing. Each - * omission below is a fact about {@link InMemoryProvider} rather than a convenience: + * omission below is a fact about {@link InMemoryProvider} rather than a convenience, and each is + * a property rather than a defect — so none of them leans on the self-test carve-out + * Appendix F grants these suites, and {@link MultiProviderTckTest} is the only one in this + * module that does: * *
            *
          • {@link Capability#NUMERIC_COERCION} — omitted. {@link InMemoryProvider} keeps the two @@ -74,7 +77,9 @@ public FeatureProvider createProvider() { * rule is borrowed from flagd's ADR rather than from the specification, so strict typing * is a choice the SDK's reference provider is entitled to, not a defect to declare; the * capability is withheld and the three scenarios are skipped with that reason. Declare it - * again if the SDK ever adopts the coercion rule. + * again if the SDK ever adopts the coercion rule. Appendix F's scenario-level declaring + * rule does not reach this omission: it decides whether a question is askable, + * not whether the provider owes an answer, and no requirement says this one is owed. *
          • {@link Capability#LIFECYCLE} — omitted. {@link InMemoryProvider} is handed its whole * flag set by its constructor, so initialisation acquires nothing and cannot be refused, * and the readiness scenario would pass without demonstrating anything — which is exactly diff --git a/tools/tck/src/test/java/dev/openfeature/contrib/tools/tck/MultiProviderTckTest.java b/tools/tck/src/test/java/dev/openfeature/contrib/tools/tck/MultiProviderTckTest.java index 237866dc88..166752ae78 100644 --- a/tools/tck/src/test/java/dev/openfeature/contrib/tools/tck/MultiProviderTckTest.java +++ b/tools/tck/src/test/java/dev/openfeature/contrib/tools/tck/MultiProviderTckTest.java @@ -66,6 +66,21 @@ public FeatureProvider createProvider() { * {@code @configuration-change} scenario is reported as skipped-with-reason rather than passing * on a provider that cannot satisfy it. * + *

            This is the one omission in this module that rests on Appendix F's self-test + * carve-out, and it is worth naming as such. The rule for an adoption is that a + * provider which attempts a behaviour and gets it wrong declares the capability and lets the + * scenario fail; + * Appendix + * F exempts a TCK's own self-tests, because they run the scenarios against an SDK provider + * as a fixture, report on nobody, and run in the ordinary build where a permanently failing + * scenario is a broken build rather than a finding — the fix is an SDK release away. The + * exemption has one condition: the defect is pinned by a test of its own, so that the + * skip is not the only record. That condition is met only partly here. The issue is named above + * and the deletion criterion with it, but nothing in this module asserts the swallowed event + * directly, so a reader has the javadoc and the skip reason and no executing assertion. The + * other two self-test suites do not need the carve-out at all: every capability they omit is a + * property of the provider rather than a defect. + * *

            Everything else holds. Values, variants, reasons, the full type-mismatch matrix, * {@code FLAG_NOT_FOUND}, falsy values, 32-bit integer precision and structured values all * survive the delegation hop unchanged — {@link Capability#VARIANTS} is declared for exactly From 5509e26d74beb611da1e0f095a3477eb2773df68 Mon Sep 17 00:00:00 2001 From: Simon Schrottner Date: Sun, 13 Sep 2026 19:42:45 +0200 Subject: [PATCH 46/55] chore(tck): re-pin the canonical assets to aa2ad24f Three prose commits in Appendix F: the numeric-coercion note stops teaching withhold-plus-deviate, the declaring rules gain a sixth -- once a provider is attempting a capability, the unit of the decision is the scenario rather than the tag -- and that sixth rule gains back the condition it was first published without, so it decides whether a question is askable rather than whether an answer is owed. Nothing under specification/assets/ moves across any of them, so the Gherkin, the canonical flag set and the control API are byte-identical to 89b1519a's and PINNED_DIGEST does not change. That is asserted rather than assumed: CanonicalAssetDigestTest passes unchanged at the new revision, over all three asset trees, which is the whole of what an unchanged digest line here means. 246 tests, 0 failures, 43 skipped -- the same numbers as at the old pin. The gitlink and PINNED_REVISION move together as that class documents. On the report branch tck.spec.revision follows, and the build fails if it does not. Signed-off-by: Simon Schrottner --- tools/tck/spec | 2 +- .../openfeature/contrib/tools/tck/CanonicalAssetDigestTest.java | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/tools/tck/spec b/tools/tck/spec index 89b1519a08..aa2ad24f5a 160000 --- a/tools/tck/spec +++ b/tools/tck/spec @@ -1 +1 @@ -Subproject commit 89b1519a08d81c46ba47fc2a54c44d40fdee845d +Subproject commit aa2ad24f5a14ae2b5756df0b6d23f493f39507e6 diff --git a/tools/tck/src/test/java/dev/openfeature/contrib/tools/tck/CanonicalAssetDigestTest.java b/tools/tck/src/test/java/dev/openfeature/contrib/tools/tck/CanonicalAssetDigestTest.java index 13d3cae8db..ae3145e926 100644 --- a/tools/tck/src/test/java/dev/openfeature/contrib/tools/tck/CanonicalAssetDigestTest.java +++ b/tools/tck/src/test/java/dev/openfeature/contrib/tools/tck/CanonicalAssetDigestTest.java @@ -80,7 +80,7 @@ class CanonicalAssetDigestTest { *

            Must equal {@code git -C tools/tck/spec rev-parse HEAD}, and is the value the report * branch publishes as the source of a run's scenarios. */ - static final String PINNED_REVISION = "89b1519a08d81c46ba47fc2a54c44d40fdee845d"; + static final String PINNED_REVISION = "aa2ad24f5a14ae2b5756df0b6d23f493f39507e6"; /** SHA-256 of the three asset trees at {@link #PINNED_REVISION}, as {@link #digest} computes it. */ static final String PINNED_DIGEST = "a7c74fbe178cf5a9937be8d26e098bc4d6f4b723991a77e0e73d0a330a299c94"; From b34f130939fa53638297c81024d56ae890f0be6f Mon Sep 17 00:00:00 2001 From: Simon Schrottner Date: Sun, 13 Sep 2026 20:56:49 +0200 Subject: [PATCH 47/55] docs(tck): the documented command is a step, and the step is called tck Appendix F now asks an adopter for a step of its own rather than a corner of an existing end-to-end suite, and the reason is what a red build says: a conformance run carries failures by design wherever a knownDeviation is declared, while an e2e suite is expected green, so a signal shared between the two ends with somebody silencing the informative half. In Maven that step is a profile, one per adopting module, named `tck` because JavaScript's `nx tck` target and Python's `poe test-tck` task already spell it that way. This section shows its shape and says why the exclusion has to be cleared and the includes narrowed in the same profile: clearing alone re-enables every Docker-dependent suite the module has, and narrowing alone leaves the exclusion in force and runs nothing. The three command-line overrides this replaces - `-DtestExclusions=`, `-Dtest='*TckTest'`, `-Dsurefire.failIfNoSpecifiedTests=false` - did run the right suites, so this is not a correctness fix. It is that a command a reader has to reassemble from three flags is not a step: nothing names it, CI cannot invoke it by name, and its failure is indistinguishable from any other Surefire failure in the same module. It also says not to reach for `-am` on the run, which was the first shape tried here: it pulls this module, and tools/flagd-core for providers/flagd, into the reactor and runs their suites before the first scenario, putting the two kinds of failure back on one signal. A one-off `install` is what `-am` was there for. Signed-off-by: Simon Schrottner --- tools/tck/README.md | 55 +++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 53 insertions(+), 2 deletions(-) diff --git a/tools/tck/README.md b/tools/tck/README.md index 060fae062e..35ff352185 100644 --- a/tools/tck/README.md +++ b/tools/tck/README.md @@ -930,8 +930,59 @@ putting it back — and both were found by running this, not by reading: mvn -Pe2e -pl providers/ help:evaluate -Dexpression=testExclusions -DforceStdout ``` -The single documented command that runs a suite deliberately, per the appendix, is the one in each -adoption's own README: `-DtestExclusions=` on the command line overrides the property for one run. +**Then give the suite a step of its own**, which is the appendix's other requirement and the reason +is what a red build *says*: a conformance run carries failures by design wherever a `knownDeviation` +is declared, so a signal it shares with a suite that is expected green ends with somebody silencing +the informative half. In Maven that step is a profile, one per adopting module, and it is named +`tck` because JavaScript's `nx tck` target and Python's `poe test-tck` task already spell it that +way: + +```xml + + tck + + + + + + + + org.apache.maven.plugins + maven-surefire-plugin + + + **/e2e/*TckTest.java + + + + + + +``` + +Clearing the exclusion and narrowing the includes **in the same profile** is what makes it a +conformance step rather than a wider one. Clearing alone re-enables every Docker-dependent suite the +module has; narrowing alone leaves the exclusion in force and runs nothing. + +```bash +# once, if this module is not in your local repository yet +mvn -pl tools/tck -am -DskipTests install + +mvn -Ptck -pl providers/ test +``` + +The second line is the documented command the appendix asks for, and it is the one in each +adoption's own README. **Resist adding `-am` to it.** `-am` pulls this module — and anything else +the adoption depends on, `tools/flagd-core` for `providers/flagd` — into the reactor and runs their +test suites before the first scenario, so a failure in any of them comes out as a `-Ptck` failure. +That is the signal-mixing the separate step exists to prevent, reintroduced by a flag. The one-off +`install` is what `-am` was there for. + +The three command-line overrides this replaced — `-DtestExclusions= -Dtest='*TckTest' +-Dsurefire.failIfNoSpecifiedTests=false` — did run the right suites, so this is not a correctness +fix. It is that a command a reader has to reassemble from three flags is not a step: nothing names +it, CI cannot invoke it by name, and its failure is indistinguishable from any other Surefire +failure in the same module. Scenarios run **serially** and the suite enforces this, overriding any `cucumber.execution.parallel.enabled=true` in your module's `junit-platform.properties`. Control API From dafa391560a0f268d4fc7960279493493468173b Mon Sep 17 00:00:00 2001 From: Simon Schrottner Date: Sun, 13 Sep 2026 22:24:43 +0200 Subject: [PATCH 48/55] docs(tck): select the conformance suite by directory, not by filename The step this module documented selected its suites with `**/e2e/*TckTest.java` -- a filename pattern inside another suite's directory. An adoption now gets a directory of its own, a `tck` package beside the module's other test packages rather than inside one, and every selector names it. Nesting under `e2e/` said "this is a kind of e2e test", which is the conflation the separate step exists to undo: an e2e suite is expected green, a conformance suite fails scenarios by design wherever a knownDeviation is declared. And selection stops being a naming convention -- a file is in the directory or it is not, where a filename pattern works right up until somebody adds a suite whose name does not fit it and nothing says so. So the README's mechanism section now reads: * the exclusion is `**/tck/*.java`, and a module with a second Docker- dependent package lists both -- providers/flagd's reads `**/e2e/*.java,**/tck/*.java`; * the `tck` profile drops that directory from the exclusion and includes `**/tck/*.java`. Both halves are still needed, for the reason they always were: the include alone leaves the exclusion in force and runs nothing, and dropping the exclusion alone runs the module's unit tests too; * a module whose CI activates another profile keeps the other half of the property there, so the two steps stay disjoint whichever is activated. The example suites lose the `Tck` the directory now carries, and the section says to keep the `*Test` suffix, which Surefire's default includes need -- that is a different thing from the selector being removed. One trap found while moving flagd, and it is worth an adopter's attention rather than only flagd's: configuration() derives the name a run is filed under from the class name, so a suite renamed to `InProcessTest` on the strength of its package derives `in-process`, which does not say whose. The derivation reads a class name and a report is read by someone who has neither it nor the package. Both the README and the two javadocs now say to check it, and flagd's suites state `flagd-rpc` and `flagd-in-process` outright. Javadoc and prose only; no signature changes. Signed-off-by: Simon Schrottner --- tools/tck/README.md | 74 ++++++++++++++----- .../tck/ContainerizedProviderTckTest.java | 2 +- .../contrib/tools/tck/ProviderTckHarness.java | 8 +- .../contrib/tools/tck/ReportNames.java | 10 ++- 4 files changed, 67 insertions(+), 27 deletions(-) diff --git a/tools/tck/README.md b/tools/tck/README.md index 35ff352185..8aab2317a3 100644 --- a/tools/tck/README.md +++ b/tools/tck/README.md @@ -131,7 +131,7 @@ change is the provider's own update mechanism emitting its own event. canonical flag set. The entire adoption is three methods: ```java -public class MyProviderTckTest extends ProviderTckTest { +public class MyProviderTest extends ProviderTckTest { private final InProcessBackendControl control = new InProcessBackendControl(); @@ -385,7 +385,7 @@ baseline. ### 4. The test class ```java -public class MyProviderTckTest extends ContainerizedProviderTckTest { +public class MyProviderTest extends ContainerizedProviderTckTest { @Override public File composeFile() { @@ -427,16 +427,16 @@ registration, no system property, no build configuration. Each class is its own own Compose stack, and they can share a base class: ```java -abstract class AbstractMyProviderTckTest extends ContainerizedProviderTckTest { +abstract class AbstractMyProviderTest extends ContainerizedProviderTckTest { protected abstract Mode mode(); // composeFile(), createProvider(), capabilities() ... shared here } -public class MyProviderRemoteTckTest extends AbstractMyProviderTckTest { +public class RemoteTest extends AbstractMyProviderTest { @Override protected Mode mode() { return Mode.REMOTE; } } -public class MyProviderInProcessTckTest extends AbstractMyProviderTckTest { +public class InProcessTest extends AbstractMyProviderTest { @Override protected Mode mode() { return Mode.IN_PROCESS; } } ``` @@ -458,7 +458,7 @@ and IDEs all do by default. If your launcher disables listener auto-registration harness explicitly instead at `src/test/resources/META-INF/services/dev.openfeature.contrib.tools.tck.ProviderTckHarness`, and if you register more than one, select between them with -`-Dopenfeature.tck.harness=MyProviderRemoteTckTest`. +`-Dopenfeature.tck.harness=RemoteTest`. @@ -832,7 +832,12 @@ exercised. A provider with two materially different modes — flagd's RPC and in runs two suites whose results are not interchangeable, and the name is what keeps them apart. It defaults to the suite class name, hyphenated and with the JUnit suffix dropped, so -`FlagdInProcessTckTest` becomes `flagd-in-process`. Override it when that does not read well. +`MyProviderInProcessTest` becomes `my-provider-in-process`. Override it when that does not read +well — and **check it if your suite lives in a package that already names the provider**, which is +the layout below recommends. A suite called `InProcessTest` in `...providers/flagd/tck/` derives +`in-process`, which says nothing about whose in-process mode it was to anyone reading the report +away from this repository. flagd's two suites therefore state `flagd-rpc` and `flagd-in-process` +outright. The derivation reads a class name; a report is read by someone who has neither. ### How the backend was driven @@ -890,12 +895,37 @@ belongs in that backend's issue tracker; raising a pause in four languages is no ## Running it ```bash -mvn test -Dtest=MyProviderTckTest +mvn -Ptck -pl providers/ test ``` A suite extending `ProviderTckTest` with in-process control needs no Docker and no network. A suite extending `ContainerizedProviderTckTest` needs a working Docker daemon for its Compose stack. +### Put the adoption in a directory of its own + +**A `tck` package beside your module's other test packages, not inside one of them.** In +`providers/flagd` that is `src/test/java/dev/openfeature/contrib/providers/flagd/tck/`, a sibling of +the `e2e` package rather than a corner of it. + +Two reasons, and the second is the one the rest of this section rests on. + +A conformance suite and an end-to-end suite mean different things by failure. An e2e suite tests +your provider against your own harness and is expected green; a conformance suite tests it against +the OpenFeature provider contract and fails scenarios *by design*, wherever a `knownDeviation` is +declared. Filing one under the other says they are the same kind of result, which is the conflation +the separate step below exists to undo. + +And selection stops being a naming convention. Every selector — the exclusion, the profile that +runs the suite, the profile that must not — then names a directory, and a file is in it or it is +not. Selecting by filename works right up until somebody adds a suite whose name does not fit the +pattern, and nothing tells them. + +Once the directory selects, names that repeated it are saying the same thing twice, so drop that +part: `FlagdRpcTckTest` in package `...flagd.tck` is `RpcTest`, and the fully-qualified name still +carries everything. **Keep the `*Test` suffix** — Surefire's default includes need it, which is a +different thing from the selector being removed. And check `configuration()` when you do: see +[Naming the configuration under test](#naming-the-configuration-under-test). + ### Containerised suites are excluded from the default build, on purpose **Why** an adoption suite is excluded rather than gating, and the two mistakes that exclusion @@ -910,21 +940,24 @@ property the parent POM feeds to Surefire: ```xml - **/e2e/*.java + **/tck/*.java ``` The parent POM defines no default for it, so a module that wants the gate must declare the property itself. It is a **Surefire** exclusion, not a compiler one: the suite still compiles against the -harness in every build, which is what keeps an adoption from rotting unnoticed. +harness in every build, which is what keeps an adoption from rotting unnoticed. A module with more +than one Docker-dependent package lists them all — `providers/flagd` has its legacy `e2e` suites as +well, so its property reads `**/e2e/*.java,**/tck/*.java`. **Then resolve the property under every profile your CI activates** — do not read the POM, which is the mistake the appendix names first. Here, `ci.yml`'s `main` job activates `e2e` on every push, and `providers/flagd` has an `e2e` profile for its legacy `Run*Test` suites; that profile therefore -narrows the exclusion to `**/e2e/*TckTest.java` rather than clearing it to ``, so -the legacy suites keep running and the TCK suites stay out. Both halves of the appendix's warning -happened in this repository — one adoption never declared the property, the other had a profile -putting it back — and both were found by running this, not by reading: +drops only `**/e2e/*.java` from the exclusion and leaves `**/tck/*.java` in it, rather than clearing +it to ``, so the legacy suites keep running and the TCK suites stay out. Both +halves of the appendix's warning happened in this repository — one adoption never declared the +property, the other had a profile putting it back — and both were found by running this, not by +reading: ```bash mvn -Pe2e -pl providers/ help:evaluate -Dexpression=testExclusions -DforceStdout @@ -941,7 +974,7 @@ way: tck - + @@ -951,7 +984,7 @@ way: maven-surefire-plugin - **/e2e/*TckTest.java + **/tck/*.java @@ -960,9 +993,12 @@ way: ``` -Clearing the exclusion and narrowing the includes **in the same profile** is what makes it a -conformance step rather than a wider one. Clearing alone re-enables every Docker-dependent suite the -module has; narrowing alone leaves the exclusion in force and runs nothing. +Dropping the directory from the exclusion and narrowing the includes to it **in the same profile** +is what makes this a conformance step rather than a wider one. Dropping alone runs the module's unit +tests alongside the suites; narrowing alone leaves the exclusion in force and runs nothing. A module +with a second excluded package keeps that half of the property — `providers/flagd`'s `tck` profile +sets `**/e2e/*.java`, the exact mirror of what its `e2e` profile sets — so the two steps stay +disjoint whichever one is activated. ```bash # once, if this module is not in your local repository yet diff --git a/tools/tck/src/main/java/dev/openfeature/contrib/tools/tck/ContainerizedProviderTckTest.java b/tools/tck/src/main/java/dev/openfeature/contrib/tools/tck/ContainerizedProviderTckTest.java index d8f86722fa..66a429ffad 100644 --- a/tools/tck/src/main/java/dev/openfeature/contrib/tools/tck/ContainerizedProviderTckTest.java +++ b/tools/tck/src/main/java/dev/openfeature/contrib/tools/tck/ContainerizedProviderTckTest.java @@ -35,7 +35,7 @@ *

            Example — the entire adoption for a provider with one transport: * *

            {@code
            - * public class MyProviderTckTest extends ContainerizedProviderTckTest {
            + * public class MyProviderTest extends ContainerizedProviderTckTest {
              *
              *     @Override
              *     public File composeFile() {
            diff --git a/tools/tck/src/main/java/dev/openfeature/contrib/tools/tck/ProviderTckHarness.java b/tools/tck/src/main/java/dev/openfeature/contrib/tools/tck/ProviderTckHarness.java
            index 2a216dd078..40b8cb795a 100644
            --- a/tools/tck/src/main/java/dev/openfeature/contrib/tools/tck/ProviderTckHarness.java
            +++ b/tools/tck/src/main/java/dev/openfeature/contrib/tools/tck/ProviderTckHarness.java
            @@ -31,7 +31,7 @@
              * 

            Example — the entire adoption for a backend-less provider: * *

            {@code
            - * public class MyProviderTckTest extends ProviderTckTest {
            + * public class MyProviderTest extends ProviderTckTest {
              *
              *     private final InProcessBackendControl control = new InProcessBackendControl();
              *
            @@ -202,8 +202,10 @@ default List knownDeviations() {
                  * materially different modes — flagd's RPC and in-process resolvers, say — produces two runs
                  * that are not interchangeable and must not be labelled the same.
                  *
            -     * 

            Derived from the suite class name by default: {@code FlagdInProcessTckTest} becomes - * {@code flagd-in-process}. Override it when that does not read well. + *

            Derived from the suite class name by default: {@code MyProviderInProcessTest} becomes + * {@code my-provider-in-process}. Override it when that does not read well, and check it when + * the suite lives in a package that already names the provider — {@code InProcessTest} derives + * {@code in-process}, which does not say whose. * * @return a short name for this configuration */ diff --git a/tools/tck/src/main/java/dev/openfeature/contrib/tools/tck/ReportNames.java b/tools/tck/src/main/java/dev/openfeature/contrib/tools/tck/ReportNames.java index b1cc6bfdc9..250d97c4bc 100644 --- a/tools/tck/src/main/java/dev/openfeature/contrib/tools/tck/ReportNames.java +++ b/tools/tck/src/main/java/dev/openfeature/contrib/tools/tck/ReportNames.java @@ -22,10 +22,12 @@ private ReportNames() {} /** * Derives a configuration name from a suite class. * - *

            {@code FlagdInProcessTckTest} becomes {@code flagd-in-process}: the suffix that exists only - * so JUnit picks the class up is dropped, and the rest is hyphenated. A provider whose modes do - * not read well this way overrides {@link ProviderTckHarness#configuration()} and says so - * directly. + *

            {@code MyProviderInProcessTest} becomes {@code my-provider-in-process}: the suffix that + * exists only so JUnit picks the class up is dropped, and the rest is hyphenated. A provider + * whose modes do not read well this way overrides {@link ProviderTckHarness#configuration()} and + * says so directly — as one whose suite sits in a package that already names it should, since a + * class called {@code InProcessTest} derives {@code in-process} and a report is read by someone + * who cannot see which package it came from. * * @param suite the concrete suite class * @return a hyphenated, lower-case configuration name From 6aace039ac9d3a787c136fd17a8c4ac7458895ae Mon Sep 17 00:00:00 2001 From: Simon Schrottner Date: Mon, 14 Sep 2026 06:55:15 +0200 Subject: [PATCH 49/55] docs(tck): rewrite the README as an adoption guide The base README had grown to 74 KB across 29 sections - larger than Appendix F, which is the normative document for the whole cross-language effort. Most of it was not documentation of this binding. Deleted, because Appendix F already carries it normatively and a reader who needs it should read one copy rather than four: the scope list, the control API endpoint table and its two invariants, the canonical flag set details (the assets README has all five load-bearing properties), the capability Meaning column, the @reinitialization permitted-not- required reasoning, the @caching reservation and the declare-everything trap, the @lifecycle/@events split, the scenario-not-tag rule, the whole @standard-reasons discussion and its situation table, the two shapes a known deviation takes, and the suite-wide known gaps. Deleted as duplication of code that sits closer to the thing it explains: the added-step rationale table, which restates javadoc already on every one of the seven methods in ProviderSteps and FlagSteps. Kept, because it is Java's and is recorded nowhere else: declarable() and declarableExcept() and why they exist where EnumSet.complementOf does not; that @large-integers is refused because getIntegerDetails is a 32-bit Integer; the testcontainers provided/optional scope and the SDK version range; the compose contract in its Java spelling; the testExclusions and tck-profile mechanism with the help:evaluate check; the classpath-additive-scan reason gherkin/ and extensions/ are separate directories; TckSuiteListener discovery; the generated-asset guards; and the static TckRuntime gap. 23 KB, 17 sections. No behaviour change and no count moves. Signed-off-by: Simon Schrottner --- tools/tck/README.md | 1312 ++++++++++--------------------------------- 1 file changed, 289 insertions(+), 1023 deletions(-) diff --git a/tools/tck/README.md b/tools/tck/README.md index 8aab2317a3..2ec7576ea5 100644 --- a/tools/tck/README.md +++ b/tools/tck/README.md @@ -1,14 +1,16 @@ -# OpenFeature Provider TCK +# OpenFeature Provider TCK (Java) -A conformance test suite that any OpenFeature provider can adopt to verify it implements the -provider contract of the [OpenFeature specification](https://openfeature.dev/specification/). +A conformance suite any OpenFeature Java provider can adopt to verify that it implements the +provider contract of the [specification](https://openfeature.dev/specification/). -OpenFeature's central promise is that swapping providers does not change application behaviour. -Today nothing verifies that — every provider tests differently, so "implements the provider -contract" is an unverified claim. This is the shared suite that makes it checkable. +It is the Java implementation of [Appendix F][appendix-f], which defines the scenarios, the canonical +flag set, the control API and the capability vocabulary that every language's TCK shares. **Read it +for anything true of the suite rather than of this artifact** — what is tested and what is not, the +rules for declaring a capability, what a known deviation means, and how to run an adoption in CI. +Tracking issue: [open-feature/spec#417][tracking]. -> **Status: proof of concept.** The scenario set is a representative subset covering each -> architectural mechanism once, not exhaustive coverage. See [Known gaps](#known-gaps). +> **Status: proof of concept.** The scenario set covers each architectural mechanism once rather than +> exhaustively. Expect breaking changes. ## Installation @@ -23,366 +25,32 @@ contract" is an unverified claim. This is the shared suite that makes it checkab ``` -Requires Java 11+ and JUnit 5. A working Docker daemon is needed only for providers with an -external backend — see [Which base class to extend](#which-base-class-to-extend). +Java 11+ and JUnit 5. Docker is needed only by providers with an external backend. -### Testcontainers, for containerised adopters only +**Testcontainers is not transitive.** `ContainerizedProviderTckTest` owns a `ComposeContainer`, so this +artifact compiles against Testcontainers but declares it `provided` and `optional`. A containerised +adopter adds `org.testcontainers:testcontainers` itself — one line for the adopters that need it, and +it keeps Testcontainers off the classpath of every backend-less adopter, which would otherwise resolve +it for a class it never loads. -`ContainerizedProviderTckTest` owns a `ComposeContainer`, so this artifact compiles against -Testcontainers — but it declares the dependency `provided` and `optional`, so **it is not -transitive**. A containerised adopter adds it itself: +**The SDK is a `provided` version range**, `[1.21.0,1.99999)`, inherited from this repository's parent +POM — never a pin. A conformance suite that forces an SDK upgrade before you can run it is one nobody +runs; the TCK uses only long-stable API. -```xml - - org.testcontainers - testcontainers - 2.0.4 - test - -``` - -That is one line for the adopters that need it, and it keeps Testcontainers off the test classpath of -every backend-less adopter — in-memory, environment-variable, file-based — which would otherwise -resolve it for a class they never load. It also lets an adopter stay on the Testcontainers major its -other suites already use instead of inheriting ours. - -### OpenFeature SDK compatibility - -The TCK declares `dev.openfeature:sdk` as a **`provided` version range** (`[1.21.0,1.99999)`), -inherited from this repository's parent POM. It never pins an SDK version. - -That is deliberate. A conformance suite that forces an SDK upgrade before you can run it is a -conformance suite nobody runs. Your build keeps whatever SDK version it already resolves; the TCK -uses only long-stable API — `OpenFeatureAPI`, `Client`, typed evaluation, `ProviderEvent`, -`ProviderState`. - -## What it tests, and what it does not - -**In scope — the provider contract:** - -- mapping backend responses onto typed resolution details (value, reason, error code), with no error - message on a success path — and the variant where the backend names one, which is gated on - `@variants` because Requirement 2.2.4 is a `SHOULD` and `types.md` types the field optional -- keeping the integer and float types distinct; that `false`, `0` and `""` are values, not absences; - integer precision to 2^31 − 1 -- error handling: type mismatch and unknown flag return the code default, report the right error - code, and never throw -- lifecycle: reaching `READY`, settling into `ERROR` against an unreachable backend, and a shutdown - that can be repeated, returns promptly when the backend is gone, and is undone by initialising again -- events: `PROVIDER_READY`, `PROVIDER_ERROR`, `PROVIDER_STALE`, `PROVIDER_CONFIGURATION_CHANGED` -- that a signalled configuration change is actually applied on re-evaluation -- that the provider identifies itself by a non-empty metadata name -- that supplying an evaluation context does not disturb an untargeted resolution, and — gated on - `@targeting` — that a matching context resolves the targeted variant -- gated on `@disabled-flags`, that a flag disabled in the management system resolves to the code - default rather than to its configured value, and without an error. Gated because the answer depends - on where the substitution happens: a provider that evaluates locally holds the caller's default and - can return it, one whose backend decides never sends it and cannot - -**Out of scope — not the provider's contract:** - -- backend evaluation logic, bucketing and rule-language correctness. Every enabled flag in the - canonical set except `targeting-key-flag` resolves to its default variant whatever the context, so - what is under test is the provider's mapping of a response, not the backend's decision. That one - carries the one rule, and it is there to prove the context reached the backend rather than to test - how the backend evaluated it. The four `disabled-*` flags are the only ones whose state is not - `ENABLED`; they resolve to nothing at all. -- the provider↔backend wire protocol. How you talk to your backend is your business. -- SDK behaviour. That belongs to the SDK's own test suite. - -## Which base class to extend - -Two, and the choice is made by one question: **does your provider talk to something outside the -JVM?** - -| | Extend | Backend control | You supply | -|---|---|---|---| -| Provider has an external backend | `ContainerizedProviderTckTest` | `HttpBackendControl`, over the HTTP control API | a Compose stack, a control API, a test class | -| Provider has no backend — in-memory, environment variables, a local file | `ProviderTckTest` | an in-process `BackendControl` | a test class | - -`ContainerizedProviderTckTest` is the normal case and everything in [Adopting it](#adopting-it) -below describes it. It extends `ProviderTckTest` and adds the Compose lifecycle, port discovery and -control API client on top. - -### In-process control is for backend-less providers only - -Step definitions never touch a backend directly. They go through one interface, `BackendControl`, -which is what lets the same Gherkin run against a container over HTTP and against an in-memory -provider manipulated in the same JVM. - -That seam is not an invitation to skip the control API. **If your provider has an external backend, -use `HttpBackendControl` via `ContainerizedProviderTckTest`.** The control API described in -[`openapi/control-api.yaml`](src/main/resources/openapi/control-api.yaml) is the normative contract -for those providers, and it is the whole basis of a portable conformance claim: another language's -TCK drives the same endpoints against the same stack and must get the same answers. - -A custom in-JVM `BackendControl` that reaches an external backend through a side channel — a -test-only admin client, a shared database handle, a static hook inside the provider — bypasses that -contract. It will pass, and it will prove nothing, because the path it exercised is not the path the -contract describes. - -In-process control exists for providers that have **nothing to contract with**, where "the backend" -is a data structure in the same JVM. For those, flag operations are map updates and a configuration -change is the provider's own update mechanism emitting its own event. - -### Adopting it without a backend - -`InProcessBackendControl` implements this for the SDK's `InMemoryProvider`, seeded with the -canonical flag set. The entire adoption is three methods: - -```java -public class MyProviderTest extends ProviderTckTest { - - private final InProcessBackendControl control = new InProcessBackendControl(); - - @Override - public BackendControl backendControl() { - return control; - } +## Quick start - @Override - public FeatureProvider createProvider() { - return control.createProvider(); - } +Two base classes, and one question chooses between them: **does your provider talk to something +outside the JVM?** - @Override - public Set capabilities() { - return EnumSet.of(Capability.EVENTS, Capability.CONFIGURATION_CHANGE, Capability.OBJECT); - } -} -``` +| | Extend | Backend control | +| --- | --- | --- | +| External backend | `ContainerizedProviderTckTest` | `HttpBackendControl`, over the HTTP control API | +| No backend — in-memory, environment variables, a local file | `ProviderTckTest` | an in-process `BackendControl` | -One object backs both factory methods because in-process the flag store and the provider are the -same thing: `changeFlag()` has to reach the live provider instance to emit an event from it. - -**Connection control does not apply**, and the capability declaration is where you say so rather -than stubbing it out. An in-memory provider has no connection to lose, so -`InProcessBackendControl` leaves `disconnect()` and `reconnect()` unimplemented — -they throw. Leaving `STALE` and `UNAVAILABLE_INIT` out of `capabilities()` is what keeps that -honest: the scenarios needing them are skipped before any step can reach an unsupported operation. - -Get that pairing wrong — declare `STALE` against a control that cannot disconnect — and you get an -`UnsupportedOperationException` naming the fix, not a silent pass. That is deliberate. A -`BackendControl` may throw `UnsupportedOperationException` for operations it does not support, and -reaching one from a scenario that actually ran is a **test-configuration bug**, never a skip. - -### The TCK's own self-tests - -Three suites in this module are exactly the class above, and all three run with no Docker in well -under a second. They are the reference adoption, and they are the fast CI canary. - -[`InMemoryProviderTckTest`](src/test/java/dev/openfeature/contrib/tools/tck/InMemoryProviderTckTest.java) -runs the full applicable suite against the SDK's `InMemoryProvider` — of the 65 scenarios (outline -rows counted individually), 49 pass and 16 are skipped by capability: the six `@lifecycle` ones — one -of which also carries `@reinitialization`, and is skipped for the first of the two — the `@stale` -one, the three `@numeric-coercion` ones, the `@large-integers` one — which no Java provider can -declare, so that skip is the SDK's rather than this suite's — and the five `@targeting` ones -(three in `evaluation.feature`, two more in `reason.feature`). -It declares `VARIANTS`, because `InMemoryProvider` does name the variant it served, so the gated -variant outline runs rather than being skipped. It declares `DISABLED_FLAGS` too, on the same kind of -evidence: the provider honours a flag's state, so the four `disabled-*` flags resolve to nothing, the -caller's default stands in with no error code, and all four rows of that outline pass. It declares -`STANDARD_REASONS` on the same footing: the provider reports `STATIC` for a rule-less flag, `ERROR` -alongside `FLAG_NOT_FOUND` and `TYPE_MISMATCH`, and `DISABLED` for a disabled flag, so seven of -`reason.feature`'s nine scenarios run and pass — the two carrying `@targeting` are skipped because -that capability is withheld, which is the tag composition working as intended. It does not -declare `TARGETING`: the provider reads a flag's `variants` and `defaultVariant` and evaluates no -rules, so `targeting-key-flag`'s `targeting` member is inert and a matching context resolves `miss` -like any other. It does not declare `NUMERIC_COERCION`, because `InMemoryProvider` keeps the two -numeric types strictly apart in both -directions — it refuses `10.0` as an integer and `10` as a float exactly as it refuses `0.5` — and the -tag requires the lossless direction too. That is a choice the SDK's reference provider is entitled to, -not a defect; see the class javadoc. - -[`ControllableProviderTckTest`](src/test/java/dev/openfeature/contrib/tools/tck/ControllableProviderTckTest.java) -runs it against a provider with a **real initialisation**, and it is the only Docker-free cover the -`@lifecycle` feature has. `InMemoryProvider` cannot provide it: its constructor is handed the whole -flag set, so `initialize()` records a state and `shutdown()` releases nothing observable, and -running those scenarios against it would establish nothing — which is exactly why the suite above -withholds `LIFECYCLE`. The consequence was that shutdown, double shutdown, shutdown against a dead -backend and initialise-again had coverage only inside a containerised provider suite, where a break -in them reads as a provider defect rather than a TCK one. `ControllableProvider` acquires its flag -store at `initialize()` time from a store that may refuse it, so it declares `LIFECYCLE`, -`REINITIALIZATION` and `UNAVAILABLE_INIT` and all six `@lifecycle` scenarios run. Of the 16 the -in-memory suite skips, only 10 remain: the three `@numeric-coercion`, the five `@targeting`, the -`@large-integers` one and the `@stale` one — `@stale` because an in-JVM store can refuse an -initialisation but cannot take a connection away from a running provider and hand it back, so -`disconnect()` stays at its throwing default. That is the one capability still without Docker-free -coverage. - -The in-JVM store is not a licence for a provider that does have a backend to test itself this way; -see [In-process control is for backend-less providers -only](#in-process-control-is-for-backend-less-providers-only). `InMemoryProviderTckTest` stays the -reference adoption an adopter copies, because it is written against the published -`InProcessBackendControl` and the SDK's own provider. - -[`MultiProviderTckTest`](src/test/java/dev/openfeature/contrib/tools/tck/MultiProviderTckTest.java) -runs it against `MultiProvider` wrapping **one** `InMemoryProvider`. A provider that delegates is -still a provider, and delegation is where the contract is easiest to drop: a variant that does not -survive the hop, a reason rewritten, an error code flattened, an event that never arrives. With a -single child the correct answer is precisely what the in-memory suite already asserts, so any -difference between the two suites is attributable to `MultiProvider` and nothing else. - -That suite has already paid for itself. It does **not** declare `CONFIGURATION_CHANGE`, because -`MultiProvider` extends `EventProvider` but never subscribes to its children — a child's -`PROVIDER_CONFIGURATION_CHANGED`, `PROVIDER_ERROR` and `PROVIDER_STALE` are all swallowed. Wrapping -a provider in a multi-provider silently costs you those events, with nothing in the API to hint at -it. That is a known SDK gap, -[open-feature/java-sdk#1882](https://github.com/open-feature/java-sdk/issues/1882) (gap 1, High), -which the suite reproduced from the outside — the gap was originally found by hand-comparing -implementations against the js-sdk reference. Everything else survives delegation unchanged. - -**A self-test is allowed one thing an adoption is not: withholding a capability for a defect.** -[Appendix F](https://github.com/open-feature/spec/blob/main/specification/appendix-f-provider-conformance.md) -carves these suites out of the rule that a provider which attempts a behaviour and gets it wrong must -declare the capability and let the scenario fail. A self-test runs the scenarios against an SDK -provider as a fixture for the harness, reports on nobody, and runs in the ordinary build, where a -permanently failing scenario is a broken build rather than a finding anyone downstream can act on — -the fix is an SDK release away. The licence carries one condition: **the defect must be pinned by a -test of its own**, so the skip is not the only record of it. An adoption has no such licence: it -exists to report on a provider, and a skip there is a claim about that provider. - -Two of the three suites here do not need the carve-out. Every omission in `InMemoryProviderTckTest` -and `ControllableProviderTckTest` is a property of the provider rather than a defect: strict numeric -typing, no rule evaluation, no connection to lose, a flag set handed over by the constructor. -`MultiProviderTckTest`'s missing `CONFIGURATION_CHANGE` is the one that does use it — java-sdk#1882 -is a defect, not a design choice — and today it meets the carve-out's condition only partly: the -class javadoc names the issue and says what to delete when it is fixed, but no test in this module -asserts the swallowed event directly, so the skip is still the only executable record. That is the -open item against this section. - -Two more tests are not suites at all, because what they guard is invisible from inside a scenario. -[`InProcessBackendControlTest`](src/test/java/dev/openfeature/contrib/tools/tck/InProcessBackendControlTest.java) -calls the unsupported operations directly, so a connection operation that quietly did nothing cannot -pass as a skip. -[`HttpBackendControlTest`](src/test/java/dev/openfeature/contrib/tools/tck/HttpBackendControlTest.java) -stubs the control API with the JDK's own `com.sun.net.httpserver.HttpServer` — no Docker, nothing off -loopback — and asserts the request sequence in order: that `/reset` is preferred and `/start` is the -fallback, that an unimplemented `/reset` is probed **once per suite** and the answer cached, and that -the scenario after a `disconnect()` uses `/start` rather than `/reset`. All three are normative in -`openapi/control-api.yaml`, all three are decided in code no scenario can observe, and a control that -got any of them wrong would let scenarios run against the previous one's backend state and report the -results as conformance. - -## Adopting it - -This section describes a provider with an external backend — the common case. -Four things to implement, then two small files. - -### 1. A Docker Compose stack - -```yaml -# src/test/resources/tck/docker-compose.yaml -services: - backend: - image: your-org/your-testbed:1.0.0 - ports: - - 8080 # control API (see below) - - 5000 # whatever your provider connects to -``` - -The whole compose contract, which is the same eight concepts with the same defaults in every -language's TCK: - -| Concept | Required | Default | Java | -|---|---|---|---| -| Compose file | yes | — | `File composeFile()` — resolved relative to the Maven module directory | -| Backend service | no | `backend` | `String backendService()` — the service hosting both the control API and the backend | -| Backend ports | yes | — | `List backendPorts()` — container-internal ports the *provider* connects to. Do not list the control port; it is exposed automatically | -| Control port | no | `8080` | `int controlPort()` | -| Additional ports | no | none | `Map> additionalPorts()` — extra service → ports, resolved through the endpoint by service name | -| Backend configuration | no | `default` | `String backendConfiguration()` — the backend configuration name passed to `POST /start` | -| Startup timeout | no | 60s | `Duration startupTimeout()` — the stack and its control API becoming reachable | -| Endpoint | — | — | `BackendEndpoint` — `host()` and `port(internalPort)`, optionally qualified by service | +### A provider with a backend -`backendConfiguration()` names a configuration the **backend** understands. It is not -`configuration()`, which names the mode of the **provider** — see [Naming the configuration under -test](#naming-the-configuration-under-test). The two words were the same in three of the four -languages' first drafts, and telling them apart is the reason this one is spelled out. - -**Never pin host ports.** External ports are mapped dynamically and discovered after startup — -that is why the provider comes from a factory rather than a constant. Pinned ports make the suite -unrunnable in parallel with anything else and collide with a developer's local backend. - -The stack may contain any number of extra containers: a toxiproxy, an edge service, a sidecar. -The TCK only cares about the two conventions above. - -### 2. A control API on the backend - -Your stack must expose a small HTTP control API so the TCK can put the backend into specific -states. The full contract is in [`openapi/control-api.yaml`](src/main/resources/openapi/control-api.yaml), -packaged inside the JAR. Summary: - -| Endpoint | Status | Purpose | -|---|---|---| -| `POST /start?config={name}` | **required** | start the backend, seed flags to that config's baseline | -| `POST /stop` | **required** | make the backend unreachable | -| `POST /change` | **required** | change `changing-flag`'s resolved value | -| `POST /reset` | optional | restore baseline without an outage; falls back to `/start` | -| `POST /restart?seconds={n}` | optional | bounded outage, flag state preserved — **no shipped scenario calls it** | -| `GET /healthz` | optional | readiness; falls back to a TCP port check | - -`/restart` is optional and this suite binds no method to it. The disconnect/reconnect scenario is -written as an *unbounded* outage — `the connection is lost`, then `the connection is restored` — -which is `/stop` followed by `/start`, because a self-healing outage cannot express "assert the -provider is stale, and only then reconnect". It stays specified because a future `@caching` scenario -asserting what a stale provider serves *during* an outage needs exactly its flag-state preservation, -which `/start`-on-reconnect does not give. - -Two normative requirements are worth repeating here because getting them wrong is subtle: - -> **Never stop or restart a container to simulate an outage.** Testcontainers cannot reliably -> preserve dynamically mapped host ports across a container restart, so a restart silently -> invalidates every provider already pointed at the old port — in some language bindings, and not -> in others, which makes it a portability trap rather than a bug you would catch locally. Simulate -> outages *inside* the running stack: kill the backend process, add a proxy toxic, block the -> socket. The [flagd testbed](https://github.com/open-feature/flagd-testbed) kills and restarts the -> flagd process inside a container that keeps running — that is the reference behaviour. - -> **`/start`, `/change` and `/reset` must not return until the new state is being served.** That -> promise is about the *backend*: a fresh evaluation against it must already resolve the new value -> when the call returns. How long the provider under test takes to notice is a property of its -> transport and is what `eventTimeout()` bounds. Confusing the two makes the provider's detection -> latency unmeasurable, because the clock starts before there is anything to detect — and it is why -> nothing in this suite sleeps after a control call. - -### 3. The canonical flag set - -Seed your backend with the flags in [`flags/canonical-flags.json`](src/main/resources/flags/canonical-flags.json). -It is expressed in the flagd flag-definition format because that is the only widely implemented -vendor-neutral format today — the format is not what matters, the keys, types, variants and -resolved values are. Seed them however your backend seeds flags. - -Four details are load-bearing: - -- **`missing-flag` must not exist.** Its absence is what the `FLAG_NOT_FOUND` scenario tests. -- **Only `targeting-key-flag` has a targeting rule.** Every other flag resolves to its default - variant whatever the evaluation context, which is what lets a provider declaring - `STANDARD_REASONS` expect `STATIC` rather than `TARGETING_MATCH` for them; seeding targeting onto - any other flag breaks them. Its rule is specified by behaviour — - resolve `hit` when the targeting key is exactly `5c3d8535-f81a-4478-a6d3-afaa4d51199e`, `miss` - otherwise — so express it however your backend expresses targeting. The flag, its variants and the - uuid are flagd-testbed's own, so a backend serving that harness already serves this one. A backend - that cannot carry a rule leaves `TARGETING` undeclared and the three scenarios are skipped. -- **`boolean-zero-flag`, `integer-zero-flag` and `string-zero-flag` resolve to `false`, `0` and - `""` on purpose.** A seeding step that treats them as unset and drops them turns the falsy-value - scenarios into `FLAG_NOT_FOUND` failures that look like provider defects. These names, and their - `zero`/`non-zero` variants, are the ones Appendix B's SDK suite already uses, so a backend that - serves that flag set already serves these. -- **`integral-float-flag` is a float and `huge-integer-flag` is an integer.** Seeding `10.0` as `10` - makes the lossless-coercion scenario pass without coercing anything; seeding `9007199254740991` - through a float rounds it. - -Read the file rather than retyping it. `$comment` members are documentation and may be ignored -wherever they appear; everything else is the contract. This is what -[`InProcessBackendControl`](src/main/java/dev/openfeature/contrib/tools/tck/InProcessBackendControl.java) -does — it decodes the packaged copy through -[`CanonicalFlags`](src/main/java/dev/openfeature/contrib/tools/tck/CanonicalFlags.java) -rather than restating the set in Java, because a second copy inside the TCK drifts from the spec the -same way an adopter's would, and when it does the in-memory self-tests go green against the wrong -baseline. - -### 4. The test class +Write a Compose file at `src/test/resources/tck/docker-compose.yaml` exposing your backend and its +[control API][control-api], then one test class: ```java public class MyProviderTest extends ContainerizedProviderTckTest { @@ -410,192 +78,141 @@ public class MyProviderTest extends ContainerizedProviderTckTest { ``` That is the whole adoption — one file, no registration. The class is simultaneously the JUnit suite -and the harness, and the TCK works out which suite is running from the JUnit test plan. The Compose -lifecycle, port discovery, control API calls, provider registration, event awaiting and teardown all -belong to the TCK. **If you find yourself adding test infrastructure to this class, that is a bug in -the TCK — please open an issue rather than working around it.** +and the harness; the Compose lifecycle, port discovery, control API calls, provider registration, +event awaiting and teardown belong to the TCK, and the three artifacts are packaged in the JAR, so an +adoption needs no submodule. **If you find yourself adding test infrastructure to this class, that is +a defect here — please open an issue.** -`createUnavailableProvider()` should point at a closed port on localhost, not at your stack — the -stack must stay up, and simulated outages belong to the control API. Give it a short connection -deadline; the scenario allows a bounded time for the error event and a 30-second connect timeout -will not make it. +`createUnavailableProvider()` should point at a closed port on localhost, not at your stack: the stack +stays up and outages are simulated through the control API. Give it a short connection deadline, +because the failure scenarios assert that failure is reported *promptly*. -#### Several provider modes +**Seed your backend with the [canonical flag set][flags]** — several of its properties are +load-bearing and easy to break while seeding, so read the [assets README][assets] rather than retyping +the file. -A provider with more than one transport writes **one class per mode and nothing else** — no -registration, no system property, no build configuration. Each class is its own suite, each gets its -own Compose stack, and they can share a base class: +### A provider with no backend + +`InProcessBackendControl` implements the in-process path for the SDK's `InMemoryProvider`, seeded from +the packaged flag set. The adoption is three methods: ```java -abstract class AbstractMyProviderTest extends ContainerizedProviderTckTest { - protected abstract Mode mode(); - // composeFile(), createProvider(), capabilities() ... shared here -} +public class MyProviderTest extends ProviderTckTest { -public class RemoteTest extends AbstractMyProviderTest { - @Override protected Mode mode() { return Mode.REMOTE; } -} + private final InProcessBackendControl control = new InProcessBackendControl(); + + @Override + public BackendControl backendControl() { + return control; + } + + @Override + public FeatureProvider createProvider() { + return control.createProvider(); + } -public class InProcessTest extends AbstractMyProviderTest { - @Override protected Mode mode() { return Mode.IN_PROCESS; } + @Override + public Set capabilities() { + return EnumSet.of(Capability.EVENTS, Capability.CONFIGURATION_CHANGE, Capability.OBJECT); + } } ``` -This is how the flagd provider covers RPC and in-process. Abstract classes are not run, so an -intermediate base is safe. +One object backs both methods because in-process the flag store and the provider are the same thing: +`changeFlag()` has to reach the live provider instance to emit an event from it. -Note that per-mode differences may include timing, not just wiring: flagd's in-process resolver -syncs the whole ruleset before reporting ready, so it needs a longer initialisation deadline than -its RPC mode. Give a connecting provider a generous deadline and an intentionally unreachable one a -short deadline — the failure scenarios assert that failure is reported *promptly*. +This path is a narrow allowance for providers with **nothing to contract with**, not an invitation to +skip the control API — see [Appendix F, "Providers with no backend"][appendix-f]. It leaves +`disconnect()` and `reconnect()` throwing, and withholding `STALE` and `UNAVAILABLE_INIT` is what +keeps that honest: declare one anyway and the scenario fails with an `UnsupportedOperationException` +naming the fix, because reaching an unsupported operation from a scenario that actually ran is a +test-configuration bug, never a skip. -

            -Fallback: ServiceLoader registration +### Several provider modes -Suite discovery relies on the JUnit Platform auto-registering `TckSuiteListener` (declared in this -JAR's `META-INF/services/org.junit.platform.launcher.TestExecutionListener`), which Surefire, Gradle -and IDEs all do by default. If your launcher disables listener auto-registration, register the -harness explicitly instead at -`src/test/resources/META-INF/services/dev.openfeature.contrib.tools.tck.ProviderTckHarness`, -and if you register more than one, select between them with -`-Dopenfeature.tck.harness=RemoteTest`. +A provider with more than one transport writes **one class per mode and nothing else** — no +registration, no system property, no build configuration. Each class is its own suite with its own +Compose stack, and abstract classes are not run, so an intermediate base is safe. Per-mode differences +may include timing as well as wiring: flagd's in-process resolver syncs the whole ruleset before +reporting ready, so it needs a longer initialisation deadline than its RPC mode. -
            +## The options -## Adding your own scenarios +Eight Compose concepts, with the same names and defaults in every language's TCK, spelled here as +overridable methods on `ContainerizedProviderTckTest`. -A provider with features of its own — flagd's `fractional` targeting, a vendor's proprietary -evaluation mode — extends the suite rather than maintaining a second one. Two files, no annotations: +| Concept | Required | Default | Java | +| --- | --- | --- | --- | +| Compose file | yes | — | `File composeFile()` — resolved relative to the Maven module directory | +| Backend service | no | `backend` | `String backendService()` — the service hosting both the control API and the backend | +| Backend ports | yes | — | `List backendPorts()` — container-internal ports the *provider* connects to. Do not list the control port; it is exposed automatically | +| Control port | no | `8080` | `int controlPort()` | +| Additional ports | no | none | `Map> additionalPorts()` — extra service → ports, resolved through the endpoint by service name | +| Backend configuration | no | `default` | `String backendConfiguration()` — the name passed to `POST /start` | +| Startup timeout | no | 60s | `Duration startupTimeout()` — the stack and its control API becoming reachable | +| Endpoint | — | — | `BackendEndpoint` — `host()` and `port(internalPort)`, optionally qualified by service | -``` -src/test/resources/extensions/fractional.feature -src/test/java/openfeature/tck/extensions/FractionalSteps.java // package openfeature.tck.extensions -``` +**Never pin host ports.** They are mapped dynamically and discovered after startup, which is why the +provider comes from a factory rather than a constant. The stack may hold any number of extra +containers; the TCK only cares about the conventions above. -That is the whole extension point. Both are already selected by `ProviderTckTest`, so your scenarios -run **inside** the suite: same backend lifecycle, same `@BeforeAll`, same `BackendControl`. Step -classes may take `TckState` as a constructor argument exactly as the canonical steps do, and reach -the backend control and the backend endpoint through `TckRuntime.get()`. Canonical steps are on the -glue path too, so an extension scenario can open with `Given a stable provider` and go on to whatever -is specific to your provider. - -The alternative — your own Cucumber runner — is a second backend lifecycle to start and a second copy -of this suite's configuration to keep in step with it. - -**Why `extensions/` and not `gherkin/`.** Two classpath roots holding the same directory are -scanned additively; two holding the same directory *and* the same file name are not — one wins -silently and the other file is never read. A `gherkin/errors.feature` in your test resources would -therefore *replace* the canonical file, and the suite would report success having run yours. -`gherkin/` and `extensions/` being two distinct directories means that collision cannot be reached -by accident. `gherkin/` is the canonical set and belongs to the specification; extensions are yours. -If a scenario is portable across providers, send it to the TCK rather than keeping it as an -extension. - -Both names are Appendix F's. It identifies a canonical feature by its path relative to the spec's -asset directory — `gherkin/errors.feature` — and reserves the prefix `extensions/` for an adopter's -own, so the URIs a run reports (`classpath:gherkin/errors.feature`, -`classpath:extensions/fractional.feature`) partition the same way here as in every other language's -TCK. Comparison is on the path after the URI scheme; the `classpath:` prefix is this runner's and is -not part of the identity. - -The directory is shipped in this JAR containing only a README, because a classpath resource selector -naming a resource that exists on no classpath root is a hard discovery error rather than an empty -selection. An adopter who extends nothing therefore still resolves it, and pays nothing for the glue -package either — Cucumber tolerates a glue package that does not exist. - -### The suite's configuration as constants - -`ProviderTck` names every value the suite's annotations carry, so that an adopter who does write a -`@ConfigurationParameter` composes rather than copies: +`backendConfiguration()` names a configuration the **backend** understands; `configuration()` names the +mode of the **provider**. The bare word `configuration` meant opposite things in three of the four +languages' first drafts, which is why these two are spelled apart. -```java -@ConfigurationParameter(key = Constants.GLUE_PROPERTY_NAME, value = ProviderTck.ALL_GLUE + ",com.vendor.steps") -``` +### Timeouts + +| Method | Default | What it bounds | +| --- | --- | --- | +| `eventTimeout()` | 12s | waiting for a provider event | +| `readyTimeout()` | 30s | waiting for a provider to reach a lifecycle state | +| `startupTimeout()` | 60s | the Compose stack and its control API becoming reachable | + +Set `eventTimeout()` to comfortably exceed your worst-case detection latency, or the suite reports +timeouts that are really impatience; scenarios asserting promptness as part of their point use the +explicit `within {int}ms` step, which always wins. Every entry is a **bound on an await** and none is +a pause — nothing sleeps after a control call, for the reason Appendix F's control-API invariants give. + +### Identifying the run -| Constant | Value | -|---|---| -| `ProviderTck.FEATURES` | `gherkin` — the canonical set, reserved | -| `ProviderTck.EXTENSIONS` | `extensions` — where yours go | -| `ProviderTck.GLUE` | the canonical step definitions package | -| `ProviderTck.EXTENSION_GLUE` | `openfeature.tck.extensions` | -| `ProviderTck.ALL_GLUE` | both, comma-separated — what the suite runs with | -| `ProviderTck.PLUGINS`, `PARALLEL_EXECUTION_ENABLED`, `FEATURE_EXECUTION_MODE`, `OBJECT_FACTORY` | the rest of the Cucumber configuration | +`configuration()` names which of the provider's *modes* this suite exercised — flagd's RPC and +in-process resolvers run two suites whose results are not interchangeable. It defaults to the suite +class name, hyphenated with the JUnit suffix dropped. **Check it if your suite lives in a package that +already names the provider**, which is what the layout below recommends: `InProcessTest` in +`...providers/flagd/tck/` derives `in-process`, which says nothing about whose in-process mode it was +to a reader away from this repository. flagd's two suites state `flagd-rpc` and `flagd-in-process` +outright. -An annotation value has to be a compile-time constant, so a method call would not compile there; -constant concatenation does. If you add a glue package this way, keep `ProviderTck.GLUE` in the -value — dropping it makes every canonical step undefined. +`BackendControl.controlApi()` says which contract a run was conducted under, `ControlApi.HTTP` or +`ControlApi.IN_PROCESS`. It is abstract and the enum closed, because Appendix F requires the control +to *state* the path rather than have the harness infer it from a concrete type. ## Declaring capabilities -Not every provider implements every optional part of the spec. Scenarios that exercise an optional -capability carry a tag; declare which ones you support and the rest are reported as **skipped**, -with the reason printed. They are never silently passed — a conformance suite that quietly goes -green on scenarios it did not run is worse than no suite at all. - -| Capability | Tag | Meaning | -|---|---|---| -| `LIFECYCLE` | `@lifecycle` | performs an initialisation that reaches its backend, with an observable outcome | -| `REINITIALIZATION` | `@reinitialization` | can be initialised again after `shutdown` — [Requirement 2.5.2](https://github.com/open-feature/spec/blob/main/specification/sections/02-providers.md) *permits* this rather than requiring it | -| `EVENTS` | `@events` | emits lifecycle events at all | -| `STALE` | `@stale` | enters `STALE` and emits `PROVIDER_STALE` on backend loss — *needs connection control* | -| `CONFIGURATION_CHANGE` | `@configuration-change` | detects config changes, emits `PROVIDER_CONFIGURATION_CHANGED` | -| `OBJECT` | `@object` | supports structured flag values | -| `VARIANTS` | `@variants` | names the variant it resolved — [Requirement 2.2.4](https://github.com/open-feature/spec/blob/main/specification/sections/02-providers.md) is a `SHOULD` and `types.md` types the field optional, so a backend with no variant concept withholds it | -| `DISABLED_FLAGS` | `@disabled-flags` | resolves a flag disabled in the management system to the code default — *needs the substitution to happen where the caller's default is, so a provider whose backend decides cannot hold it* | -| `UNAVAILABLE_INIT` | `@unavailable` | reports an error state instead of hanging on a dead backend — *needs connection control* | -| `NUMERIC_COERCION` | `@numeric-coercion` | coerces between integer and float only when lossless, else `TYPE_MISMATCH` — both directions tested | -| `LARGE_INTEGERS` | `@large-integers` | resolves integers up to 2^53 − 1 exactly — **not declarable in Java**, because `Client.getIntegerDetails` is a 32-bit `Integer` and no Java provider can be asked the question | -| `TARGETING` | `@targeting` | resolves `targeting-key-flag` differently for a matching evaluation context — *needs a backend that evaluates rules* | -| `STANDARD_REASONS` | `@standard-reasons` | reports the standard resolution reasons, with the meanings [Appendix F](https://github.com/open-feature/spec/blob/main/specification/appendix-f-provider-conformance.md) gives them — a claim, not an exemption | -| `CACHING` | `@caching` | reserved, **not declarable** — no scenarios yet | - -The default is every *declarable* capability. **Narrow it, do not widen it**: start from the -default, run the suite, and remove only what your provider genuinely cannot do. - -**Once your provider is attempting a capability, the unit of that decision is the scenario, not the -tag.** -[Appendix F](https://github.com/open-feature/spec/blob/main/specification/appendix-f-provider-conformance.md) -states it as: declare a capability when at least one scenario gating it can actually be put to your -provider, and withhold it only when none can. `@numeric-coercion` has three scenarios, so a backend -that cannot serve the flag one of them asks for still has two answers to give, and withholding the tag -hides both of them to avoid one failure. - -**The opening clause is a condition, not throat-clearing.** That rule decides whether the question can -be *asked*; whether your provider owes an answer is the earlier question, and [Saying that a gap is a -defect](#saying-that-a-gap-is-a-defect) is where that one is settled. Where the specification permits -declining — `@numeric-coercion` rests on no requirement, so a provider may simply not coerce — -withholding is the honest report however askable the scenarios are, and applying the scenario rule -there manufactures a failure out of a permitted choice. The self-tests in this module withhold that -tag on exactly those grounds, and are right to. - -Two consequences follow from the rule itself, and they go wrong in opposite directions: - -- **A scenario that fails because the backend cannot serve its fixture is not a provider defect.** Say - so in the deviation's `summary` beside it, or the report accuses your provider of the stack's gap. -- **A capability withheld for a backend gap is temporary**, in a way one withheld by choice is not. - Say why it is withheld and what would change the answer, or it outlives its reason and no later - reader can tell that it was meant to be revisited. - -Neither applies to `LARGE_INTEGERS`, which is refused here for a reason upstream of any backend — see -below. - -Two rows above are not yours to decide and are refused if you name them, with different messages -because they are different facts: - -- **`CACHING` is reserved.** No scenario in any language carries the tag yet. The reservation - expires the moment the specification writes them, and then it becomes an ordinary capability — - `TARGETING` was reserved until `targeting-key-flag` arrived. -- **`LARGE_INTEGERS` is inexpressible in Java.** The scenario exists, and Go and JavaScript run it - and pass. What is missing is a way to ask for 2^53 − 1 through `Client.getIntegerDetails`, and - that lasts until the SDK grows a wider accessor. Its scenario is skipped with a reason that names - the SDK, so a reader of the report can tell *"this provider declined"* from *"no Java provider can - be asked"* — only the first says anything about the provider. - -`STALE` and `UNAVAILABLE_INIT` are the two that need a backend the provider can be cut off from. -They are what a backend-less provider leaves undeclared — see -[In-process control is for backend-less providers only](#in-process-control-is-for-backend-less-providers-only). -Declaring one against a `BackendControl` that cannot simulate an outage fails the scenario with an -`UnsupportedOperationException` naming the fix, rather than passing it. +Each scenario exercising an optional part of the contract carries a tag, and a provider declares what +it supports. Undeclared ones are reported as **skipped with the reason** — never as passed. Each name +below is a member of `Capability`. + +| Capability | Tag | | Capability | Tag | +| --- | --- | --- | --- | --- | +| `LIFECYCLE` | `@lifecycle` | | `UNAVAILABLE_INIT` | `@unavailable` | +| `REINITIALIZATION` | `@reinitialization` | | `NUMERIC_COERCION` | `@numeric-coercion` | +| `EVENTS` | `@events` | | `TARGETING` | `@targeting` | +| `STALE` | `@stale` | | `STANDARD_REASONS` | `@standard-reasons` | +| `CONFIGURATION_CHANGE` | `@configuration-change` | | `LARGE_INTEGERS` | `@large-integers` ¹ | +| `OBJECT` | `@object` | | `CACHING` | `@caching` ² | +| `VARIANTS` | `@variants` | | | | +| `DISABLED_FLAGS` | `@disabled-flags` | | | | + +¹ not declarable in Java    ² reserved, not declarable + +What each tag means, and — more importantly — **when to declare one and when to withhold it** are +[Appendix F's][appendix-f], under "Capabilities" and "Rules for declaring". The decision is per +*scenario* rather than per tag, and three of the four implementations got that wrong in three +different directions, so it is worth the read. + +The default is every *declarable* capability. **Narrow it, do not widen it**: start from the default, +run the suite, and remove only what your provider genuinely cannot do. ```java @Override @@ -604,203 +221,28 @@ public Set capabilities() { } ``` -A reserved entry is part of the vocabulary so that every language's TCK spells the same property the -same way, but no scenario carries its tag — so declaring it cannot produce a skip, cannot be -contradicted by any result, and tells a reader a capability was verified when nothing examined it. -Declaring one **fails the run**, with a message naming the tag. `CACHING` is the only reserved entry -left: `TARGETING` was reserved until `targeting-key-flag`'s three scenarios arrived, and is an -ordinary declarable capability now. - -The other direction fails the run too, and it is the one an adopter will meet first. A reservation -expires the day the specification writes the scenarios it was held open for, and if this package has -not followed, the two rules meet in the worst possible place: the new scenario is skipped for a -capability nobody is permitted to declare — a question put and silently withdrawn, which -[Appendix F](https://github.com/open-feature/spec/blob/main/specification/appendix-f-provider-conformance.md) -calls the unclaimable capability. The report is well-formed and the run is -green, so nothing else would notice. So a scenario carrying a reserved tag **fails**, naming the tag -and saying that the reserved flag on that constant is now the only thing to change. The check reads -Cucumber's parsed tags rather than the feature files as text, which matters more than it sounds: -`gherkin/events.feature` names `@caching` inside a `#` comment explaining what is deliberately not -covered yet, so a text scan would fail every adoption on the day it shipped. - -There is a third direction, and it is this module's own build that has to catch it: a capability the -suite says has scenarios that **gates nothing**. Declarable, that is the same vacuous claim as a -declared reserved tag — nothing can produce a skip, no result can contradict it, and a report tells -its reader a capability was examined when nothing examined it. Its realistic cause is a build -accident rather than a design mistake: the canonical assets are copied out of the `spec` submodule, -and the submodule's gitlink and its working tree move by different commands, so a rebase followed by -a build can package the previous pin's assets. The result is internally consistent — the old feature -files agree with each other, and with the old flag set — so **counting scenarios does not catch it**. -`CanonicalTagCoverageTest` asserts that every capability this suite does not call reserved is carried -by at least one canonical scenario, and that no reserved one is; it parses the packaged Gherkin for -the same reason the runtime check does. It is over every unreserved capability rather than every -declarable one on purpose, because `@large-integers` having scenarios is exactly what distinguishes -it from a reservation. - -That catches one symptom of a stale checkout. [Stale assets](#stale-assets) is how the build catches -the rest. - -That is a rule about an accident rather than about intent: `EnumSet.complementOf(EnumSet.of(X))` -reads as "everything except X" and in fact means "every other enum constant", reserved tags -included. The flagd suite said exactly that and published `"declared": [..., "@targeting", -"@caching"]` for two capabilities nobody had claimed — back when both were reserved. -`Capability.declarable()` and `Capability.declarableExcept(...)` are the forms that mean what the -first one looks like, and they still exclude `@caching`. - -A note on `LIFECYCLE` vs `EVENTS`: they look like the same thing and are not. `EVENTS` says the -provider emits events; `LIFECYCLE` says there is a real initialisation behind them. The SDK's -`FeatureProviderStateManager` emits `PROVIDER_READY`/`PROVIDER_ERROR` around `initialize` for *any* -provider, `EventProvider` or not — so a provider that does no initialisation of its own reaches -`READY` exactly as `NoOpProvider` would, and gating the readiness scenario on `EVENTS` would pass it -vacuously. Conversely a stateless provider such as OFREP genuinely initialises against a backend -while emitting no events of its own, and would have been excluded. Declare `LIFECYCLE` only if -initialisation actually talks to the backend; a provider with nothing to reach — an in-memory -provider, or a facade over other providers — should not declare it however many events it emits. - -A note on `REINITIALIZATION`, which is separate from `LIFECYCLE` for a different reason and is worth -reading before you withhold anything else. -[Requirement 2.5.2](https://github.com/open-feature/spec/blob/main/specification/sections/02-providers.md) -says a provider **SHOULD** revert to its uninitialized state after `shutdown`, and its supporting -text adds that *"some providers **may** allow reinitialization from this state"*. Reuse is -**permitted, not required**: a provider that releases its client on shutdown and refuses to be -started again is exercising a choice the specification offers it, so withholding the tag needs no -`KnownDeviation`. The scenario it gates was originally untagged, and therefore mandatory, on the -reading that reverting to the uninitialized state is observable as exactly one thing — being -initialisable again. That inference does not hold, and it cost something: run against the flagd -provider, which keeps `isInitialized` and `isShutDown` as separate flags and refuses `initialize()` -when either is set, the scenario failed and was one step from being filed as a defect against a -provider doing nothing wrong. **A false failure is the mirror image of a vacuous pass.** The tag -still earns its keep in the other direction, for the providers that do offer reuse: releasing the -client on shutdown while leaving an initialised flag set is easy to write, and it leaves the provider -evaluating against a closed connection rather than failing outright. - -The general rule behind that, which is worth more than the tag: **never withhold a capability, or -record a deviation, because a scenario failed — first find the numbered requirement and check -whether the specification asks for that behaviour at all.** Three rules in this suite have now been -found asserted more strongly than the spec states them. - -A note on `NUMERIC_COERCION`: the rule it tests is **borrowed, not normative**. Coercion between -integer and float is permitted **when it is lossless** and must fail with `TYPE_MISMATCH` **when it -is not** — `10.0` requested as an integer must succeed, `10` requested as a float must succeed, and -`0.5` requested as an integer must not. All three have scenarios and a provider declaring the tag -must satisfy all three; rejecting every float passes the lossy one and fails the other two. The rule -comes from flagd's [numeric coercion -ADR](https://github.com/open-feature/flagd/blob/main/docs/architecture-decisions/numeric-coercion.md); -the specification has a single numeric type and says nothing about a value that does not fit the -accessor it was asked through ([spec#430](https://github.com/open-feature/spec/issues/430)), so a -provider that behaves differently is not violating it. It is still worth saying which kind of -difference it is: narrowing `0.5` to `0` with no error code hands an application a plausible value and -no signal, which is a defect, whereas keeping the two types strictly apart — what `InMemoryProvider` -does — is a choice. - -**The two are reported differently, and that is the point of the distinction.** A provider that -narrows **declares** the tag, lets the lossy scenario fail, and records a `KnownDeviation` beside the -failure — it does attempt the coercion and gets one direction wrong, which is exactly what a skip -cannot express. A provider that cannot attempt it at all — a single numeric type, or strict typing in -both directions — **withholds** the tag, and needs no deviation, because there is no distinction -there to get wrong. Do not read the defect half as a reason to withhold: withholding a capability *in -order to* turn a failure into a skip is the one use [Saying that a gap is a -defect](#saying-that-a-gap-is-a-defect) rules out. The flagd adoption in this repository is the first -kind — it declares `@numeric-coercion` in both modes and carries a tracked deviation for the -narrowing, see [flagd#1996](https://github.com/open-feature/flagd/issues/1996) — and the OFREP -adoption is the second. - -A note on `LARGE_INTEGERS`, which **you do not have to know anything about**: accessor width is a -property of the SDK rather than of the provider, and Java's is 32 bits — `Client.getIntegerDetails` -takes and returns an `Integer`, which has no room for 2^53 − 1. So no Java provider can be asked the -question, now or ever, until the SDK grows a wider accessor. - -That used to be documentation, and every Java adopter was expected to act on it by naming the -capability in `declarableExcept(...)`. It is refused here instead: - -```java -// Capability.declarable() does not contain it, and this fails with a message naming the accessor -return EnumSet.of(Capability.EVENTS, Capability.LARGE_INTEGERS); -``` - -Two suites in this module and both adoptions in this repository each withheld it by hand, each with -its own comment restating the paragraph above. That is a fact about Java remembered in four places, -and one of them being wrong would put a claim in a report that no scenario could have verified — -which is exactly what the reserved-capability rules exist to prevent, reached by another route. -[Appendix F](https://github.com/open-feature/spec/blob/main/specification/appendix-f-provider-conformance.md) -states the rule: *a capability the language's SDK cannot express is refused by the implementation, -not left to adopters.* - -**It is not a reservation, and the two must not be read as the same thing.** `@caching` has no -scenarios anywhere and expires when the specification writes some; `@large-integers` has scenarios -that run and pass in Go and JavaScript. So its scenario is skipped with a reason that names the SDK -and says the provider had no say, rather than the ordinary *"provider does not declare"*. Neither -needs a `KnownDeviation` — neither is a defect. The 32-bit precision scenario -(`large-integer-flag`, 2^31 − 1) is untagged and always runs. - -**It is not a backend gap either**, which is the third thing it could be mistaken for now that the -declaring rules distinguish them. A capability withheld because the backend serves no flag for a -scenario is temporary, needs a note saying what would change the answer, and is revisited when the -backend gains the fixture — Appendix F illustrates that rule with this very tag, because the -reference backend serves no flag for its one scenario. Here the question never reaches a backend: no -`Integer` can carry 2^53 − 1 however the stack is provisioned, so no fixture arriving anywhere would -change the answer and no Java suite reaches the declaring rules for this tag at all. Only a wider SDK -accessor would. - -A note on `STANDARD_REASONS`, which is **a claim rather than an exemption** and is the one capability -whose absence costs a provider nothing. -[Requirement 2.2.5](https://github.com/open-feature/spec/blob/main/specification/sections/02-providers.md) -is a `SHOULD` that goes further than the others: it lets a provider populate `reason` with one of the -listed values *"or some other string indicating the semantic reason for the returned flag value"*. A -provider whose backend reports vendor-specific reasons is therefore conformant, and asserting an exact -reason against it would fail it for something the specification permits. An earlier revision of this -suite did exactly that, in thirteen places across three feature files, and it bought very little — -every canonical flag resolves to a value distinct from the caller's default, so a provider that -silently falls back is already caught by the value assertion. - -So the reasons live in `gherkin/reason.feature`, gated as a whole. Declaring the tag is a provider -saying *"I use the standard vocabulary with the standard meanings"*, and that file is what checks the -claim. A provider that does not declare it loses nothing: its values, variants and error codes are -asserted everywhere else, on `MUST` requirements. What the declaration adds is something a report's -reader can act on — anyone building telemetry, dashboards or debugging on `reason` can see that the -vocabulary was verified rather than assumed. Withholding it needs no `KnownDeviation`. - -| Situation | Reason | -|---|---| -| The flag was resolved from configuration and carries no targeting rule | `STATIC` | -| A targeting rule matched the evaluation context | `TARGETING_MATCH` | -| A targeting rule exists and did not match | `DEFAULT` | -| The flag is disabled in the management system | `DISABLED` | -| The evaluation failed, and an error code is reported with it | `ERROR` | - -`STATIC` for the first row is the call worth flagging. -[`types.md`](https://github.com/open-feature/spec/blob/main/specification/types.md) types `DEFAULT` as -*"no dynamic evaluation occurred **or** dynamic evaluation yielded no result"*, which a rule-less flag -satisfies as readily as `STATIC` does — two providers can disagree here and both conform. A provider -that answers `DEFAULT` for a rule-less flag is not defective; it does not use the standard meanings, -and should not declare the tag. - -**Tags compose, and here that is load-bearing.** `TARGETING_MATCH` cannot be observed without -targeting and `DISABLED` cannot be observed unless the backend distinguishes a disabled flag, so -those two scenarios carry `@targeting` and `@disabled-flags` as well. A provider declaring -`STANDARD_REASONS` alone runs the other six rows and skips those two with their reason. - -### Saying that a gap is a defect - -A `knownDeviations` entry says one thing: **this provider fails to do something it is required to -do.** The requirement has to be a numbered `MUST`, or a rule the implementation bound itself to -elsewhere — flagd's numeric-coercion ADR, say. Where the specification *permits* the choice, -withholding the capability **is** the honest report, and a deviation entry would assert a defect -that does not exist. - -It is legitimate in two shapes, and a run's results already tell them apart: - -1. **The capability is declared, the scenario runs, and it fails.** *Prefer this.* The failure stays - visible and the deviation says it is known and why, so a reader sees both the assertion that - broke and your account of it. -2. **The capability is withheld, and its scenarios skip.** Legitimate only when the provider cannot - attempt the behaviour at all — there is no connection to lose, no structured value to return — so - running the scenario would establish nothing. The deviation explains the absence, so a reader can - tell a defect from a design decision. - -Withdrawing a capability *in order to* turn a failing scenario into a skip is the failure mode this -field exists to prevent. If the provider attempts the behaviour and gets it wrong, declare the -capability, let the scenario fail, and record the deviation beside the failure. +**Use `Capability.declarable()` and `declarableExcept(...)`, not `EnumSet.complementOf(...)`.** +`complementOf(EnumSet.of(X))` reads as "everything except X" and in fact means "every other enum +constant", reserved and inexpressible ones included. The flagd suite said exactly that and published +`"declared": [..., "@targeting", "@caching"]` for two capabilities nobody had claimed. The two +factories mean what the first one looks like, and naming a refused capability directly is an error +rather than a quiet correction, so you cannot get this wrong silently either. + +**`LARGE_INTEGERS` is inexpressible in Java, and you do not have to know anything about it.** +`Client.getIntegerDetails` takes and returns a 32-bit `Integer`, which has no room for 2^53 − 1, so no +Java provider can be asked the question until the SDK grows a wider accessor. Appendix F's rule is +that such a capability is refused by the implementation rather than left to every adopter to remember +— this module had the same paragraph restated in four places before it was. Its scenario is skipped +with a reason naming the **SDK**, so a report's reader can tell *"this provider declined"* from *"no +Java provider can be asked"*; the 32-bit precision scenario is untagged and always runs. A reserved +capability is a different thing and its reason says so. Neither refusal is a defect, and neither needs +a `KnownDeviation`. + +### Known deviations + +**A `knownDeviations` entry says: this provider fails to do something it is required to do.** The +requirement must be a numbered `MUST`, or a rule the implementation bound itself to elsewhere. Where +the specification *permits* the choice, withholding the capability **is** the honest report. ```java @Override @@ -812,349 +254,173 @@ public List knownDeviations() { } ``` -`summary` is **required**: an entry with no summary records that something is wrong without saying -what, which is worth less than the bare skip or failure it accompanies. `issue` is **optional** — -use `KnownDeviation.untracked(...)` when there is nothing to point at yet. That is still worth -declaring, because naming the defect is what separates it from a choice, but an issue link is -better. The capability may be `null`, when the gap is against a mandatory, ungated scenario. It may -not be either of the two whose scenarios were never put to your provider, and both are refused where -you write them, with different messages: a -[reserved](#declaring-capabilities) one, since no scenario carries the tag in any language, and an -[inexpressible](#declaring-capabilities) one, since the scenarios exist and this SDK cannot ask them. -Neither leaves anything to deviate from, and a deviation reads as an admission of fault — here it -would be a fault nobody committed and nobody could fix. Empty is the default, and it is silence -rather than a claim of having none. - -### Naming the configuration under test - -`configuration()` is the provider's *configuration*, not its identity: which of its modes this suite -exercised. A provider with two materially different modes — flagd's RPC and in-process resolvers — -runs two suites whose results are not interchangeable, and the name is what keeps them apart. - -It defaults to the suite class name, hyphenated and with the JUnit suffix dropped, so -`MyProviderInProcessTest` becomes `my-provider-in-process`. Override it when that does not read -well — and **check it if your suite lives in a package that already names the provider**, which is -the layout below recommends. A suite called `InProcessTest` in `...providers/flagd/tck/` derives -`in-process`, which says nothing about whose in-process mode it was to anyone reading the report -away from this repository. flagd's two suites therefore state `flagd-rpc` and `flagd-in-process` -outright. The derivation reads a class name; a report is read by someone who has neither. - -### How the backend was driven - -`BackendControl.controlApi()` says which of the two contracts a run was conducted under: -`ControlApi.HTTP`, the normative control API, or `ControlApi.IN_PROCESS`, the narrow allowance for a -provider with no backend at all. They serialise as `http` and `in-process`. A claim of `in-process` -for a provider that does have a backend should be treated with suspicion — see [In-process control is -for backend-less providers only](#in-process-control-is-for-backend-less-providers-only). - -It is **required and has no default**, which is a deliberate choice and not an oversight: - -- The two runs it distinguishes are not the same claim. The same scenarios passing over the control - API and passing through in-process manipulation of a provider that *does* have a backend prove - different things, and this is the only field that separates them. An unanswered value is therefore - not "no claim made" — it is an unfalsifiable one. -- It cannot be inferred from the control's concrete type. `HttpBackendControl` and - `InProcessBackendControl` answer it themselves, and an adopter with a real backend writes no - control at all. The only person who implements this interface by hand is the one writing a custom - control — precisely the case where nothing downstream can guess. -- A `String` would be wider than the report schema's enum, so an implementor could return `"HTTP"` - and produce a document that fails validation with no local error. `ControlApi` is closed for that - reason. - -## Tuning timeouts - -How fast a provider notices a backend change differs by orders of magnitude between transports: a -streaming provider sees a configuration change in milliseconds, a provider polling every 30 seconds -needs most of a poll interval. Every await timeout is therefore overridable. - -| Method | Default | What it bounds | -|---|---|---| -| `eventTimeout()` | 12s | waiting for a provider event | -| `readyTimeout()` | 30s | waiting for a provider to reach a lifecycle state | -| `startupTimeout()` | 60s | bringing the Compose stack up, and its control API becoming reachable (`ContainerizedProviderTckTest` only) | - -```java -@Override -public Duration eventTimeout() { - return Duration.ofSeconds(45); // we poll every 30s -} -``` - -Set `eventTimeout()` to comfortably exceed your worst-case detection latency, or the suite reports -timeouts that are really just impatience. Scenarios that assert promptness as part of their point -use the explicit `within {int}ms` step, which always wins. - -Every entry in that table is a **bound on an await**, and there is deliberately no entry that is a -**pause**. Nothing sleeps after a control API call: a control call returns when the backend has -acted, because that is what the control API promises — `POST /start` blocks until the flags are -evaluable. A fixed pause would cover that window whether or not the promise is kept, which is the -difference between a suite that can detect a control API regression and one that hides it. If a -step after a control call is racy on your stack, the defect is in the backend's control API and it -belongs in that backend's issue tracker; raising a pause in four languages is not the fix. +The two legitimate shapes, and why the declared-and-failing one is preferred, are +[Appendix F's][appendix-f]. `summary` is required and `issue` is not — `KnownDeviation.untracked(...)` +is the untracked form. The capability may be `null` for a mandatory, ungated scenario; it may not be +`CACHING` or `LARGE_INTEGERS`, since no scenario was ever put to your provider for either, and both +are refused where you write them. Empty is the default, and it is silence rather than a claim of +having none. ## Running it ```bash +# once, if this module is not in your local repository yet +mvn -pl tools/tck -am -DskipTests install + mvn -Ptck -pl providers/ test ``` -A suite extending `ProviderTckTest` with in-process control needs no Docker and no network. A suite -extending `ContainerizedProviderTckTest` needs a working Docker daemon for its Compose stack. - -### Put the adoption in a directory of its own - -**A `tck` package beside your module's other test packages, not inside one of them.** In -`providers/flagd` that is `src/test/java/dev/openfeature/contrib/providers/flagd/tck/`, a sibling of -the `e2e` package rather than a corner of it. - -Two reasons, and the second is the one the rest of this section rests on. +**Resist adding `-am` to the second line.** It pulls this module — and anything else the adoption +depends on — into the reactor and runs their suites before the first scenario, so a failure in any of +them comes out as a `-Ptck` failure. The one-off `install` is what `-am` was there for. -A conformance suite and an end-to-end suite mean different things by failure. An e2e suite tests -your provider against your own harness and is expected green; a conformance suite tests it against -the OpenFeature provider contract and fails scenarios *by design*, wherever a `knownDeviation` is -declared. Filing one under the other says they are the same kind of result, which is the conflation -the separate step below exists to undo. +Scenarios run **serially**, enforced over any `cucumber.execution.parallel.enabled=true` in your +module: control API state is global to the stack, so concurrent scenarios corrupt each other and the +symptom looks like a flaky provider. The stack starts once per suite and is never restarted. -And selection stops being a naming convention. Every selector — the exclusion, the profile that -runs the suite, the profile that must not — then names a directory, and a file is in it or it is -not. Selecting by filename works right up until somebody adds a suite whose name does not fit the -pattern, and nothing tells them. +**Put the adoption in a `tck` package of its own**, beside your module's other test packages rather +than inside one — in `providers/flagd` that is +`src/test/java/dev/openfeature/contrib/providers/flagd/tck/`, a sibling of `e2e` rather than a corner +of it. Why a conformance suite is not a kind of end-to-end test, and why selection by directory beats +selection by filename, are [Appendix F's][appendix-f] under "Running the suite in CI". Once the +directory selects, names that repeat it say the same thing twice — `FlagdRpcTckTest` in package +`...flagd.tck` is `RpcTest` — but **keep the `*Test` suffix**, which Surefire's default includes need, +and check `configuration()` when you rename. -Once the directory selects, names that repeated it are saying the same thing twice, so drop that -part: `FlagdRpcTckTest` in package `...flagd.tck` is `RpcTest`, and the fully-qualified name still -carries everything. **Keep the `*Test` suffix** — Surefire's default includes need it, which is a -different thing from the selector being removed. And check `configuration()` when you do: see -[Naming the configuration under test](#naming-the-configuration-under-test). +### The Maven mechanism -### Containerised suites are excluded from the default build, on purpose - -**Why** an adoption suite is excluded rather than gating, and the two mistakes that exclusion -invites, are written down once for all four languages in -[Appendix F: Running the suite in CI](https://github.com/open-feature/spec/blob/main/specification/appendix-f-provider-conformance.md#running-the-suite-in-ci). -Read that first. What follows is only the Maven mechanism, which is this repository's and not the -appendix's business. - -A `ContainerizedProviderTckTest` subclass is **excluded from the module's default test run**, and -the adopting module says so in its own POM. This repository's convention is the `testExclusions` -property the parent POM feeds to Surefire: +Appendix F has the reasoning for excluding an adoption from the default build and giving it a step of +its own; this is only how that is spelled here. The exclusion is the `testExclusions` property, which +the parent POM feeds to Surefire and gives no default, so a module that wants the gate declares it, +listing every Docker-dependent package — `providers/flagd` has its legacy `e2e` suites too: ```xml - **/tck/*.java + **/e2e/*.java,**/tck/*.java ``` -The parent POM defines no default for it, so a module that wants the gate must declare the property -itself. It is a **Surefire** exclusion, not a compiler one: the suite still compiles against the -harness in every build, which is what keeps an adoption from rotting unnoticed. A module with more -than one Docker-dependent package lists them all — `providers/flagd` has its legacy `e2e` suites as -well, so its property reads `**/e2e/*.java,**/tck/*.java`. - -**Then resolve the property under every profile your CI activates** — do not read the POM, which is -the mistake the appendix names first. Here, `ci.yml`'s `main` job activates `e2e` on every push, and -`providers/flagd` has an `e2e` profile for its legacy `Run*Test` suites; that profile therefore -drops only `**/e2e/*.java` from the exclusion and leaves `**/tck/*.java` in it, rather than clearing -it to ``, so the legacy suites keep running and the TCK suites stay out. Both +It is a **Surefire** exclusion and not a compiler one, so the adoption still compiles against the +harness in every build — the "keep it typechecked by something that runs ordinarily" property the +appendix asks for, which Maven gives for free. + +**Then resolve the property under every profile your CI activates, rather than reading the POM.** Both halves of the appendix's warning happened in this repository — one adoption never declared the -property, the other had a profile putting it back — and both were found by running this, not by -reading: +property, the other had a profile putting it back — and both were found by running this: ```bash mvn -Pe2e -pl providers/ help:evaluate -Dexpression=testExclusions -DforceStdout ``` -**Then give the suite a step of its own**, which is the appendix's other requirement and the reason -is what a red build *says*: a conformance run carries failures by design wherever a `knownDeviation` -is declared, so a signal it shares with a suite that is expected green ends with somebody silencing -the informative half. In Maven that step is a profile, one per adopting module, and it is named -`tck` because JavaScript's `nx tck` target and Python's `poe test-tck` task already spell it that -way: +The dedicated step is a profile, one per adopting module, named `tck` because JavaScript's `nx tck` +target and Python's `poe test-tck` task already spell it that way. It drops the `tck` directory from +the exclusion **and** narrows Surefire's includes to it: ```xml tck - - + + **/e2e/*.java - - - - org.apache.maven.plugins - maven-surefire-plugin - - - **/tck/*.java - - - - - + + org.apache.maven.plugins + maven-surefire-plugin + **/tck/*.java + ``` -Dropping the directory from the exclusion and narrowing the includes to it **in the same profile** -is what makes this a conformance step rather than a wider one. Dropping alone runs the module's unit -tests alongside the suites; narrowing alone leaves the exclusion in force and runs nothing. A module -with a second excluded package keeps that half of the property — `providers/flagd`'s `tck` profile -sets `**/e2e/*.java`, the exact mirror of what its `e2e` profile sets — so the two steps stay -disjoint whichever one is activated. - -```bash -# once, if this module is not in your local repository yet -mvn -pl tools/tck -am -DskipTests install +**Both halves are needed.** Dropping alone runs the module's unit tests alongside the suites; +narrowing alone leaves the exclusion in force and runs nothing. -mvn -Ptck -pl providers/ test -``` +## Extending it -The second line is the documented command the appendix asks for, and it is the one in each -adoption's own README. **Resist adding `-am` to it.** `-am` pulls this module — and anything else -the adoption depends on, `tools/flagd-core` for `providers/flagd` — into the reactor and runs their -test suites before the first scenario, so a failure in any of them comes out as a `-Ptck` failure. -That is the signal-mixing the separate step exists to prevent, reintroduced by a flag. The one-off -`install` is what `-am` was there for. - -The three command-line overrides this replaced — `-DtestExclusions= -Dtest='*TckTest' --Dsurefire.failIfNoSpecifiedTests=false` — did run the right suites, so this is not a correctness -fix. It is that a command a reader has to reassemble from three flags is not a step: nothing names -it, CI cannot invoke it by name, and its failure is indistinguishable from any other Surefire -failure in the same module. - -Scenarios run **serially** and the suite enforces this, overriding any -`cucumber.execution.parallel.enabled=true` in your module's `junit-platform.properties`. Control API -state is global to the Compose stack, so concurrent scenarios corrupt each other — one scenario's -`/start` restarts the backend underneath another's disconnect assertion. The symptom looks like a -flaky provider rather than a broken test, which is exactly why it is enforced rather than -documented. - -The Compose stack starts once per suite and is never restarted. Scenario isolation comes from the -control API. - -## Relationship to the flagd test harness - -The step vocabulary is inherited from the -[flagd test harness](https://github.com/open-feature/test-harness) wherever it was already -provider-neutral, so flagd's existing feature files port with a near-zero diff and the step -definitions stay familiar. Only genuinely flagd-specific wording was renamed: - -| flagd test harness | Provider TCK | Why | -|---|---|---| -| `Given a stable flagd provider` | `Given a stable provider` | drops the vendor name | -| `Given a unavailable flagd provider` | `Given a unavailable provider` | drops the vendor name | - -Everything else is unchanged: `a -flag with key ... and a default value ...`, -`the flag was evaluated with details`, `the resolved details value should be "..."`, -`the reason should be ...`, `the variant should be ...`, `the error-code should be ...`, -`a event handler`, `the event handler should have been executed[ within ms]`, -`the connection is lost`, `the flag was modified`, -`the flag should be part of the event payload`, `the client should be in state`. - -The steps the TCK added: - -| Step | Why it was added | -|---|---| -| `When the connection is restored` | the flagd harness only has a self-healing `lost for {int}s` form, which cannot express "assert stale, *then* reconnect" — the reconnect races the assertion. Splitting it is why `POST /restart` is optional and why this suite binds no step to it | -| `When the resolved value is remembered` / `Then the resolved details value should have changed` | the control API only requires that `/change` changes `changing-flag`'s value, not which value it changes to; asserting a delta keeps the scenario vendor-neutral | -| `Then no exception should have been thrown` | makes the "never throws" half of the error contract explicit rather than implicit in a step failure; also covers a repeated `shutdown()` and an `initialize()` after it | -| `Then the error message should be empty` | a value *and* an error message are two contradictory signals (requirement 2.3.2); asserted on every success path | -| `Then the provider metadata name should not be empty` | a conformance report keyed on the provider's name cannot be attributed if the name is empty (requirement 2.1.1) | -| `When the provider is shut down` / `When the provider is initialized again` | call the provider's own `shutdown()` and `initialize()` directly, not through the SDK — replacing the provider would test the SDK's bookkeeping, which Appendix B covers; the SDK is not told, so the next evaluation through the same client reaches the re-initialised provider | -| `Then the shutdown should have completed within {int}ms` | a shutdown that waits for a graceful close of a connection that will never answer hangs the host application's own shutdown | - -## Where these artifacts come from - -The feature files, the canonical flag set and the control API document are **not Java artifacts**. -They are language-agnostic definitions of the provider contract that every language's TCK must agree -on byte for byte, and that backend vendors implement in whatever language their testbed is written -in. - -They live in the OpenFeature [spec repository](https://github.com/open-feature/spec) as -[Appendix F: Provider Conformance](https://github.com/open-feature/spec/blob/main/specification/appendix-f-provider-conformance.md), -under `specification/assets/provider-tck/`. This module is their Java delivery vehicle: the `spec` -git submodule is updated at `initialize`, the three directories are copied into -`src/main/resources/` at `generate-resources`, and from there they are packaged into the release -JAR. Consumers see no difference — the features stay on the classpath and need no submodule of their -own. - -The three travel together by necessity: a feature file that evaluates `boolean-flag` is meaningless -without the flag definition, and a disconnect scenario is meaningless without the control endpoint -that produces the disconnect. - -> **Do not edit `src/main/resources/gherkin/`, `flags/` or `openapi/`.** They are generated and -> git-ignored. Changes belong in `open-feature/spec` and arrive here by bumping the submodule. - -Building this module therefore needs the submodule: +A provider with features of its own — flagd's `fractional` targeting, a vendor's proprietary mode — +extends the suite rather than maintaining a second one. Two files, no annotations: -```bash -git submodule update --init tools/tck/spec +``` +src/test/resources/extensions/fractional.feature +src/test/java/openfeature/tck/extensions/FractionalSteps.java // package openfeature.tck.extensions ``` -Maven does this itself at `initialize`, so a plain `mvn verify` works from a fresh clone; the -explicit command is only useful when working offline or inspecting the sources by hand. - -### Stale assets - -A re-pin moves two things by two different commands, and a build between them is silent. A rebase, a -branch switch or a `git checkout` moves the **gitlink**; only `git submodule update` moves the -**working tree**. Build in between and the copy step packages the previous pin's assets under the new -pin's name — and nothing looks wrong, because the old feature files agree with each other and with -the old flag set. One language's suite ran a whole adoption this way and the only trace was that its -totals matched the previous run exactly. - -Three things in the build make that unrepresentable rather than something to remember: - -1. **The checkout runs on every build**, at `initialize`. It is skippable, but only through - `-Dtck.spec.checkout.skip=true`, which is a switch of its own rather than a generic one — the - point being that you cannot turn it off as a side effect of turning something else off. There is - one good reason to use it: a checkout where `git` cannot read the repository at all. -2. **The three generated directories are emptied before anything is copied into them**, at - `generate-resources`. `copy-resources` overwrites and never deletes, and they are git-ignored, so - without this a file that exists in the old pin and not in the new one survives the re-pin. Going - forwards that is invisible; going *backwards* — a baseline measurement, a bisect — it produces an - asset set that exists in no revision of the specification, and a run against it that looks - entirely plausible. -3. **`CanonicalAssetDigestTest` fails the build if the packaged assets are not the pinned revision's**, - by digest, over all three directories and not just the Gherkin. This is what makes skipping the - checkout safe, and it is the only one of the three that catches a pin whose only change is - *content*: the re-pin it was written for changed two `$comment` blocks in `canonical-flags.json` - and not one scenario, which every count and every tag check in this module passes unmoved. - -Re-pinning is therefore one commit containing the gitlink, `PINNED_REVISION` and `PINNED_DIGEST` in -that test — which prints the value it wanted when it fails — and, on a branch that reports it, -`tck.spec.revision` in the POM. +Both are already selected by `ProviderTckTest`, so your scenarios run **inside** the suite: same +backend lifecycle, same `@BeforeAll`, same `BackendControl`, and canonical steps are on the glue path +too, so an extension scenario can open with `Given a stable provider`. A step class may take +`TckState` as a constructor argument exactly as the canonical steps do, and reach the backend control +and endpoint through `TckRuntime.get()` — **build a client of your own instead and you resolve against +a provider this suite never registered.** If a scenario is portable across providers, send it to the +TCK rather than keeping it as an extension. + +`gherkin/` and `extensions/` are Appendix F's names, and being two distinct directories is what makes +shadowing unreachable here: two classpath roots holding the same directory are scanned additively, but +two holding the same directory *and* the same file name are not — one wins silently, so a +`gherkin/errors.feature` in your test resources would *replace* the canonical file and the suite would +report success having run yours. The `extensions/` directory ships in this JAR holding only a README, +because a classpath resource selector naming a resource on no classpath root is a hard discovery error +rather than an empty selection. + +`ProviderTck` names every value the suite's annotations carry — `FEATURES`, `EXTENSIONS`, `GLUE`, +`EXTENSION_GLUE`, `ALL_GLUE` and the rest of the Cucumber configuration — so an adopter who does write +a `@ConfigurationParameter` composes rather than copies, an annotation value having to be a +compile-time constant. Keep `ProviderTck.GLUE` in it; dropping it makes every canonical step undefined. + +## Java notes + +**Suite discovery** relies on the JUnit Platform auto-registering `TckSuiteListener`, declared in this +JAR's `META-INF/services/org.junit.platform.launcher.TestExecutionListener`, which Surefire, Gradle +and IDEs all do by default. If your launcher disables listener auto-registration, register the harness +explicitly at `src/test/resources/META-INF/services/dev.openfeature.contrib.tools.tck.ProviderTckHarness` +and select between several with `-Dopenfeature.tck.harness=RpcTest`. + +**This module's own suites are the reference adoption to copy**, and all three run without Docker in +under a second: `InMemoryProviderTckTest` against the SDK's `InMemoryProvider`, +`ControllableProviderTckTest` against a provider with a real initialisation — the only Docker-free +cover the `@lifecycle` feature has — and `MultiProviderTckTest` against `MultiProvider` wrapping one +`InMemoryProvider`, where any difference from the first is attributable to delegation and nothing +else. That last one has already paid for itself: it cannot declare `CONFIGURATION_CHANGE`, because +`MultiProvider` never subscribes to its children and swallows their events — +[java-sdk#1882](https://github.com/open-feature/java-sdk/issues/1882), reproduced from the outside. +Appendix F's carve-out for a self-test withholding a capability over a defect applies to these and +**not** to an adoption. + +**The packaged artifacts are generated.** The Gherkin, flag set and control-API document live in +[open-feature/spec][spec] under `specification/assets/provider-tck/`; the `spec` submodule is updated +at `initialize` and the three directories are copied into `src/main/resources/` at +`generate-resources`. **Do not edit `src/main/resources/gherkin/`, `flags/` or `openapi/`** — they are +git-ignored, and changes belong upstream. Appendix F asks that a stale checkout be unrunnable rather +than merely discouraged, since a rebase moves the gitlink while only `git submodule update` moves the +working tree; three things enforce that here. The checkout runs on every build, skippable only through +the dedicated `-Dtck.spec.checkout.skip=true`; the generated directories are emptied before the copy, +so a file present in the old pin and not the new one cannot survive; and `CanonicalAssetDigestTest` +fails the build by digest over all three directories, which is the only one of the three that catches +a pin whose sole change is *content*. + +**The step vocabulary** is inherited from the [flagd test harness](https://github.com/open-feature/test-harness) +wherever it was already provider-neutral, so flagd's feature files ported with a near-zero diff; only +`Given a stable flagd provider` and `Given a unavailable flagd provider` were renamed, to drop the +vendor. The canonical set adds seven steps of its own, and each carries its rationale as javadoc on +the method that binds it, in `steps/ProviderSteps` and `steps/FlagSteps` — read those before writing +an extension step, since several of them exist to keep a scenario vendor-neutral in a way that is not +obvious from the wording. ## Known gaps -- **Evaluation context passthrough, beyond the targeting key.** `targeting-key-flag` resolves - differently for a matching context, so a provider that drops the context is caught by the resolved - value itself — that is what the `@targeting` scenarios do, and no echo operation is needed for it. - What is still unverified is that the *whole* context arrives intact: a provider that forwards the - targeting key and silently discards every other attribute passes. Closing that needs either an - echo operation on the control API — something like `GET /last-evaluation` returning the request the - backend last received — or a canonical flag whose rule keys on a custom attribute. -- **Targeting and bucketing correctness.** Out of scope by design: that is backend evaluation logic. - `targeting-key-flag` carries the one rule in the canonical set, and it is there to prove the - context reached the backend rather than to test how the backend evaluated it — which is why its - rule is stated as behaviour and not as a syntax. -- **Caching.** Whether a stale provider keeps serving last-known values during an outage depends on - whether it holds a local copy of the ruleset. The `@caching` tag is reserved; no scenarios yet, - and so not declarable. -- **Setting and removing individual flags.** `BackendControl` exposes `prepareScenario()` and - `changeFlag()` — reset to the canonical baseline, and mutate `changing-flag` — because those are - what the Gherkin needs and what the control API defines. Finer-grained `setFlag(key, value)` / - `removeFlag(key)` operations would need control API endpoints that do not exist yet, so adding - them to the interface would produce methods `HttpBackendControl` could not implement. They belong - to a control API revision, not to the Java seam. -- **Hooks.** Not covered. -- **Flag metadata.** The flagd harness has metadata scenarios; they are not yet ported. -- **Multi-suite JVMs.** `TckRuntime` is static, so TCK suites run one at a time within a JVM fork. - Several suites in one fork is fine — they run sequentially, each with its own Compose stack — but - they cannot run concurrently. -- **Scenario coverage is a representative subset**, covering each architectural mechanism once - rather than exhaustively. +[Appendix F][appendix-f] carries the suite's gaps — context passthrough beyond the targeting key, +per-flag control operations, caching, `@stale` without containers, hooks, flag metadata, and the +requirements not yet covered. One is Java's alone: **`TckRuntime` is static**, so TCK suites run one +at a time within a JVM fork. Several suites in one fork is fine — they run sequentially, each with its +own Compose stack — but they cannot run concurrently. ## Contributing -See the repository [CONTRIBUTING.md](../../CONTRIBUTING.md). New scenarios should be portable -across providers: if a scenario can only pass against one vendor's backend semantics, it belongs in -that provider's own suite, not here. +See the repository [CONTRIBUTING.md](../../CONTRIBUTING.md). New scenarios should be portable across +providers: a scenario that can only pass against one vendor's backend semantics belongs in that +provider's own suite, not here. + +[appendix-f]: https://github.com/open-feature/spec/blob/main/specification/appendix-f-provider-conformance.md +[assets]: https://github.com/open-feature/spec/blob/main/specification/assets/provider-tck/README.md +[control-api]: https://github.com/open-feature/spec/blob/main/specification/assets/provider-tck/openapi/control-api.yaml +[flags]: https://github.com/open-feature/spec/blob/main/specification/assets/provider-tck/flags/canonical-flags.json +[spec]: https://github.com/open-feature/spec +[tracking]: https://github.com/open-feature/spec/issues/417 From bc9dd65710679fe0dbeb9ab529fe1c0dd60b10c4 Mon Sep 17 00:00:00 2001 From: Simon Schrottner Date: Mon, 14 Sep 2026 08:26:22 +0200 Subject: [PATCH 50/55] docs(tck): let Appendix F own the vocabulary and the declaring rules The capability javadoc had grown into a second copy of Appendix F: the tag vocabulary, why @lifecycle is not @events, why @reinitialization is gated, the whole @standard-reasons section including its situation/reason table, the numeric-coercion rule and its borrowed-not-normative status. All of it is in the appendix, which is where it belongs -- one copy that four languages read rather than four that drift. What stays is what is Java's or this implementation's: that FeatureProviderStateManager emits PROVIDER_READY around initialize() for any provider, so a provider with no initialisation passes the readiness scenario vacuously; that Client.getIntegerDetails is a 32-bit Integer; why @disabled-flags is gated on architecture rather than on quality, which no other document states; the classpath collision that forces gherkin/ and extensions/ apart; and every API contract an adopter reads at the point of writing. The two worked adoptions in the class javadoc of ProviderTckHarness and ContainerizedProviderTckTest are in tools/tck/README.md, so they point there instead. Comments only. 246 tests / 43 skipped, unchanged; javadoc still builds under -Pcodequality,deploy. Signed-off-by: Simon Schrottner --- .../contrib/tools/tck/BackendControl.java | 60 +-- .../contrib/tools/tck/BackendEndpoint.java | 12 +- .../contrib/tools/tck/CanonicalFlags.java | 15 +- .../contrib/tools/tck/Capability.java | 455 +++++------------- .../contrib/tools/tck/CapabilityGate.java | 51 +- .../tck/ContainerizedProviderTckTest.java | 51 +- .../contrib/tools/tck/ControlApi.java | 17 +- .../contrib/tools/tck/HttpBackendControl.java | 18 +- .../tools/tck/InProcessBackendControl.java | 16 +- .../contrib/tools/tck/KnownDeviation.java | 112 ++--- .../contrib/tools/tck/ProviderTck.java | 16 +- .../contrib/tools/tck/ProviderTckHarness.java | 119 +---- .../contrib/tools/tck/ProviderTckTest.java | 51 +- .../tools/tck/steps/ProviderSteps.java | 14 +- 14 files changed, 266 insertions(+), 741 deletions(-) diff --git a/tools/tck/src/main/java/dev/openfeature/contrib/tools/tck/BackendControl.java b/tools/tck/src/main/java/dev/openfeature/contrib/tools/tck/BackendControl.java index 9b18dfd6a2..8f142453d7 100644 --- a/tools/tck/src/main/java/dev/openfeature/contrib/tools/tck/BackendControl.java +++ b/tools/tck/src/main/java/dev/openfeature/contrib/tools/tck/BackendControl.java @@ -11,35 +11,22 @@ * *

            Which implementation is right for your provider

            * - *

            If your provider talks to a backend — a server, a service, anything out of process — use - * {@link HttpBackendControl} by extending {@link ContainerizedProviderTckTest}. The HTTP control - * API in {@code openapi/control-api.yaml} is the normative contract for those providers, and it is - * what makes a conformance claim portable: another language's TCK drives the same endpoints against - * the same stack and must get the same answers. - * - *

            Do not write a custom in-JVM {@code BackendControl} that reaches into an - * external backend through a side channel — a test-only admin client, a shared database handle, a - * static hook inside the provider. It will pass, and it will prove nothing, because the thing it - * exercised is not the thing the contract describes. - * - *

            In-process control exists for providers that have no backend to contract with: - * in-memory, environment-variable and file-based providers, where "the backend" is a data structure - * in the same JVM. See {@link InProcessBackendControl}. + *

            A provider that talks to a backend uses {@link HttpBackendControl} by extending + * {@link ContainerizedProviderTckTest}; in-process control is for a provider with no + * backend to contract with — see {@link InProcessBackendControl}. That allowance is narrow, and + * Appendix + * F says why a custom in-JVM control reaching an external backend through a side channel passes + * while proving nothing. * *

            Operations a backend may not support

            * - *

            {@link #prepareScenario()} and {@link #changeFlag()} are mandatory: a backend that cannot reset - * itself or change a flag cannot run the suite at all. - * - *

            {@link #controlApi()} is mandatory too, and has deliberately no default — see {@link ControlApi} - * for why silence there is not a neutral answer. - * - *

            The two connection operations are not mandatory. A provider with nothing to disconnect from - * leaves them at their defaults, which throw {@link UnsupportedOperationException}. That exception is a - * test-configuration bug, never a skip — the scenarios that need connection - * control are gated behind {@link Capability#STALE} and {@link Capability#UNAVAILABLE_INIT}, so - * reaching one of these defaults means a capability was declared that the backend cannot back up. - * Failing loudly there is deliberate: a silent no-op would report the scenario as passed. + *

            {@link #prepareScenario()}, {@link #changeFlag()} and {@link #controlApi()} are mandatory. The + * two connection operations are not: a provider with nothing to disconnect from leaves them at + * their defaults, which throw {@link UnsupportedOperationException}. That exception is a + * test-configuration bug, never a skip — the scenarios needing connection control + * are gated behind {@link Capability#STALE} and {@link Capability#UNAVAILABLE_INIT}, so reaching a + * default means a capability was declared the backend cannot back up, and a silent no-op there + * would report the scenario as passed. * * @see Capability * @see ProviderTckTest @@ -96,23 +83,10 @@ default String description() { /** * Returns how the backend is driven, as one of the two kinds the provider contract recognises. * - *

            {@link ControlApi#HTTP} is the normative control API: the backend is a real one and it is - * driven over the endpoints in {@code openapi/control-api.yaml}, which is what makes a - * conformance claim portable between languages. {@link ControlApi#IN_PROCESS} is the narrow - * allowance for a provider with no backend at all, where "the backend" is a data structure in - * this JVM — a claim of {@code in-process} for a provider that does have a backend should be - * treated with suspicion. - * - *

            Part of the declaration vocabulary rather than of any one consumer of it: it says which of - * the two contracts a run was conducted under, which anyone reading the result needs whether or - * not a machine-readable report is being produced. A custom {@code BackendControl} states it - * here and nothing downstream has to guess. - * - *

            Required, with no default. Both implementations the TCK ships answer it - * already, and an adopter with a real backend writes no control at all — the HTTP one comes with - * {@link ContainerizedProviderTckTest}. The only person who implements this interface by hand is - * the one writing a custom control, which is precisely the case where the value cannot be - * inferred. An unanswered value would not be "no claim made"; it would be an unfalsifiable one. + *

            Appendix F requires the control to state this rather than the harness to infer it, and is + * why there is deliberately no default: an omitted value would be an unfalsifiable claim rather + * than no claim. Both implementations the TCK ships answer it, so the only author who has to is + * the one writing a custom control — precisely the case where it cannot be inferred. * * @return which of the two control contracts this run is conducted under */ diff --git a/tools/tck/src/main/java/dev/openfeature/contrib/tools/tck/BackendEndpoint.java b/tools/tck/src/main/java/dev/openfeature/contrib/tools/tck/BackendEndpoint.java index 22d1462339..88b37db0e1 100644 --- a/tools/tck/src/main/java/dev/openfeature/contrib/tools/tck/BackendEndpoint.java +++ b/tools/tck/src/main/java/dev/openfeature/contrib/tools/tck/BackendEndpoint.java @@ -6,14 +6,10 @@ * Addresses of the running backend stack, handed to * {@link ContainerizedProviderTckTest#createProvider(BackendEndpoint)}. * - *

            This type exists because external ports are only known after the Compose stack has - * started. Compose stacks under test must not pin host ports — Docker assigns them dynamically, so - * a provider cannot be configured until the stack is up. That is the whole reason the harness - * exposes a factory method rather than a pre-built provider instance. - * - *

            The port mapping is stable for the lifetime of the suite: the stack is started once and never - * restarted, so a provider built from this endpoint stays valid across every scenario. See the - * no-container-restart invariant in {@code openapi/control-api.yaml}. + *

            This type exists because host ports are only known after the Compose stack has + * started, which is why the harness exposes a factory method rather than a pre-built provider. The + * mapping is stable for the lifetime of the suite, since the stack is started once and never + * restarted — see the no-container-restart invariant in {@code openapi/control-api.yaml}. */ public final class BackendEndpoint { diff --git a/tools/tck/src/main/java/dev/openfeature/contrib/tools/tck/CanonicalFlags.java b/tools/tck/src/main/java/dev/openfeature/contrib/tools/tck/CanonicalFlags.java index 33dc696a88..0c498f9ecd 100644 --- a/tools/tck/src/main/java/dev/openfeature/contrib/tools/tck/CanonicalFlags.java +++ b/tools/tck/src/main/java/dev/openfeature/contrib/tools/tck/CanonicalFlags.java @@ -20,18 +20,13 @@ * {@code specification/assets/provider-tck/}, is copied in from the {@code spec} submodule at build * time and is packaged into the release JAR, which is why this class reads it off the classpath. * - *

            Why decoded rather than transcribed. Appendix F exposes the canonical set so - * that an adopting provider can seed a backend directly from the canonical definition rather than - * transcribing it, transcription being the usual way the two drift apart. A hand-written copy inside - * the TCK is the same drift with a shorter fuse: the in-process self-tests would then verify the - * suite against a second baseline of our own, so a rename in the spec makes them pass against the - * wrong flags while reporting green. Go's TCK decodes the same file for the same reason, and this - * follows it. + *

            Why decoded rather than transcribed. A hand-written copy inside the TCK would + * have the in-process self-tests verify the suite against a second baseline of our own, so a rename + * in the spec makes them pass against the wrong flags while reporting green. * *

            The file is flagd's flag-definition format — - * {"flags": {"<key>": {"state", "variants", "defaultVariant"}}} — because that is - * the only widely implemented vendor-neutral format today. {@code $comment} members are - * documentation and are ignored wherever they appear. + * {"flags": {"<key>": {"state", "variants", "defaultVariant"}}}. {@code $comment} + * members are documentation and are ignored wherever they appear. * *

            What the decoding has to preserve

            * diff --git a/tools/tck/src/main/java/dev/openfeature/contrib/tools/tck/Capability.java b/tools/tck/src/main/java/dev/openfeature/contrib/tools/tck/Capability.java index 3467710e6d..2967ae6699 100644 --- a/tools/tck/src/main/java/dev/openfeature/contrib/tools/tck/Capability.java +++ b/tools/tck/src/main/java/dev/openfeature/contrib/tools/tck/Capability.java @@ -10,98 +10,43 @@ /** * An optional part of the OpenFeature provider contract that a provider may or may not support. * - *

            Not every provider implements every spec feature — a provider backed by a static file has no - * meaningful notion of going stale, and a provider without a streaming transport cannot emit - * configuration-change events. Rather than forcing such providers to fail scenarios they were never - * going to satisfy, the TCK lets each one declare what it supports via - * {@link ProviderTckHarness#capabilities()}. - * - *

            Every capability corresponds to exactly one Gherkin tag. Scenarios carrying a tag whose - * capability was not declared are aborted before they run and are reported as skipped — - * never as passed. Silently green scenarios would make a conformance suite worthless. - * - *

            Scenarios with no capability tag are considered mandatory and always run. - * - *

            Two kinds of capability nobody may declare

            - * - *

            Most entries here are an ordinary choice: declare it if your provider does it, leave it out if - * it does not, and the results say which. Two are not a choice at all, and both are refused by - * {@link #requireDeclarable} rather than left to an adopter to remember. They are refused for - * different reasons, they are said differently, and they must not be confused with each other — - * a reader who sees a capability missing from a report has to be able to tell "this provider - * declined" from "no provider in this language can be asked", because only the first - * says anything about the provider. - * - *

            An entry may be {@linkplain #reserved() reserved}: it exists in the vocabulary so that every - * language's TCK spells the same property the same way, but no scenario anywhere carries - * its tag yet. {@link #CACHING} is the only one left — {@link #TARGETING} was reserved - * until the {@code targeting-key-flag} scenarios arrived, and is an ordinary declarable capability - * now. A reservation is global and temporary: every language has it, and it expires the moment the - * specification writes the scenarios. - * - *

            An entry may instead be {@linkplain #inexpressible() inexpressible}: the scenarios - * exist and are asked in other languages, but this SDK cannot put the question. {@link - * #LARGE_INTEGERS} is the only one — {@code Client.getIntegerDetails} takes and returns a 32-bit - * {@link Integer}, so 2^53 − 1 cannot be asked for by any Java provider whatever its backend serves. - * That is one language's and permanent: it lasts until the SDK grows a wider accessor, and no - * provider author can do anything about it. Refusing it centrally is what stops every Java adopter - * having to know a fact about Java and act on it, and stops a single wrong one putting a claim in a - * report that no scenario could have verified. + *

            Each capability is one Gherkin tag, declared through {@link ProviderTckHarness#capabilities()}. + * A scenario carrying an undeclared tag is reported as skipped, never as passed; an + * untagged scenario is mandatory. The vocabulary, what each tag means and the rules for declaring + * are * Appendix - * F states the rule. + * F's; the javadoc below adds only what is specific to this SDK or to this implementation. * - *

            Declare {@link #declarable()}, or {@link #declarableExcept} for "everything except", rather - * than {@code EnumSet.allOf} or {@code EnumSet.complementOf}: both of the latter sweep up the - * reserved and inexpressible tags on the way past, which is how a report comes to claim a capability - * nobody examined. + *

            Two entries are not an adopter's choice at all, and {@link #requireDeclarable} refuses both + * rather than leaving them to be remembered. A {@linkplain #reserved() reserved} one — {@link + * #CACHING} — has no scenarios in any language; an {@linkplain #inexpressible() inexpressible} one — + * {@link #LARGE_INTEGERS} — has scenarios that run elsewhere and no way to ask them through this + * SDK. Build a declaration with {@link #declarable()} or {@link #declarableExcept}, never with + * {@code EnumSet.allOf} or {@code EnumSet.complementOf}, which sweep both up on the way past. * - *

            The connection-dependent capabilities

            - * - *

            {@link #STALE} and {@link #UNAVAILABLE_INIT} are the two that require a backend the provider - * can be cut off from. They are what a harness leaves undeclared when its {@link BackendControl} - * has no connection to control — an in-memory, environment-variable or file-based provider, where - * the backend is a data structure in the same JVM. Every step that would call - * {@link BackendControl#disconnect()}, {@link BackendControl#reconnect()} or - * {@link ProviderTckHarness#createUnavailableProvider()} lives in a scenario carrying one of these - * two tags, so undeclaring them skips those scenarios before an unsupported operation can be - * reached. - * - *

            Getting that pairing wrong surfaces as an {@link UnsupportedOperationException} rather than a - * skip, which is deliberate: it means a capability was declared that the harness cannot back up, - * and that is a test-configuration bug. + *

            {@link #STALE} and {@link #UNAVAILABLE_INIT} are the two that need a backend the provider can + * be cut off from, so they are what a harness with an in-process {@link BackendControl} leaves + * undeclared. Every step that would reach {@link BackendControl#disconnect()}, + * {@link BackendControl#reconnect()} or {@link ProviderTckHarness#createUnavailableProvider()} sits + * in a scenario carrying one of the two; declaring them anyway surfaces as an + * {@link UnsupportedOperationException} rather than a skip, which is deliberate. */ public enum Capability { /** * Provider performs an initialisation that reaches its backend, with an observable outcome. * - *

            Gates the lifecycle scenarios: reaching {@code READY} against a healthy backend, and - * settling into {@code ERROR} — promptly, rather than blocking forever or throwing out of - * provider registration — against one that cannot be reached. - * - *

            Why this is not {@link #EVENTS}. Gating these scenarios on {@code EVENTS} - * is wrong in both directions. Too strict, because a stateless provider that emits no events of - * its own — OFREP, for instance — still initialises against a backend and still owes the - * contract; it simply cannot declare {@code EVENTS}. Too lax, because a provider that declares - * {@code EVENTS} passes the readiness scenario vacuously: - * {@code dev.openfeature.sdk.FeatureProviderStateManager} emits {@code PROVIDER_READY} and - * {@code PROVIDER_ERROR} around {@code initialize} for any provider, whether or not it - * is an {@code EventProvider}. A provider with no initialisation of its own therefore reaches - * {@code READY} exactly as {@code NoOpProvider} would, and the scenario goes green having - * demonstrated nothing about the provider. - * - *

            So {@code EVENTS} asserts that the provider emits events; {@code LIFECYCLE} asserts that - * there is a real initialisation behind the event whose outcome the events describe. Declare it - * only if initialisation actually talks to the backend. A provider with nothing to reach — one - * handed its whole flag set by its constructor, or a facade over other providers — should - * not declare it, however many events it emits. - * *

            The test is whether initialisation acquires something it did not already hold and * can be refused, not whether the thing acquired is across a socket. The TCK's own * {@code ControllableProviderTckTest} declares this against a store in the same JVM, because - * that store is read at {@code initialize()} time and can decline — so {@code READY} is the - * outcome of the call rather than a state the SDK manufactured. The SDK's + * that store is read at {@code initialize()} time and can decline. The SDK's * {@code InMemoryProvider} cannot, which is why {@code InMemoryProviderTckTest} withholds it. + * + *

            Deliberately not {@link #EVENTS}, and in Java the reason is concrete: + * {@code dev.openfeature.sdk.FeatureProviderStateManager} emits {@code PROVIDER_READY} and + * {@code PROVIDER_ERROR} around {@code initialize} for any provider, whether or not it + * is an {@code EventProvider}, so a provider with no initialisation of its own reaches + * {@code READY} exactly as {@code NoOpProvider} would. */ LIFECYCLE("@lifecycle"), @@ -109,34 +54,15 @@ public enum Capability { * Provider can be initialised again after {@code shutdown} and serves flags afterwards. * *

            Gates exactly one scenario, "A provider that was shut down can be initialized again", and - * it is gated because the specification permits reuse rather than requiring it. + * it is gated because * Requirement - * 2.5.2 says a provider SHOULD revert to its uninitialized state after - * {@code shutdown}, and its supporting text adds that "some providers MAY allow - * reinitialization from this state". A provider that releases its client on shutdown and - * declines to start again is taking an option the specification offers it, so withholding this - * capability is a choice and needs no {@link KnownDeviation}. - * - *

            Why this is not {@link #LIFECYCLE}. The scenario was originally untagged - * — and therefore mandatory — on the reading that reverting to the uninitialized state is - * observable as exactly one thing, being initialisable again. That inference does not hold, and - * the cost of it was concrete: run against the flagd provider, whose - * {@code FlagdProviderSyncResources} keeps {@code isInitialized} and {@code isShutDown} as - * separate flags and refuses {@code initialize()} when either is set, the scenario failed and - * was one step from being filed as a defect against a provider doing nothing wrong. A false - * failure is the mirror image of a vacuous pass. - * - *

            What the tag buys is the other direction. A provider that does offer reuse has - * somewhere to be held to it, because "shutdown() releases the client and initialize() returns - * early because an initialised flag was never cleared" is easy to write and leaves the provider - * evaluating against a closed connection rather than failing outright. Reverting the state is - * not separately observable — a provider that reverts but refuses reuse presents exactly as one - * that did neither — so a gated reuse scenario is the only assertion the requirement admits. + * 2.5.2 permits reuse rather than requiring it — so withholding it is a + * choice and needs no {@link KnownDeviation}. Appendix F records why the scenario is gated + * separately from {@link #LIFECYCLE} rather than left mandatory. * *

            Declaring {@code LIFECYCLE} and withholding this one is the expected combination for a * provider whose initialisation reaches a backend it does not reopen. The scenario carries both - * tags, so a provider that declares neither sees it skipped for {@code @lifecycle} and loses - * nothing by the second omission. + * tags, so a provider declaring neither sees it skipped for {@code @lifecycle}. */ REINITIALIZATION("@reinitialization"), @@ -155,63 +81,33 @@ public enum Capability { /** * Provider names the variant it resolved. * - *

            Gated, because a variant is optional rather than required. - * {@code types.md} - * declares the field "variant (string, optional)", and + *

            Gated because a variant is optional rather than required: * Requirement - * 2.2.4 is a {@code SHOULD}: in normal execution a provider "SHOULD populate the - * resolution details structure's variant field". The same section adds that the value - * "might only be meaningful in the context of the flag management system associated with - * the provider". - * - *

            Some backends have no variant concept for a plain flag at all. Their evaluation response - * carries no such key, so the provider never receives one and no amount of seeding can produce - * one. Asserting a variant in every evaluation scenario failed such a backend ten times over for - * something that is not a defect and that no provider author can fix — and left nothing to - * record as a {@link KnownDeviation}, because there was no capability to hang one on. - * - *

            A provider whose backend names its variants declares this and the {@code @variants} - * scenario outline runs. One whose backend does not leaves it undeclared, and those rows are - * skipped with that reason rather than passed. Either way the value assertions are unaffected: - * they are untagged, and Requirement 2.2.3 makes the value a {@code MUST}. The reason is the same - * shape of question one requirement further on, and it is gated the same way — see - * {@link #STANDARD_REASONS}. + * 2.2.4 is a {@code SHOULD} and + * {@code types.md} + * types the field as optional. Withholding it needs no {@link KnownDeviation}. The value + * assertions are untagged and unaffected; the reason is the same shape of question one + * requirement further on and is gated the same way — see {@link #STANDARD_REASONS}. */ VARIANTS("@variants"), /** * Provider resolves a flag disabled in the management system to the caller's default value. * - *

            Gates one Scenario Outline, four rows: {@code disabled-boolean-flag}, - * {@code disabled-string-flag}, {@code disabled-integer-flag} and {@code disabled-float-flag}, - * each asked for with a default that differs from the value the flag is configured with. A - * provider that ignores the state serves the configured value and is caught on the value alone. - * - *

            Gated because the answer is a property of architecture rather than of quality. - * Where the substitution happens decides whether it can happen at all. A provider that evaluates - * locally — flagd's RPC and in-process resolvers, an in-memory provider — holds the caller's - * default in its own hands and can return it. A provider whose backend decides, one speaking - * OFREP for instance, cannot: the default never leaves the process, so the server has nothing to - * echo back and the provider has nothing to substitute. The same flag cannot behave the same way - * across those two designs, and neither of them is wrong, so withholding this needs no - * {@link KnownDeviation}. - * - *

            Nothing in the specification says what a provider owes a disabled flag. - * Requirement - * 1.4.7 is about the SDK propagating whatever reason arrived, and - * Requirement - * 2.2.5 only lists {@code DISABLED} among the reason strings a provider may use. So - * Appendix - * F states the behaviour, as it does for {@link #NUMERIC_COERCION}, and gates it. - * - *

            The value is asserted here, not the reason. The value rests on Requirement - * 2.2.3, a {@code MUST}; pinning reason {@code DISABLED} on these rows would rest on 2.2.5, a - * {@code SHOULD} that explicitly permits "some other string", and would narrow it for every - * adopter. It is pinned in {@code gherkin/reason.feature} instead, on a scenario carrying both - * this tag and {@code @standard-reasons}, so a provider opts into that narrowing rather than - * inheriting it — see {@link #STANDARD_REASONS}. No variant is asserted either — a disabled flag - * resolved no variant, so there is none to name, and this capability and {@link #VARIANTS} - * deliberately do not compose. + *

            Gated because the answer is a property of architecture rather than of quality, + * which is the part of this tag no other document states. Where the substitution happens decides + * whether it can happen at all: a provider that evaluates locally — flagd's RPC and in-process + * resolvers, an in-memory provider — holds the caller's default in its own hands and can return + * it, while a provider whose backend decides, one speaking OFREP for instance, cannot, because + * the default never leaves the process and the server has nothing to echo back. The same flag + * cannot behave the same way across those two designs and neither of them is wrong, so + * withholding this needs no {@link KnownDeviation}. + * + *

            The value is asserted here and not the reason, because the value rests on a {@code MUST} + * and the reason on a {@code SHOULD} that permits any string. Reason {@code DISABLED} is pinned + * in {@code gherkin/reason.feature} instead, on a scenario carrying this tag and + * {@code @standard-reasons} together. No variant is asserted either, so this capability and + * {@link #VARIANTS} deliberately do not compose. */ DISABLED_FLAGS("@disabled-flags"), @@ -221,37 +117,19 @@ public enum Capability { /** * Provider coerces between the integer and float types only when the coercion is lossless. * - *

            The rule is lossless coercion is permitted; lossy coercion must fail with - * {@code TYPE_MISMATCH}. An integral float such as {@code 10.0} requested as an integer - * must succeed, because nothing is lost by answering it; {@code 0.5} requested as an integer must - * not, because narrowing it to {@code 0} discards the fractional part. The distinction is flagd's + *

            Lossless coercion is permitted; lossy coercion must fail with {@code TYPE_MISMATCH}. The + * rule is borrowed rather than normative — it is flagd's * numeric - * coercion ADR, and this capability is named after it. - * - *

            The rule is borrowed, not normative. OpenFeature has a single numeric type - * and lets a typed language split it into two accessors "as idioms dictate", and nothing in the - * specification says what a provider owes a value that does not fit the accessor it was asked - * through — that is open-feature/spec#430. - * A provider that behaves differently is not violating the specification, and a report must - * not be read as saying it is. The difference is still worth a word: narrowing {@code 0.5} to - * {@code 0} with no error code hands an application a plausible value and no signal, and that is - * a defect rather than a choice. - * - *

            A provider in that position declares the tag. It does attempt the - * coercion and gets one direction wrong, which is precisely what a skip cannot express, so the - * honest report is to declare, let the lossy scenario fail, and record a {@link KnownDeviation} - * beside the failure. Withholding is for a provider that cannot attempt the behaviour - * at all — one whose SDK has a single numeric type, or one that keeps the two types strictly - * apart in both directions as {@code InMemoryProvider} does, where the distinction does not - * exist to get wrong. That is the choice half of the same rule, and it needs no deviation. - * - *

            Both halves are tested. The lossy half asks for {@code float-flag} (0.5) - * as an integer and expects {@code TYPE_MISMATCH}; the lossless half asks for - * {@code integral-float-flag} (10.0) as an integer and for {@code integer-flag} (10) as a float - * and expects both to succeed. A provider declaring this must satisfy all three — rejecting - * every float is an easy way to pass the first, and the other two are what stop it. A provider - * that keeps the two numeric types strictly apart in both directions, as the SDK's own - * {@code InMemoryProvider} does, therefore cannot declare it. + * coercion ADR, this capability is named after it, and no OpenFeature requirement says what + * a provider owes a value that does not fit the accessor it was asked through + * (open-feature/spec#430). A + * provider that behaves differently is not violating the specification, and a report must not be + * read as saying it is. Appendix F carries the rest, including which of declaring and + * withholding is honest for which provider. + * + *

            Java-specific consequence: a provider that keeps the two numeric types strictly apart in + * both directions — as the SDK's own {@code InMemoryProvider} does, so the self-tests in this + * module withhold the tag — cannot attempt the behaviour and needs no {@link KnownDeviation}. */ NUMERIC_COERCION("@numeric-coercion"), @@ -259,40 +137,17 @@ public enum Capability { * Provider resolves integers up to 2^53 − 1 exactly. * *

            {@linkplain #inexpressible() Inexpressible} in Java, so no Java provider may - * declare it and {@link #requireDeclarable} refuses one that tries. Whether the value - * can be asked for at all is a property of the SDK's integer accessor rather than of any - * provider: {@code Client.getIntegerDetails} takes and returns a 32-bit {@link Integer}, so a - * Java provider has nowhere to put {@code 9007199254740991} however faithfully its backend - * serves it. Go's accessor is {@code int64} and JavaScript's number reaches 2^53 − 1 exactly, so - * their suites declare it and run the scenario. - * - *

            This is not a reservation. The scenario exists, is asked, and passes - * elsewhere; what is missing is a way to ask it here, and that will be missing until the Java - * SDK grows a wider accessor. So the scenario is skipped, with a reason that says the SDK cannot - * ask the question rather than that the provider declined — {@link CapabilityGate} keeps the two - * apart, because only the second describes the provider under test. - * - *

            Withholding it needs no {@link KnownDeviation}, and there is no longer anything for an - * adopter to withhold: the refusal is here, once, instead of in each adoption's - * {@code capabilities()} with a comment restating this paragraph. - * Appendix - * F states the rule that puts it here. - * - *

            It is also not the same kind of decision as a capability withheld because a backend - * cannot serve a scenario's flag, and the two are now formally different. Appendix F's - * declaring rule — once a provider is attempting a capability, declare it when at least one - * scenario gating it can actually be put to the provider, and withhold only when none can — is - * answered per scenario, with a backend in - * view, and a capability withheld on those grounds is temporary: it needs a note saying - * why, and it is revisited when the backend gains the fixture. The appendix illustrates that - * rule with this very tag, because the reference backend serves no flag for its one scenario. - * In Java the question never reaches a backend. The accessor cannot carry {@code 2^53 − 1} - * however the backend is provisioned, so no Java suite gets as far as that rule for this tag, - * and no fixture arriving anywhere would change the answer. Only a wider SDK accessor would. - * - *

            The 32-bit precision scenario — {@code large-integer-flag}, 2^31 − 1 — is untagged and - * always runs. What a provider owes a value that does not fit the requested accessor is the - * open question in open-feature/spec#430. + * declare it and {@link #requireDeclarable} refuses one that tries. + * {@code Client.getIntegerDetails} takes and returns a 32-bit {@link Integer}, so a Java + * provider has nowhere to put {@code 9007199254740991} however faithfully its backend serves it. + * The scenario exists and passes in languages whose accessor is wide enough, which is what makes + * this an inexpressible capability rather than a {@linkplain #reserved() reserved} one, and its + * skip reason names the SDK rather than the provider — see {@link CapabilityGate}. + * + *

            Refused centrally, per Appendix F, so nothing is left for an adoption to withhold and no + * {@link KnownDeviation} is owed. No backend fixture would change the answer; only a wider SDK + * accessor would. The 32-bit precision scenario — {@code large-integer-flag}, 2^31 − 1 — is + * untagged and always runs. */ LARGE_INTEGERS( "@large-integers", @@ -302,96 +157,33 @@ public enum Capability { /** * Provider resolves a flag differently for a matching evaluation context. * - *

            Gates the three {@code targeting-key-flag} scenarios: a matching targeting key resolves - * {@code hit}, a non-matching one resolves {@code miss}, and no context at all resolves - * {@code miss} without erroring. - * - *

            This is what makes context passthrough observable. Every other flag in the canonical set - * resolves the same way whatever the context, so a provider that drops the context entirely - * passes them all; here a matching context resolves to a different value, so dropping it is - * caught by the resolved value itself rather than needing an echo endpoint on the control API. - * - *

            The flag's rule is specified by behaviour rather than by syntax — resolve {@code hit} when - * the targeting key is exactly {@code 5c3d8535-f81a-4478-a6d3-afaa4d51199e}, {@code miss} - * otherwise — so a backend expresses it however it expresses targeting. A provider whose backend - * has no targeting at all, or whose harness seeds a flag set that cannot carry a rule, leaves - * this undeclared and the three scenarios are skipped with that reason. - * - *

            What is still not covered is that the whole context arrives intact: a provider - * that forwards the targeting key and silently discards every other attribute declares this and - * passes. That gap needs either an echo operation on the control API or a second flag keyed on a - * custom attribute. + *

            Gates the three {@code targeting-key-flag} scenarios, and it is the only tag under which + * dropping the evaluation context is caught by a resolved value rather than needing an echo + * endpoint. The flag's rule, and the fact that it is specified by behaviour rather than by + * syntax, are in the + * canonical + * flag set's README; that context beyond the targeting key is still unverified is an open + * question in Appendix F. */ TARGETING("@targeting"), /** * Provider reports the standard resolution reasons, with the meanings Appendix F gives them. * - *

            Gates {@code gherkin/reason.feature} in its entirety — and it is a claim, not an - * exemption. + *

            Gates {@code gherkin/reason.feature} in its entirety, and it is a claim rather than + * an exemption: declaring it says "I use the standard vocabulary with the standard + * meanings", and that file is what checks the claim. * Requirement - * 2.2.5 is a {@code SHOULD}, and it goes further than 2.2.4 does: it lets a provider populate - * {@code reason} with one of the listed values "or some other string indicating the semantic - * reason for the returned flag value". A provider whose backend reports vendor-specific - * reasons is therefore conformant, and asserting an exact reason against it would fail it for - * something the specification permits. - * - *

            An earlier revision of the suite asserted a reason in thirteen places across three feature - * files, which narrowed that {@code SHOULD} into a {@code MUST} for every adopter. It bought very - * little: every canonical flag resolves to a value distinct from the caller's default, so a - * provider that silently falls back is already caught by the value assertion, and the reason only - * said why it failed. - * - *

            So declaring this is a provider saying "I use the standard vocabulary with the standard - * meanings", and {@code reason.feature} is what checks the claim. A provider that does not - * declare it loses nothing: its values, variants and error codes are asserted everywhere else, on - * {@code MUST} requirements. What the declaration adds is something a report's reader can act on — - * anyone building telemetry, dashboards or debugging on {@code reason} can see that the vocabulary - * was verified rather than assumed. Withholding it therefore needs no {@link KnownDeviation}. - * - *

            The meanings are the content of the claim, and they constrain nobody who does not make it: - * - * - * - * - * - * - * - * - * - * - *
            The reason each situation is claimed to produce
            SituationReason
            The flag was resolved from configuration and carries no targeting rule{@code STATIC}
            A targeting rule matched the evaluation context{@code TARGETING_MATCH}
            A targeting rule exists and did not match{@code DEFAULT}
            The flag is disabled in the management system{@code DISABLED}
            The evaluation failed, and an error code is reported with it{@code ERROR}
            - * - *

            {@code STATIC} for the first row is the call worth flagging. - * {@code types.md} - * types {@code DEFAULT} as "no dynamic evaluation occurred or dynamic - * evaluation yielded no result", which a rule-less flag satisfies as readily as - * {@code STATIC} does — two providers can disagree here and both conform. A provider that answers - * {@code DEFAULT} for a rule-less flag is not defective; it does not use the standard meanings and - * should not declare the tag. - * - *

            {@code ERROR} is the row where the suite's subject is blurred, and it is asserted anyway. The - * other four rest on - * Requirement - * 1.4.7, which makes the SDK propagate the provider's reason — but only "in cases of normal - * execution". Abnormal execution is 1.4.9, a {@code SHOULD} on the SDK to - * indicate an error, and nothing requires the provider's reason to survive. So a passing - * {@code ERROR} scenario establishes that the value reaching the application is coherent, not that - * the provider produced it. It is still worth asserting: the error code alone is already covered - * ungated in {@code errors.feature}, the reason alone could have been written by the SDK, and an - * evaluation reporting {@code FLAG_NOT_FOUND} with reason {@code STATIC} is incoherent whoever - * wrote it. - * - *

            Tags compose, and here that is load-bearing. {@code TARGETING_MATCH} cannot - * be observed without targeting and {@code DISABLED} cannot be observed unless the backend - * distinguishes a disabled flag, so those scenarios carry {@link #TARGETING} and - * {@link #DISABLED_FLAGS} as well. A provider declaring this one alone runs the rest and skips - * those two with their reason. - * - *

            {@code SPLIT}, {@code UNKNOWN}, {@code CACHED} and {@code STALE} are not asserted. The first - * two have no scenario that produces them; {@code CACHED} belongs behind {@link #CACHING} and needs - * a repeat evaluation that nothing here performs, and {@code STALE} needs a scenario asserting what - * a provider serves during an outage, which is the same gap. + * 2.2.5 is a {@code SHOULD} that permits any string, so a provider that does not declare + * this loses nothing and owes no {@link KnownDeviation} — its values, variants and error codes + * are asserted everywhere else on {@code MUST} requirements. + * + *

            Appendix F's {@code @standard-reasons} section holds the situation-to-reason table that is + * the content of the claim, why {@code STATIC} rather than {@code DEFAULT} for a rule-less flag, + * why {@code ERROR} is asserted even though the SDK may have written it, and which reasons are + * not asserted at all. Two of the scenarios compose with {@link #TARGETING} and + * {@link #DISABLED_FLAGS}, so a provider declaring this one alone runs the rest and skips those + * two with their own reason. */ STANDARD_REASONS("@standard-reasons"), @@ -436,10 +228,8 @@ public String tag() { /** * Returns whether this capability is reserved, and so must not be declared. * - *

            Reserved means the tag is part of the shared vocabulary but no scenario in the suite - * carries it. Such a capability cannot gate anything: it produces no skip, so it plays no part - * in reading the results, and listing it in a report invites a reader to believe it was verified - * when nothing examined it. + *

            Reserved means the tag is part of the shared vocabulary but no scenario carries it, so it + * can gate nothing and listing it in a report would invite a reader to believe it was verified. * * @return {@code true} if no scenario carries this capability's tag */ @@ -451,17 +241,11 @@ public boolean reserved() { * Returns whether this capability is one the Java SDK cannot express, and so must not be * declared by any provider written against it. * - *

            The opposite case to {@link #reserved()}, and kept apart from it deliberately. A reserved - * capability has no scenarios in any language and its reservation expires when the specification - * writes them. An inexpressible one has scenarios that run and pass in other languages; what is - * missing is a way to put the question through this SDK's API, and that lasts until the SDK - * changes. Both are refused by {@link #requireDeclarable}, with different messages, and their - * scenarios are skipped with different reasons. - * - *

            It is the implementation that refuses it, rather than each adopter remembering to withhold - * it. A property of the language is then recorded once, where it is true, instead of in every - * suite that adopts the TCK — and an adopter cannot get it wrong in the one direction that - * matters, which is claiming a capability no scenario could have verified. + *

            The opposite case to {@link #reserved()}, and kept apart from it deliberately: a reserved + * capability has no scenarios anywhere and expires when the specification writes them, while an + * inexpressible one has scenarios that pass elsewhere and lasts until this SDK changes. Both are + * refused by {@link #requireDeclarable}, with different messages, and their scenarios are + * skipped with different reasons. * * @return {@code true} if no provider written against this SDK can be asked this capability's * scenarios @@ -493,13 +277,9 @@ public static Optional fromTag(String tag) { * Returns every capability a provider written against this SDK may declare. * *

            This, not {@code EnumSet.allOf(Capability.class)}, is what "everything" means for a - * declaration. {@linkplain #reserved() Reserved} capabilities are left out because no scenario - * carries their tag; {@linkplain #inexpressible() inexpressible} ones because this SDK cannot - * ask what they ask, so no Java provider could be held to them. - * - *

            It is a set a Java provider may declare unchanged, and the default. Narrow it only for - * things this provider cannot do — what no provider in this language can do has already - * been taken out. + * declaration: {@linkplain #reserved() reserved} and {@linkplain #inexpressible() inexpressible} + * capabilities are left out. It is the default, and a set a Java provider may declare unchanged. + * Narrow it only for things this provider cannot do. * * @return the declarable capabilities, as a fresh mutable set */ @@ -515,11 +295,7 @@ public static EnumSet declarable() { *

            The counterpart to {@code EnumSet.complementOf}, and the reason it exists: a provider * saying "everything except the one thing I cannot do" wants everything declarable * except that thing, whereas {@code complementOf} hands back the reserved and inexpressible tags - * as well. - * - *

            What belongs in {@code excluded} is a fact about this provider. A fact about Java - * does not: {@link #LARGE_INTEGERS} is already absent, and naming it here is harmless but says - * nothing, because no Java provider could have declared it. + * as well. What belongs in {@code excluded} is a fact about this provider. * * @param excluded capabilities to withhold; reserved and inexpressible capabilities are absent * regardless @@ -536,24 +312,17 @@ public static EnumSet declarableExcept(Capability... excluded) { /** * Rejects a declaration that claims something no result could check. * - *

            Fails the run rather than warning and dropping it. The declaration is the one part of a - * conformance report that no result can check — everything else in it was observed, this is - * asserted by the provider author — so a claim that cannot possibly be true is worth stopping - * for. There is nothing to lose by refusing, either: in neither case below does any coverage - * depend on the claim, and the fix is to call {@link #declarable()} or - * {@link #declarableExcept}. + *

            Fails the run rather than warning and dropping it: the declaration is the one part of a + * conformance report that no result can check, so a claim that cannot possibly be true is worth + * stopping for, and the fix is to call {@link #declarable()} or {@link #declarableExcept}. * - *

            Two claims are refused, for different reasons, and they are reported separately. - * A {@linkplain #reserved() reserved} capability has no scenarios in any language; an - * {@linkplain #inexpressible() inexpressible} one has scenarios that pass in other languages and - * no way to ask them here. Collapsing them into one message would tell an adopter the two facts - * are the same fact, and they behave differently: the first expires when the specification - * writes the scenarios, the second when the SDK changes. Both lists are gathered before either - * is thrown, so a declaration that gets both wrong hears about both. + *

            The {@linkplain #reserved() reserved} and {@linkplain #inexpressible() inexpressible} cases + * are reported separately rather than in one message, because they are different facts and + * expire on different events. Both lists are gathered before either is thrown, so a declaration + * that gets both wrong hears about both. * - *

            Nothing else is refused. A capability whose scenario the provider cannot satisfy is not a - * claim that cannot be checked — it is one the results contradict, which is what a conformance - * run is for. + *

            Nothing else is refused. A capability whose scenario the provider cannot satisfy is one the + * results contradict, which is what a conformance run is for. * * @param declared the capabilities a harness declares * @throws IllegalArgumentException if any of them is reserved or inexpressible diff --git a/tools/tck/src/main/java/dev/openfeature/contrib/tools/tck/CapabilityGate.java b/tools/tck/src/main/java/dev/openfeature/contrib/tools/tck/CapabilityGate.java index 1013bbad80..94aa5fc33a 100644 --- a/tools/tck/src/main/java/dev/openfeature/contrib/tools/tck/CapabilityGate.java +++ b/tools/tck/src/main/java/dev/openfeature/contrib/tools/tck/CapabilityGate.java @@ -45,15 +45,12 @@ private CapabilityGate() {} *

            Tags that gate nothing are ignored, so a scenario with no capability tag is mandatory and * always runs. * - *

            Two skips, and they do not say the same thing. The ordinary one is a - * capability the provider did not declare, and it names the provider. The other is a capability - * this SDK {@linkplain Capability#inexpressible() cannot express} — {@link - * Capability#LARGE_INTEGERS} on the Java SDK's 32-bit integer accessor — where the provider had - * no say: no Java provider can be asked that scenario, and {@link Capability#requireDeclarable} - * refuses a declaration that pretends otherwise. Reporting both as "the provider does not - * declare it" would read as a decision the provider took, and a reader of the report would - * believe it. So the reason names the SDK instead, and is checked before the declaration, which - * makes it the reason every time rather than only when the provider happens to have withheld it. + *

            Two skips, and they do not say the same thing. The ordinary one names the + * provider, which did not declare the capability. The other names the SDK, which + * {@linkplain Capability#inexpressible() cannot express} it — reporting that as "the provider + * does not declare it" would read as a decision the provider took. It is checked before the + * declaration, which makes it the reason every time rather than only when the provider happens + * to have withheld the tag as well. * * @param tags the scenario's Gherkin tags, including the leading at-sign * @param declared the capabilities the provider declares @@ -86,28 +83,24 @@ public static void requireDeclared(Collection tags, Set decl /** * Fails the run if a scenario carries the tag of a capability this suite still calls reserved. * - *

            This is the expiry check on {@link Capability#reserved()}, and it is the other half of - * {@link Capability#requireDeclarable}. That one refuses a declaration naming a reserved - * capability; this one refuses a scenario carrying its tag. There is no equivalent for - * an {@linkplain Capability#inexpressible() inexpressible} capability and there could not be: a - * scenario carrying its tag is exactly what is expected, since the scenarios are what the other - * languages run. A reservation is a name held - * open for scenarios that do not exist yet and is only ever temporary — the specification writes - * them, the tag starts gating something, and the capability becomes declarable. Until this - * implementation follows, the two halves meet in the worst possible place: the scenario is - * skipped for a capability no adopter is permitted to claim, a question put and silently - * withdrawn. That is the unclaimable-capability failure - * Appendix - * F describes. + *

            The expiry check on {@link Capability#reserved()}, and the other half of + * {@link Capability#requireDeclarable}: that one refuses a declaration naming a reserved + * capability, this one a scenario carrying its tag. An + * {@linkplain Capability#inexpressible() inexpressible} capability has no equivalent and could + * not — a scenario carrying its tag is exactly what is expected, since other languages run it. * - *

            Nothing else in the suite would notice it. The report is well-formed, the run is green, and - * a capability-gated skip is explicitly not a gap — so the new scenario is executed by nobody and - * the results say only what they say about every undeclared capability. It has no local symptom - * at all, which is why it is checked rather than watched for: {@link Capability#TARGETING} was - * reserved until the {@code targeting-key-flag} scenarios arrived. + *

            When the specification writes the scenarios a reservation was holding the name open for and + * this implementation has not followed, the two halves meet in the worst possible place: the + * scenario is skipped for a capability no adopter is permitted to claim — the + * unclaimable-capability failure + * Appendix + * F describes. Nothing else in the suite would notice: the report is well-formed, the run is + * green, and a capability-gated skip is explicitly not a gap. It has no local symptom at all, + * which is why it is checked rather than watched for — {@link Capability#TARGETING} was reserved + * until the {@code targeting-key-flag} scenarios arrived. * - *

            Refused rather than worked around. Quietly treating the tag as declarable here would let a - * run claim a capability against an implementation that does not know the tag exists; the point + *

            Refused rather than worked around: treating the tag as declarable here would let a run + * claim a capability against an implementation that does not know the tag exists, and the point * of the check is that a human re-reads the reserved list against the specification. * *

            The tags are the parsed ones. They come from diff --git a/tools/tck/src/main/java/dev/openfeature/contrib/tools/tck/ContainerizedProviderTckTest.java b/tools/tck/src/main/java/dev/openfeature/contrib/tools/tck/ContainerizedProviderTckTest.java index 66a429ffad..00f56f7cdb 100644 --- a/tools/tck/src/main/java/dev/openfeature/contrib/tools/tck/ContainerizedProviderTckTest.java +++ b/tools/tck/src/main/java/dev/openfeature/contrib/tools/tck/ContainerizedProviderTckTest.java @@ -20,44 +20,15 @@ * providers. * *

            The HTTP control API described in {@code openapi/control-api.yaml} is the normative contract - * here, and that is the point: another language's TCK drives the same endpoints against the same - * stack and must get the same answers. Substituting a custom in-JVM {@link BackendControl} that - * manipulates an external backend through a side channel bypasses that contract — see - * {@link BackendControl} for why that is not an acceptable adoption path. + * here; a custom in-JVM {@link BackendControl} manipulating an external backend through a side + * channel bypasses it — see {@link BackendControl}. * *

            Provider authors implement three methods, optionally a fourth, and override the defaults their * stack needs. The Compose stack is started once, before the first scenario, and - * stopped after the last one. It is never stopped or restarted in between: Testcontainers cannot - * reliably preserve dynamically mapped host ports across a container restart, so a restart would - * silently invalidate every provider already pointed at the old port. Backend unavailability is - * therefore always simulated inside the running stack through the control API. + * stopped after the last one; it is never restarted in between, and backend unavailability is + * always simulated inside the running stack through the control API. Appendix F says why. * - *

            Example — the entire adoption for a provider with one transport: - * - *

            {@code
            - * public class MyProviderTest extends ContainerizedProviderTckTest {
            - *
            - *     @Override
            - *     public File composeFile() {
            - *         return new File("src/test/resources/tck/docker-compose.yaml");
            - *     }
            - *
            - *     @Override
            - *     public List backendPorts() {
            - *         return Collections.singletonList(8013);
            - *     }
            - *
            - *     @Override
            - *     public FeatureProvider createProvider(BackendEndpoint endpoint) {
            - *         return new MyProvider(endpoint.host(), endpoint.port(8013));
            - *     }
            - *
            - *     @Override
            - *     public FeatureProvider createUnavailableProvider() {
            - *         return new MyProvider("localhost", 9999);
            - *     }
            - * }
            - * }
            + *

            {@code tools/tck/README.md} carries a worked adoption. * * @see ProviderTckTest * @see HttpBackendControl @@ -169,9 +140,8 @@ public Map> additionalPorts() { * Returns the name of the backend configuration used to seed the canonical flag set. * *

            Not to be confused with {@link ProviderTckHarness#configuration()}, which names the mode of - * the provider under test — flagd's RPC resolver versus its in-process one — and is what - * a conformance report's {@code provider.configuration} carries. This one is a name the backend - * understands, passed through to {@code POST /start?config=...}. + * the provider under test. This one is a name the backend understands, passed through + * to {@code POST /start?config=...}. * * @return the backend configuration name passed to {@code POST /start}, {@code default} by * default @@ -199,10 +169,9 @@ public Duration startupTimeout() { *

            Starts the Compose stack, resolves the control API's mapped port and waits for it to * accept commands. * - *

            The await here is the only timing allowance the suite makes, and it is a readiness check - * against the control API itself rather than a guess at how long a backend takes: it probes - * until the control API answers, bounded by {@link #startupTimeout()}. Nothing sleeps after a - * control command — see {@link HttpBackendControl}. + *

            The await here is the only timing allowance the suite makes: a readiness check against the + * control API itself, bounded by {@link #startupTimeout()}, rather than a guess at how long a + * backend takes. Nothing sleeps after a control command — see {@link HttpBackendControl}. */ @Override public final void startSuite() { diff --git a/tools/tck/src/main/java/dev/openfeature/contrib/tools/tck/ControlApi.java b/tools/tck/src/main/java/dev/openfeature/contrib/tools/tck/ControlApi.java index a6d09bf02b..e2340ca065 100644 --- a/tools/tck/src/main/java/dev/openfeature/contrib/tools/tck/ControlApi.java +++ b/tools/tck/src/main/java/dev/openfeature/contrib/tools/tck/ControlApi.java @@ -6,17 +6,9 @@ * Which of the two control contracts a conformance run was conducted under. * *

            Closed on purpose. The report schema's {@code backend.controlApi} is an enum of exactly these - * two values, and a {@code String} here would be wider than the thing it feeds: an implementor could + * two values, so a {@code String} here would be wider than the thing it feeds: an implementor could * answer {@code "HTTP"} and produce a document that fails validation with no local error. There is - * also no third case to leave room for — every {@link BackendControl} is either driving a real - * backend over the normative HTTP endpoints or manipulating an in-process one. - * - *

            {@link BackendControl#controlApi()} has no default for the same reason. The value answers a - * question only the author of a control can answer, it cannot be inferred from the control's - * concrete type once an adopter writes a custom one, and the two runs it distinguishes are not the - * same claim: the same scenarios passing over the control API and passing through in-process - * manipulation of a provider that does have a backend prove different things, and this is the only - * field that separates them. + * no third case to leave room for either. * * @see BackendControl#controlApi() */ @@ -24,10 +16,7 @@ public enum ControlApi { /** * The normative control API: a real backend driven over the HTTP control endpoints in - * {@code openapi/control-api.yaml}. - * - *

            This is what makes a conformance claim portable — another language's TCK drives the same - * endpoints against the same stack and must get the same answers. + * {@code openapi/control-api.yaml}. This is what makes a conformance claim portable. */ HTTP("http"), diff --git a/tools/tck/src/main/java/dev/openfeature/contrib/tools/tck/HttpBackendControl.java b/tools/tck/src/main/java/dev/openfeature/contrib/tools/tck/HttpBackendControl.java index 32ff796cc8..770cbc2f6a 100644 --- a/tools/tck/src/main/java/dev/openfeature/contrib/tools/tck/HttpBackendControl.java +++ b/tools/tck/src/main/java/dev/openfeature/contrib/tools/tck/HttpBackendControl.java @@ -47,18 +47,12 @@ public final class HttpBackendControl implements BackendControl { /** * Creates a control client for a running backend. * - *

            There is no post-command settle, deliberately. A control call returns when the backend has - * acted, because that is what the control API promises: {@code /start}, {@code /change} and - * {@code /reset} all block until the new state is actually being served. A fixed pause after - * every command would cover that window whether or not the promise is kept, which is the - * difference between a suite that can detect a control API regression and one that hides it. If - * a step after a control call is racy, the defect is in the backend's control API and belongs in - * its issue tracker. - * - *

            The promise is about the backend. How long the provider under test takes to notice - * is a property of its transport and is what {@code eventTimeout()} bounds; conflating the two - * makes the provider's detection latency unmeasurable, because the clock would start before - * there is anything to detect. + *

            There is no post-command settle, deliberately. A control call returns when + * the backend has acted, because that is what the control API promises, and + * Appendix + * F says why a suite must not add a pause of its own. If a step after a control call is + * racy, the defect is in the backend's control API. The promise is about the backend; + * how long the provider takes to notice is what {@code eventTimeout()} bounds. * * @param baseUrl the control API base URL, without a trailing slash * @param backendConfiguration the backend configuration name defining the canonical baseline diff --git a/tools/tck/src/main/java/dev/openfeature/contrib/tools/tck/InProcessBackendControl.java b/tools/tck/src/main/java/dev/openfeature/contrib/tools/tck/InProcessBackendControl.java index 77421929c6..26ca4b9145 100644 --- a/tools/tck/src/main/java/dev/openfeature/contrib/tools/tck/InProcessBackendControl.java +++ b/tools/tck/src/main/java/dev/openfeature/contrib/tools/tck/InProcessBackendControl.java @@ -17,20 +17,16 @@ * {@code PROVIDER_CONFIGURATION_CHANGED} through the provider's own event mechanism rather than * through a simulated one. * - *

            This is not a shortcut for providers that do have a backend. Reaching into an - * external backend from inside the JVM — a test-only admin client, a shared database handle, a - * static hook in the provider — produces a suite that passes while proving nothing, because the - * path it exercised is not the path the contract describes. Those providers use - * {@link HttpBackendControl} via {@link ContainerizedProviderTckTest}, and the control API in - * {@code openapi/control-api.yaml} stays the normative contract. See {@link BackendControl}. + *

            This is not a shortcut for providers that do have a backend. Those use + * {@link HttpBackendControl} via {@link ContainerizedProviderTckTest}; {@link BackendControl} says + * why, and links the rule. * *

            Connection control

            * *

            {@link #disconnect()} and {@link #reconnect()} are not implemented, so they inherit the - * interface defaults and throw. An in-memory provider has no connection to lose, - * and pretending otherwise with a no-op would report {@code @stale} scenarios as passed. The - * harness instead leaves {@link Capability#STALE} and {@link Capability#UNAVAILABLE_INIT} - * undeclared, and those scenarios are reported as skipped. + * interface defaults and throw. An in-memory provider has no connection to lose, and a no-op would + * report {@code @stale} scenarios as passed. The harness instead leaves {@link Capability#STALE} and + * {@link Capability#UNAVAILABLE_INIT} undeclared, and those scenarios are skipped. * *

            Ownership of the provider

            * diff --git a/tools/tck/src/main/java/dev/openfeature/contrib/tools/tck/KnownDeviation.java b/tools/tck/src/main/java/dev/openfeature/contrib/tools/tck/KnownDeviation.java index 84de4a966c..f82e8f0524 100644 --- a/tools/tck/src/main/java/dev/openfeature/contrib/tools/tck/KnownDeviation.java +++ b/tools/tck/src/main/java/dev/openfeature/contrib/tools/tck/KnownDeviation.java @@ -10,74 +10,30 @@ * choice, withholding the capability is the honest report and a deviation entry would * assert a defect that does not exist. * - *

            The clearest illustration is one capability withheld twice, for two different - * reasons. A provider with no streaming transport does not declare - * {@code @configuration-change}: it has no way to notice a change and is not pretending otherwise, - * so the skip is the whole report and there is nothing to deviate from. This module's own - * {@code MultiProviderTckTest} withholds that same tag because {@code MultiProvider} extends - * {@code EventProvider} and never subscribes to its children, so a child's - * {@code PROVIDER_CONFIGURATION_CHANGED} is swallowed — - * open-feature/java-sdk#1882, - * a defect with a fix pending rather than a design. One skip, two meanings, and only the second is - * something a reader has to be told. - * - *

            That is the distinction. It is not a rule about which absences may carry a deviation: - * a provider that attempts a behaviour and gets it wrong declares the capability and lets the - * scenario fail — shape 1 below — rather than withholding it. flagd narrowing {@code 0.5} to - * {@code 0} with no error code is that case, and the adoption in this repository declares - * {@code @numeric-coercion} for exactly that reason. An earlier revision of + *

            An entry is legitimate in two shapes, and the first is preferred: declare the + * capability and let the scenario fail, or — only where the provider cannot attempt the behaviour at + * all — withhold it and let the scenarios skip. Withdrawing a capability in order to turn a + * failure into a skip is the failure mode this class exists to prevent. * Appendix - * F illustrated the choice-against-defect distinction with a provider that withheld - * {@code @numeric-coercion} because it narrows, and two of the four implementations followed it into - * the withhold-plus-deviate combination this class exists to discourage. The appendix has since been - * corrected and so has this paragraph. - * - *

            The two legitimate shapes

            - * - *

            A run's results already distinguish them, so the entry does not have to say which: - * - *

              - *
            1. The capability is declared, the scenario runs, and it fails. - * Prefer this. The failure stays visible in the results and the deviation says it is - * known and why. A reader sees both the assertion that broke and the author's account of it. - *
            2. The capability is withheld, and its scenarios skip. Legitimate only when - * the provider cannot attempt the behaviour at all, so running the scenario would - * establish nothing — there is no connection to lose, no structured value to return. The - * deviation then explains the absence, so a reader can tell a defect from a design decision. - *
            - * - *

            Withdrawing a capability in order to turn a failing scenario into a skip is the - * failure mode this field exists to prevent. If the provider attempts the behaviour and gets it - * wrong, shape 1 is the honest report: declare the capability, let the scenario fail, and record the - * deviation beside the failure. + * F states both shapes and the rule behind them. * - *

            What the three fields are for

            + *

            An illustration from this module, because it is the one case where the same absence means two + * things. A provider with no streaming transport does not declare {@code @configuration-change} and + * owes nothing further; {@code MultiProviderTckTest} withholds the same tag because + * {@code MultiProvider} extends {@code EventProvider} and never subscribes to its children, so a + * child's {@code PROVIDER_CONFIGURATION_CHANGED} is swallowed — + * open-feature/java-sdk#1882. + * Only the second is something a reader has to be told. * - *

            {@link #summary} is required. A deviation with no summary records that - * something is wrong without saying what, which is worth less than the bare skip or failure it - * accompanies. - * - *

            {@link #issue} is optional — see {@link #tracked} and {@link #untracked}. - * Naming an untracked defect is still what separates it from a choice; prefer the tracked form as - * soon as there is an issue to point at. - * - *

            {@link #capability} may be {@code null}, when the gap is against a mandatory, ungated scenario - * and so belongs to no capability. It may not name a capability whose scenarios - * were never put to this provider, and there are two of those, refused with different messages: a - * {@linkplain Capability#reserved() reserved} one, where no scenario carries the tag in any - * language, and an {@linkplain Capability#inexpressible() inexpressible} one, where the scenarios - * exist and this SDK cannot ask them. Neither leaves anything to deviate from, and a deviation reads - * as an admission of fault — here it would be a fault nobody committed and nobody could fix. + *

            {@link #summary} is required; {@link #issue} is optional — see {@link #tracked} and + * {@link #untracked}. {@link #capability} may be {@code null}, when the gap is against a mandatory, + * ungated scenario. It may not name a {@linkplain Capability#reserved() reserved} + * or {@linkplain Capability#inexpressible() inexpressible} capability: neither's scenarios were put + * to this provider, so a deviation would assert a fault nobody committed. * *

            Declared by the provider author through {@link ProviderTckHarness#knownDeviations()}, because - * that is the only place that knows. The TCK cannot infer any of this: from the outside, a - * capability the provider chose not to offer and one it cannot honour are the same absence, and a - * failing scenario says nothing about whether its author already knows. - * - *

            Part of the declaration vocabulary rather than of any one consumer of it. This is something an - * adopter writes, alongside {@link ProviderTckHarness#capabilities()}, so it belongs to the - * suite an adopter adopts. Whatever reads the declaration — a machine-readable conformance report, - * a build check, a human — is downstream of it and does not widen it. + * that is the only place that knows. Whatever reads the declaration — a conformance report, a build + * check, a human — is downstream of it and does not widen it. */ @JsonInclude(JsonInclude.Include.NON_NULL) public final class KnownDeviation { @@ -107,17 +63,11 @@ private KnownDeviation(Capability capability, String issue, String summary) { * Refuses a deviation against a capability whose scenarios were never put to this provider. * *

            The same rule as {@link Capability#requireDeclarable}, one step along: a deviation asserts - * that the provider fails to do something it is required to do, so it has to be about a question - * that was actually asked. Two are not, and they are refused separately because they are - * different facts. - * - *

            A {@linkplain Capability#reserved() reserved} capability has no scenarios in any language, - * so there is nothing to deviate from. An {@linkplain Capability#inexpressible() inexpressible} - * one has scenarios that run elsewhere and no way to put them through this SDK — so they were - * never asked of this provider, and a deviation would assert a defect that could not have been - * observed. Declaring it was already refused; recording a deviation against it is the same claim - * by another route, and it is the more dangerous of the two, because a deviation reads as an - * admission of fault and the fault here would belong to nobody. + * that the provider fails something it is required to do, so it has to be about a question that + * was actually asked. Recording a deviation against a capability that could not be declared is + * the same claim by another route, and the more dangerous of the two, because a deviation reads + * as an admission of fault. The two cases are refused separately because they are different + * facts. * *

            Checked when the deviation is constructed rather than when it is read, so an adopter is told * at the point they wrote it and whether or not anything downstream ever reads the declaration. @@ -150,10 +100,9 @@ private static void requireDeviable(Capability capability) { * Records a deviation that is tracked somewhere. The preferred form. * * @param capability the capability the gap is about — declared and failing, or withheld and - * skipped — or {@code null} when the gap is against a mandatory, ungated scenario and so - * belongs to no capability. Must not be {@linkplain Capability#reserved() reserved} or - * {@linkplain Capability#inexpressible() inexpressible}: neither's scenarios were put to - * this provider + * skipped — or {@code null} when the gap belongs to no capability. Must not be + * {@linkplain Capability#reserved() reserved} or + * {@linkplain Capability#inexpressible() inexpressible} * @param issue a URI where the gap is tracked * @param summary what the gap is; required * @return the deviation, ready to declare @@ -171,10 +120,9 @@ public static KnownDeviation tracked(Capability capability, String issue, String * the two happened. Prefer {@link #tracked} as soon as there is an issue to point at. * * @param capability the capability the gap is about — declared and failing, or withheld and - * skipped — or {@code null} when the gap is against a mandatory, ungated scenario and so - * belongs to no capability. Must not be {@linkplain Capability#reserved() reserved} or - * {@linkplain Capability#inexpressible() inexpressible}: neither's scenarios were put to - * this provider + * skipped — or {@code null} when the gap belongs to no capability. Must not be + * {@linkplain Capability#reserved() reserved} or + * {@linkplain Capability#inexpressible() inexpressible} * @param summary what the gap is; required * @return the deviation, ready to declare * @throws IllegalArgumentException if the capability is reserved or inexpressible diff --git a/tools/tck/src/main/java/dev/openfeature/contrib/tools/tck/ProviderTck.java b/tools/tck/src/main/java/dev/openfeature/contrib/tools/tck/ProviderTck.java index 30c99b56a0..a09508905e 100644 --- a/tools/tck/src/main/java/dev/openfeature/contrib/tools/tck/ProviderTck.java +++ b/tools/tck/src/main/java/dev/openfeature/contrib/tools/tck/ProviderTck.java @@ -11,11 +11,9 @@ * Annotation values permit constant concatenation, so {@code ProviderTck.ALL_GLUE + ",com.vendor.steps"} * is legal where a method call is not. * - *

            A class of its own rather than constants on the suite. The values describe the TCK's classpath - * conventions rather than the behaviour of a suite, and things that are not suites read them: a build - * check, a custom launcher, a test that asserts the extension point still works. Putting them on - * {@link ProviderTckTest} would also inherit the whole namespace into every adopter's suite class, - * where {@code GLUE} would show up as a member of their own type. + *

            A class of its own rather than constants on the suite, because things that are not suites read + * them and because putting them on {@link ProviderTckTest} would inherit the whole namespace into + * every adopter's suite class. * *

            Nothing here is a setting. Changing what the suite passes to Cucumber means changing the * annotations on {@link ProviderTckTest}; these constants follow that, they do not drive it. @@ -30,10 +28,10 @@ public final class ProviderTck { * {@link #EXTENSIONS}. * *

            Named for the directory the assets have in Appendix F rather than for Cucumber's habit of - * calling them features. Appendix F identifies a canonical feature by its path relative to the - * asset directory — {@code gherkin/errors.feature} — and a consumer joining results from several - * languages keys on that path, so the directory a runner reports has to be this one. Cucumber's - * {@code classpath:} scheme in front of it is the runner's and is compared past, not stripped. + * calling them features: a consumer joining results from several languages keys on the path a + * canonical feature has relative to the asset directory, so the directory a runner reports has + * to be this one. Cucumber's {@code classpath:} scheme in front of it is the runner's and is + * compared past, not stripped. */ public static final String FEATURES = "gherkin"; diff --git a/tools/tck/src/main/java/dev/openfeature/contrib/tools/tck/ProviderTckHarness.java b/tools/tck/src/main/java/dev/openfeature/contrib/tools/tck/ProviderTckHarness.java index 40b8cb795a..6a2636c0d6 100644 --- a/tools/tck/src/main/java/dev/openfeature/contrib/tools/tck/ProviderTckHarness.java +++ b/tools/tck/src/main/java/dev/openfeature/contrib/tools/tck/ProviderTckHarness.java @@ -18,8 +18,6 @@ * *

              *
            • Your provider talks to an external backend — extend {@link ContainerizedProviderTckTest}. - * It brings the Compose lifecycle, port discovery and {@link HttpBackendControl}, and the - * HTTP control API stays the normative contract for your conformance claim. *
            • Your provider has no backend (in-memory, environment variables, a local file) — extend * {@link ProviderTckTest} directly and supply an in-process {@link BackendControl}. *
            @@ -28,29 +26,7 @@ * {@link java.util.ServiceLoader} as a fallback. Extend one of the two base classes — each is both * the JUnit suite and the harness — and no registration is needed. * - *

            Example — the entire adoption for a backend-less provider: - * - *

            {@code
            - * public class MyProviderTest extends ProviderTckTest {
            - *
            - *     private final InProcessBackendControl control = new InProcessBackendControl();
            - *
            - *     @Override
            - *     public BackendControl backendControl() {
            - *         return control;
            - *     }
            - *
            - *     @Override
            - *     public FeatureProvider createProvider() {
            - *         return control.createProvider();
            - *     }
            - *
            - *     @Override
            - *     public Set capabilities() {
            - *         return EnumSet.of(Capability.EVENTS, Capability.CONFIGURATION_CHANGE, Capability.OBJECT);
            - *     }
            - * }
            - * }
            + *

            {@code tools/tck/README.md} carries a worked adoption for each of the two shapes. * * @see ProviderTckTest * @see ContainerizedProviderTckTest @@ -86,20 +62,14 @@ public interface ProviderTckHarness { /** * Creates a provider pointed at a backend that does not exist. * - *

            Used by the initialisation-failure scenarios, which assert that a provider that cannot - * reach its backend settles into {@code ERROR} and emits {@code PROVIDER_ERROR} rather than - * hanging or throwing out of {@code setProvider}. - * - *

            Point this at a closed port on localhost. Do not point it at the backend under test — that - * must stay up and reachable, and simulated outages belong to {@link BackendControl}. - * - *

            Configure a short connection deadline. The scenario allows a bounded time for the error - * event to arrive, and a provider with a 30-second connect timeout will not make it. + *

            Point this at a closed port on localhost, with a short connection deadline: the scenario + * allows a bounded time for {@code PROVIDER_ERROR} to arrive, and a provider with a 30-second + * connect timeout will not make it. Do not point it at the backend under test — that must stay + * up, and simulated outages belong to {@link BackendControl}. * *

            Defaults to throwing, because a provider with no backend has no way to be unreachable. - * Such a harness leaves {@link Capability#UNAVAILABLE_INIT} undeclared and the scenarios that - * would call this are reported as skipped, so the default is never reached. Reaching it means a - * capability was declared that the harness cannot back up. + * Such a harness leaves {@link Capability#UNAVAILABLE_INIT} undeclared, so the default is never + * reached; reaching it means a capability was declared that the harness cannot back up. * * @return a configured provider that cannot reach a backend */ @@ -122,47 +92,18 @@ default FeatureProvider createUnavailableProvider() { * provider genuinely cannot do — {@link Capability#declarableExcept} is the idiomatic way to say * "everything except". * - *

            Once your provider is attempting a capability, the unit of that decision is the - * scenario, not the tag. - * Appendix - * F states it as: declare a capability when at least one scenario gating it can actually be - * put to your provider, and withhold it only when none can. A tag with three scenarios whose - * backend cannot serve the flag one of them asks for still has two answers to give, and - * withholding it hides both to avoid one failure. - * - *

            The opening clause is a condition, not throat-clearing. This rule decides - * whether the question can be asked; whether your provider owes an answer is the earlier - * question, and {@link KnownDeviation} is where that one is settled. Where the specification - * permits declining — {@code @numeric-coercion} rests on no requirement, so a provider may - * simply not coerce — withholding is the honest report however askable its scenarios are, and - * applying this rule there manufactures a failure out of a permitted choice. The self-tests in - * this module withhold that tag on exactly those grounds. - * - *

            Two consequences follow from the rule itself, and they are easy to get wrong in opposite - * directions: - * - *

              - *
            • A scenario that fails because the backend cannot serve its fixture is not - * a provider defect. Say so in the {@link KnownDeviation#summary} beside it, or the report - * accuses your provider of the stack's gap. - *
            • A capability withheld for a backend gap is temporary in a way one - * withheld by choice is not. Note why it is withheld and what would change the answer, or - * it outlives its reason and no later reader can tell that it should have been revisited. - *
            - * - *

            This does not reach {@link Capability#LARGE_INTEGERS}, which is refused here for a reason - * upstream of any backend — see that constant. + *

            Read Appendix F's + * rules + * for declaring before narrowing this. They are what makes two reports comparable, + * and the two that are most often got wrong in opposite directions are that the unit of the + * decision is the scenario rather than the tag, and that whether your provider owes an + * answer at all is the question that comes first. * *

            Remove only what your provider cannot do. What no Java provider can do is already - * gone: {@link Capability#LARGE_INTEGERS} asks for 2^53 − 1 and {@code Client.getIntegerDetails} - * is a 32-bit {@link Integer} with no room for it, so it is - * {@linkplain Capability#inexpressible() inexpressible} here, absent from - * {@link Capability#declarable()}, and refused if you name it. You do not have to know that, and - * that is the point of it being refused rather than documented. - * - *

            Do not build the set with {@code EnumSet.allOf} or {@code EnumSet.complementOf}. Both - * include the {@linkplain Capability#reserved() reserved} and inexpressible capabilities, which - * no provider may claim; declaring one fails the run. + * gone — see {@link Capability#LARGE_INTEGERS} — and neither that nor a + * {@linkplain Capability#reserved() reserved} capability may be declared, so do not build the + * set with {@code EnumSet.allOf} or {@code EnumSet.complementOf}: both include them and + * declaring one fails the run. * * @return the capabilities this provider supports */ @@ -176,17 +117,10 @@ default Set capabilities() { * *

            Declared so that a consumer can tell a design decision from a defect. The TCK cannot tell * them apart from the outside: a capability the provider chose not to offer and one it cannot - * honour are the same absence, and a failing scenario says nothing about whether its author - * already knows. Only the provider author can, so only the provider author can say. - * - *

            Empty by default, which is silence rather than a claim. + * honour are the same absence, and only the provider author knows which happened. * - *

            An entry is legitimate in two shapes, and the first is preferred: declare the - * capability, let the scenario fail, and record the deviation beside the failure. - * Withholding the capability so that its scenarios skip is for the case where the provider - * cannot attempt the behaviour at all — withdrawing one in order to turn a failure into - * a skip is the failure mode this method exists to prevent. See {@link KnownDeviation} for the - * full rule, including what counts as a requirement to deviate from. + *

            Empty by default, which is silence rather than a claim. See {@link KnownDeviation} for what + * counts as a requirement to deviate from and which of the two legitimate shapes to reach for. * * @return the deviations this provider acknowledges, empty by default */ @@ -240,14 +174,11 @@ default void stopSuite() { /** * Returns how long to wait for a provider event to arrive. * - *

            This is the single most important knob for a provider author, because providers observe - * backend changes on wildly different timescales. A streaming provider sees a configuration - * change in milliseconds; a provider that polls every 30 seconds may need most of a poll - * interval before it notices. Set this to comfortably exceed your worst-case detection latency, - * or the suite will report timeouts that are really just impatience. - * - *

            Individual scenarios can tighten this with the explicit {@code within {int}ms} step, which - * always wins over this value. + *

            The single most important knob for a provider author, because providers observe backend + * changes on wildly different timescales — a streaming provider in milliseconds, one that polls + * every 30 seconds in most of a poll interval. Set it to comfortably exceed your worst-case + * detection latency, or the suite reports timeouts that are really just impatience. A scenario + * can tighten it with the explicit {@code within {int}ms} step, which always wins. * * @return the default event await timeout, 12 seconds by default */ diff --git a/tools/tck/src/main/java/dev/openfeature/contrib/tools/tck/ProviderTckTest.java b/tools/tck/src/main/java/dev/openfeature/contrib/tools/tck/ProviderTckTest.java index 2c86bcde36..7773f7e525 100644 --- a/tools/tck/src/main/java/dev/openfeature/contrib/tools/tck/ProviderTckTest.java +++ b/tools/tck/src/main/java/dev/openfeature/contrib/tools/tck/ProviderTckTest.java @@ -21,26 +21,18 @@ * such as {@link InProcessBackendControl}. * *

            When your provider talks to an external backend, extend {@link ContainerizedProviderTckTest} - * instead. It adds the Compose stack lifecycle, port discovery and {@link HttpBackendControl}, and - * the HTTP control API in {@code openapi/control-api.yaml} remains the normative contract for that - * conformance claim. In-process control is for backend-less providers only; an external backend - * driven through a custom in-JVM {@code BackendControl} bypasses that contract and proves nothing. + * instead. In-process control is for backend-less providers only — see {@link BackendControl}. * *

            Serial execution

            * - *

            Scenarios run serially, and this class enforces that rather than merely - * asking for it. Backend state — which flags are seeded, whether the backend is reachable — is - * global to the suite, so concurrent scenarios corrupt each other: one scenario's reconnect - * restarts the backend underneath another's disconnect assertion. The failure looks like a flaky - * provider rather than a broken test, which makes it expensive to diagnose. + *

            Scenarios run serially, and this class enforces that rather than merely asking + * for it: it pins {@code cucumber.execution.parallel.enabled=false} here, where it overrides any + * {@code junit-platform.properties} the consuming module ships. Several providers already enable + * Cucumber parallelism for their own suites, and inheriting that setting silently breaks the TCK — + * backend state is global to the suite, so the failure looks like a flaky provider. * - *

            The suite therefore pins {@code cucumber.execution.parallel.enabled=false} here, where it - * overrides any {@code junit-platform.properties} the consuming module happens to ship. Several - * providers already enable Cucumber parallelism for their own suites, and inheriting that setting - * silently breaks the TCK. - * - *

            Note this class carries no lifecycle code of its own. Provider registration, event awaiting - * and backend manipulation are owned by the step definitions in + *

            This class carries no lifecycle code of its own. Provider registration, event awaiting and + * backend manipulation are owned by the step definitions in * {@code dev.openfeature.contrib.tools.tck.steps}, which reach the harness and its * {@link BackendControl} through {@link TckRuntime}. * @@ -50,28 +42,13 @@ * proprietary evaluation mode — puts feature files in {@code src/test/resources/extensions/} and * step definitions in the package {@code openfeature.tck.extensions}, and writes no annotations. * Both are selected here, so the extra scenarios run inside this suite: same backend lifecycle, same - * {@code @BeforeAll}, same {@link BackendControl}. The alternative — a second suite of one's own — - * is a second backend lifecycle to start and a second set of runner configuration to keep in step - * with this one. - * - *

            The extension directory is not {@code gherkin/} and is not a subdirectory of it, for - * a measured reason. Two classpath roots that contain the same directory are scanned additively, but - * two that contain the same directory and the same file name are not: one wins silently and - * the other file is never read. An adopter who put {@code gherkin/errors.feature} in their test - * resources would replace a canonical feature with their own and see the suite pass — a conformance - * suite reporting success for questions it never asked. {@code gherkin/} and {@code extensions/} - * being two distinct directories removes the collision rather than documenting it. - * - *

            Both names come from Appendix F, which identifies a canonical feature by its path relative to - * the asset directory and reserves {@code extensions/} for an adopter's own. That is what makes the - * URIs this suite reports — {@code classpath:gherkin/errors.feature}, - * {@code classpath:extensions/fractional.feature} — partition the same way in every language. + * {@link BackendControl}. The alternative, a second suite of one's own, is a second backend + * lifecycle to start and a second set of runner configuration to keep in step with this one. * - *

            The directory is shipped inside this JAR holding nothing but a README, because - * {@link SelectClasspathResource} on a resource that exists on no classpath root is a discovery - * error, not an empty selection. An adopter who adds nothing therefore still resolves it. The - * extension glue package costs nothing when unused either: Cucumber tolerates a glue package that - * does not exist. + *

            The extension directory must not be {@code gherkin/} nor a subdirectory of it — see + * {@link ProviderTck#EXTENSIONS} for the classpath collision that rules out. It is shipped inside + * this JAR holding nothing but a README, because {@link SelectClasspathResource} on a resource that + * exists on no classpath root is a discovery error rather than an empty selection. * *

            Every value these annotations carry is named in {@link ProviderTck}. An adopter who does write a * {@code @ConfigurationParameter} of their own composes from those constants — diff --git a/tools/tck/src/main/java/dev/openfeature/contrib/tools/tck/steps/ProviderSteps.java b/tools/tck/src/main/java/dev/openfeature/contrib/tools/tck/steps/ProviderSteps.java index 1fb5624017..86093903ba 100644 --- a/tools/tck/src/main/java/dev/openfeature/contrib/tools/tck/steps/ProviderSteps.java +++ b/tools/tck/src/main/java/dev/openfeature/contrib/tools/tck/steps/ProviderSteps.java @@ -209,15 +209,11 @@ public void theProviderIsShutDown() { /** * Initialises the provider under test again by calling its own {@code initialize()} directly. * - *

            Requirement 2.5.2 says a provider SHOULD revert to its uninitialised state after - * shutdown, and its supporting text says some providers MAY allow reinitialisation from - * it. Reuse is therefore permitted rather than required, and the one scenario using this step is - * gated on {@link dev.openfeature.contrib.tools.tck.Capability#REINITIALIZATION} - * accordingly — a provider that discards its client on shutdown and never rebuilds it is making - * a choice the specification offers, not exhibiting a defect. The SDK still holds the provider - * as {@code READY}, because it was never told about the shutdown, so the evaluation that follows - * this step reaches the re-initialised provider through the scenario's client with nothing in - * between. + *

            The one scenario using this step is gated on + * {@link dev.openfeature.contrib.tools.tck.Capability#REINITIALIZATION}, which says why reuse is + * permitted rather than required. The SDK still holds the provider as {@code READY}, because it + * was never told about the shutdown, so the evaluation that follows this step reaches the + * re-initialised provider through the scenario's client with nothing in between. * *

            The scenario's evaluation context is passed, which is empty unless a context step added to * it. Exceptions are recorded rather than propagated, the same way an evaluation's are. From 1c7e1891e909ad5e2d973a5b6b02ebb00c4b64d1 Mon Sep 17 00:00:00 2001 From: Simon Schrottner Date: Mon, 14 Sep 2026 08:58:11 +0200 Subject: [PATCH 51/55] docs(tck): @disabled-flags is gated on what the response says, not on where evaluation happens The paragraph this corrects was the one piece of @disabled-flags reasoning Appendix F does not carry, so the doc-placement pass kept it -- and then the OFREP adoption in this repository turned out to disprove its worked example. It said a provider whose backend decides cannot hold the tag, because the caller's default never leaves the process and the server has nothing to echo back, and it named OFREP as the case. OFREP is the counter-example: codeDefaultFlag is a success carrying a reason and no value, which tells the provider to use the code default, and flagd answers a disabled flag in exactly that shape. So the gate is whether the provider is told the flag was deliberately disabled, which is a question about the response rather than about where evaluation happens. OfrepTest has the protocol citation and the probed response and is now cited from here. Comments only. 246 tests / 43 skipped, unchanged. Signed-off-by: Simon Schrottner --- .../contrib/tools/tck/Capability.java | 22 ++++++++++++------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/tools/tck/src/main/java/dev/openfeature/contrib/tools/tck/Capability.java b/tools/tck/src/main/java/dev/openfeature/contrib/tools/tck/Capability.java index 2967ae6699..76d2cf464c 100644 --- a/tools/tck/src/main/java/dev/openfeature/contrib/tools/tck/Capability.java +++ b/tools/tck/src/main/java/dev/openfeature/contrib/tools/tck/Capability.java @@ -94,14 +94,20 @@ public enum Capability { /** * Provider resolves a flag disabled in the management system to the caller's default value. * - *

            Gated because the answer is a property of architecture rather than of quality, - * which is the part of this tag no other document states. Where the substitution happens decides - * whether it can happen at all: a provider that evaluates locally — flagd's RPC and in-process - * resolvers, an in-memory provider — holds the caller's default in its own hands and can return - * it, while a provider whose backend decides, one speaking OFREP for instance, cannot, because - * the default never leaves the process and the server has nothing to echo back. The same flag - * cannot behave the same way across those two designs and neither of them is wrong, so - * withholding this needs no {@link KnownDeviation}. + *

            Gated on whether the provider is told the flag was deliberately disabled, + * which is the part of this tag no other document states. A provider that evaluates locally — + * flagd's resolvers, an in-memory provider — reads the state itself. A provider whose backend + * decides can only substitute the caller's default if the response distinguishes a disabled flag + * from an absent one; where it does not, the provider has nothing to act on, and withholding + * this needs no {@link KnownDeviation}. + * + *

            Do not assume a remote-evaluation protocol is in that position. The + * obvious reading — the caller's default never leaves the process, so the server has nothing to + * echo back — is wrong for at least one protocol: OFREP's {@code codeDefaultFlag} is a success + * carrying a {@code reason} and no {@code value}, which tells the provider to use the code + * default. {@code OfrepTest} in {@code providers/ofrep} has the protocol citation and the probed + * response. Check what the response actually carries before concluding a provider cannot hold + * this tag. * *

            The value is asserted here and not the reason, because the value rests on a {@code MUST} * and the reason on a {@code SHOULD} that permits any string. Reason {@code DISABLED} is pinned From 29582eaf4ff8f3d02530f734c06e04db3d6b96b0 Mon Sep 17 00:00:00 2001 From: Simon Schrottner Date: Tue, 15 Sep 2026 22:13:47 +0200 Subject: [PATCH 52/55] feat(tck): add @string-typing, and re-pin the canonical assets to d47a66eb The pin moves four scenarios out of the mandatory wrong-type matrix and onto a new capability tag. A non-string flag requested as a string is no longer something every provider owes TYPE_MISMATCH for: every value has a string representation, so a backend that stores flag values as strings satisfies the string accessor for every flag and has no mismatch to report. The only normative statement about value type is Requirement 1.3.4, a SHOULD, and on the client rather than the provider. The capability and the pin arrive together because neither is valid alone. A capability whose scenarios have not yet been packaged gates nothing, which CanonicalTagCoverageTest fails; the assets alone would leave four scenarios carrying a tag no Capability matches, and CapabilityGate ignores an unknown tag, so they would silently stay mandatory for every adoption instead of breaking loudly. The three self-tests declare it rather than inheriting a skip. ControllableProvider and the SDK's InMemoryProvider keep their types apart, so all four scenarios can be put to them and all four pass -- each suite skips four fewer than it would have with the tag withheld, and none fails. PINNED_REVISION and PINNED_DIGEST move in this commit too, as the class requires: the assets are copied out of the submodule at generate-resources, and the digest is what catches a gitlink whose working tree has not followed. Signed-off-by: Simon Schrottner --- tools/tck/README.md | 6 ++-- tools/tck/spec | 2 +- .../contrib/tools/tck/Capability.java | 36 +++++++++++++++++++ .../tools/tck/CanonicalAssetDigestTest.java | 4 +-- .../tck/ControllableProviderTckTest.java | 1 + .../tools/tck/InMemoryProviderTckTest.java | 1 + .../tools/tck/MultiProviderTckTest.java | 1 + 7 files changed, 45 insertions(+), 6 deletions(-) diff --git a/tools/tck/README.md b/tools/tck/README.md index 2ec7576ea5..8d7154433a 100644 --- a/tools/tck/README.md +++ b/tools/tck/README.md @@ -199,9 +199,9 @@ below is a member of `Capability`. | `REINITIALIZATION` | `@reinitialization` | | `NUMERIC_COERCION` | `@numeric-coercion` | | `EVENTS` | `@events` | | `TARGETING` | `@targeting` | | `STALE` | `@stale` | | `STANDARD_REASONS` | `@standard-reasons` | -| `CONFIGURATION_CHANGE` | `@configuration-change` | | `LARGE_INTEGERS` | `@large-integers` ¹ | -| `OBJECT` | `@object` | | `CACHING` | `@caching` ² | -| `VARIANTS` | `@variants` | | | | +| `CONFIGURATION_CHANGE` | `@configuration-change` | | `STRING_TYPING` | `@string-typing` | +| `OBJECT` | `@object` | | `LARGE_INTEGERS` | `@large-integers` ¹ | +| `VARIANTS` | `@variants` | | `CACHING` | `@caching` ² | | `DISABLED_FLAGS` | `@disabled-flags` | | | | ¹ not declarable in Java    ² reserved, not declarable diff --git a/tools/tck/spec b/tools/tck/spec index aa2ad24f5a..d47a66ebb9 160000 --- a/tools/tck/spec +++ b/tools/tck/spec @@ -1 +1 @@ -Subproject commit aa2ad24f5a14ae2b5756df0b6d23f493f39507e6 +Subproject commit d47a66ebb9500706e5bded7799d0e49aa1e86bfd diff --git a/tools/tck/src/main/java/dev/openfeature/contrib/tools/tck/Capability.java b/tools/tck/src/main/java/dev/openfeature/contrib/tools/tck/Capability.java index 76d2cf464c..50f3dc9e24 100644 --- a/tools/tck/src/main/java/dev/openfeature/contrib/tools/tck/Capability.java +++ b/tools/tck/src/main/java/dev/openfeature/contrib/tools/tck/Capability.java @@ -139,6 +139,42 @@ public enum Capability { */ NUMERIC_COERCION("@numeric-coercion"), + /** + * Provider reports {@code TYPE_MISMATCH} for a non-string flag requested as a string, rather + * than the value's string representation. + * + *

            The same gap as {@link #NUMERIC_COERCION}, one type further out, and gated for a stronger + * reason: every value has a string representation, so a backend that stores flag values as + * strings satisfies the string accessor for every flag and has no mismatch to report. + * Its flags are strings, and + * Requirement + * 2.2.3 asks it to populate {@code value} with the resolved flag value, which it did. + * + *

            Nothing in the specification contradicts that, because the specification never says what + * the type of a flag value is. {@code TYPE_MISMATCH} appears once, as a row in + * the error code + * table, and no requirement obliges anyone to raise it; the only normative statement about + * value type is + * Requirement + * 1.3.4, a {@code SHOULD} and on the client rather than the provider. So + * a provider that withholds this tag is not violating the specification and + * owes no {@link KnownDeviation} — the same instrument, and the same reasoning, as the numeric + * rule it sits beside. + * + *

            Gates four scenarios in {@code gherkin/errors.feature}, which were mandatory until + * specification revision {@code d47a66eb} moved them out: {@code boolean-flag}, + * {@code integer-flag} and {@code float-flag} requested as strings, and {@code object-flag} + * requested as a string. That last one carries {@link #OBJECT} as well, since a provider with no + * structured values cannot be asked the question at all, so withholding either tag skips it. + * + *

            Java-specific consequence: {@code Client.getStringDetails} is the one accessor every + * backend can satisfy, so this tag is a claim about the backend's typing rather than + * about anything the SDK does. A provider over a typed backend — flagd's resolvers, OFREP — + * declares it; one over a backend that stores values as strings withholds it with the reason + * recorded. + */ + STRING_TYPING("@string-typing"), + /** * Provider resolves integers up to 2^53 − 1 exactly. * diff --git a/tools/tck/src/test/java/dev/openfeature/contrib/tools/tck/CanonicalAssetDigestTest.java b/tools/tck/src/test/java/dev/openfeature/contrib/tools/tck/CanonicalAssetDigestTest.java index ae3145e926..eaca1e21f7 100644 --- a/tools/tck/src/test/java/dev/openfeature/contrib/tools/tck/CanonicalAssetDigestTest.java +++ b/tools/tck/src/test/java/dev/openfeature/contrib/tools/tck/CanonicalAssetDigestTest.java @@ -80,10 +80,10 @@ class CanonicalAssetDigestTest { *

            Must equal {@code git -C tools/tck/spec rev-parse HEAD}, and is the value the report * branch publishes as the source of a run's scenarios. */ - static final String PINNED_REVISION = "aa2ad24f5a14ae2b5756df0b6d23f493f39507e6"; + static final String PINNED_REVISION = "d47a66ebb9500706e5bded7799d0e49aa1e86bfd"; /** SHA-256 of the three asset trees at {@link #PINNED_REVISION}, as {@link #digest} computes it. */ - static final String PINNED_DIGEST = "a7c74fbe178cf5a9937be8d26e098bc4d6f4b723991a77e0e73d0a330a299c94"; + static final String PINNED_DIGEST = "f7e9b9e63138b19f11f795095bb9e5c78801989d02822789e9202f777af319e9"; /** The generated resource directories, in the order they are digested. */ private static final List ASSET_DIRECTORIES = Arrays.asList("flags", "gherkin", "openapi"); diff --git a/tools/tck/src/test/java/dev/openfeature/contrib/tools/tck/ControllableProviderTckTest.java b/tools/tck/src/test/java/dev/openfeature/contrib/tools/tck/ControllableProviderTckTest.java index 5b338fde2a..9a7b806bef 100644 --- a/tools/tck/src/test/java/dev/openfeature/contrib/tools/tck/ControllableProviderTckTest.java +++ b/tools/tck/src/test/java/dev/openfeature/contrib/tools/tck/ControllableProviderTckTest.java @@ -121,6 +121,7 @@ public Set capabilities() { Capability.OBJECT, Capability.VARIANTS, Capability.DISABLED_FLAGS, + Capability.STRING_TYPING, Capability.STANDARD_REASONS); } } diff --git a/tools/tck/src/test/java/dev/openfeature/contrib/tools/tck/InMemoryProviderTckTest.java b/tools/tck/src/test/java/dev/openfeature/contrib/tools/tck/InMemoryProviderTckTest.java index 9b03cdc249..257e9a7a64 100644 --- a/tools/tck/src/test/java/dev/openfeature/contrib/tools/tck/InMemoryProviderTckTest.java +++ b/tools/tck/src/test/java/dev/openfeature/contrib/tools/tck/InMemoryProviderTckTest.java @@ -121,6 +121,7 @@ public Set capabilities() { Capability.OBJECT, Capability.VARIANTS, Capability.DISABLED_FLAGS, + Capability.STRING_TYPING, Capability.STANDARD_REASONS); } } diff --git a/tools/tck/src/test/java/dev/openfeature/contrib/tools/tck/MultiProviderTckTest.java b/tools/tck/src/test/java/dev/openfeature/contrib/tools/tck/MultiProviderTckTest.java index 166752ae78..f6dd91759a 100644 --- a/tools/tck/src/test/java/dev/openfeature/contrib/tools/tck/MultiProviderTckTest.java +++ b/tools/tck/src/test/java/dev/openfeature/contrib/tools/tck/MultiProviderTckTest.java @@ -108,6 +108,7 @@ public Set capabilities() { Capability.OBJECT, Capability.VARIANTS, Capability.DISABLED_FLAGS, + Capability.STRING_TYPING, Capability.STANDARD_REASONS); } } From 337a531d256ebf42271cca8d32aca89b7cafda16 Mon Sep 17 00:00:00 2001 From: Simon Schrottner Date: Wed, 16 Sep 2026 08:48:09 +0200 Subject: [PATCH 53/55] feat(tck): re-pin to bda599f1, and fail a capability tag the vocabulary does not know MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Appendix F split @string-typing in two. The outline now covers boolean-flag and integer-flag only; float-flag and object-flag moved behind a new @fully-typed-values, because one tag over all four cases let a real defect be published as a permitted absence — a Flagsmith-backed store records no native float or structure, so every provider stringifies those two, while it does record booleans and integers and only one language failed them. Under one tag that language withheld and the suite went quiet on its bug. FULLY_TYPED_VALUES joins Capability beside STRING_TYPING. The three self-test harnesses declare it explicitly rather than inheriting it: the SDK's in-memory stores are fully typed, and without it the float and object scenarios would have become skips in suites that were passing them, which is exactly the coverage loss the split is meant to expose. The same revision made three run-integrity rules normative, one of which was this package's own gap: CapabilityGate did nothing but `continue` on a tag Capability.fromTag could not resolve. An unknown tag gates nothing, so its scenarios stay mandatory for every adopter — the suite keeps demanding the old behaviour and the only symptom is the one provider that legitimately cannot support the capability failing while the rest stay green. So requireKnownVocabulary fails the run for it, and CanonicalTagCoverageTest fails this build for it. The run-time check is scoped to gherkin/ by the scenario's URI: the extension point exists so an adopter can carry tags this vocabulary does not know, and EXTENSIONS is a different directory from FEATURES precisely so the two can be told apart. Both halves are kept because an adopter can shadow the packaged gherkin/ on another classpath root, which this build's own tests cannot see. The pin moves the submodule gitlink, PINNED_REVISION and PINNED_DIGEST together, as the class comment requires. Signed-off-by: Simon Schrottner --- tools/tck/README.md | 22 +++- tools/tck/spec | 2 +- .../contrib/tools/tck/Capability.java | 58 +++++++-- .../contrib/tools/tck/CapabilityGate.java | 120 +++++++++++++++++- .../tools/tck/steps/ProviderSteps.java | 17 ++- .../tools/tck/CanonicalAssetDigestTest.java | 4 +- .../tools/tck/CanonicalTagCoverageTest.java | 28 +++- .../tck/ControllableProviderTckTest.java | 1 + .../tools/tck/InMemoryProviderTckTest.java | 1 + .../tools/tck/MultiProviderTckTest.java | 1 + 10 files changed, 234 insertions(+), 20 deletions(-) diff --git a/tools/tck/README.md b/tools/tck/README.md index 8d7154433a..242d8009b1 100644 --- a/tools/tck/README.md +++ b/tools/tck/README.md @@ -202,7 +202,7 @@ below is a member of `Capability`. | `CONFIGURATION_CHANGE` | `@configuration-change` | | `STRING_TYPING` | `@string-typing` | | `OBJECT` | `@object` | | `LARGE_INTEGERS` | `@large-integers` ¹ | | `VARIANTS` | `@variants` | | `CACHING` | `@caching` ² | -| `DISABLED_FLAGS` | `@disabled-flags` | | | | +| `DISABLED_FLAGS` | `@disabled-flags` | | `FULLY_TYPED_VALUES` | `@fully-typed-values` | ¹ not declarable in Java    ² reserved, not declarable @@ -238,6 +238,17 @@ Java provider can be asked"*; the 32-bit precision scenario is untagged and alwa capability is a different thing and its reason says so. Neither refusal is a defect, and neither needs a `KnownDeviation`. +**`STRING_TYPING` and `FULLY_TYPED_VALUES` are a pair, and the narrower one is a claim about the +backend.** `@string-typing` asks whether a boolean or an integer flag requested as a string is a +`TYPE_MISMATCH` rather than its string representation; `@fully-typed-values` asks the same of a float +and of a structure. Every scenario the second gates carries the first as well, so declare the second +only alongside the first — and expect to withhold it alone, because a store that records booleans and +integers natively while keeping floats and structures as text can answer two of the four cases and +not the other two. It was a single tag until specification revision `bda599f1`; the split exists +because one tag let a real defect in one language be published as a permitted absence, and Appendix F +carries the measurement that showed it. Neither tag rests on a `MUST`, so withholding either needs no +`KnownDeviation`. + ### Known deviations **A `knownDeviations` entry says: this provider fails to do something it is required to do.** The @@ -396,6 +407,15 @@ so a file present in the old pin and not the new one cannot survive; and `Canoni fails the build by digest over all three directories, which is the only one of the three that catches a pin whose sole change is *content*. +**A fourth check faces the other way: a pin that arrives with a capability tag this module has not +learned.** An unknown tag gates nothing, so its scenarios stay mandatory for *every* adopter — the +suite does not report a new capability, it quietly keeps demanding the old behaviour, and the only +symptom is the one provider that legitimately cannot support it failing while the rest stay green. +`CanonicalTagCoverageTest` fails this build for it, and `CapabilityGate.requireKnownVocabulary` fails +an adopter's run for it, which is where Appendix F asks the check to be in force. It applies to +`gherkin/` only: a feature file of your own under `extensions/` is expected to carry tags this +vocabulary does not know. + **The step vocabulary** is inherited from the [flagd test harness](https://github.com/open-feature/test-harness) wherever it was already provider-neutral, so flagd's feature files ported with a near-zero diff; only `Given a stable flagd provider` and `Given a unavailable flagd provider` were renamed, to drop the diff --git a/tools/tck/spec b/tools/tck/spec index d47a66ebb9..bda599f1db 160000 --- a/tools/tck/spec +++ b/tools/tck/spec @@ -1 +1 @@ -Subproject commit d47a66ebb9500706e5bded7799d0e49aa1e86bfd +Subproject commit bda599f1db440aa8d395d1d3af7b9b3cc3103b98 diff --git a/tools/tck/src/main/java/dev/openfeature/contrib/tools/tck/Capability.java b/tools/tck/src/main/java/dev/openfeature/contrib/tools/tck/Capability.java index 50f3dc9e24..1662ef9968 100644 --- a/tools/tck/src/main/java/dev/openfeature/contrib/tools/tck/Capability.java +++ b/tools/tck/src/main/java/dev/openfeature/contrib/tools/tck/Capability.java @@ -140,8 +140,8 @@ public enum Capability { NUMERIC_COERCION("@numeric-coercion"), /** - * Provider reports {@code TYPE_MISMATCH} for a non-string flag requested as a string, rather - * than the value's string representation. + * Provider reports {@code TYPE_MISMATCH} for a boolean or integer flag requested as a string, + * rather than the value's string representation. * *

            The same gap as {@link #NUMERIC_COERCION}, one type further out, and gated for a stronger * reason: every value has a string representation, so a backend that stores flag values as @@ -159,13 +159,22 @@ public enum Capability { * 1.3.4, a {@code SHOULD} and on the client rather than the provider. So * a provider that withholds this tag is not violating the specification and * owes no {@link KnownDeviation} — the same instrument, and the same reasoning, as the numeric - * rule it sits beside. - * - *

            Gates four scenarios in {@code gherkin/errors.feature}, which were mandatory until - * specification revision {@code d47a66eb} moved them out: {@code boolean-flag}, - * {@code integer-flag} and {@code float-flag} requested as strings, and {@code object-flag} - * requested as a string. That last one carries {@link #OBJECT} as well, since a provider with no - * structured values cannot be asked the question at all, so withholding either tag skips it. + * rule it sits beside. The open question is + * open-feature/spec#433. + * + *

            Boolean and integer only. Gates the two rows of the + * {@code gherkin/errors.feature} Scenario Outline — {@code boolean-flag} and + * {@code integer-flag} requested as strings. The float and structured cases carried this tag + * too until specification revision {@code bda599f1} moved them behind + * {@link #FULLY_TYPED_VALUES}; they still carry this one as well, so withholding it skips all + * four and declaring it alone runs only these two. {@link #FULLY_TYPED_VALUES} says why the + * one tag became two. + * + *

            These two are the rows a partially typed backend can still answer: a boolean and + * an integer are types such a store records natively, so failing them is the provider's own + * doing rather than the backend's shape. That is what makes this tag worth asking separately — + * a Flagsmith-backed provider records no native float or structure and yet does record these + * two, and Java answers both. * *

            Java-specific consequence: {@code Client.getStringDetails} is the one accessor every * backend can satisfy, so this tag is a claim about the backend's typing rather than @@ -175,6 +184,37 @@ public enum Capability { */ STRING_TYPING("@string-typing"), + /** + * Backend records a native type for float and structured values too, so the string-typing + * question can be asked of them. + * + *

            Strictly narrower than {@link #STRING_TYPING} and always declared alongside it: the two + * scenarios this gates — {@code float-flag} and {@code object-flag} requested as strings — + * carry both tags, so withholding either skips them. Declaring this one without + * {@code STRING_TYPING} claims something no scenario will check. + * + *

            Why the split exists, since a single tag looks simpler. It was a single + * tag, over all four cases, until specification revision {@code bda599f1}. Measurement across + * three languages against one Flagsmith backend showed the problem: {@code float-flag} and + * {@code object-flag} were stringified by every provider, because that store records no native + * float or structure type and no provider over it can report a mismatch — a permitted absence. + * {@code boolean-flag} and {@code integer-flag} were not: the store does record those two, Go + * and Java answered them, and JavaScript returned {@code "true"} and {@code "10"} because of + * its own code. Under one tag that provider withholds, and a real defect is published as a + * permitted absence — the suite goes quiet on a bug. Appendix F states the general rule: a + * capability coarser than the variation providers actually show hides defects inside permitted + * absences. + * + *

            So the unit of declaration is the question the backend can be asked, not the + * accessor the SDK offers. Withholding this while declaring {@code STRING_TYPING} is the + * expected combination for a partially typed store, and it needs no {@link KnownDeviation} for + * the reason {@code STRING_TYPING} gives: the behaviour is not required. + * + *

            Nothing about this is Java-specific. {@code Client.getStringDetails} asks all four cases + * equally well; what differs is whether the backend has a type to mismatch against. + */ + FULLY_TYPED_VALUES("@fully-typed-values"), + /** * Provider resolves integers up to 2^53 − 1 exactly. * diff --git a/tools/tck/src/main/java/dev/openfeature/contrib/tools/tck/CapabilityGate.java b/tools/tck/src/main/java/dev/openfeature/contrib/tools/tck/CapabilityGate.java index 94aa5fc33a..a503fbbbd5 100644 --- a/tools/tck/src/main/java/dev/openfeature/contrib/tools/tck/CapabilityGate.java +++ b/tools/tck/src/main/java/dev/openfeature/contrib/tools/tck/CapabilityGate.java @@ -1,5 +1,6 @@ package dev.openfeature.contrib.tools.tck; +import java.net.URI; import java.util.ArrayList; import java.util.Collection; import java.util.List; @@ -27,6 +28,10 @@ * provider: a capability this SDK {@linkplain Capability#inexpressible() cannot express}. Its skip * reason is deliberately different from an undeclared capability's, because a report's reader has * to be able to tell them apart. + * + *

            The fourth is the second one's own direction reversed, and fails too: a canonical + * scenario carrying a tag this vocabulary does not know at all. See + * {@link #requireKnownVocabulary}. */ public final class CapabilityGate { @@ -58,11 +63,35 @@ private CapabilityGate() {} * @throws TestAbortedException if a tag gates an inexpressible or an undeclared capability */ public static void requireDeclared(Collection tags, Set declared) { - requireNoExpiredReservation(tags); + requireDeclared(null, tags, declared); + } + /** + * Applies every gate rule to a scenario about to run, knowing where the scenario came from. + * + *

            The overload {@link #requireDeclared(Collection, Set)} calls into this one with no source, + * which is every rule except {@link #requireKnownVocabulary} — that one is the only rule whose + * answer depends on whether the scenario is canonical, and it cannot be applied to a scenario + * of unknown origin without failing an adopter's own feature file for using its own tag. + * + * @param source the scenario's feature file, as Cucumber reports it, or {@code null} if unknown + * @param tags the scenario's Gherkin tags, including the leading at-sign + * @param declared the capabilities the provider declares + * @throws IllegalStateException if a tag names a reserved capability, or if a canonical + * scenario carries a tag this vocabulary does not know + * @throws TestAbortedException if a tag gates an inexpressible or an undeclared capability + */ + public static void requireDeclared(URI source, Collection tags, Set declared) { + requireNoExpiredReservation(tags); + requireKnownVocabulary(source, tags); for (String tag : tags) { Optional found = Capability.fromTag(tag); if (!found.isPresent()) { + // Not a capability tag as far as this vocabulary is concerned, so it gates nothing + // here. Skipping it is right for an adopter's own tag under extensions/ and wrong + // for a canonical one, and the two are told apart by requireKnownVocabulary above + // rather than here — by the time this loop runs, an unknown canonical tag has + // already failed the scenario. continue; } Capability capability = found.get(); @@ -80,6 +109,95 @@ public static void requireDeclared(Collection tags, Set decl } } + /** + * Fails the run if a canonical scenario carries a tag this vocabulary does not know. + * + *

            {@link #requireNoExpiredReservation} run backwards. That one catches a tag this + * implementation knows and says nothing carries; this one catches a tag something carries and + * this implementation does not know. Both end in a scenario whose gating is wrong in a way no + * result reports, and this direction is the easier of the two to leave out — an unknown + * tag gates nothing, so its scenarios stay mandatory for every adopter. A suite that + * has not learned a new capability does not report a new capability; it silently keeps + * demanding the old behaviour, and the symptom is a provider that legitimately withholds the + * capability showing unexplained failures while every other provider stays green. Nothing in + * the results says why. All four reference implementations ignored an unknown tag rather than + * failing before Appendix F made this normative, and this package was one of them: the loop in + * {@link #requireDeclared} did nothing but {@code continue}. + * + *

            Canonical scenarios only, and that restriction is not a weakening. The + * extension point exists so an adopter can add feature files under + * {@link ProviderTck#EXTENSIONS} with tags of its own, which this vocabulary is not supposed to + * know — failing those would make the extension point unusable, and {@code DeclarationApiTest} + * pins that a tag gating nothing is tolerated. What distinguishes them is the directory: + * {@link ProviderTck#FEATURES} holds the canonical set and nothing else, which is why + * {@code EXTENSIONS} is deliberately a different name rather than a subdirectory of it. A tag + * in there that resolves to nothing is a capability this implementation has not + * learned. + * + *

            Checked at run time as well as in this artifact's own tests, and the run-time half is not + * redundant: {@code CanonicalTagCoverageTest} reads the assets packaged in this build, + * and an adopter can put a {@code gherkin/} directory on a classpath root that shadows the + * packaged one. Appendix F also requires the check to be in force where the scenarios execute, + * which the artifact's own test suite is not. + * + * @param source the scenario's feature file, as Cucumber reports it, or {@code null} if unknown + * @param tags the scenario's Gherkin tags, including the leading at-sign + * @throws IllegalStateException if the scenario is canonical and carries an unknown tag + */ + public static void requireKnownVocabulary(URI source, Collection tags) { + if (!isCanonical(source)) { + return; + } + List unknown = new ArrayList<>(); + for (String tag : tags) { + if (!Capability.fromTag(tag).isPresent()) { + unknown.add(tag); + } + } + if (unknown.isEmpty()) { + return; + } + throw new IllegalStateException("The canonical scenario at " + source + " carries " + unknown + + ", which this implementation's capability vocabulary does not know. An unknown tag " + + "gates nothing, so without this check the scenario would stay mandatory for every " + + "adopter — including one that legitimately cannot support the capability, which " + + "would see unexplained failures while every other provider stayed green, and " + + "nothing in the results would say why. The pinned specification revision has " + + "added a capability this package has not: add it to the Capability enum, beside " + + "the tag it was split from or grouped with, and say in its javadoc what declaring " + + "it claims. If instead this is a feature file of your own, move it under " + + ProviderTck.EXTENSIONS + "/ — " + ProviderTck.FEATURES + "/ is the canonical set " + + "and is checked against the vocabulary."); + } + + /** + * Whether a scenario came from the canonical set rather than from an adopter's extension. + * + *

            Decided on the feature file's immediate parent directory being + * {@link ProviderTck#FEATURES}, over the URI Cucumber reports — {@code classpath:gherkin/ + * errors.feature} for the packaged assets, a {@code file:} URI when the features are read from + * a directory. Only the last two segments are looked at, so neither form needs special casing + * and a shadowing copy on another classpath root is still canonical, which is the point. + * + *

            A {@code null} source is not canonical. It is what the two-argument + * {@link #requireDeclared(Collection, Set)} passes, and the callers that use it are tests + * asserting the other three rules over a bare tag list; treating an unknown origin as canonical + * would make those assert this rule by accident. + */ + private static boolean isCanonical(URI source) { + if (source == null) { + return false; + } + String path = source.toString().replace('\\', '/'); + int lastSeparator = path.lastIndexOf('/'); + if (lastSeparator < 0) { + return false; + } + String parent = path.substring(0, lastSeparator); + int start = Math.max(parent.lastIndexOf('/'), parent.lastIndexOf(':')) + 1; + return ProviderTck.FEATURES.equals(parent.substring(start)); + } + /** * Fails the run if a scenario carries the tag of a capability this suite still calls reserved. * diff --git a/tools/tck/src/main/java/dev/openfeature/contrib/tools/tck/steps/ProviderSteps.java b/tools/tck/src/main/java/dev/openfeature/contrib/tools/tck/steps/ProviderSteps.java index 86093903ba..d60b230008 100644 --- a/tools/tck/src/main/java/dev/openfeature/contrib/tools/tck/steps/ProviderSteps.java +++ b/tools/tck/src/main/java/dev/openfeature/contrib/tools/tck/steps/ProviderSteps.java @@ -6,6 +6,7 @@ import dev.openfeature.contrib.tools.tck.Capability; import dev.openfeature.contrib.tools.tck.CapabilityGate; +import dev.openfeature.contrib.tools.tck.ProviderTck; import dev.openfeature.contrib.tools.tck.ProviderTckHarness; import dev.openfeature.contrib.tools.tck.TckRuntime; import dev.openfeature.contrib.tools.tck.TckState; @@ -77,16 +78,22 @@ public static void afterAll() { * leaves {@link Capability#STALE} and {@link Capability#UNAVAILABLE_INIT} undeclared, and the * scenarios needing them are skipped here — before any step can reach an unsupported operation. * - *

            The one tag that is failed rather than skipped is a - * {@linkplain Capability#reserved() reserved} one, which cannot be declared and so could only - * ever produce a skip nobody is able to clear. See - * {@link CapabilityGate#requireNoExpiredReservation}. + *

            Two tags are failed rather than skipped, and both are failures of this suite + * rather than of the provider. A {@linkplain Capability#reserved() reserved} one cannot be + * declared, so it could only ever produce a skip nobody is able to clear — see + * {@link CapabilityGate#requireNoExpiredReservation}. A tag on a canonical scenario that this + * vocabulary does not know at all gates nothing, so it would leave its scenario mandatory for + * every adopter — see {@link CapabilityGate#requireKnownVocabulary}. + * + *

            The scenario's URI is passed along with its tags, and only the second of those two checks + * reads it: an adopter's feature file under {@link ProviderTck#EXTENSIONS} is expected to carry + * tags this vocabulary does not know, and {@link ProviderTck#FEATURES} is expected not to. * * @param scenario the scenario about to run */ @Before(order = 0) public void gateOnCapabilities(Scenario scenario) { - CapabilityGate.requireDeclared(scenario.getSourceTagNames(), harness().capabilities()); + CapabilityGate.requireDeclared(scenario.getUri(), scenario.getSourceTagNames(), harness().capabilities()); } /** diff --git a/tools/tck/src/test/java/dev/openfeature/contrib/tools/tck/CanonicalAssetDigestTest.java b/tools/tck/src/test/java/dev/openfeature/contrib/tools/tck/CanonicalAssetDigestTest.java index eaca1e21f7..7d6b4768bc 100644 --- a/tools/tck/src/test/java/dev/openfeature/contrib/tools/tck/CanonicalAssetDigestTest.java +++ b/tools/tck/src/test/java/dev/openfeature/contrib/tools/tck/CanonicalAssetDigestTest.java @@ -80,10 +80,10 @@ class CanonicalAssetDigestTest { *

            Must equal {@code git -C tools/tck/spec rev-parse HEAD}, and is the value the report * branch publishes as the source of a run's scenarios. */ - static final String PINNED_REVISION = "d47a66ebb9500706e5bded7799d0e49aa1e86bfd"; + static final String PINNED_REVISION = "bda599f1db440aa8d395d1d3af7b9b3cc3103b98"; /** SHA-256 of the three asset trees at {@link #PINNED_REVISION}, as {@link #digest} computes it. */ - static final String PINNED_DIGEST = "f7e9b9e63138b19f11f795095bb9e5c78801989d02822789e9202f777af319e9"; + static final String PINNED_DIGEST = "7a753b5f5f60248336d93f565bc12ad844af9f04de40867916b98f283363354b"; /** The generated resource directories, in the order they are digested. */ private static final List ASSET_DIRECTORIES = Arrays.asList("flags", "gherkin", "openapi"); diff --git a/tools/tck/src/test/java/dev/openfeature/contrib/tools/tck/CanonicalTagCoverageTest.java b/tools/tck/src/test/java/dev/openfeature/contrib/tools/tck/CanonicalTagCoverageTest.java index 58da46e286..8913d02079 100644 --- a/tools/tck/src/test/java/dev/openfeature/contrib/tools/tck/CanonicalTagCoverageTest.java +++ b/tools/tck/src/test/java/dev/openfeature/contrib/tools/tck/CanonicalTagCoverageTest.java @@ -29,7 +29,7 @@ /** * Every capability this suite does not call reserved is carried by at least one canonical scenario, - * and every reserved one is carried by none. + * every reserved one is carried by none, and every tag they carry is a capability this suite knows. * *

            {@link CapabilityGate#requireNoExpiredReservation} already fails a run where a scenario * carries a reserved tag — a capability no adopter may declare, gating something, so the scenario is @@ -108,6 +108,32 @@ void noReservedCapabilityGatesAnything() { } } + @Test + @DisplayName("every tag a canonical scenario carries resolves to a capability") + void everyCarriedTagIsInTheVocabulary() { + // The reverse of the first test, and the direction that is easy to leave out: that one + // catches a capability with no scenarios, this one a scenario tag with no capability. An + // unknown tag gates nothing, so its scenarios stay mandatory for every adopter — a suite + // that has not learned a new capability keeps demanding the old behaviour, and the only + // symptom is a provider that legitimately withholds it failing while the rest stay green. + // + // This fires on the re-pin that adds a tag, which is the moment it is needed: the pin and + // the Capability constant move in the same commit, and nothing else notices if only the + // pin moves. CapabilityGate.requireKnownVocabulary is the same rule at run time, for the + // canonical assets an adopter actually executes rather than the ones packaged here. + for (String tag : CARRIED) { + assertThat(Capability.fromTag(tag)) + .as( + "A canonical scenario carries %s, which Capability.fromTag does not resolve. The " + + "pinned specification revision has a capability this package does not: add " + + "it to the Capability enum and say in its javadoc what declaring it claims. " + + "Until then the tag gates nothing and its scenarios are mandatory for every " + + "adopter, including the ones that cannot support it.", + tag) + .isPresent(); + } + } + @Test @DisplayName("a tag named only in a Gherkin comment is prose, not a tag") void aTagInACommentIsNotCarried() { diff --git a/tools/tck/src/test/java/dev/openfeature/contrib/tools/tck/ControllableProviderTckTest.java b/tools/tck/src/test/java/dev/openfeature/contrib/tools/tck/ControllableProviderTckTest.java index 9a7b806bef..750a95b83a 100644 --- a/tools/tck/src/test/java/dev/openfeature/contrib/tools/tck/ControllableProviderTckTest.java +++ b/tools/tck/src/test/java/dev/openfeature/contrib/tools/tck/ControllableProviderTckTest.java @@ -122,6 +122,7 @@ public Set capabilities() { Capability.VARIANTS, Capability.DISABLED_FLAGS, Capability.STRING_TYPING, + Capability.FULLY_TYPED_VALUES, Capability.STANDARD_REASONS); } } diff --git a/tools/tck/src/test/java/dev/openfeature/contrib/tools/tck/InMemoryProviderTckTest.java b/tools/tck/src/test/java/dev/openfeature/contrib/tools/tck/InMemoryProviderTckTest.java index 257e9a7a64..b69a25d279 100644 --- a/tools/tck/src/test/java/dev/openfeature/contrib/tools/tck/InMemoryProviderTckTest.java +++ b/tools/tck/src/test/java/dev/openfeature/contrib/tools/tck/InMemoryProviderTckTest.java @@ -122,6 +122,7 @@ public Set capabilities() { Capability.VARIANTS, Capability.DISABLED_FLAGS, Capability.STRING_TYPING, + Capability.FULLY_TYPED_VALUES, Capability.STANDARD_REASONS); } } diff --git a/tools/tck/src/test/java/dev/openfeature/contrib/tools/tck/MultiProviderTckTest.java b/tools/tck/src/test/java/dev/openfeature/contrib/tools/tck/MultiProviderTckTest.java index f6dd91759a..2f5076ac26 100644 --- a/tools/tck/src/test/java/dev/openfeature/contrib/tools/tck/MultiProviderTckTest.java +++ b/tools/tck/src/test/java/dev/openfeature/contrib/tools/tck/MultiProviderTckTest.java @@ -109,6 +109,7 @@ public Set capabilities() { Capability.VARIANTS, Capability.DISABLED_FLAGS, Capability.STRING_TYPING, + Capability.FULLY_TYPED_VALUES, Capability.STANDARD_REASONS); } } From 863b41e52607abe490f27f57e4ba4dd9cd194422 Mon Sep 17 00:00:00 2001 From: Simon Schrottner Date: Wed, 16 Sep 2026 21:47:17 +0200 Subject: [PATCH 54/55] style(tck): wrap the capability-gate call the way spotless wants Adding the unknown-vocabulary check pushed this call past the line limit. It was missed locally because spotless reports every file in this tree as violating -- core.autocrlf=true gives the Windows working tree CRLF while the committed blobs are LF -- so a real violation was hiding inside 41 false ones. CI checks out LF and reported exactly this one. Signed-off-by: Simon Schrottner --- .../dev/openfeature/contrib/tools/tck/steps/ProviderSteps.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tools/tck/src/main/java/dev/openfeature/contrib/tools/tck/steps/ProviderSteps.java b/tools/tck/src/main/java/dev/openfeature/contrib/tools/tck/steps/ProviderSteps.java index d60b230008..3456e5b607 100644 --- a/tools/tck/src/main/java/dev/openfeature/contrib/tools/tck/steps/ProviderSteps.java +++ b/tools/tck/src/main/java/dev/openfeature/contrib/tools/tck/steps/ProviderSteps.java @@ -93,7 +93,8 @@ public static void afterAll() { */ @Before(order = 0) public void gateOnCapabilities(Scenario scenario) { - CapabilityGate.requireDeclared(scenario.getUri(), scenario.getSourceTagNames(), harness().capabilities()); + CapabilityGate.requireDeclared( + scenario.getUri(), scenario.getSourceTagNames(), harness().capabilities()); } /** From 92fefde94467e6303234c3cbcd5ce715d4ca23d9 Mon Sep 17 00:00:00 2001 From: Simon Schrottner Date: Wed, 16 Sep 2026 21:47:45 +0200 Subject: [PATCH 55/55] chore(tck): follow the spec pin to the rebased appendix Gitlink and PINNED_REVISION move together, as their own javadoc requires. The digest is unchanged: assets are byte-identical across the move, which changed appendix prose and rebased the branch onto main. Signed-off-by: Simon Schrottner --- tools/tck/spec | 2 +- .../openfeature/contrib/tools/tck/CanonicalAssetDigestTest.java | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/tools/tck/spec b/tools/tck/spec index bda599f1db..ff68adb4c7 160000 --- a/tools/tck/spec +++ b/tools/tck/spec @@ -1 +1 @@ -Subproject commit bda599f1db440aa8d395d1d3af7b9b3cc3103b98 +Subproject commit ff68adb4c7617ad2d980988241e92603bc247926 diff --git a/tools/tck/src/test/java/dev/openfeature/contrib/tools/tck/CanonicalAssetDigestTest.java b/tools/tck/src/test/java/dev/openfeature/contrib/tools/tck/CanonicalAssetDigestTest.java index 7d6b4768bc..0d20a4bd90 100644 --- a/tools/tck/src/test/java/dev/openfeature/contrib/tools/tck/CanonicalAssetDigestTest.java +++ b/tools/tck/src/test/java/dev/openfeature/contrib/tools/tck/CanonicalAssetDigestTest.java @@ -80,7 +80,7 @@ class CanonicalAssetDigestTest { *

            Must equal {@code git -C tools/tck/spec rev-parse HEAD}, and is the value the report * branch publishes as the source of a run's scenarios. */ - static final String PINNED_REVISION = "bda599f1db440aa8d395d1d3af7b9b3cc3103b98"; + static final String PINNED_REVISION = "ff68adb4c7617ad2d980988241e92603bc247926"; /** SHA-256 of the three asset trees at {@link #PINNED_REVISION}, as {@link #digest} computes it. */ static final String PINNED_DIGEST = "7a753b5f5f60248336d93f565bc12ad844af9f04de40867916b98f283363354b";