Skip to content

refactor(provider-tck): emit a conformance report, with results in Cucumber Messages - #944

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

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

Conversation

@aepfli

@aepfli aepfli commented Aug 24, 2026

Copy link
Copy Markdown
Member

Emits a machine-readable conformance report for each suite run, when
TCK_REPORT_DIR is set. Unset means no report, which is the default and is not
an error. It gates an output, not the run: the suite runs either way, and
which target ran it is make tck's business rather than an environment
variable's.

Tracking: open-feature/spec#417. Appendix F: open-feature/spec#423. Report
format: open-feature/spec#424. Schema: open-feature/spec#425.

What a run writes

Two files. An envelope at <dir>/<name>.json, conforming to the schema in
open-feature/spec#425, and the results it references at <dir>/<name>.ndjson,
which is a Cucumber Messages stream.

{
  "schemaVersion": "1",
  "provider": { "name": "InMemoryProvider", "language": "go", "configuration": "in-memory" },
  "sdk": { "name": "github.com/open-feature/go-sdk", "version": "v1.18.0" },
  "tck": {
    "implementation": "go-sdk-contrib/tools/tck",
    "version": "v0.1.0",
    "specRevision": "4cab032043f9b2335a023310b1010b20d7a7d0d9"
  },
  "backend": {
    "description": "the Go SDK's memprovider.InMemoryProvider, rebuilt per scenario",
    "controlApi": "in-process"
  },
  "declaration": { "declared": ["@events", "@numeric-coercion", "@object"] },
  "results": {
    "format": "cucumber-messages",
    "location": "in-memory.ndjson",
    "digest": "sha256:4754d458ac5a1a137080082b7947f8f1eafc5df9f6d54551440da9f8ac6a0dce"
  }
}

The envelope identifies what was tested and what the provider claims. It does
not contain the results. A Messages stream carries the feature sources and so is
far larger than the envelope, and a consumer deciding whether it cares about a
report should not have to fetch a whole run to find out. results.digest covers
the payload byte for byte.

Why the results are a standard format

This PR previously defined its own: a per-scenario list, a four-value outcome
enum, and a field naming the Examples row an entry came from. All three are
gone. The per-scenario outcome, the tags, the executed feature source and the
identity of a Scenario Outline row are already specified by Cucumber Messages,
which is maintained, cross-language, schema'd, and emitted natively by
cucumber-jvm. Restating them here meant a second format to version, and two
places for the same fact to disagree.

Reading the results needs no bespoke tooling: every scenario is a pickle,
every result is a testCase with one testStepFinished per step, and a
scenario's outcome is the most severe of its step results, which is how Cucumber
itself derives it.

example is removed because Messages already carries row identity. A
pickle's astNodeIds end with the id of the Examples TableRow it was expanded
from, and the stream carries the gherkinDocument those ids belong to, so the
row's cells are recoverable from the stream alone. Four implementations had each
reinvented that field independently, one of them by reverse-engineering how its
runner maps a pickle back to a table row.

Removing it removed a coupling too. Recovering the row from a *godog.Scenario
meant reparsing the embedded feature files and reproducing the AST node ids
godog assigns, which come from a counter godog shares across the files it
parses, so reproducing them meant reproducing godog's whole parse. That is what
pkg/tck/examples.go did, and it is deleted. A formatter is handed the same
gherkinDocument godog compiled the pickles from, so the ids agree by
construction rather than by imitation.

assetsTree is removed because Messages carries the executed source. The
tree hash existed so a consumer could tell which questions a report answers
without trusting the recorded commit. The stream's source messages answer that
with the feature text rather than with a hash of it, and unlike the hash cannot
be asserted wrongly, because it is the input godog parsed.

What stays OpenFeature-specific, and why

The declaration. declaration.declared is an input to reading the results
rather than a summary of them, which is why it cannot be derived from the
payload. A SKIPPED scenario says the question was not put to this provider;
only the declaration says whether that is because the provider declines the
capability. Given the declaration and a scenario's tags, which the stream
carries, the reason for a skip follows without being transported per scenario.

The tested subject. No standard results format has a slot for the provider
under test: Messages records the runner, the runtime and the OS, not the
subject. So provider, sdk, tck and backend stay in the envelope.

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

tck.StrictNumericTyping becomes tck.NumericCoercion and its tag becomes
@numeric-coercion. The spec submodule moved to dc4d7ae8 for this change and the embedded assets
were re-synced from it, so SpecRevision changed too. It has since advanced to 4cab0320, which is
what the envelope above records; the assets are byte-identical across that range.

The old name was chosen 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
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
previously said "does not coerce between integer and float", which forbids the
case the ADR requires to work. flagd's own testbed is gaining
@numeric-coercion scenarios as part of that work, so keeping a second name for
one property was drift, not caution.

When this was written the tag had one scenario, asking about 0.5 — the lossy
half — and this section recorded "the lossless case has no scenario" as an open
gap. That gap is closed. The canonical 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 — the shortcut two of the four
OFREP adoptions turned out to be taking.

One gap does remain, and Appendix F records it rather than closing it here:

  • 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.

The capability's doc comment says so.

A reserved capability can no longer be declared

@caching is reserved: part of the vocabulary, carried by no scenario. It was
not the only one when this was written — @targeting was reserved too, and
stopped being so at spec 26362f85, when scenarios started carrying it. That 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. Appendix F now states plainly that such a tag must not be declared and
must not appear in a report's declaration — nothing carries it, so declaring it
cannot be verified, cannot even produce a skip, and tells a reader of the report
only that something was claimed and nothing examined.

This was a live defect rather than a hypothetical one. A Java conformance report
asserts both tags as declared, not by anyone's decision but because that
adoption declares "every capability except X" and collects every reserved tag on
the way past. AllCapabilities() had the same shape, and it is the default when
Config.Capabilities is nil.

  • The reserved set is named once, beside the constants, and exposed as
    Capability.IsReserved.
  • AllCapabilities() omits it, which also fixes the nil default. Its doc
    comment says what it now returns and why.
  • An adopter who names a reserved capability outright is failed, not warned.
    The check lives in newCapabilitySet, which Config.validate calls, so the
    run stops before any scenario with the same message shape as any other
    configuration problem. Warning would leave the claim in a published report,
    which is the outcome the rule exists to prevent, and the fix is to delete one
    line.
  • Putting the check there rather than only in Config.validate is deliberate:
    the capability set is the only thing declaration.declared is built from, so
    a reserved tag now has no path into a report at all, and there is no second
    route that could drift from the rule.

The Messages formatter

godog 0.15.1 has no Messages formatter. It registers cucumber (the legacy
relishapp JSON), events, junit, pretty and progress. So
pkg/tck/messages.go is one, registered through the public godog.Format
plugin interface and consuming the formatter event stream. Nothing in it
implements the Messages specification: the types come from
github.com/cucumber/messages/go/v21, which this module already depended on. It
is a candidate for contributing upstream to godog.

godog's JUnit output was not a usable fallback: a capability-gated skip comes
out as skipped="0" on the suite, in a non-standard <error type="skipped">
element, with the step text where the skip reason should be.

That is not a coincidence, and it is the reason the events are the right source.
For the first step of a gated scenario godog inserts a FAILED step result into
its internal storage and calls Skipped on the formatter. The built-in
formatters read storage, which is where the malformed JUnit comes from; a
formatter built on the events sees one SKIPPED. External formatters cannot
read storage anyway, since SetStorage takes an internal type, which here is a
feature.

Two limits of the formatter interface, both worked around and both documented in
the file:

  • No scenario-finished event. The interface has TestRunStarted, Feature,
    Pickle, the per-step results and Summary, and nothing that fires when a
    scenario ends. The stream is therefore accumulated and written at Summary
    rather than streamed, which also lets the envelopes be emitted in the
    conventional order. It assumes serial execution, which this suite already
    enforces for unrelated reasons.
  • No reason for a skip. Skipped(pickle, step, definition) carries no
    error, so the capability that gated a scenario is unrecoverable from the
    events. The reason comes from the capability gate itself and lands in
    TestStepResult.message. This is the one part of the formatter that is
    specific to this suite rather than general.

The skip is truthful

This is the whole reason the report exists. godog counts a capability-gated skip
in its passed tally:

