feat(provider-tck): emit a machine-readable conformance report - #1841
Draft
aepfli wants to merge 14 commits into
Draft
feat(provider-tck): emit a machine-readable conformance report#1841aepfli wants to merge 14 commits into
aepfli wants to merge 14 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
September 10, 2026 18:12
aepfli
force-pushed
the
feat/provider-tck-report
branch
2 times, most recently
from
September 11, 2026 10:33
e905d57 to
24cc3e9
Compare
This was referenced Sep 11, 2026
aepfli
force-pushed
the
feat/provider-tck-report
branch
2 times, most recently
from
September 11, 2026 13:44
33e18be to
0943288
Compare
aepfli
force-pushed
the
feat/provider-tck-report
branch
13 times, most recently
from
September 13, 2026 18:03
2a7df04 to
f711236
Compare
Set PROVIDER_TCK_REPORT_DIR (or -Dprovider.tck.report.dir) and each suite writes two files: an envelope conforming to the report schema in Appendix F, and the run's results as a Cucumber Messages stream. An environment variable rather than a method on ProviderTckHarness, so emitting a report is a property of the run and not of the code: CI sets it, a developer running the suite locally does not, and no adopter changes a line to publish one. Unset means no report, which is not an error. Several suites in one JVM each write their own pair, so two resolver modes do not collide. The results are not a format this project defines. The .ndjson is produced by Cucumber's own io.cucumber.core.plugin.MessageFormatter -- the same class the built-in message:<path> plugin instantiates -- so the bytes are what --plugin message:... would have written. It already carries everything a per-scenario report would have had to invent: every scenario's outcome, its tags including any on an individual Examples block, an exact Scenario Outline row identity in pickle.astNodeIds, and the source of every feature that ran. An earlier version of this reverse-engineered that last fact by re-parsing the feature source and matching line numbers; the stream states it outright, which is the argument for a standard format over one we maintain. A plugin of our own rather than the built-in one only because a @ConfigurationParameter value is a compile-time constant, so the built-in plugin's path cannot be derived from the directory the run asked for. The envelope carries the four things no results format can state: what the provider calls itself, the SDK version actually on the classpath (read at runtime, because the TCK depends on a version range and never pins one), which TCK implementation and open-feature/spec revision asked the questions, and the capability declaration. The declaration is an input to reading the results rather than a summary of them: the stream says a scenario was skipped, and only the declaration says whether that is because the provider declines the capability it needed. Nothing here widens the adopter-facing API. capabilities(), knownDeviations(), configuration() and BackendControl.controlApi() are all on the base, and this branch only reads them -- so adopting the TCK and emitting a report are the same declaration, and a provider that never emits one is not asked for less. What the report is for: this suite promises that a scenario skipped for an undeclared capability is reported as skipped with the reason and never as passed, and a promise is not a check. Every scenario appears exactly once, whatever happened to it -- a report that quietly omitted the scenarios it did not run would satisfy every other rule and still mislead. ConformanceReportPluginTest drives a fixture suite through the real Cucumber engine and asserts both properties over the emitted stream. Signed-off-by: Simon Schrottner <simon.schrottner@flagsmith.com>
Extending the suite is safe by convention. Shrinking it is what a conformance suite has to prevent: a run that asks twenty-seven of the twenty-nine questions and reports success is indistinguishable, in every artifact it produces, from one that asked all twenty-nine. CanonicalScenarioGuard is an ordinary JUnit Jupiter test that the suite selects, and it fails the build when the run is set up to execute less than the canonical set -- a feature file added to features/ or shadowing a canonical one, cucumber.filter.tags or .name, or selectors and glue overridden in a consuming module's junit-platform.properties. A selected test rather than a listener, deliberately: the JUnit Platform catches and logs whatever a TestExecutionListener throws, which for this check is exactly the silent pass it exists to prevent. The listener only observes the plan and hands it over. It checks the setup rather than counting afterwards. Both the discovered plan and the filter configuration are settled before the first scenario, so the check needs no backend and takes no measurable time. CanonicalScenarios reads the canonical set out of this artifact's own JAR rather than through the classpath, so a shadowing file cannot also redefine what the guard compares against. Extension scenarios are ignored -- the check is defined over features/ alone, which is what keeps the extension point and this guard from contradicting each other. Filtering while debugging stays possible: -Dprovider.tck.partial=true (or PROVIDER_TCK_PARTIAL) makes the guard report itself as skipped rather than passed, so the run states that its canonical set was not verified rather than going quietly green. On this branch rather than the base because it is the same argument as the report: both exist so that a conformance claim can be checked rather than trusted. The guard is what makes the report's scenario count mean something. Signed-off-by: Simon Schrottner <simon.schrottner@flagsmith.com>
…evision The base moved the spec submodule twice: to ba002ce8 when it renamed the falsy canonical flags, and to fc99d5ac when it gated the reinitialisation scenario on @reinitialization. A report's tck.specRevision has to name the revision that actually produced its scenarios, so the pin follows -- left behind, every report from this branch would cite a revision predating both the flag names and the tag it evaluated. This is the invariant the property's comment states: provider-tck.spec.revision equals `git -C spec rev-parse HEAD`. Checked by eye again; worth automating, since this is the third bump it has had to follow. Signed-off-by: Simon Schrottner <simon.schrottner@flagsmith.com>
@large-integers is no longer set apart as "not applicable in Java": it is an ordinary declarable capability that a Java provider withholds, because the reason it cannot hold -- a 32-bit integer accessor -- is a property of the SDK and is recorded in Appendix F rather than in every report. So the maximal declaration this test asks for now includes it, and the assertion follows. The test's own point is unchanged and still holds: a reserved tag, which no scenario gates, cannot reach the declaration however the set was built. The emitted envelope is unaffected. declaration carries declared and nothing else, which is what the schema at spec 7f03f672 permits -- it sets additionalProperties: false, so a notApplicable member would now be rejected rather than merely unused. Signed-off-by: Simon Schrottner <simon.schrottner@flagsmith.com>
…evision The base moved the pin to 26362f85, where @Variants gates the variant assertions and @targeting stops being reserved. A report's tck.specRevision has to name the revision that actually produced its scenarios, so the property follows: left at fc99d5ac, every report from this branch would cite a revision whose evaluation.feature had twelve scenario instances rather than twenty-four, no @Variants in its vocabulary, and no scenario passing an evaluation context. This is the invariant the property's comment states: provider-tck.spec.revision equals `git -C spec rev-parse HEAD`. Fourth bump it has had to follow by hand, and the argument for automating it has not got weaker. Signed-off-by: Simon Schrottner <simon.schrottner@flagsmith.com>
@Variants is new and @targeting is no longer reserved, and the maximal-declaration test asserts the declared list exactly, so both join it in enum order. The reserved case changes with it: "everything except X" spelt EnumSet.complementOf now sweeps up one reserved tag rather than two, so the overclaim fixture asks for @caching, which is the tag the refusal is about. Asking for @targeting would have tested nothing -- it is declarable now, so the declaration would have been accepted and the assertion would have failed for the wrong reason. The canonical count in the guard's javadoc and in the README follows the assets: fifty-two scenario instances. Prose only -- the guard counts nothing itself, it compares the discovered plan against CanonicalScenarios.shipped() and prints canonical.size() in the failure -- but a number that disagrees with the suite is exactly what makes a reader think the check is a count taken afterwards. Signed-off-by: Simon Schrottner <simon.schrottner@flagsmith.com>
The reported spec revision has to move with the submodule pin, or a conformance report names a revision that did not produce its scenarios. 009afe06 is where the @disabled-flags outline and its four flags come from. The maximal-declaration test gains @disabled-flags in vocabulary order. It asserts the exact declared list a provider claiming everything declarable publishes, so a new capability that no scenario carried would be caught there; one that does gate scenarios has to be added, and the comment says why this one is gated at all. CanonicalScenarioGuard's javadoc and the README's section on it both counted the canonical set out loud. Fifty-two becomes fifty-six. Signed-off-by: Simon Schrottner <simon.schrottner@flagsmith.com>
Path and package churn from the base's provider-tck -> tck rename, which
this branch's own files had to be carried through: the package
declarations of the six classes added here, the plugin's fully-qualified
name in ProviderTck.PLUGINS, and the filtered build-info resource, which
becomes tck-build.properties fed by a tck.spec.revision property.
Two values are more than churn and were checked rather than swept:
- TckBuildInfo.IMPLEMENTATION, which a conformance report carries as
`tck.implementation`, becomes "java-sdk-contrib/tools/tck". It names
the module a run came from, so it has to name the module that exists.
- the plugin's log and failure prefixes become "tck [<configuration>]".
They are what a build log shows when a report cannot be written.
Signed-off-by: Simon Schrottner <simon.schrottner@flagsmith.com>
The report schema now has backend in its top-level required array and controlApi in backend's, so the conditional emission goes away. The old backend description said "omitted for a provider with no backend", which contradicted the controlApi enum whose in-process member exists for exactly that provider - the one value most worth knowing could never legally appear. With the member required and closed on the base branch, backendOf() has nothing left to decide: it builds the block unconditionally and the empty-value fallback stops existing. TckRunMetadata.controlApi() returns ControlApi rather than Optional<String> for the same reason. Re-pins tck.spec.revision to 93eb1a58, matching the submodule the base branch now carries. The two have to move together or a report names a revision that did not produce its scenarios. No report test had to change in substance: the one assertion on the field already expected "http", which ControlApi.HTTP serialises to. The fixture's metadata now passes the enum, and the envelope test additionally asserts that backend is present rather than present-if-set. Note for the record: this branch carries no copy of conformance-report.schema.json and no JSON-schema validator, so it cannot validate an emitted envelope against the schema. The assertions are field-by- field against the schema read by hand. Claiming otherwise would be the kind of unverified assertion this effort keeps tripping over. Signed-off-by: Simon Schrottner <simon.schrottner@flagsmith.com>
PROVIDER_TCK_REPORT_DIR becomes TCK_REPORT_DIR and PROVIDER_TCK_PARTIAL becomes TCK_PARTIAL, with the Maven system properties following: provider.tck.report.dir becomes tck.report.dir and provider.tck.partial becomes tck.partial. The package is called tck, not provider-tck, so PROVIDER_ names the thing after what it happens to test today. The report-directory variable in particular is read by all four languages' suites, so one cross-language CI job sets one variable and the name has to agree; renaming it in three languages and not the fourth is worse than either consistent answer. Nothing is published and nobody has scripted against either spelling, so it is free now and expensive later. PROVIDER_TCK_PARTIAL was not in the agreed list, and is renamed anyway: a half-renamed set of knobs is unguessable, which is the same reason the agreed list covered two variables rather than one. Flagged for the other languages that carry it. Two tests now pin the four spellings as literals rather than through the constants, because what matters is the name an adopter or a CI job types, and nothing about a self-consistent rename would fail a compiler. Also re-pins the spec submodule and tck.spec.revision to ccdb8879 together, so a report cannot name a revision that did not produce its scenarios. Signed-off-by: Simon Schrottner <simon.schrottner@flagsmith.com>
tck.spec.revision follows the submodule to c342461a, so a report names the revision that actually produced its scenarios rather than the one before the reasons moved into reason.feature. Checked with help:evaluate against the module POM and against `git -C spec rev-parse HEAD`, which is the pair the property's comment says must agree. The one pinned expectation that had to move is the maximal declaration in ConformanceReportPluginTest: it lists every declarable tag in order, so @standard-reasons had to be added with the note saying why an opt-in claim is still an ordinary declarable capability. That test is the reason the list is pinned at all -- "everything" once meant EnumSet.allOf and published claims about capabilities no scenario examines. Nothing else on this branch pins a count. CanonicalScenarios reads the packaged gherkin directory out of the artifact's own code source, so the canonical set moved from 57 to 66 scenarios per suite with no edit, and CanonicalScenarioGuard compared the new set against the new plan. That is the under-collection guard doing the job it exists for: had the sixth feature file not been collected, the guard would have failed rather than the run going quietly green on a smaller question set. 272 tests, 43 skipped, up from 245 and 37. The 32-test gap to the base branch is unchanged. Signed-off-by: Simon Schrottner <simon.schrottner@flagsmith.com>
…ames Three things follow from the base branch's two changes. tck.spec.revision moves to 89b1519a, and stops being maintained on trust. ConformanceReportPluginTest now asserts that what TckBuildInfo reads back equals CanonicalAssetDigestTest.PINNED_REVISION, which that test has in turn checked against the packaged assets by digest. The POM comment used to end "update both together; a mismatch means a report names a revision that did not produce its scenarios", and nothing enforced it. Now the chain from the submodule gitlink to the revision a published report claims is checked end to end, and a re-pin that forgets this line fails the build. The maximal declaration loses @large-integers. That test exists because "everything" once meant EnumSet.allOf and published claims about capabilities nothing examined; the maximal claim has to be the maximal claim a provider written against THIS SDK can make, and no Java provider can be asked for 2^53 - 1. And the report self-test fixture gains a scenario carrying @large-integers, because the report is where the distinction has to survive. The envelope's declaration explains every other skip: a reader takes the scenario's tags, checks them against the declared set, and the reason follows. It does not explain this one -- the capability is absent from every Java declaration, and absent for a reason that says nothing about the provider, so a reader inferring "the provider declined" would be reading a decision into something no Java provider was ever offered. The gate's reason is carried on the hook result that produced the skip, and the test now asserts that the two skips in the same stream cannot be confused: one says "does not declare capability STALE", the other says "the Java SDK cannot express capability LARGE_INTEGERS" and "not the provider under test declining", and neither contains the other's wording. The README's declaration bullet says so rather than continuing to claim the declaration accounts for every skip. Signed-off-by: Simon Schrottner <simon.schrottner@flagsmith.com>
tck.spec.revision is the second half of the pin: it is what a conformance report publishes as the source of its scenarios, and ConformanceReportPluginTest asserts it equals CanonicalAssetDigestTest.PINNED_REVISION, which the digest has in turn checked against the packaged assets. So this line moves in the same pass as the gitlink or the build fails. Prose-only upstream, assets byte-identical, and the numbers say so: 279 tests, 0 failures, 43 skipped, unchanged from the old pin. Signed-off-by: Simon Schrottner <simon.schrottner@flagsmith.com>
…iscourages The report self-test's metadata fixture carried an untracked deviation against a withheld @numeric-coercion, summarised "the fixture provider narrows a float to an integer". That is withhold-plus-deviate: a provider that narrows is attempting the coercion, so the honest report declares the capability and lets the scenario fail. A fixture is read as an example whether or not it is meant as one, and this was the fifth place in these branches where that example appeared. The deviation now names @configuration-change with a summary describing a provider that never subscribes and so can never report a change -- withheld because the behaviour cannot be attempted at all, which is the shape a withheld-and-skipped deviation is for. The assertion follows the tag; nothing else about the fixture moves. 279 tests, 0 failures, 43 skipped, unchanged. Signed-off-by: Simon Schrottner <simon.schrottner@flagsmith.com>
aepfli
force-pushed
the
feat/provider-tck-report
branch
from
September 13, 2026 19:22
f711236 to
a8bca76
Compare
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.
Stacked on #1830 (
feat/provider-tck). Part of open-feature/spec#424; the envelope this emits is defined by the schema in open-feature/spec#425.Sibling of #1847 (flagd adoption), not stacked above or below it: this branch adds reporting without changing the adopter-facing API, so the two do not conflict and either can land first. Verified —
git merge-treereports zero collisions between them.Also now carries the canonical-set guard.
CanonicalScenarioGuardfails a run that is set up to execute less than the canonical set — a tag filter, a selector override, or a feature file shadowing a canonical one. It belongs here rather than on the base for the same reason the report does: both exist so a conformance claim can be checked rather than trusted, and the guard is what makes the report's scenario count mean something. It arrived via #1846, which is now closed; the extension point from that PR went to #1830 instead.This has been reworked. The earlier revision defined its own per-scenario results format: a list of scenarios, each with a four-value outcome and the
Examplesrow it came from. That was the wrong call, and the review that said so was right. A results format has to be maintained, versioned and reimplemented in four languages, and everything it carried is already specified by Cucumber Messages. The schema in #425 has been reshaped accordingly and so has this.Set
PROVIDER_TCK_REPORT_DIRand each suite now writes two files:<configuration>.json<configuration>.ndjsonThe envelope's
results.locationnames the stream andresults.digestcovers it. Unset means no report, and that is not an error.Real output
Both flagd resolvers, on this branch, against the flagd testbed:
{ "schemaVersion" : "1", "provider" : { "name" : "flagd", "language" : "java", "configuration" : "flagd-rpc" }, "sdk" : { "name" : "dev.openfeature:sdk", "version" : "1.22.1" }, "tck" : { "implementation" : "java-sdk-contrib/tools/provider-tck", "version" : "0.0.1", "specRevision" : "dc4d7ae8df1c664f82a4adf46cd43812980c0da3" }, "backend" : { "description" : "Docker Compose stack docker-compose.yaml, service backend", "controlApi" : "http" }, "declaration" : { "declared" : [ "@lifecycle", "@events", "@stale", "@configuration-change", "@object", "@unavailable" ] }, "results" : { "format" : "cucumber-messages", "formatVersion" : "30.1.0", "location" : "flagd-rpc.ndjson", "digest" : "sha256:47c6e8297fd655200e07cdcc85f3314a1b22100dd7de7159d6e8716dd13699de" }, "knownDeviations" : [ { "capability" : "@numeric-coercion", "issue" : "https://github.com/open-feature/flagd/issues/1996", "summary" : "The lossy half of the coercion rule is not enforced: evaluating float-flag (0.5) through the integer API returns 0 with no error code, rather than TYPE_MISMATCH with the code default, so the fractional part is discarded silently. Lossless coercion is permitted and is not the defect. Both resolvers behave identically, which places it in the shared provider layer rather than in either transport." } ] }Reading the outcomes out of the stream, which needs one thing understood — a scenario's outcome is the most severe result among its steps, hooks included, because
testCaseFinishedcarries no status of its own:Identical for
flagd-in-process. 29 pickles, 29testCase, 29testCaseStarted, 29testCaseFinished, nothing started twice, nothing left unexecuted. The one skip in each is A float flag is not silently narrowed to an integer, and it is the aborted@Beforehook that makes it a skip: its step result isSKIPPEDand carries the gate's own message,Skipped: provider does not declare capability NUMERIC_COERCION (tag @numeric-coercion). That is the rule Appendix F cares about, and it is truthful in the stream.What the stream carries, and what it replaced
Scenario Outline row identity.
pickle.astNodeIdsis[scenario id, table row id], and the second entry resolves in thegherkinDocumentmessage to theExamplesrow the scenario was compiled from. For the eleven rows of Requesting the wrong type returns the code default inerrors.feature:Eleven distinct row ids for eleven scenarios sharing one name.
ScenarioExamplesand the report'sexamplefield are deleted: they re-parsed the feature source Cucumber publishes onTestSourceReadand matched a pickle's reported line number, viaTestCase.getLocation(), back against theExamplestables — an approximation, reverse-engineered fromCucumberQuery.getLocationBy, of exactly the mechanism the format already provides. That is the clearest argument for adopting a standard format rather than defining one, so it is worth saying rather than quietly dropping.Also deleted: the per-scenario list, the per-capability rollup, the four-value
Outcomeenum, andtck.assetsTree— the stream carries thesourceof every feature that executed, which is strictly better than a tree hash asserting which revision it came from.specRevisionstays, because it identifies the two artifacts the stream does not carry,flags/canonical-flags.jsonandopenapi/control-api.yaml.Tags, including per-
Examples-block tags, are onpickle.tagswith the AST node each came from. Gherkin allows a tag on an individualExamplesblock, so two rows of one outline can differ in whether the capability gate stops them; the self-test fixture has exactly that shape and asserts that only the tagged row is skipped.Cucumber's own formatter, at a path the run chooses
The stream is produced by
io.cucumber.core.plugin.MessageFormatter— the same class the built-inmessage:<path>plugin instantiates — so the bytes are what--plugin message:...would have written.ConformanceReportPluginregisters it against the same publisher and writes only the envelope.The built-in plugin is not used directly for one reason: a
@ConfigurationParametervalue is a compile-time constant, socucumber.plugin=message:<path>cannot have a path derived fromPROVIDER_TCK_REPORT_DIR, and flagd's two suites in one module would write to the same file. Delegating gets the standard bytes without giving up per-suite naming or the zero-configuration adoption.Two consequences worth naming. The stream is buffered in memory and written at the end, because the file name comes from the provider configuration, which the suite only reports once its runtime has started — after the first messages have been emitted. It is 330 KB here. And the envelope handler is registered after the formatter's, because Cucumber invokes handlers for one event type in registration order and the formatter closes its writer on the run-finished message; going second is what guarantees the digest covers a complete file.
What stays OpenFeature-specific, and why
The envelope is not a summary of the results. Every field in it answers a question no results format answers, because a Messages stream cannot say what it was a test of:
provider— what the provider calls itself through its own metadata, not the suite name. The suite name reads well in a failure message (flagd-rpc), which makes it the configuration; one provider with two materially different modes produces two reports that are not interchangeable.sdk— read from the classpath rather than declared, because the TCK depends on an SDK version range so that adopting it can never force an upgrade. What a consumer actually ran against is only knowable at runtime.tck— which implementation asked the questions, and which revision of the artifacts.declaration— the capability set the provider claims. This is the load-bearing one, and it is an input to reading the results rather than a summary of them, which is why it cannot be derived from the stream. The stream says a scenario was skipped; only the declaration says whether that is because the provider declines the capability it needed. Given the declaration and a scenario's tags — both present — the reason for each skip follows, so it does not have to be transported per scenario, which is what let the whole per-scenario list go.knownDeviationsis the one thing neither the stream nor the declaration can express. Withholding a capability reads identically whether it describes a limitation or works around a bug, and the TCK cannot tell the two apart from the outside. SoProviderTckHarness.knownDeviations()lets the provider author say, and flagd says it:@numeric-coercionis withheld because flagd narrows a float to an integer with no error code, in both resolvers, which places the defect in the shared provider layer rather than in either transport.Tracked, not merely named. The gap is flagd#1996, the ADR that settles what the rule is, so
KnownDeviation.tracked(...)carries the link. Silence was the alternative and it is worse: a consumer would read flagd declining@numeric-coercionexactly as it reads a provider with no streaming transport declining@configuration-change, and one of those is a decision while the other is a bug.KnownDeviation.untracked(...)remains for a defect with nothing to point at yet.Testing the property rather than the serialisation
ConformanceReportPluginTestno longer asserts over a report this code wrote. It runs a fixture suite through the real Cucumber engine on the JUnit Platform, with the real plugin registered, and reads the emitted stream back the way a consumer would. That change is the point: "a gated scenario is never reported as passed" is now a property of what Cucumber emits, and only a real run can demonstrate it. Asserting over hand-built messages would only have checked Cucumber's serialiser.The fixture is shaped like the suite rather than minimal — a capability tag on the feature, one on a scenario, one on a single
Examplesblock, and an outline whose rows share a name — because those are the shapes the properties depend on. Seventeen tests: every pickle executed exactly once, outcome counts, both gated scenariosSKIPPEDand neitherPASSED, the gate's reason present,Examples-block tags reaching the right row only, eleven distinct row ids resolving to the right cells, the executed source byte-identical to the file, the digest matching the stream, and the envelope carrying what the schema requires and nothing it forbids.The capability gate moved to
CapabilityGate.requireDeclaredso that the gate producing the skip and the test proving the skip survives are looking at the same code. Inlined in the step definitions, the self-test could only have shown that some abort becomes a skip.Verification
mvn -pl tools/provider-tck verify— green: 14 tests, 0 Checkstyle violations, PMD clean, SpotBugsBugInstance size is 0, spotless clean.mvn -pl providers/flagd test -Dtest='Flagd*TckTest'withPROVIDER_TCK_REPORT_DIRset — 58 scenarios, 0 failures, 2 skipped. Four files written, one pair per resolver, no collision.jsonschema4.10.3).results.digestmatchessha256sumof the corresponding.ndjsonfor both.jsonschema/messages.schema.jsonfromcucumber/messages@main— 744 messages each, zero invalid.testCase/ 29testCaseStarted/ 29testCaseFinished, no pickle executed twice or never, 28PASSED+ 1SKIPPED, and the one scenario carrying an undeclared capability tag is theSKIPPEDone.jqrecipes in the README were run against the real output and produce what they claim.Run on JDK 21 with Docker; the flagd suites need a Docker daemon, so they are not part of the module's own test run.
How this interacts with the rest of the stack
ConformanceReport,ConformanceReportPlugin,ScenarioExamples,Outcome— while the additions landed in three of the four files it does.ProviderTckHarnessgrew from 21 to 41 conflicted lines (knownDeviations()sits next tocapabilities(), which feat(provider-tck): in-process control path and in-memory/multi-provider self-tests #1837 also moves) andTckRuntimefrom 76 to 90 (recordRun). Two new files conflict:steps/ProviderSteps(one line, the gate call) andAbstractFlagdTckTest(two lines). All of it is mechanical.pom.xmlandREADME.mdstill merge cleanly despite both branches editing them. ThecontrolApi()follow-up described before still applies: it should become a default method onBackendControlreturning empty, overridden byHttpBackendControlwithhttpandInProcessBackendControlwithin-process.git rev-parse HEADin the same step that copies the artifacts.provider-tck.spec.assets-treeis gone, so there is one fewer thing to generate.Since the last review: two changes to what the declaration says
@strict-numeric-typingis now@numeric-coercion, and the rule is correctedThe tag was named for a stricter rule than the specification wants. flagd is implementing an accepted numeric coercion ADR (open-feature/flagd#1996) whose rule is that coercion is permitted when it is lossless and must fail with
TYPE_MISMATCHonly when information would be lost:10.0requested as an integer succeeds,0.5does not. Appendix F said "does not coerce between integer and float", which forbids the case the ADR requires to work — the one scenario survives the difference only because it asks about0.5, which does have a fractional part. flagd's own testbed is also gaining@numeric-coercionscenarios, so keeping the old name would have left the reference implementation and the specification disagreeing about what a rule is called.The spec side is open-feature/spec@
dc4d7ae8onfeat/provider-tck-appendix. This branch's vendoredsrc/main/resources/{features,flags,openapi}are byte-identical tospecification/assets/provider-tckat that revision modulo line endings, andprovider-tck.spec.revisionrecords it. That range also carried two unrelated changes now vendored here: the lifecycle readiness scenario's name, and the/startrequirement that a200means the seeded flag state is already being served.flagd's deviation moves from
untrackedtotrackedagainst flagd#1996, and its summary now says which half of the rule is broken — the defect is the lossy case being silently accepted, not coercion as such.No scenario is added, and two gaps are recorded as open rather than closed:
10.0as an integer declares the capability and passes.@int32-bounded. Neither Appendix F nor this suite has anything equivalent, and it is a real source of cross-language disagreement.A reserved capability can no longer be declared
@targetingand@cachingare reserved: they exist in the vocabulary so every language's TCK spells the same property the same way, but no scenario carries either tag. Such a capability cannot gate anything — it produces no skip, so nothing in a run can confirm or contradict it — and the report schema in #425 now says it must not be declared and must not appear indeclaration.declared.It was appearing. Before, from this branch:
After:
Nobody decided to claim the last two.
AbstractFlagdTckTestsaidEnumSet.complementOf(EnumSet.of(NUMERIC_COERCION)), which reads as "everything except the one thing flagd cannot do" and in fact means "every other enum constant", collecting both reserved tags on the way past. Both published reports claimed capabilities nothing had examined — the vacuous conformance claim the capability vocabulary exists to prevent.ProviderTckHarness.capabilities()defaulted toEnumSet.allOf, which had the same defect for any adopter who never overrode it.Capabilitynow carries the reserved flag itself, so the list cannot drift from the rule, and offers the two forms that mean whatcomplementOflooks like:declarable()anddeclarableExcept(...). The default becomesdeclarable().Naming a reserved capability explicitly fails the run, rather than being dropped with a warning. The declaration is the one part of the report no result can check — everything else in it was observed, this is asserted by the provider author — and a report is read long after the log a warning would have gone to. Nothing is lost by refusing, because no scenario carries the tag, and the check runs before the Compose stack starts, so the mistake costs seconds rather than a suite. It is enforced in
TckRunMetadata, the one place every path to a report passes through; the emitted list skips reserved capabilities as well, so "no reserved tag in a declaration" is a property of the code that writes the document and not only of a check upstream of it. Three tests cover it: the maximal declaration's emitteddeclaredcontains neither reserved tag, naming one throws with the tag in the message, anddeclarableExceptis contrasted with thecomplementOfit replaces.