Skip to content

feat: add a schema for machine-readable provider conformance reports - #425

Draft
aepfli wants to merge 5 commits into
feat/provider-tck-appendixfrom
feat/provider-tck-report-schema
Draft

feat: add a schema for machine-readable provider conformance reports#425
aepfli wants to merge 5 commits into
feat/provider-tck-appendixfrom
feat/provider-tck-report-schema

Conversation

@aepfli

@aepfli aepfli commented Aug 24, 2026

Copy link
Copy Markdown
Member

Adds a JSON Schema for the machine-readable output of one provider conformance run.

Stacked on #423 (Appendix F), which defines the suite this reports on. Review that one first; this PR is only the report format.

Phase 1 of #424, which is the design discussion. This is the part of that discussion that seems settled enough to write down: what a report has to contain for two reports to be comparable. Collection and trust — how OpenFeature gets reports from providers it does not host, and how much a self-published report should be believed — stay open on the issue and are deliberately not addressed here.

The decisions worth arguing about

Four outcomes, not two. passed, failed, not-declared, not-applicable. Collapsing the last two would misrepresent whole languages: @strict-numeric-typing is unsatisfiable in JavaScript because the language has no integer type, so reporting it as not-declared would show every JavaScript provider as missing something no JavaScript provider can have.

Per-scenario results are required, and required to be complete. This is what makes Appendix F's "skipped, with the reason — never as passed" checkable instead of aspirational. It matters concretely: godog's summary counts capability-skipped scenarios as passed (29 scenarios (29 passed) with two skipped), so the Go runner's headline number already says something false. A runner with that flaw still produces a truthful report here, and that is the point — the rule stops depending on each runner's summary being trustworthy.

No aggregate score. No percentage, no "27/29", no grade. A single number invites comparing providers that declared different capability sets, which is exactly the comparison the tag vocabulary exists to prevent. A consumer that wants one can compute it and own the choice of denominator.

specRevision and assetsTree are a self-checking pair, and git computes both. assetsTree is the git tree object ID of specification/assets/provider-tck. It does two jobs a commit SHA alone cannot. It is checkablegit rev-parse <specRevision>:specification/assets/provider-tck must reproduce it, so a revision recorded wrongly does not pass unnoticed. And it is stable across unrelated spec commits, so two runs that executed identical assets report the same value even when pinned to different commits, which is precisely the comparability question a consumer is asking; a commit SHA changes for every unrelated edit to the specification and would make those two runs look incomparable.

An earlier revision of this PR specified a SHA-256 content digest instead. It was dropped as machinery we would have had to write four times: agreeing on file selection, path form, ordering, framing and line-ending normalisation — the last mattering because Go embeds committed copies that a Windows checkout converts to CRLF. The drift it chiefly targeted, a vendored file edited locally while the revision still points at the original, is already handled where it arises: Java, Python and JavaScript read the assets straight from the submodule so no second copy exists to diverge, and Go has committed copies plus a CI check that regenerates them and fails on any difference. It is a build-time problem with a build-time fix.

The tradeoff kept: the tree covers the asset directory's documentation, so a README edit changes it. That errs towards reporting two comparable runs as different rather than the reverse.

configuration is not optional in practice. One provider can produce several non-interchangeable reports. flagd's RPC and in-process resolvers differ in whether they emit PROVIDER_STALE, so a report keyed on provider name alone would have to pick one and misrepresent the other.

knownDeviation distinguishes "chose not to implement an optional feature" from "has a known bug, tracked here". The tag vocabulary alone cannot express that difference, and three of the four implementations have already needed it: flagd narrows a float to an integer in both Java and Go, Python needs xfail(strict=True) for open-feature/python-sdk#619, and the JavaScript multi-provider flattens every child error code to GENERAL. Each invented a local shape for the same idea.

What is not here

The emitters. No implementation writes this format yet. That is phase 2, and it should follow rather than precede agreement on the shape — writing four emitters against a schema still under discussion would mean rewriting four emitters.

Closes nothing; #424 stays open for collection and trust.

First step of the design discussed in #424: the report format, before any
implementation emits one, so four languages do not invent four dialects.

Three properties are deliberate and are the parts worth arguing about.

Four outcomes, not two. not-declared and not-applicable are distinct, and
collapsing them misrepresents a provider: @strict-numeric-typing is
unsatisfiable in JavaScript because the language has no integer type, so
reporting it as not-declared would show every JS provider as missing something
no JS provider can have.

Per-scenario results are required, and required to be complete. That is what
makes the appendix's own rule -- a skipped scenario is never reported as
passed -- checkable rather than aspirational. It is not hypothetical: godog's
summary counts capability-gated skips as passes, so the Go runner satisfies the
rule only in a separate log line. A runner with that flaw still produces a
truthful report here.

The spec revision sits inside the tck object rather than standing alone,
because it is a property of the artifact that ran rather than an independent
assertion, and it travels with a digest of the assets actually executed. The
revision is a claim; the digest is the check that catches a locally edited
vendored file.

There is deliberately no aggregate score. A single percentage would reward
declaring fewer capabilities, which inverts the incentive the suite exists to
create.

Signed-off-by: Simon Schrottner <simon.schrottner@flagsmith.com>
@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

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

… digest