29 scenarios (29 passed)

Five of those twenty-nine did not run. Appendix F is unambiguous that such a
scenario is reported as skipped with the reason and never as passed.

The stream for that same run reports 24 PASSED and 5 SKIPPED, accounts for
all 29 scenarios exactly once, and gives every skip a reason naming the
capability that caused it. At step level, 182 PASSED and 26 SKIPPED.

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 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. ReportBackend.ControlAPI is never empty: the control
    states it, nothing infers it.
  • 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 in Java, @numeric-coercion in
    JavaScript — 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.

Verification

  • gofmt, go build ./..., go vet ./... clean; package tests pass, including
    under -race.
  • Three suites emitted real reports. All three envelopes validate against the
    schema with a Draft 2020-12 validator, and every results.digest matches its
    payload.
  • Each stream, 543 messages (meta, 4 source, 4 gherkinDocument, 29
    pickle, 29 each of testCase/testCaseStarted/testCaseFinished, 208 each
    of testStepStarted/testStepFinished, testRunStarted,
    testRunFinished), validates against the published Cucumber Messages
    v21.0.1 JSON schemas with zero errors — and also against 24.1.0, a
    newer major than the types that produced it, so the stream is readable by a
    current cucumber toolchain and not only by the version it was emitted with.
  • The eleven rows of "Requesting the wrong type returns the code default" in
    errors.feature resolve to eleven distinct astNodeIds and eleven distinct
    Examples rows, recovered from the stream's own gherkinDocument.

For the two changes above:

  • make provider-tck-assets-check passes after the submodule bump. The synced
    assets contain @numeric-coercion and no occurrence of
    @strict-numeric-typing.
  • Outcome counts over the three streams are unchanged in shape:
    controllable-in-memory 26 PASSED / 3 SKIPPED, in-memory 24 / 5,
    multi-provider 25 / 4. The renamed scenario, "A float flag is not silently
    narrowed to an integer", is reported PASSED under @numeric-coercion in all
    three, and every gated skip is still SKIPPED with a reason naming its
    capability.
  • declaration.declared in all three envelopes contains neither @targeting
    nor @caching.
  • Four tests cover the reserved rule: AllCapabilities() excludes exactly the
    reserved set and nothing else, the nil default excludes it, Config.validate
    rejects each reserved capability by name, and the declaration built by
    buildReport from the default set carries no reserved tag. Removing the
    exclusion from AllCapabilities() fails three of them.

The submodule bump also picks up two unrelated spec changes between dfa16586
and dc4d7ae8: the lifecycle readiness scenario is renamed to "A provider that
successfully initializes becomes ready", and the control API's POST /start
gains the requirement that it not return until the seeded flag state is being
served. Nothing in this module referenced the old scenario name, and no Go code
implements /start.

Tests were rewritten accordingly: the assertions about scenarios[], the four
outcomes and example are gone, replaced by assertions over the Messages
stream. The two properties that matter are kept and asserted directly, that no
gated scenario is ever reported as passed and that every scenario is accounted
for exactly once. The internal tests drive the formatter through godog's event
sequence to cover the FAILED path no passing suite reaches.

@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 added a commit to open-feature/js-sdk-contrib that referenced this pull request Aug 24, 2026
…ng untested ones

Three defects in the capability rollup, all of them ways the report could report
a result the suite never obtained.

A failed capability was emitted as {"state": "failed"} with no reason. The schema
requires a reason for any outcome other than passed, so that entry does not
validate -- and it appears only when a provider is actually failing, which is
precisely when the report matters. It now says how many of how many scenarios
carrying the tag failed, and points at the per-scenario results for which and why.

No test caught it because every self-test suite passes, so nothing that runs end
to end reaches that branch. The new test drives the report builder directly with
synthetic records, which is the only way to exercise a failure without breaking a
provider on purpose.

A declared capability that no scenario carries was reported as passed. @targeting
is reserved -- it is in the vocabulary and nothing tests it, because asserting
that an evaluation context reached the backend needs an echo operation the control
API does not have -- so a provider declaring it got a green result for a claim
nothing had examined.

A declared capability whose every scenario was skipped for a *different* one was
likewise reported as passed, because the rollup counted a capability as exercised
by tag presence rather than by execution. Both scenarios in events.feature carry
@events as well as @Stale or @configuration-change, so the in-memory suite
reported @events as passed while neither scenario ran.

Both are the vacuous pass the capability vocabulary was introduced to eliminate,
arriving through the report rather than through the suite. A scenario now counts
towards a capability only if it actually ran, and a declared capability with
nothing to show is omitted. The suite asked no question of it, so it has no answer
to report, and a consumer sees the tag is absent rather than a pass it cannot rely
on. Omitting is preferred to inventing a fifth outcome: the four in the schema are
about what the provider did, and "this run demonstrated nothing" is a fact about
the run.

An undeclared capability is still reported as not-declared with its reason, and
not-applicable is unaffected -- @strict-numeric-typing being unanswerable in a
language with no integer type is a different statement from a capability nothing
exercised, and both need saying.

Matches the same fix in Go, open-feature/go-sdk-contrib#944.

Signed-off-by: Simon Schrottner <simon.schrottner@flagsmith.com>
@aepfli aepfli changed the title feat(provider-tck): emit a machine-readable conformance report refactor(provider-tck): emit a conformance report, with results in Cucumber Messages Sep 10, 2026
@aepfli
aepfli force-pushed the feat/provider-tck-report branch from e037280 to cb69ccf Compare September 11, 2026 07:37
aepfli added a commit to open-feature/js-sdk-contrib that referenced this pull request Sep 11, 2026
…ng untested ones

Three defects in the capability rollup, all of them ways the report could report
a result the suite never obtained.

A failed capability was emitted as {"state": "failed"} with no reason. The schema
requires a reason for any outcome other than passed, so that entry does not
validate -- and it appears only when a provider is actually failing, which is
precisely when the report matters. It now says how many of how many scenarios
carrying the tag failed, and points at the per-scenario results for which and why.

No test caught it because every self-test suite passes, so nothing that runs end
to end reaches that branch. The new test drives the report builder directly with
synthetic records, which is the only way to exercise a failure without breaking a
provider on purpose.

A declared capability that no scenario carries was reported as passed. @targeting
is reserved -- it is in the vocabulary and nothing tests it, because asserting
that an evaluation context reached the backend needs an echo operation the control
API does not have -- so a provider declaring it got a green result for a claim
nothing had examined.

A declared capability whose every scenario was skipped for a *different* one was
likewise reported as passed, because the rollup counted a capability as exercised
by tag presence rather than by execution. Both scenarios in events.feature carry
@events as well as @Stale or @configuration-change, so the in-memory suite
reported @events as passed while neither scenario ran.

Both are the vacuous pass the capability vocabulary was introduced to eliminate,
arriving through the report rather than through the suite. A scenario now counts
towards a capability only if it actually ran, and a declared capability with
nothing to show is omitted. The suite asked no question of it, so it has no answer
to report, and a consumer sees the tag is absent rather than a pass it cannot rely
on. Omitting is preferred to inventing a fifth outcome: the four in the schema are
about what the provider did, and "this run demonstrated nothing" is a fact about
the run.

An undeclared capability is still reported as not-declared with its reason, and
not-applicable is unaffected -- @strict-numeric-typing being unanswerable in a
language with no integer type is a different statement from a capability nothing
exercised, and both need saying.

Matches the same fix in Go, open-feature/go-sdk-contrib#944.

Signed-off-by: Simon Schrottner <simon.schrottner@flagsmith.com>
@aepfli
aepfli force-pushed the feat/provider-tck-report branch 5 times, most recently from 6bc4c55 to e794277 Compare September 11, 2026 11:09
aepfli added a commit to open-feature/js-sdk-contrib that referenced this pull request Sep 11, 2026
…ng untested ones

Three defects in the capability rollup, all of them ways the report could report
a result the suite never obtained.

A failed capability was emitted as {"state": "failed"} with no reason. The schema
requires a reason for any outcome other than passed, so that entry does not
validate -- and it appears only when a provider is actually failing, which is
precisely when the report matters. It now says how many of how many scenarios
carrying the tag failed, and points at the per-scenario results for which and why.

