Skip to content

feat(provider-tck): emit a conformance report whose results are Cucumber Messages - #413

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

feat(provider-tck): emit a conformance report whose results are Cucumber Messages#413
aepfli wants to merge 20 commits into
feat/provider-tckfrom
feat/provider-tck-report

Conversation

@aepfli

@aepfli aepfli commented Aug 24, 2026

Copy link
Copy Markdown
Member

Stacked on #409 — base is feat/provider-tck, so the diff here is only the report emitter. Part of open-feature/spec#424; the schema is open-feature/spec#425.

This PR was reworked after review. It originally defined a per-scenario result format in the report schema. It no longer does: the results are carried in Cucumber Messages, the ndjson protocol, and the schema is an envelope that references them. The rest of this description describes what that means and what was deleted.

Setting TCK_REPORT_DIR makes each suite write two files: an envelope at <dir>/<name>.json and the results it points at at <dir>/<name>.ndjson.

$ TCK_REPORT_DIR=./reports uv run pytest tools/openfeature-tck/tests
provider-tck [controllable-in-memory]: report written to reports/controllable-in-memory.json with results in controllable-in-memory.ndjson (1 failed, 24 passed, 4 skipped)
provider-tck [in-memory]: report written to reports/in-memory.json with results in in-memory.ndjson (1 failed, 23 passed, 5 skipped)

96 passed, 9 skipped, 2 xfailed

The envelope:

{
  "schemaVersion": "1",
  "provider": { "name": "In-Memory Provider", "language": "python", "configuration": "in-memory" },
  "sdk": { "name": "openfeature-sdk", "version": "0.10.0" },
  "tck": {
    "implementation": "python-sdk-contrib/tools/openfeature-tck",
    "version": "0.1.0",
    "specRevision": "aa2ad24f5a14ae2b5756df0b6d23f493f39507e6"
  },
  "declaration": { "declared": ["@events", "@numeric-coercion", "@object"] },
  "results": {
    "format": "cucumber-messages",
    "formatVersion": "34.2.0",
    "location": "in-memory.ndjson",
    "digest": "sha256:bfa19f6f07aaac33effac4ad898e6fe05a875c22932428c502c0a0fcb20b342a"
  },
  "backend": {
    "description": "the Python SDK's InMemoryProvider, rebuilt per scenario",
    "controlApi": "in-process"
  },
  "knownDeviations": [
    {
      "issue": "https://github.com/open-feature/python-sdk/issues/619",
      "summary": "python-sdk: a boolean satisfies an Integer request. …"
    }
  ]
}

Two capability changes came with the submodule bump

The spec submodule moves from dfa16586 to dc4d7ae8, which renames a tag and states a rule the enum was only documenting. Both land here as separate commits.

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

The rule is lossless coercion is permitted; lossy coercion must fail with TYPE_MISMATCH10.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 flagd's accepted numeric coercion ADR (open-feature/flagd#1996) requires to work. flagd's own testbed is gaining @numeric-coercion scenarios, so the tag takes flagd's name rather than leaving the reference implementation and the specification with two vocabularies for one property.

So Capability.STRICT_NUMERIC_TYPING becomes Capability.NUMERIC_COERCION, the marker and tag become numeric-coercion and @numeric-coercion, and the docstring states the rule that holds rather than the one the tag was named for. The pytest marker registration needed no change — it iterates the enum. The existing scenario is unchanged and still passes: it asks about 0.5, which is the lossy half.

Two gaps stay open, and no scenario is added here. The lossless half has no scenario in any language, because the canonical flag set holds 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 this suite. And the width of a language's integer accessor is not modelled at all: the ADR distinguishes a 64-bit accessor from a 32-bit one, which flagd's testbed tags @int32-bounded, and Appendix F has nothing equivalent. Appendix F records both as open rather than closing them, and the capability docstring and README say so too. @numeric-coercion passing is not the same as the coercion contract being tested.

A reserved capability can no longer be declared

@caching is reserved: it exists in the vocabulary and no scenario carries it. @targeting was in the same position 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 enum said so in a docstring and left it there, which reads as documentation rather than as the rule it is. The report schema now states it — see the declaration.declared description — because a capability nothing carries cannot be verified, cannot produce a skip, and tells a reader of a report only that something was claimed and nothing examined.

This was a live defect rather than a hypothetical one: a real Java report asserts both tags as declared, not because anyone decided to claim them but because that adoption declares "every capability except X" and picks up every reserved tag on the way past.

  • The set is written down once, as RESERVED_CAPABILITIES, and read everywhere else — by Capability.reserved, by the declare-everything helper and by TckConfig's validation — so the list cannot drift from the rule.
  • ALL_CAPABILITIES becomes DECLARABLE_CAPABILITIES: the vocabulary minus the reserved tags, named for what it is rather than for "all", because the declare-everything convenience is precisely the route a reserved tag takes into a report by accident. It is also TckConfig.capabilities' default, so a suite that does not narrow its capabilities no longer declares a tag nothing tests.
  • Naming one explicitly raises from TckConfig, in capabilities or in not_applicable. TckConfig already refuses a capability claimed as both declared and not-applicable; this is the same class of error — a claim that cannot be true — caught in the same place, where the adopter's own code is still on the stack. A silent drop would make a rejected configuration look like an accepted one, and a warning is a line of CI output nobody reads while an untested capability goes on being asserted in a published report, which is how it got into one.
  • Nothing filters the declaration at emission time, and report.py says why: by the time an envelope is built, a reserved tag cannot be in the TckConfig at all.

Why the results are not our format

Per-scenario outcomes, tags, Scenario Outline row identity and the executed feature source are all already specified by Cucumber Messages, which is maintained, cross-language, schema'd, and emitted natively by cucumber-jvm. Defining them again in the report schema created a second format to maintain and version, and two places for the same fact to disagree.

What was deleted

scenarios[]. Now TestCase / TestCaseStarted / TestStepStarted / TestStepFinished / TestCaseFinished, keyed by ids the protocol defines.

The four-value outcome enum. Cucumber's own seven statuses replace it. The not-declared / not-applicable split it existed for was never a property of the run: it follows from the envelope's declaration and the scenario's tags, both of which are present, so it is stated once instead of once per scenario. declaration.notApplicable keeps the distinction the enum was drawing, where it belongs — as an input to reading the results rather than a per-scenario fact.