The schema carried a `gherkinDigest`, a SHA-256 over the feature files and the
canonical flag set, on the argument that `specRevision` is an assertion and a
digest is a check.

The check is worth having; computing it ourselves is not. It would have made
every implementation carry a normative hashing algorithm -- agreeing on file
selection, path form, ordering, framing and line-ending normalisation, the last
of which matters because Go embeds committed copies that a Windows checkout
converts to CRLF. Four implementations of that is a lot of surface for something
git already computes.

`git rev-parse <revision>:specification/assets/provider-tck` is the same idea
with none of the specification. It has two properties the digest was reaching
for. It is verifiable: a third party can recompute it from the recorded revision,
so a revision recorded wrongly does not pass unnoticed. And it is stable across
unrelated spec commits, so two runs that executed identical assets report the
same value even when pinned to different commits -- which the commit SHA alone
gets wrong, since it changes for every unrelated edit to the specification.

The drift the digest was chiefly aimed at -- a vendored copy edited locally while
the recorded revision still points at the original -- is in any case already
handled where it arises. Java, Python and JavaScript read the assets straight
from the submodule, so no second copy exists to diverge. Go has committed copies
and a CI check that regenerates them and fails on any difference. It is a
build-time problem with a build-time fix, and pushing it into the report format
would have every implementation re-detect what its own CI already catches.

The cost is that the tree covers the asset directory's documentation too, so a
README edit changes it. That errs towards reporting two comparable runs as
different rather than the reverse, which is the safe direction.

Signed-off-by: Simon Schrottner <simon.schrottner@flagsmith.com>
Three changes, all of them found by implementing the format rather than by
reading it. Writing four emitters against this schema was always going to be
the real review, and it was.

A reason is now required whenever an outcome is not "passed", at both the
capability and the scenario level. The schema previously said it was "required
in practice", which is another way of saying not required: nothing enforced it,
and a report of bare tags with no explanation would have validated. The reason
is most of the value to a person comparing providers -- "does not support
configuration-change events" is information, "@configuration-change:
not-declared" is a shrug.

knownDeviation moves to a shared definition and becomes available on a scenario
as well as a capability, because the two are different claims. A capability-level
deviation says the provider does not do X. A scenario-level one pins the
deviation to the single case that fails, which is what an implementation marking
one expected failure has to record -- the Python suite marks exactly one, for
open-feature/python-sdk#619, and until now had nowhere to put it but prose in the
reason string.

The capabilities object now states plainly that it is not a verdict. Scenarios
carrying no capability tag are mandatory, roll up into no capability, and are
therefore invisible in that summary -- so a provider can fail a mandatory
scenario while every entry reads "passed". The Python implementation produced
exactly that report. A consumer deciding whether a provider conforms has to read
the scenarios, which is the reason those are required and required to be
complete, and the schema should say so where someone will read it rather than
leaving it to be discovered.

All reports emitted by the Go, Python and JavaScript implementations still
validate, and the two new constraints were checked to reject a report that omits
a reason rather than merely being decorative.

Signed-off-by: Simon Schrottner <simon.schrottner@flagsmith.com>
A scenario entry was identified by feature and name, and every row of a Scenario
Outline shares one name. The type-mismatch matrix is eleven rows, so a report
carried eleven entries distinguished by nothing but a duration measurement. When
one row fails and ten pass -- which is what the Python implementation actually
produced -- the report cannot say which failed, and a consumer keying on feature
and name keeps whichever row it happened to see last.

An entry now carries the Examples row it came from, as the row's parameters keyed
by column header, and identity is feature, name and example together.

The parameters are the identity, and they come from the feature file rather than
from any runner, which is why this is a field rather than a naming convention.
Mandating a mangled name would have put a separator, an ordering and an escaping
rule for values containing the separator into normative text that four
implementations must reproduce byte-for-byte, where drift stays invisible until
two reports quietly fail to line up.

That was not a hypothetical risk. Before this field existed the three
implementations had already diverged on exactly this point, each reasonably and
none compatibly: Go emitted the bare scenario name for all eleven rows, Python
appended pytest's example id, JavaScript used jest-cucumber's expanded title. The
same row of the same feature file had three identities, which defeats the
comparison the format exists to make possible.

Values are the cell contents verbatim as strings, because Gherkin has no types.
Coercing "1" to a number here would make the report disagree with the table it
came from.

Signed-off-by: Simon Schrottner <simon.schrottner@flagsmith.com>
aepfli added a commit to open-feature/go-sdk-contrib that referenced this pull request Aug 24, 2026
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>
Two implementations asked independently whether the four languages should agree
on the key order within an example object. They currently do not: Java emits
Examples column order, Go sorts alphabetically because encoding/json sorts map
keys when marshalling, and neither is wrong.

A JSON object is unordered, so the answer is that order carries no meaning and
implementations need not agree. Saying so explicitly is worth a sentence, because
the alternative is three more implementations each deciding privately and someone
eventually byte-comparing two reports and concluding they disagree when they do
not.

If byte-level reproducibility is ever required -- an attestation over the
document is the obvious case -- that is a canonicalisation problem for the whole
report, not something to solve inside one field.

Signed-off-by: Simon Schrottner <simon.schrottner@flagsmith.com>
@aepfli
aepfli force-pushed the feat/provider-tck-report-schema branch from 5adef5b to 56ed4f9 Compare August 24, 2026 19:57
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