No test caught it because every self-test suite passes, so nothing that runs end
to end reaches that branch. The new test drives the report builder directly with
synthetic records, which is the only way to exercise a failure without breaking a
provider on purpose.

A declared capability that no scenario carries was reported as passed. @targeting
is reserved -- it is in the vocabulary and nothing tests it, because asserting
that an evaluation context reached the backend needs an echo operation the control
API does not have -- so a provider declaring it got a green result for a claim
nothing had examined.

A declared capability whose every scenario was skipped for a *different* one was
likewise reported as passed, because the rollup counted a capability as exercised
by tag presence rather than by execution. Both scenarios in events.feature carry
@events as well as @Stale or @configuration-change, so the in-memory suite
reported @events as passed while neither scenario ran.

Both are the vacuous pass the capability vocabulary was introduced to eliminate,
arriving through the report rather than through the suite. A scenario now counts
towards a capability only if it actually ran, and a declared capability with
nothing to show is omitted. The suite asked no question of it, so it has no answer
to report, and a consumer sees the tag is absent rather than a pass it cannot rely
on. Omitting is preferred to inventing a fifth outcome: the four in the schema are
about what the provider did, and "this run demonstrated nothing" is a fact about
the run.

An undeclared capability is still reported as not-declared with its reason, and
not-applicable is unaffected -- @strict-numeric-typing being unanswerable in a
language with no integer type is a different statement from a capability nothing
exercised, and both need saying.

Matches the same fix in Go, open-feature/go-sdk-contrib#944.

Signed-off-by: Simon Schrottner <simon.schrottner@flagsmith.com>
@aepfli
aepfli force-pushed the feat/provider-tck-report branch from e794277 to 9a8f61c Compare September 11, 2026 13:34
aepfli added a commit to open-feature/js-sdk-contrib that referenced this pull request Sep 11, 2026
…ng untested ones

Three defects in the capability rollup, all of them ways the report could report
a result the suite never obtained.

A failed capability was emitted as {"state": "failed"} with no reason. The schema
requires a reason for any outcome other than passed, so that entry does not
validate -- and it appears only when a provider is actually failing, which is
precisely when the report matters. It now says how many of how many scenarios
carrying the tag failed, and points at the per-scenario results for which and why.

No test caught it because every self-test suite passes, so nothing that runs end
to end reaches that branch. The new test drives the report builder directly with
synthetic records, which is the only way to exercise a failure without breaking a
provider on purpose.

A declared capability that no scenario carries was reported as passed. @targeting
is reserved -- it is in the vocabulary and nothing tests it, because asserting
that an evaluation context reached the backend needs an echo operation the control
API does not have -- so a provider declaring it got a green result for a claim
nothing had examined.

A declared capability whose every scenario was skipped for a *different* one was
likewise reported as passed, because the rollup counted a capability as exercised
by tag presence rather than by execution. Both scenarios in events.feature carry
@events as well as @Stale or @configuration-change, so the in-memory suite
reported @events as passed while neither scenario ran.

Both are the vacuous pass the capability vocabulary was introduced to eliminate,
arriving through the report rather than through the suite. A scenario now counts
towards a capability only if it actually ran, and a declared capability with
nothing to show is omitted. The suite asked no question of it, so it has no answer
to report, and a consumer sees the tag is absent rather than a pass it cannot rely
on. Omitting is preferred to inventing a fifth outcome: the four in the schema are
about what the provider did, and "this run demonstrated nothing" is a fact about
the run.

An undeclared capability is still reported as not-declared with its reason, and
not-applicable is unaffected -- @strict-numeric-typing being unanswerable in a
language with no integer type is a different statement from a capability nothing
exercised, and both need saying.

Matches the same fix in Go, open-feature/go-sdk-contrib#944.

Signed-off-by: Simon Schrottner <simon.schrottner@flagsmith.com>
@aepfli
aepfli force-pushed the feat/provider-tck-report branch from 81856b1 to da5bdbd Compare September 11, 2026 16:31
aepfli added a commit to open-feature/js-sdk-contrib that referenced this pull request Sep 11, 2026
…ng untested ones

Three defects in the capability rollup, all of them ways the report could report
a result the suite never obtained.

A failed capability was emitted as {"state": "failed"} with no reason. The schema
requires a reason for any outcome other than passed, so that entry does not
validate -- and it appears only when a provider is actually failing, which is
precisely when the report matters. It now says how many of how many scenarios
carrying the tag failed, and points at the per-scenario results for which and why.

No test caught it because every self-test suite passes, so nothing that runs end
to end reaches that branch. The new test drives the report builder directly with
synthetic records, which is the only way to exercise a failure without breaking a
provider on purpose.

A declared capability that no scenario carries was reported as passed. @targeting
is reserved -- it is in the vocabulary and nothing tests it, because asserting
that an evaluation context reached the backend needs an echo operation the control
API does not have -- so a provider declaring it got a green result for a claim
nothing had examined.

A declared capability whose every scenario was skipped for a *different* one was
likewise reported as passed, because the rollup counted a capability as exercised
by tag presence rather than by execution. Both scenarios in events.feature carry
@events as well as @Stale or @configuration-change, so the in-memory suite
reported @events as passed while neither scenario ran.

Both are the vacuous pass the capability vocabulary was introduced to eliminate,
arriving through the report rather than through the suite. A scenario now counts
towards a capability only if it actually ran, and a declared capability with
nothing to show is omitted. The suite asked no question of it, so it has no answer
to report, and a consumer sees the tag is absent rather than a pass it cannot rely
on. Omitting is preferred to inventing a fifth outcome: the four in the schema are
about what the provider did, and "this run demonstrated nothing" is a fact about
the run.

An undeclared capability is still reported as not-declared with its reason, and
not-applicable is unaffected -- @strict-numeric-typing being unanswerable in a
language with no integer type is a different statement from a capability nothing
exercised, and both need saying.

Matches the same fix in Go, open-feature/go-sdk-contrib#944.

Signed-off-by: Simon Schrottner <simon.schrottner@flagsmith.com>
aepfli added a commit to open-feature/js-sdk-contrib that referenced this pull request Sep 11, 2026
…ng untested ones

Three defects in the capability rollup, all of them ways the report could report
a result the suite never obtained.

A failed capability was emitted as {"state": "failed"} with no reason. The schema
requires a reason for any outcome other than passed, so that entry does not
validate -- and it appears only when a provider is actually failing, which is
precisely when the report matters. It now says how many of how many scenarios
carrying the tag failed, and points at the per-scenario results for which and why.

No test caught it because every self-test suite passes, so nothing that runs end
to end reaches that branch. The new test drives the report builder directly with
synthetic records, which is the only way to exercise a failure without breaking a
provider on purpose.

A declared capability that no scenario carries was reported as passed. @targeting
is reserved -- it is in the vocabulary and nothing tests it, because asserting
that an evaluation context reached the backend needs an echo operation the control
API does not have -- so a provider declaring it got a green result for a claim
nothing had examined.

A declared capability whose every scenario was skipped for a *different* one was
likewise reported as passed, because the rollup counted a capability as exercised
by tag presence rather than by execution. Both scenarios in events.feature carry
@events as well as @Stale or @configuration-change, so the in-memory suite
reported @events as passed while neither scenario ran.

Both are the vacuous pass the capability vocabulary was introduced to eliminate,
arriving through the report rather than through the suite. A scenario now counts
towards a capability only if it actually ran, and a declared capability with
nothing to show is omitted. The suite asked no question of it, so it has no answer
to report, and a consumer sees the tag is absent rather than a pass it cannot rely
on. Omitting is preferred to inventing a fifth outcome: the four in the schema are
about what the provider did, and "this run demonstrated nothing" is a fact about
the run.

An undeclared capability is still reported as not-declared with its reason, and
not-applicable is unaffected -- @strict-numeric-typing being unanswerable in a
language with no integer type is a different statement from a capability nothing
exercised, and both need saying.

Matches the same fix in Go, open-feature/go-sdk-contrib#944.

Signed-off-by: Simon Schrottner <simon.schrottner@flagsmith.com>
@aepfli
aepfli force-pushed the feat/provider-tck-report branch from da5bdbd to 6d7c0cc Compare September 11, 2026 18:01
@aepfli
aepfli force-pushed the feat/provider-tck-report branch 2 times, most recently from fe9fda9 to a71c35b Compare September 12, 2026 06:33
aepfli added a commit to open-feature/js-sdk-contrib that referenced this pull request Sep 12, 2026
…ng untested ones