example. This is the clearest case. It was added days ago so an outline row could be identified, and four implementations each reinvented it — one of them, this one, by reverse-engineering how its runner maps a pickle back to a table row. Messages has carried that identity all along: a pickle's astNodeIds are [scenario id, table row id], and the row id resolves in the GherkinDocument to exactly the cells the feature file wrote.

tck.assetsTree. The stream carries the executed feature Source verbatim, so a consumer can read the questions that were actually asked rather than trusting a hash to stand for them. That is strictly stronger than what the tree hash was carrying, and it is one fewer thing for the build to get right. The generated spec_revision.json is now just {"specRevision": "…"}.

What stays OpenFeature-specific, and why

The declaration is an input, not a summary. A skipped scenario in the payload says the question was not put to this provider; only the declaration says whether that is because the provider declines the capability. It cannot be derived from the results, which is exactly why it has to be stated.

No standard results format identifies the tested subject. Messages' Meta records the runtime, the OS and the CPU — what produced the answers, not what was being asked about. So provider, sdk, tck and backend stay in the envelope.

knownDeviations. A gap the provider acknowledges is a claim about the provider, not a result. The Python suite marks one scenario xfail(strict=True) against python-sdk#619; the payload still reports that scenario as FAILED, and the envelope carries the acknowledgement beside it. An adoption declares it with TckConfig.known_deviations. Recording it in the envelope is what lets a consumer tell a tracked gap from a surprise without the result itself being weakened, which is the trade the previous version got wrong by carrying the issue reference as prose inside a reason.

Dependencies added

pytest-bdd emits no Cucumber Messages. It ships cucumber_json.py, the legacy Cucumber JSON format, and nothing for the ndjson protocol — so messages.py assembles the stream. Two libraries, each doing the half it owns:

  • cucumber-messages (34.2.0, new) — the official Python types, published from cucumber/messages itself. Zero dependencies, requires-python >=3.9, actively released. Used for the execution messages: Meta, TestCase, TestCaseStarted, TestStepFinished, TestStepResult, TestRunStarted/Finished, Hook.
  • gherkin-official (>=29, promoted from a transitive dependency of pytest-bdd to a direct one) — the reference Gherkin parser. It produces the gherkinDocument and pickle payloads, which are Messages: emitting Messages ndjson is what that library exists for, so its output is used as it comes rather than round-tripped through another representation that could quietly drop a field it does not model.

uv.lock is regenerated, and uv sync --frozen verified against it. Only cucumber-messages is genuinely new to the lock.

The feature files are parsed a second time, by this package. pytest-bdd parses them with gherkin-official too but converts the result into dataclasses of its own that do not carry the AST node ids — and those ids are what a pickle refers to and what makes one outline row distinguishable from another. Four small files, once per session.

Truthful skips, and per-step results

Appendix F requires that a scenario skipped for an undeclared capability is reported as skipped with the reason and never as passed. pytest, unlike godog, already reports a skip honestly, and Cucumber's SKIPPED says the same thing, so this is the easy part in Python — but it is checked explicitly rather than assumed.

Step results come from pytest-bdd's step hooks (pytest_bdd_after_step, pytest_bdd_step_error, pytest_bdd_step_func_lookup_error) rather than from the scenario's verdict. Marking all eight steps of a scenario FAILED because the scenario failed would be saying something untrue about the seven that passed and the ones never reached. The failing row of the type-mismatch matrix comes out as:

test-case-7-setup     PASSED
test-case-7-0         PASSED
test-case-7-1         PASSED
test-case-7-2         PASSED
test-case-7-3         FAILED   flag 'boolean-flag' resolved to True (bool), expected 1 (int)
test-case-7-4         SKIPPED
test-case-7-5         SKIPPED
test-case-7-6         SKIPPED
test-case-7-teardown  PASSED

with the assertion carried as a TestStepResult.exception of type AssertionError, which is what that field is for.

Every test case also carries a before- and after-hook TestStep, with a Hook message declaring each. pytest runs a scenario in three phases and only the middle one executes Gherkin steps: the capability gate skips during setup, and a provider that fails to shut down fails during teardown. Neither has a pickle step to attach a result to, so without hooks a gated skip would have to borrow the first step's result and a teardown failure would be invisible behind a row of passed steps. Cucumber models exactly this.

That also closes a gap worth naming. A consumer derives a test case's outcome as the worst of its steps. A verdict no step accounts for — a strict xfail that passes, which pytest fails while every step passed — would be lost on the way out, so it is attached to the after-hook. There is a unit test for it.

Scenarios are enumerated at collection and resolved only at the end of the session, unchanged from before and still load-bearing: a scenario skipped by a marker never runs a fixture, so an emitter that learned of a scenario when its fixtures ran would leave it out of the stream entirely.

TckConfig briefly gained not_applicable={Capability.X: "why"} for a capability that cannot hold rather than one the provider declines. It is gone. Appendix F now makes that the implementation's call rather than each adopter's — a capability the language's SDK cannot express is refused at configuration time, uniformly, rather than being declared inapplicable by whoever remembers to — and the schema's declaration object is additionalProperties: false over declared alone, so there was nowhere for the field to go. Python has no such capability today, which is why the removal cost it nothing.

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, and the one both of this package's own suites use — the one that could never appear.
  • 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 that language's SDK rather than of the provider, and a reader who conflated the two would read a language's limit as a provider's decision. Python refuses nothing today; the field it used to carry for this, not_applicable, is removed above.

Verification

