Repository navigation
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: true
|
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
aepfli
force-pushed
the
feat/provider-tck-report
branch
3 times, most recently
from
September 14, 2026 07:41
73eae82 to
0a18a0b
Compare
aepfli
force-pushed
the
feat/provider-tck
branch
from
September 14, 2026 14:00
ccf78a3 to
b9f5150
Compare
aepfli
force-pushed
the
feat/provider-tck-report
branch
3 times, most recently
from
September 16, 2026 07:45
dc6fcef to
6532ada
Compare
aepfli
force-pushed
the
feat/provider-tck
branch
from
September 16, 2026 19:49
3e0553e to
92fefde
Compare
aepfli
force-pushed
the
feat/provider-tck-report
branch
from
September 16, 2026 19:49
6532ada to
72b1e9c
Compare
aepfli
force-pushed
the
feat/provider-tck-report
branch
2 times, most recently
from
October 2, 2026 08:57
46a2785 to
2b7f52d
Compare
aepfli
force-pushed
the
feat/provider-tck-report
branch
from
October 8, 2026 08:19
2b7f52d to
99b3be3
Compare
aepfli
force-pushed
the
feat/provider-tck
branch
from
October 9, 2026 07:38
bb4eb19 to
4020dc9
Compare
aepfli
force-pushed
the
feat/provider-tck-report
branch
from
October 9, 2026 07:38
99b3be3 to
02d4be9
Compare
aepfli
force-pushed
the
feat/provider-tck
branch
from
October 14, 2026 13:23
4020dc9 to
2f23e29
Compare
aepfli
force-pushed
the
feat/provider-tck-report
branch
2 times, most recently
from
October 14, 2026 14:32
a464333 to
ac361fd
Compare
aepfli
force-pushed
the
feat/provider-tck
branch
from
October 16, 2026 15:41
eab7f16 to
39314fd
Compare
aepfli
force-pushed
the
feat/provider-tck-report
branch
4 times, most recently
from
October 16, 2026 17:16
f93b2eb to
b765641
Compare
toddbaert
force-pushed
the
feat/provider-tck
branch
from
October 19, 2026 17:53
8d25760 to
0ebcd38
Compare
aepfli
force-pushed
the
feat/provider-tck-report
branch
2 times, most recently
from
November 2, 2026 10:23
05b8c68 to
9028198
Compare
Collapsed from 29 commits when this branch was replayed onto main, where the base suite landed squashed and reworked: TckRuntime is gone, the steps moved to internal.steps behind TckLifecycle, and the comments were culled. Most of the commits followed spec pins and base revisions that no longer exist, so one squashed replay meant one conflict pass, following the js-sdk-contrib replay of the same branch. What moved with the base: - The run metadata TckRuntime used to hold now lives on TckRunMetadata. TckLifecycle records it when a suite starts, and ProviderSteps records the provider's own name. Its two static entry points are public only because the lifecycle sits in the internal package, the pattern AdopterTags and CanonicalScenarioGuard.observe follow. - The report writes backend.backendType from BackendControl.backendType(), so a control that states none is reported as "custom", matching the schema change in spec#425. - The report self-test gates through Capability.requireDeclared, which replaced CapabilityGate. - The README sections are cut to main's style. The original subjects, oldest first: - feat(provider-tck): emit a machine-readable conformance report - feat(provider-tck): fail a run that executes less than the canonical set - chore(provider-tck): follow the spec submodule bump in the reported revision - fix(provider-tck): follow the capability model in the declaration test - chore(provider-tck): follow the spec submodule bump in the reported revision - test(provider-tck): follow the capability change in the declaration test - chore(provider-tck): follow the disabled-flag capability in the report - refactor(tck): follow the rename into the reporting machinery - refactor(tck): always write the backend block, and type controlApi - refactor(tck)!: TCK knobs lose the PROVIDER_ prefix - feat(tck): report the revision that gated the reasons - feat(tck): close the loop from the gitlink to the revision a report names - chore(tck): follow the re-pin to aa2ad24f in the revision a report names - test(tck): stop the report fixture depicting the shape the guidance discourages - docs(tck): stop naming an adoption's class in the report's own docs - docs(tck): follow the base README rewrite into the reporting section - docs(tck): stop naming a canonical count the suite has already outgrown - docs(tck): drop two notes about what a previous revision said - chore(tck): follow the re-pin to d47a66eb in the revision a report names - chore(tck): follow the re-pin to bda599f1 in the revision a report names - chore(tck): follow the spec pin in the revision a report names - chore(tck): pin the assets branch in the revision a report names - chore(tck): follow the assets pin in the revision a report names - chore(tck): move the recorded spec revision with the re-pin - chore(tck): move the recorded spec revision with the re-pin - chore(tck): follow the spec pin to the current #423 tip - chore(tck): pin the spec assets to main now that #423 has merged - chore(tck): follow the spec assets to their first release, v0.1.0 - style(provider-tck): wrap the tag assertion the way spotless wants Signed-off-by: Simon Schrottner <simon.schrottner@flagsmith.com>
Set TCK_REPORT_DIR for the TCK run, summarise each suite's envelope and Cucumber Messages stream into the job summary, and upload both as an artifact. A scenario counts by its most severe step, as the report plugin defines it. Signed-off-by: Simon Schrottner <simon.schrottner@flagsmith.com>
Signed-off-by: Simon Schrottner <simon.schrottner@flagsmith.com>
aepfli
force-pushed
the
feat/provider-tck-report
branch
from
November 2, 2026 21:16
690b781 to
3b5b59d
Compare
This branch has not been deployed
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
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/tck", "version" : "0.0.1", "specRevision" : "8374621763f04a3d0785a911f12035db4794179e" }, "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 fromTCK_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/tck verify— green: 14 tests, 0 Checkstyle violations, PMD clean, SpotBugsBugInstance size is 0, spotless clean.mvn -Ptck -pl providers/flagd testwithTCK_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.
Three schema changes since this was opened
All three are in spec#425 and this branch emits against them.
backend.controlApiis required. A provider with no backend still had its flag state manipulated somehow, and which of the two ways that was is the single most important thing a reader needs in order to know what the results are worth. The old shape made the wholebackendblock omissible, which made the one value most worth knowing —in-process, the case the enum exists for — the one that could never appear. It is now stated by the control rather than inferred:BackendControl.controlApi(),httpfrom the HTTP control andin-processfrom the in-process one.summaryis required, and its issue link is not. A deviation with no prose is a line of tags that tells a reader nothing; a deviation with no filed issue is an honest state to be in, and requiring one encourages filing a placeholder.@large-integershere, because Java's integer accessor is a 32-bitIntegerand no Java provider can be asked for 2^53 − 1 — which is a property of the SDK rather than of the provider, and a reader who conflated the two would read a language's limit as a provider's decision.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) and flagd's shared resolver base class (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@
83746217, the assets branch behind open-feature/spec#423. The assets arrive through thetools/tck/specsubmodule rather than vendored copies, the gitlink andPINNED_REVISIONmove together, andtck.spec.revisionis what a report records asspecRevision. An earlier revision of this paragraph described vendoredsrc/main/resources/{features,flags,openapi}and aprovider-tck.spec.revisionproperty; neither exists any more.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 was added at the time, and two gaps were recorded as open. One has since closed:
The lossless case has no scenario.Closed. The canonical flag set gainedintegral-float-flag, anderrors.featurenow carries three@numeric-coercionscenarios — the lossy one,10.0requested as an integer, and10requested as a float. A provider declaring the tag must satisfy all three, which is what stops the shortcut of rejecting every float and calling it strictness. Two of the four OFREP adoptions turned out to be taking exactly that shortcut, so the two lossless scenarios earned their place immediately.Accessor width is modelled, and what remains open is narrower than it looks. The ADR
distinguishes a 64-bit integer accessor from a 32-bit one, and flagd's testbed tags the 32-bit-only
scenarios
@int32-bounded. Appendix F draws the same boundary from the other side: every language'saccessor can ask for 2^31 − 1, so that precision scenario is untagged and mandatory; only some
can ask for 2^53 − 1, so that one carries
@large-integers, which a provider on a 32-bitaccessor leaves undeclared. Mandatory-versus-gated instead of two tags, and no tag needed for the
common case.
What is genuinely open is the negative half: nothing asserts what a 32-bit-accessor provider owes
when asked for a value it cannot represent. The scenario simply skips, so a provider that silently
truncates passes exactly like one that returns
TYPE_MISMATCH. That is deliberate rather thanoverlooked — spec#430 has not settled what a
provider owes a value that does not fit the requested accessor, and asserting it here would be this
suite inventing a rule the specification does not have.
A reserved capability can no longer be declared
@cachingis reserved: it exists in the vocabulary so every language's TCK spells the same property the same way, but no scenario carries the tag.@targetingwas reserved too when this was written and stopped being so at spec26362f85, once scenarios started carrying it — which is the shape a reserved tag is meant to have: a placeholder that either grows scenarios or is removed, never a claim a report can carry indefinitely. The examples below are from before that change and show both. 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. the shared flagd base class said
EnumSet.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.