Skip to content

feat(provider-tck): emit a machine-readable conformance report - #1841

Draft
aepfli wants to merge 14 commits into
feat/provider-tckfrom
feat/provider-tck-report
Draft

feat(provider-tck): emit a machine-readable conformance report#1841
aepfli wants to merge 14 commits into
feat/provider-tckfrom
feat/provider-tck-report

Conversation

@aepfli

@aepfli aepfli commented Aug 24, 2026

Copy link
Copy Markdown
Member

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-tree reports zero collisions between them.

Also now carries the canonical-set guard. CanonicalScenarioGuard fails 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 Examples row 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_DIR and each suite now writes two files:

File What it is
<configuration>.json the envelope — what was tested, and what the provider claims
<configuration>.ndjson the results — a Cucumber Messages stream

The envelope's results.location names the stream and results.digest covers it. Unset means no report, and that is not an error.

Real output

Both flagd resolvers, on this branch, against the flagd testbed:

$ PROVIDER_TCK_REPORT_DIR=./reports mvn -pl providers/flagd test -Dtest='Flagd*TckTest'
...
Tests run: 58, Failures: 0, Errors: 0, Skipped: 2

$ ls reports/
flagd-in-process.json  flagd-in-process.ndjson  flagd-rpc.json  flagd-rpc.ndjson
{
  "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 testCaseFinished carries no status of its own:

$ jq -c 'select(.testStepFinished) | .testStepFinished
         | {c: .testCaseStartedId, s: .testStepResult.status}' reports/flagd-rpc.ndjson \
    | jq -s 'group_by(.c) | map({s: (map(.s) | if any(. == "FAILED") then "FAILED"
                                              elif any(. == "SKIPPED") then "SKIPPED"
                                              else "PASSED" end)})
             | group_by(.s) | map({(.[0].s): length}) | add'
{
  "PASSED": 28,
  "SKIPPED": 1
}

Identical for flagd-in-process. 29 pickles, 29 testCase, 29 testCaseStarted, 29 testCaseFinished, 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 @Before hook that makes it a skip: its step result is SKIPPED and 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.astNodeIds is [scenario id, table row id], and the second entry resolves in the gherkinDocument message to the Examples row the scenario was compiled from. For the eleven rows of Requesting the wrong type returns the code default in errors.feature:

$ jq -c 'select(.pickle) | .pickle
         | select(.name == "Requesting the wrong type returns the code default")
         | {id, row: .astNodeIds[1]}' reports/flagd-rpc.ndjson
{"id":"6c8debd2-...","row":"ab8b4a4b-..."}   # -> ["string-flag","Boolean","false"]
{"id":"a7c76b0a-...","row":"63d6d6c8-..."}   # -> ["string-flag","Integer","1"]
{"id":"fecd333d-...","row":"bbd7f5ee-..."}   # -> ["string-flag","Float","0.1"]
{"id":"1a999e43-...","row":"0f899f28-..."}   # -> ["wrong-flag","Boolean","false"]
{"id":"32a4239b-...","row":"f88ca988-..."}   # -> ["boolean-flag","String","fallback"]
{"id":"f5b60fb7-...","row":"0597f6a3-..."}   # -> ["boolean-flag","Integer","1"]
{"id":"1bdf7aa2-...","row":"adee07be-..."}   # -> ["boolean-flag","Float","0.1"]
{"id":"392a66f7-...","row":"3409ce6f-..."}   # -> ["integer-flag","Boolean","false"]
{"id":"c9efc3ab-...","row":"adba2cac-..."}   # -> ["integer-flag","String","fallback"]
{"id":"94c6c1a0-...","row":"48bbee15-..."}   # -> ["float-flag","Boolean","false"]
{"id":"6acf3b06-...","row":"ef0378ff-..."}   # -> ["float-flag","String","fallback"]

Eleven distinct row ids for eleven scenarios sharing one name. ScenarioExamples and the report's example field are deleted: they re-parsed the feature source Cucumber publishes on TestSourceRead and matched a pickle's reported line number, via TestCase.getLocation(), back against the Examples tables — an approximation, reverse-engineered from CucumberQuery.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 Outcome enum, and tck.assetsTree — the stream carries the source of every feature that executed, which is strictly better than a tree hash asserting which revision it came from. specRevision stays, because it identifies the two artifacts the stream does not carry, flags/canonical-flags.json and openapi/control-api.yaml.

Tags, including per-Examples-block tags, are on pickle.tags with the AST node each came from. Gherkin allows a tag on an individual Examples block, 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-in message:<path> plugin instantiates — so the bytes are what --plugin message:... would have written. ConformanceReportPlugin registers it against the same publisher and writes only the envelope.

The built-in plugin is not used directly for one reason: a @ConfigurationParameter value is a compile-time constant, so cucumber.plugin=message:<path> cannot have a path derived from PROVIDER_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.

knownDeviations is 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. So ProviderTckHarness.knownDeviations() lets the provider author say, and flagd says it: @numeric-coercion is 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-coercion exactly 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

ConformanceReportPluginTest no 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 Examples block, 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 scenarios SKIPPED and neither PASSED, 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.requireDeclared so 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, SpotBugs BugInstance size is 0, spotless clean.
  • mvn -pl providers/flagd test -Dtest='Flagd*TckTest' with PROVIDER_TCK_REPORT_DIR set — 58 scenarios, 0 failures, 2 skipped. Four files written, one pair per resolver, no collision.
  • Both envelopes valid against the reshaped schema from chore(deps): update actions/cache digest to 704facf #425 with a Draft 2020-12 validator (python jsonschema 4.10.3).
  • results.digest matches sha256sum of the corresponding .ndjson for both.
  • Both streams valid against the published Cucumber Messages schema, jsonschema/messages.schema.json from cucumber/messages@main — 744 messages each, zero invalid.
  • Stream accounting per resolver: 29 pickles / 29 testCase / 29 testCaseStarted / 29 testCaseFinished, no pickle executed twice or never, 28 PASSED + 1 SKIPPED, and the one scenario carrying an undeclared capability tag is the SKIPPED one.
  • Both jq recipes 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

Since the last review: two changes to what the declaration says

@strict-numeric-typing is now @numeric-coercion, and the rule is corrected

The 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_MISMATCH only when information would be lost: 10.0 requested as an integer succeeds, 0.5 does 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 about 0.5, which does have a fractional part. flagd's own testbed is also gaining @numeric-coercion scenarios, 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@dc4d7ae8 on feat/provider-tck-appendix. This branch's vendored src/main/resources/{features,flags,openapi} are byte-identical to specification/assets/provider-tck at that revision modulo line endings, and provider-tck.spec.revision records it. That range also carried two unrelated changes now vendored here: the lifecycle readiness scenario's name, and the /start requirement that a 200 means the seeded flag state is already being served.

flagd's deviation moves from untracked to tracked against 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:

  • The lossless case has no scenario. The canonical flag set contains no integral float to ask it of, and adding one changes the flag set for every language at once — so a provider that wrongly rejects 10.0 as an integer declares the capability and passes.
  • Accessor width is unmodelled. The ADR distinguishes a 64-bit integer accessor from a 32-bit one, which flagd's testbed tags @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

@targeting and @caching are 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 in declaration.declared.

It was appearing. Before, from this branch:

"declared": ["@lifecycle","@events","@stale","@configuration-change","@object","@unavailable","@targeting","@caching"]

After:

"declared": ["@lifecycle","@events","@stale","@configuration-change","@object","@unavailable"]

Nobody decided to claim the last two. AbstractFlagdTckTest 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 to EnumSet.allOf, which had the same defect for any adopter who never overrode it.

Capability now carries the reserved flag itself, so the list cannot drift from the rule, and offers the two forms that mean what complementOf looks like: declarable() and declarableExcept(...). The default becomes declarable().

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 emitted declared contains neither reserved tag, naming one throws with the tag in the message, and declarableExcept is contrasted with the complementOf it replaces.

@coderabbitai

coderabbitai Bot commented Aug 24, 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.

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
aepfli force-pushed the feat/provider-tck-report branch from f711236 to a8bca76 Compare September 13, 2026 19:22
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.

5 participants