Three defects in the capability rollup, all of them ways the report could report
a result the suite never obtained.

A failed capability was emitted as {"state": "failed"} with no reason. The schema
requires a reason for any outcome other than passed, so that entry does not
validate -- and it appears only when a provider is actually failing, which is
precisely when the report matters. It now says how many of how many scenarios
carrying the tag failed, and points at the per-scenario results for which and why.

No test caught it because every self-test suite passes, so nothing that runs end
to end reaches that branch. The new test drives the report builder directly with
synthetic records, which is the only way to exercise a failure without breaking a
provider on purpose.

A declared capability that no scenario carries was reported as passed. @targeting
is reserved -- it is in the vocabulary and nothing tests it, because asserting
that an evaluation context reached the backend needs an echo operation the control
API does not have -- so a provider declaring it got a green result for a claim
nothing had examined.

A declared capability whose every scenario was skipped for a *different* one was
likewise reported as passed, because the rollup counted a capability as exercised
by tag presence rather than by execution. Both scenarios in events.feature carry
@events as well as @Stale or @configuration-change, so the in-memory suite
reported @events as passed while neither scenario ran.

Both are the vacuous pass the capability vocabulary was introduced to eliminate,
arriving through the report rather than through the suite. A scenario now counts
towards a capability only if it actually ran, and a declared capability with
nothing to show is omitted. The suite asked no question of it, so it has no answer
to report, and a consumer sees the tag is absent rather than a pass it cannot rely
on. Omitting is preferred to inventing a fifth outcome: the four in the schema are
about what the provider did, and "this run demonstrated nothing" is a fact about
the run.

An undeclared capability is still reported as not-declared with its reason, and
not-applicable is unaffected -- @strict-numeric-typing being unanswerable in a
language with no integer type is a different statement from a capability nothing
exercised, and both need saying.

Matches the same fix in Go, open-feature/go-sdk-contrib#944.

Signed-off-by: Simon Schrottner <simon.schrottner@flagsmith.com>
@aepfli
aepfli force-pushed the feat/provider-tck-report branch from a71c35b to 9cf29bb Compare September 12, 2026 10:43
aepfli added a commit to open-feature/js-sdk-contrib that referenced this pull request Sep 12, 2026
…ng untested ones

Three defects in the capability rollup, all of them ways the report could report
a result the suite never obtained.

A failed capability was emitted as {"state": "failed"} with no reason. The schema
requires a reason for any outcome other than passed, so that entry does not
validate -- and it appears only when a provider is actually failing, which is
precisely when the report matters. It now says how many of how many scenarios
carrying the tag failed, and points at the per-scenario results for which and why.

No test caught it because every self-test suite passes, so nothing that runs end
to end reaches that branch. The new test drives the report builder directly with
synthetic records, which is the only way to exercise a failure without breaking a
provider on purpose.

A declared capability that no scenario carries was reported as passed. @targeting
is reserved -- it is in the vocabulary and nothing tests it, because asserting
that an evaluation context reached the backend needs an echo operation the control
API does not have -- so a provider declaring it got a green result for a claim
nothing had examined.

A declared capability whose every scenario was skipped for a *different* one was
likewise reported as passed, because the rollup counted a capability as exercised
by tag presence rather than by execution. Both scenarios in events.feature carry
@events as well as @Stale or @configuration-change, so the in-memory suite
reported @events as passed while neither scenario ran.

Both are the vacuous pass the capability vocabulary was introduced to eliminate,
arriving through the report rather than through the suite. A scenario now counts
towards a capability only if it actually ran, and a declared capability with
nothing to show is omitted. The suite asked no question of it, so it has no answer
to report, and a consumer sees the tag is absent rather than a pass it cannot rely
on. Omitting is preferred to inventing a fifth outcome: the four in the schema are
about what the provider did, and "this run demonstrated nothing" is a fact about
the run.

An undeclared capability is still reported as not-declared with its reason, and
not-applicable is unaffected -- @strict-numeric-typing being unanswerable in a
language with no integer type is a different statement from a capability nothing
exercised, and both need saying.

Matches the same fix in Go, open-feature/go-sdk-contrib#944.

Signed-off-by: Simon Schrottner <simon.schrottner@flagsmith.com>
@aepfli
aepfli force-pushed the feat/provider-tck-report branch from 9cf29bb to e8ea5d1 Compare September 12, 2026 12:35
aepfli added a commit to open-feature/js-sdk-contrib that referenced this pull request Sep 12, 2026
…ed ones

Three defects in the capability rollup, all of them ways the report could report
a result the suite never obtained.

A failed capability was emitted as {"state": "failed"} with no reason. The schema
requires a reason for any outcome other than passed, so that entry does not
validate -- and it appears only when a provider is actually failing, which is
precisely when the report matters. It now says how many of how many scenarios
carrying the tag failed, and points at the per-scenario results for which and why.

No test caught it because every self-test suite passes, so nothing that runs end
to end reaches that branch. The new test drives the report builder directly with
synthetic records, which is the only way to exercise a failure without breaking a
provider on purpose.

A declared capability that no scenario carries was reported as passed. @targeting
is reserved -- it is in the vocabulary and nothing tests it, because asserting
that an evaluation context reached the backend needs an echo operation the control
API does not have -- so a provider declaring it got a green result for a claim
nothing had examined.

A declared capability whose every scenario was skipped for a *different* one was
likewise reported as passed, because the rollup counted a capability as exercised
by tag presence rather than by execution. Both scenarios in events.feature carry
@events as well as @Stale or @configuration-change, so the in-memory suite
reported @events as passed while neither scenario ran.

Both are the vacuous pass the capability vocabulary was introduced to eliminate,
arriving through the report rather than through the suite. A scenario now counts
towards a capability only if it actually ran, and a declared capability with
nothing to show is omitted. The suite asked no question of it, so it has no answer
to report, and a consumer sees the tag is absent rather than a pass it cannot rely
on. Omitting is preferred to inventing a fifth outcome: the four in the schema are
about what the provider did, and "this run demonstrated nothing" is a fact about
the run.

An undeclared capability is still reported as not-declared with its reason, and
not-applicable is unaffected -- @strict-numeric-typing being unanswerable in a
language with no integer type is a different statement from a capability nothing
exercised, and both need saying.

Matches the same fix in Go, open-feature/go-sdk-contrib#944.

