Skip to content

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

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

aepfli wants to merge 21 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 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:

$ TCK_REPORT_DIR=./reports mvn -Ptck -pl providers/flagd test
...
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/tck",
    "version" : "0.0.1",
    "specRevision" : "bda599f1db440aa8d395d1d3af7b9b3cc3103b98"
  },
  "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 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/tck verify — green: 14 tests, 0 Checkstyle violations, PMD clean, SpotBugs BugInstance size is 0, spotless clean.
  • mvn -Ptck -pl providers/flagd test with 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.

Three schema changes since this was opened

All three are in spec#425 and this branch emits against them.

  • backend.controlApi is 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 whole backend block 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(), http from the HTTP control and in-process from the in-process one.
  • A known deviation's summary is 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.
  • The declaration no longer claims to explain every skip. It explains the skips that come from an undeclared capability. It does not explain a skip the implementation refuses — @large-integers here, because Java's integer accessor is a 32-bit Integer and 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

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@aa2ad24f 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 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 gained integral-float-flag, and errors.feature now carries three @numeric-coercion scenarios — the lossy one, 10.0 requested as an integer, and 10 requested 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's
    accessor 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-bit
    accessor 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 than
    overlooked — 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

@caching is reserved: it exists in the vocabulary so every language's TCK spells the same property the same way, but no scenario carries the tag. @targeting was reserved too when this was written and stopped being so at spec 26362f85, 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 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. 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 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.

@aepfli
aepfli force-pushed the feat/provider-tck-report branch 3 times, most recently from 73eae82 to 0a18a0b Compare September 14, 2026 07:41
@aepfli
aepfli force-pushed the feat/provider-tck-report branch 3 times, most recently from dc6fcef to 6532ada Compare September 16, 2026 07:45
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>
The envelope section illustrated configuration() with `FlagdInProcessTckTest`
-> `flagd-in-process`. That class is gone: the flagd adoption now lives in a
`tck` package of its own and its suites are `RpcTest` and `InProcessTest`.

It was the only line on this branch that reached into an adoption, and it is
the reason to use a made-up class here instead. The configuration names in the
rest of the section are untouched and still correct -- flagd's suites state
`flagd-rpc` and `flagd-in-process` rather than deriving them, precisely so a
report does not start saying `in-process` when a class is renamed.

Signed-off-by: Simon Schrottner <simon.schrottner@flagsmith.com>
Three references the rewrite broke or made stale, all of them this
branch pointing into prose the base branch now spells differently.

"How the backend was driven" became "Identifying the run", and
"Saying that a withheld capability is a defect" became "Known
deviations", so both anchors dangled. And the report example still
selected the flagd suites by the filename pattern the directory move
removed - -Dtest='Flagd*TckTest' matches nothing now, so a reader
copying it would have got an empty reports/ directory and no error. It
is the documented -Ptck command instead.

No content moved and no count changed.

Signed-off-by: Simon Schrottner <simon.schrottner@flagsmith.com>
The sentence illustrating why the canonical set cannot be reduced said "fifty-four
of the fifty-six questions". The canonical set was 56 when that was written and is
65 now, so the illustration named a number the suite had already left behind -- and
would do so again at the next addition. The point does not need a count, so it no
longer carries one.

Signed-off-by: Simon Schrottner <simon.schrottner@flagsmith.com>
One recorded that an earlier revision of this class described `backend`
as omitted for a backend-less provider; the field is required and the
javadoc now just says so. The other named Go's and Python's Cucumber
Messages pins, where what the field needs to convey is that the pins
differ at all.

Comments only. 279 tests / 43 skipped, unchanged.

Signed-off-by: Simon Schrottner <simon.schrottner@flagsmith.com>
tck.spec.revision is what a conformance report publishes as the source of its
scenarios, and ConformanceReportPluginTest checks it against PINNED_REVISION
where it reads it back, so it moves with the pin or the build says so.

Two expectations the pin moved with it. The type-mismatch matrix is eight rows
rather than eleven, the three "requested as a String" rows having become
@string-typing scenarios of their own; and the maximal declaration a Java
provider can publish now carries @string-typing between @numeric-coercion and
@targeting, which is the assertion that would otherwise let a new capability
reach a report unnoticed.

Signed-off-by: Simon Schrottner <simon.schrottner@flagsmith.com>
tck.spec.revision is the fourth thing a re-pin moves, after the submodule
gitlink, PINNED_REVISION and PINNED_DIGEST. ConformanceReportPluginTest
already fails the build when it disagrees with PINNED_REVISION, so this is
the check reporting rather than a convention being remembered.

The maximal-claim assertion gains @fully-typed-values, which is not
bookkeeping: it is containsExactly over Capability.declarable() in
vocabulary order, so a new declarable capability has to be named here or the
report's most complete declaration is not the most complete declaration.

CanonicalScenarioGuardTest's count of eight is unchanged and its note about
d47a66eb still describes what that revision did, so neither moves. The split
took a row out of one outline and gave it a scenario of its own, which leaves
the canonical total where it was.

Signed-off-by: Simon Schrottner <simon.schrottner@flagsmith.com>
The fourth of the coordinated moves: gitlink and PINNED_REVISION are on the base
branch, this property is what a report records as tck.specRevision.

Signed-off-by: Simon Schrottner <simon.schrottner@flagsmith.com>
@aepfli
aepfli force-pushed the feat/provider-tck-report branch from 6532ada to 72b1e9c Compare September 16, 2026 19:49

This branch has not been deployed

No deployments
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