Run on WSL Ubuntu, Python 3.10 (the repo's pinned version), against the schema as it stands on feat/provider-tck-report-schema. CI does not run on this PR: build.yml triggers only on pull requests targeting main, and this one targets feat/provider-tck, so everything below is local.

Re-run in full after the submodule bump to aa2ad24f: the test suite, ruff, mypy, envelope validation against the report schema, the digest, the message count, the outcome counts and the gated-skip listing. The Cucumber Messages payload schema validation and the uv build check below were done at dfa16586 and were not repeated; the bump changes feature-file text and a scenario name, not the shape of the stream.

Package test suite. 96 passed, 9 skipped, 2 xfailed. ruff check, ruff format --check and mypy (whose scope includes tests) are all clean.

The renamed tag is what actually ran. Every executed Source carries @numeric-coercion and none carries @strict-numeric-typing, and tck.specRevision in both envelopes is aa2ad24f5a14ae2b5756df0b6d23f493f39507e6 — the submodule pin.

No reserved capability reaches a declaration. No reserved tag appears in declaration.declared in either envelope, and test_a_reserved_capability_cannot_reach_the_declaration asserts it of the emitted envelope for a config that leaves capabilities at its default — the "declare everything" case that put one into a published report elsewhere. test_a_reserved_capability_cannot_be_declared covers the explicit route for every member of RESERVED_CAPABILITIES rather than for a list written down beside it.

Envelope validation. Both envelopes validate with jsonschema as Draft 2020-12 against conformance-report.schema.json, zero errors, and results.digest matches a SHA-256 recomputed over the ndjson bytes:

Suite Envelope Digest Payload
in-memory VALID matches 661 messages
controllable-in-memory VALID matches 661 messages

Payload validation. Both streams validate clean — 661 of 661 messages, zero errors — against the published Cucumber Messages JSON schema at tag v34.2.0 (jsonschema/src/*.schema.json from cucumber/messages, resolved locally through a referencing registry so the relative $refs work). Message census, identical for both suites:

meta 1, testRunStarted 1, hook 2, source 4, gherkinDocument 4, pickle 29,
testCase 29, testCaseStarted 29, testStepStarted 266, testStepFinished 266,
testCaseFinished 29, testRunFinished 1

Every scenario accounted for. 29 TestCase messages against 29 scenarios from pytest's own --collect-only, in both suites, with (uri, scenario name, Examples row) distinct across all 29. test_every_collected_scenario_appears_exactly_once checks the total against --collect-only rather than a number written down beside it, so adding a scenario to the specification cannot leave it passing while the payload loses one.

Outcome counts, computed the way a consumer must — worst step status per test case:

Suite PASSED SKIPPED FAILED
in-memory 23 5 1
controllable-in-memory 24 4 1

Per step, for in-memory: 220 PASSED, 45 SKIPPED, 1 FAILED. Of the 45, 42 belong to the five gated scenarios (their before-hooks included, which is where the reason is) and three are the steps of the failing scenario that were never reached.

Gated skips are truthful. Every scenario carrying a tag absent from declaration.declared is SKIPPED, at every step, in both suites — none is reported as passed. Checked against the reason the gate actually gave, so the derivation from the two documents agrees with reality rather than merely being possible:

SKIPPED  ['@configuration-change']       A configuration change is signalled and applied
         provider does not declare capability @configuration-change. Declared: @events @numeric-coercion @object
SKIPPED  ['@lifecycle']                  A provider that successfully initializes becomes ready
SKIPPED  ['@lifecycle', '@unavailable']  A provider that cannot reach its backend reports an error
SKIPPED  ['@lifecycle', '@unavailable']  A provider that cannot reach its backend still returns code defaults
SKIPPED  ['@stale']                      Losing the backend makes the provider stale, regaining it makes it ready again

(The first scenario's name changed with the submodule bump too, from "A provider reaching its backend becomes ready"; nothing in this package referred to it by name.)

And the distinction survives: the scenario the generated test suite skips with a plain marker is also SKIPPED, but carries no undeclared tag, so it is not attributable to a capability.

The payload disagrees with the runner, deliberately. pytest exits zero — the one scenario the SDK fails is xfailed — and the stream reports it FAILED. That is asserted, not just observed.

The eleven rows. Requesting the wrong type returns the code default produces eleven test cases sharing one scenario name, told apart by the second entry of the pickle's astNodeIds (the id of the TableRow it was compiled from), which resolves in the GherkinDocument to the cells the feature file wrote:

astNodeIds=[25, 9]   PASSED   {key: string-flag,  requested: Boolean, default: false}
astNodeIds=[25, 10]  PASSED   {key: string-flag,  requested: Integer, default: 1}
astNodeIds=[25, 11]  PASSED   {key: string-flag,  requested: Float,   default: 0.1}
astNodeIds=[25, 12]  PASSED   {key: wrong-flag,   requested: Boolean, default: false}
astNodeIds=[25, 15]  PASSED   {key: boolean-flag, requested: String,  default: fallback}
astNodeIds=[25, 16]  FAILED   {key: boolean-flag, requested: Integer, default: 1}
astNodeIds=[25, 17]  PASSED   {key: boolean-flag, requested: Float,   default: 0.1}
astNodeIds=[25, 20]  PASSED   {key: integer-flag, requested: Boolean, default: false}
astNodeIds=[25, 21]  PASSED   {key: integer-flag, requested: String,  default: fallback}
astNodeIds=[25, 22]  PASSED   {key: float-flag,   requested: Boolean, default: false}
astNodeIds=[25, 23]  PASSED   {key: float-flag,   requested: String,  default: fallback}

25 is the Scenario node all eleven share, which is right: the scenario is one scenario. The rows the tests recover from the stream this way are compared against the three Examples tables read out of the Gherkin by hand, rather than against the parser that produced them.

The same holds for rows the capability gate skipped, since row identity comes from the pickle rather than from the run: with @object undeclared, all four rows of Requesting a structured flag as a scalar returns the code default are present, SKIPPED, and individually identified.

Tags on an Examples block. Still covered. Gherkin lets an Examples block carry its own tags, so two rows of one outline can differ in which capability gates them, and those tags are on neither the scenario, the feature nor the rule. No canonical feature file does this yet, so the test writes its own: with one row of a three-row outline gated, all three appear, the gated one is SKIPPED and carries @object, and its two siblings pass.

The executed source is present and correct. Every Source message's data compares equal to the feature file on disk, and the set of Source uris equals the set of uris the test cases name.

Cross-platform. pathlib and list-argv subprocess throughout. Feature uris are normalised to forward slashes: pytest-bdd builds its relative filename with os.path.join, so on Windows it arrives backslash-separated, and the same string has to appear in the Source, the GherkinDocument and every Pickle or nothing ties them together — a report emitted on Windows would otherwise not be comparable with one emitted on Linux. There is a test for it. The emitter itself was developed and verified on Linux; the Windows path of that normalisation is covered by the unit test rather than by a Windows run.

Packaging. uv build produces a wheel carrying spec_revision.json alongside the four feature files, the canonical flag set and control-api.yaml, and declaring the two new requirements.

Failure to write still fails the run loudly, verified by pointing TCK_REPORT_DIR at a path under a regular file. A run that asked for a report and silently did not get one is how a publishing pipeline ends up serving a stale result forever. A scenario that ran and matches no pickle fails the session the same way and names the scenario, because that is the one failure mode this format exists to rule out.

Things worth raising, rather than glossing

The capability rollup is gone entirely, not moved. The previous version summarised a verdict per capability. Both defects raised against it — a failed capability with no reason, and a declared-but-untested capability reported green — disappear with it, but so does the summary. A consumer wanting "did @object pass" now computes it from the payload: the scenarios carrying that tag, and their statuses. That is more work for the consumer and one fewer thing to disagree with the results. Whether the schema should offer a derived summary is worth deciding deliberately rather than by inheritance.

There is still no overall verdict in the envelope. TestRunFinished.success in the payload is false when any scenario failed, which is more than the previous version had, but a consumer must open the payload to see it. Worth deciding whether the envelope wants a top-level verdict.

The stream is assembled by this package, not by the runner. If pytest-bdd ever emits Messages natively, messages.py should shrink to a shim. Two places where that assembly makes a judgement a native emitter would not have to: the join from a pytest node to a pickle goes through (uri, scenario name, Examples row), because pytest-bdd's parse and this one are separate; and test-case/step ids are synthesised (test-case-7-3) rather than being ids the runner already owns. Both are internally consistent and neither is observable across runs, but they are not the ids cucumber-js would emit.

Meta.protocolVersion is read from the installed cucumber-messages version, not pinned in code, so it cannot go stale after a dependency bump. It reports the library version rather than a protocol version negotiated with anything.

Not done here: CI does not upload the reports as artifacts. That belongs with whatever consumes them, and can follow.

@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 changed the title feat(provider-tck): emit a machine-readable conformance report refactor(provider-tck): emit a conformance report whose results are Cucumber Messages Sep 10, 2026
@aepfli aepfli changed the title refactor(provider-tck): emit a conformance report whose results are Cucumber Messages feat(provider-tck): emit a conformance report whose results are Cucumber Messages Sep 10, 2026
@aepfli
aepfli force-pushed the feat/provider-tck-report branch from 4a0f396 to 7ec7e43 Compare September 11, 2026 07:51
@aepfli
aepfli force-pushed the feat/provider-tck-report branch 4 times, most recently from 720c2cd to 7e344b8 Compare September 11, 2026 14:37
@aepfli
aepfli force-pushed the feat/provider-tck-report branch 3 times, most recently from 1a4817f to 9af7cce Compare September 11, 2026 16:47
@codecov

codecov Bot commented Sep 11, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 76.24811% with 157 lines in your changes missing coverage. Please review.
✅ Project coverage is 86.01%. Comparing base (01bb657) to head (b2f9e77).

Files with missing lines Patch % Lines
...e-tck/src/openfeature/contrib/tools/tck/emitter.py 69.60% 69 Missing ⚠️
...-tck/src/openfeature/contrib/tools/tck/messages.py 72.54% 56 Missing ⚠️
...re-tck/src/openfeature/contrib/tools/tck/report.py 87.66% 19 Missing ⚠️
tools/openfeature-tck/hatch_build_sync.py 47.36% 10 Missing ⚠️
...tck/src/openfeature/contrib/tools/tck/canonical.py 94.44% 2 Missing ⚠️
...nfeature/contrib/tools/tck/steps/provider_steps.py 87.50% 1 Missing ⚠️
Additional details and impacted files
@@                  Coverage Diff                  @@
##           feat/provider-tck     #413      +/-   ##
=====================================================
- Coverage              89.02%   86.01%   -3.01%     
=====================================================
  Files                     44       48       +4     
  Lines                   2160     2818     +658     
=====================================================
+ Hits                    1923     2424     +501     
- Misses                   237      394     +157     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@aepfli
aepfli force-pushed the feat/provider-tck-report branch 10 times, most recently from 161f4b2 to e2e48b3 Compare September 13, 2026 14:06
@aepfli
aepfli force-pushed the feat/provider-tck-report branch 4 times, most recently from f6736dc to 47f9cad Compare September 13, 2026 20:01
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). Unset means no report,
which is the default and is not an error.

An environment variable rather than a TckConfig field, so that emitting 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. Several suites in one pytest
session each write their own file, so flagd's two resolvers would not collide.

The load-bearing part is the per-scenario list. Appendix F requires that a
scenario skipped for an undeclared capability is reported as skipped with the
reason and never as passed, and nothing downstream can check that against a
summary line. Recording every scenario's outcome individually makes the rule
checkable by the consumer instead of dependent on the runner. It is also
required to be complete, because a document that quietly dropped what it skipped
would satisfy the letter of the rule and still mislead whoever read it.

pytest, unlike godog, reports a skip honestly -- so the interesting divergence
here is elsewhere. The one scenario the Python SDK cannot satisfy is marked
xfail, so the run finishes green; the provider still did not satisfy it, and the
document says failed with the reason. An expected failure is a recorded
deviation, not an excused one. Scenarios are therefore enumerated at collection
and resolved at the end of the session rather than as fixtures run, which is
also what keeps a scenario skipped by a marker -- whose fixtures never run at
all -- from vanishing from the document.

Identity comes from spec_revision.json, generated by hatch_build_sync.py beside
the copied assets and force-included into the wheel. It has to be captured at
build time: the submodule that knows the answer is not in the distribution, so
an installed copy has nothing left to ask. A build that cannot reach git -- an
unpacked sdist -- warns and records "unknown" rather than inventing a commit.
Both the commit and the tree hash are recorded, the tree because it identifies
the assets alone: unchanged by unrelated edits elsewhere in the specification,
so two runs of identical assets 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 TckConfig.name recorded as the configuration, because
TckConfig.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 off an optional
control_api property rather than added to the BackendControl protocol, so that
adding it leaves every existing control complete and one that stays quiet simply
omits the field.

The tests assert the two properties a consumer is entitled to assume -- that no
scenario the capability gate stopped is ever reported as passed, and that every
collected scenario appears exactly once, counted against pytest's own collection
rather than against a number written down beside it.

Signed-off-by: Simon Schrottner <simon.schrottner@flagsmith.com>
A report entry was identified by feature and name. Every row of a Scenario
Outline shares one name, so the eleven rows of the type-mismatch matrix in
errors.feature produced eleven entries nothing could tell apart -- and in the
Python run one of the eleven fails while ten pass, which is exactly the case the
report could not express. A consumer keying on feature and name kept whichever
row it happened to see last.

Each entry from an outline now carries the row it came from, as the Examples
parameters keyed by column header, matching the "example" property added to the
schema. Values are the cell contents verbatim as strings: Gherkin has no types,
so "1" stays "1" rather than becoming a number the table never mentioned.
pytest-bdd parametrizes the generated test over one dict per row, keyed by the
header, so the row is read back off the node's callspec -- available at
collection, which is what lets a row the capability gate skipped be identified
as precisely as one that ran.

This removes the workaround that appended pytest's own id for the row to the
scenario name. It was the wrong shape twice over. The name is the feature file's
name, and qualifying it made Python disagree with Go and JavaScript about a
scenario all three ran, which defeats the cross-language comparison the report
exists for. And a name format would be normative text -- a separator, an
ordering, an escaping rule -- that four languages have to reproduce byte for
byte, where drift is invisible until two reports silently fail to line up. The
parameters are the identity, and they come from the feature file rather than
from any runner.

The uniqueness test now keys on feature, name and example together, which is the
property this change exists to establish. The examples the report emits are
checked against the Examples tables read out of the Gherkin by hand, rather than
against pytest-bdd's parser, which is what produced them.

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

Two defects in the capability rollup, mirroring the fix already made in Go
(go-sdk-contrib#944).

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 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 ever reaches that branch. The test now 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.

An undeclared capability is still reported with its reason whether or not any
scenario carries it, because that is a fact about the provider rather than about
the suite.

Signed-off-by: Simon Schrottner <simon.schrottner@flagsmith.com>
Gherkin lets an Examples block carry its own tags, so two rows of one Scenario
Outline can differ in which capability gates them. The capability gate already
handled that correctly -- pytest-bdd attaches an Examples block's tags as marks on
that block's parameter sets, and the gate reads the node's markers -- but the
report did not. A scenario's tags were read from the scenario, the feature and
the rule, which is everywhere those tags are not.

The consequence was a misreport of exactly the kind the format exists to rule
out. A row skipped because its Examples block was tagged with an undeclared
capability appeared with no tags at all, so it was classified not-applicable
rather than not-declared -- the run had a reason not to execute it, said the
report, when the reason was a capability the provider does not have. The
capability rollup did not count it either.

The row's tags are now resolved by intersecting the tags the scenario's Examples
blocks declare with the markers pytest put on the node. That names this row's
blocks without having to work out which block a row came from, and admits nothing
that is not a Gherkin tag of this scenario.

No canonical feature file uses per-Examples tags today, so this is latent. It was
found while checking a defect the Go implementation hit in the same area, where
per-scenario bookkeeping keyed by scenario name let one gated row suppress the
accounting for every row of its outline. Nothing here is keyed by name -- the
collector, the durations and the records are all keyed by pytest node id, which
is unique per row -- and the test added here confirms that every row of an
outline is still reported when one of them is gated.

Signed-off-by: Simon Schrottner <simon.schrottner@flagsmith.com>
The emitter defined its own per-scenario result list: a four-value outcome enum,
a tag list, a reason, and a field naming which Scenario Outline row an entry came
from. All of it already exists in Cucumber Messages, which is maintained,
cross-language, schema'd, and emitted natively by cucumber-jvm. The report schema
was reshaped to reference a Messages payload rather than define one
(open-feature/spec#425); this follows it.

A run now writes two files per suite: <name>.json, the envelope, and
<name>.ndjson, the results it points at, with results.digest over the exact bytes
written.

Deleted, because Messages carries them:

  scenarios[]  - now TestCase/TestCaseStarted/TestStepFinished/TestCaseFinished.
  the outcome enum - Cucumber's own seven statuses. The declared/not-applicable
      distinction was never a property of the run: it follows from the
      declaration and the scenario's tags, so it is stated once in the envelope
      instead of once per scenario.
  example      - a pickle's astNodeIds are [scenario id, table row id], and the
      row id resolves in the GherkinDocument to the cells the feature file wrote.
      Four implementations were each reinventing this field by hand.
  tck.assetsTree - the payload carries the executed feature Source verbatim,
      which answers "did two runs ask the same questions" directly rather than by
      proxy.

Two things Messages cannot carry, so they stay. The declaration is an input to
reading the results, not a summary of them. And no standard results format has a
slot for the tested subject: Messages records the runtime and the OS, not what
was being asked about.

pytest-bdd emits no Messages -- it ships the legacy Cucumber JSON format -- so
messages.py assembles the stream. Two dependencies, each doing the half it owns:
cucumber-messages, the official Python types from the protocol's own repository,
for the execution messages; gherkin-official, already a transitive dependency of
pytest-bdd, for the gherkinDocument and pickle payloads, which are used as it
produces them rather than round-tripped through another representation. The
feature files are parsed again because pytest-bdd's own dataclasses drop the AST
node ids a pickle refers to.

Step results come from pytest-bdd's step hooks rather than from the scenario's
verdict, because a stream that marked all eight steps of a scenario failed would
be saying something untrue about the seven that passed and the ones never
reached. Each test case also carries a before- and after-hook TestStep: pytest
runs three phases and only the middle one executes steps, so that is where a
capability skip's reason and a teardown failure belong. A verdict no step
accounts for -- a strict xfail that passes -- is attached to the after-hook, so
it survives a consumer computing the test case's status as the worst of its
steps.

An expected failure is still a failure in the payload. The acknowledgement moved
to the envelope's knownDeviations, declared by TckConfig.known_deviations, where
it records the gap without softening the result. TckConfig also gains
not_applicable, for a capability that cannot hold rather than one the provider
declines.

Verified locally; CI does not run on this branch, which targets the report
branch rather than main. Both suites' envelopes validate against the reshaped
schema with a Draft 2020-12 validator and their digests match; both streams
validate clean against the Cucumber Messages JSON schema at v34.2.0 (661
messages each, zero errors). The stream accounts for all 29 collected scenarios;
the five the capability gate stopped are SKIPPED for every step, none PASSED, and
the one row the SDK fails is FAILED while pytest exits zero.

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 is on 34.2.0 while the Go TCK builds against v21
and cucumber-jvm ships a different release again, so a consumer holding 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.

It reuses the function that already computes the stream's own Meta
protocolVersion rather than adding a second source, so the envelope and the
stream cannot disagree about which release produced it. That function reads the
version from the installed distribution rather than declaring it, so a
dependency bump cannot leave the report claiming the old one.

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, the Python one
stayed silent. Not because the backends differ, but because the self-test control
never offered the optional attribute that reports it.

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

PlainMemoryControl now reports in-process, which is what it is -- the in-memory
provider is rebuilt in this process for every scenario and there is no backend to
drive.

More usefully, a control that reports nothing now says so in the run output.
Every control either drives a real backend over the normative HTTP API or
manipulates one in process, 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. Written to the terminal rather than failing the run, because a
missing optional field is not a conformance problem -- it is a gap in what the
report can say.

Signed-off-by: Simon Schrottner <simon.schrottner@flagsmith.com>
The tag was named for a stricter rule than the specification asks for, and it was
about to collide with a second vocabulary for the same property.

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 names for one property is the drift
a shared vocabulary exists to prevent. The specification renamed the tag and
corrected the rule in open-feature/spec dc4d7ae8; this follows it.

So Capability.STRICT_NUMERIC_TYPING becomes Capability.NUMERIC_COERCION, the
marker and tag become numeric-coercion and @numeric-coercion, and the docstring
states the rule that now holds rather than the one it was named for. The pytest
marker registration needs no change: it iterates the enum.

The submodule bump also carries two unrelated spec changes into the executed
assets -- a lifecycle scenario renamed, and POST /start required not to return
until the seeded flag state is being served. Neither is referenced by name here.

Two gaps are recorded rather than closed, in the capability docstring and the
README, because closing either is a change to every language at once. The lossless
half of the contract has no scenario: the canonical flag set contains no integral
float to ask it of, 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 it.

Signed-off-by: Simon Schrottner <simon.schrottner@flagsmith.com>
@targeting and @caching exist in the vocabulary and no scenario carries either.
The enum said so in a docstring and left it there, which reads as documentation
rather than as the rule it is: a capability nothing carries cannot be verified,
cannot produce a skip, and tells a reader of a conformance report only that
something was claimed and nothing examined.

It is a live defect rather than a hypothetical one. A real Java report asserts
both tags as declared -- not by anyone's decision, but because that adoption
declares "every capability except X" and picks up every reserved tag on the way
past. The report schema now forbids it: see the declaration.declared description
in open-feature/spec.

So the set is written down once, as RESERVED_CAPABILITIES, and read everywhere
else -- by Capability.reserved, by the declare-everything helper, and by the
validation in TckConfig -- so the list cannot drift from the rule.

ALL_CAPABILITIES becomes DECLARABLE_CAPABILITIES: the vocabulary minus the
reserved tags, and named for what it is rather than for "all", because the
declare-everything convenience is precisely the route a reserved tag takes into
a report by accident. It is also TckConfig.capabilities' default, so a suite
that does not narrow its capabilities no longer declares a tag nothing tests.

An adopter who names a reserved capability explicitly gets a ValueError from
TckConfig rather than a warning or a silent drop. TckConfig already refuses a
capability claimed as both declared and not-applicable, and this is the same
class of error -- a claim that cannot be true -- caught in the same place, where
the adopter's own code is still on the stack. A silent drop would make a rejected
configuration look like an accepted one; a warning is a line of CI output nobody
reads while an untested capability goes on being asserted in a published report,
which is how it got into one. Naming one in not_applicable is refused too: an
impossibility recorded about a question never asked reaches the same declaration
block.

Nothing filters the declaration at emission time, and report.py says why: by the
time an envelope is built, a reserved tag cannot be in the TckConfig at all.

Signed-off-by: Simon Schrottner <simon.schrottner@flagsmith.com>
The suite can already run an adopter's own scenarios, and already derives the uri
each feature file is identified by from where the file is -- `features/` for the
packaged assets, `extensions/` for anything under a `tck-extensions` directory.
Nothing read that derivation, because the thing it exists for is a report, and
the report is here.

So the emitter uses it, falling back to what pytest-bdd called a file when the
file is neither. pytest-bdd names a feature by its parent directory joined to its
own name, which two files can share: an extension at
`tck-extensions/features/errors.feature` arrives under exactly the uri the
canonical `errors.feature` occupies, and a Messages stream carries one Source per
uri -- the canonical source is parsed first, the extension's is never read, and
its scenarios are reported against the canonical file's pickles or against none
at all.

Deriving the uri closes the route an adopter is actually likely to take. It does
not close the other two, and those are now refused rather than documented: a
feature file of the adopter's own that would reach the reserved `features/`
prefix, and two files that would share one uri. Both fail the run and write no
report. A document presenting an adopter's feature file as the specification's --
or one file's scenarios against another's source -- is worse than no document,
because it is the one thing a consumer cannot check from the outside. Java
measured the loud form: a same-named feature file in a second classpath root
replaced the canonical one outright and the run went green having asked the
adopter's questions.

`test_extension_reporting` checks the half of the extension point a consumer
reads, and leaves the half that is about running scenarios where it already is:
`test_extensions` asks whether an extension runs inside the canonical suite, and
answers it out of pytest's own JUnit XML, with no report in sight.

Signed-off-by: Simon Schrottner <simon.schrottner@flagsmith.com>
The capability gate rules out the loud way a conformance suite can go green on
scenarios it did not run: an undeclared capability is reported as skipped, with
its reason, never as passed. Nothing ruled out the quiet way, where the
scenarios were never collected at all. `-k`, `-m`, `--deselect`, or a test
module that stopped calling `scenarios()` on the canonical path each run less of
the suite, and none of them is an error to pytest.

Go measured the consequence: `-run` on a single scenario passed green and
emitted a well-formed report covering one of twenty-nine canonical scenarios,
with nothing in the document saying so.

So every run is now checked against the scenarios this distribution ships. The
expectation is compiled from the packaged feature files with the same Gherkin
compiler that produces the results payload, which makes it one entry per
Scenario Outline row -- the granularity the runner generates, and therefore the
only one a comparison can be made at. A suite that did not execute all of them
fails the run and writes no report, naming the scenarios that are missing.

Two things may not close a gap. A scenario the capability gate skipped counts as
having run, because it was asked and the report accounts for it with a reason.
An adopter's own scenarios do not count at all: they are matched by path against
the packaged assets rather than by the uri the emitter derives, so the check does
not rest on the same derivation it exists to corroborate.

`PROVIDER_TCK_PARTIAL=1` buys a green run for someone working on one scenario,
and nothing else -- an incomplete suite writes no report either way. Java's TCK
spells the same escape hatch the same way.

Two adjustments fall out of it. Scenarios are enumerated `trylast` so that
pytest's own deselection has already happened, or a filtered run reports every
deselected scenario as collected but never run and drowns the one message that
matters. And the self-test for per-Examples tags now runs its feature file as an
extension beside the canonical set, because a suite that leaves the canonical set
out no longer produces a report to read back.

Separable from the extension work by design: it guards a bypass rather than
enabling anything, and dropping it leaves the extension point unaffected.

Signed-off-by: Simon Schrottner <simon.schrottner@flagsmith.com>
The base branch followed the conformance assets to open-feature/spec@15fe861,
which takes the canonical set from twenty-nine scenarios to forty. Three
expectations here restated the old number and one declaration claimed a
capability the provider behind InProcessControl does not have.

- test_canonical_set derived nothing: two message assertions and one length
  assertion each named 29. The count now comes from canonical_scenarios() --
  the same parse the guard itself runs -- so adding a canonical scenario does
  not require editing a test that exists to notice the addition. Appendix F
  makes that the rule, and a suite whose own tests have to be edited to follow
  the assets is a suite that will be edited to agree with them.
- test_the_canonical_set_is_read_from_the_packaged_assets would have become
  vacuous if it compared canonical_scenarios() to itself, so it is measured
  against what pytest-bdd actually collected in the unfiltered run, less the
  three scenarios the generated extension contributes. That pins the parse to
  the runner rather than to a literal.
- The generated adoptions in test_report declared NUMERIC_COERCION. The two
  lossless scenarios the new assets add are exactly the ones that catch a
  provider which does not coerce, and the provider behind InProcessControl does
  not: they failed. LARGE_INTEGERS is declared instead -- honest for a Python
  int, and still a third declared capability, which is all those tests needed.
  The declaration is corrected rather than the assertion weakened; finding 3
  in the README is the same call for the in-memory self-tests.

The README's figures follow: the self-test total, the example report line, the
per-step status counts and the partial-run transcript are all re-measured.

Signed-off-by: Simon Schrottner <simon.schrottner@flagsmith.com>
The reporting half names the canonical prefix in its own right: the emitter
refuses a payload whose uris are unsound, the canonical-set guard blames a
missing scenario by uri, and the tests assert on both. Those assertions were
written as the literal "features/", so the rename on the suite left them
pinning a prefix nothing emits any more.

The stale assertions fail loudly, which is the good case. The trap was the
README, whose worked example of a refused partial run printed two canonical
scenarios under features/ -- output no run can produce now, and the one place a
reader would have taken the old form from.

Test assertions move to CANONICAL_DIRECTORY rather than to a new literal, so a
further change to the prefix cannot leave them half-renamed. The single literal
pin lives on the suite branch beside the constant it pins, where the reason for
it is legible.

Two fixtures change shape rather than wording, and both still exercise what they
did. The shadowing adoption copies a canonical feature file into
extensions/gherkin/ so pytest-bdd names it gherkin/errors.feature -- the
canonical uri -- and the derivation has to hold it apart; and the refused
adoption hands scenarios() a directory of its own named gherkin, which is the
one route left to a canonical-looking uri. Renaming those directories to follow
the constant is what keeps them adversarial: pointed at the old name they would
now be ordinary extensions, and both tests would pass having examined nothing.

Signed-off-by: Simon Schrottner <simon.schrottner@flagsmith.com>
The report schema dropped the field in open-feature/spec 7f03f672, and Appendix F
records where language-level impossibility lives in 600ef9fd. TckConfig no longer
carries the set, so there is nothing left for the declaration block to emit.

What the block says is unchanged in substance. A skip in the payload says the
question was not put to this provider; `declared` says whether that is because
the capability was not claimed; the scenario's own tags say what was asked. One
skip carrying its reason was always the whole mechanism, and a parallel field
restated a language fact on every provider's behalf -- @numeric-coercion where
the language has a single numeric type, @large-integers on a 32-bit accessor --
while saying nothing at all in a run where no scenario carried the tag.

The generated self-test suite withheld @Stale by calling it not-applicable, and
now simply leaves it undeclared. Its scenarios were already skipped identically,
so the run it produces is unchanged: the gate never distinguished the two, and
neither did the Messages stream.

Two assertions got stronger rather than weaker. Both places that read an emitted
declaration now pin the block exactly -- `set(declaration) == {"declared"}` and
an equality against the whole dict -- so a field creeping back in fails rather
than passing unnoticed.

Also removes a table row duplicated inside the self-test output fence, which has
been there since the extension-reporting commit. The count above it had to be
refreshed anyway.

Signed-off-by: Simon Schrottner <simon.schrottner@flagsmith.com>
@disabled-flags brings four more Scenario Outline rows, so every number
a reader could check against a run has moved. Measured rather than
arithmetic: the in-memory suite's summary line, the per-step status
counts from its own ndjson, and the partial-run refusal, each read back
off the run that produced them.

The in-memory suite now reports 1 failed, 37 passed, 18 skipped, and its
stream carries 1 FAILED, 360 PASSED and 146 SKIPPED steps -- 143 of
those belonging to the eighteen scenarios the capability gate stopped
and three to the steps of the failing scenario that were never reached.
The partial-run refusal names 55 of 56.

Three counts that were pinned to a moment stop being. Go's single-
scenario run covered one scenario out of the canonical set of the day,
which was twenty-nine; the appendix generalised its own wording at this
revision for the same reason, because a stale number reads as a current
one. The truncation limit's rationale says the same thing without a
number.

231 passed, 35 skipped, 2 xfailed.

Signed-off-by: Simon Schrottner <simon.schrottner@flagsmith.com>
The report machinery landed before the base renamed the package to
openfeature-tck, removed `features_path()` and added the Compose harness,
so rebasing over it leaves this branch's own files behind:

  - test_report.py and test_extension_reporting.py called
    `features_path()`, which no longer exists. Both need the packaged
    canonical directory for a path rather than a scenario set, so they use
    `canonical_root()`; the generated adoptions they write use
    `scenarios(*feature_paths())` like every other one;
  - the baseline adoption in test_extension_reporting -- the one that must
    see no extension -- used to be the `features_path()` call. It is now a
    module one directory below the extensions directory, which tests the
    same property against the call that survives: an extension belongs to
    the module it sits beside;
  - the self-test count in the README goes to 257, and the table gains the
    row for the Compose harness's own tests.

The report's own vocabulary follows the rename too: the log prefix is
`tck [<suite>]` rather than `provider-tck [<suite>]`, and the Cucumber
Messages run and hook ids are `tck-run`, `tck-setup` and `tck-teardown`.
The spec's asset directory, `specification/assets/provider-tck/`, is not
ours to rename and is untouched -- including in `ASSETS_PATH_IN_SPEC`,
which has to keep naming the path git resolves the assets tree at.

Signed-off-by: Simon Schrottner <simon.schrottner@flagsmith.com>
Follows the base's promotion of control_api to a required member of
BackendControl.

Deleted, both of them consequences of the field having been optional:

  - control_api_of(), which read the attribute duck-typed through getattr,
    checked it against the two legal values and fell back to "";
  - control_api_gap(), which described in the run output a control that had
    declined to say -- because omission was otherwise invisible: the suite
    passed, the report validated, and the field was simply absent. It went
    unnoticed until four languages' reports were compared and two were silent
    about the same kind of in-process backend.

Neither has anything left to do. The type assertion, the empty-string branch
and the emitter's extra log line all stop existing, so this is a net deletion.

The `backend` block is now emitted unconditionally, because the report schema
at spec@bcd005f1 puts `backend` in the top-level `required` array and
`controlApi` in `backend`'s. `description` stays conditional: it is free text
for a person and the schema leaves it optional, so an empty one is left out
rather than emitted blank.

Two report tests changed rather than being deleted, and one was load-bearing in
the wrong direction: it asserted the field is *omitted* when a control does not
say, which is now asserting an invalid document. It is replaced by one that
pins both values and one that pins the empty-description case. The test that
checked control_api_of ignores "carrier pigeon" is gone with the function --
the closed Literal on the protocol is where a third value is refused now, and
test_declaration pins that.

Verified rather than assumed: both envelopes the self-tests emit validate
against conformance-report.schema.json at bcd005f1 under
jsonschema.Draft202012Validator. This branch has no automated way to do that --
the schema lives on the spec's report-schema branch, which is not what the
submodule pins, and nothing here depends on jsonschema -- so it was checked out
of band and is not a claim the suite makes for itself.

Signed-off-by: Simon Schrottner <simon.schrottner@flagsmith.com>
PROVIDER_TCK_REPORT_DIR becomes TCK_REPORT_DIR and PROVIDER_TCK_PARTIAL
becomes TCK_PARTIAL, following the package's own rename: the prefix named the
thing after what it currently tests rather than after what it is, and an
options-shaped entry point leaves room for a tck that tests something other
than a provider.

Both, together. The report directory is read by all four languages' suites and
the partial escape hatch by two, so a rename that reached one variable and not
the other would leave TCK_REPORT_DIR beside PROVIDER_TCK_PARTIAL in a single
package -- worse than either consistent answer. Nothing is published and
nothing is scripted against either name, so it is free now and expensive later.

PROVIDER_TCK_PARTIAL is not in the set the cross-language decision named,
which named only the report directory and Go's run gate. It is renamed anyway
for the reason above, and it needs the same change in Java, which spells the
same escape hatch the same way.

REPORT_DIR_ENV and PARTIAL_ENV are unchanged: they are the constants that
carry the names, and nothing reads the strings directly.

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

The note explaining why this deviation names no capability said @numeric-coercion
is "a neighbouring question -- whether 0.5 satisfies an integer request -- and
this provider satisfies it". True of the question and false of the capability:
the SDK's in-memory provider refuses 0.5, which is the answer the lossy row wants,
and fails both lossless rows, which is why neither self-test declares the tag.

Left as it stood, that sentence reads as a provider satisfying a capability it
withholds -- and it sits two files from the notes that now explain, following
spec@045950ca, which withholdings the appendix still calls right. This one is:
a provider that does not attempt the coercion at all, passing the lossy row by
rejecting every float, which is the shortcut the lossless rows exist to catch.
The boolean-as-Integer gap this entry is actually about is a third thing again,
mandatory and ungated, which is what it names no capability for.

Signed-off-by: Simon Schrottner <simon.schrottner@flagsmith.com>
The base rewrote this file from 59,708 bytes to 17,464, and the conflict it
caused here is not one a rebase can resolve: every hunk this branch had in the
README patched prose the base deleted. Those hunks were resolved to the base
text as the rebase replayed them, which left the branch documenting a reporting
feature nowhere in the file.

So this puts it back in one place and in the rewritten register: TCK_REPORT_DIR
and the two files, why the results are Cucumber Messages rather than a format of
our own, how to read the payload -- the hook steps, the pickle tags, the
astNodeIds that identify a Scenario Outline row -- what identifies a report, and
the one gap that is this implementation's, that pytest-bdd emits no Messages.
What is gone with the base's prose is the material Appendix F already carries.

The self-test count in the contributor block follows this branch rather than the
base: 289 passed, 42 skipped, 2 xfailed, measured, and unmoved by any of this.

Signed-off-by: Simon Schrottner <simon.schrottner@flagsmith.com>
@aepfli
aepfli force-pushed the feat/provider-tck-report branch from 47f9cad to b2f9e77 Compare September 13, 2026 21:32
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