feat(provider-tck): add Java conformance suite for OpenFeature providers - #1830
Draft
aepfli wants to merge 48 commits into
Draft
feat(provider-tck): add Java conformance suite for OpenFeature providers#1830aepfli wants to merge 48 commits into
aepfli wants to merge 48 commits into
Conversation
|
Important Draft PR not reviewedDraft PRs are not automatically reviewed by default.
To automatically review draft PRs, update your CodeRabbit configuration: reviews:
auto_review:
drafts: trueComment |
github-actions
Bot
requested review from
Kavindu-Dodan,
beeme1mr,
thisthat and
toddbaert
July 27, 2026 09:13
This was referenced Aug 24, 2026
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 <simon.schrottner@flagsmith.com>
…atch 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 <simon.schrottner@flagsmith.com>
aepfli
force-pushed
the
feat/provider-tck
branch
from
August 24, 2026 09:42
0eed5e5 to
03ef057
Compare
This was referenced Aug 24, 2026
lifecycle.feature was tagged @events, which is wrong in both directions.
Too strict: a stateless provider such as OFREP emits no events of its own
and cannot declare @events, yet it still initialises against a backend and
still owes the lifecycle contract.
Too lax: FeatureProviderStateManager emits PROVIDER_READY/PROVIDER_ERROR
around initialize for any provider, whether or not it is an EventProvider.
So a provider that does no initialisation of its own reaches READY exactly
as NoOpProvider would, and the readiness scenario passes vacuously.
Adds Capability.LIFECYCLE ("@lifecycle") -- performs an initialisation that
reaches its backend, with an observable outcome -- and re-vendors
lifecycle.feature verbatim from the spec assets, where the feature-level tag
is now @lifecycle (spec dfa16586, PR open-feature/spec#423).
flagd declares LIFECYCLE in both resolver modes: RPC does a round trip and
in-process syncs the whole ruleset during initialisation, so the scenarios
assert something real there.
Signed-off-by: Simon Schrottner <simon.schrottner@flagsmith.com>
The report PR was refactoring this one's public API, which meant the flagd and OFREP adoptions -- stacked on this branch, not on that one -- were written against an API that would change under them the moment the report merged. Two commits from that branch straddled the boundary, and the split is where the seam actually is. The rename touched seven files here and two there; the reserved-capability rule touched five here and four there. Everything an adopter calls belongs on this side: the Capability vocabulary, the harness contract, the runtime, and the flagd suite that consumes them. What stays with the report is machinery no adoption references -- the plugin, the run metadata, the report document and their tests -- and excluding them left no dangling reference, which is the check that the seam is real rather than convenient. So this PR now carries: @strict-numeric-typing renamed to @numeric-coercion, with the corrected rule. Coercion is permitted when lossless and must fail only when information would be lost, which is flagd's numeric coercion ADR rather than an OpenFeature requirement -- the specification has one numeric type, of "unspecified type or size", so a provider behaving differently is not violating it. The gap in the provider contract is open-feature/spec#430. The vendored assets move to dc4d7ae8 accordingly. A reserved capability is no longer declarable. @targeting and @caching exist in the vocabulary with no scenario behind them, and this suite's flagd test declared both by accident: EnumSet.complementOf collects every reserved tag on the way past. Capability.declarableExcept is the counterpart that does not, the harness default is declarable() rather than allOf(), and naming a reserved capability now fails the run before Docker starts rather than reaching a published report. Signed-off-by: Simon Schrottner <simon.schrottner@flagsmith.com>
Step definitions reached the Compose stack and the HTTP control API directly, through TckRuntime. That made the suite unrunnable for any provider without a containerised backend, and it put transport knowledge in the one layer that should have none. Introduce BackendControl as the single seam between the step definitions and whatever manipulates the backend. All nine touchpoints — scenario reset, flag change, disconnect, reconnect, bounded outage, provider creation and the suite lifecycle — now go through it. ControlApiClient becomes HttpBackendControl, one implementation of that seam; nothing about the HTTP control API spec changes and it remains the normative contract for external backends. Split the base class along the same line. ProviderTckTest (renamed from AbstractProviderTckTest) keeps only what every provider needs: capability declaration, timeouts, awaiting and step wiring. ContainerizedProviderTckTest extends it with the Compose lifecycle, port discovery and HttpBackendControl construction, and carries the compose-specific configuration that used to sit on ProviderTckHarness. Adopters with an external backend keep an unchanged surface — the flagd suites need only the superclass name. Behaviour is unchanged: same control API calls in the same order, same once-per-suite Compose lifecycle, same no-container-restart invariant. Signed-off-by: Simon Schrottner <simon.schrottner@flagsmith.com>
…test 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 <simon.schrottner@flagsmith.com>
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 <simon.schrottner@flagsmith.com>
…pec submodule
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 <simon.schrottner@flagsmith.com>
…s 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 <simon.schrottner@flagsmith.com>
…efer
`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 <simon.schrottner@flagsmith.com>
…es 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 <simon.schrottner@flagsmith.com>
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 <simon.schrottner@flagsmith.com>
aepfli
added a commit
that referenced
this pull request
Sep 12, 2026
Runs the conformance suite against the flagd provider in both resolver modes. The whole adoption is a shared abstract base and two subclasses that differ only in resolver and port: the TCK brings its own Gherkin, its own step definitions and its own Compose lifecycle, and works out which suite is running from the JUnit test plan, so a mode needs no registration and no build configuration. The Compose stack wraps the unmodified flagd-testbed image, which already serves both flagd and the launchpad control API that this TCK's control API contract was derived from. No host port bindings: the TCK discovers dynamically mapped ports after startup, so the suite runs in parallel and does not collide with a developer's local flagd. capabilities() is declarableExcept(NUMERIC_COERCION). Evaluating float-flag (0.5) through the integer API returns 0 with no error code rather than TYPE_MISMATCH with the code default -- the value is silently truncated. Coercion as such is permitted; it is the lossy case being accepted that is the defect, tracked as open-feature/flagd#1996. Both resolvers behave identically, which places it in the shared provider layer rather than in either transport, so it is declared once here. Delete the override when the defect is fixed. Split out of #1830 so that the suite and its first adopter are reviewed as separate questions: whether the TCK is the right contract, and whether flagd satisfies it. Signed-off-by: Simon Schrottner <simon.schrottner@flagsmith.com>
…gone Two changes to the BackendControl surface, taken together because the same spec re-pin carries both. controlApi() becomes required and closed. The default returning in-process answered a question only the author of a control can answer: the same scenarios passing over the control API and passing through in-process manipulation of a provider that does have a backend are not the same claim, and this is the only field that separates them, so silence there is unfalsifiable rather than neutral. The default happened to under-claim rather than over-claim, which is why it was never visibly wrong, but that made it a fallback where it needed to be a decision. The String return and the two CONTROL_API_* constants become ControlApi.HTTP / ControlApi.IN_PROCESS, serialising to http / in-process: the report schema's enum has exactly those two members, so a String was wider than the thing it feeds and an implementor could return "HTTP" and produce a document that fails validation with no local error. Nothing is published, so no existing implementation breaks; HttpBackendControl and InProcessBackendControl answer it themselves, and an adopter with a real backend writes no control at all. disconnectFor() and the step behind it are deleted. POST /restart was marked [REQUIRED] on the strength of a claim in its own description that the TCK used it for the disconnect/reconnect scenarios. It does not: @Stale is written as an unbounded outage - "the connection is lost", "the connection is restored" - which is /stop plus /start, and nothing in the shipped Gherkin reaches /restart in any language. The spec is now fixed rather than worked around: the endpoint is [OPTIONAL] at the re-pinned appendix tip, with the condition that would bring it back written down, so this is dead surface rather than a capability being dropped. The spec submodule moves 009afe06 -> 93eb1a58 on feat/provider-tck-appendix, which also makes /change and /reset promise not to return until the new state is being served - a promise about the backend, with the provider's own detection latency left to the event timeout. Signed-off-by: Simon Schrottner <simon.schrottner@flagsmith.com>
defaultConfig() -> backendConfiguration(), completing the compose-contract naming across all four languages. "Configuration" was already taken. The report schema's provider.configuration is "which configuration of the provider was tested, when a provider has more than one materially different mode" - flagd RPC versus in-process - which is the provider's mode, and it is what ProviderTckHarness.configuration() feeds. This method names something else entirely: a configuration the backend understands, passed through to POST /start?config=. Java was the only language that kept the two apart, but it spelled this one "default", which was its own small confusion because it named the default value of a thing whose other values are also configurations. configuration() keeps its schema meaning and is untouched. Signed-off-by: Simon Schrottner <simon.schrottner@flagsmith.com>
Scope compile -> provided, plus optional, so the dependency stops being transitive. Only two types in src/main touch Testcontainers - ContainerizedProviderTckTest, which owns the ComposeContainer lifecycle, and BackendEndpoint, which holds one. The JUnit Platform Suite shape forces those into the published artifact, so this module must compile against Testcontainers; it does not follow that consumers must resolve it. Every backend-less adopter - in-memory, environment-variable, file-based - was dragging Testcontainers onto its test classpath for a class it never loads. The cost is zero today: both adoptions in this repository already declare org.testcontainers:testcontainers in their own poms, so nothing relies on the transitive edge. It also lets an adopter stay on the Testcontainers major its other suites use instead of inheriting ours. A separate tck-testcontainers artifact was considered and rejected: it would drag createProvider(BackendEndpoint), the main adopter-facing factory signature, out of the core artifact, and double the release, release-please and CODEOWNERS surface for two classes. Signed-off-by: Simon Schrottner <simon.schrottner@flagsmith.com>
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 <simon.schrottner@flagsmith.com>
aepfli
added a commit
that referenced
this pull request
Sep 12, 2026
Runs the conformance suite against the flagd provider in both resolver modes. The whole adoption is a shared abstract base and two subclasses that differ only in resolver and port: the TCK brings its own Gherkin, its own step definitions and its own Compose lifecycle, and works out which suite is running from the JUnit test plan, so a mode needs no registration and no build configuration. The Compose stack wraps the unmodified flagd-testbed image, which already serves both flagd and the launchpad control API that this TCK's control API contract was derived from. No host port bindings: the TCK discovers dynamically mapped ports after startup, so the suite runs in parallel and does not collide with a developer's local flagd. capabilities() is declarableExcept(NUMERIC_COERCION). Evaluating float-flag (0.5) through the integer API returns 0 with no error code rather than TYPE_MISMATCH with the code default -- the value is silently truncated. Coercion as such is permitted; it is the lossy case being accepted that is the defect, tracked as open-feature/flagd#1996. Both resolvers behave identically, which places it in the shared provider layer rather than in either transport, so it is declared once here. Delete the override when the defect is fixed. Split out of #1830 so that the suite and its first adopter are reviewed as separate questions: whether the TCK is the right contract, and whether flagd satisfies it. Signed-off-by: Simon Schrottner <simon.schrottner@flagsmith.com>
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 <simon.schrottner@flagsmith.com>
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 <simon.schrottner@flagsmith.com>
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 <simon.schrottner@flagsmith.com>
aepfli
added a commit
that referenced
this pull request
Sep 12, 2026
Runs the conformance suite against the flagd provider in both resolver modes. The whole adoption is a shared abstract base and two subclasses that differ only in resolver and port: the TCK brings its own Gherkin, its own step definitions and its own Compose lifecycle, and works out which suite is running from the JUnit test plan, so a mode needs no registration and no build configuration. The Compose stack wraps the unmodified flagd-testbed image, which already serves both flagd and the launchpad control API that this TCK's control API contract was derived from. No host port bindings: the TCK discovers dynamically mapped ports after startup, so the suite runs in parallel and does not collide with a developer's local flagd. capabilities() is declarableExcept(NUMERIC_COERCION). Evaluating float-flag (0.5) through the integer API returns 0 with no error code rather than TYPE_MISMATCH with the code default -- the value is silently truncated. Coercion as such is permitted; it is the lossy case being accepted that is the defect, tracked as open-feature/flagd#1996. Both resolvers behave identically, which places it in the shared provider layer rather than in either transport, so it is declared once here. Delete the override when the defect is fixed. Split out of #1830 so that the suite and its first adopter are reviewed as separate questions: whether the TCK is the right contract, and whether flagd satisfies it. Signed-off-by: Simon Schrottner <simon.schrottner@flagsmith.com>
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 <simon.schrottner@flagsmith.com>
aepfli
added a commit
that referenced
this pull request
Sep 13, 2026
Runs the conformance suite against the flagd provider in both resolver modes. The whole adoption is a shared abstract base and two subclasses that differ only in resolver and port: the TCK brings its own Gherkin, its own step definitions and its own Compose lifecycle, and works out which suite is running from the JUnit test plan, so a mode needs no registration and no build configuration. The Compose stack wraps the unmodified flagd-testbed image, which already serves both flagd and the launchpad control API that this TCK's control API contract was derived from. No host port bindings: the TCK discovers dynamically mapped ports after startup, so the suite runs in parallel and does not collide with a developer's local flagd. capabilities() is declarableExcept(NUMERIC_COERCION). Evaluating float-flag (0.5) through the integer API returns 0 with no error code rather than TYPE_MISMATCH with the code default -- the value is silently truncated. Coercion as such is permitted; it is the lossy case being accepted that is the defect, tracked as open-feature/flagd#1996. Both resolvers behave identically, which places it in the shared provider layer rather than in either transport, so it is declared once here. Delete the override when the defect is fixed. Split out of #1830 so that the suite and its first adopter are reviewed as separate questions: whether the TCK is the right contract, and whether flagd satisfies it. Signed-off-by: Simon Schrottner <simon.schrottner@flagsmith.com>
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 <simon.schrottner@flagsmith.com>
aepfli
added a commit
that referenced
this pull request
Sep 13, 2026
Runs the conformance suite against the flagd provider in both resolver modes. The whole adoption is a shared abstract base and two subclasses that differ only in resolver and port: the TCK brings its own Gherkin, its own step definitions and its own Compose lifecycle, and works out which suite is running from the JUnit test plan, so a mode needs no registration and no build configuration. The Compose stack wraps the unmodified flagd-testbed image, which already serves both flagd and the launchpad control API that this TCK's control API contract was derived from. No host port bindings: the TCK discovers dynamically mapped ports after startup, so the suite runs in parallel and does not collide with a developer's local flagd. capabilities() is declarableExcept(NUMERIC_COERCION). Evaluating float-flag (0.5) through the integer API returns 0 with no error code rather than TYPE_MISMATCH with the code default -- the value is silently truncated. Coercion as such is permitted; it is the lossy case being accepted that is the defect, tracked as open-feature/flagd#1996. Both resolvers behave identically, which places it in the shared provider layer rather than in either transport, so it is declared once here. Delete the override when the defect is fixed. Split out of #1830 so that the suite and its first adopter are reviewed as separate questions: whether the TCK is the right contract, and whether flagd satisfies it. Signed-off-by: Simon Schrottner <simon.schrottner@flagsmith.com>
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 <simon.schrottner@flagsmith.com>
…d 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 <simon.schrottner@flagsmith.com>
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 <simon.schrottner@flagsmith.com>
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 <simon.schrottner@flagsmith.com>
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 <simon.schrottner@flagsmith.com>
aepfli
added a commit
that referenced
this pull request
Sep 13, 2026
Runs the conformance suite against the flagd provider in both resolver modes. The whole adoption is a shared abstract base and two subclasses that differ only in resolver and port: the TCK brings its own Gherkin, its own step definitions and its own Compose lifecycle, and works out which suite is running from the JUnit test plan, so a mode needs no registration and no build configuration. The Compose stack wraps the unmodified flagd-testbed image, which already serves both flagd and the launchpad control API that this TCK's control API contract was derived from. No host port bindings: the TCK discovers dynamically mapped ports after startup, so the suite runs in parallel and does not collide with a developer's local flagd. capabilities() is declarableExcept(NUMERIC_COERCION). Evaluating float-flag (0.5) through the integer API returns 0 with no error code rather than TYPE_MISMATCH with the code default -- the value is silently truncated. Coercion as such is permitted; it is the lossy case being accepted that is the defect, tracked as open-feature/flagd#1996. Both resolvers behave identically, which places it in the shared provider layer rather than in either transport, so it is declared once here. Delete the override when the defect is fixed. Split out of #1830 so that the suite and its first adopter are reviewed as separate questions: whether the TCK is the right contract, and whether flagd satisfies it. Signed-off-by: Simon Schrottner <simon.schrottner@flagsmith.com>
…sence 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 <simon.schrottner@flagsmith.com>
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 <simon.schrottner@flagsmith.com>
aepfli
added a commit
that referenced
this pull request
Sep 13, 2026
Runs the conformance suite against the flagd provider in both resolver modes. The whole adoption is a shared abstract base and two subclasses that differ only in resolver and port: the TCK brings its own Gherkin, its own step definitions and its own Compose lifecycle, and works out which suite is running from the JUnit test plan, so a mode needs no registration and no build configuration. The Compose stack wraps the unmodified flagd-testbed image, which already serves both flagd and the launchpad control API that this TCK's control API contract was derived from. No host port bindings: the TCK discovers dynamically mapped ports after startup, so the suite runs in parallel and does not collide with a developer's local flagd. capabilities() is declarableExcept(NUMERIC_COERCION). Evaluating float-flag (0.5) through the integer API returns 0 with no error code rather than TYPE_MISMATCH with the code default -- the value is silently truncated. Coercion as such is permitted; it is the lossy case being accepted that is the defect, tracked as open-feature/flagd#1996. Both resolvers behave identically, which places it in the shared provider layer rather than in either transport, so it is declared once here. Delete the override when the defect is fixed. Split out of #1830 so that the suite and its first adopter are reviewed as separate questions: whether the TCK is the right contract, and whether flagd satisfies it. Signed-off-by: Simon Schrottner <simon.schrottner@flagsmith.com>
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='<Your>*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 <simon.schrottner@flagsmith.com>
aepfli
added a commit
that referenced
this pull request
Sep 13, 2026
Runs the conformance suite against the flagd provider in both resolver modes. The whole adoption is a shared abstract base and two subclasses that differ only in resolver and port: the TCK brings its own Gherkin, its own step definitions and its own Compose lifecycle, and works out which suite is running from the JUnit test plan, so a mode needs no registration and no build configuration. The Compose stack wraps the unmodified flagd-testbed image, which already serves both flagd and the launchpad control API that this TCK's control API contract was derived from. No host port bindings: the TCK discovers dynamically mapped ports after startup, so the suite runs in parallel and does not collide with a developer's local flagd. capabilities() is declarableExcept(NUMERIC_COERCION). Evaluating float-flag (0.5) through the integer API returns 0 with no error code rather than TYPE_MISMATCH with the code default -- the value is silently truncated. Coercion as such is permitted; it is the lossy case being accepted that is the defect, tracked as open-feature/flagd#1996. Both resolvers behave identically, which places it in the shared provider layer rather than in either transport, so it is declared once here. Delete the override when the defect is fixed. Split out of #1830 so that the suite and its first adopter are reviewed as separate questions: whether the TCK is the right contract, and whether flagd satisfies it. Signed-off-by: Simon Schrottner <simon.schrottner@flagsmith.com>
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 <simon.schrottner@flagsmith.com>
aepfli
added a commit
that referenced
this pull request
Sep 13, 2026
Runs the conformance suite against the flagd provider in both resolver modes. The whole adoption is a shared abstract base and two subclasses that differ only in resolver and port: the TCK brings its own Gherkin, its own step definitions and its own Compose lifecycle, and works out which suite is running from the JUnit test plan, so a mode needs no registration and no build configuration. The Compose stack wraps the unmodified flagd-testbed image, which already serves both flagd and the launchpad control API that this TCK's control API contract was derived from. No host port bindings: the TCK discovers dynamically mapped ports after startup, so the suite runs in parallel and does not collide with a developer's local flagd. capabilities() is declarableExcept(NUMERIC_COERCION). Evaluating float-flag (0.5) through the integer API returns 0 with no error code rather than TYPE_MISMATCH with the code default -- the value is silently truncated. Coercion as such is permitted; it is the lossy case being accepted that is the defect, tracked as open-feature/flagd#1996. Both resolvers behave identically, which places it in the shared provider layer rather than in either transport, so it is declared once here. Delete the override when the defect is fixed. Split out of #1830 so that the suite and its first adopter are reviewed as separate questions: whether the TCK is the right contract, and whether flagd satisfies it. Signed-off-by: Simon Schrottner <simon.schrottner@flagsmith.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #1829
The PR chain
The same four-branch shape in every language, so understanding one is understanding all four:
reportandflagdare siblings, not a sequence. The follow-ups fill gaps without changing theadopter-facing API, so neither blocks the other and either can land first. The suite works without
reporting, which is why reporting is not in this PR.
The seam: this branch owns everything an adopter declares —
capabilities(),knownDeviations(),configuration(),BackendControl.controlApi(), the reserved-capability rulesand the tag lookup. Reporting is purely additive over that, so adopting the suite and emitting a
report ask for the same declaration and nothing more.
What this is
A conformance suite any OpenFeature provider can adopt to check that it implements the provider
contract in the specification. OpenFeature's central promise is that swapping providers does not
change application behaviour; nothing verifies that today, and every provider tests differently.
The new
tools/tckmodule carries the Cucumber step definitions and an abstract JUnit PlatformSuite that owns the whole test lifecycle. It does not carry the Gherkin, the canonical flag set
or the control API description: those are language-agnostic and live in
open-feature/spec, reachingthis module through a submodule and packaged into the JAR at build time. A copy would be a second
place for conformance to drift, which is the one thing this suite exists to prevent. Adopters need
no submodule.
flagd is the first adopter, in both resolver modes.
flagd-testbedis not modified and the existingflagd e2e suites are untouched.
The adoption surface
Four methods with no default; everything else is a convention you can override. The whole of flagd's
RPC mode, on top of a shared abstract class:
The shared part supplies the four required methods:
createProvideris a factory taking an endpoint rather than a field, because container host portsare assigned dynamically and do not exist until the stack is up. Conventions with working defaults:
backendService()(backend),controlPort()(8080),additionalPorts(),backendConfiguration()(default),startupTimeout()(60s).The suite owns the container stack. Starting Compose, discovering mapped ports, building the
control client, waiting for the control API, tearing down — all of it. An adopter writes a Compose
file and the methods above. This is now required by Appendix F, after three of the four languages
originally shipped only the control-API client and left orchestration to the adopter, at which point
every adoption hand-rolled between 130 and 460 lines of the same wrapper.
A provider with no backend supplies its own
BackendControlinstead and never touches Compose.Declaring what a provider can and cannot do
Two separate statements, because conflating them is how a conformance suite starts lying.
capabilities()names the optional parts of the contract this provider supports, from fourteen tags(
@lifecycle,@events,@stale,@targeting,@disabled-flags,@numeric-coercion,@standard-reasons, …;@cachingis reserved and cannot be declared). A scenario gated on anundeclared capability is reported
skipped with its reason, never passed.
@large-integerscannot be declared in Java at all, and the suite refuses it rather thanexpecting adopters to know that. The integer accessor is a 32-bit
Integer, so 2^53 − 1 cannot beasked for by any Java provider — it is a property of the SDK and says nothing about the provider.
Appendix F makes this the implementation's call rather than each adopter's, and naming it now fails
at configuration time with a message saying why. Its scenario stays gated rather than becoming
ungated, so it is skipped with a reason distinct from "the provider declined" — a reader of a Java
report can tell the two apart. Five places in this repository previously stated that fact, including
a README section instructing adopters to withhold it.
knownDeviations()says the provider fails something it is required to do. Appendix F settles whichof its two shapes to prefer: declare the capability, let the scenario fail, and record the deviation
beside the failure — rather than withdrawing the capability so the scenario skips. Withdrawing to
turn a failure into a skip hides a defect behind something that looks deliberate.
flagd is the worked example. It narrows
0.5to0when a float flag is read through the integeraccessor, with no error code. Earlier revisions of this PR withheld
@numeric-coercion; it nowdeclares it and carries a tracked deviation, because the widening scenario passes — so flagd
does coerce and gets one direction wrong, and a skip could not distinguish that from declining to
coerce at all.
reasonis asserted in two places only, and they mean different things. Requirement 2.2.5 is aSHOULDpermitting "some other string", so the scenarios that resolve a value no longer assert areason at all — an exact-match assertion there fails providers the specification permits. What
replaced it is a capability.
@standard-reasonsdeclares that this provider speaks thespecification's own vocabulary, and a feature file then checks the whole of it:
STATIC,TARGETING_MATCH,DEFAULT,DISABLED. Undeclared, those scenarios skip with their reason, andnothing else in the suite cares what a provider calls a reason.
The error cases are not part of that bargain and are asserted unconditionally. Requirement 1.4.8
makes an error code mandatory in abnormal execution, so
ERRORthere is not a dialect — a reasonthat disagrees with the error code shipped beside it is a contradiction. The assertion is on the two
agreeing, not on which of them is authoritative.
The two invariants a TCK implementation gets wrong
Both are normative in the control API description.
Containers are never stopped or restarted mid-suite. Unavailability is simulated inside the
running stack. This is portability, not preference: container runtimes assign host ports dynamically
and do not reliably preserve them across a restart, and which bindings preserve them differs by
language — so a TCK that restarts containers works in one language and mysteriously fails in
another.
A control endpoint that changes flag state must not return until that state is being served.
Returning on accepted rather than applied pushes a race onto every caller, and the caller cannot
close it: a suite cannot distinguish "the backend has not caught up" from "the provider resolved the
wrong value". Earlier revisions of this PR slept 50ms after every control call; that is gone. The
wait is the endpoint's obligation, and where a backend breaks it the wait belongs in that adoption,
named and citing the defect — see
AbstractResolverTest, which does exactly that forflagd-testbed#394.
Running it
A conformance adoption is a directory, not a naming convention. It lives in the module's own
…/tck/package beside the e2e suite rather than inside it, and every selector is now a path:**/tck/*.javafor thetckprofile's includes,**/e2e/*.java,**/tck/*.javafor the defaultbuild's exclusions.
providers/ofrep'se2epackage is gone entirely — the conformance suite hadbeen its only member, which is the argument in its clearest form. The class names lost the
Tcktheselector used to be spelled with:
RpcTest,InProcessTest,OfrepTest.*Teststays, becauseSurefire's default includes need it and that is a different thing from the selector being removed.
The adoption suites are excluded from the default build via
testExclusionsin the adopter'sPOM, and a maintainer runs them through the dedicated
tckprofile:Do not add
-amto the second command. It dragstools/tckandtools/flagd-coreinto thereactor and runs their unit suites first, which puts a conformance failure and a unit-test failure
back on one signal — the thing a separate step exists to prevent.
An earlier revision of this section documented
mvn -P e2e test, and that was wrong in the worstdirection. Measured against a real run, it executes the three legacy e2e suites — 788 tests — and
TckTestappears nowhere in the 7309-line log.-P e2eis in fact the one flag that guaranteesthe conformance suites do not run. The tallies below were always produced by a different invocation
and the adoption README documented that one correctly; only this description was wrong.
Appendix F carries the reasoning for excluding it: an adoption suite's honest output is red while
real gaps remain — filed provider defects, missing backend fixtures — so making it a required gate
forces someone to silence it, and the cheapest way to silence a conformance suite is to stop asking
the question.
So read the tally below rather than the green check. CI green on the adoption PRs means the
suite did not run. It does still compile in the default build, which is deliberate and which
testExclusionspreserves by being a Surefire and not a compiler exclusion: a suite that hasquietly stopped building against its own harness is a worse failure than one that runs and fails.
Verification
At
fe4ac3be, run locally:mvn --projects tools/tck -P codequality,deploy clean verifyfailOnWarnings=trueRpcTestInProcessTestThe four failures are one real provider defect (the float-to-int narrowing above, declared as a
deviation) and three backend fixture gaps that
flagd-testbed#392 fills. The two skips are
the scenarios gated on
@reinitializationand@large-integers, the two capabilities flagdwithholds — each reported with its reason rather than passed.
Six lifecycle scenarios also run without Docker, through a test-scoped controllable provider, so
shutdown and re-initialisation are exercised in a normal build rather than only through the
containerised adoption.
Open questions
testcontainersbe a compile dependency oftools/tck? It isprovided/optionalhere. A separate
tck-testcontainersartifact would dragcreateProvider(BackendEndpoint)outof the core, so I did not split it — but four languages answered this four ways and none is
obviously wrong.
branch checks fields by hand. The schema is what makes reports comparable across languages, so
this is the weakest link in the design and it wants one answer rather than four.
boundary in the right place?
Known gaps
Listed in full in
tools/tck/README.md, and the same five in every language: whole-contextpassthrough beyond the targeting key, targeting and bucketing correctness (out of scope by design),
caching, per-flag set/remove control operations, and hooks.