Skip to content

feat(provider-tck): add Java conformance suite for OpenFeature providers - #1830

Draft
aepfli wants to merge 48 commits into
mainfrom
feat/provider-tck
Draft

feat(provider-tck): add Java conformance suite for OpenFeature providers#1830
aepfli wants to merge 48 commits into
mainfrom
feat/provider-tck

Conversation

@aepfli

@aepfli aepfli commented Jul 27, 2026

Copy link
Copy Markdown
Member

Part of open-feature/spec#417 (cross-language tracking) via #1829 (Java implementation issue). The language-agnostic artifacts live in spec#423; the report envelope in spec#425.

Closes #1829

Draft — opened for review of the approach. The module passes the full codequality,deploy gate. The open questions are where I would most value pushback.

The PR chain

The same four-branch shape in every language, so understanding one is understanding all four:

main
 └── #1830  feat/provider-tck           this PR — the suite, both control paths, the spec
      │                                  submodule, the extension point, and the entire
      │                                  adopter-facing API
      ├── #1841  feat/provider-tck-report   Cucumber Messages and the report envelope
      └── #1847  feat/provider-tck-flagd    flagd adoption, both resolvers
           └── #1840  feat/provider-tck-ofrep   OFREP adoption

report and flagd are siblings, not a sequence. The follow-ups fill gaps without changing the
adopter-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 declarescapabilities(),
knownDeviations(), configuration(), BackendControl.controlApi(), the reserved-capability rules
and 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/tck module carries the Cucumber step definitions and an abstract JUnit Platform
Suite 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, reaching
this 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-testbed is not modified and the existing
flagd 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:

public class RpcTest extends AbstractResolverTest {

    @Override protected Config.Resolver resolver() { return Config.Resolver.RPC; }

    @Override protected int backendPort() { return 8013; }
}

The shared part supplies the four required methods:

@Override public File composeFile() { ... }                      // the stack to run
@Override public List<Integer> backendPorts() { ... }             // ports the provider connects to
@Override public FeatureProvider createProvider(BackendEndpoint endpoint) { ... }
@Override public FeatureProvider createUnavailableProvider() { ... }

createProvider is a factory taking an endpoint rather than a field, because container host ports
are 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 BackendControl instead 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, …; @caching is reserved and cannot be declared). A scenario gated on an
undeclared capability is reported
skipped with its reason, never passed.

@large-integers cannot be declared in Java at all, and the suite refuses it rather than
expecting adopters to know that.
The integer accessor is a 32-bit Integer, so 2^53 − 1 cannot be
asked 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 which
of 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.5 to 0 when a float flag is read through the integer
accessor, with no error code. Earlier revisions of this PR withheld @numeric-coercion; it now
declares 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.

reason is asserted in two places only, and they mean different things. Requirement 2.2.5 is a
SHOULD permitting "some other string", so the scenarios that resolve a value no longer assert a
reason at all — an exact-match assertion there fails providers the specification permits. What
replaced it is a capability. @standard-reasons declares that this provider speaks the
specification'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, and
nothing 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 ERROR there is not a dialect — a reason
that 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 for
flagd-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/*.java for the tck profile's includes, **/e2e/*.java,**/tck/*.java for the default
build's exclusions. providers/ofrep's e2e package is gone entirely — the conformance suite had
been its only member, which is the argument in its clearest form. The class names lost the Tck the
selector used to be spelled with: RpcTest, InProcessTest, OfrepTest. *Test stays, because
Surefire'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 testExclusions in the adopter's
POM, and a maintainer runs them through the dedicated tck profile:

mvn -pl tools/tck -am -DskipTests install     # once — tools/tck is not published yet
mvn -Ptck -pl providers/flagd test

Do not add -am to the second command. It drags tools/tck and tools/flagd-core into the
reactor 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 worst
direction. Measured against a real run, it executes the three legacy e2e suites — 788 tests — and
TckTest appears nowhere in the 7309-line log. -P e2e is in fact the one flag that guarantees
the 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
testExclusions preserves by being a Surefire and not a compiler exclusion: a suite that has
quietly stopped building against its own harness is a worse failure than one that runs and fails.

The gating half of this is provisional and under discussion. The suite is excluded because its
honest output is red — but that is only true while a run is judged by "zero failures". Judged
instead by whether its results match its declaration — every failure covered by a declared
deviation, every skip gated by an undeclared capability, every deviation still failing something —
a healthy adoption is green in its steady state, deviations included, and this could be a required
gate. Proposed in
open-feature/spec#417;
nothing here depends on the outcome. What is settled either way is that the conformance suite gets
a step of its own rather than being folded into the provider's e2e suite.

Verification

At fe4ac3be, run locally:

Check Result
mvn --projects tools/tck -P codequality,deploy clean verify passes — checkstyle, PMD, SpotBugs, Spotless/Palantir, javadoc failOnWarnings=true
Module's own suite 246 tests green, no Docker
RpcTest 65 scenarios — 59 passed, 4 failed, 2 skipped
InProcessTest 65 scenarios — 59 passed, 4 failed, 2 skipped
Existing flagd e2e suites unaffected and green

The 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 @reinitialization and @large-integers, the two capabilities flagd
withholds — 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

  1. Should testcontainers be a compile dependency of tools/tck? It is provided/optional
    here. A separate tck-testcontainers artifact would drag createProvider(BackendEndpoint) out
    of the core, so I did not split it — but four languages answered this four ways and none is
    obviously wrong.
  2. Nothing validates an emitted report against the schema in CI, in any language. The report
    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.
  3. Scenario scope. A representative subset covering each architectural mechanism once. Is the
    boundary in the right place?

Known gaps

Listed in full in tools/tck/README.md, and the same five in every language: whole-context
passthrough beyond the targeting key, targeting and bucketing correctness (out of scope by design),
caching, per-flag set/remove control operations, and hooks.

@coderabbitai

coderabbitai Bot commented Jul 27, 2026

Copy link
Copy Markdown

Important

Draft PR not reviewed

Draft PRs are not automatically reviewed by default.

  • Trigger a manual review

To automatically review draft PRs, update your CodeRabbit configuration:

reviews:
  auto_review:
    drafts: true

Comment @coderabbitai help to get the list of available commands.

aepfli added 2 commits August 24, 2026 11:40
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>
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add tools/provider-tck: a Java conformance suite for OpenFeature providers

5 participants