Signed-off-by: Simon Schrottner <simon.schrottner@flagsmith.com>
Setting PROVIDER_TCK_REPORT_DIR makes each suite write its run to
<dir>/<name>.json against the report schema in the specification repository
(open-feature/spec#425, part of open-feature/spec#424).

Go is the language that needs this first. godog counts a capability-gated skip
in its passed tally, so a run that skipped five of twenty-nine scenarios prints
"29 scenarios (29 passed)". Appendix F is unambiguous that a scenario skipped
for an undeclared capability is reported as skipped with the reason and never as
passed, and the harness does say so in a separate log line -- but the headline
number still says something false, and the number is what gets read. pytest and
jest-cucumber report skips correctly, so this is the runner's property rather
than the suite's design.

The report does not fix godog's summary. It makes the summary stop mattering, by
recording every scenario's outcome individually so a consumer can check the rule
instead of trusting the runner to have applied it. The same run now reports
twenty-four passed and five not-declared, each with its reason.

Identity comes from revision.go, generated by sync_assets.go beside the embedded
artifacts. Generating both in one command is what keeps them honest: the CI check
regenerates and fails on any difference, so a revision disagreeing with the
artifacts beside it cannot be committed. The check is widened to cover the
generated file, which it would otherwise have missed. Both the commit and the
tree hash are recorded, the tree because it identifies the artifacts alone --
unchanged by unrelated edits elsewhere in the specification, so two runs of
identical artifacts agree even when pinned to different commits, and checkable
because `git rev-parse <commit>:specification/assets/provider-tck` reproduces it.

Two smaller decisions. The provider is identified by the name it reports through
its own metadata, with Config.Name recorded as the configuration, because
Config.Name is chosen to read well in a failure message -- "flagd-rpc" -- and a
provider with two materially different modes produces two reports that are not
interchangeable. And how the backend was driven is read through an optional
interface rather than a new BackendControl method, so that adding it breaks no
existing implementation and a control that does not implement it simply omits
the field.

Emission is opt-in through the environment rather than through Config so that
producing a report is a property of the run and not of the code: CI sets it, a
local run does not, and no adopter changes a line to publish one.

The tests assert the property that motivated the work -- that no scenario the
capability gate stopped is ever reported as passed, and that every scenario is
accounted for exactly once, since a report that silently omitted what it skipped
would satisfy the first half while still misleading a reader. That test earned
its place immediately: it caught this emitter recording every skipped scenario
twice, the second time as passed, because godog does not deliver the before
hook's ErrSkip to the after hook.

Signed-off-by: Simon Schrottner <simon.schrottner@flagsmith.com>
…ed ones

Two defects in the capability rollup, both found by the Java implementation
reviewing this one.

A failed capability was emitted as {"state": "failed"} with no reason. The schema
now requires a reason for any outcome other than passed, so that entry does not
validate -- and it would have appeared only when a provider was actually failing,
which is precisely when the report matters. It now says how many of how many
scenarios carrying the tag failed, and points at the per-scenario results for
which and why.

No test caught it because every self-test suite passes, so nothing that runs end
to end ever reaches that branch. The new internal test drives the report builder
directly with synthetic records, which is the only way to exercise a failure
without breaking a provider on purpose.

A declared capability that no scenario carries was reported as passed. @targeting
is reserved -- it exists in the vocabulary but nothing tests it, because asserting
that an evaluation context reached the backend needs an echo operation the control
API does not have -- so a provider declaring it got a green result for a claim
nothing had examined. That is the vacuous pass the capability vocabulary was
introduced to eliminate, arriving through the report rather than through the
suite.

Such a capability is now omitted. The suite asked no question, so it has no answer
to report, and a consumer sees the tag is absent rather than a pass it cannot
rely on. Omitting is preferred to inventing a fifth outcome: the four in the
schema are about what the provider did, and "the suite does not test this" is a
fact about the suite.

Signed-off-by: Simon Schrottner <simon.schrottner@flagsmith.com>
A scenario entry in the conformance report was identified by feature and name.
Every row of a Scenario Outline shares one name, so the type-mismatch matrix in
errors.feature produced eleven entries that differed only in durationMs. If one
row failed and ten passed, the report could not say which failed, and a consumer
keying on feature and name kept whichever row it read last.

Per the report schema, a scenarioResult now carries `example`: the Examples row
it came from, keyed by column header, with the cells verbatim as strings.
Gherkin has no types, so "1" stays "1" rather than becoming 1 -- the report says
what the table said. It is present only for outline rows and omitted otherwise.

godog hands a hook an already-expanded pickle, whose step text has the
parameters substituted in and whose row is otherwise gone. What survives is
AstNodeIds, whose last entry is the id of the Examples TableRow. The row is
therefore recovered by parsing the embedded feature files a second time and
indexing every TableRow by that id. Those ids come from a counter godog creates
once per run and shares across the files it parses, so reproducing them means
reproducing godog's parse -- same files, same order, pickle compilation in
between. That coupling is not left to be trusted: a pickle that came from an
outline and did not resolve fails the run, because quietly returning to the
ambiguity this field exists to remove is worse than a build failure.

The capability gate records its outcome before a scenario starts, so it fills
the field in too. Four skipped rows of the @object outline are as ambiguous as
four failed ones.

The gate's own bookkeeping is keyed by pickle id rather than by scenario name
for the same reason. Gherkin allows an Examples block to carry its own tags, so
two rows of one outline can differ in whether the gate stops them; keyed by
name, gating one row suppressed the after hook for every row and the rows that
did run would have vanished from the report.

gherkin/go/v26 moves from an indirect requirement to a direct one. It is the
same module and version godog already builds against, so no dependency is added
and no go.sum entry changes.

Verified against the schema on open-feature/spec#425 with a Draft 2020-12
validator: every report the self-tests emit validates, the eleven matrix rows
carry eleven distinct examples matching the feature file, and (feature, name,
example) is unique across every scenario in a report.

Signed-off-by: Simon Schrottner <simon.schrottner@flagsmith.com>
A capability whose scenarios were all skipped still reported as passed.

The in-memory report claimed "@events": "passed" while both scenarios in
events.feature had been skipped. Those scenarios carry @events alongside @Stale
and @configuration-change, so withholding either one skipped them -- and the
rollup counted a skipped scenario's remaining tags as exercising their
capabilities. @events was declared, so it missed the not-declared branch, and
fell through to passed on the strength of two scenarios that never executed.

It is the same vacuous pass as the reserved-capability case fixed alongside it,
reached by a different route: the rollup was counting tag presence rather than
execution.

A scenario now contributes to its capabilities only when its outcome is passed or
failed. A declared capability whose every scenario was skipped falls to zero
exercised and is omitted, which is the honest answer -- the suite has the question
but never got to put it to this provider.

Found by the Python implementation, which reached the same rollup semantics and
then noticed what they produced.

Signed-off-by: Simon Schrottner <simon.schrottner@flagsmith.com>
The report emitted its own per-scenario result list, with a four-value outcome
enum and a field identifying which Scenario Outline row an entry came from. All
three already exist in Cucumber Messages, which is maintained, cross-language
and schema'd. The report is now an envelope plus a referenced Messages stream:
one run writes <dir>/<name>.json and <dir>/<name>.ndjson, with the envelope
naming and digesting the latter.

godog 0.15.1 has no Messages formatter -- it registers cucumber (the legacy
relishapp JSON), events, junit, pretty and progress -- so pkg/tck/messages.go is
one, registered through the public godog.Format plugin interface. Its JUnit
output was not a usable fallback: a capability-gated skip comes out as
skipped="0" on the suite, in a non-standard <error type="skipped"> element, with
the step text where the skip reason should be.

The formatter events are the right source, and not incidentally so. For the
first step of a gated scenario godog inserts a FAILED step result into its
internal storage and also calls Skipped on the formatter. The built-in
formatters read storage, which is where the malformed JUnit comes from; a
formatter built on the events sees one SKIPPED. Verified end to end: a run godog
summarises as "29 scenarios (29 passed)" produces a stream reporting 24 PASSED
and 5 SKIPPED, each skip naming the capability that gated it.

Two things the formatter interface does not give us. There is no
scenario-finished event, so the stream is accumulated and written at Summary
rather than streamed, which also lets the envelopes be written in the
conventional order. And Skipped(pickle, step, definition) carries no error, so
the capability that gated a scenario is unrecoverable from the events; the
reason comes from the gate itself and lands in TestStepResult.message.

The example field is gone. Messages identifies a row exactly: a pickle's
astNodeIds end with the id of the Examples TableRow, and the stream carries the
gherkinDocument those ids belong to. That also removes a coupling. Recovering
the row from a *godog.Scenario meant reparsing the embedded feature files and
reproducing the node ids godog assigns, which come from a counter godog shares
across the files it parses -- so reproducing them meant reproducing godog's
whole parse, and pkg/tck/examples.go existed to do that. A formatter is handed
the same gherkinDocument godog compiled the pickles from, so the ids agree by
construction.

assetsTree is gone for the same reason. It was a git tree hash over the artifact
directory, carried so a consumer could tell which questions a report answers
without trusting the recorded commit. The stream carries the executed feature
text, which answers that with the source rather than with a hash of it, and
unlike the hash cannot be asserted wrongly because it is the input godog
parsed.

What stays OpenFeature-specific. The declaration -- which capabilities the
provider claims -- is an input to reading the results rather than a summary of
them: a SKIPPED scenario says the question was not put to this provider, and
only the declaration says whether that is because the provider declines the
capability. And the tested subject, since no standard results format has a slot
for it; Messages records the runner, the runtime and the machine.

Verified: the three envelopes validate against the reshaped schema with a Draft
2020-12 validator, their digests match, and each 543-message stream validates
against the published Cucumber Messages v21.0.1 JSON schemas with no errors.

Signed-off-by: Simon Schrottner <simon.schrottner@flagsmith.com>
The envelope named the results format but not its version, and Messages is
versioned. This implementation builds against messages/go/v21 while the Python
TCK emits 34.2.0 and cucumber-jvm ships a different one again, so a consumer
receiving two reports cannot assume one schema validates both.

Guessing is worse than not validating. A later schema accepts messages this
producer could not have emitted, and an earlier one rejects messages that are
perfectly valid, so a check against the wrong version reports a result that has
nothing to do with the stream. During this work a schema copy of a later release
was left in a shared temporary directory, and validating a v21 stream against it
would have done exactly that.

It reuses the protocol version already computed for the stream's own Meta
message, rather than adding a second source: the envelope and the stream now
cannot disagree about which release produced it. That helper reads the version
from the build and falls back only when the build carries none, which happens
for a test binary built from the module itself.

Signed-off-by: Simon Schrottner <simon.schrottner@flagsmith.com>
Two reports of the same kind of provider disagreed about whether they described
an in-process backend: the JavaScript in-memory suite said so and the Go one
stayed silent. Not because the backends differ, but because Go's self-test
controls never implemented the optional interface that reports it.

The field is optional in the report and the interface is optional here, both so
that introducing it broke no existing control. Together they make omission
invisible: the suite passes, the report validates, and the field is simply
absent. It surfaced only when four languages' reports were compared side by side.

The two self-test controls now report in-process, which is what they are -- they
manipulate a provider in this process and there is no backend to drive.

More usefully, a control that reports nothing now says so in the run output. The
silence was the actual defect: every control either drives a real backend over
HTTP or manipulates an in-process one, so there is no third case an absent value
legitimately describes, and an adopter had no way to discover their report had a
hole in it. Logged rather than failed, because a missing optional field is not a
conformance problem -- it is a gap in what the report can say about the run.

Signed-off-by: Simon Schrottner <simon.schrottner@flagsmith.com>
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 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, and flagd's own testbed
is gaining @numeric-coercion scenarios -- two vocabularies for one property is
the drift a shared vocabulary exists to prevent.

So tck.StrictNumericTyping becomes tck.NumericCoercion, its tag becomes
@numeric-coercion, and its doc comment states the corrected rule rather than
"does not coerce".

The spec submodule moves to dc4d7ae8 and the embedded assets are re-synced from
it, which also picks up the renamed lifecycle readiness scenario and the POST
/start requirement on the control API. No scenario is added: the existing one
asks about 0.5 and remains the lossy half of the contract.

Two gaps are recorded rather than closed, in the capability's doc comment and in
the README. The lossless half has no scenario, because 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
still passes. And accessor width is not modelled at all: the ADR distinguishes a
64-bit integer accessor from a 32-bit one, and this suite is silent about the
difference.

Signed-off-by: Simon Schrottner <simon.schrottner@flagsmith.com>
@targeting and @caching are reserved: they are part of the vocabulary but no
scenario carries either tag. Appendix F now states plainly that such a tag must
not be declared and must not appear in a conformance report's declaration --
nothing carries it, so declaring it cannot be verified, cannot even produce a
skip, and tells a reader of the report only that something was claimed and
nothing examined.

That was a live defect here rather than a hypothetical one. A Java conformance
report asserts both tags as declared, not by anyone's decision but because that
adoption declares "every capability except X" and collects every reserved tag on
the way past. AllCapabilities had the same shape, and it is the default when
Config.Capabilities is nil.

So the reserved set is named once, beside the constants, and exposed as
Capability.IsReserved. AllCapabilities omits it, which also fixes the default.
An adopter who names a reserved capability outright is failed rather than
warned: it is rejected by newCapabilitySet, which Config.validate calls, so the
run stops before any scenario with the same message shape as any other
configuration problem. Warning would leave the claim in a published report,
which is the outcome the rule exists to prevent, and the fix is to delete one
line.

Putting the check in newCapabilitySet rather than only in Config.validate is
deliberate: the capability set is the only thing declaration.declared is built
from, so a reserved tag now has no path into a report at all, and there is no
second route that could drift from the rule.

Signed-off-by: Simon Schrottner <simon.schrottner@flagsmith.com>
A conformance report is a claim that a provider was asked the canonical
questions, and nothing so far established that it was asked all of them.
`go test -run` matching one scenario name produced a green suite and a
well-formed report describing one scenario out of twenty-nine; a
mis-wired extension filesystem would do the same. There is no field in
the envelope a consumer could read to notice, and the schema is closed,
so failing the test is the only lever there is.

The expectation is the embedded assets compiled by godog's own parser
rather than by a second one. A parser of this package's own would
disagree with godog about exactly the cases that matter -- an Examples
block carrying its own tags, scenarios inside a Rule -- and the
expectation has to be what a full run would actually have produced.

Two shapes of gap are distinguished, because they have different causes.
A scenario absent from the results was never parsed. A scenario present
with no outcome was announced and then never executed: godog reports a
pickle before handing the scenario to the subtest, so a `-run` selector
leaves the test case behind with no step result in it. The outcome comes
from the Messages formatter for the same reason -- the After hook does
not run for a scenario that never started, so nothing else in the suite
sees it.

A capability-gated scenario is not a gap. It ran the gate and appears as
SKIPPED with its reason, so the question was put and declined; treating
that as a gap would force every provider to declare every capability.

Extension scenarios are counted and reported but can never close a gap.
An adopter's feature is an addition to the canonical set, and a rule
where supplying enough scenarios of your own made the canonical ones
optional would defeat the check.

The duplicate status ordering in report_internal_test.go is folded into
the one the formatter now uses, since a scenario's outcome is derived in
one place rather than two.

This is the guard on its own. Its other half, the extension point in
Config.ExtensionFeatures and Config.ExtensionSteps, reads no report and
went to the base branch instead.

The guard sits here rather than on the base because its input is the
report's own stream: r.executed has exactly one producer,
messagesSink.executed in messages.go, and what the guard protects is the
published report -- its failure message says so. Giving the base a second
producer for r.executed would mean duplicating the Messages plumbing
there to no end.

selftest_extensions_test.go arrives with the guard rather than with the
extension point for the same reason: it establishes what actually ran by
reading the conformance report, so it is a report-side test of a
base-side feature and does not compile without report.go.

Signed-off-by: Simon Schrottner <simon.schrottner@flagsmith.com>
The assets now arrive as a Go module, so the generator that wrote revision.go
is gone and the constant it wrote has no source. Point it at the pin in go.mod,
which is where the revision is actually recorded now, and drop the "Code
generated -- DO NOT EDIT" header that is no longer true.

The obvious replacement does not exist. runtime/debug.ReadBuildInfo reports no
dependencies at all from a library package's test binary -- not in workspace
mode, not in plain module mode, not under a replace, across go1.22, go1.25 and
go1.26 -- and the TCK only ever runs inside one, both its own self-tests and an
adopter's TestConformance. So the value is written by hand and duplicated from
go.mod on purpose.

A hand-written constant can go stale in silence, which is the one failure mode
that leaves a report structurally valid and semantically wrong: naming a
revision the run did not use. So TestSpecRevisionIsRecorded reads ../../go.mod
and fails when the two disagree. That file is inside the module, present in
every clone and in every module zip, so moving the pin without updating the
constant breaks this suite's own tests rather than a consumer's report.

The pin is a pseudo-version today, which still names the commit -- the trailing
twelve characters are its prefix. Once the assets are released under a tag it
reads as that tag, which is the form worth putting in a report.

Signed-off-by: Simon Schrottner <simon.schrottner@flagsmith.com>
Reading the assets out of the module instead of a vendored copy shortened every
canonical feature uri: gherkin/errors.feature, where it used to be
assets/gherkin/errors.feature. The reporting side keys on that prefix to tell a
canonical scenario from an extension, so three extension self-tests classified
every canonical scenario as "neither canonical nor an extension" and failed.

The short form is the right one to land on, not merely the one that fell out:
Appendix F says canonical features keep the path they have in the spec
repository, and gherkin/errors.feature is that path. assets/ was an artefact of
where the copies were vendored to.

The synthetic uris in report_internal_test.go move with it. They are fixtures
and nothing resolves them, but a fixture that no longer matches the prefix the
code partitions on is a test that agrees with itself and with nothing else.

Signed-off-by: Simon Schrottner <simon.schrottner@flagsmith.com>
…re lacks

The canonical assets gained two lossless numeric-coercion scenarios -- an
integral float requested as an integer, and an integer requested as a float.
The extension self-tests failed both.

They failed honestly. Their fixture backend is the Go SDK's
memprovider.InMemoryProvider, which does not coerce: it type-asserts, so it
refuses integral-float-flag as an integer and integer-flag as a float and
reports TYPE_MISMATCH for each. TestInMemoryProvider had already recorded that
finding and withdrawn NumericCoercion. vendorConfig restated the capability
list instead of sharing it, so it kept declaring a capability the provider
never had -- true only while @numeric-coercion carried the lossy scenario
alone, which memprovider passes by refusing every float.

So the declaration was corrected rather than the assertion, and the list now
lives in one place: inMemoryCapabilities, shared by every self-test that drives
this backend. The extension self-tests ask whether ExtensionFeatures and
ExtensionSteps work; what the fixture provider is capable of is not their
question, and answering it separately is how the two lists came to disagree.

The canonical-set guard needed nothing -- it parses the embedded assets through
godog itself, so it counted all 40 scenarios without being told the number.

Also points SpecRevision at the assets module version the base branch now pins,
which TestSpecRevisionIsRecorded compares against go.mod.

Signed-off-by: Simon Schrottner <simon.schrottner@flagsmith.com>
The base moved the spec assets pin to the revision that gates the
re-initialisation scenario on @reinitialization. SpecRevision is a
hand-written copy of that pin -- nothing can generate it, because a
library package's test binary carries no module build information at
all -- so it has to be moved by hand too.

TestSpecRevisionIsRecorded is the reason that is safe rather than
hopeful. It reads ../../go.mod and compares, and it failed here exactly
as designed:

    SpecRevision = "v0.0.0-20260911113217-ba002ce8e807" but go.mod pins
    github.com/open-feature/spec/specification/assets/provider-tck at
    "v0.0.0-20260911124500-fc99d5ace4da"; move the pin with `go get` and
    update SpecRevision to match

A report whose specRevision names a different revision of the
conformance definition than the suite actually ran is worse than one
with no revision at all, since a consumer has no way to notice. That
matters more than usual for this particular move: the set of mandatory
scenarios changed, so two runs a fortnight apart answer different
questions, and specRevision is the only field that says which.

Signed-off-by: Simon Schrottner <simon.schrottner@flagsmith.com>
The declaration says which capabilities were claimed and the results say
which scenarios ran. Neither says why a capability was not claimed, and
the two reasons are not alike: declining @configuration-change for want
of a streaming transport is a decision, declining @numeric-coercion
because 0.5 narrows to 0 with no error code is a defect. A consumer
reading the report sees the same absence for both.

Config.KnownDeviations is where the adopter states the difference, and
this carries it into the envelope so it reaches whoever reads the report
rather than only whoever reads the adoption. Appendix F expects the field
under this name, and the Java TCK already emits it, so a consumer
comparing two languages' reports reads one shape.

Verbatim, and deliberately so. A summary is prose written by the provider
author for someone comparing providers; an emitter that paraphrased or
normalised it would be substituting its own account of the defect for
theirs.

Top-level, beside the declaration rather than inside it, matching Java.
An entry is not a statement about the declared set: it may concern a
capability that WAS declared -- the capability holds, one scenario it
gates does not -- or a mandatory scenario belonging to no capability at
all. What it qualifies is the declaration as a whole.

Omitted when empty, which is the opposite of the rule for declared
capabilities, and for a reason. An empty declared list is a claim: this
provider declares nothing. An empty deviation list would read as "this
provider has no known defects", which is not what silence means -- only
that none were recorded. Two tests pin the two halves, along with the
untracked-and-uncapability-ed shape, which is the one most likely to be
dropped or defaulted on the way through.

No adopter-facing API file changes here: the type and both constructors
live on the base, because they are something an adopter writes. This
branch only reads them.

Signed-off-by: Simon Schrottner <simon.schrottner@flagsmith.com>
The field existed only so that a consumer unmarshalling ReportDeclaration saw
the whole schema; its own comment said so, and that nothing populated it because
Config has no field for it. The schema has since dropped it: not-declared and
not-applicable are both skips, the skip carries its reason, and a capability that
cannot hold in a language at all is a property of the SDK recorded in Appendix F
rather than restated in every report.

So there is no schema left for it to mirror, and nothing to populate it with.

Signed-off-by: Simon Schrottner <simon.schrottner@flagsmith.com>
SpecRevision is duplicated from go.mod by hand, for the reason its own comment
gives, and TestSpecRevisionIsRecorded is what stops the two drifting. The pin
moved with the @Variants and @targeting work on the parent branch, so this is
the other half of that move: a report that named the previous revision would
tell a consumer the run answered questions it did not ask.

The shadowing fixture follows the same rename. It takes its scenario name from
the canonical evaluation.feature on purpose -- being hostile is the point, and a
name that no longer collides with anything tests nothing. "Resolve values with
variant and reason" became "Resolve values with reason" when the variant
assertions moved into their own gated outline, so the fixture and the constant
the test compares against both move with it.

The sample envelope in the README is refreshed from a real run for the same
reason: it showed a bare commit id where specRevision now carries the module
pseudo-version, and a declaration the in-memory suite has not made for two
revisions.

Signed-off-by: Simon Schrottner <simon.schrottner@flagsmith.com>
Two findings golangci-lint v2.13.2 reports against this branch and has done
since the report machinery landed, so `make lint` is red on it today. Neither
is a behaviour change.

The Fprintf writes a bare newline into a Messages stream whose encoding has
already failed, which is a best-effort marker in something already broken --
the one error here with genuinely nowhere to go. Discarding it explicitly says
so; leaving it unchecked only looked like it did.

Signed-off-by: Simon Schrottner <simon.schrottner@flagsmith.com>
SpecRevision is duplicated from go.mod on purpose -- nothing can generate it,
because a library package's test binary carries no module build information --
and TestSpecRevisionIsRecorded exists so that moving the pin without updating
the constant breaks this suite's own tests rather than a consumer's report.
spec 009afe06 moved the pin, so this is that test doing its job.

The README's illustrative numbers move with it. Every one of them was measured
from a run of the in-memory suite rather than adjusted by arithmetic: the
Messages stream now tallies 270 PASSED and 125 SKIPPED steps, godog's headline
reads "56 scenarios (56 passed)" of which eighteen did not run, and a `-run`
selector matching a single scenario name now leaves 27 canonical scenario names
with no outcome rather than 12. The sample envelope's specRevision follows the
pin; its digest deliberately does not, because a Messages stream carries
timestamps and so hashes differently on every run -- that field is opaque by
nature and no value printed here could be the right one.

Signed-off-by: Simon Schrottner <simon.schrottner@flagsmith.com>
…inery

The base branch moved the package to tools/tck and replaced tck.Config
with functional options. The reporting machinery and its tests are the
part of this branch the base could not carry along, so they are brought
over here.

Mechanical, with two things worth noting.

vendorConfig now returns []tck.Option rather than a tck.Config, and the
shadowing test appends tck.WithFeatures instead of assigning a field.
That is the ordering guarantee doing real work: a later option wins over
an earlier one, which is how one shared adoption can be pointed at a
different extension filesystem without a second copy of it.

The revision guard read ../../go.mod, which was the module root back
when the package sat under pkg/tck. The package is the module root now,
so it reads go.mod beside it.

Signed-off-by: Simon Schrottner <simon.schrottner@flagsmith.com>
…erface replaces

A net deletion. BackendControl now requires ControlAPI() ControlAPI on the base
branch, so the optional interface this branch used to sniff for, the
empty-string fallback behind it, and the runner log that told an adopter their
control had not said, all stop having a case to cover.

What goes:

  - controlAPIReporter, the unexported optional interface.
  - controlAPIOf, whose own doc comment had already reached the conclusion this
    implements -- "silence here is a small lie by omission: every control is
    either driving a real backend over HTTP or manipulating an in-process one,
    so there is no third case the empty value legitimately covers".
  - runner.reportControlAPIGap, a log line that existed only because the field
    could be absent. A required method is a compile error instead, which is
    where that belongs.
  - InProcessControl.ControlAPI() string and the self-test control's, both
    superseded by the typed ones on the base.

Report.Backend stops being a pointer and loses omitempty, and
ReportBackend.ControlAPI loses its own: the schema now requires the backend
block at the top level and controlApi within it. The old shape made the one
value most worth knowing -- in-process, the case the enum exists for -- the one
value that could never legally appear.

No report test asserted an absent backend block or an absent controlApi, so
none had to change. An emitted report was validated against the updated schema
by hand: conformance-report.schema.json at spec bcd005f1 accepts
reports/in-memory.json unchanged, including the newly required members.

Signed-off-by: Simon Schrottner <simon.schrottner@flagsmith.com>
SpecRevision is written by hand and TestSpecRevisionIsRecorded fails when it
disagrees with go.mod, which is what it is for: the base branch moved the pin
to the appendix tip 93eb1a58 and this is the constant that says so in every
report.

The report sample in the README follows, and gains two members it should
already have had: controlApi, now required, and results.formatVersion, which
the emitter has always written. Two stale ./pkg/tck/ links from the flattening
go with them.

Signed-off-by: Simon Schrottner <simon.schrottner@flagsmith.com>
PROVIDER_TCK_REPORT_DIR becomes TCK_REPORT_DIR, following the package rename to
`tck` and TCK_RUN on the base. The two move together on purpose: a partial
rename would leave TCK_RUN beside PROVIDER_TCK_REPORT_DIR in one repository,
which is worse than either consistent answer. Nothing is published and nothing
is scripted against either name, so this is free now and expensive later.

The recorded SpecRevision follows the pin the base moved, to
v0.0.0-20260912211427-ccdb88790bb4, in the two places that carry it by hand:
revision.go, which nothing can generate because a library package's test binary
has no module build information, and the README's sample report.
TestSpecRevisionIsRecorded is what makes the pair safe -- it reads go.mod and
fails when the constant disagrees, which is how the report branch's copy of the
pin was found in the first place.

Verified rather than assumed: emitting with TCK_REPORT_DIR set writes both
files and the envelope reads "specRevision": "v0.0.0-20260912211427-ccdb88790bb4".

Signed-off-by: Simon Schrottner <simon.schrottner@flagsmith.com>
SpecRevision is written by hand and TestSpecRevisionIsRecorded is what keeps it
honest against go.mod, so moving the pin moves it too: spec c342461a, pseudo-
version v0.0.0-20260913074034-c342461aa95d.

Two things downstream of the pin move with it.

The shadowing fixture impersonates a canonical scenario by name, and the name it
copied was "Resolve values with reason" -- an outline that lost its reason column
in this revision and is now "Resolve values". A fixture that no longer collides
tests nothing: TestAnExtensionCannotShadowACanonicalScenario would have gone
green because there was nothing left to shadow. It now copies the new name, and
the canonical-rows assertion is what caught the drift.

The README's sample report gains "@standard-reasons" in declaration.declared and
a regenerated results.digest, both taken from an emitted report rather than
edited by hand.

Signed-off-by: Simon Schrottner <simon.schrottner@flagsmith.com>
…ip reason into the report

SpecRevision and the README's sample envelope move to v0.0.0-20260913091110-89b1519a08d8
with the base. TestSpecRevisionIsRecorded is what caught the second of those, which is
the only reason the pair stays in step.

The sample envelope's results.digest is deliberately not touched. It is not a function
of the pin: the Messages stream carries per-run durations and ids, so three consecutive
runs of the same suite against the same assets produce three different digests. The
field in the README is illustrative like the version numbers beside it, and rewriting
it each pass would suggest a reproducibility it does not have.

The report side of the inexpressibility refusal is the half a consumer actually reads.
A capability-gated skip carries its reason into the Messages stream as the step result's
message, so the two refusals have to differ there and not only in the log -- "this
provider does not declare it" describes a choice the provider made, and a capability the
Go SDK cannot express was never the provider's to choose. The outcome is deliberately
not a new value: the report schema is shared across four languages and the distinction
belongs in the reason, which the schema already carries.

Go has no inexpressible capability, so the new test installs one and drives the real
gate. Mutating the branch away reds it and the base-branch test together.

Signed-off-by: Simon Schrottner <simon.schrottner@flagsmith.com>
SpecRevision is duplicated from go.mod on purpose and
TestSpecRevisionIsRecorded fails when the two disagree, so the re-pin to spec
4cab0320 has to be followed here by hand. The sample envelope in the README
carries the same string and moves with it.

results.digest in that envelope is deliberately left alone: the Cucumber
Messages stream carries per-run durations and ids, so the digest is not a
function of the pin and rewriting it each pass would imply a reproducibility it
does not have.

Nothing else moves. The assets are byte-identical across the two pins, so the
report's fixtures and counts are unchanged.

Signed-off-by: Simon Schrottner <simon.schrottner@flagsmith.com>
The base branch now runs the conformance suites from `make tck` and has removed
the environment variable that used to decide whether they ran at all. This one
is a different variable and stays, but with the other one gone the distinction
is worth stating rather than leaving to be inferred: TCK_REPORT_DIR selects an
output, and `make tck` without it runs exactly the same scenarios and writes no
report.

The example gains the adoption form beside the self-test one, so the single
documented command appears here too.

Signed-off-by: Simon Schrottner <simon.schrottner@flagsmith.com>
The base README was rewritten as a guide to this binding, and the
reporting section is rebased onto it. Two things were carried over
rather than kept:

  - the "What identifies a scenario" section still described the
    "example" field, which was removed when the results moved to
    Cucumber Messages -- Messages carries the Examples row identity in a
    pickle's astNodeIds, so the field had nothing left to do. The
    surviving two sentences are in "Why Cucumber Messages".
  - the rationale for Messages, the godog formatter and the skip tally
    is compressed. What is kept is the part that is Go's: godog counts a
    capability-gated skip as passed, has no Messages formatter, and
    reports a gated scenario correctly through its formatter events
    while holding a FAILED result for the same step in its internal
    storage.

83478 bytes to 33778, no behaviour change.

Signed-off-by: Simon Schrottner <simon.schrottner@flagsmith.com>
@aepfli
aepfli force-pushed the feat/provider-tck-report branch from 14391ce to 1720e69 Compare September 14, 2026 06:26
aepfli added a commit to open-feature/js-sdk-contrib that referenced this pull request Sep 14, 2026
…ed ones

Three defects in the capability rollup, all of them ways the report could report
a result the suite never obtained.

A failed capability was emitted as {"state": "failed"} with no reason. The schema
requires a reason for any outcome other than passed, so that entry does not
validate -- and it appears only when a provider is actually failing, which is
precisely when the report matters. It now says how many of how many scenarios
carrying the tag failed, and points at the per-scenario results for which and why.

No test caught it because every self-test suite passes, so nothing that runs end
to end reaches that branch. The new test drives the report builder directly with
synthetic records, which is the only way to exercise a failure without breaking a
provider on purpose.

A declared capability that no scenario carries was reported as passed. @targeting
is reserved -- it is in the vocabulary and nothing tests it, because asserting
that an evaluation context reached the backend needs an echo operation the control
API does not have -- so a provider declaring it got a green result for a claim
nothing had examined.

A declared capability whose every scenario was skipped for a *different* one was
likewise reported as passed, because the rollup counted a capability as exercised
by tag presence rather than by execution. Both scenarios in events.feature carry
@events as well as @Stale or @configuration-change, so the in-memory suite
reported @events as passed while neither scenario ran.

Both are the vacuous pass the capability vocabulary was introduced to eliminate,
arriving through the report rather than through the suite. A scenario now counts
towards a capability only if it actually ran, and a declared capability with
nothing to show is omitted. The suite asked no question of it, so it has no answer
to report, and a consumer sees the tag is absent rather than a pass it cannot rely
on. Omitting is preferred to inventing a fifth outcome: the four in the schema are
about what the provider did, and "this run demonstrated nothing" is a fact about
the run.

An undeclared capability is still reported as not-declared with its reason, and
not-applicable is unaffected -- @strict-numeric-typing being unanswerable in a
language with no integer type is a different statement from a capability nothing
exercised, and both need saying.

Matches the same fix in Go, open-feature/go-sdk-contrib#944.

Signed-off-by: Simon Schrottner <simon.schrottner@flagsmith.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant