From 609de7b0f44c41070c722aa24457366cc756cf19 Mon Sep 17 00:00:00 2001 From: Speculator55005 <50082482+fas89@users.noreply.github.com> Date: Mon, 7 Sep 2026 20:56:11 +0200 Subject: [PATCH] feat(conformance): make FLUID's claims executable FLUID is published as "the open, declarative standard for Data Products" and, until this commit, shipped twelve schema files, no tests, no conformance vectors, and a backward-compatibility promise checked by nothing. Its sibling FLUX already ships enforcement vectors and a regression suite. This closes that gap on the flagship. It is not only about third parties. The FLUID contract already has more than one first-party reader -- FluidSchemaManager in forge-cli, the FLUX engine's seam/, and the Command Center -- and nothing has ever proved they agree about what a contract means. A shared corpus is how they get held to one answer. tests/0.7.5/ 267 vectors across 11 groups tests/optional/0.7.5/ 2 vectors for behaviour the spec permits but does not require (format assertion) conformance/run.py the runner; assertions are on JSON Schema keyword + RFC 6901 pointer, never message text, so the corpus ports to any language conformance/check_reference.py runs the corpus against forge-cli, which is implementation #1 under test, never the referee conformance/mutation_coverage.py deletes one schema constraint at a time and reports which ones no case would catch scripts/check-compat.py the backward-compatibility gate: a static narrowing diff plus an empirical replay of the previous version's corpus tests/meta_test.py 19 checks proving both gates can actually fail What the gates found on their first run: * 0.7.2 is NOT backward compatible with 0.7.1, contrary to docs/releases/0.7.2.md. $defs/notification gained additionalProperties: false, so a contract with any extra member on a build.execution.notifications[] entry validated under 0.7.1 and is rejected by 0.7.2. Proven with a document plus a negative control that isolates the cause. Waived, because it cannot be un-shipped, with the evidence recorded; the release note still needs correcting. * FLUID never says whether `format` is assertive. JSON Schema 2020-12 makes it an annotation by default and the reference implementation does not assert it, so a contract can validate with a malformed metadata.owner.email. The core tier follows the weak reading and the question is written down rather than silently assumed. * forge-cli validates a 2020-12 schema with a Draft 7 validator. Latent today, because the schemas use only $defs from the 2020-12-only keyword set. It becomes a silent-acceptance bug the moment a schema uses prefixItems, unevaluatedProperties, dependentRequired, minContains or maxContains. Mutation coverage is 16.8% (191 of 1140 constraints). That is the honest baseline, not a good number: 269 cases sound like more coverage than they buy. exposePolicy and semanticModel have no cases at all. Borrowed, with the reasoning recorded in each file: the corpus shape and core/optional split from json-schema-org/JSON-Schema-Test-Suite (MIT); the failure-list file from protocolbuffers/protobuf (BSD-3-Clause); the still-execute-known-failures refinement from connectrpc/conformance (Apache-2.0); expected-error-code assertions from w3c/json-ld-api; and the vectors envelope and meta-test discipline from flux-spec. Co-Authored-By: Claude Opus 5 --- .github/workflows/conformance.yml | 122 +++ .gitignore | 8 + conformance/check_reference.py | 172 ++++ conformance/known-failures.txt | 23 + conformance/mutation_coverage.py | 238 +++++ conformance/run.py | 433 ++++++++ scripts/check-compat.py | 462 +++++++++ scripts/compat-waivers.txt | 59 ++ tests/0.7.5/agentpolicy.json | 715 ++++++++++++++ tests/0.7.5/binding.json | 968 ++++++++++++++++++ tests/0.7.5/build-consumes.json | 875 ++++++++++++++++ tests/0.7.5/contract-schema.json | 1111 +++++++++++++++++++++ tests/0.7.5/envelope.json | 594 +++++++++++ tests/0.7.5/expose.json | 537 ++++++++++ tests/0.7.5/governance-access.json | 673 +++++++++++++ tests/0.7.5/lifecycle-observability.json | 1154 ++++++++++++++++++++++ tests/0.7.5/metadata.json | 469 +++++++++ tests/0.7.5/primitives.json | 746 ++++++++++++++ tests/0.7.5/sovereignty.json | 711 +++++++++++++ tests/README.md | 159 +++ tests/meta_test.py | 315 ++++++ tests/optional/0.7.5/metadata.json | 56 ++ 22 files changed, 10600 insertions(+) create mode 100644 .github/workflows/conformance.yml create mode 100644 conformance/check_reference.py create mode 100644 conformance/known-failures.txt create mode 100644 conformance/mutation_coverage.py create mode 100644 conformance/run.py create mode 100644 scripts/check-compat.py create mode 100644 scripts/compat-waivers.txt create mode 100644 tests/0.7.5/agentpolicy.json create mode 100644 tests/0.7.5/binding.json create mode 100644 tests/0.7.5/build-consumes.json create mode 100644 tests/0.7.5/contract-schema.json create mode 100644 tests/0.7.5/envelope.json create mode 100644 tests/0.7.5/expose.json create mode 100644 tests/0.7.5/governance-access.json create mode 100644 tests/0.7.5/lifecycle-observability.json create mode 100644 tests/0.7.5/metadata.json create mode 100644 tests/0.7.5/primitives.json create mode 100644 tests/0.7.5/sovereignty.json create mode 100644 tests/README.md create mode 100644 tests/meta_test.py create mode 100644 tests/optional/0.7.5/metadata.json diff --git a/.github/workflows/conformance.yml b/.github/workflows/conformance.yml new file mode 100644 index 0000000..bd4b95e --- /dev/null +++ b/.github/workflows/conformance.yml @@ -0,0 +1,122 @@ +# The gate that makes FLUID a standard rather than a schema with a docs site. +# +# Three jobs, deliberately separate so a red build says WHICH promise broke: +# +# meta proves the other two gates can fail. Runs first: a green +# conformance run means nothing if the runner cannot go red. +# conformance runs the vector corpus against the published schemas, and +# again against the reference implementation (forge-cli) so +# the corpus and the engine cannot drift apart unnoticed. +# compat enforces the backward-compatibility promise the README and +# every release note make. + +name: conformance + +on: + push: + branches: [main] + paths: + - "schema/**" + - "tests/**" + - "conformance/**" + - "scripts/check-compat.py" + - "scripts/compat-waivers.txt" + - ".github/workflows/conformance.yml" + pull_request: + paths: + - "schema/**" + - "tests/**" + - "conformance/**" + - "scripts/check-compat.py" + - "scripts/compat-waivers.txt" + - ".github/workflows/conformance.yml" + workflow_dispatch: + +permissions: + contents: read + +jobs: + meta: + name: gates can fail + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + # The [format] extra is REQUIRED, not optional polish: without it, + # jsonschema silently registers no checker for date-time (it needs + # rfc3339-validator), so tests/optional/ format assertions pass + # vacuously in one environment and fail in another. Pinning the extra + # is what makes a local run and a CI run mean the same thing. + - run: pip install "jsonschema[format]>=4.22" + - name: prove the conformance runner and compat gate can go red + run: python3 tests/meta_test.py + + conformance: + name: corpus + runs-on: ubuntu-latest + needs: meta + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + # The [format] extra is REQUIRED, not optional polish: without it, + # jsonschema silently registers no checker for date-time (it needs + # rfc3339-validator), so tests/optional/ format assertions pass + # vacuously in one environment and fail in another. Pinning the extra + # is what makes a local run and a CI run mean the same thing. + - run: pip install "jsonschema[format]>=4.22" + + - name: run the corpus against the published schemas + run: python3 conformance/run.py --junit conformance-report.xml + + # The corpus defines conformance; forge-cli is implementation #1 under + # test, never the referee. Installing latest-stable here is deliberate: + # it is how the Command Center installs it, so a divergence between the + # standard and the engine surfaces on this repo's PRs rather than in a + # customer's container. + - name: install the reference implementation + run: pip install data-product-forge + continue-on-error: true + id: install_forge + + - name: check the reference implementation against the corpus + if: steps.install_forge.outcome == 'success' + run: python3 conformance/check_reference.py + + - uses: actions/upload-artifact@v4 + if: always() + with: + name: conformance-report + path: conformance-report.xml + + compat: + name: backward compatibility + runs-on: ubuntu-latest + needs: meta + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + # The [format] extra is REQUIRED, not optional polish: without it, + # jsonschema silently registers no checker for date-time (it needs + # rfc3339-validator), so tests/optional/ format assertions pass + # vacuously in one environment and fail in another. Pinning the extra + # is what makes a local run and a CI run mean the same thing. + - run: pip install "jsonschema[format]>=4.22" + + # Pre-0.7.1 schemas predate the compatibility promise and already + # diverge from forge-cli at the byte level (see schema-sync.yml), so the + # gate runs over the range where the promise was actually made. + - name: every release keeps its backward-compatibility promise + run: | + set -euo pipefail + for pair in "0.7.1 0.7.2" "0.7.2 0.7.3" "0.7.3 0.7.4" "0.7.4 0.7.5"; do + set -- $pair + echo "::group::$1 -> $2" + python3 scripts/check-compat.py --from "$1" --to "$2" + echo "::endgroup::" + done diff --git a/.gitignore b/.gitignore index 85459fb..ee0fe43 100644 --- a/.gitignore +++ b/.gitignore @@ -32,3 +32,11 @@ npm-debug.log* # Environment .env .env.local + +# Python — this repo gained a conformance runner and gate scripts; until then +# it carried no importable Python, so it had no Python ignores. +__pycache__/ +*.py[cod] +.venv/ +venv/ +.pytest_cache/ diff --git a/conformance/check_reference.py b/conformance/check_reference.py new file mode 100644 index 0000000..1fdf5b6 --- /dev/null +++ b/conformance/check_reference.py @@ -0,0 +1,172 @@ +#!/usr/bin/env python3 +"""Run the corpus against the reference implementation, not just the schema. + +``conformance/run.py`` checks the corpus against the published JSON Schema +using a generic validator. That proves the corpus is right about the schema. +It does not prove the *reference implementation* agrees -- and forge-cli does +more than schema validation, so the two can drift. + +This is the check that matters most, because the FLUID contract already has +more than one first-party reader: + + * ``fluid_build.schema_manager.FluidSchemaManager`` (forge-cli) + * ``flux_engine.seam`` (the FLUX engine, which resolves and compiles a + referenced FLUID contract with its own type parser) + * the Command Center + +Nothing has ever proved those agree about what a FLUID contract means. A +shared corpus is how they are held to one answer. This script covers the +first; the others plug in the same way. + + pip install data-product-forge + python3 conformance/check_reference.py + python3 conformance/check_reference.py --junit reference.xml + +Exit 0 when the implementation's verdict matches the corpus on every case, +1 on any disagreement, 2 if the implementation is not installed. + +A disagreement is not automatically an implementation bug. It can equally mean +the corpus is wrong, or that the implementation deliberately applies semantic +rules the schema cannot express. The first two must be fixed; the third belongs +in ``tests/optional/`` where it is asserted rather than tolerated. +""" + +from __future__ import annotations + +import argparse +import json +import sys +from pathlib import Path +from typing import Any, Dict, List, Optional, Sequence + +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +from run import ( # noqa: E402 + CorpusError, + discover_versions, + load_corpus, + write_junit, +) + + +def load_reference(): + try: + from fluid_build.schema_manager import FluidSchemaManager + except ImportError: + print( + "check_reference: the reference implementation is not installed.\n" + " pip install data-product-forge\n" + "This check is skipped rather than failed when forge-cli is absent, " + "so the corpus stays runnable on its own.", + file=sys.stderr, + ) + return None + return FluidSchemaManager() + + +def verdict(manager, contract: Dict[str, Any], version: str) -> Dict[str, Any]: + """Normalise the implementation's answer to valid/invalid plus a reason.""" + try: + result = manager.validate_contract(contract, schema_version=version) + except Exception as exc: # an exception is a verdict of 'invalid', loudly + return {"valid": False, "reason": f"{type(exc).__name__}: {exc}", "raised": True} + + errors = list(getattr(result, "errors", []) or []) + is_valid = getattr(result, "is_valid", None) + if is_valid is None: + is_valid = not errors + return { + "valid": bool(is_valid), + "reason": "; ".join(str(e) for e in errors[:3]), + "raised": False, + } + + +def main(argv: Optional[Sequence[str]] = None) -> int: + ap = argparse.ArgumentParser(description=__doc__.split("\n")[0]) + ap.add_argument("--version", action="append", dest="versions", metavar="X.Y.Z") + ap.add_argument("--junit", type=Path, metavar="PATH") + args = ap.parse_args(argv) + + manager = load_reference() + if manager is None: + return 2 + + versions = args.versions or discover_versions() + results: List[Dict[str, Any]] = [] + disagreements: List[str] = [] + unimplemented: List[str] = [] + + for version in versions: + try: + # optional/ asserts semantics beyond the schema, which is exactly + # what an implementation MAY implement -- include it here. + cases = load_corpus(version, include_optional=True) + except CorpusError as exc: + print(f"check_reference: {exc}", file=sys.stderr) + return 2 + + for case in cases: + test_id = f"{version}/{case['id']}" + got = verdict(manager, case["contract"], version) + agrees = got["valid"] == case["valid"] + optional = bool(case.get("optional")) + detail = "" + if not agrees and optional: + # The optional tier asserts behaviour the specification permits + # but does not require. An implementation that does not do it is + # still conformant, so this is reported, never failed. + unimplemented.append(test_id) + results.append( + { + "id": test_id, + "group": f"{version}.{case['group']}", + "source": case["source"], + "status": "skip", + "detail": "optional behaviour not implemented", + } + ) + continue + if not agrees: + expected = "VALID" if case["valid"] else "INVALID" + actual = "VALID" if got["valid"] else "INVALID" + detail = ( + f"corpus says {expected}, {version} reference implementation " + f"says {actual}" + ) + if got["reason"]: + detail += f"\n implementation said: {got['reason'][:300]}" + disagreements.append(test_id) + results.append( + { + "id": test_id, + "group": f"{version}.{case['group']}", + "source": case["source"], + "status": "pass" if agrees else "fail", + "detail": detail, + } + ) + + for r in results: + if r["status"] == "fail": + print(f"\nDISAGREEMENT {r['id']}\n ({r['source']})\n {r['detail']}") + + for test_id in unimplemented: + print(f"\noptional, not implemented {test_id}") + + print( + f"\nreference implementation vs corpus: {len(results)} cases, " + f"{len(results) - len(disagreements) - len(unimplemented)} agree, " + f"{len(disagreements)} disagree, " + f"{len(unimplemented)} optional behaviour(s) not implemented" + ) + + if args.junit: + write_junit(args.junit, results) + print(f" junit: {args.junit}") + + return 1 if disagreements else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/conformance/known-failures.txt b/conformance/known-failures.txt new file mode 100644 index 0000000..265a4ca --- /dev/null +++ b/conformance/known-failures.txt @@ -0,0 +1,23 @@ +# Conformance cases that are expected to FAIL. +# +# One test id per line, '#' for comments, kept sorted. Get ids from: +# python3 conformance/run.py --list +# +# Two rules make this file a record rather than a mute button: +# +# 1. Listed cases are still EXECUTED. A case here that starts passing is an +# error, not a quiet success -- the fix and the removal land together, so +# the file never drifts from reality. +# 2. An entry naming a test that no longer exists is also an error. A +# renamed case cannot leave a stale exemption behind. +# +# Convention from protocolbuffers/protobuf's conformance failure_list +# (BSD-3-Clause), with the still-execute refinement from connectrpc/conformance +# (Apache-2.0). +# +# What belongs here: a real, understood shortcoming that cannot be fixed in the +# same change that discovers it. What does NOT belong here: a case that is +# merely inconvenient, or one whose expectation nobody has checked. If a case +# is wrong, fix the case. +# +# Currently empty: every published case passes against every published schema. diff --git a/conformance/mutation_coverage.py b/conformance/mutation_coverage.py new file mode 100644 index 0000000..4a03a69 --- /dev/null +++ b/conformance/mutation_coverage.py @@ -0,0 +1,238 @@ +#!/usr/bin/env python3 +"""Mutation coverage: which schema constraints does the corpus actually pin? + +Counting cases measures effort, not coverage. The question that matters is: +*if someone weakened this schema, would anything go red?* A constraint no case +catches is a promise the standard makes and cannot keep. + +So this deletes one constraint at a time and re-runs the corpus: + + constraint deleted -> some case fails -> COVERED + constraint deleted -> corpus still green -> UNCOVERED + +An UNCOVERED constraint is not necessarily a bug. Some are unreachable, some +are genuinely not worth a case. But each one should be a decision rather than +an accident, which is what this report makes possible. + + python3 conformance/mutation_coverage.py + python3 conformance/mutation_coverage.py --version 0.7.5 --format json + python3 conformance/mutation_coverage.py --min-coverage 80 # gate mode + +Mutation testing as a coverage measure is standard practice (Stryker, mutmut, +PIT); what is specific here is mutating the SCHEMA rather than the code, since +the schema is the artefact under test and the corpus is the test suite. +""" + +from __future__ import annotations + +import argparse +import copy +import json +import sys +from pathlib import Path +from typing import Any, Callable, Dict, List, Optional, Sequence, Tuple + +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +from run import ( # noqa: E402 + CorpusError, + _build_validators, + discover_versions, + load_corpus, + load_schema, + run_case, +) + +Mutation = Tuple[str, str, Callable[[Dict[str, Any]], None]] + + +def _walk(node: Any, pointer: str, out: List[Mutation]) -> None: + """Collect one mutation per constraint the schema expresses.""" + if isinstance(node, dict): + if isinstance(node.get("required"), list): + for name in list(node["required"]): + out.append( + ( + f"{pointer}/required", + f"drop required member {name!r}", + _dropper(pointer, "required", name), + ) + ) + for keyword in ( + "enum", + "pattern", + "minItems", + "maxItems", + "minLength", + "maxLength", + "minimum", + "maximum", + "exclusiveMinimum", + "exclusiveMaximum", + "uniqueItems", + "minProperties", + "maxProperties", + "const", + ): + if keyword in node: + out.append( + ( + f"{pointer}/{keyword}", + f"delete {keyword}", + _deleter(pointer, keyword), + ) + ) + if node.get("additionalProperties") is False: + out.append( + ( + f"{pointer}/additionalProperties", + "open the object (additionalProperties -> true)", + _setter(pointer, "additionalProperties", True), + ) + ) + if "type" in node and pointer: + out.append( + (f"{pointer}/type", "delete type", _deleter(pointer, "type")) + ) + + for key, value in node.items(): + if key in ("enum", "required", "const", "examples", "description"): + continue + _walk(value, f"{pointer}/{key}", out) + elif isinstance(node, list): + for i, value in enumerate(node): + _walk(value, f"{pointer}/{i}", out) + + +def _resolve(schema: Dict[str, Any], pointer: str) -> Any: + node: Any = schema + for part in pointer.split("/"): + if part == "": + continue + node = node[int(part)] if isinstance(node, list) else node[part] + return node + + +def _deleter(pointer: str, keyword: str) -> Callable[[Dict[str, Any]], None]: + def apply(schema: Dict[str, Any]) -> None: + _resolve(schema, pointer).pop(keyword, None) + + return apply + + +def _setter(pointer: str, keyword: str, value: Any) -> Callable[[Dict[str, Any]], None]: + def apply(schema: Dict[str, Any]) -> None: + _resolve(schema, pointer)[keyword] = value + + return apply + + +def _dropper(pointer: str, keyword: str, item: Any) -> Callable[[Dict[str, Any]], None]: + def apply(schema: Dict[str, Any]) -> None: + node = _resolve(schema, pointer) + if item in node.get(keyword, []): + node[keyword] = [x for x in node[keyword] if x != item] + + return apply + + +def corpus_is_red(cases: List[Dict[str, Any]], schema: Dict[str, Any]) -> bool: + """True when at least one case fails against this (mutated) schema.""" + validators = _build_validators(schema) + for case in cases: + if not run_case(case, validators)["ok"]: + return True + return False + + +def main(argv: Optional[Sequence[str]] = None) -> int: + ap = argparse.ArgumentParser(description=__doc__.split("\n")[0]) + ap.add_argument("--version", action="append", dest="versions", metavar="X.Y.Z") + ap.add_argument("--format", choices=("text", "json"), default="text") + ap.add_argument( + "--min-coverage", + type=float, + default=None, + metavar="PCT", + help="exit non-zero if coverage falls below this percentage", + ) + ap.add_argument("--limit", type=int, default=None, help="sample N mutations") + args = ap.parse_args(argv) + + report: Dict[str, Any] = {} + worst = 100.0 + + for version in args.versions or discover_versions(): + try: + cases = load_corpus(version, include_optional=True) + except CorpusError as exc: + print(f"mutation-coverage: {exc}", file=sys.stderr) + return 2 + schema = load_schema(version) + + mutations: List[Mutation] = [] + _walk(schema, "", mutations) + if args.limit: + mutations = mutations[: args.limit] + + # Sanity: the corpus must be green before anything is mutated, or every + # mutation trivially "fails" and the whole report is meaningless. + if corpus_is_red(cases, copy.deepcopy(schema)): + print( + f"mutation-coverage: the {version} corpus is not green against the " + "unmutated schema; fix that first -- otherwise every constraint " + "reads as covered.", + file=sys.stderr, + ) + return 2 + + covered: List[str] = [] + uncovered: List[Tuple[str, str]] = [] + for pointer, label, apply in mutations: + mutated = copy.deepcopy(schema) + try: + apply(mutated) + except (KeyError, IndexError, TypeError): + continue + if mutated == schema: + continue + if corpus_is_red(cases, mutated): + covered.append(f"{pointer}: {label}") + else: + uncovered.append((pointer, label)) + + total = len(covered) + len(uncovered) + pct = (100.0 * len(covered) / total) if total else 100.0 + worst = min(worst, pct) + report[version] = { + "cases": len(cases), + "mutations": total, + "covered": len(covered), + "uncovered": [{"pointer": p, "mutation": m} for p, m in uncovered], + "coveragePct": round(pct, 1), + } + + if args.format == "text": + print(f"\n{version}: {len(cases)} cases vs {total} schema constraints") + print(f" covered {len(covered):4} ({pct:.1f}%)") + print(f" UNCOVERED {len(uncovered):4}") + for pointer, label in uncovered[:40]: + print(f" {pointer} -- {label}") + if len(uncovered) > 40: + print(f" ... and {len(uncovered) - 40} more (use --format json)") + + if args.format == "json": + print(json.dumps(report, indent=2)) + + if args.min_coverage is not None and worst < args.min_coverage: + print( + f"\nmutation-coverage: {worst:.1f}% is below the required " + f"{args.min_coverage:.1f}%", + file=sys.stderr, + ) + return 1 + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/conformance/run.py b/conformance/run.py new file mode 100644 index 0000000..8dca128 --- /dev/null +++ b/conformance/run.py @@ -0,0 +1,433 @@ +#!/usr/bin/env python3 +"""The FLUID conformance runner. + +Runs the vector corpus in ``tests/`` against a FLUID schema and reports which +cases an implementation gets right. FLUID's schemas are authored in forge-cli +and distributed here; this runner is what makes "FLUID-conformant" mean +something other than "the reference implementation did not crash". + + python3 conformance/run.py # every published version + python3 conformance/run.py --version 0.7.5 # one version + python3 conformance/run.py --junit report.xml # for CI + python3 conformance/run.py --list # print test ids + +Exit code is 0 only when every case behaves as the corpus says it must, and +every id in ``conformance/known-failures.txt`` still fails. A known failure +that starts passing is an error, not a quiet success: the corpus and the file +have to be updated together, or the record of what we cannot do yet rots. + +Design borrowed (adapt-the-pattern) from prior art surveyed under +borrow-before-build: + +* json-schema-org/JSON-Schema-Test-Suite (MIT) -- the corpus is data, grouped + into per-topic files under a per-version directory, and requires only a JSON + parser to consume. Its ``optional/`` split is ours too: ``tests/optional/`` + holds cases an implementation may legitimately not reach. +* protocolbuffers/protobuf (BSD-3-Clause) -- the failure-list file: one test id + per line, ``#`` comments, kept sorted, so a suite can be strict without being + merge-blocking on the day it lands. +* connectrpc/conformance (Apache-2.0) -- known-failing cases are still + EXECUTED, so a fix flips them green loudly instead of silently. +* w3c/json-ld-api (W3C-20150513) -- asserting an expected *error code* rather + than a message string, so the assertion survives rewording. +* flux-spec (Apache-2.0, same owner) -- the vectors envelope, and the + meta-test discipline of proving a gate can actually fail. + +Assertions are on JSON Schema keyword + RFC 6901 instance pointer, never on +message text, because message text is a rendering choice and every validator +words it differently. That is what keeps the corpus portable to ajv, to +check-jsonschema, and to an implementation in a language nobody has written +yet. +""" + +from __future__ import annotations + +import argparse +import json +import sys +from pathlib import Path +from typing import Any, Dict, Iterable, List, Optional, Sequence, Tuple +from xml.etree import ElementTree as ET + +REPO = Path(__file__).resolve().parent.parent +SCHEMA_DIR = REPO / "schema" +TESTS_DIR = REPO / "tests" +KNOWN_FAILURES = Path(__file__).resolve().parent / "known-failures.txt" + +SCHEMA_URI_TEMPLATE = ( + "https://open-data-protocol.github.io/fluid/schema/fluid-schema-{version}.json" +) + + +class CorpusError(RuntimeError): + """The corpus itself is malformed. Never a conformance failure.""" + + +# -------------------------------------------------------------------------- +# corpus loading +# -------------------------------------------------------------------------- + + +def discover_versions() -> List[str]: + """Corpus directories that have a matching published schema.""" + versions = [] + for child in sorted(TESTS_DIR.iterdir()): + if not child.is_dir() or child.name == "optional": + continue + if (SCHEMA_DIR / f"fluid-schema-{child.name}.json").is_file(): + versions.append(child.name) + return versions + + +def load_schema(version: str) -> Dict[str, Any]: + path = SCHEMA_DIR / f"fluid-schema-{version}.json" + if not path.is_file(): + raise CorpusError(f"no published schema for version {version}: {path}") + return json.loads(path.read_text()) + + +def _corpus_files( + version: str, include_optional: bool, group: Optional[str] = None +) -> List[Tuple[Path, bool]]: + pattern = f"{group}.json" if group else "*.json" + files: List[Tuple[Path, bool]] = [ + (p, False) for p in sorted((TESTS_DIR / version).glob(pattern)) + ] + optional_dir = TESTS_DIR / "optional" / version + if include_optional and optional_dir.is_dir(): + files += [(p, True) for p in sorted(optional_dir.glob(pattern))] + return files + + +def load_corpus( + version: str, include_optional: bool, group: Optional[str] = None +) -> List[Dict[str, Any]]: + """Flatten every case for ``version`` into a list carrying its own id. + + A case id is ``/``. Descriptions are required to be + unique inside a group so ids stay stable when cases are inserted -- an + index-based id would renumber every downstream case on an insert and + silently repoint every known-failure entry. + """ + cases: List[Dict[str, Any]] = [] + for path, optional in _corpus_files(version, include_optional, group): + doc = json.loads(path.read_text()) + # Deliberately not rebinding `group`: it is the file filter for the whole + # loop, and a per-file label would clobber it on the second iteration. + label = doc.get("group") or path.stem + raw_cases = doc.get("cases") + if not isinstance(raw_cases, list) or not raw_cases: + raise CorpusError(f"{path}: 'cases' must be a non-empty array") + + seen: set = set() + for case in raw_cases: + desc = case.get("description") + if not desc: + raise CorpusError(f"{path}: every case needs a 'description'") + if desc in seen: + raise CorpusError(f"{path}: duplicate case description {desc!r}") + seen.add(desc) + if "contract" not in case: + raise CorpusError(f"{path}: case {desc!r} has no 'contract'") + if not isinstance(case.get("valid"), bool): + raise CorpusError(f"{path}: case {desc!r} needs a boolean 'valid'") + if case["valid"] and case.get("errors"): + raise CorpusError( + f"{path}: case {desc!r} is valid but declares expected errors" + ) + if not case["valid"] and not case.get("errors"): + raise CorpusError( + f"{path}: case {desc!r} is invalid but declares no expected " + "errors -- an invalid case must say WHY, or it passes for " + "any reason at all" + ) + cases.append( + { + "id": f"{label}/{desc}", + "group": label, + "optional": optional, + "source": str(path.relative_to(REPO)), + **case, + } + ) + return cases + + +# -------------------------------------------------------------------------- +# validation +# -------------------------------------------------------------------------- + + +def _build_validators(schema: Dict[str, Any]) -> Dict[bool, Any]: + """One validator per tier, keyed by whether `format` is asserted. + + In JSON Schema 2020-12 `format` is an ANNOTATION by default, not an + assertion: a conformant validator may accept "not-an-email" for + {"format": "email"}. FLUID has never said which it wants, and its own + reference implementation does not assert formats. + + So the core corpus is checked WITHOUT format assertion -- the weaker, + universally-agreed reading -- and cases that depend on formats being + assertive live in tests/optional/, exactly where JSON-Schema-Test-Suite + puts its own format tests. That way the core corpus stays runnable by any + conformant validator, and format behaviour is asserted where an + implementation opts into it, rather than being silently assumed. + """ + try: + from jsonschema import Draft202012Validator, FormatChecker + except ImportError: # pragma: no cover - environment problem, not a failure + sys.exit( + "conformance: the reference runner needs 'jsonschema'.\n" + " pip install jsonschema>=4.22\n" + "An implementation in another language does not need this runner; " + "the corpus under tests/ is plain JSON." + ) + return { + False: Draft202012Validator(schema), + True: Draft202012Validator(schema, format_checker=FormatChecker()), + } + + +def _pointer(error) -> str: + """RFC 6901 pointer for the instance location that failed.""" + out = "" + for part in error.absolute_path: + out += "/" + str(part).replace("~", "~0").replace("/", "~1") + return out + + +def _observed(error) -> Dict[str, Any]: + obs = {"pointer": _pointer(error), "keyword": error.validator} + # For 'required', the useful discriminator is WHICH property is missing. + # jsonschema puts it in the message; the schema keeps it in validator_value + # only as the full list, so recover it from the message deterministically. + if error.validator == "required": + msg = error.message + if "'" in msg: + obs["detail"] = msg.split("'")[1] + elif error.validator == "additionalProperties": + obs["detail"] = ", ".join(sorted(_extra_props(error))) + return obs + + +def _extra_props(error) -> Iterable[str]: + allowed = set() + schema = error.schema or {} + allowed |= set((schema.get("properties") or {}).keys()) + instance = error.instance if isinstance(error.instance, dict) else {} + return sorted(set(instance.keys()) - allowed) + + +def _matches(expected: Dict[str, Any], observed: Sequence[Dict[str, Any]]) -> bool: + """An expectation matches if some observed error agrees on every field it names. + + Expectations are deliberately partial: a case that says only + ``{"keyword": "required", "detail": "fluidVersion"}`` should not break when + a future schema change moves the pointer. + """ + for obs in observed: + if all(obs.get(k) == v for k, v in expected.items()): + return True + return False + + +def run_case(case: Dict[str, Any], validators: Dict[bool, Any]) -> Dict[str, Any]: + validator = validators[bool(case.get("optional"))] + errors = sorted(validator.iter_errors(case["contract"]), key=lambda e: str(e.path)) + observed = [_observed(e) for e in errors] + actually_valid = not errors + + if case["valid"]: + if actually_valid: + return {"ok": True} + return { + "ok": False, + "reason": "expected the contract to be VALID, but the schema rejected it", + "observed": observed[:6], + } + + if actually_valid: + return { + "ok": False, + "reason": "expected the contract to be INVALID, but the schema accepted it", + "observed": [], + } + + unmet = [e for e in case["errors"] if not _matches(e, observed)] + if unmet: + return { + "ok": False, + "reason": "the contract was rejected, but not for the declared reason", + "unmet": unmet, + "observed": observed[:6], + } + return {"ok": True} + + +# -------------------------------------------------------------------------- +# known failures +# -------------------------------------------------------------------------- + + +def load_known_failures() -> List[str]: + if not KNOWN_FAILURES.is_file(): + return [] + ids = [] + for line in KNOWN_FAILURES.read_text().splitlines(): + line = line.split("#", 1)[0].strip() + if line: + ids.append(line) + if ids != sorted(ids): + raise CorpusError( + f"{KNOWN_FAILURES.name} must be sorted so merges stay reviewable; " + "run `sort -o conformance/known-failures.txt " + "conformance/known-failures.txt`" + ) + return ids + + +# -------------------------------------------------------------------------- +# reporting +# -------------------------------------------------------------------------- + + +def write_junit(path: Path, results: List[Dict[str, Any]]) -> None: + suite = ET.Element( + "testsuite", + name="fluid-conformance", + tests=str(len(results)), + failures=str(sum(1 for r in results if r["status"] == "fail")), + skipped=str(sum(1 for r in results if r["status"] == "knownfail")), + ) + for r in results: + case = ET.SubElement( + suite, "testcase", classname=r["group"], name=r["id"], file=r["source"] + ) + if r["status"] == "fail": + failure = ET.SubElement(case, "failure", message=r["detail"][:200]) + failure.text = r["detail"] + elif r["status"] == "knownfail": + ET.SubElement(case, "skipped", message="known failure") + path.parent.mkdir(parents=True, exist_ok=True) + ET.ElementTree(suite).write(path, encoding="utf-8", xml_declaration=True) + + +def _describe(outcome: Dict[str, Any]) -> str: + parts = [outcome.get("reason", "failed")] + for e in outcome.get("unmet", []): + parts.append(f" expected error not raised: {json.dumps(e, sort_keys=True)}") + for o in outcome.get("observed", []): + parts.append(f" observed: {json.dumps(o, sort_keys=True)}") + return "\n".join(parts) + + +# -------------------------------------------------------------------------- +# entry point +# -------------------------------------------------------------------------- + + +def main(argv: Optional[Sequence[str]] = None) -> int: + ap = argparse.ArgumentParser( + description="Run the FLUID conformance corpus against a published schema." + ) + ap.add_argument("--version", action="append", dest="versions", metavar="X.Y.Z") + ap.add_argument( + "--group", + metavar="NAME", + help="run only one corpus file (its stem), e.g. --group agentpolicy. " + "Other files are not even loaded, so a malformed sibling cannot mask " + "or break the group under test.", + ) + ap.add_argument("--junit", type=Path, metavar="PATH") + ap.add_argument("--list", action="store_true", help="print test ids and exit") + ap.add_argument( + "--no-optional", + action="store_true", + help="skip tests/optional/, which asserts semantics beyond the schema", + ) + ap.add_argument("-q", "--quiet", action="store_true") + args = ap.parse_args(argv) + + versions = args.versions or discover_versions() + if not versions: + print("conformance: no corpus directories found under tests/", file=sys.stderr) + return 2 + + try: + known = set(load_known_failures()) + except CorpusError as exc: + print(f"conformance: {exc}", file=sys.stderr) + return 2 + + results: List[Dict[str, Any]] = [] + for version in versions: + try: + cases = load_corpus( + version, + include_optional=not args.no_optional, + group=args.group, + ) + validators = _build_validators(load_schema(version)) + except CorpusError as exc: + print(f"conformance: {exc}", file=sys.stderr) + return 2 + + for case in cases: + test_id = f"{version}/{case['id']}" + if args.list: + print(test_id) + continue + outcome = run_case(case, validators) + expected_fail = test_id in known + if outcome["ok"]: + status = "unexpectedpass" if expected_fail else "pass" + detail = ( + "listed in known-failures.txt but PASSED -- remove the entry" + if expected_fail + else "" + ) + else: + status = "knownfail" if expected_fail else "fail" + detail = _describe(outcome) + results.append( + { + "id": test_id, + "group": f"{version}.{case['group']}", + "source": case["source"], + "status": status, + "detail": detail, + } + ) + + if args.list: + return 0 + + ran = {r["id"] for r in results} + stale = [] if args.group else sorted(known - ran) + failed = [r for r in results if r["status"] == "fail"] + unexpected = [r for r in results if r["status"] == "unexpectedpass"] + knownfail = [r for r in results if r["status"] == "knownfail"] + + if not args.quiet: + for r in failed + unexpected: + marker = "FAIL" if r["status"] == "fail" else "UNEXPECTED PASS" + print(f"\n{marker} {r['id']}\n ({r['source']})\n{r['detail']}") + + print( + f"\nfluid conformance: {len(results)} cases, " + f"{len(results) - len(failed) - len(unexpected) - len(knownfail)} passed, " + f"{len(failed)} failed, {len(unexpected)} unexpectedly passed, " + f"{len(knownfail)} known failures" + ) + for entry in stale: + print(f" stale known-failure (no such test): {entry}", file=sys.stderr) + + if args.junit: + write_junit(args.junit, results) + print(f" junit: {args.junit}") + + return 1 if (failed or unexpected or stale) else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/check-compat.py b/scripts/check-compat.py new file mode 100644 index 0000000..2f67969 --- /dev/null +++ b/scripts/check-compat.py @@ -0,0 +1,462 @@ +#!/usr/bin/env python3 +"""The FLUID backward-compatibility gate. + +Every FLUID release makes the same promise, in the README and in every +``docs/releases/0.7.x.md``: + + "additive and fully backward-compatible -- every valid 0.7.4 contract + still validates as 0.7.5 unchanged" + +Until now nothing checked it. This script does, two independent ways: + + STATIC walk both schemas together and classify every difference as + additive (safe) or narrowing (breaking). A narrowing change is one + that can turn a document that used to validate into one that does + not: a new required member, a removed enum value, a removed + property on a closed object, a tightened bound, a changed type. + + EMPIRICAL run the previous version's conformance corpus against the new + schema. Every case the old corpus calls valid MUST still be valid. + This is the promise stated literally, and it only reports on + behaviour the corpus actually covers -- which is why the static + pass exists alongside it. + +Usage: + python3 scripts/check-compat.py # every adjacent pair + python3 scripts/check-compat.py --from 0.7.4 --to 0.7.5 + python3 scripts/check-compat.py --format json + +Exit 0 when every pair is compatible or every breaking change is waived in +``scripts/compat-waivers.txt``; 1 when a release breaks the promise; 2 on a +usage or input error. + +A waiver is how a DELIBERATE break ships: it records the decision in the repo +instead of quietly weakening the gate. Waivers are one ``-> `` +per line, ``#`` comments, kept sorted -- the failure-list convention from +protocolbuffers/protobuf (BSD-3-Clause), which keeps a strict gate adoptable on +the day it lands rather than merge-blocking on history nobody can change. +""" + +from __future__ import annotations + +import argparse +import json +import re +import sys +from pathlib import Path +from typing import Any, Dict, List, Optional, Sequence, Tuple + +REPO = Path(__file__).resolve().parent.parent +SCHEMA_DIR = REPO / "schema" +TESTS_DIR = REPO / "tests" +WAIVERS = Path(__file__).resolve().parent / "compat-waivers.txt" + +VERSION_RE = re.compile(r"^fluid-schema-(\d+\.\d+\.\d+)\.json$") + +# Keywords whose numeric value narrowing the instance set is breaking. +LOWER_BOUNDS = ("minimum", "exclusiveMinimum", "minItems", "minLength", "minProperties") +UPPER_BOUNDS = ("maximum", "exclusiveMaximum", "maxItems", "maxLength", "maxProperties") + + +class Finding: + __slots__ = ("pointer", "kind", "detail", "breaking") + + def __init__(self, pointer: str, kind: str, detail: str, breaking: bool) -> None: + self.pointer = pointer or "" + self.kind = kind + self.detail = detail + self.breaking = breaking + + def as_dict(self) -> Dict[str, Any]: + return { + "pointer": self.pointer, + "kind": self.kind, + "detail": self.detail, + "breaking": self.breaking, + } + + def __str__(self) -> str: + flag = "BREAKING" if self.breaking else "additive" + return f" [{flag}] {self.pointer}\n {self.kind}: {self.detail}" + + +# -------------------------------------------------------------------------- +# discovery +# -------------------------------------------------------------------------- + + +def published_versions() -> List[str]: + found = [] + for path in SCHEMA_DIR.glob("fluid-schema-*.json"): + m = VERSION_RE.match(path.name) + if m: + found.append(m.group(1)) + return sorted(found, key=lambda v: tuple(int(p) for p in v.split("."))) + + +def adjacent_pairs(versions: Sequence[str]) -> List[Tuple[str, str]]: + return [(versions[i], versions[i + 1]) for i in range(len(versions) - 1)] + + +def load(version: str) -> Dict[str, Any]: + path = SCHEMA_DIR / f"fluid-schema-{version}.json" + if not path.is_file(): + sys.exit(f"check-compat: no such schema: {path}") + return json.loads(path.read_text()) + + +# -------------------------------------------------------------------------- +# static comparison +# -------------------------------------------------------------------------- + + +def _as_set(value: Any) -> set: + return set(value) if isinstance(value, list) else set() + + +def _type_widened(old: Any, new: Any) -> bool: + """True when `new` admits everything `old` did.""" + o = {old} if isinstance(old, str) else set(old or []) + n = {new} if isinstance(new, str) else set(new or []) + return o.issubset(n) + + +def compare(old: Any, new: Any, pointer: str, out: List[Finding]) -> None: + if not isinstance(old, dict) or not isinstance(new, dict): + return + + # -- type ------------------------------------------------------------- + if "type" in old and "type" in new and old["type"] != new["type"]: + breaking = not _type_widened(old["type"], new["type"]) + out.append( + Finding( + pointer, + "type changed", + f"{json.dumps(old['type'])} -> {json.dumps(new['type'])}", + breaking, + ) + ) + elif "type" not in old and "type" in new: + out.append( + Finding(pointer, "type constraint added", json.dumps(new["type"]), True) + ) + + # -- enum ------------------------------------------------------------- + old_enum, new_enum = old.get("enum"), new.get("enum") + if old_enum is not None and new_enum is not None: + removed = _as_set(old_enum) - _as_set(new_enum) + added = _as_set(new_enum) - _as_set(old_enum) + if removed: + out.append( + Finding( + pointer, + "enum values removed", + ", ".join(sorted(map(str, removed))), + True, + ) + ) + if added: + out.append( + Finding( + pointer, + "enum values added", + ", ".join(sorted(map(str, added))), + False, + ) + ) + elif old_enum is None and new_enum is not None: + out.append( + Finding( + pointer, + "enum constraint added", + f"values now restricted to {len(new_enum)} option(s)", + True, + ) + ) + + # -- required --------------------------------------------------------- + old_req, new_req = _as_set(old.get("required")), _as_set(new.get("required")) + newly_required = new_req - old_req + if newly_required: + out.append( + Finding( + pointer, + "required members added", + ", ".join(sorted(newly_required)), + True, + ) + ) + if old_req - new_req: + out.append( + Finding( + pointer, + "required members relaxed", + ", ".join(sorted(old_req - new_req)), + False, + ) + ) + + # -- closed object property sets -------------------------------------- + old_props = old.get("properties") or {} + new_props = new.get("properties") or {} + old_closed = old.get("additionalProperties") is False + new_closed = new.get("additionalProperties") is False + + if not old_closed and new_closed: + out.append( + Finding( + pointer, + "object closed", + "additionalProperties became false; previously-allowed extra " + "members are now rejected", + True, + ) + ) + elif old_closed and not new_closed: + out.append(Finding(pointer, "object opened", "additionalProperties relaxed", False)) + + dropped = set(old_props) - set(new_props) + if dropped and (old_closed or new_closed): + out.append( + Finding( + pointer, + "properties removed from a closed object", + ", ".join(sorted(dropped)), + True, + ) + ) + elif dropped: + out.append( + Finding( + pointer, "properties removed", ", ".join(sorted(dropped)), False + ) + ) + + # -- pattern ---------------------------------------------------------- + old_pat, new_pat = old.get("pattern"), new.get("pattern") + if old_pat != new_pat: + if old_pat is None: + out.append(Finding(pointer, "pattern added", f"{new_pat!r}", True)) + elif new_pat is None: + out.append(Finding(pointer, "pattern removed", f"was {old_pat!r}", False)) + else: + # Regex containment is undecidable in the general case, so a changed + # pattern is reported as breaking and waived if it is in fact a + # widening. Silence here would be the dangerous default. + out.append( + Finding( + pointer, + "pattern changed", + f"{old_pat!r} -> {new_pat!r} (containment not decidable " + "statically; waive if this is a widening)", + True, + ) + ) + + # -- numeric and size bounds ------------------------------------------ + for kw in LOWER_BOUNDS: + if kw in new and (kw not in old or _num(new[kw]) > _num(old[kw])): + out.append( + Finding( + pointer, + f"{kw} tightened", + f"{old.get(kw, 'absent')} -> {new[kw]}", + True, + ) + ) + for kw in UPPER_BOUNDS: + if kw in new and (kw not in old or _num(new[kw]) < _num(old[kw])): + out.append( + Finding( + pointer, + f"{kw} tightened", + f"{old.get(kw, 'absent')} -> {new[kw]}", + True, + ) + ) + if new.get("uniqueItems") and not old.get("uniqueItems"): + out.append(Finding(pointer, "uniqueItems enabled", "duplicates now rejected", True)) + + # -- recurse ---------------------------------------------------------- + for name in sorted(set(old_props) & set(new_props)): + compare(old_props[name], new_props[name], f"{pointer}/properties/{name}", out) + + old_defs = old.get("$defs") or {} + new_defs = new.get("$defs") or {} + for name in sorted(set(old_defs) - set(new_defs)): + out.append(Finding(f"{pointer}/$defs/{name}", "definition removed", name, True)) + for name in sorted(set(old_defs) & set(new_defs)): + compare(old_defs[name], new_defs[name], f"{pointer}/$defs/{name}", out) + + for kw in ("items", "contains", "not", "additionalProperties", "propertyNames"): + if isinstance(old.get(kw), dict) and isinstance(new.get(kw), dict): + compare(old[kw], new[kw], f"{pointer}/{kw}", out) + + for kw in ("anyOf", "oneOf", "allOf"): + o, n = old.get(kw), new.get(kw) + if isinstance(o, list) and isinstance(n, list): + if kw in ("anyOf", "oneOf") and len(n) < len(o): + out.append( + Finding( + f"{pointer}/{kw}", + f"{kw} branches removed", + f"{len(o)} -> {len(n)} branch(es)", + True, + ) + ) + for i in range(min(len(o), len(n))): + compare(o[i], n[i], f"{pointer}/{kw}/{i}", out) + + +def _num(value: Any) -> float: + try: + return float(value) + except (TypeError, ValueError): + return float("nan") + + +# -------------------------------------------------------------------------- +# empirical comparison +# -------------------------------------------------------------------------- + + +def empirical(old_version: str, new_version: str) -> List[Finding]: + """Every case the OLD corpus calls valid must still validate under NEW.""" + corpus_dir = TESTS_DIR / old_version + if not corpus_dir.is_dir(): + return [] + try: + from jsonschema import Draft202012Validator, FormatChecker + except ImportError: + return [ + Finding( + "", + "skipped", + "jsonschema not installed; static pass only", + False, + ) + ] + + validator = Draft202012Validator(load(new_version), format_checker=FormatChecker()) + findings: List[Finding] = [] + for path in sorted(corpus_dir.glob("*.json")): + doc = json.loads(path.read_text()) + for case in doc.get("cases", []): + if not case.get("valid"): + continue + contract = dict(case["contract"]) + # The version member is expected to move with the release; pinning + # it would make every case fail for a reason the promise excludes. + if contract.get("fluidVersion") == old_version: + contract["fluidVersion"] = new_version + errors = list(validator.iter_errors(contract)) + if errors: + findings.append( + Finding( + f"{doc.get('group', path.stem)}/{case['description']}", + "previously-valid contract now rejected", + errors[0].message[:180], + True, + ) + ) + return findings + + +# -------------------------------------------------------------------------- +# waivers +# -------------------------------------------------------------------------- + + +def load_waivers() -> Dict[str, set]: + waived: Dict[str, set] = {} + if not WAIVERS.is_file(): + return waived + lines = [] + for raw in WAIVERS.read_text().splitlines(): + line = raw.split("#", 1)[0].strip() + if not line: + continue + lines.append(line) + pair, _, pointer = line.partition(" ") + waived.setdefault(pair, set()).add(pointer.strip()) + if lines != sorted(lines): + sys.exit( + f"check-compat: {WAIVERS.name} must be sorted; run " + f"`sort -o {WAIVERS} {WAIVERS}`" + ) + return waived + + +# -------------------------------------------------------------------------- +# entry point +# -------------------------------------------------------------------------- + + +def main(argv: Optional[Sequence[str]] = None) -> int: + ap = argparse.ArgumentParser(description=__doc__.split("\n")[0]) + ap.add_argument("--from", dest="old", metavar="X.Y.Z") + ap.add_argument("--to", dest="new", metavar="X.Y.Z") + ap.add_argument("--format", choices=("text", "json"), default="text") + ap.add_argument( + "--show-additive", action="store_true", help="also list additive changes" + ) + args = ap.parse_args(argv) + + if bool(args.old) != bool(args.new): + ap.error("--from and --to must be given together") + + versions = published_versions() + pairs = [(args.old, args.new)] if args.old else adjacent_pairs(versions) + if not pairs: + print("check-compat: fewer than two published schemas; nothing to compare") + return 0 + + waived = load_waivers() + report: Dict[str, Any] = {} + broke = False + + for old_v, new_v in pairs: + findings: List[Finding] = [] + compare(load(old_v), load(new_v), "", findings) + findings += empirical(old_v, new_v) + + key = f"{old_v}->{new_v}" + allow = waived.get(key, set()) + breaking = [f for f in findings if f.breaking and f.pointer not in allow] + waived_here = [f for f in findings if f.breaking and f.pointer in allow] + additive = [f for f in findings if not f.breaking] + if breaking: + broke = True + + report[key] = { + "breaking": [f.as_dict() for f in breaking], + "waived": [f.as_dict() for f in waived_here], + "additive": [f.as_dict() for f in additive], + } + + if args.format == "text": + verdict = "BREAKS THE PROMISE" if breaking else "compatible" + print(f"\n{key}: {verdict}") + print( + f" {len(breaking)} breaking, {len(waived_here)} waived, " + f"{len(additive)} additive" + ) + for f in breaking: + print(f) + if args.show_additive: + for f in additive: + print(f) + + if args.format == "json": + print(json.dumps(report, indent=2)) + elif not broke: + print( + "\ncheck-compat: every release keeps the promise " + "-- no document valid under a version stops validating under its successor." + ) + + return 1 if broke else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/compat-waivers.txt b/scripts/compat-waivers.txt new file mode 100644 index 0000000..a24446b --- /dev/null +++ b/scripts/compat-waivers.txt @@ -0,0 +1,59 @@ +# Backward-compatibility waivers. +# +# One "-> " per line, sorted, '#' for comments. +# A waiver is how a KNOWN narrowing stays visible instead of being hidden by +# weakening the gate. Every entry must say what was checked and why it stands. +# +# Convention borrowed from protocolbuffers/protobuf's conformance failure_list +# (BSD-3-Clause): a strict gate is only adoptable if history it cannot change +# can be recorded rather than silently tolerated. +# +# Pre-0.7.1 pairs are not waived individually. Those schemas predate the +# compatibility promise and, per .github/workflows/schema-sync.yml, already +# diverge from forge-cli at the byte level; CI runs the gate from 0.7.1 only. + +# --------------------------------------------------------------------------- +# 0.7.1 -> 0.7.2 -- REAL BREAK, SHIPPED. Not benign; waived only because it +# is history that cannot be un-shipped. +# +# $defs/notification gained "additionalProperties": false. A contract carrying +# any extra member on a build.execution.notifications[] entry validated under +# 0.7.1 and is rejected by 0.7.2. +# +# Verified with an actual document, plus a negative control that isolates the +# cause (the same document minus the extra member validates under both): +# build.execution.notifications[0].retryOnFailure +# 0.7.1: VALID 0.7.2: REJECTED (additionalProperties) +# +# docs/releases/0.7.2.md says "100% backward compatible with 0.7.1" and +# "No breaking changes". That claim is false and should be corrected to name +# this change. This waiver exists so the gate can run green on history while +# the release note is fixed -- not to make the problem go away. +0.7.1->0.7.2 /$defs/notification + +# --------------------------------------------------------------------------- +# 0.7.2 -> 0.7.3 -- FALSE POSITIVE. A widening, not a narrowing. +# +# $defs/identifier: ^[a-z0-9_]...$ -> ^[A-Za-z0-9_]...$ (uppercase admitted). +# Regex containment is undecidable in general, so the gate reports any changed +# pattern as breaking and asks for a decision rather than guessing. +# +# Verified exhaustively: 11,110 strings up to length 4 over the alphabet +# "abz09_.-AZ" were generated; every string the 0.7.2 pattern accepts, the +# 0.7.3 pattern also accepts. Zero counterexamples. +0.7.2->0.7.3 /$defs/identifier + +# --------------------------------------------------------------------------- +# 0.7.3 -> 0.7.4 -- INTENTIONAL, and outside what the promise covers. +# +# properties/fluidVersion gained an enum (0.7.3 constrained the member by +# pattern alone, so any \d+\.\d+(\.\d+)? was accepted; 0.7.4 restricts it to +# ["0.7.3", "0.7.4"]). This is the version gate doing its job: a document +# declares the schema version it targets, and that member is expected to move +# with the release. The empirical pass in check-compat.py normalises +# fluidVersion across versions for exactly this reason. +# +# Re-examine if the enum ever DROPS a version that a supported release still +# admits -- that would strand real documents rather than just old version +# strings. +0.7.3->0.7.4 /properties/fluidVersion diff --git a/tests/0.7.5/agentpolicy.json b/tests/0.7.5/agentpolicy.json new file mode 100644 index 0000000..e7f0e4c --- /dev/null +++ b/tests/0.7.5/agentpolicy.json @@ -0,0 +1,715 @@ +{ + "corpusVersion": "0.7.5", + "schema": "https://open-data-protocol.github.io/fluid/schema/fluid-schema-0.7.5.json", + "group": "agentpolicy", + "description": "agentPolicy, the AI/LLM usage-governance block reachable at exposes[].policy.agentPolicy: its eleven governance members, their types and bounds, and the closed property set that keeps 'reporting yes, training no' policies from silently drifting.", + "cases": [ + { + "description": "agentPolicy sets a full model, usage, and retention governance profile", + "contract": { + "fluidVersion": "0.7.5", + "kind": "DataProduct", + "id": "customer_profiles", + "name": "Customer Profiles", + "metadata": { "owner": { "team": "customer-analytics" } }, + "exposes": [ + { + "exposeId": "profiles_table", + "kind": "table", + "binding": { + "platform": "gcp", + "format": "bigquery_table", + "location": { "project": "acme-prod", "dataset": "customers", "table": "profiles" } + }, + "contract": { "schema": [{ "name": "customer_id", "type": "string" }] }, + "policy": { + "agentPolicy": { + "allowedModels": ["gpt-4", "claude-3-opus", "claude-3-sonnet"], + "deniedModels": ["gpt-3.5-turbo-finetuned-marketing"], + "maxTokensPerRequest": 8192, + "maxTokensPerDay": 500000, + "allowedUseCases": ["inference", "reasoning", "summarization"], + "deniedUseCases": ["training", "fine_tuning"], + "canReason": true, + "canStore": false, + "retentionPolicy": { "maxRetentionDays": 30, "requireDeletion": true }, + "auditRequired": true, + "purposeLimitation": "Customer service chatbot only, not for marketing or profiling." + } + } + } + ] + }, + "valid": true + }, + { + "description": "agentPolicy present as an empty object with every member left to its default", + "contract": { + "fluidVersion": "0.7.5", + "kind": "DataProduct", + "id": "customer_profiles", + "name": "Customer Profiles", + "metadata": { "owner": { "team": "customer-analytics" } }, + "exposes": [ + { + "exposeId": "profiles_table", + "kind": "table", + "binding": { + "platform": "gcp", + "format": "bigquery_table", + "location": { "project": "acme-prod", "dataset": "customers", "table": "profiles" } + }, + "contract": { "schema": [{ "name": "customer_id", "type": "string" }] }, + "policy": { "agentPolicy": {} } + } + ] + }, + "valid": true + }, + { + "description": "allowedModels is an empty array, denying all AI access", + "contract": { + "fluidVersion": "0.7.5", + "kind": "DataProduct", + "id": "customer_profiles", + "name": "Customer Profiles", + "metadata": { "owner": { "team": "customer-analytics" } }, + "exposes": [ + { + "exposeId": "profiles_table", + "kind": "table", + "binding": { + "platform": "gcp", + "format": "bigquery_table", + "location": { "project": "acme-prod", "dataset": "customers", "table": "profiles" } + }, + "contract": { "schema": [{ "name": "customer_id", "type": "string" }] }, + "policy": { "agentPolicy": { "allowedModels": [] } } + } + ] + }, + "valid": true + }, + { + "description": "maxTokensPerRequest at its minimum boundary of one", + "contract": { + "fluidVersion": "0.7.5", + "kind": "DataProduct", + "id": "customer_profiles", + "name": "Customer Profiles", + "metadata": { "owner": { "team": "customer-analytics" } }, + "exposes": [ + { + "exposeId": "profiles_table", + "kind": "table", + "binding": { + "platform": "gcp", + "format": "bigquery_table", + "location": { "project": "acme-prod", "dataset": "customers", "table": "profiles" } + }, + "contract": { "schema": [{ "name": "customer_id", "type": "string" }] }, + "policy": { "agentPolicy": { "maxTokensPerRequest": 1 } } + } + ] + }, + "valid": true + }, + { + "description": "retentionPolicy.maxRetentionDays set to zero, permitting no retention", + "contract": { + "fluidVersion": "0.7.5", + "kind": "DataProduct", + "id": "customer_profiles", + "name": "Customer Profiles", + "metadata": { "owner": { "team": "customer-analytics" } }, + "exposes": [ + { + "exposeId": "profiles_table", + "kind": "table", + "binding": { + "platform": "gcp", + "format": "bigquery_table", + "location": { "project": "acme-prod", "dataset": "customers", "table": "profiles" } + }, + "contract": { "schema": [{ "name": "customer_id", "type": "string" }] }, + "policy": { "agentPolicy": { "retentionPolicy": { "maxRetentionDays": 0 } } } + } + ] + }, + "valid": true + }, + { + "description": "deniedModels carries an identifier with uppercase letters and spaces", + "contract": { + "fluidVersion": "0.7.5", + "kind": "DataProduct", + "id": "customer_profiles", + "name": "Customer Profiles", + "metadata": { "owner": { "team": "customer-analytics" } }, + "exposes": [ + { + "exposeId": "profiles_table", + "kind": "table", + "binding": { + "platform": "gcp", + "format": "bigquery_table", + "location": { "project": "acme-prod", "dataset": "customers", "table": "profiles" } + }, + "contract": { "schema": [{ "name": "customer_id", "type": "string" }] }, + "policy": { "agentPolicy": { "deniedModels": ["GPT-4 Legacy (deprecated)"] } } + } + ] + }, + "valid": true + }, + { + "description": "allowedModels carries a single-character model identifier", + "contract": { + "fluidVersion": "0.7.5", + "kind": "DataProduct", + "id": "customer_profiles", + "name": "Customer Profiles", + "metadata": { "owner": { "team": "customer-analytics" } }, + "exposes": [ + { + "exposeId": "profiles_table", + "kind": "table", + "binding": { + "platform": "gcp", + "format": "bigquery_table", + "location": { "project": "acme-prod", "dataset": "customers", "table": "profiles" } + }, + "contract": { "schema": [{ "name": "customer_id", "type": "string" }] }, + "policy": { "agentPolicy": { "allowedModels": ["x"] } } + } + ] + }, + "valid": true + }, + { + "description": "agentPolicy carries the shared tags and labels members alongside its governance fields", + "contract": { + "fluidVersion": "0.7.5", + "kind": "DataProduct", + "id": "customer_profiles", + "name": "Customer Profiles", + "metadata": { "owner": { "team": "customer-analytics" } }, + "exposes": [ + { + "exposeId": "profiles_table", + "kind": "table", + "binding": { + "platform": "gcp", + "format": "bigquery_table", + "location": { "project": "acme-prod", "dataset": "customers", "table": "profiles" } + }, + "contract": { "schema": [{ "name": "customer_id", "type": "string" }] }, + "policy": { + "agentPolicy": { + "tags": ["pii", "real-time"], + "labels": { "team": "customer-analytics" } + } + } + } + ] + }, + "valid": true + }, + { + "description": "maxTokensPerRequest of zero", + "contract": { + "fluidVersion": "0.7.5", + "kind": "DataProduct", + "id": "customer_profiles", + "name": "Customer Profiles", + "metadata": { "owner": { "team": "customer-analytics" } }, + "exposes": [ + { + "exposeId": "profiles_table", + "kind": "table", + "binding": { + "platform": "gcp", + "format": "bigquery_table", + "location": { "project": "acme-prod", "dataset": "customers", "table": "profiles" } + }, + "contract": { "schema": [{ "name": "customer_id", "type": "string" }] }, + "policy": { "agentPolicy": { "maxTokensPerRequest": 0 } } + } + ] + }, + "valid": false, + "errors": [ + { "pointer": "/exposes/0/policy/agentPolicy/maxTokensPerRequest", "keyword": "minimum" } + ] + }, + { + "description": "maxTokensPerDay is a negative number", + "contract": { + "fluidVersion": "0.7.5", + "kind": "DataProduct", + "id": "customer_profiles", + "name": "Customer Profiles", + "metadata": { "owner": { "team": "customer-analytics" } }, + "exposes": [ + { + "exposeId": "profiles_table", + "kind": "table", + "binding": { + "platform": "gcp", + "format": "bigquery_table", + "location": { "project": "acme-prod", "dataset": "customers", "table": "profiles" } + }, + "contract": { "schema": [{ "name": "customer_id", "type": "string" }] }, + "policy": { "agentPolicy": { "maxTokensPerDay": -1000 } } + } + ] + }, + "valid": false, + "errors": [ + { "pointer": "/exposes/0/policy/agentPolicy/maxTokensPerDay", "keyword": "minimum" } + ] + }, + { + "description": "maxTokensPerRequest given as a boolean", + "contract": { + "fluidVersion": "0.7.5", + "kind": "DataProduct", + "id": "customer_profiles", + "name": "Customer Profiles", + "metadata": { "owner": { "team": "customer-analytics" } }, + "exposes": [ + { + "exposeId": "profiles_table", + "kind": "table", + "binding": { + "platform": "gcp", + "format": "bigquery_table", + "location": { "project": "acme-prod", "dataset": "customers", "table": "profiles" } + }, + "contract": { "schema": [{ "name": "customer_id", "type": "string" }] }, + "policy": { "agentPolicy": { "maxTokensPerRequest": true } } + } + ] + }, + "valid": false, + "errors": [ + { "pointer": "/exposes/0/policy/agentPolicy/maxTokensPerRequest", "keyword": "type" } + ] + }, + { + "description": "allowedModels given as a bare string instead of an array", + "contract": { + "fluidVersion": "0.7.5", + "kind": "DataProduct", + "id": "customer_profiles", + "name": "Customer Profiles", + "metadata": { "owner": { "team": "customer-analytics" } }, + "exposes": [ + { + "exposeId": "profiles_table", + "kind": "table", + "binding": { + "platform": "gcp", + "format": "bigquery_table", + "location": { "project": "acme-prod", "dataset": "customers", "table": "profiles" } + }, + "contract": { "schema": [{ "name": "customer_id", "type": "string" }] }, + "policy": { "agentPolicy": { "allowedModels": "gpt-4" } } + } + ] + }, + "valid": false, + "errors": [ + { "pointer": "/exposes/0/policy/agentPolicy/allowedModels", "keyword": "type" } + ] + }, + { + "description": "allowedUseCases contains a use case outside the enum", + "contract": { + "fluidVersion": "0.7.5", + "kind": "DataProduct", + "id": "customer_profiles", + "name": "Customer Profiles", + "metadata": { "owner": { "team": "customer-analytics" } }, + "exposes": [ + { + "exposeId": "profiles_table", + "kind": "table", + "binding": { + "platform": "gcp", + "format": "bigquery_table", + "location": { "project": "acme-prod", "dataset": "customers", "table": "profiles" } + }, + "contract": { "schema": [{ "name": "customer_id", "type": "string" }] }, + "policy": { "agentPolicy": { "allowedUseCases": ["marketing"] } } + } + ] + }, + "valid": false, + "errors": [ + { "pointer": "/exposes/0/policy/agentPolicy/allowedUseCases/0", "keyword": "enum" } + ] + }, + { + "description": "deniedUseCases contains a use case outside the enum", + "contract": { + "fluidVersion": "0.7.5", + "kind": "DataProduct", + "id": "customer_profiles", + "name": "Customer Profiles", + "metadata": { "owner": { "team": "customer-analytics" } }, + "exposes": [ + { + "exposeId": "profiles_table", + "kind": "table", + "binding": { + "platform": "gcp", + "format": "bigquery_table", + "location": { "project": "acme-prod", "dataset": "customers", "table": "profiles" } + }, + "contract": { "schema": [{ "name": "customer_id", "type": "string" }] }, + "policy": { "agentPolicy": { "deniedUseCases": ["deletion"] } } + } + ] + }, + "valid": false, + "errors": [ + { "pointer": "/exposes/0/policy/agentPolicy/deniedUseCases/0", "keyword": "enum" } + ] + }, + { + "description": "allowedModels contains an uppercase model identifier", + "contract": { + "fluidVersion": "0.7.5", + "kind": "DataProduct", + "id": "customer_profiles", + "name": "Customer Profiles", + "metadata": { "owner": { "team": "customer-analytics" } }, + "exposes": [ + { + "exposeId": "profiles_table", + "kind": "table", + "binding": { + "platform": "gcp", + "format": "bigquery_table", + "location": { "project": "acme-prod", "dataset": "customers", "table": "profiles" } + }, + "contract": { "schema": [{ "name": "customer_id", "type": "string" }] }, + "policy": { "agentPolicy": { "allowedModels": ["GPT-4"] } } + } + ] + }, + "valid": false, + "errors": [ + { "pointer": "/exposes/0/policy/agentPolicy/allowedModels/0", "keyword": "pattern" } + ] + }, + { + "description": "allowedModels contains a duplicate model identifier", + "contract": { + "fluidVersion": "0.7.5", + "kind": "DataProduct", + "id": "customer_profiles", + "name": "Customer Profiles", + "metadata": { "owner": { "team": "customer-analytics" } }, + "exposes": [ + { + "exposeId": "profiles_table", + "kind": "table", + "binding": { + "platform": "gcp", + "format": "bigquery_table", + "location": { "project": "acme-prod", "dataset": "customers", "table": "profiles" } + }, + "contract": { "schema": [{ "name": "customer_id", "type": "string" }] }, + "policy": { "agentPolicy": { "allowedModels": ["gpt-4", "gpt-4"] } } + } + ] + }, + "valid": false, + "errors": [ + { "pointer": "/exposes/0/policy/agentPolicy/allowedModels", "keyword": "uniqueItems" } + ] + }, + { + "description": "retentionPolicy.maxRetentionDays is negative", + "contract": { + "fluidVersion": "0.7.5", + "kind": "DataProduct", + "id": "customer_profiles", + "name": "Customer Profiles", + "metadata": { "owner": { "team": "customer-analytics" } }, + "exposes": [ + { + "exposeId": "profiles_table", + "kind": "table", + "binding": { + "platform": "gcp", + "format": "bigquery_table", + "location": { "project": "acme-prod", "dataset": "customers", "table": "profiles" } + }, + "contract": { "schema": [{ "name": "customer_id", "type": "string" }] }, + "policy": { "agentPolicy": { "retentionPolicy": { "maxRetentionDays": -1 } } } + } + ] + }, + "valid": false, + "errors": [ + { + "pointer": "/exposes/0/policy/agentPolicy/retentionPolicy/maxRetentionDays", + "keyword": "minimum" + } + ] + }, + { + "description": "agentPolicy carries a property outside its closed set", + "contract": { + "fluidVersion": "0.7.5", + "kind": "DataProduct", + "id": "customer_profiles", + "name": "Customer Profiles", + "metadata": { "owner": { "team": "customer-analytics" } }, + "exposes": [ + { + "exposeId": "profiles_table", + "kind": "table", + "binding": { + "platform": "gcp", + "format": "bigquery_table", + "location": { "project": "acme-prod", "dataset": "customers", "table": "profiles" } + }, + "contract": { "schema": [{ "name": "customer_id", "type": "string" }] }, + "policy": { "agentPolicy": { "requiresHumanReview": true } } + } + ] + }, + "valid": false, + "errors": [ + { + "pointer": "/exposes/0/policy/agentPolicy", + "keyword": "additionalProperties", + "detail": "requiresHumanReview" + } + ] + }, + { + "description": "auditRequired given as an integer instead of a boolean", + "contract": { + "fluidVersion": "0.7.5", + "kind": "DataProduct", + "id": "customer_profiles", + "name": "Customer Profiles", + "metadata": { "owner": { "team": "customer-analytics" } }, + "exposes": [ + { + "exposeId": "profiles_table", + "kind": "table", + "binding": { + "platform": "gcp", + "format": "bigquery_table", + "location": { "project": "acme-prod", "dataset": "customers", "table": "profiles" } + }, + "contract": { "schema": [{ "name": "customer_id", "type": "string" }] }, + "policy": { "agentPolicy": { "auditRequired": 1 } } + } + ] + }, + "valid": false, + "errors": [ + { "pointer": "/exposes/0/policy/agentPolicy/auditRequired", "keyword": "type" } + ] + }, + { + "description": "deniedModels given as a bare string instead of an array", + "contract": { + "fluidVersion": "0.7.5", + "kind": "DataProduct", + "id": "customer_profiles", + "name": "Customer Profiles", + "metadata": { "owner": { "team": "customer-analytics" } }, + "exposes": [ + { + "exposeId": "profiles_table", + "kind": "table", + "binding": { + "platform": "gcp", + "format": "bigquery_table", + "location": { "project": "acme-prod", "dataset": "customers", "table": "profiles" } + }, + "contract": { "schema": [{ "name": "customer_id", "type": "string" }] }, + "policy": { "agentPolicy": { "deniedModels": "gpt-3.5-turbo" } } + } + ] + }, + "valid": false, + "errors": [ + { "pointer": "/exposes/0/policy/agentPolicy/deniedModels", "keyword": "type" } + ] + }, + { + "description": "allowedModels holds a non-string element", + "contract": { + "fluidVersion": "0.7.5", + "kind": "DataProduct", + "id": "customer_profiles", + "name": "Customer Profiles", + "metadata": { "owner": { "team": "customer-analytics" } }, + "exposes": [ + { + "exposeId": "profiles_table", + "kind": "table", + "binding": { + "platform": "gcp", + "format": "bigquery_table", + "location": { "project": "acme-prod", "dataset": "customers", "table": "profiles" } + }, + "contract": { "schema": [{ "name": "customer_id", "type": "string" }] }, + "policy": { "agentPolicy": { "allowedModels": [42] } } + } + ] + }, + "valid": false, + "errors": [ + { "pointer": "/exposes/0/policy/agentPolicy/allowedModels/0", "keyword": "type" } + ] + }, + { + "description": "deniedModels holds a non-string element", + "contract": { + "fluidVersion": "0.7.5", + "kind": "DataProduct", + "id": "customer_profiles", + "name": "Customer Profiles", + "metadata": { "owner": { "team": "customer-analytics" } }, + "exposes": [ + { + "exposeId": "profiles_table", + "kind": "table", + "binding": { + "platform": "gcp", + "format": "bigquery_table", + "location": { "project": "acme-prod", "dataset": "customers", "table": "profiles" } + }, + "contract": { "schema": [{ "name": "customer_id", "type": "string" }] }, + "policy": { "agentPolicy": { "deniedModels": [42] } } + } + ] + }, + "valid": false, + "errors": [ + { "pointer": "/exposes/0/policy/agentPolicy/deniedModels/0", "keyword": "type" } + ] + }, + { + "description": "maxTokensPerDay given as a boolean", + "contract": { + "fluidVersion": "0.7.5", + "kind": "DataProduct", + "id": "customer_profiles", + "name": "Customer Profiles", + "metadata": { "owner": { "team": "customer-analytics" } }, + "exposes": [ + { + "exposeId": "profiles_table", + "kind": "table", + "binding": { + "platform": "gcp", + "format": "bigquery_table", + "location": { "project": "acme-prod", "dataset": "customers", "table": "profiles" } + }, + "contract": { "schema": [{ "name": "customer_id", "type": "string" }] }, + "policy": { "agentPolicy": { "maxTokensPerDay": true } } + } + ] + }, + "valid": false, + "errors": [ + { "pointer": "/exposes/0/policy/agentPolicy/maxTokensPerDay", "keyword": "type" } + ] + }, + { + "description": "canReason given as a string instead of a boolean", + "contract": { + "fluidVersion": "0.7.5", + "kind": "DataProduct", + "id": "customer_profiles", + "name": "Customer Profiles", + "metadata": { "owner": { "team": "customer-analytics" } }, + "exposes": [ + { + "exposeId": "profiles_table", + "kind": "table", + "binding": { + "platform": "gcp", + "format": "bigquery_table", + "location": { "project": "acme-prod", "dataset": "customers", "table": "profiles" } + }, + "contract": { "schema": [{ "name": "customer_id", "type": "string" }] }, + "policy": { "agentPolicy": { "canReason": "yes" } } + } + ] + }, + "valid": false, + "errors": [ + { "pointer": "/exposes/0/policy/agentPolicy/canReason", "keyword": "type" } + ] + }, + { + "description": "canStore given as a string instead of a boolean", + "contract": { + "fluidVersion": "0.7.5", + "kind": "DataProduct", + "id": "customer_profiles", + "name": "Customer Profiles", + "metadata": { "owner": { "team": "customer-analytics" } }, + "exposes": [ + { + "exposeId": "profiles_table", + "kind": "table", + "binding": { + "platform": "gcp", + "format": "bigquery_table", + "location": { "project": "acme-prod", "dataset": "customers", "table": "profiles" } + }, + "contract": { "schema": [{ "name": "customer_id", "type": "string" }] }, + "policy": { "agentPolicy": { "canStore": "no" } } + } + ] + }, + "valid": false, + "errors": [ + { "pointer": "/exposes/0/policy/agentPolicy/canStore", "keyword": "type" } + ] + }, + { + "description": "retentionPolicy.requireDeletion given as a string instead of a boolean", + "contract": { + "fluidVersion": "0.7.5", + "kind": "DataProduct", + "id": "customer_profiles", + "name": "Customer Profiles", + "metadata": { "owner": { "team": "customer-analytics" } }, + "exposes": [ + { + "exposeId": "profiles_table", + "kind": "table", + "binding": { + "platform": "gcp", + "format": "bigquery_table", + "location": { "project": "acme-prod", "dataset": "customers", "table": "profiles" } + }, + "contract": { "schema": [{ "name": "customer_id", "type": "string" }] }, + "policy": { "agentPolicy": { "retentionPolicy": { "requireDeletion": "yes" } } } + } + ] + }, + "valid": false, + "errors": [ + { + "pointer": "/exposes/0/policy/agentPolicy/retentionPolicy/requireDeletion", + "keyword": "type" + } + ] + } + ] +} diff --git a/tests/0.7.5/binding.json b/tests/0.7.5/binding.json new file mode 100644 index 0000000..9d2e63b --- /dev/null +++ b/tests/0.7.5/binding.json @@ -0,0 +1,968 @@ +{ + "corpusVersion": "0.7.5", + "schema": "https://open-data-protocol.github.io/fluid/schema/fluid-schema-0.7.5.json", + "group": "binding", + "description": "The binding object embedded in every expose: its required platform/format/location members, the platform and format enums, the closed bindingLocation property set (including the v0.7.5 Confluent Tableflow fields), and icebergConfig's own structure -- writeVersion/fileFormat enums, its closed property set, and the partitionSpec/sortOrder arrays' per-entry required fields and enums. Also documents that the schema does not cross-validate platform against format.", + "cases": [ + { + "description": "binding carrying only the three required members", + "contract": { + "fluidVersion": "0.7.5", + "kind": "DataProduct", + "id": "customer_profiles", + "name": "Customer Profiles", + "metadata": { + "owner": { + "team": "customer-analytics" + } + }, + "exposes": [ + { + "exposeId": "profiles_table", + "kind": "table", + "binding": { + "platform": "kafka", + "format": "kafka_topic", + "location": { + "topic": "customer-events" + } + }, + "contract": { + "schema": [ + { + "name": "customer_id", + "type": "string" + } + ] + } + } + ] + }, + "valid": true + }, + { + "description": "bindingLocation combines every non-Confluent addressing property in one object", + "contract": { + "fluidVersion": "0.7.5", + "kind": "DataProduct", + "id": "customer_profiles", + "name": "Customer Profiles", + "metadata": { + "owner": { + "team": "customer-analytics" + } + }, + "exposes": [ + { + "exposeId": "profiles_table", + "kind": "table", + "binding": { + "platform": "gcp", + "format": "other", + "location": { + "account": "123456789012", + "project": "acme-prod", + "dataset": "customers", + "database": "analytics", + "schema": "public", + "table": "profiles", + "bucket": "acme-data-lake", + "path": "raw/customers/profiles/", + "gateway": "internal-gw.acme.com", + "baseUrl": "https://api.acme.com/v1/customers", + "topic": "customer-profile-updates", + "subscription": "profile-updates-sub", + "region": "us-central1", + "zone": "us-central1-a" + } + }, + "contract": { + "schema": [ + { + "name": "customer_id", + "type": "string" + } + ] + } + } + ] + }, + "valid": true + }, + { + "description": "Confluent Tableflow location fields accompany a confluent platform binding", + "contract": { + "fluidVersion": "0.7.5", + "kind": "DataProduct", + "id": "customer_profiles", + "name": "Customer Profiles", + "metadata": { + "owner": { + "team": "customer-analytics" + } + }, + "exposes": [ + { + "exposeId": "profiles_table", + "kind": "table", + "binding": { + "platform": "confluent", + "format": "iceberg", + "location": { + "topic": "orders", + "environment_id": "env-abc123", + "kafka_cluster_id": "lkc-xyz789", + "confluent_role_arn": "arn:aws:iam::123456789012:role/confluent-tableflow-role" + } + }, + "contract": { + "schema": [ + { + "name": "customer_id", + "type": "string" + } + ] + } + } + ] + }, + "valid": true + }, + { + "description": "platform and format name binding to unrelated cloud ecosystems", + "contract": { + "fluidVersion": "0.7.5", + "kind": "DataProduct", + "id": "customer_profiles", + "name": "Customer Profiles", + "metadata": { + "owner": { + "team": "customer-analytics" + } + }, + "exposes": [ + { + "exposeId": "profiles_table", + "kind": "table", + "binding": { + "platform": "azure", + "format": "bigquery_table", + "location": { + "dataset": "customers", + "table": "profiles" + } + }, + "contract": { + "schema": [ + { + "name": "customer_id", + "type": "string" + } + ] + } + } + ] + }, + "valid": true + }, + { + "description": "icebergConfig specifies partitioning, sort order, and table properties in full", + "contract": { + "fluidVersion": "0.7.5", + "kind": "DataProduct", + "id": "customer_profiles", + "name": "Customer Profiles", + "metadata": { + "owner": { + "team": "customer-analytics" + } + }, + "exposes": [ + { + "exposeId": "profiles_table", + "kind": "table", + "binding": { + "platform": "aws", + "format": "iceberg", + "location": { + "bucket": "acme-lake", + "path": "warehouse/customers/profiles" + }, + "icebergConfig": { + "writeVersion": 2, + "fileFormat": "parquet", + "partitionSpec": [ + { + "name": "created_year", + "sourceColumn": "created_at", + "transform": "year" + }, + { + "name": "customer_bucket", + "sourceColumn": "customer_id", + "transform": "bucket", + "numBuckets": 16 + } + ], + "sortOrder": [ + { + "column": "created_at", + "direction": "desc", + "nullOrder": "nulls-last" + } + ], + "properties": { + "write.format.default": "parquet", + "history.expire.max-snapshot-age-ms": "604800000" + } + } + }, + "contract": { + "schema": [ + { + "name": "customer_id", + "type": "string" + } + ] + } + } + ] + }, + "valid": true + }, + { + "description": "icebergConfig at writeVersion 1 carries only a file format", + "contract": { + "fluidVersion": "0.7.5", + "kind": "DataProduct", + "id": "customer_profiles", + "name": "Customer Profiles", + "metadata": { + "owner": { + "team": "customer-analytics" + } + }, + "exposes": [ + { + "exposeId": "profiles_table", + "kind": "table", + "binding": { + "platform": "databricks", + "format": "iceberg", + "location": { + "bucket": "acme-lake", + "path": "warehouse/legacy/" + }, + "icebergConfig": { + "writeVersion": 1, + "fileFormat": "avro" + } + }, + "contract": { + "schema": [ + { + "name": "customer_id", + "type": "string" + } + ] + } + } + ] + }, + "valid": true + }, + { + "description": "platform is absent from the binding", + "contract": { + "fluidVersion": "0.7.5", + "kind": "DataProduct", + "id": "customer_profiles", + "name": "Customer Profiles", + "metadata": { + "owner": { + "team": "customer-analytics" + } + }, + "exposes": [ + { + "exposeId": "profiles_table", + "kind": "table", + "binding": { + "format": "kafka_topic", + "location": { + "topic": "customer-events" + } + }, + "contract": { + "schema": [ + { + "name": "customer_id", + "type": "string" + } + ] + } + } + ] + }, + "valid": false, + "errors": [ + { + "pointer": "/exposes/0/binding", + "keyword": "required", + "detail": "platform" + } + ] + }, + { + "description": "format is absent from the binding", + "contract": { + "fluidVersion": "0.7.5", + "kind": "DataProduct", + "id": "customer_profiles", + "name": "Customer Profiles", + "metadata": { + "owner": { + "team": "customer-analytics" + } + }, + "exposes": [ + { + "exposeId": "profiles_table", + "kind": "table", + "binding": { + "platform": "kafka", + "location": { + "topic": "customer-events" + } + }, + "contract": { + "schema": [ + { + "name": "customer_id", + "type": "string" + } + ] + } + } + ] + }, + "valid": false, + "errors": [ + { + "pointer": "/exposes/0/binding", + "keyword": "required", + "detail": "format" + } + ] + }, + { + "description": "location is absent from the binding", + "contract": { + "fluidVersion": "0.7.5", + "kind": "DataProduct", + "id": "customer_profiles", + "name": "Customer Profiles", + "metadata": { + "owner": { + "team": "customer-analytics" + } + }, + "exposes": [ + { + "exposeId": "profiles_table", + "kind": "table", + "binding": { + "platform": "kafka", + "format": "kafka_topic" + }, + "contract": { + "schema": [ + { + "name": "customer_id", + "type": "string" + } + ] + } + } + ] + }, + "valid": false, + "errors": [ + { + "pointer": "/exposes/0/binding", + "keyword": "required", + "detail": "location" + } + ] + }, + { + "description": "platform names a cloud provider outside the enum", + "contract": { + "fluidVersion": "0.7.5", + "kind": "DataProduct", + "id": "customer_profiles", + "name": "Customer Profiles", + "metadata": { + "owner": { + "team": "customer-analytics" + } + }, + "exposes": [ + { + "exposeId": "profiles_table", + "kind": "table", + "binding": { + "platform": "digitalocean", + "format": "kafka_topic", + "location": { + "topic": "customer-events" + } + }, + "contract": { + "schema": [ + { + "name": "customer_id", + "type": "string" + } + ] + } + } + ] + }, + "valid": false, + "errors": [ + { + "pointer": "/exposes/0/binding/platform", + "keyword": "enum" + } + ] + }, + { + "description": "format names a file type outside the enum", + "contract": { + "fluidVersion": "0.7.5", + "kind": "DataProduct", + "id": "customer_profiles", + "name": "Customer Profiles", + "metadata": { + "owner": { + "team": "customer-analytics" + } + }, + "exposes": [ + { + "exposeId": "profiles_table", + "kind": "table", + "binding": { + "platform": "kafka", + "format": "excel_sheet", + "location": { + "topic": "customer-events" + } + }, + "contract": { + "schema": [ + { + "name": "customer_id", + "type": "string" + } + ] + } + } + ] + }, + "valid": false, + "errors": [ + { + "pointer": "/exposes/0/binding/format", + "keyword": "enum" + } + ] + }, + { + "description": "bindingLocation carries an undocumented addressing property", + "contract": { + "fluidVersion": "0.7.5", + "kind": "DataProduct", + "id": "customer_profiles", + "name": "Customer Profiles", + "metadata": { + "owner": { + "team": "customer-analytics" + } + }, + "exposes": [ + { + "exposeId": "profiles_table", + "kind": "table", + "binding": { + "platform": "kafka", + "format": "kafka_topic", + "location": { + "topic": "customer-events", + "host": "kafka.internal:9092" + } + }, + "contract": { + "schema": [ + { + "name": "customer_id", + "type": "string" + } + ] + } + } + ] + }, + "valid": false, + "errors": [ + { + "pointer": "/exposes/0/binding/location", + "keyword": "additionalProperties", + "detail": "host" + } + ] + }, + { + "description": "binding carries a property outside its closed set", + "contract": { + "fluidVersion": "0.7.5", + "kind": "DataProduct", + "id": "customer_profiles", + "name": "Customer Profiles", + "metadata": { + "owner": { + "team": "customer-analytics" + } + }, + "exposes": [ + { + "exposeId": "profiles_table", + "kind": "table", + "binding": { + "platform": "kafka", + "format": "kafka_topic", + "location": { + "topic": "customer-events" + }, + "connectionString": "kafka://broker1:9092,broker2:9092" + }, + "contract": { + "schema": [ + { + "name": "customer_id", + "type": "string" + } + ] + } + } + ] + }, + "valid": false, + "errors": [ + { + "pointer": "/exposes/0/binding", + "keyword": "additionalProperties", + "detail": "connectionString" + } + ] + }, + { + "description": "icebergConfig.writeVersion is outside the {1,2} enum", + "contract": { + "fluidVersion": "0.7.5", + "kind": "DataProduct", + "id": "customer_profiles", + "name": "Customer Profiles", + "metadata": { + "owner": { + "team": "customer-analytics" + } + }, + "exposes": [ + { + "exposeId": "profiles_table", + "kind": "table", + "binding": { + "platform": "databricks", + "format": "iceberg", + "location": { + "bucket": "acme-lake", + "path": "warehouse/legacy/" + }, + "icebergConfig": { + "writeVersion": 3, + "fileFormat": "avro" + } + }, + "contract": { + "schema": [ + { + "name": "customer_id", + "type": "string" + } + ] + } + } + ] + }, + "valid": false, + "errors": [ + { + "pointer": "/exposes/0/binding/icebergConfig/writeVersion", + "keyword": "enum" + } + ] + }, + { + "description": "icebergConfig.fileFormat names a format outside its enum", + "contract": { + "fluidVersion": "0.7.5", + "kind": "DataProduct", + "id": "customer_profiles", + "name": "Customer Profiles", + "metadata": { + "owner": { + "team": "customer-analytics" + } + }, + "exposes": [ + { + "exposeId": "profiles_table", + "kind": "table", + "binding": { + "platform": "databricks", + "format": "iceberg", + "location": { + "bucket": "acme-lake", + "path": "warehouse/legacy/" + }, + "icebergConfig": { + "writeVersion": 1, + "fileFormat": "csv" + } + }, + "contract": { + "schema": [ + { + "name": "customer_id", + "type": "string" + } + ] + } + } + ] + }, + "valid": false, + "errors": [ + { + "pointer": "/exposes/0/binding/icebergConfig/fileFormat", + "keyword": "enum" + } + ] + }, + { + "description": "icebergConfig carries a property outside its closed set", + "contract": { + "fluidVersion": "0.7.5", + "kind": "DataProduct", + "id": "customer_profiles", + "name": "Customer Profiles", + "metadata": { + "owner": { + "team": "customer-analytics" + } + }, + "exposes": [ + { + "exposeId": "profiles_table", + "kind": "table", + "binding": { + "platform": "databricks", + "format": "iceberg", + "location": { + "bucket": "acme-lake", + "path": "warehouse/legacy/" + }, + "icebergConfig": { + "writeVersion": 1, + "fileFormat": "parquet", + "compressionCodec": "zstd" + } + }, + "contract": { + "schema": [ + { + "name": "customer_id", + "type": "string" + } + ] + } + } + ] + }, + "valid": false, + "errors": [ + { + "pointer": "/exposes/0/binding/icebergConfig", + "keyword": "additionalProperties", + "detail": "compressionCodec" + } + ] + }, + { + "description": "a partitionSpec entry omits the required transform", + "contract": { + "fluidVersion": "0.7.5", + "kind": "DataProduct", + "id": "customer_profiles", + "name": "Customer Profiles", + "metadata": { + "owner": { + "team": "customer-analytics" + } + }, + "exposes": [ + { + "exposeId": "profiles_table", + "kind": "table", + "binding": { + "platform": "aws", + "format": "iceberg", + "location": { + "bucket": "acme-lake", + "path": "warehouse/customers/profiles" + }, + "icebergConfig": { + "writeVersion": 2, + "fileFormat": "parquet", + "partitionSpec": [ + { + "sourceColumn": "created_at" + } + ] + } + }, + "contract": { + "schema": [ + { + "name": "customer_id", + "type": "string" + } + ] + } + } + ] + }, + "valid": false, + "errors": [ + { + "pointer": "/exposes/0/binding/icebergConfig/partitionSpec/0", + "keyword": "required", + "detail": "transform" + } + ] + }, + { + "description": "bindingLocation.baseUrl is a number instead of a string", + "contract": { + "fluidVersion": "0.7.5", + "kind": "DataProduct", + "id": "customer_profiles", + "name": "Customer Profiles", + "metadata": { + "owner": { + "team": "customer-analytics" + } + }, + "exposes": [ + { + "exposeId": "profiles_table", + "kind": "table", + "binding": { + "platform": "gcp", + "format": "http_api", + "location": { + "baseUrl": 42 + } + }, + "contract": { + "schema": [ + { + "name": "customer_id", + "type": "string" + } + ] + } + } + ] + }, + "valid": false, + "errors": [ + { + "pointer": "/exposes/0/binding/location/baseUrl", + "keyword": "type" + } + ] + }, + { + "description": "a partitionSpec entry omits the required sourceColumn", + "contract": { + "fluidVersion": "0.7.5", + "kind": "DataProduct", + "id": "customer_profiles", + "name": "Customer Profiles", + "metadata": { + "owner": { + "team": "customer-analytics" + } + }, + "exposes": [ + { + "exposeId": "profiles_table", + "kind": "table", + "binding": { + "platform": "aws", + "format": "iceberg", + "location": { + "bucket": "acme-lake", + "path": "warehouse/customers/profiles" + }, + "icebergConfig": { + "writeVersion": 2, + "fileFormat": "parquet", + "partitionSpec": [ + { + "transform": "identity" + } + ] + } + }, + "contract": { + "schema": [ + { + "name": "customer_id", + "type": "string" + } + ] + } + } + ] + }, + "valid": false, + "errors": [ + { + "pointer": "/exposes/0/binding/icebergConfig/partitionSpec/0", + "keyword": "required", + "detail": "sourceColumn" + } + ] + }, + { + "description": "a partitionSpec entry names a transform outside its enum", + "contract": { + "fluidVersion": "0.7.5", + "kind": "DataProduct", + "id": "customer_profiles", + "name": "Customer Profiles", + "metadata": { + "owner": { + "team": "customer-analytics" + } + }, + "exposes": [ + { + "exposeId": "profiles_table", + "kind": "table", + "binding": { + "platform": "aws", + "format": "iceberg", + "location": { + "bucket": "acme-lake", + "path": "warehouse/customers/profiles" + }, + "icebergConfig": { + "writeVersion": 2, + "fileFormat": "parquet", + "partitionSpec": [ + { + "sourceColumn": "id", + "transform": "quarterly" + } + ] + } + }, + "contract": { + "schema": [ + { + "name": "customer_id", + "type": "string" + } + ] + } + } + ] + }, + "valid": false, + "errors": [ + { + "pointer": "/exposes/0/binding/icebergConfig/partitionSpec/0/transform", + "keyword": "enum" + } + ] + }, + { + "description": "a sortOrder entry omits the required column", + "contract": { + "fluidVersion": "0.7.5", + "kind": "DataProduct", + "id": "customer_profiles", + "name": "Customer Profiles", + "metadata": { + "owner": { + "team": "customer-analytics" + } + }, + "exposes": [ + { + "exposeId": "profiles_table", + "kind": "table", + "binding": { + "platform": "aws", + "format": "iceberg", + "location": { + "bucket": "acme-lake", + "path": "warehouse/customers/profiles" + }, + "icebergConfig": { + "writeVersion": 2, + "fileFormat": "parquet", + "sortOrder": [ + { + "direction": "asc" + } + ] + } + }, + "contract": { + "schema": [ + { + "name": "customer_id", + "type": "string" + } + ] + } + } + ] + }, + "valid": false, + "errors": [ + { + "pointer": "/exposes/0/binding/icebergConfig/sortOrder/0", + "keyword": "required", + "detail": "column" + } + ] + } + ] +} diff --git a/tests/0.7.5/build-consumes.json b/tests/0.7.5/build-consumes.json new file mode 100644 index 0000000..32d8a44 --- /dev/null +++ b/tests/0.7.5/build-consumes.json @@ -0,0 +1,875 @@ +{ + "corpusVersion": "0.7.5", + "schema": "https://open-data-protocol.github.io/fluid/schema/fluid-schema-0.7.5.json", + "group": "build-consumes", + "description": "The build block and its four pattern variants (hybrid-reference, embedded-logic, multi-stage, acquisition) -- including each variant's own closed property set, not just its required member -- the build-level capabilities vocabulary and dependencies[].condition enum, the builds array of independent build configurations, the consumes array and consumeRef, and the inline environments overrides object. A conformant implementation reproduces every verdict below.", + "cases": [ + { + "description": "hybrid-reference build carries every optional member: outputs, dependencies, tags, labels, an adapter-qualified engine, and pattern-specific select/materializations/vars", + "contract": { + "fluidVersion": "0.7.5", + "kind": "DataProduct", + "id": "customer_profiles", + "name": "Customer Profiles", + "metadata": { "owner": { "team": "customer-analytics" } }, + "exposes": [ + { + "exposeId": "profiles_table", + "kind": "table", + "binding": { + "platform": "gcp", + "format": "bigquery_table", + "location": { "project": "acme-prod", "dataset": "customers", "table": "profiles" } + }, + "contract": { "schema": [{ "name": "customer_id", "type": "string" }] } + } + ], + "build": { + "id": "stg_customers_build", + "description": "Staging build for customer profile enrichment.", + "pattern": "hybrid-reference", + "engine": "dbt-snowflake", + "outputs": ["profiles_table"], + "dependencies": [{ "buildId": "raw_customers_build", "condition": "success" }], + "tags": ["batch"], + "labels": { "team": "customer-analytics" }, + "properties": { + "model": "stg_customers", + "select": ["stg_customers", "+downstream"], + "materializations": { "stg_customers": "incremental" }, + "vars": { "start_date": "2024-01-01" } + } + } + }, + "valid": true + }, + { + "description": "build.pattern is set to a value outside its four-member enum", + "contract": { + "fluidVersion": "0.7.5", + "kind": "DataProduct", + "id": "customer_profiles", + "name": "Customer Profiles", + "metadata": { "owner": { "team": "customer-analytics" } }, + "exposes": [ + { + "exposeId": "profiles_table", + "kind": "table", + "binding": { + "platform": "gcp", + "format": "bigquery_table", + "location": { "project": "acme-prod", "dataset": "customers", "table": "profiles" } + }, + "contract": { "schema": [{ "name": "customer_id", "type": "string" }] } + } + ], + "build": { "pattern": "streaming", "engine": "dbt", "properties": { "model": "stg_customers" } } + }, + "valid": false, + "errors": [{ "pointer": "/build/pattern", "keyword": "enum" }] + }, + { + "description": "build carries a top-level member the schema does not define", + "contract": { + "fluidVersion": "0.7.5", + "kind": "DataProduct", + "id": "customer_profiles", + "name": "Customer Profiles", + "metadata": { "owner": { "team": "customer-analytics" } }, + "exposes": [ + { + "exposeId": "profiles_table", + "kind": "table", + "binding": { + "platform": "gcp", + "format": "bigquery_table", + "location": { "project": "acme-prod", "dataset": "customers", "table": "profiles" } + }, + "contract": { "schema": [{ "name": "customer_id", "type": "string" }] } + } + ], + "build": { + "pattern": "hybrid-reference", + "engine": "dbt", + "properties": { "model": "stg_customers" }, + "priority": 5 + } + }, + "valid": false, + "errors": [{ "pointer": "/build", "keyword": "additionalProperties", "detail": "priority" }] + }, + { + "description": "build.engine matches neither the fixed enum nor the adapter-qualified dbt- pattern", + "contract": { + "fluidVersion": "0.7.5", + "kind": "DataProduct", + "id": "customer_profiles", + "name": "Customer Profiles", + "metadata": { "owner": { "team": "customer-analytics" } }, + "exposes": [ + { + "exposeId": "profiles_table", + "kind": "table", + "binding": { + "platform": "gcp", + "format": "bigquery_table", + "location": { "project": "acme-prod", "dataset": "customers", "table": "profiles" } + }, + "contract": { "schema": [{ "name": "customer_id", "type": "string" }] } + } + ], + "build": { + "pattern": "hybrid-reference", + "engine": "snowflake-native", + "properties": { "model": "stg_customers" } + } + }, + "valid": false, + "errors": [{ "pointer": "/build/engine", "keyword": "anyOf" }] + }, + { + "description": "hybrid-reference build's properties omit the required model", + "contract": { + "fluidVersion": "0.7.5", + "kind": "DataProduct", + "id": "customer_profiles", + "name": "Customer Profiles", + "metadata": { "owner": { "team": "customer-analytics" } }, + "exposes": [ + { + "exposeId": "profiles_table", + "kind": "table", + "binding": { + "platform": "gcp", + "format": "bigquery_table", + "location": { "project": "acme-prod", "dataset": "customers", "table": "profiles" } + }, + "contract": { "schema": [{ "name": "customer_id", "type": "string" }] } + } + ], + "build": { "pattern": "hybrid-reference", "engine": "dbt", "properties": {} } + }, + "valid": false, + "errors": [{ "pointer": "/build/properties", "keyword": "required", "detail": "model" }] + }, + { + "description": "hybrid-reference build's properties carry an embedded-logic field (sql) that its own closed set does not admit", + "contract": { + "fluidVersion": "0.7.5", + "kind": "DataProduct", + "id": "customer_profiles", + "name": "Customer Profiles", + "metadata": { "owner": { "team": "customer-analytics" } }, + "exposes": [ + { + "exposeId": "profiles_table", + "kind": "table", + "binding": { + "platform": "gcp", + "format": "bigquery_table", + "location": { "project": "acme-prod", "dataset": "customers", "table": "profiles" } + }, + "contract": { "schema": [{ "name": "customer_id", "type": "string" }] } + } + ], + "build": { + "pattern": "hybrid-reference", + "engine": "dbt", + "properties": { "model": "stg_customers", "sql": "SELECT 1" } + } + }, + "valid": false, + "errors": [{ "pointer": "/build/properties", "keyword": "additionalProperties", "detail": "sql" }] + }, + { + "description": "embedded-logic build carries sql, a language choice, and free-form parameters", + "contract": { + "fluidVersion": "0.7.5", + "kind": "DataProduct", + "id": "customer_profiles", + "name": "Customer Profiles", + "metadata": { "owner": { "team": "customer-analytics" } }, + "exposes": [ + { + "exposeId": "profiles_table", + "kind": "table", + "binding": { + "platform": "gcp", + "format": "bigquery_table", + "location": { "project": "acme-prod", "dataset": "customers", "table": "profiles" } + }, + "contract": { "schema": [{ "name": "customer_id", "type": "string" }] } + } + ], + "build": { + "pattern": "embedded-logic", + "engine": "python", + "properties": { + "sql": "SELECT * FROM raw.customers", + "language": "pyspark", + "parameters": { "batch_size": 100 } + } + } + }, + "valid": true + }, + { + "description": "embedded-logic build's properties omit the required sql", + "contract": { + "fluidVersion": "0.7.5", + "kind": "DataProduct", + "id": "customer_profiles", + "name": "Customer Profiles", + "metadata": { "owner": { "team": "customer-analytics" } }, + "exposes": [ + { + "exposeId": "profiles_table", + "kind": "table", + "binding": { + "platform": "gcp", + "format": "bigquery_table", + "location": { "project": "acme-prod", "dataset": "customers", "table": "profiles" } + }, + "contract": { "schema": [{ "name": "customer_id", "type": "string" }] } + } + ], + "build": { "pattern": "embedded-logic", "engine": "sql", "properties": { "language": "sql" } } + }, + "valid": false, + "errors": [{ "pointer": "/build/properties", "keyword": "required", "detail": "sql" }] + }, + { + "description": "embedded-logic build's language falls outside its enum", + "contract": { + "fluidVersion": "0.7.5", + "kind": "DataProduct", + "id": "customer_profiles", + "name": "Customer Profiles", + "metadata": { "owner": { "team": "customer-analytics" } }, + "exposes": [ + { + "exposeId": "profiles_table", + "kind": "table", + "binding": { + "platform": "gcp", + "format": "bigquery_table", + "location": { "project": "acme-prod", "dataset": "customers", "table": "profiles" } + }, + "contract": { "schema": [{ "name": "customer_id", "type": "string" }] } + } + ], + "build": { + "pattern": "embedded-logic", + "engine": "sql", + "properties": { "sql": "SELECT 1", "language": "javascript" } + } + }, + "valid": false, + "errors": [{ "pointer": "/build/properties/language", "keyword": "enum" }] + }, + { + "description": "embedded-logic build's properties carry a hybrid-reference field (model) that its own closed set does not admit", + "contract": { + "fluidVersion": "0.7.5", + "kind": "DataProduct", + "id": "customer_profiles", + "name": "Customer Profiles", + "metadata": { "owner": { "team": "customer-analytics" } }, + "exposes": [ + { + "exposeId": "profiles_table", + "kind": "table", + "binding": { + "platform": "gcp", + "format": "bigquery_table", + "location": { "project": "acme-prod", "dataset": "customers", "table": "profiles" } + }, + "contract": { "schema": [{ "name": "customer_id", "type": "string" }] } + } + ], + "build": { + "pattern": "embedded-logic", + "engine": "sql", + "properties": { "sql": "SELECT 1", "model": "should_not_be_here" } + } + }, + "valid": false, + "errors": [{ "pointer": "/build/properties", "keyword": "additionalProperties", "detail": "model" }] + }, + { + "description": "hybrid-reference build declares a dependency whose condition falls outside its three-member enum", + "contract": { + "fluidVersion": "0.7.5", + "kind": "DataProduct", + "id": "customer_profiles", + "name": "Customer Profiles", + "metadata": { "owner": { "team": "customer-analytics" } }, + "exposes": [ + { + "exposeId": "profiles_table", + "kind": "table", + "binding": { + "platform": "gcp", + "format": "bigquery_table", + "location": { "project": "acme-prod", "dataset": "customers", "table": "profiles" } + }, + "contract": { "schema": [{ "name": "customer_id", "type": "string" }] } + } + ], + "build": { + "pattern": "hybrid-reference", + "engine": "dbt", + "properties": { "model": "stg_customers" }, + "dependencies": [{ "buildId": "raw_customers_build", "condition": "sometimes" }] + } + }, + "valid": false, + "errors": [{ "pointer": "/build/dependencies/0/condition", "keyword": "enum" }] + }, + { + "description": "multi-stage build orchestrates two dependent stages under a bounded parallelism", + "contract": { + "fluidVersion": "0.7.5", + "kind": "DataProduct", + "id": "customer_profiles", + "name": "Customer Profiles", + "metadata": { "owner": { "team": "customer-analytics" } }, + "exposes": [ + { + "exposeId": "profiles_table", + "kind": "table", + "binding": { + "platform": "gcp", + "format": "bigquery_table", + "location": { "project": "acme-prod", "dataset": "customers", "table": "profiles" } + }, + "contract": { "schema": [{ "name": "customer_id", "type": "string" }] } + } + ], + "build": { + "pattern": "multi-stage", + "engine": "spark", + "properties": { + "stages": [ + { "name": "extract", "pattern": "embedded-logic", "outputs": ["raw_stage"] }, + { "name": "transform", "pattern": "hybrid-reference", "dependsOn": ["extract"], "outputs": ["profiles_table"] } + ], + "orchestration": { "parallelism": 2 } + } + } + }, + "valid": true + }, + { + "description": "multi-stage build's stage carries a pattern value that is valid at the build level but outside the stage's narrower two-member enum", + "contract": { + "fluidVersion": "0.7.5", + "kind": "DataProduct", + "id": "customer_profiles", + "name": "Customer Profiles", + "metadata": { "owner": { "team": "customer-analytics" } }, + "exposes": [ + { + "exposeId": "profiles_table", + "kind": "table", + "binding": { + "platform": "gcp", + "format": "bigquery_table", + "location": { "project": "acme-prod", "dataset": "customers", "table": "profiles" } + }, + "contract": { "schema": [{ "name": "customer_id", "type": "string" }] } + } + ], + "build": { + "pattern": "multi-stage", + "engine": "spark", + "properties": { "stages": [{ "name": "ingest", "pattern": "acquisition" }] } + } + }, + "valid": false, + "errors": [{ "pointer": "/build/properties/stages/0/pattern", "keyword": "enum" }] + }, + { + "description": "multi-stage build's orchestration.parallelism is set below its minimum of one", + "contract": { + "fluidVersion": "0.7.5", + "kind": "DataProduct", + "id": "customer_profiles", + "name": "Customer Profiles", + "metadata": { "owner": { "team": "customer-analytics" } }, + "exposes": [ + { + "exposeId": "profiles_table", + "kind": "table", + "binding": { + "platform": "gcp", + "format": "bigquery_table", + "location": { "project": "acme-prod", "dataset": "customers", "table": "profiles" } + }, + "contract": { "schema": [{ "name": "customer_id", "type": "string" }] } + } + ], + "build": { + "pattern": "multi-stage", + "engine": "spark", + "properties": { + "stages": [{ "name": "only_stage" }], + "orchestration": { "parallelism": 0 } + } + } + }, + "valid": false, + "errors": [{ "pointer": "/build/properties/orchestration/parallelism", "keyword": "minimum" }] + }, + { + "description": "multi-stage build's properties carry a top-level member outside multiStagePattern's closed set", + "contract": { + "fluidVersion": "0.7.5", + "kind": "DataProduct", + "id": "customer_profiles", + "name": "Customer Profiles", + "metadata": { "owner": { "team": "customer-analytics" } }, + "exposes": [ + { + "exposeId": "profiles_table", + "kind": "table", + "binding": { + "platform": "gcp", + "format": "bigquery_table", + "location": { "project": "acme-prod", "dataset": "customers", "table": "profiles" } + }, + "contract": { "schema": [{ "name": "customer_id", "type": "string" }] } + } + ], + "build": { + "pattern": "multi-stage", + "engine": "spark", + "properties": { "stages": [{ "name": "only_stage" }], "priority": 5 } + } + }, + "valid": false, + "errors": [{ "pointer": "/build/properties", "keyword": "additionalProperties", "detail": "priority" }] + }, + { + "description": "acquisition build ingests from a minimal external source with no transformation", + "contract": { + "fluidVersion": "0.7.5", + "kind": "DataProduct", + "id": "customer_profiles", + "name": "Customer Profiles", + "metadata": { "owner": { "team": "customer-analytics" } }, + "exposes": [ + { + "exposeId": "profiles_table", + "kind": "table", + "binding": { + "platform": "gcp", + "format": "bigquery_table", + "location": { "project": "acme-prod", "dataset": "customers", "table": "profiles" } + }, + "contract": { "schema": [{ "name": "customer_id", "type": "string" }] } + } + ], + "build": { + "pattern": "acquisition", + "engine": "dlt", + "properties": { "source": { "kind": "postgres", "mode": "full_refresh" } } + } + }, + "valid": true + }, + { + "description": "acquisition build's properties omit the required source", + "contract": { + "fluidVersion": "0.7.5", + "kind": "DataProduct", + "id": "customer_profiles", + "name": "Customer Profiles", + "metadata": { "owner": { "team": "customer-analytics" } }, + "exposes": [ + { + "exposeId": "profiles_table", + "kind": "table", + "binding": { + "platform": "gcp", + "format": "bigquery_table", + "location": { "project": "acme-prod", "dataset": "customers", "table": "profiles" } + }, + "contract": { "schema": [{ "name": "customer_id", "type": "string" }] } + } + ], + "build": { "pattern": "acquisition", "engine": "dlt", "properties": {} } + }, + "valid": false, + "errors": [{ "pointer": "/build/properties", "keyword": "required", "detail": "source" }] + }, + { + "description": "acquisition build's properties carry a hybrid-reference field (model) that its own closed set does not admit", + "contract": { + "fluidVersion": "0.7.5", + "kind": "DataProduct", + "id": "customer_profiles", + "name": "Customer Profiles", + "metadata": { "owner": { "team": "customer-analytics" } }, + "exposes": [ + { + "exposeId": "profiles_table", + "kind": "table", + "binding": { + "platform": "gcp", + "format": "bigquery_table", + "location": { "project": "acme-prod", "dataset": "customers", "table": "profiles" } + }, + "contract": { "schema": [{ "name": "customer_id", "type": "string" }] } + } + ], + "build": { + "pattern": "acquisition", + "engine": "dlt", + "properties": { + "source": { "kind": "postgres", "mode": "full_refresh" }, + "model": "should_not_be_here" + } + } + }, + "valid": false, + "errors": [{ "pointer": "/build/properties", "keyword": "additionalProperties", "detail": "model" }] + }, + { + "description": "acquisition build declares a capability value outside the build.capabilities vocabulary", + "contract": { + "fluidVersion": "0.7.5", + "kind": "DataProduct", + "id": "customer_profiles", + "name": "Customer Profiles", + "metadata": { "owner": { "team": "customer-analytics" } }, + "exposes": [ + { + "exposeId": "profiles_table", + "kind": "table", + "binding": { + "platform": "gcp", + "format": "bigquery_table", + "location": { "project": "acme-prod", "dataset": "customers", "table": "profiles" } + }, + "contract": { "schema": [{ "name": "customer_id", "type": "string" }] } + } + ], + "build": { + "pattern": "acquisition", + "engine": "dlt", + "properties": { "source": { "kind": "postgres", "mode": "full_refresh" } }, + "capabilities": ["full_refresh", "at_random_once"] + } + }, + "valid": false, + "errors": [{ "pointer": "/build/capabilities/1", "keyword": "enum" }] + }, + { + "description": "builds array declares two independent configurations for a multi-modal product, batch and streaming", + "contract": { + "fluidVersion": "0.7.5", + "kind": "DataProduct", + "id": "customer_profiles", + "name": "Customer Profiles", + "metadata": { "owner": { "team": "customer-analytics" } }, + "exposes": [ + { + "exposeId": "profiles_table", + "kind": "table", + "binding": { + "platform": "gcp", + "format": "bigquery_table", + "location": { "project": "acme-prod", "dataset": "customers", "table": "profiles" } + }, + "contract": { "schema": [{ "name": "customer_id", "type": "string" }] } + } + ], + "builds": [ + { "id": "batch-processing", "pattern": "hybrid-reference", "engine": "dbt", "properties": { "model": "stg_customers" } }, + { "id": "streaming-ingest", "pattern": "acquisition", "engine": "kafka-connect", "properties": { "source": { "kind": "kafka", "mode": "streaming" } } } + ] + }, + "valid": true + }, + { + "description": "second entry of the builds array fails its own pattern-specific required member", + "contract": { + "fluidVersion": "0.7.5", + "kind": "DataProduct", + "id": "customer_profiles", + "name": "Customer Profiles", + "metadata": { "owner": { "team": "customer-analytics" } }, + "exposes": [ + { + "exposeId": "profiles_table", + "kind": "table", + "binding": { + "platform": "gcp", + "format": "bigquery_table", + "location": { "project": "acme-prod", "dataset": "customers", "table": "profiles" } + }, + "contract": { "schema": [{ "name": "customer_id", "type": "string" }] } + } + ], + "builds": [ + { "id": "batch-processing", "pattern": "hybrid-reference", "engine": "dbt", "properties": { "model": "stg_customers" } }, + { "id": "bad-build", "pattern": "embedded-logic", "engine": "sql", "properties": {} } + ] + }, + "valid": false, + "errors": [{ "pointer": "/builds/1/properties", "keyword": "required", "detail": "sql" }] + }, + { + "description": "consumeRef declares every optional member: version constraint, full qos expectations, required policies, purpose, tags, and labels", + "contract": { + "fluidVersion": "0.7.5", + "kind": "DataProduct", + "id": "customer_profiles", + "name": "Customer Profiles", + "metadata": { "owner": { "team": "customer-analytics" } }, + "exposes": [ + { + "exposeId": "profiles_table", + "kind": "table", + "binding": { + "platform": "gcp", + "format": "bigquery_table", + "location": { "project": "acme-prod", "dataset": "customers", "table": "profiles" } + }, + "contract": { "schema": [{ "name": "customer_id", "type": "string" }] } + } + ], + "consumes": [ + { + "productId": "silver.hr.people_v2", + "exposeId": "people_table", + "versionConstraint": "^1.2", + "qosExpectations": { + "freshnessMax": "PT15M", + "maxStaleness": "P1D", + "minCompleteness": 0.95, + "tags": ["critical"], + "labels": { "tier": "gold" } + }, + "requiredPolicies": ["pii-masking"], + "purpose": "customer 360 enrichment", + "tags": ["upstream-hr"], + "labels": { "domain": "hr" } + } + ] + }, + "valid": true + }, + { + "description": "consumeRef omits the required productId", + "contract": { + "fluidVersion": "0.7.5", + "kind": "DataProduct", + "id": "customer_profiles", + "name": "Customer Profiles", + "metadata": { "owner": { "team": "customer-analytics" } }, + "exposes": [ + { + "exposeId": "profiles_table", + "kind": "table", + "binding": { + "platform": "gcp", + "format": "bigquery_table", + "location": { "project": "acme-prod", "dataset": "customers", "table": "profiles" } + }, + "contract": { "schema": [{ "name": "customer_id", "type": "string" }] } + } + ], + "consumes": [{ "exposeId": "people_table" }] + }, + "valid": false, + "errors": [{ "pointer": "/consumes/0", "keyword": "required", "detail": "productId" }] + }, + { + "description": "consumeRef omits the required exposeId", + "contract": { + "fluidVersion": "0.7.5", + "kind": "DataProduct", + "id": "customer_profiles", + "name": "Customer Profiles", + "metadata": { "owner": { "team": "customer-analytics" } }, + "exposes": [ + { + "exposeId": "profiles_table", + "kind": "table", + "binding": { + "platform": "gcp", + "format": "bigquery_table", + "location": { "project": "acme-prod", "dataset": "customers", "table": "profiles" } + }, + "contract": { "schema": [{ "name": "customer_id", "type": "string" }] } + } + ], + "consumes": [{ "productId": "silver.hr.people_v2" }] + }, + "valid": false, + "errors": [{ "pointer": "/consumes/0", "keyword": "required", "detail": "exposeId" }] + }, + { + "description": "consumeRef uses the legacy 'provider' member name the schema comment says was renamed away", + "contract": { + "fluidVersion": "0.7.5", + "kind": "DataProduct", + "id": "customer_profiles", + "name": "Customer Profiles", + "metadata": { "owner": { "team": "customer-analytics" } }, + "exposes": [ + { + "exposeId": "profiles_table", + "kind": "table", + "binding": { + "platform": "gcp", + "format": "bigquery_table", + "location": { "project": "acme-prod", "dataset": "customers", "table": "profiles" } + }, + "contract": { "schema": [{ "name": "customer_id", "type": "string" }] } + } + ], + "consumes": [{ "productId": "silver.hr.people_v2", "exposeId": "people_table", "provider": "legacy-alias" }] + }, + "valid": false, + "errors": [{ "pointer": "/consumes/0", "keyword": "additionalProperties", "detail": "provider" }] + }, + { + "description": "consumeRef's qosExpectations.minCompleteness is set above its maximum of one", + "contract": { + "fluidVersion": "0.7.5", + "kind": "DataProduct", + "id": "customer_profiles", + "name": "Customer Profiles", + "metadata": { "owner": { "team": "customer-analytics" } }, + "exposes": [ + { + "exposeId": "profiles_table", + "kind": "table", + "binding": { + "platform": "gcp", + "format": "bigquery_table", + "location": { "project": "acme-prod", "dataset": "customers", "table": "profiles" } + }, + "contract": { "schema": [{ "name": "customer_id", "type": "string" }] } + } + ], + "consumes": [{ "productId": "x", "exposeId": "y", "qosExpectations": { "minCompleteness": 1.5 } }] + }, + "valid": false, + "errors": [{ "pointer": "/consumes/0/qosExpectations/minCompleteness", "keyword": "maximum" }] + }, + { + "description": "consumeRef's qosExpectations carries a member outside its own closed set", + "contract": { + "fluidVersion": "0.7.5", + "kind": "DataProduct", + "id": "customer_profiles", + "name": "Customer Profiles", + "metadata": { "owner": { "team": "customer-analytics" } }, + "exposes": [ + { + "exposeId": "profiles_table", + "kind": "table", + "binding": { + "platform": "gcp", + "format": "bigquery_table", + "location": { "project": "acme-prod", "dataset": "customers", "table": "profiles" } + }, + "contract": { "schema": [{ "name": "customer_id", "type": "string" }] } + } + ], + "consumes": [{ "productId": "x", "exposeId": "y", "qosExpectations": { "staleness_minutes": 30 } }] + }, + "valid": false, + "errors": [{ "pointer": "/consumes/0/qosExpectations", "keyword": "additionalProperties", "detail": "staleness_minutes" }] + }, + { + "description": "environments overrides declare a dev environment with metadata, an expose override, tags, and labels", + "contract": { + "fluidVersion": "0.7.5", + "kind": "DataProduct", + "id": "customer_profiles", + "name": "Customer Profiles", + "metadata": { "owner": { "team": "customer-analytics" } }, + "exposes": [ + { + "exposeId": "profiles_table", + "kind": "table", + "binding": { + "platform": "gcp", + "format": "bigquery_table", + "location": { "project": "acme-prod", "dataset": "customers", "table": "profiles" } + }, + "contract": { "schema": [{ "name": "customer_id", "type": "string" }] } + } + ], + "environments": { + "dev": { + "metadata": { + "owner": { "team": "customer-analytics", "email": "data@acme.com" }, + "tags": ["dev"], + "labels": { "cost-center": "eng" } + }, + "exposes": [{ "exposeId": "profiles_table", "tags": ["sandbox"] }], + "tags": ["low-cost"], + "labels": { "tier": "dev" } + } + } + }, + "valid": true + }, + { + "description": "environments.dev carries a member outside environmentConfig's closed set", + "contract": { + "fluidVersion": "0.7.5", + "kind": "DataProduct", + "id": "customer_profiles", + "name": "Customer Profiles", + "metadata": { "owner": { "team": "customer-analytics" } }, + "exposes": [ + { + "exposeId": "profiles_table", + "kind": "table", + "binding": { + "platform": "gcp", + "format": "bigquery_table", + "location": { "project": "acme-prod", "dataset": "customers", "table": "profiles" } + }, + "contract": { "schema": [{ "name": "customer_id", "type": "string" }] } + } + ], + "environments": { "dev": { "region": "us-east1" } } + }, + "valid": false, + "errors": [{ "pointer": "/environments/dev", "keyword": "additionalProperties", "detail": "region" }] + }, + { + "description": "environments.dev's expose override names an exposeId that violates the identifier grammar", + "contract": { + "fluidVersion": "0.7.5", + "kind": "DataProduct", + "id": "customer_profiles", + "name": "Customer Profiles", + "metadata": { "owner": { "team": "customer-analytics" } }, + "exposes": [ + { + "exposeId": "profiles_table", + "kind": "table", + "binding": { + "platform": "gcp", + "format": "bigquery_table", + "location": { "project": "acme-prod", "dataset": "customers", "table": "profiles" } + }, + "contract": { "schema": [{ "name": "customer_id", "type": "string" }] } + } + ], + "environments": { "dev": { "exposes": [{ "exposeId": "-bad" }] } } + }, + "valid": false, + "errors": [{ "pointer": "/environments/dev/exposes/0/exposeId", "keyword": "pattern" }] + } + ] +} diff --git a/tests/0.7.5/contract-schema.json b/tests/0.7.5/contract-schema.json new file mode 100644 index 0000000..8f46903 --- /dev/null +++ b/tests/0.7.5/contract-schema.json @@ -0,0 +1,1111 @@ +{ + "corpusVersion": "0.7.5", + "schema": "https://open-data-protocol.github.io/fluid/schema/fluid-schema-0.7.5.json", + "group": "contract-schema", + "description": "The tabular contract surface of an expose: schemaSignature and schemaPolicy on exposeContract, the schema/openapiRef anyOf, the guarantees and simplified quality sub-objects, the column required-field, minLength and closed property-set rules, the deliberately generous column.type anyOf (canonical enum plus a parameterised cross-dialect pattern), sensitivity classification against sensitivityLevel, and the dqSpec/dqRule required members, enums and monitoring block.", + "cases": [ + { + "description": "signed schema, evolve_safe policy, parameterized decimal column, dq rule with optional operator/threshold/window", + "contract": { + "fluidVersion": "0.7.5", + "kind": "DataProduct", + "id": "customer_profiles", + "name": "Customer Profiles", + "metadata": { + "owner": { + "team": "customer-analytics" + } + }, + "exposes": [ + { + "exposeId": "profiles_table", + "kind": "table", + "binding": { + "platform": "gcp", + "format": "bigquery_table", + "location": { + "project": "acme-prod", + "dataset": "customers", + "table": "profiles" + } + }, + "contract": { + "schema": [ + { + "name": "customer_id", + "type": "string" + }, + { + "name": "lifetime_value", + "type": "decimal(18,4)" + } + ], + "schemaSignature": "sha256:a1b2c3d4a1b2c3d4a1b2c3d4a1b2c3d4a1b2c3d4a1b2c3d4a1b2c3d4a1b2c3d4", + "schemaPolicy": "evolve_safe", + "dq": { + "rules": [ + { + "id": "freshness_customer_id", + "type": "freshness", + "severity": "warn", + "operator": ">=", + "threshold": 0.95, + "window": "P1D" + } + ], + "monitoring": { + "enabled": true, + "window": "PT15M", + "owner": "data-eng" + } + } + } + } + ] + }, + "valid": true + }, + { + "description": "expose contract satisfied via openapiRef instead of a tabular schema", + "contract": { + "fluidVersion": "0.7.5", + "kind": "DataProduct", + "id": "customer_profiles", + "name": "Customer Profiles", + "metadata": { + "owner": { + "team": "customer-analytics" + } + }, + "exposes": [ + { + "exposeId": "profiles_table", + "kind": "api", + "binding": { + "platform": "gcp", + "format": "bigquery_table", + "location": { + "project": "acme-prod", + "dataset": "customers", + "table": "profiles" + } + }, + "contract": { + "openapiRef": "https://api.example.com/openapi.json" + } + } + ] + }, + "valid": true + }, + { + "description": "column classified with a pii sensitivity level", + "contract": { + "fluidVersion": "0.7.5", + "kind": "DataProduct", + "id": "customer_profiles", + "name": "Customer Profiles", + "metadata": { + "owner": { + "team": "customer-analytics" + } + }, + "exposes": [ + { + "exposeId": "profiles_table", + "kind": "table", + "binding": { + "platform": "gcp", + "format": "bigquery_table", + "location": { + "project": "acme-prod", + "dataset": "customers", + "table": "profiles" + } + }, + "contract": { + "schema": [ + { + "name": "ssn", + "type": "string", + "sensitivity": "pii" + } + ] + } + } + ] + }, + "valid": true + }, + { + "description": "expose contract declares neither schema nor openapiRef", + "contract": { + "fluidVersion": "0.7.5", + "kind": "DataProduct", + "id": "customer_profiles", + "name": "Customer Profiles", + "metadata": { + "owner": { + "team": "customer-analytics" + } + }, + "exposes": [ + { + "exposeId": "profiles_table", + "kind": "table", + "binding": { + "platform": "gcp", + "format": "bigquery_table", + "location": { + "project": "acme-prod", + "dataset": "customers", + "table": "profiles" + } + }, + "contract": {} + } + ] + }, + "valid": false, + "errors": [ + { + "pointer": "/exposes/0/contract", + "keyword": "anyOf" + } + ] + }, + { + "description": "schemaSignature missing the sha256 hex digest", + "contract": { + "fluidVersion": "0.7.5", + "kind": "DataProduct", + "id": "customer_profiles", + "name": "Customer Profiles", + "metadata": { + "owner": { + "team": "customer-analytics" + } + }, + "exposes": [ + { + "exposeId": "profiles_table", + "kind": "table", + "binding": { + "platform": "gcp", + "format": "bigquery_table", + "location": { + "project": "acme-prod", + "dataset": "customers", + "table": "profiles" + } + }, + "contract": { + "schema": [ + { + "name": "customer_id", + "type": "string" + } + ], + "schemaSignature": "sha256:not-a-real-hash" + } + } + ] + }, + "valid": false, + "errors": [ + { + "pointer": "/exposes/0/contract/schemaSignature", + "keyword": "pattern" + } + ] + }, + { + "description": "schemaPolicy set to an unsupported evolution value", + "contract": { + "fluidVersion": "0.7.5", + "kind": "DataProduct", + "id": "customer_profiles", + "name": "Customer Profiles", + "metadata": { + "owner": { + "team": "customer-analytics" + } + }, + "exposes": [ + { + "exposeId": "profiles_table", + "kind": "table", + "binding": { + "platform": "gcp", + "format": "bigquery_table", + "location": { + "project": "acme-prod", + "dataset": "customers", + "table": "profiles" + } + }, + "contract": { + "schema": [ + { + "name": "customer_id", + "type": "string" + } + ], + "schemaPolicy": "evolve_fast" + } + } + ] + }, + "valid": false, + "errors": [ + { + "pointer": "/exposes/0/contract/schemaPolicy", + "keyword": "enum" + } + ] + }, + { + "description": "expose contract carries an undeclared columnDefs property", + "contract": { + "fluidVersion": "0.7.5", + "kind": "DataProduct", + "id": "customer_profiles", + "name": "Customer Profiles", + "metadata": { + "owner": { + "team": "customer-analytics" + } + }, + "exposes": [ + { + "exposeId": "profiles_table", + "kind": "table", + "binding": { + "platform": "gcp", + "format": "bigquery_table", + "location": { + "project": "acme-prod", + "dataset": "customers", + "table": "profiles" + } + }, + "contract": { + "schema": [ + { + "name": "customer_id", + "type": "string" + } + ], + "columnDefs": [] + } + } + ] + }, + "valid": false, + "errors": [ + { + "pointer": "/exposes/0/contract", + "keyword": "additionalProperties", + "detail": "columnDefs" + } + ] + }, + { + "description": "column type is not a recognized SQL or FLUID type", + "contract": { + "fluidVersion": "0.7.5", + "kind": "DataProduct", + "id": "customer_profiles", + "name": "Customer Profiles", + "metadata": { + "owner": { + "team": "customer-analytics" + } + }, + "exposes": [ + { + "exposeId": "profiles_table", + "kind": "table", + "binding": { + "platform": "gcp", + "format": "bigquery_table", + "location": { + "project": "acme-prod", + "dataset": "customers", + "table": "profiles" + } + }, + "contract": { + "schema": [ + { + "name": "customer_id", + "type": "MYSTERY_TYPE" + } + ] + } + } + ] + }, + "valid": false, + "errors": [ + { + "pointer": "/exposes/0/contract/schema/0/type", + "keyword": "anyOf" + } + ] + }, + { + "description": "column missing the required name", + "contract": { + "fluidVersion": "0.7.5", + "kind": "DataProduct", + "id": "customer_profiles", + "name": "Customer Profiles", + "metadata": { + "owner": { + "team": "customer-analytics" + } + }, + "exposes": [ + { + "exposeId": "profiles_table", + "kind": "table", + "binding": { + "platform": "gcp", + "format": "bigquery_table", + "location": { + "project": "acme-prod", + "dataset": "customers", + "table": "profiles" + } + }, + "contract": { + "schema": [ + { + "type": "string" + } + ] + } + } + ] + }, + "valid": false, + "errors": [ + { + "pointer": "/exposes/0/contract/schema/0", + "keyword": "required", + "detail": "name" + } + ] + }, + { + "description": "column missing the required type", + "contract": { + "fluidVersion": "0.7.5", + "kind": "DataProduct", + "id": "customer_profiles", + "name": "Customer Profiles", + "metadata": { + "owner": { + "team": "customer-analytics" + } + }, + "exposes": [ + { + "exposeId": "profiles_table", + "kind": "table", + "binding": { + "platform": "gcp", + "format": "bigquery_table", + "location": { + "project": "acme-prod", + "dataset": "customers", + "table": "profiles" + } + }, + "contract": { + "schema": [ + { + "name": "customer_id" + } + ] + } + } + ] + }, + "valid": false, + "errors": [ + { + "pointer": "/exposes/0/contract/schema/0", + "keyword": "required", + "detail": "type" + } + ] + }, + { + "description": "column carries an undeclared primaryKey property", + "contract": { + "fluidVersion": "0.7.5", + "kind": "DataProduct", + "id": "customer_profiles", + "name": "Customer Profiles", + "metadata": { + "owner": { + "team": "customer-analytics" + } + }, + "exposes": [ + { + "exposeId": "profiles_table", + "kind": "table", + "binding": { + "platform": "gcp", + "format": "bigquery_table", + "location": { + "project": "acme-prod", + "dataset": "customers", + "table": "profiles" + } + }, + "contract": { + "schema": [ + { + "name": "customer_id", + "type": "string", + "primaryKey": true + } + ] + } + } + ] + }, + "valid": false, + "errors": [ + { + "pointer": "/exposes/0/contract/schema/0", + "keyword": "additionalProperties", + "detail": "primaryKey" + } + ] + }, + { + "description": "column sensitivity outside the accepted classification enum", + "contract": { + "fluidVersion": "0.7.5", + "kind": "DataProduct", + "id": "customer_profiles", + "name": "Customer Profiles", + "metadata": { + "owner": { + "team": "customer-analytics" + } + }, + "exposes": [ + { + "exposeId": "profiles_table", + "kind": "table", + "binding": { + "platform": "gcp", + "format": "bigquery_table", + "location": { + "project": "acme-prod", + "dataset": "customers", + "table": "profiles" + } + }, + "contract": { + "schema": [ + { + "name": "ssn", + "type": "string", + "sensitivity": "topsecret" + } + ] + } + } + ] + }, + "valid": false, + "errors": [ + { + "pointer": "/exposes/0/contract/schema/0/sensitivity", + "keyword": "enum" + } + ] + }, + { + "description": "data quality rule missing the required id", + "contract": { + "fluidVersion": "0.7.5", + "kind": "DataProduct", + "id": "customer_profiles", + "name": "Customer Profiles", + "metadata": { + "owner": { + "team": "customer-analytics" + } + }, + "exposes": [ + { + "exposeId": "profiles_table", + "kind": "table", + "binding": { + "platform": "gcp", + "format": "bigquery_table", + "location": { + "project": "acme-prod", + "dataset": "customers", + "table": "profiles" + } + }, + "contract": { + "schema": [ + { + "name": "customer_id", + "type": "string" + } + ], + "dq": { + "rules": [ + { + "type": "completeness", + "severity": "error" + } + ] + } + } + } + ] + }, + "valid": false, + "errors": [ + { + "pointer": "/exposes/0/contract/dq/rules/0", + "keyword": "required", + "detail": "id" + } + ] + }, + { + "description": "data quality rule missing the required type", + "contract": { + "fluidVersion": "0.7.5", + "kind": "DataProduct", + "id": "customer_profiles", + "name": "Customer Profiles", + "metadata": { + "owner": { + "team": "customer-analytics" + } + }, + "exposes": [ + { + "exposeId": "profiles_table", + "kind": "table", + "binding": { + "platform": "gcp", + "format": "bigquery_table", + "location": { + "project": "acme-prod", + "dataset": "customers", + "table": "profiles" + } + }, + "contract": { + "schema": [ + { + "name": "customer_id", + "type": "string" + } + ], + "dq": { + "rules": [ + { + "id": "completeness_customer_id", + "severity": "error" + } + ] + } + } + } + ] + }, + "valid": false, + "errors": [ + { + "pointer": "/exposes/0/contract/dq/rules/0", + "keyword": "required", + "detail": "type" + } + ] + }, + { + "description": "data quality rule missing the required severity", + "contract": { + "fluidVersion": "0.7.5", + "kind": "DataProduct", + "id": "customer_profiles", + "name": "Customer Profiles", + "metadata": { + "owner": { + "team": "customer-analytics" + } + }, + "exposes": [ + { + "exposeId": "profiles_table", + "kind": "table", + "binding": { + "platform": "gcp", + "format": "bigquery_table", + "location": { + "project": "acme-prod", + "dataset": "customers", + "table": "profiles" + } + }, + "contract": { + "schema": [ + { + "name": "customer_id", + "type": "string" + } + ], + "dq": { + "rules": [ + { + "id": "completeness_customer_id", + "type": "completeness" + } + ] + } + } + } + ] + }, + "valid": false, + "errors": [ + { + "pointer": "/exposes/0/contract/dq/rules/0", + "keyword": "required", + "detail": "severity" + } + ] + }, + { + "description": "data quality rule type outside the accepted enum", + "contract": { + "fluidVersion": "0.7.5", + "kind": "DataProduct", + "id": "customer_profiles", + "name": "Customer Profiles", + "metadata": { + "owner": { + "team": "customer-analytics" + } + }, + "exposes": [ + { + "exposeId": "profiles_table", + "kind": "table", + "binding": { + "platform": "gcp", + "format": "bigquery_table", + "location": { + "project": "acme-prod", + "dataset": "customers", + "table": "profiles" + } + }, + "contract": { + "schema": [ + { + "name": "customer_id", + "type": "string" + } + ], + "dq": { + "rules": [ + { + "id": "completeness_customer_id", + "type": "outlier_detection", + "severity": "error" + } + ] + } + } + } + ] + }, + "valid": false, + "errors": [ + { + "pointer": "/exposes/0/contract/dq/rules/0/type", + "keyword": "enum" + } + ] + }, + { + "description": "data quality rule severity outside the accepted enum", + "contract": { + "fluidVersion": "0.7.5", + "kind": "DataProduct", + "id": "customer_profiles", + "name": "Customer Profiles", + "metadata": { + "owner": { + "team": "customer-analytics" + } + }, + "exposes": [ + { + "exposeId": "profiles_table", + "kind": "table", + "binding": { + "platform": "gcp", + "format": "bigquery_table", + "location": { + "project": "acme-prod", + "dataset": "customers", + "table": "profiles" + } + }, + "contract": { + "schema": [ + { + "name": "customer_id", + "type": "string" + } + ], + "dq": { + "rules": [ + { + "id": "completeness_customer_id", + "type": "completeness", + "severity": "fatal" + } + ] + } + } + } + ] + }, + "valid": false, + "errors": [ + { + "pointer": "/exposes/0/contract/dq/rules/0/severity", + "keyword": "enum" + } + ] + }, + { + "description": "data quality specification carries an undeclared thresholds property", + "contract": { + "fluidVersion": "0.7.5", + "kind": "DataProduct", + "id": "customer_profiles", + "name": "Customer Profiles", + "metadata": { + "owner": { + "team": "customer-analytics" + } + }, + "exposes": [ + { + "exposeId": "profiles_table", + "kind": "table", + "binding": { + "platform": "gcp", + "format": "bigquery_table", + "location": { + "project": "acme-prod", + "dataset": "customers", + "table": "profiles" + } + }, + "contract": { + "schema": [ + { + "name": "customer_id", + "type": "string" + } + ], + "dq": { + "rules": [ + { + "id": "completeness_customer_id", + "type": "completeness", + "severity": "error" + } + ], + "thresholds": {} + } + } + } + ] + }, + "valid": false, + "errors": [ + { + "pointer": "/exposes/0/contract/dq", + "keyword": "additionalProperties", + "detail": "thresholds" + } + ] + }, + { + "description": "data quality rule operator outside the accepted comparison enum", + "contract": { + "fluidVersion": "0.7.5", + "kind": "DataProduct", + "id": "customer_profiles", + "name": "Customer Profiles", + "metadata": { + "owner": { + "team": "customer-analytics" + } + }, + "exposes": [ + { + "exposeId": "profiles_table", + "kind": "table", + "binding": { + "platform": "gcp", + "format": "bigquery_table", + "location": { + "project": "acme-prod", + "dataset": "customers", + "table": "profiles" + } + }, + "contract": { + "schema": [ + { + "name": "customer_id", + "type": "string" + } + ], + "dq": { + "rules": [ + { + "id": "completeness_customer_id", + "type": "completeness", + "severity": "error", + "operator": "eq" + } + ] + } + } + } + ] + }, + "valid": false, + "errors": [ + { + "pointer": "/exposes/0/contract/dq/rules/0/operator", + "keyword": "enum" + } + ] + }, + { + "description": "column name is an empty string", + "contract": { + "fluidVersion": "0.7.5", + "kind": "DataProduct", + "id": "customer_profiles", + "name": "Customer Profiles", + "metadata": { + "owner": { + "team": "customer-analytics" + } + }, + "exposes": [ + { + "exposeId": "profiles_table", + "kind": "table", + "binding": { + "platform": "gcp", + "format": "bigquery_table", + "location": { + "project": "acme-prod", + "dataset": "customers", + "table": "profiles" + } + }, + "contract": { + "schema": [ + { + "name": "", + "type": "string" + } + ] + } + } + ] + }, + "valid": false, + "errors": [ + { + "pointer": "/exposes/0/contract/schema/0/name", + "keyword": "minLength" + } + ] + }, + { + "description": "dq monitoring block carries an undeclared cronSchedule property", + "contract": { + "fluidVersion": "0.7.5", + "kind": "DataProduct", + "id": "customer_profiles", + "name": "Customer Profiles", + "metadata": { + "owner": { + "team": "customer-analytics" + } + }, + "exposes": [ + { + "exposeId": "profiles_table", + "kind": "table", + "binding": { + "platform": "gcp", + "format": "bigquery_table", + "location": { + "project": "acme-prod", + "dataset": "customers", + "table": "profiles" + } + }, + "contract": { + "schema": [ + { + "name": "customer_id", + "type": "string" + } + ], + "dq": { + "rules": [ + { + "id": "completeness_customer_id", + "type": "completeness", + "severity": "error" + } + ], + "monitoring": { + "enabled": true, + "cronSchedule": "0 * * * *" + } + } + } + } + ] + }, + "valid": false, + "errors": [ + { + "pointer": "/exposes/0/contract/dq/monitoring", + "keyword": "additionalProperties", + "detail": "cronSchedule" + } + ] + }, + { + "description": "expose contract's simplified quality rule missing the required severity", + "contract": { + "fluidVersion": "0.7.5", + "kind": "DataProduct", + "id": "customer_profiles", + "name": "Customer Profiles", + "metadata": { + "owner": { + "team": "customer-analytics" + } + }, + "exposes": [ + { + "exposeId": "profiles_table", + "kind": "table", + "binding": { + "platform": "gcp", + "format": "bigquery_table", + "location": { + "project": "acme-prod", + "dataset": "customers", + "table": "profiles" + } + }, + "contract": { + "schema": [ + { + "name": "customer_id", + "type": "string" + } + ], + "quality": [ + { + "rule": "non_negative_ltv", + "expression": "lifetime_value >= 0" + } + ] + } + } + ] + }, + "valid": false, + "errors": [ + { + "pointer": "/exposes/0/contract/quality/0", + "keyword": "required", + "detail": "severity" + } + ] + }, + { + "description": "expose contract guarantees compatibility value outside the accepted enum", + "contract": { + "fluidVersion": "0.7.5", + "kind": "DataProduct", + "id": "customer_profiles", + "name": "Customer Profiles", + "metadata": { + "owner": { + "team": "customer-analytics" + } + }, + "exposes": [ + { + "exposeId": "profiles_table", + "kind": "table", + "binding": { + "platform": "gcp", + "format": "bigquery_table", + "location": { + "project": "acme-prod", + "dataset": "customers", + "table": "profiles" + } + }, + "contract": { + "schema": [ + { + "name": "customer_id", + "type": "string" + } + ], + "guarantees": { + "compatibility": "loose" + } + } + } + ] + }, + "valid": false, + "errors": [ + { + "pointer": "/exposes/0/contract/guarantees/compatibility", + "keyword": "enum" + } + ] + } + ] +} diff --git a/tests/0.7.5/envelope.json b/tests/0.7.5/envelope.json new file mode 100644 index 0000000..6888aaf --- /dev/null +++ b/tests/0.7.5/envelope.json @@ -0,0 +1,594 @@ +{ + "corpusVersion": "0.7.5", + "schema": "https://open-data-protocol.github.io/fluid/schema/fluid-schema-0.7.5.json", + "group": "envelope", + "description": "The shared document envelope: the six required top-level members, the closed top-level property set, and the identifier and version grammars every FLUID document is bound by. A conformant implementation reproduces every verdict below.", + "cases": [ + { + "description": "minimal document carrying exactly the required members", + "contract": { + "fluidVersion": "0.7.5", + "kind": "DataProduct", + "id": "customer_profiles", + "name": "Customer Profiles", + "metadata": { + "owner": { + "team": "customer-analytics" + } + }, + "exposes": [ + { + "exposeId": "profiles_table", + "kind": "table", + "binding": { + "platform": "gcp", + "format": "bigquery_table", + "location": { + "project": "acme-prod", + "dataset": "customers", + "table": "profiles" + } + }, + "contract": { + "schema": [ + { + "name": "customer_id", + "type": "string" + } + ] + } + } + ] + }, + "valid": true + }, + { + "description": "fluidVersion absent", + "contract": { + "kind": "DataProduct", + "id": "customer_profiles", + "name": "Customer Profiles", + "metadata": { + "owner": { + "team": "customer-analytics" + } + }, + "exposes": [ + { + "exposeId": "profiles_table", + "kind": "table", + "binding": { + "platform": "gcp", + "format": "bigquery_table", + "location": { + "dataset": "customers", + "table": "profiles" + } + }, + "contract": { + "schema": [ + { + "name": "customer_id", + "type": "string" + } + ] + } + } + ] + }, + "valid": false, + "errors": [ + { + "pointer": "", + "keyword": "required", + "detail": "fluidVersion" + } + ] + }, + { + "description": "metadata absent", + "contract": { + "fluidVersion": "0.7.5", + "kind": "DataProduct", + "id": "customer_profiles", + "name": "Customer Profiles", + "exposes": [ + { + "exposeId": "profiles_table", + "kind": "table", + "binding": { + "platform": "gcp", + "format": "bigquery_table", + "location": { + "dataset": "customers", + "table": "profiles" + } + }, + "contract": { + "schema": [ + { + "name": "customer_id", + "type": "string" + } + ] + } + } + ] + }, + "valid": false, + "errors": [ + { + "pointer": "", + "keyword": "required", + "detail": "metadata" + } + ] + }, + { + "description": "metadata present but owner absent", + "contract": { + "fluidVersion": "0.7.5", + "kind": "DataProduct", + "id": "customer_profiles", + "name": "Customer Profiles", + "metadata": { + "layer": "gold" + }, + "exposes": [ + { + "exposeId": "profiles_table", + "kind": "table", + "binding": { + "platform": "gcp", + "format": "bigquery_table", + "location": { + "dataset": "customers", + "table": "profiles" + } + }, + "contract": { + "schema": [ + { + "name": "customer_id", + "type": "string" + } + ] + } + } + ] + }, + "valid": false, + "errors": [ + { + "pointer": "/metadata", + "keyword": "required", + "detail": "owner" + } + ] + }, + { + "description": "exposes is an empty array", + "contract": { + "fluidVersion": "0.7.5", + "kind": "DataProduct", + "id": "customer_profiles", + "name": "Customer Profiles", + "metadata": { + "owner": { + "team": "customer-analytics" + } + }, + "exposes": [] + }, + "valid": false, + "errors": [ + { + "pointer": "/exposes", + "keyword": "minItems" + } + ] + }, + { + "description": "unknown top-level member is refused because the envelope is closed", + "contract": { + "fluidVersion": "0.7.5", + "kind": "DataProduct", + "id": "customer_profiles", + "name": "Customer Profiles", + "metadata": { + "owner": { + "team": "customer-analytics" + } + }, + "owner": "customer-analytics", + "exposes": [ + { + "exposeId": "profiles_table", + "kind": "table", + "binding": { + "platform": "gcp", + "format": "bigquery_table", + "location": { + "dataset": "customers", + "table": "profiles" + } + }, + "contract": { + "schema": [ + { + "name": "customer_id", + "type": "string" + } + ] + } + } + ] + }, + "valid": false, + "errors": [ + { + "pointer": "", + "keyword": "additionalProperties", + "detail": "owner" + } + ] + }, + { + "description": "kind outside the enum", + "contract": { + "fluidVersion": "0.7.5", + "kind": "DataMesh", + "id": "customer_profiles", + "name": "Customer Profiles", + "metadata": { + "owner": { + "team": "customer-analytics" + } + }, + "exposes": [ + { + "exposeId": "profiles_table", + "kind": "table", + "binding": { + "platform": "gcp", + "format": "bigquery_table", + "location": { + "dataset": "customers", + "table": "profiles" + } + }, + "contract": { + "schema": [ + { + "name": "customer_id", + "type": "string" + } + ] + } + } + ] + }, + "valid": false, + "errors": [ + { + "pointer": "/kind", + "keyword": "enum" + } + ] + }, + { + "description": "MLPipeline is an accepted kind", + "contract": { + "fluidVersion": "0.7.5", + "kind": "MLPipeline", + "id": "churn_model", + "name": "Churn Model", + "metadata": { + "owner": { + "team": "ml-platform" + } + }, + "exposes": [ + { + "exposeId": "churn_scores", + "kind": "model", + "binding": { + "platform": "gcp", + "format": "bigquery_table", + "location": { + "dataset": "ml", + "table": "churn_scores" + } + }, + "contract": { + "schema": [ + { + "name": "score", + "type": "double" + } + ] + } + } + ] + }, + "valid": true + }, + { + "description": "id starting with a hyphen violates the identifier grammar", + "contract": { + "fluidVersion": "0.7.5", + "kind": "DataProduct", + "id": "-customer-profiles", + "name": "Customer Profiles", + "metadata": { + "owner": { + "team": "customer-analytics" + } + }, + "exposes": [ + { + "exposeId": "profiles_table", + "kind": "table", + "binding": { + "platform": "gcp", + "format": "bigquery_table", + "location": { + "dataset": "customers", + "table": "profiles" + } + }, + "contract": { + "schema": [ + { + "name": "customer_id", + "type": "string" + } + ] + } + } + ] + }, + "valid": false, + "errors": [ + { + "pointer": "/id", + "keyword": "pattern" + } + ] + }, + { + "description": "single-character id is legal under the identifier grammar's second branch", + "contract": { + "fluidVersion": "0.7.5", + "kind": "DataProduct", + "id": "x", + "name": "Single character id", + "metadata": { + "owner": { + "team": "customer-analytics" + } + }, + "exposes": [ + { + "exposeId": "profiles_table", + "kind": "table", + "binding": { + "platform": "gcp", + "format": "bigquery_table", + "location": { + "dataset": "customers", + "table": "profiles" + } + }, + "contract": { + "schema": [ + { + "name": "customer_id", + "type": "string" + } + ] + } + } + ] + }, + "valid": true + }, + { + "description": "a fluidVersion the 0.7.5 schema does not admit", + "contract": { + "fluidVersion": "0.7.2", + "kind": "DataProduct", + "id": "customer_profiles", + "name": "Customer Profiles", + "metadata": { + "owner": { + "team": "customer-analytics" + } + }, + "exposes": [ + { + "exposeId": "profiles_table", + "kind": "table", + "binding": { + "platform": "gcp", + "format": "bigquery_table", + "location": { + "dataset": "customers", + "table": "profiles" + } + }, + "contract": { + "schema": [ + { + "name": "customer_id", + "type": "string" + } + ] + } + } + ] + }, + "valid": false, + "errors": [ + { + "pointer": "/fluidVersion", + "keyword": "enum" + } + ] + }, + { + "description": "kind absent", + "contract": { + "fluidVersion": "0.7.5", + "id": "customer_profiles", + "name": "Customer Profiles", + "metadata": { + "owner": { + "team": "customer-analytics" + } + }, + "exposes": [ + { + "exposeId": "profiles_table", + "kind": "table", + "binding": { + "platform": "gcp", + "format": "bigquery_table", + "location": { + "project": "acme-prod", + "dataset": "customers", + "table": "profiles" + } + }, + "contract": { + "schema": [ + { + "name": "customer_id", + "type": "string" + } + ] + } + } + ] + }, + "valid": false, + "errors": [ + { + "pointer": "", + "keyword": "required", + "detail": "kind" + } + ] + }, + { + "description": "id absent", + "contract": { + "fluidVersion": "0.7.5", + "kind": "DataProduct", + "name": "Customer Profiles", + "metadata": { + "owner": { + "team": "customer-analytics" + } + }, + "exposes": [ + { + "exposeId": "profiles_table", + "kind": "table", + "binding": { + "platform": "gcp", + "format": "bigquery_table", + "location": { + "project": "acme-prod", + "dataset": "customers", + "table": "profiles" + } + }, + "contract": { + "schema": [ + { + "name": "customer_id", + "type": "string" + } + ] + } + } + ] + }, + "valid": false, + "errors": [ + { + "pointer": "", + "keyword": "required", + "detail": "id" + } + ] + }, + { + "description": "name absent", + "contract": { + "fluidVersion": "0.7.5", + "kind": "DataProduct", + "id": "customer_profiles", + "metadata": { + "owner": { + "team": "customer-analytics" + } + }, + "exposes": [ + { + "exposeId": "profiles_table", + "kind": "table", + "binding": { + "platform": "gcp", + "format": "bigquery_table", + "location": { + "project": "acme-prod", + "dataset": "customers", + "table": "profiles" + } + }, + "contract": { + "schema": [ + { + "name": "customer_id", + "type": "string" + } + ] + } + } + ] + }, + "valid": false, + "errors": [ + { + "pointer": "", + "keyword": "required", + "detail": "name" + } + ] + }, + { + "description": "exposes absent", + "contract": { + "fluidVersion": "0.7.5", + "kind": "DataProduct", + "id": "customer_profiles", + "name": "Customer Profiles", + "metadata": { + "owner": { + "team": "customer-analytics" + } + } + }, + "valid": false, + "errors": [ + { + "pointer": "", + "keyword": "required", + "detail": "exposes" + } + ] + } + ] +} diff --git a/tests/0.7.5/expose.json b/tests/0.7.5/expose.json new file mode 100644 index 0000000..ddc06a5 --- /dev/null +++ b/tests/0.7.5/expose.json @@ -0,0 +1,537 @@ +{ + "corpusVersion": "0.7.5", + "schema": "https://open-data-protocol.github.io/fluid/schema/fluid-schema-0.7.5.json", + "group": "expose", + "description": "The expose object: its four required members, its closed property set, the full breadth of the kind enum, the mcp and iceberg member shapes, exposeId against the identifier grammar, and version against semver.", + "cases": [ + { + "description": "version sits at the lowest permissible semver value", + "contract": { + "fluidVersion": "0.7.5", + "kind": "DataProduct", + "id": "customer_profiles", + "name": "Customer Profiles", + "metadata": { "owner": { "team": "customer-analytics" } }, + "exposes": [ + { + "exposeId": "raw_events", + "version": "0.0.0", + "kind": "table", + "binding": { + "platform": "gcp", + "format": "bigquery_table", + "location": { "project": "acme-prod", "dataset": "raw", "table": "events" } + }, + "contract": { "schema": [{ "name": "event_id", "type": "string" }] } + } + ] + }, + "valid": true + }, + { + "description": "optional title, description, version, tags and labels populated on a view", + "contract": { + "fluidVersion": "0.7.5", + "kind": "DataProduct", + "id": "customer_profiles", + "name": "Customer Profiles", + "metadata": { "owner": { "team": "customer-analytics" } }, + "exposes": [ + { + "exposeId": "customer_view", + "title": "Customer 360 View", + "description": "Denormalized customer view joining profile and orders.", + "version": "1.4.0", + "kind": "view", + "tags": ["pii", "customer-facing"], + "labels": { "team": "customer-analytics", "retention": "7-years" }, + "binding": { + "platform": "snowflake", + "format": "snowflake_view", + "location": { "database": "ANALYTICS", "schema": "PUBLIC", "table": "customer_view" } + }, + "contract": { "schema": [{ "name": "customer_id", "type": "string" }] } + } + ] + }, + "valid": true + }, + { + "description": "kind is api exposed over http with an openapiRef contract", + "contract": { + "fluidVersion": "0.7.5", + "kind": "DataProduct", + "id": "customer_profiles", + "name": "Customer Profiles", + "metadata": { "owner": { "team": "customer-analytics" } }, + "exposes": [ + { + "exposeId": "customers_api", + "kind": "api", + "binding": { + "platform": "aws", + "format": "http_api", + "location": { "baseUrl": "https://api.acme.com/v1/customers", "gateway": "customers-gw" } + }, + "contract": { "openapiRef": "https://api.acme.com/openapi.json" } + } + ] + }, + "valid": true + }, + { + "description": "kind is stream backed by a kafka topic binding", + "contract": { + "fluidVersion": "0.7.5", + "kind": "DataProduct", + "id": "customer_profiles", + "name": "Customer Profiles", + "metadata": { "owner": { "team": "customer-analytics" } }, + "exposes": [ + { + "exposeId": "customer_events", + "kind": "stream", + "binding": { + "platform": "kafka", + "format": "kafka_topic", + "location": { "topic": "customer-events" } + }, + "contract": { "schema": [{ "name": "event_id", "type": "string" }] } + } + ] + }, + "valid": true + }, + { + "description": "kind is model with an other-platform binding and a float output column", + "contract": { + "fluidVersion": "0.7.5", + "kind": "DataProduct", + "id": "customer_profiles", + "name": "Customer Profiles", + "metadata": { "owner": { "team": "customer-analytics" } }, + "exposes": [ + { + "exposeId": "churn_model", + "kind": "model", + "binding": { + "platform": "other", + "format": "other", + "location": { "path": "s3://ml-models/churn/v2" } + }, + "contract": { "schema": [{ "name": "churn_probability", "type": "float" }] } + } + ] + }, + "valid": true + }, + { + "description": "exposeId is a single character", + "contract": { + "fluidVersion": "0.7.5", + "kind": "DataProduct", + "id": "customer_profiles", + "name": "Customer Profiles", + "metadata": { "owner": { "team": "customer-analytics" } }, + "exposes": [ + { + "exposeId": "x", + "kind": "table", + "binding": { + "platform": "gcp", + "format": "bigquery_table", + "location": { "project": "acme-prod", "dataset": "customers", "table": "profiles" } + }, + "contract": { "schema": [{ "name": "customer_id", "type": "string" }] } + } + ] + }, + "valid": true + }, + { + "description": "second expose entry carries a dotted-hyphenated exposeId and a full prerelease-plus-build version", + "contract": { + "fluidVersion": "0.7.5", + "kind": "DataProduct", + "id": "customer_profiles", + "name": "Customer Profiles", + "metadata": { "owner": { "team": "customer-analytics" } }, + "exposes": [ + { + "exposeId": "profiles_table", + "kind": "table", + "binding": { + "platform": "gcp", + "format": "bigquery_table", + "location": { "project": "acme-prod", "dataset": "customers", "table": "profiles" } + }, + "contract": { "schema": [{ "name": "customer_id", "type": "string" }] } + }, + { + "exposeId": "sales.orders-v2", + "version": "2.1.0-beta.1+build.42", + "kind": "view", + "binding": { + "platform": "databricks", + "format": "delta_table", + "location": { "database": "analytics", "table": "orders_v2" } + }, + "contract": { "schema": [{ "name": "order_id", "type": "string" }] } + } + ] + }, + "valid": true + }, + { + "description": "expose carries the mcp gateway member alongside crawler configuration", + "contract": { + "fluidVersion": "0.7.5", + "kind": "DataProduct", + "id": "customer_profiles", + "name": "Customer Profiles", + "metadata": { "owner": { "team": "customer-analytics" } }, + "exposes": [ + { + "exposeId": "profiles_table", + "kind": "table", + "binding": { + "platform": "gcp", + "format": "bigquery_table", + "location": { "project": "acme-prod", "dataset": "customers", "table": "profiles" } + }, + "contract": { "schema": [{ "name": "customer_id", "type": "string" }] }, + "crawler": { "name": "profiles-crawler", "role": "arn:aws:iam::123456789012:role/glue-crawler" }, + "mcp": { + "sampling": { "maxRows": 250 }, + "classification": { "dataClass": "confidential" } + } + } + ] + }, + "valid": true + }, + { + "description": "exposeId absent from the first expose entry", + "contract": { + "fluidVersion": "0.7.5", + "kind": "DataProduct", + "id": "customer_profiles", + "name": "Customer Profiles", + "metadata": { "owner": { "team": "customer-analytics" } }, + "exposes": [ + { + "kind": "table", + "binding": { + "platform": "gcp", + "format": "bigquery_table", + "location": { "project": "acme-prod", "dataset": "customers", "table": "profiles" } + }, + "contract": { "schema": [{ "name": "customer_id", "type": "string" }] } + } + ] + }, + "valid": false, + "errors": [{ "pointer": "/exposes/0", "keyword": "required", "detail": "exposeId" }] + }, + { + "description": "kind absent from the expose entry", + "contract": { + "fluidVersion": "0.7.5", + "kind": "DataProduct", + "id": "customer_profiles", + "name": "Customer Profiles", + "metadata": { "owner": { "team": "customer-analytics" } }, + "exposes": [ + { + "exposeId": "profiles_table", + "binding": { + "platform": "gcp", + "format": "bigquery_table", + "location": { "project": "acme-prod", "dataset": "customers", "table": "profiles" } + }, + "contract": { "schema": [{ "name": "customer_id", "type": "string" }] } + } + ] + }, + "valid": false, + "errors": [{ "pointer": "/exposes/0", "keyword": "required", "detail": "kind" }] + }, + { + "description": "binding absent from the expose entry", + "contract": { + "fluidVersion": "0.7.5", + "kind": "DataProduct", + "id": "customer_profiles", + "name": "Customer Profiles", + "metadata": { "owner": { "team": "customer-analytics" } }, + "exposes": [ + { + "exposeId": "profiles_table", + "kind": "table", + "contract": { "schema": [{ "name": "customer_id", "type": "string" }] } + } + ] + }, + "valid": false, + "errors": [{ "pointer": "/exposes/0", "keyword": "required", "detail": "binding" }] + }, + { + "description": "contract absent from the expose entry", + "contract": { + "fluidVersion": "0.7.5", + "kind": "DataProduct", + "id": "customer_profiles", + "name": "Customer Profiles", + "metadata": { "owner": { "team": "customer-analytics" } }, + "exposes": [ + { + "exposeId": "profiles_table", + "kind": "table", + "binding": { + "platform": "gcp", + "format": "bigquery_table", + "location": { "project": "acme-prod", "dataset": "customers", "table": "profiles" } + } + } + ] + }, + "valid": false, + "errors": [{ "pointer": "/exposes/0", "keyword": "required", "detail": "contract" }] + }, + { + "description": "unknown member on the expose entry is refused because its property set is closed", + "contract": { + "fluidVersion": "0.7.5", + "kind": "DataProduct", + "id": "customer_profiles", + "name": "Customer Profiles", + "metadata": { "owner": { "team": "customer-analytics" } }, + "exposes": [ + { + "exposeId": "profiles_table", + "kind": "table", + "owner": "customer-analytics", + "binding": { + "platform": "gcp", + "format": "bigquery_table", + "location": { "project": "acme-prod", "dataset": "customers", "table": "profiles" } + }, + "contract": { "schema": [{ "name": "customer_id", "type": "string" }] } + } + ] + }, + "valid": false, + "errors": [{ "pointer": "/exposes/0", "keyword": "additionalProperties", "detail": "owner" }] + }, + { + "description": "kind value is outside the enum", + "contract": { + "fluidVersion": "0.7.5", + "kind": "DataProduct", + "id": "customer_profiles", + "name": "Customer Profiles", + "metadata": { "owner": { "team": "customer-analytics" } }, + "exposes": [ + { + "exposeId": "profiles_table", + "kind": "queue", + "binding": { + "platform": "gcp", + "format": "bigquery_table", + "location": { "project": "acme-prod", "dataset": "customers", "table": "profiles" } + }, + "contract": { "schema": [{ "name": "customer_id", "type": "string" }] } + } + ] + }, + "valid": false, + "errors": [{ "pointer": "/exposes/0/kind", "keyword": "enum" }] + }, + { + "description": "exposeId contains a space", + "contract": { + "fluidVersion": "0.7.5", + "kind": "DataProduct", + "id": "customer_profiles", + "name": "Customer Profiles", + "metadata": { "owner": { "team": "customer-analytics" } }, + "exposes": [ + { + "exposeId": "profiles table", + "kind": "table", + "binding": { + "platform": "gcp", + "format": "bigquery_table", + "location": { "project": "acme-prod", "dataset": "customers", "table": "profiles" } + }, + "contract": { "schema": [{ "name": "customer_id", "type": "string" }] } + } + ] + }, + "valid": false, + "errors": [{ "pointer": "/exposes/0/exposeId", "keyword": "pattern" }] + }, + { + "description": "exposeId is an empty string", + "contract": { + "fluidVersion": "0.7.5", + "kind": "DataProduct", + "id": "customer_profiles", + "name": "Customer Profiles", + "metadata": { "owner": { "team": "customer-analytics" } }, + "exposes": [ + { + "exposeId": "", + "kind": "table", + "binding": { + "platform": "gcp", + "format": "bigquery_table", + "location": { "project": "acme-prod", "dataset": "customers", "table": "profiles" } + }, + "contract": { "schema": [{ "name": "customer_id", "type": "string" }] } + } + ] + }, + "valid": false, + "errors": [{ "pointer": "/exposes/0/exposeId", "keyword": "pattern" }] + }, + { + "description": "version omits the patch component", + "contract": { + "fluidVersion": "0.7.5", + "kind": "DataProduct", + "id": "customer_profiles", + "name": "Customer Profiles", + "metadata": { "owner": { "team": "customer-analytics" } }, + "exposes": [ + { + "exposeId": "profiles_table", + "version": "1.0", + "kind": "table", + "binding": { + "platform": "gcp", + "format": "bigquery_table", + "location": { "project": "acme-prod", "dataset": "customers", "table": "profiles" } + }, + "contract": { "schema": [{ "name": "customer_id", "type": "string" }] } + } + ] + }, + "valid": false, + "errors": [{ "pointer": "/exposes/0/version", "keyword": "pattern" }] + }, + { + "description": "the seven kind values not exercised elsewhere, one per expose entry, with an iceberg block on the last", + "contract": { + "fluidVersion": "0.7.5", + "kind": "DataProduct", + "id": "customer_profiles", + "name": "Customer Profiles", + "metadata": { "owner": { "team": "customer-analytics" } }, + "exposes": [ + { + "exposeId": "raw_files", + "kind": "file", + "binding": { + "platform": "aws", + "format": "s3_file", + "location": { "bucket": "acme-raw", "path": "landing/events/" } + }, + "contract": { "schema": [{ "name": "path", "type": "string" }] } + }, + { + "exposeId": "customer_topic", + "kind": "topic", + "binding": { + "platform": "gcp", + "format": "pubsub_topic", + "location": { "topic": "customer-updates" } + }, + "contract": { "schema": [{ "name": "event_id", "type": "string" }] } + }, + { + "exposeId": "customer_features", + "kind": "feature_store", + "binding": { + "platform": "databricks", + "format": "delta_table", + "location": { "database": "features", "table": "customer_features" } + }, + "contract": { "schema": [{ "name": "customer_id", "type": "string" }] } + }, + { + "exposeId": "customer_embeddings", + "kind": "vector", + "binding": { + "platform": "other", + "format": "other", + "location": { "path": "s3://ml-vectors/customers" } + }, + "contract": { "schema": [{ "name": "embedding", "type": "array" }] } + }, + { + "exposeId": "customer_graph", + "kind": "graph", + "binding": { + "platform": "other", + "format": "other", + "location": { "path": "s3://graph-store/customers" } + }, + "contract": { "schema": [{ "name": "node_id", "type": "string" }] } + }, + { + "exposeId": "customer_metrics_ts", + "kind": "time_series", + "binding": { + "platform": "gcp", + "format": "bigquery_table", + "location": { "project": "acme-prod", "dataset": "metrics", "table": "customer_ts" } + }, + "contract": { "schema": [{ "name": "ts", "type": "timestamp" }] } + }, + { + "exposeId": "legacy_export", + "kind": "other", + "binding": { + "platform": "azure", + "format": "other", + "location": { "account": "acmestorage", "path": "exports/legacy.csv" } + }, + "contract": { "schema": [{ "name": "raw", "type": "string" }] }, + "iceberg": { + "writeFormat": "parquet", + "snapshotRetention": { "maxSnapshotAgeMs": 604800000, "minSnapshotsToKeep": 5 }, + "compaction": { "enabled": true, "targetFileSizeMb": 512 } + } + } + ] + }, + "valid": true + }, + { + "description": "iceberg.writeFormat is outside its parquet/orc/avro enum", + "contract": { + "fluidVersion": "0.7.5", + "kind": "DataProduct", + "id": "customer_profiles", + "name": "Customer Profiles", + "metadata": { "owner": { "team": "customer-analytics" } }, + "exposes": [ + { + "exposeId": "profiles_table", + "kind": "table", + "binding": { + "platform": "gcp", + "format": "bigquery_table", + "location": { "project": "acme-prod", "dataset": "customers", "table": "profiles" } + }, + "contract": { "schema": [{ "name": "customer_id", "type": "string" }] }, + "iceberg": { "writeFormat": "iceberg-native" } + } + ] + }, + "valid": false, + "errors": [{ "pointer": "/exposes/0/iceberg/writeFormat", "keyword": "enum" }] + } + ] +} diff --git a/tests/0.7.5/governance-access.json b/tests/0.7.5/governance-access.json new file mode 100644 index 0000000..f2734e3 --- /dev/null +++ b/tests/0.7.5/governance-access.json @@ -0,0 +1,673 @@ +{ + "corpusVersion": "0.7.5", + "schema": "https://open-data-protocol.github.io/fluid/schema/fluid-schema-0.7.5.json", + "group": "governance-access", + "description": "The optional accessPolicy, governance, and retention blocks: the accessPolicy grants array (required principal, permissions enum, uniqueItems), the governance lakeFormation block (ARN-pattern admins, tag-definition value arrays), and the retention block's ISO-8601 duration fields -- plus each block's closed property set.", + "cases": [ + { + "description": "accessPolicy grant carrying only the required principal", + "contract": { + "fluidVersion": "0.7.5", + "kind": "DataProduct", + "id": "customer_profiles", + "name": "Customer Profiles", + "metadata": { "owner": { "team": "customer-analytics" } }, + "exposes": [ + { + "exposeId": "profiles_table", + "kind": "table", + "binding": { + "platform": "gcp", + "format": "bigquery_table", + "location": { "project": "acme-prod", "dataset": "customers", "table": "profiles" } + }, + "contract": { "schema": [{ "name": "customer_id", "type": "string" }] } + } + ], + "accessPolicy": { + "grants": [ + { "principal": "user:analyst@company.com" } + ] + } + }, + "valid": true + }, + { + "description": "accessPolicy grant exercising the full permissions enum range plus resources and conditions", + "contract": { + "fluidVersion": "0.7.5", + "kind": "DataProduct", + "id": "customer_profiles", + "name": "Customer Profiles", + "metadata": { "owner": { "team": "customer-analytics" } }, + "exposes": [ + { + "exposeId": "profiles_table", + "kind": "table", + "binding": { + "platform": "gcp", + "format": "bigquery_table", + "location": { "project": "acme-prod", "dataset": "customers", "table": "profiles" } + }, + "contract": { "schema": [{ "name": "customer_id", "type": "string" }] } + } + ], + "accessPolicy": { + "grants": [ + { + "principal": "group:data-analytics@company.com", + "permissions": ["read", "select", "query", "write", "insert", "update", "delete", "create", "admin", "manage"], + "resources": ["$.exposes[?(@.exposeId=='profiles_table')]"], + "conditions": { "ipRange": "10.0.0.0/8" } + } + ] + } + }, + "valid": true + }, + { + "description": "accessPolicy grant missing its principal", + "contract": { + "fluidVersion": "0.7.5", + "kind": "DataProduct", + "id": "customer_profiles", + "name": "Customer Profiles", + "metadata": { "owner": { "team": "customer-analytics" } }, + "exposes": [ + { + "exposeId": "profiles_table", + "kind": "table", + "binding": { + "platform": "gcp", + "format": "bigquery_table", + "location": { "project": "acme-prod", "dataset": "customers", "table": "profiles" } + }, + "contract": { "schema": [{ "name": "customer_id", "type": "string" }] } + } + ], + "accessPolicy": { + "grants": [ + { "permissions": ["read"] } + ] + } + }, + "valid": false, + "errors": [{ "pointer": "/accessPolicy/grants/0", "keyword": "required", "detail": "principal" }] + }, + { + "description": "accessPolicy grant permission value outside the enum", + "contract": { + "fluidVersion": "0.7.5", + "kind": "DataProduct", + "id": "customer_profiles", + "name": "Customer Profiles", + "metadata": { "owner": { "team": "customer-analytics" } }, + "exposes": [ + { + "exposeId": "profiles_table", + "kind": "table", + "binding": { + "platform": "gcp", + "format": "bigquery_table", + "location": { "project": "acme-prod", "dataset": "customers", "table": "profiles" } + }, + "contract": { "schema": [{ "name": "customer_id", "type": "string" }] } + } + ], + "accessPolicy": { + "grants": [ + { "principal": "user:analyst@company.com", "permissions": ["superuser"] } + ] + } + }, + "valid": false, + "errors": [{ "pointer": "/accessPolicy/grants/0/permissions/0", "keyword": "enum" }] + }, + { + "description": "accessPolicy grant permissions array with a repeated value", + "contract": { + "fluidVersion": "0.7.5", + "kind": "DataProduct", + "id": "customer_profiles", + "name": "Customer Profiles", + "metadata": { "owner": { "team": "customer-analytics" } }, + "exposes": [ + { + "exposeId": "profiles_table", + "kind": "table", + "binding": { + "platform": "gcp", + "format": "bigquery_table", + "location": { "project": "acme-prod", "dataset": "customers", "table": "profiles" } + }, + "contract": { "schema": [{ "name": "customer_id", "type": "string" }] } + } + ], + "accessPolicy": { + "grants": [ + { "principal": "user:analyst@company.com", "permissions": ["read", "read"] } + ] + } + }, + "valid": false, + "errors": [{ "pointer": "/accessPolicy/grants/0/permissions", "keyword": "uniqueItems" }] + }, + { + "description": "accessPolicy grant principal given as a number instead of a string", + "contract": { + "fluidVersion": "0.7.5", + "kind": "DataProduct", + "id": "customer_profiles", + "name": "Customer Profiles", + "metadata": { "owner": { "team": "customer-analytics" } }, + "exposes": [ + { + "exposeId": "profiles_table", + "kind": "table", + "binding": { + "platform": "gcp", + "format": "bigquery_table", + "location": { "project": "acme-prod", "dataset": "customers", "table": "profiles" } + }, + "contract": { "schema": [{ "name": "customer_id", "type": "string" }] } + } + ], + "accessPolicy": { + "grants": [ + { "principal": 12345 } + ] + } + }, + "valid": false, + "errors": [{ "pointer": "/accessPolicy/grants/0/principal", "keyword": "type" }] + }, + { + "description": "accessPolicy grant permissions given as a bare string instead of an array", + "contract": { + "fluidVersion": "0.7.5", + "kind": "DataProduct", + "id": "customer_profiles", + "name": "Customer Profiles", + "metadata": { "owner": { "team": "customer-analytics" } }, + "exposes": [ + { + "exposeId": "profiles_table", + "kind": "table", + "binding": { + "platform": "gcp", + "format": "bigquery_table", + "location": { "project": "acme-prod", "dataset": "customers", "table": "profiles" } + }, + "contract": { "schema": [{ "name": "customer_id", "type": "string" }] } + } + ], + "accessPolicy": { + "grants": [ + { "principal": "user:analyst@company.com", "permissions": "read" } + ] + } + }, + "valid": false, + "errors": [{ "pointer": "/accessPolicy/grants/0/permissions", "keyword": "type" }] + }, + { + "description": "accessPolicy carrying an unrecognized member alongside grants", + "contract": { + "fluidVersion": "0.7.5", + "kind": "DataProduct", + "id": "customer_profiles", + "name": "Customer Profiles", + "metadata": { "owner": { "team": "customer-analytics" } }, + "exposes": [ + { + "exposeId": "profiles_table", + "kind": "table", + "binding": { + "platform": "gcp", + "format": "bigquery_table", + "location": { "project": "acme-prod", "dataset": "customers", "table": "profiles" } + }, + "contract": { "schema": [{ "name": "customer_id", "type": "string" }] } + } + ], + "accessPolicy": { + "grants": [ + { "principal": "user:analyst@company.com" } + ], + "policies": [] + } + }, + "valid": false, + "errors": [{ "pointer": "/accessPolicy", "keyword": "additionalProperties", "detail": "policies" }] + }, + { + "description": "accessPolicy grant carrying an unrecognized member alongside principal", + "contract": { + "fluidVersion": "0.7.5", + "kind": "DataProduct", + "id": "customer_profiles", + "name": "Customer Profiles", + "metadata": { "owner": { "team": "customer-analytics" } }, + "exposes": [ + { + "exposeId": "profiles_table", + "kind": "table", + "binding": { + "platform": "gcp", + "format": "bigquery_table", + "location": { "project": "acme-prod", "dataset": "customers", "table": "profiles" } + }, + "contract": { "schema": [{ "name": "customer_id", "type": "string" }] } + } + ], + "accessPolicy": { + "grants": [ + { "principal": "user:analyst@company.com", "role": "viewer" } + ] + } + }, + "valid": false, + "errors": [{ "pointer": "/accessPolicy/grants/0", "keyword": "additionalProperties", "detail": "role" }] + }, + { + "description": "governance lakeFormation block with admins and tag definitions", + "contract": { + "fluidVersion": "0.7.5", + "kind": "DataProduct", + "id": "customer_profiles", + "name": "Customer Profiles", + "metadata": { "owner": { "team": "customer-analytics" } }, + "exposes": [ + { + "exposeId": "profiles_table", + "kind": "table", + "binding": { + "platform": "gcp", + "format": "bigquery_table", + "location": { "project": "acme-prod", "dataset": "customers", "table": "profiles" } + }, + "contract": { "schema": [{ "name": "customer_id", "type": "string" }] } + } + ], + "governance": { + "lakeFormation": { + "admins": ["arn:aws:iam::123456789012:role/DataLakeAdmin"], + "tagDefinitions": { "confidentiality": ["public", "internal", "restricted"] } + } + } + }, + "valid": true + }, + { + "description": "governance carrying an unrecognized member instead of lakeFormation", + "contract": { + "fluidVersion": "0.7.5", + "kind": "DataProduct", + "id": "customer_profiles", + "name": "Customer Profiles", + "metadata": { "owner": { "team": "customer-analytics" } }, + "exposes": [ + { + "exposeId": "profiles_table", + "kind": "table", + "binding": { + "platform": "gcp", + "format": "bigquery_table", + "location": { "project": "acme-prod", "dataset": "customers", "table": "profiles" } + }, + "contract": { "schema": [{ "name": "customer_id", "type": "string" }] } + } + ], + "governance": { + "snowflake": {} + } + }, + "valid": false, + "errors": [{ "pointer": "/governance", "keyword": "additionalProperties", "detail": "snowflake" }] + }, + { + "description": "lakeFormation block carrying an unrecognized member alongside admins", + "contract": { + "fluidVersion": "0.7.5", + "kind": "DataProduct", + "id": "customer_profiles", + "name": "Customer Profiles", + "metadata": { "owner": { "team": "customer-analytics" } }, + "exposes": [ + { + "exposeId": "profiles_table", + "kind": "table", + "binding": { + "platform": "gcp", + "format": "bigquery_table", + "location": { "project": "acme-prod", "dataset": "customers", "table": "profiles" } + }, + "contract": { "schema": [{ "name": "customer_id", "type": "string" }] } + } + ], + "governance": { + "lakeFormation": { + "admins": ["arn:aws:iam::123456789012:role/DataLakeAdmin"], + "auditors": [] + } + } + }, + "valid": false, + "errors": [{ "pointer": "/governance/lakeFormation", "keyword": "additionalProperties", "detail": "auditors" }] + }, + { + "description": "lakeFormation admin entry that is not an IAM ARN", + "contract": { + "fluidVersion": "0.7.5", + "kind": "DataProduct", + "id": "customer_profiles", + "name": "Customer Profiles", + "metadata": { "owner": { "team": "customer-analytics" } }, + "exposes": [ + { + "exposeId": "profiles_table", + "kind": "table", + "binding": { + "platform": "gcp", + "format": "bigquery_table", + "location": { "project": "acme-prod", "dataset": "customers", "table": "profiles" } + }, + "contract": { "schema": [{ "name": "customer_id", "type": "string" }] } + } + ], + "governance": { + "lakeFormation": { + "admins": ["not-an-arn"] + } + } + }, + "valid": false, + "errors": [{ "pointer": "/governance/lakeFormation/admins/0", "keyword": "pattern" }] + }, + { + "description": "lakeFormation admins given as a bare string instead of an array", + "contract": { + "fluidVersion": "0.7.5", + "kind": "DataProduct", + "id": "customer_profiles", + "name": "Customer Profiles", + "metadata": { "owner": { "team": "customer-analytics" } }, + "exposes": [ + { + "exposeId": "profiles_table", + "kind": "table", + "binding": { + "platform": "gcp", + "format": "bigquery_table", + "location": { "project": "acme-prod", "dataset": "customers", "table": "profiles" } + }, + "contract": { "schema": [{ "name": "customer_id", "type": "string" }] } + } + ], + "governance": { + "lakeFormation": { + "admins": "arn:aws:iam::123456789012:role/DataLakeAdmin" + } + } + }, + "valid": false, + "errors": [{ "pointer": "/governance/lakeFormation/admins", "keyword": "type" }] + }, + { + "description": "lakeFormation admins array with a repeated ARN", + "contract": { + "fluidVersion": "0.7.5", + "kind": "DataProduct", + "id": "customer_profiles", + "name": "Customer Profiles", + "metadata": { "owner": { "team": "customer-analytics" } }, + "exposes": [ + { + "exposeId": "profiles_table", + "kind": "table", + "binding": { + "platform": "gcp", + "format": "bigquery_table", + "location": { "project": "acme-prod", "dataset": "customers", "table": "profiles" } + }, + "contract": { "schema": [{ "name": "customer_id", "type": "string" }] } + } + ], + "governance": { + "lakeFormation": { + "admins": [ + "arn:aws:iam::123456789012:role/DataLakeAdmin", + "arn:aws:iam::123456789012:role/DataLakeAdmin" + ] + } + } + }, + "valid": false, + "errors": [{ "pointer": "/governance/lakeFormation/admins", "keyword": "uniqueItems" }] + }, + { + "description": "lakeFormation tag definition with no allowed values", + "contract": { + "fluidVersion": "0.7.5", + "kind": "DataProduct", + "id": "customer_profiles", + "name": "Customer Profiles", + "metadata": { "owner": { "team": "customer-analytics" } }, + "exposes": [ + { + "exposeId": "profiles_table", + "kind": "table", + "binding": { + "platform": "gcp", + "format": "bigquery_table", + "location": { "project": "acme-prod", "dataset": "customers", "table": "profiles" } + }, + "contract": { "schema": [{ "name": "customer_id", "type": "string" }] } + } + ], + "governance": { + "lakeFormation": { + "tagDefinitions": { "confidentiality": [] } + } + } + }, + "valid": false, + "errors": [{ "pointer": "/governance/lakeFormation/tagDefinitions/confidentiality", "keyword": "minItems" }] + }, + { + "description": "lakeFormation tag definition value given as a string instead of an array", + "contract": { + "fluidVersion": "0.7.5", + "kind": "DataProduct", + "id": "customer_profiles", + "name": "Customer Profiles", + "metadata": { "owner": { "team": "customer-analytics" } }, + "exposes": [ + { + "exposeId": "profiles_table", + "kind": "table", + "binding": { + "platform": "gcp", + "format": "bigquery_table", + "location": { "project": "acme-prod", "dataset": "customers", "table": "profiles" } + }, + "contract": { "schema": [{ "name": "customer_id", "type": "string" }] } + } + ], + "governance": { + "lakeFormation": { + "tagDefinitions": { "confidentiality": "public" } + } + } + }, + "valid": false, + "errors": [{ "pointer": "/governance/lakeFormation/tagDefinitions/confidentiality", "keyword": "type" }] + }, + { + "description": "lakeFormation tag definition with a repeated allowed value", + "contract": { + "fluidVersion": "0.7.5", + "kind": "DataProduct", + "id": "customer_profiles", + "name": "Customer Profiles", + "metadata": { "owner": { "team": "customer-analytics" } }, + "exposes": [ + { + "exposeId": "profiles_table", + "kind": "table", + "binding": { + "platform": "gcp", + "format": "bigquery_table", + "location": { "project": "acme-prod", "dataset": "customers", "table": "profiles" } + }, + "contract": { "schema": [{ "name": "customer_id", "type": "string" }] } + } + ], + "governance": { + "lakeFormation": { + "tagDefinitions": { "confidentiality": ["public", "public"] } + } + } + }, + "valid": false, + "errors": [{ "pointer": "/governance/lakeFormation/tagDefinitions/confidentiality", "keyword": "uniqueItems" }] + }, + { + "description": "retention block setting all four durations", + "contract": { + "fluidVersion": "0.7.5", + "kind": "DataProduct", + "id": "customer_profiles", + "name": "Customer Profiles", + "metadata": { "owner": { "team": "customer-analytics" } }, + "exposes": [ + { + "exposeId": "profiles_table", + "kind": "table", + "binding": { + "platform": "gcp", + "format": "bigquery_table", + "location": { "project": "acme-prod", "dataset": "customers", "table": "profiles" } + }, + "contract": { "schema": [{ "name": "customer_id", "type": "string" }] } + } + ], + "retention": { + "runState": "P30D", + "runLogs": "P90D", + "lineage": "P365D", + "dlq": "P180D" + } + }, + "valid": true + }, + { + "description": "retention block setting only runLogs with an hours-and-minutes duration", + "contract": { + "fluidVersion": "0.7.5", + "kind": "DataProduct", + "id": "customer_profiles", + "name": "Customer Profiles", + "metadata": { "owner": { "team": "customer-analytics" } }, + "exposes": [ + { + "exposeId": "profiles_table", + "kind": "table", + "binding": { + "platform": "gcp", + "format": "bigquery_table", + "location": { "project": "acme-prod", "dataset": "customers", "table": "profiles" } + }, + "contract": { "schema": [{ "name": "customer_id", "type": "string" }] } + } + ], + "retention": { + "runLogs": "PT12H30M" + } + }, + "valid": true + }, + { + "description": "retention duration given as a number instead of a string", + "contract": { + "fluidVersion": "0.7.5", + "kind": "DataProduct", + "id": "customer_profiles", + "name": "Customer Profiles", + "metadata": { "owner": { "team": "customer-analytics" } }, + "exposes": [ + { + "exposeId": "profiles_table", + "kind": "table", + "binding": { + "platform": "gcp", + "format": "bigquery_table", + "location": { "project": "acme-prod", "dataset": "customers", "table": "profiles" } + }, + "contract": { "schema": [{ "name": "customer_id", "type": "string" }] } + } + ], + "retention": { + "runState": 30 + } + }, + "valid": false, + "errors": [{ "pointer": "/retention/runState", "keyword": "type" }] + }, + { + "description": "retention duration missing the leading ISO-8601 designator", + "contract": { + "fluidVersion": "0.7.5", + "kind": "DataProduct", + "id": "customer_profiles", + "name": "Customer Profiles", + "metadata": { "owner": { "team": "customer-analytics" } }, + "exposes": [ + { + "exposeId": "profiles_table", + "kind": "table", + "binding": { + "platform": "gcp", + "format": "bigquery_table", + "location": { "project": "acme-prod", "dataset": "customers", "table": "profiles" } + }, + "contract": { "schema": [{ "name": "customer_id", "type": "string" }] } + } + ], + "retention": { + "runState": "30D" + } + }, + "valid": false, + "errors": [{ "pointer": "/retention/runState", "keyword": "pattern" }] + }, + { + "description": "retention block carrying an unrecognized member", + "contract": { + "fluidVersion": "0.7.5", + "kind": "DataProduct", + "id": "customer_profiles", + "name": "Customer Profiles", + "metadata": { "owner": { "team": "customer-analytics" } }, + "exposes": [ + { + "exposeId": "profiles_table", + "kind": "table", + "binding": { + "platform": "gcp", + "format": "bigquery_table", + "location": { "project": "acme-prod", "dataset": "customers", "table": "profiles" } + }, + "contract": { "schema": [{ "name": "customer_id", "type": "string" }] } + } + ], + "retention": { + "runState": "P30D", + "sweepInterval": "P1D" + } + }, + "valid": false, + "errors": [{ "pointer": "/retention", "keyword": "additionalProperties", "detail": "sweepInterval" }] + } + ] +} diff --git a/tests/0.7.5/lifecycle-observability.json b/tests/0.7.5/lifecycle-observability.json new file mode 100644 index 0000000..a7750a3 --- /dev/null +++ b/tests/0.7.5/lifecycle-observability.json @@ -0,0 +1,1154 @@ +{ + "corpusVersion": "0.7.5", + "schema": "https://open-data-protocol.github.io/fluid/schema/fluid-schema-0.7.5.json", + "group": "lifecycle-observability", + "description": "The lifecycle, docs, observability, defaultSLIs and exposeQoS definitions: the unified lifecycleState vocabulary, each of these objects' closed property sets, their nested enums (onBreach type/severity, alert channel kind, default SLI severity), the isoDuration and availability-percentage grammars, and the 0..1 / 0..100 numeric bounds on SLI thresholds and QoS ratios.", + "cases": [ + { + "description": "top-level lifecycle in preview carries tags and labels for automation", + "contract": { + "fluidVersion": "0.7.5", + "kind": "DataProduct", + "id": "customer_profiles", + "name": "Customer Profiles", + "metadata": { "owner": { "team": "customer-analytics" } }, + "lifecycle": { + "state": "preview", + "tags": ["internal-only"], + "labels": { "stage": "beta" } + }, + "exposes": [ + { + "exposeId": "profiles_table", + "kind": "table", + "binding": { + "platform": "gcp", + "format": "bigquery_table", + "location": { "project": "acme-prod", "dataset": "customers", "table": "profiles" } + }, + "contract": { "schema": [{ "name": "customer_id", "type": "string" }] } + } + ] + }, + "valid": true + }, + { + "description": "top-level lifecycle in deprecated state carries a full deprecation policy and retention", + "contract": { + "fluidVersion": "0.7.5", + "kind": "DataProduct", + "id": "customer_profiles", + "name": "Customer Profiles", + "metadata": { "owner": { "team": "customer-analytics" } }, + "lifecycle": { + "state": "deprecated", + "retention": "P2Y", + "deprecationPolicy": { + "noticePeriod": "P30D", + "contact": "data-platform@acme.com", + "replacement": "customer_profiles_v2", + "tags": ["breaking-change"], + "labels": { "migration-owner": "data-platform" } + } + }, + "exposes": [ + { + "exposeId": "profiles_table", + "kind": "table", + "binding": { + "platform": "gcp", + "format": "bigquery_table", + "location": { "project": "acme-prod", "dataset": "customers", "table": "profiles" } + }, + "contract": { "schema": [{ "name": "customer_id", "type": "string" }] } + } + ] + }, + "valid": true + }, + { + "description": "top-level docs links every reference surface alongside a retired lifecycle", + "contract": { + "fluidVersion": "0.7.5", + "kind": "DataProduct", + "id": "customer_profiles", + "name": "Customer Profiles", + "metadata": { "owner": { "team": "customer-analytics" } }, + "lifecycle": { "state": "retired" }, + "docs": { + "homepage": "https://internal.acme.com/data-products/customer-profiles", + "runbook": "https://internal.acme.com/runbooks/customer-profiles", + "dictionary": "https://internal.acme.com/dictionary/customer-profiles", + "changeLog": "https://internal.acme.com/changelog/customer-profiles", + "tags": ["documented"], + "labels": { "docs-owner": "customer-analytics" } + }, + "exposes": [ + { + "exposeId": "profiles_table", + "kind": "table", + "binding": { + "platform": "gcp", + "format": "bigquery_table", + "location": { "project": "acme-prod", "dataset": "customers", "table": "profiles" } + }, + "contract": { "schema": [{ "name": "customer_id", "type": "string" }] } + } + ] + }, + "valid": true + }, + { + "description": "an expose declares an active lifecycle, a full observability configuration, and boundary-value QoS targets", + "contract": { + "fluidVersion": "0.7.5", + "kind": "DataProduct", + "id": "customer_profiles", + "name": "Customer Profiles", + "metadata": { "owner": { "team": "customer-analytics" } }, + "exposes": [ + { + "exposeId": "profiles_table", + "kind": "table", + "binding": { + "platform": "gcp", + "format": "bigquery_table", + "location": { "project": "acme-prod", "dataset": "customers", "table": "profiles" } + }, + "contract": { "schema": [{ "name": "customer_id", "type": "string" }] }, + "lifecycle": { "state": "active" }, + "observability": { + "metrics": [ + { "name": "row_count", "source": "warehouse.metrics.row_count", "sli": "row_count > 0" } + ], + "onBreach": [ + { + "type": "pagerduty", + "channel": "data-platform-oncall", + "target": "customer-profiles-service", + "severity": "critical", + "tags": ["p1"], + "labels": { "escalation": "immediate" } + } + ], + "defaultSLIs": { + "enabled": false, + "freshness": { + "enabled": true, + "threshold": "PT6H", + "severity": "warning", + "tags": ["freshness"], + "labels": { "sla": "gold" } + }, + "completeness": { "enabled": true, "threshold": 1, "severity": "critical" }, + "latency": { "enabled": true, "threshold": "PT5S", "percentile": 0, "severity": "info" }, + "tags": ["default-sli"], + "labels": { "generated-by": "forge-cli" } + }, + "alert": { + "channels": [ + { "kind": "webhook", "url": "https://hooks.acme.com/alerts/customer-profiles" } + ] + }, + "tags": ["observability"], + "labels": { "observed-by": "data-platform" } + }, + "qos": { + "availability": "100%", + "freshnessSLO": "PT15M", + "dataLossSLO": "0 rows", + "latencyP95": "PT5S", + "completenessTarget": 1, + "errorBudget": 0, + "tags": ["sla"], + "labels": { "tier": "gold" } + } + } + ] + }, + "valid": true + }, + { + "description": "lifecycle state uses a value outside the unified vocabulary", + "contract": { + "fluidVersion": "0.7.5", + "kind": "DataProduct", + "id": "customer_profiles", + "name": "Customer Profiles", + "metadata": { "owner": { "team": "customer-analytics" } }, + "lifecycle": { "state": "sunset" }, + "exposes": [ + { + "exposeId": "profiles_table", + "kind": "table", + "binding": { + "platform": "gcp", + "format": "bigquery_table", + "location": { "project": "acme-prod", "dataset": "customers", "table": "profiles" } + }, + "contract": { "schema": [{ "name": "customer_id", "type": "string" }] } + } + ] + }, + "valid": false, + "errors": [{ "pointer": "/lifecycle/state", "keyword": "enum" }] + }, + { + "description": "lifecycle carries an owner field the closed schema does not define", + "contract": { + "fluidVersion": "0.7.5", + "kind": "DataProduct", + "id": "customer_profiles", + "name": "Customer Profiles", + "metadata": { "owner": { "team": "customer-analytics" } }, + "lifecycle": { "state": "active", "owner": "data-platform" }, + "exposes": [ + { + "exposeId": "profiles_table", + "kind": "table", + "binding": { + "platform": "gcp", + "format": "bigquery_table", + "location": { "project": "acme-prod", "dataset": "customers", "table": "profiles" } + }, + "contract": { "schema": [{ "name": "customer_id", "type": "string" }] } + } + ] + }, + "valid": false, + "errors": [{ "pointer": "/lifecycle", "keyword": "additionalProperties", "detail": "owner" }] + }, + { + "description": "lifecycle retention is expressed in prose instead of an ISO-8601 duration", + "contract": { + "fluidVersion": "0.7.5", + "kind": "DataProduct", + "id": "customer_profiles", + "name": "Customer Profiles", + "metadata": { "owner": { "team": "customer-analytics" } }, + "lifecycle": { "state": "active", "retention": "6 months" }, + "exposes": [ + { + "exposeId": "profiles_table", + "kind": "table", + "binding": { + "platform": "gcp", + "format": "bigquery_table", + "location": { "project": "acme-prod", "dataset": "customers", "table": "profiles" } + }, + "contract": { "schema": [{ "name": "customer_id", "type": "string" }] } + } + ] + }, + "valid": false, + "errors": [{ "pointer": "/lifecycle/retention", "keyword": "pattern" }] + }, + { + "description": "deprecation policy carries an slaHours field the closed schema does not define", + "contract": { + "fluidVersion": "0.7.5", + "kind": "DataProduct", + "id": "customer_profiles", + "name": "Customer Profiles", + "metadata": { "owner": { "team": "customer-analytics" } }, + "lifecycle": { + "state": "deprecated", + "deprecationPolicy": { "noticePeriod": "P30D", "slaHours": 72 } + }, + "exposes": [ + { + "exposeId": "profiles_table", + "kind": "table", + "binding": { + "platform": "gcp", + "format": "bigquery_table", + "location": { "project": "acme-prod", "dataset": "customers", "table": "profiles" } + }, + "contract": { "schema": [{ "name": "customer_id", "type": "string" }] } + } + ] + }, + "valid": false, + "errors": [ + { "pointer": "/lifecycle/deprecationPolicy", "keyword": "additionalProperties", "detail": "slaHours" } + ] + }, + { + "description": "docs carries a wiki field the closed schema does not define", + "contract": { + "fluidVersion": "0.7.5", + "kind": "DataProduct", + "id": "customer_profiles", + "name": "Customer Profiles", + "metadata": { "owner": { "team": "customer-analytics" } }, + "docs": { + "homepage": "https://internal.acme.com/data-products/customer-profiles", + "wiki": "https://internal.acme.com/wiki/customer-profiles" + }, + "exposes": [ + { + "exposeId": "profiles_table", + "kind": "table", + "binding": { + "platform": "gcp", + "format": "bigquery_table", + "location": { "project": "acme-prod", "dataset": "customers", "table": "profiles" } + }, + "contract": { "schema": [{ "name": "customer_id", "type": "string" }] } + } + ] + }, + "valid": false, + "errors": [{ "pointer": "/docs", "keyword": "additionalProperties", "detail": "wiki" }] + }, + { + "description": "expose observability carries a dashboards field the closed schema does not define", + "contract": { + "fluidVersion": "0.7.5", + "kind": "DataProduct", + "id": "customer_profiles", + "name": "Customer Profiles", + "metadata": { "owner": { "team": "customer-analytics" } }, + "exposes": [ + { + "exposeId": "profiles_table", + "kind": "table", + "binding": { + "platform": "gcp", + "format": "bigquery_table", + "location": { "project": "acme-prod", "dataset": "customers", "table": "profiles" } + }, + "contract": { "schema": [{ "name": "customer_id", "type": "string" }] }, + "observability": { "dashboards": ["https://grafana.acme.com/customer-profiles"] } + } + ] + }, + "valid": false, + "errors": [ + { "pointer": "/exposes/0/observability", "keyword": "additionalProperties", "detail": "dashboards" } + ] + }, + { + "description": "an observability metric entry omits its name", + "contract": { + "fluidVersion": "0.7.5", + "kind": "DataProduct", + "id": "customer_profiles", + "name": "Customer Profiles", + "metadata": { "owner": { "team": "customer-analytics" } }, + "exposes": [ + { + "exposeId": "profiles_table", + "kind": "table", + "binding": { + "platform": "gcp", + "format": "bigquery_table", + "location": { "project": "acme-prod", "dataset": "customers", "table": "profiles" } + }, + "contract": { "schema": [{ "name": "customer_id", "type": "string" }] }, + "observability": { "metrics": [{ "source": "warehouse.metrics.row_count" }] } + } + ] + }, + "valid": false, + "errors": [ + { "pointer": "/exposes/0/observability/metrics/0", "keyword": "required", "detail": "name" } + ] + }, + { + "description": "an observability metric entry omits its source", + "contract": { + "fluidVersion": "0.7.5", + "kind": "DataProduct", + "id": "customer_profiles", + "name": "Customer Profiles", + "metadata": { "owner": { "team": "customer-analytics" } }, + "exposes": [ + { + "exposeId": "profiles_table", + "kind": "table", + "binding": { + "platform": "gcp", + "format": "bigquery_table", + "location": { "project": "acme-prod", "dataset": "customers", "table": "profiles" } + }, + "contract": { "schema": [{ "name": "customer_id", "type": "string" }] }, + "observability": { "metrics": [{ "name": "row_count" }] } + } + ] + }, + "valid": false, + "errors": [ + { "pointer": "/exposes/0/observability/metrics/0", "keyword": "required", "detail": "source" } + ] + }, + { + "description": "an onBreach notification entry omits its type", + "contract": { + "fluidVersion": "0.7.5", + "kind": "DataProduct", + "id": "customer_profiles", + "name": "Customer Profiles", + "metadata": { "owner": { "team": "customer-analytics" } }, + "exposes": [ + { + "exposeId": "profiles_table", + "kind": "table", + "binding": { + "platform": "gcp", + "format": "bigquery_table", + "location": { "project": "acme-prod", "dataset": "customers", "table": "profiles" } + }, + "contract": { "schema": [{ "name": "customer_id", "type": "string" }] }, + "observability": { "onBreach": [{ "channel": "data-platform-oncall" }] } + } + ] + }, + "valid": false, + "errors": [ + { "pointer": "/exposes/0/observability/onBreach/0", "keyword": "required", "detail": "type" } + ] + }, + { + "description": "an onBreach notification declares a delivery type outside the supported set", + "contract": { + "fluidVersion": "0.7.5", + "kind": "DataProduct", + "id": "customer_profiles", + "name": "Customer Profiles", + "metadata": { "owner": { "team": "customer-analytics" } }, + "exposes": [ + { + "exposeId": "profiles_table", + "kind": "table", + "binding": { + "platform": "gcp", + "format": "bigquery_table", + "location": { "project": "acme-prod", "dataset": "customers", "table": "profiles" } + }, + "contract": { "schema": [{ "name": "customer_id", "type": "string" }] }, + "observability": { "onBreach": [{ "type": "carrier-pigeon" }] } + } + ] + }, + "valid": false, + "errors": [ + { "pointer": "/exposes/0/observability/onBreach/0/type", "keyword": "enum" } + ] + }, + { + "description": "an onBreach notification declares a severity outside the supported set", + "contract": { + "fluidVersion": "0.7.5", + "kind": "DataProduct", + "id": "customer_profiles", + "name": "Customer Profiles", + "metadata": { "owner": { "team": "customer-analytics" } }, + "exposes": [ + { + "exposeId": "profiles_table", + "kind": "table", + "binding": { + "platform": "gcp", + "format": "bigquery_table", + "location": { "project": "acme-prod", "dataset": "customers", "table": "profiles" } + }, + "contract": { "schema": [{ "name": "customer_id", "type": "string" }] }, + "observability": { "onBreach": [{ "type": "slack", "severity": "catastrophic" }] } + } + ] + }, + "valid": false, + "errors": [ + { "pointer": "/exposes/0/observability/onBreach/0/severity", "keyword": "enum" } + ] + }, + { + "description": "an alert dispatcher channel omits its kind", + "contract": { + "fluidVersion": "0.7.5", + "kind": "DataProduct", + "id": "customer_profiles", + "name": "Customer Profiles", + "metadata": { "owner": { "team": "customer-analytics" } }, + "exposes": [ + { + "exposeId": "profiles_table", + "kind": "table", + "binding": { + "platform": "gcp", + "format": "bigquery_table", + "location": { "project": "acme-prod", "dataset": "customers", "table": "profiles" } + }, + "contract": { "schema": [{ "name": "customer_id", "type": "string" }] }, + "observability": { + "alert": { "channels": [{ "url": "https://hooks.acme.com/alerts" }] } + } + } + ] + }, + "valid": false, + "errors": [ + { + "pointer": "/exposes/0/observability/alert/channels/0", + "keyword": "required", + "detail": "kind" + } + ] + }, + { + "description": "an alert dispatcher channel declares a kind outside the supported set", + "contract": { + "fluidVersion": "0.7.5", + "kind": "DataProduct", + "id": "customer_profiles", + "name": "Customer Profiles", + "metadata": { "owner": { "team": "customer-analytics" } }, + "exposes": [ + { + "exposeId": "profiles_table", + "kind": "table", + "binding": { + "platform": "gcp", + "format": "bigquery_table", + "location": { "project": "acme-prod", "dataset": "customers", "table": "profiles" } + }, + "contract": { "schema": [{ "name": "customer_id", "type": "string" }] }, + "observability": { + "alert": { "channels": [{ "kind": "carrier-pigeon" }] } + } + } + ] + }, + "valid": false, + "errors": [ + { "pointer": "/exposes/0/observability/alert/channels/0/kind", "keyword": "enum" } + ] + }, + { + "description": "the default SLI trio carries a throughput field the closed schema does not define", + "contract": { + "fluidVersion": "0.7.5", + "kind": "DataProduct", + "id": "customer_profiles", + "name": "Customer Profiles", + "metadata": { "owner": { "team": "customer-analytics" } }, + "exposes": [ + { + "exposeId": "profiles_table", + "kind": "table", + "binding": { + "platform": "gcp", + "format": "bigquery_table", + "location": { "project": "acme-prod", "dataset": "customers", "table": "profiles" } + }, + "contract": { "schema": [{ "name": "customer_id", "type": "string" }] }, + "observability": { + "defaultSLIs": { "enabled": true, "throughput": { "enabled": true } } + } + } + ] + }, + "valid": false, + "errors": [ + { + "pointer": "/exposes/0/observability/defaultSLIs", + "keyword": "additionalProperties", + "detail": "throughput" + } + ] + }, + { + "description": "the default freshness SLI carries a window field the closed schema does not define", + "contract": { + "fluidVersion": "0.7.5", + "kind": "DataProduct", + "id": "customer_profiles", + "name": "Customer Profiles", + "metadata": { "owner": { "team": "customer-analytics" } }, + "exposes": [ + { + "exposeId": "profiles_table", + "kind": "table", + "binding": { + "platform": "gcp", + "format": "bigquery_table", + "location": { "project": "acme-prod", "dataset": "customers", "table": "profiles" } + }, + "contract": { "schema": [{ "name": "customer_id", "type": "string" }] }, + "observability": { + "defaultSLIs": { "freshness": { "enabled": true, "window": "PT6H" } } + } + } + ] + }, + "valid": false, + "errors": [ + { + "pointer": "/exposes/0/observability/defaultSLIs/freshness", + "keyword": "additionalProperties", + "detail": "window" + } + ] + }, + { + "description": "the default freshness SLI declares a severity outside the supported set", + "contract": { + "fluidVersion": "0.7.5", + "kind": "DataProduct", + "id": "customer_profiles", + "name": "Customer Profiles", + "metadata": { "owner": { "team": "customer-analytics" } }, + "exposes": [ + { + "exposeId": "profiles_table", + "kind": "table", + "binding": { + "platform": "gcp", + "format": "bigquery_table", + "location": { "project": "acme-prod", "dataset": "customers", "table": "profiles" } + }, + "contract": { "schema": [{ "name": "customer_id", "type": "string" }] }, + "observability": { + "defaultSLIs": { "freshness": { "severity": "catastrophic" } } + } + } + ] + }, + "valid": false, + "errors": [ + { + "pointer": "/exposes/0/observability/defaultSLIs/freshness/severity", + "keyword": "enum" + } + ] + }, + { + "description": "the default completeness SLI threshold is negative", + "contract": { + "fluidVersion": "0.7.5", + "kind": "DataProduct", + "id": "customer_profiles", + "name": "Customer Profiles", + "metadata": { "owner": { "team": "customer-analytics" } }, + "exposes": [ + { + "exposeId": "profiles_table", + "kind": "table", + "binding": { + "platform": "gcp", + "format": "bigquery_table", + "location": { "project": "acme-prod", "dataset": "customers", "table": "profiles" } + }, + "contract": { "schema": [{ "name": "customer_id", "type": "string" }] }, + "observability": { + "defaultSLIs": { "completeness": { "threshold": -0.1 } } + } + } + ] + }, + "valid": false, + "errors": [ + { + "pointer": "/exposes/0/observability/defaultSLIs/completeness/threshold", + "keyword": "minimum" + } + ] + }, + { + "description": "the default completeness SLI threshold exceeds a full ratio", + "contract": { + "fluidVersion": "0.7.5", + "kind": "DataProduct", + "id": "customer_profiles", + "name": "Customer Profiles", + "metadata": { "owner": { "team": "customer-analytics" } }, + "exposes": [ + { + "exposeId": "profiles_table", + "kind": "table", + "binding": { + "platform": "gcp", + "format": "bigquery_table", + "location": { "project": "acme-prod", "dataset": "customers", "table": "profiles" } + }, + "contract": { "schema": [{ "name": "customer_id", "type": "string" }] }, + "observability": { + "defaultSLIs": { "completeness": { "threshold": 1.5 } } + } + } + ] + }, + "valid": false, + "errors": [ + { + "pointer": "/exposes/0/observability/defaultSLIs/completeness/threshold", + "keyword": "maximum" + } + ] + }, + { + "description": "the default latency SLI percentile exceeds 100", + "contract": { + "fluidVersion": "0.7.5", + "kind": "DataProduct", + "id": "customer_profiles", + "name": "Customer Profiles", + "metadata": { "owner": { "team": "customer-analytics" } }, + "exposes": [ + { + "exposeId": "profiles_table", + "kind": "table", + "binding": { + "platform": "gcp", + "format": "bigquery_table", + "location": { "project": "acme-prod", "dataset": "customers", "table": "profiles" } + }, + "contract": { "schema": [{ "name": "customer_id", "type": "string" }] }, + "observability": { + "defaultSLIs": { "latency": { "percentile": 101 } } + } + } + ] + }, + "valid": false, + "errors": [ + { + "pointer": "/exposes/0/observability/defaultSLIs/latency/percentile", + "keyword": "maximum" + } + ] + }, + { + "description": "expose qos carries an sla field the closed schema does not define", + "contract": { + "fluidVersion": "0.7.5", + "kind": "DataProduct", + "id": "customer_profiles", + "name": "Customer Profiles", + "metadata": { "owner": { "team": "customer-analytics" } }, + "exposes": [ + { + "exposeId": "profiles_table", + "kind": "table", + "binding": { + "platform": "gcp", + "format": "bigquery_table", + "location": { "project": "acme-prod", "dataset": "customers", "table": "profiles" } + }, + "contract": { "schema": [{ "name": "customer_id", "type": "string" }] }, + "qos": { "availability": "99.9%", "sla": "gold" } + } + ] + }, + "valid": false, + "errors": [{ "pointer": "/exposes/0/qos", "keyword": "additionalProperties", "detail": "sla" }] + }, + { + "description": "expose qos availability exceeds the percentage grammar", + "contract": { + "fluidVersion": "0.7.5", + "kind": "DataProduct", + "id": "customer_profiles", + "name": "Customer Profiles", + "metadata": { "owner": { "team": "customer-analytics" } }, + "exposes": [ + { + "exposeId": "profiles_table", + "kind": "table", + "binding": { + "platform": "gcp", + "format": "bigquery_table", + "location": { "project": "acme-prod", "dataset": "customers", "table": "profiles" } + }, + "contract": { "schema": [{ "name": "customer_id", "type": "string" }] }, + "qos": { "availability": "150%" } + } + ] + }, + "valid": false, + "errors": [{ "pointer": "/exposes/0/qos/availability", "keyword": "pattern" }] + }, + { + "description": "expose qos completeness target exceeds a full ratio", + "contract": { + "fluidVersion": "0.7.5", + "kind": "DataProduct", + "id": "customer_profiles", + "name": "Customer Profiles", + "metadata": { "owner": { "team": "customer-analytics" } }, + "exposes": [ + { + "exposeId": "profiles_table", + "kind": "table", + "binding": { + "platform": "gcp", + "format": "bigquery_table", + "location": { "project": "acme-prod", "dataset": "customers", "table": "profiles" } + }, + "contract": { "schema": [{ "name": "customer_id", "type": "string" }] }, + "qos": { "completenessTarget": 1.2 } + } + ] + }, + "valid": false, + "errors": [{ "pointer": "/exposes/0/qos/completenessTarget", "keyword": "maximum" }] + }, + { + "description": "expose qos error budget is negative", + "contract": { + "fluidVersion": "0.7.5", + "kind": "DataProduct", + "id": "customer_profiles", + "name": "Customer Profiles", + "metadata": { "owner": { "team": "customer-analytics" } }, + "exposes": [ + { + "exposeId": "profiles_table", + "kind": "table", + "binding": { + "platform": "gcp", + "format": "bigquery_table", + "location": { "project": "acme-prod", "dataset": "customers", "table": "profiles" } + }, + "contract": { "schema": [{ "name": "customer_id", "type": "string" }] }, + "qos": { "errorBudget": -0.05 } + } + ] + }, + "valid": false, + "errors": [{ "pointer": "/exposes/0/qos/errorBudget", "keyword": "minimum" }] + }, + { + "description": "expose qos error budget exceeds a full ratio", + "contract": { + "fluidVersion": "0.7.5", + "kind": "DataProduct", + "id": "customer_profiles", + "name": "Customer Profiles", + "metadata": { "owner": { "team": "customer-analytics" } }, + "exposes": [ + { + "exposeId": "profiles_table", + "kind": "table", + "binding": { + "platform": "gcp", + "format": "bigquery_table", + "location": { "project": "acme-prod", "dataset": "customers", "table": "profiles" } + }, + "contract": { "schema": [{ "name": "customer_id", "type": "string" }] }, + "qos": { "errorBudget": 1.5 } + } + ] + }, + "valid": false, + "errors": [{ "pointer": "/exposes/0/qos/errorBudget", "keyword": "maximum" }] + }, + { + "description": "expose qos completeness target is negative", + "contract": { + "fluidVersion": "0.7.5", + "kind": "DataProduct", + "id": "customer_profiles", + "name": "Customer Profiles", + "metadata": { "owner": { "team": "customer-analytics" } }, + "exposes": [ + { + "exposeId": "profiles_table", + "kind": "table", + "binding": { + "platform": "gcp", + "format": "bigquery_table", + "location": { "project": "acme-prod", "dataset": "customers", "table": "profiles" } + }, + "contract": { "schema": [{ "name": "customer_id", "type": "string" }] }, + "qos": { "completenessTarget": -0.1 } + } + ] + }, + "valid": false, + "errors": [{ "pointer": "/exposes/0/qos/completenessTarget", "keyword": "minimum" }] + }, + { + "description": "expose qos p95 latency target is expressed in prose instead of an ISO-8601 duration", + "contract": { + "fluidVersion": "0.7.5", + "kind": "DataProduct", + "id": "customer_profiles", + "name": "Customer Profiles", + "metadata": { "owner": { "team": "customer-analytics" } }, + "exposes": [ + { + "exposeId": "profiles_table", + "kind": "table", + "binding": { + "platform": "gcp", + "format": "bigquery_table", + "location": { "project": "acme-prod", "dataset": "customers", "table": "profiles" } + }, + "contract": { "schema": [{ "name": "customer_id", "type": "string" }] }, + "qos": { "latencyP95": "5 seconds" } + } + ] + }, + "valid": false, + "errors": [{ "pointer": "/exposes/0/qos/latencyP95", "keyword": "pattern" }] + }, + { + "description": "deprecation policy notice period is expressed in prose instead of an ISO-8601 duration", + "contract": { + "fluidVersion": "0.7.5", + "kind": "DataProduct", + "id": "customer_profiles", + "name": "Customer Profiles", + "metadata": { "owner": { "team": "customer-analytics" } }, + "lifecycle": { + "state": "deprecated", + "deprecationPolicy": { "noticePeriod": "30 days" } + }, + "exposes": [ + { + "exposeId": "profiles_table", + "kind": "table", + "binding": { + "platform": "gcp", + "format": "bigquery_table", + "location": { "project": "acme-prod", "dataset": "customers", "table": "profiles" } + }, + "contract": { "schema": [{ "name": "customer_id", "type": "string" }] } + } + ] + }, + "valid": false, + "errors": [{ "pointer": "/lifecycle/deprecationPolicy/noticePeriod", "keyword": "pattern" }] + }, + { + "description": "the default freshness SLI threshold is expressed in prose instead of an ISO-8601 duration", + "contract": { + "fluidVersion": "0.7.5", + "kind": "DataProduct", + "id": "customer_profiles", + "name": "Customer Profiles", + "metadata": { "owner": { "team": "customer-analytics" } }, + "exposes": [ + { + "exposeId": "profiles_table", + "kind": "table", + "binding": { + "platform": "gcp", + "format": "bigquery_table", + "location": { "project": "acme-prod", "dataset": "customers", "table": "profiles" } + }, + "contract": { "schema": [{ "name": "customer_id", "type": "string" }] }, + "observability": { + "defaultSLIs": { "freshness": { "threshold": "6 hours" } } + } + } + ] + }, + "valid": false, + "errors": [ + { + "pointer": "/exposes/0/observability/defaultSLIs/freshness/threshold", + "keyword": "pattern" + } + ] + }, + { + "description": "the default latency SLI threshold is expressed in prose instead of an ISO-8601 duration", + "contract": { + "fluidVersion": "0.7.5", + "kind": "DataProduct", + "id": "customer_profiles", + "name": "Customer Profiles", + "metadata": { "owner": { "team": "customer-analytics" } }, + "exposes": [ + { + "exposeId": "profiles_table", + "kind": "table", + "binding": { + "platform": "gcp", + "format": "bigquery_table", + "location": { "project": "acme-prod", "dataset": "customers", "table": "profiles" } + }, + "contract": { "schema": [{ "name": "customer_id", "type": "string" }] }, + "observability": { + "defaultSLIs": { "latency": { "threshold": "5 seconds" } } + } + } + ] + }, + "valid": false, + "errors": [ + { + "pointer": "/exposes/0/observability/defaultSLIs/latency/threshold", + "keyword": "pattern" + } + ] + }, + { + "description": "the default latency SLI percentile is negative", + "contract": { + "fluidVersion": "0.7.5", + "kind": "DataProduct", + "id": "customer_profiles", + "name": "Customer Profiles", + "metadata": { "owner": { "team": "customer-analytics" } }, + "exposes": [ + { + "exposeId": "profiles_table", + "kind": "table", + "binding": { + "platform": "gcp", + "format": "bigquery_table", + "location": { "project": "acme-prod", "dataset": "customers", "table": "profiles" } + }, + "contract": { "schema": [{ "name": "customer_id", "type": "string" }] }, + "observability": { + "defaultSLIs": { "latency": { "percentile": -1 } } + } + } + ] + }, + "valid": false, + "errors": [ + { + "pointer": "/exposes/0/observability/defaultSLIs/latency/percentile", + "keyword": "minimum" + } + ] + }, + { + "description": "an observability metric entry carries a unit field the closed schema does not define", + "contract": { + "fluidVersion": "0.7.5", + "kind": "DataProduct", + "id": "customer_profiles", + "name": "Customer Profiles", + "metadata": { "owner": { "team": "customer-analytics" } }, + "exposes": [ + { + "exposeId": "profiles_table", + "kind": "table", + "binding": { + "platform": "gcp", + "format": "bigquery_table", + "location": { "project": "acme-prod", "dataset": "customers", "table": "profiles" } + }, + "contract": { "schema": [{ "name": "customer_id", "type": "string" }] }, + "observability": { + "metrics": [ + { "name": "row_count", "source": "warehouse.metrics.row_count", "unit": "rows" } + ] + } + } + ] + }, + "valid": false, + "errors": [ + { + "pointer": "/exposes/0/observability/metrics/0", + "keyword": "additionalProperties", + "detail": "unit" + } + ] + }, + { + "description": "an onBreach notification entry carries a retries field the closed schema does not define", + "contract": { + "fluidVersion": "0.7.5", + "kind": "DataProduct", + "id": "customer_profiles", + "name": "Customer Profiles", + "metadata": { "owner": { "team": "customer-analytics" } }, + "exposes": [ + { + "exposeId": "profiles_table", + "kind": "table", + "binding": { + "platform": "gcp", + "format": "bigquery_table", + "location": { "project": "acme-prod", "dataset": "customers", "table": "profiles" } + }, + "contract": { "schema": [{ "name": "customer_id", "type": "string" }] }, + "observability": { + "onBreach": [{ "type": "slack", "retries": 3 }] + } + } + ] + }, + "valid": false, + "errors": [ + { + "pointer": "/exposes/0/observability/onBreach/0", + "keyword": "additionalProperties", + "detail": "retries" + } + ] + }, + { + "description": "the alert dispatcher config carries an enabled field the closed schema does not define", + "contract": { + "fluidVersion": "0.7.5", + "kind": "DataProduct", + "id": "customer_profiles", + "name": "Customer Profiles", + "metadata": { "owner": { "team": "customer-analytics" } }, + "exposes": [ + { + "exposeId": "profiles_table", + "kind": "table", + "binding": { + "platform": "gcp", + "format": "bigquery_table", + "location": { "project": "acme-prod", "dataset": "customers", "table": "profiles" } + }, + "contract": { "schema": [{ "name": "customer_id", "type": "string" }] }, + "observability": { + "alert": { "enabled": true } + } + } + ] + }, + "valid": false, + "errors": [ + { "pointer": "/exposes/0/observability/alert", "keyword": "additionalProperties", "detail": "enabled" } + ] + }, + { + "description": "an alert dispatcher channel carries a retryPolicy field the closed schema does not define", + "contract": { + "fluidVersion": "0.7.5", + "kind": "DataProduct", + "id": "customer_profiles", + "name": "Customer Profiles", + "metadata": { "owner": { "team": "customer-analytics" } }, + "exposes": [ + { + "exposeId": "profiles_table", + "kind": "table", + "binding": { + "platform": "gcp", + "format": "bigquery_table", + "location": { "project": "acme-prod", "dataset": "customers", "table": "profiles" } + }, + "contract": { "schema": [{ "name": "customer_id", "type": "string" }] }, + "observability": { + "alert": { + "channels": [{ "kind": "webhook", "retryPolicy": "exponential" }] + } + } + } + ] + }, + "valid": false, + "errors": [ + { + "pointer": "/exposes/0/observability/alert/channels/0", + "keyword": "additionalProperties", + "detail": "retryPolicy" + } + ] + } + ] +} diff --git a/tests/0.7.5/metadata.json b/tests/0.7.5/metadata.json new file mode 100644 index 0000000..70cff4b --- /dev/null +++ b/tests/0.7.5/metadata.json @@ -0,0 +1,469 @@ +{ + "corpusVersion": "0.7.5", + "schema": "https://open-data-protocol.github.io/fluid/schema/fluid-schema-0.7.5.json", + "group": "metadata", + "description": "The metadata object: the required owner member, metadata's closed property set, the productType and classification enums, the closed owner object (team/email/slack/oncall), the closed businessContext object, and the experimental array's uniqueItems constraint. Format-assertion cases for owner.email and createdAt (format is an annotation, not an assertion, in Draft 2020-12) live in tests/optional/0.7.5/metadata.json.", + "cases": [ + { + "description": "metadata populated with every member of its closed property set", + "contract": { + "fluidVersion": "0.7.5", + "kind": "DataProduct", + "id": "customer_profiles", + "name": "Customer Profiles", + "metadata": { + "provenance": { + "schema_version": 1, + "kind": "ContractMetadata", + "generated_at": "2024-03-01T12:00:00Z", + "generated_by": { "tool": "fluid", "version": "0.7.5", "command": "fluid init" } + }, + "layer": "Silver", + "productType": "ADP", + "classification": "confidential", + "experimental": ["acquisition"], + "owner": { + "team": "customer-analytics", + "email": "customer-analytics@acme.com", + "slack": "#customer-analytics", + "oncall": "customer-analytics-oncall" + }, + "createdAt": "2024-03-01T12:00:00Z", + "businessContext": { + "domain": "customer", + "subdomain": "profiles", + "businessCapability": "customer-360", + "valueStream": "acquisition" + }, + "tags": ["customer-data", "pii"] + }, + "exposes": [ + { + "exposeId": "profiles_table", + "kind": "table", + "binding": { + "platform": "gcp", + "format": "bigquery_table", + "location": { "project": "acme-prod", "dataset": "customers", "table": "profiles" } + }, + "contract": { "schema": [{ "name": "customer_id", "type": "string" }] } + } + ] + }, + "valid": true + }, + { + "description": "owner present as an empty object with none of its optional members set", + "contract": { + "fluidVersion": "0.7.5", + "kind": "DataProduct", + "id": "customer_profiles", + "name": "Customer Profiles", + "metadata": { "owner": {} }, + "exposes": [ + { + "exposeId": "profiles_table", + "kind": "table", + "binding": { + "platform": "gcp", + "format": "bigquery_table", + "location": { "project": "acme-prod", "dataset": "customers", "table": "profiles" } + }, + "contract": { "schema": [{ "name": "customer_id", "type": "string" }] } + } + ] + }, + "valid": true + }, + { + "description": "owner carries slack and oncall but omits team and email", + "contract": { + "fluidVersion": "0.7.5", + "kind": "DataProduct", + "id": "customer_profiles", + "name": "Customer Profiles", + "metadata": { + "owner": { "slack": "#customer-analytics", "oncall": "pd-customer-analytics" } + }, + "exposes": [ + { + "exposeId": "profiles_table", + "kind": "table", + "binding": { + "platform": "gcp", + "format": "bigquery_table", + "location": { "project": "acme-prod", "dataset": "customers", "table": "profiles" } + }, + "contract": { "schema": [{ "name": "customer_id", "type": "string" }] } + } + ] + }, + "valid": true + }, + { + "description": "productType set to SDP with no layer present", + "contract": { + "fluidVersion": "0.7.5", + "kind": "DataProduct", + "id": "customer_profiles", + "name": "Customer Profiles", + "metadata": { "owner": { "team": "ingestion" }, "productType": "SDP" }, + "exposes": [ + { + "exposeId": "profiles_table", + "kind": "table", + "binding": { + "platform": "gcp", + "format": "bigquery_table", + "location": { "project": "acme-prod", "dataset": "customers", "table": "profiles" } + }, + "contract": { "schema": [{ "name": "customer_id", "type": "string" }] } + } + ] + }, + "valid": true + }, + { + "description": "productType set to CDP with no layer present", + "contract": { + "fluidVersion": "0.7.5", + "kind": "DataProduct", + "id": "customer_profiles", + "name": "Customer Profiles", + "metadata": { "owner": { "team": "analytics" }, "productType": "CDP" }, + "exposes": [ + { + "exposeId": "profiles_table", + "kind": "table", + "binding": { + "platform": "gcp", + "format": "bigquery_table", + "location": { "project": "acme-prod", "dataset": "customers", "table": "profiles" } + }, + "contract": { "schema": [{ "name": "customer_id", "type": "string" }] } + } + ] + }, + "valid": true + }, + { + "description": "classification set to public, the least restrictive value", + "contract": { + "fluidVersion": "0.7.5", + "kind": "DataProduct", + "id": "customer_profiles", + "name": "Customer Profiles", + "metadata": { "owner": { "team": "analytics" }, "classification": "public" }, + "exposes": [ + { + "exposeId": "profiles_table", + "kind": "table", + "binding": { + "platform": "gcp", + "format": "bigquery_table", + "location": { "project": "acme-prod", "dataset": "customers", "table": "profiles" } + }, + "contract": { "schema": [{ "name": "customer_id", "type": "string" }] } + } + ] + }, + "valid": true + }, + { + "description": "classification set to restricted alongside empty experimental and tags arrays", + "contract": { + "fluidVersion": "0.7.5", + "kind": "DataProduct", + "id": "customer_profiles", + "name": "Customer Profiles", + "metadata": { + "owner": { "team": "analytics" }, + "classification": "restricted", + "experimental": [], + "tags": [] + }, + "exposes": [ + { + "exposeId": "profiles_table", + "kind": "table", + "binding": { + "platform": "gcp", + "format": "bigquery_table", + "location": { "project": "acme-prod", "dataset": "customers", "table": "profiles" } + }, + "contract": { "schema": [{ "name": "customer_id", "type": "string" }] } + } + ] + }, + "valid": true + }, + { + "description": "layer set to Bronze with no productType present", + "contract": { + "fluidVersion": "0.7.5", + "kind": "DataProduct", + "id": "customer_profiles", + "name": "Customer Profiles", + "metadata": { "owner": { "team": "ingestion" }, "layer": "Bronze" }, + "exposes": [ + { + "exposeId": "profiles_table", + "kind": "table", + "binding": { + "platform": "gcp", + "format": "bigquery_table", + "location": { "project": "acme-prod", "dataset": "customers", "table": "profiles" } + }, + "contract": { "schema": [{ "name": "customer_id", "type": "string" }] } + } + ] + }, + "valid": true + }, + { + "description": "classification set to internal", + "contract": { + "fluidVersion": "0.7.5", + "kind": "DataProduct", + "id": "customer_profiles", + "name": "Customer Profiles", + "metadata": { "owner": { "team": "analytics" }, "classification": "internal" }, + "exposes": [ + { + "exposeId": "profiles_table", + "kind": "table", + "binding": { + "platform": "gcp", + "format": "bigquery_table", + "location": { "project": "acme-prod", "dataset": "customers", "table": "profiles" } + }, + "contract": { "schema": [{ "name": "customer_id", "type": "string" }] } + } + ] + }, + "valid": true + }, + { + "description": "metadata is an empty object, never supplying owner", + "contract": { + "fluidVersion": "0.7.5", + "kind": "DataProduct", + "id": "customer_profiles", + "name": "Customer Profiles", + "metadata": {}, + "exposes": [ + { + "exposeId": "profiles_table", + "kind": "table", + "binding": { + "platform": "gcp", + "format": "bigquery_table", + "location": { "project": "acme-prod", "dataset": "customers", "table": "profiles" } + }, + "contract": { "schema": [{ "name": "customer_id", "type": "string" }] } + } + ] + }, + "valid": false, + "errors": [{ "pointer": "/metadata", "keyword": "required", "detail": "owner" }] + }, + { + "description": "metadata carries an unrecognized description property alongside owner", + "contract": { + "fluidVersion": "0.7.5", + "kind": "DataProduct", + "id": "customer_profiles", + "name": "Customer Profiles", + "metadata": { "owner": { "team": "customer-analytics" }, "description": "raw customer profile records" }, + "exposes": [ + { + "exposeId": "profiles_table", + "kind": "table", + "binding": { + "platform": "gcp", + "format": "bigquery_table", + "location": { "project": "acme-prod", "dataset": "customers", "table": "profiles" } + }, + "contract": { "schema": [{ "name": "customer_id", "type": "string" }] } + } + ] + }, + "valid": false, + "errors": [{ "pointer": "/metadata", "keyword": "additionalProperties", "detail": "description" }] + }, + { + "description": "owner carries an unrecognized phone property", + "contract": { + "fluidVersion": "0.7.5", + "kind": "DataProduct", + "id": "customer_profiles", + "name": "Customer Profiles", + "metadata": { "owner": { "team": "customer-analytics", "phone": "+1-555-0100" } }, + "exposes": [ + { + "exposeId": "profiles_table", + "kind": "table", + "binding": { + "platform": "gcp", + "format": "bigquery_table", + "location": { "project": "acme-prod", "dataset": "customers", "table": "profiles" } + }, + "contract": { "schema": [{ "name": "customer_id", "type": "string" }] } + } + ] + }, + "valid": false, + "errors": [{ "pointer": "/metadata/owner", "keyword": "additionalProperties", "detail": "phone" }] + }, + { + "description": "productType set to a lowercase value outside the enum", + "contract": { + "fluidVersion": "0.7.5", + "kind": "DataProduct", + "id": "customer_profiles", + "name": "Customer Profiles", + "metadata": { "owner": { "team": "ingestion" }, "productType": "sdp" }, + "exposes": [ + { + "exposeId": "profiles_table", + "kind": "table", + "binding": { + "platform": "gcp", + "format": "bigquery_table", + "location": { "project": "acme-prod", "dataset": "customers", "table": "profiles" } + }, + "contract": { "schema": [{ "name": "customer_id", "type": "string" }] } + } + ] + }, + "valid": false, + "errors": [{ "pointer": "/metadata/productType", "keyword": "enum" }] + }, + { + "description": "classification set to a value outside the enum", + "contract": { + "fluidVersion": "0.7.5", + "kind": "DataProduct", + "id": "customer_profiles", + "name": "Customer Profiles", + "metadata": { "owner": { "team": "analytics" }, "classification": "secret" }, + "exposes": [ + { + "exposeId": "profiles_table", + "kind": "table", + "binding": { + "platform": "gcp", + "format": "bigquery_table", + "location": { "project": "acme-prod", "dataset": "customers", "table": "profiles" } + }, + "contract": { "schema": [{ "name": "customer_id", "type": "string" }] } + } + ] + }, + "valid": false, + "errors": [{ "pointer": "/metadata/classification", "keyword": "enum" }] + }, + { + "description": "owner is a plain string rather than an object", + "contract": { + "fluidVersion": "0.7.5", + "kind": "DataProduct", + "id": "customer_profiles", + "name": "Customer Profiles", + "metadata": { "owner": "customer-analytics" }, + "exposes": [ + { + "exposeId": "profiles_table", + "kind": "table", + "binding": { + "platform": "gcp", + "format": "bigquery_table", + "location": { "project": "acme-prod", "dataset": "customers", "table": "profiles" } + }, + "contract": { "schema": [{ "name": "customer_id", "type": "string" }] } + } + ] + }, + "valid": false, + "errors": [{ "pointer": "/metadata/owner", "keyword": "type" }] + }, + { + "description": "metadata is a string rather than an object", + "contract": { + "fluidVersion": "0.7.5", + "kind": "DataProduct", + "id": "customer_profiles", + "name": "Customer Profiles", + "metadata": "owned-by-customer-analytics", + "exposes": [ + { + "exposeId": "profiles_table", + "kind": "table", + "binding": { + "platform": "gcp", + "format": "bigquery_table", + "location": { "project": "acme-prod", "dataset": "customers", "table": "profiles" } + }, + "contract": { "schema": [{ "name": "customer_id", "type": "string" }] } + } + ] + }, + "valid": false, + "errors": [{ "pointer": "/metadata", "keyword": "type" }] + }, + { + "description": "businessContext carries an unrecognized region property", + "contract": { + "fluidVersion": "0.7.5", + "kind": "DataProduct", + "id": "customer_profiles", + "name": "Customer Profiles", + "metadata": { + "owner": { "team": "customer-analytics" }, + "businessContext": { "domain": "customer", "region": "us-east" } + }, + "exposes": [ + { + "exposeId": "profiles_table", + "kind": "table", + "binding": { + "platform": "gcp", + "format": "bigquery_table", + "location": { "project": "acme-prod", "dataset": "customers", "table": "profiles" } + }, + "contract": { "schema": [{ "name": "customer_id", "type": "string" }] } + } + ] + }, + "valid": false, + "errors": [{ "pointer": "/metadata/businessContext", "keyword": "additionalProperties", "detail": "region" }] + }, + { + "description": "experimental repeats the same feature gate twice", + "contract": { + "fluidVersion": "0.7.5", + "kind": "DataProduct", + "id": "customer_profiles", + "name": "Customer Profiles", + "metadata": { + "owner": { "team": "customer-analytics" }, + "experimental": ["acquisition", "acquisition"] + }, + "exposes": [ + { + "exposeId": "profiles_table", + "kind": "table", + "binding": { + "platform": "gcp", + "format": "bigquery_table", + "location": { "project": "acme-prod", "dataset": "customers", "table": "profiles" } + }, + "contract": { "schema": [{ "name": "customer_id", "type": "string" }] } + } + ] + }, + "valid": false, + "errors": [{ "pointer": "/metadata/experimental", "keyword": "uniqueItems" }] + } + ] +} diff --git a/tests/0.7.5/primitives.json b/tests/0.7.5/primitives.json new file mode 100644 index 0000000..76eaed9 --- /dev/null +++ b/tests/0.7.5/primitives.json @@ -0,0 +1,746 @@ +{ + "corpusVersion": "0.7.5", + "schema": "https://open-data-protocol.github.io/fluid/schema/fluid-schema-0.7.5.json", + "group": "primitives", + "description": "The shared primitive grammars referenced throughout the document: identifier, semver, semverRange, tags, labels, isoDuration, availabilityPct and lifecycleState. Each is exercised at one or more of its real usage sites (exposeId, expose.version, qos, lifecycle, consumes), not only where the envelope group already pins it down.", + "cases": [ + { + "description": "a document exercising tags, labels, semver with prerelease and build metadata, QoS availability and freshness, lifecycle state and retention, and a consumed version constraint together", + "contract": { + "fluidVersion": "0.7.5", + "kind": "DataProduct", + "id": "customer_profiles", + "name": "Customer Profiles", + "tags": ["customer-data", "pii"], + "labels": { "team": "customer-analytics", "cost-center": "engineering" }, + "metadata": { + "owner": { "team": "customer-analytics" }, + "tags": ["gold-tier"] + }, + "exposes": [ + { + "exposeId": "profiles_table", + "kind": "table", + "version": "2.1.0-beta.1+exp.sha.abc123", + "tags": ["real-time"], + "labels": { "region": "us-central1" }, + "binding": { + "platform": "gcp", + "format": "bigquery_table", + "location": { "project": "acme-prod", "dataset": "customers", "table": "profiles" } + }, + "contract": { "schema": [{ "name": "customer_id", "type": "string" }] }, + "qos": { + "availability": "99.95%", + "freshnessSLO": "PT15M" + }, + "lifecycle": { + "state": "active", + "retention": "P365D", + "deprecationPolicy": { "noticePeriod": "P30D" } + } + } + ], + "consumes": [ + { "productId": "silver.hr.people", "exposeId": "people_v2", "versionConstraint": "^1.2.0" } + ] + }, + "valid": true + }, + { + "description": "top-level tags is an empty array and top-level labels is an empty object", + "contract": { + "fluidVersion": "0.7.5", + "kind": "DataProduct", + "id": "customer_profiles", + "name": "Customer Profiles", + "tags": [], + "labels": {}, + "metadata": { "owner": { "team": "customer-analytics" } }, + "exposes": [ + { + "exposeId": "profiles_table", + "kind": "table", + "binding": { + "platform": "gcp", + "format": "bigquery_table", + "location": { "project": "acme-prod", "dataset": "customers", "table": "profiles" } + }, + "contract": { "schema": [{ "name": "customer_id", "type": "string" }] } + } + ] + }, + "valid": true + }, + { + "description": "QoS availability sits at the exact 100 percent upper boundary", + "contract": { + "fluidVersion": "0.7.5", + "kind": "DataProduct", + "id": "customer_profiles", + "name": "Customer Profiles", + "metadata": { "owner": { "team": "customer-analytics" } }, + "exposes": [ + { + "exposeId": "profiles_table", + "kind": "table", + "binding": { + "platform": "gcp", + "format": "bigquery_table", + "location": { "project": "acme-prod", "dataset": "customers", "table": "profiles" } + }, + "contract": { "schema": [{ "name": "customer_id", "type": "string" }] }, + "qos": { "availability": "100%" } + } + ] + }, + "valid": true + }, + { + "description": "lifecycle retention combines date and time units in one ISO-8601 duration", + "contract": { + "fluidVersion": "0.7.5", + "kind": "DataProduct", + "id": "customer_profiles", + "name": "Customer Profiles", + "metadata": { "owner": { "team": "customer-analytics" } }, + "exposes": [ + { + "exposeId": "profiles_table", + "kind": "table", + "binding": { + "platform": "gcp", + "format": "bigquery_table", + "location": { "project": "acme-prod", "dataset": "customers", "table": "profiles" } + }, + "contract": { "schema": [{ "name": "customer_id", "type": "string" }] }, + "lifecycle": { "retention": "P1DT12H" } + } + ] + }, + "valid": true + }, + { + "description": "a consumed product id mixes an internal dot and hyphen in the identifier's middle", + "contract": { + "fluidVersion": "0.7.5", + "kind": "DataProduct", + "id": "customer_profiles", + "name": "Customer Profiles", + "metadata": { "owner": { "team": "customer-analytics" } }, + "exposes": [ + { + "exposeId": "profiles_table", + "kind": "table", + "binding": { + "platform": "gcp", + "format": "bigquery_table", + "location": { "project": "acme-prod", "dataset": "customers", "table": "profiles" } + }, + "contract": { "schema": [{ "name": "customer_id", "type": "string" }] } + } + ], + "consumes": [ + { "productId": "silver.hr.people-v2", "exposeId": "people_v2" } + ] + }, + "valid": true + }, + { + "description": "the expose id is a single underscore character", + "contract": { + "fluidVersion": "0.7.5", + "kind": "DataProduct", + "id": "customer_profiles", + "name": "Customer Profiles", + "metadata": { "owner": { "team": "customer-analytics" } }, + "exposes": [ + { + "exposeId": "_", + "kind": "table", + "binding": { + "platform": "gcp", + "format": "bigquery_table", + "location": { "project": "acme-prod", "dataset": "customers", "table": "profiles" } + }, + "contract": { "schema": [{ "name": "customer_id", "type": "string" }] } + } + ] + }, + "valid": true + }, + { + "description": "a consumed version constraint uses a compound range expression rather than a bare semver", + "contract": { + "fluidVersion": "0.7.5", + "kind": "DataProduct", + "id": "customer_profiles", + "name": "Customer Profiles", + "metadata": { "owner": { "team": "customer-analytics" } }, + "exposes": [ + { + "exposeId": "profiles_table", + "kind": "table", + "binding": { + "platform": "gcp", + "format": "bigquery_table", + "location": { "project": "acme-prod", "dataset": "customers", "table": "profiles" } + }, + "contract": { "schema": [{ "name": "customer_id", "type": "string" }] } + } + ], + "consumes": [ + { "productId": "upstream_product", "exposeId": "upstream_expose", "versionConstraint": "^1.2.0 || ~1.4.0" } + ] + }, + "valid": true + }, + { + "description": "a top-level tag contains an uppercase letter", + "contract": { + "fluidVersion": "0.7.5", + "kind": "DataProduct", + "id": "customer_profiles", + "name": "Customer Profiles", + "tags": ["Customer-Data"], + "metadata": { "owner": { "team": "customer-analytics" } }, + "exposes": [ + { + "exposeId": "profiles_table", + "kind": "table", + "binding": { + "platform": "gcp", + "format": "bigquery_table", + "location": { "project": "acme-prod", "dataset": "customers", "table": "profiles" } + }, + "contract": { "schema": [{ "name": "customer_id", "type": "string" }] } + } + ] + }, + "valid": false, + "errors": [{ "pointer": "/tags/0", "keyword": "pattern" }] + }, + { + "description": "the tags array repeats the same value twice", + "contract": { + "fluidVersion": "0.7.5", + "kind": "DataProduct", + "id": "customer_profiles", + "name": "Customer Profiles", + "tags": ["pii", "pii"], + "metadata": { "owner": { "team": "customer-analytics" } }, + "exposes": [ + { + "exposeId": "profiles_table", + "kind": "table", + "binding": { + "platform": "gcp", + "format": "bigquery_table", + "location": { "project": "acme-prod", "dataset": "customers", "table": "profiles" } + }, + "contract": { "schema": [{ "name": "customer_id", "type": "string" }] } + } + ] + }, + "valid": false, + "errors": [{ "pointer": "/tags", "keyword": "uniqueItems" }] + }, + { + "description": "a top-level label value is a number instead of a string", + "contract": { + "fluidVersion": "0.7.5", + "kind": "DataProduct", + "id": "customer_profiles", + "name": "Customer Profiles", + "labels": { "team": 42 }, + "metadata": { "owner": { "team": "customer-analytics" } }, + "exposes": [ + { + "exposeId": "profiles_table", + "kind": "table", + "binding": { + "platform": "gcp", + "format": "bigquery_table", + "location": { "project": "acme-prod", "dataset": "customers", "table": "profiles" } + }, + "contract": { "schema": [{ "name": "customer_id", "type": "string" }] } + } + ] + }, + "valid": false, + "errors": [{ "pointer": "/labels/team", "keyword": "type" }] + }, + { + "description": "the QoS freshness SLO omits the ISO-8601 duration's leading P", + "contract": { + "fluidVersion": "0.7.5", + "kind": "DataProduct", + "id": "customer_profiles", + "name": "Customer Profiles", + "metadata": { "owner": { "team": "customer-analytics" } }, + "exposes": [ + { + "exposeId": "profiles_table", + "kind": "table", + "binding": { + "platform": "gcp", + "format": "bigquery_table", + "location": { "project": "acme-prod", "dataset": "customers", "table": "profiles" } + }, + "contract": { "schema": [{ "name": "customer_id", "type": "string" }] }, + "qos": { "freshnessSLO": "15M" } + } + ] + }, + "valid": false, + "errors": [{ "pointer": "/exposes/0/qos/freshnessSLO", "keyword": "pattern" }] + }, + { + "description": "the QoS availability target exceeds 100 percent", + "contract": { + "fluidVersion": "0.7.5", + "kind": "DataProduct", + "id": "customer_profiles", + "name": "Customer Profiles", + "metadata": { "owner": { "team": "customer-analytics" } }, + "exposes": [ + { + "exposeId": "profiles_table", + "kind": "table", + "binding": { + "platform": "gcp", + "format": "bigquery_table", + "location": { "project": "acme-prod", "dataset": "customers", "table": "profiles" } + }, + "contract": { "schema": [{ "name": "customer_id", "type": "string" }] }, + "qos": { "availability": "101%" } + } + ] + }, + "valid": false, + "errors": [{ "pointer": "/exposes/0/qos/availability", "keyword": "pattern" }] + }, + { + "description": "the expose version is prefixed with a literal v", + "contract": { + "fluidVersion": "0.7.5", + "kind": "DataProduct", + "id": "customer_profiles", + "name": "Customer Profiles", + "metadata": { "owner": { "team": "customer-analytics" } }, + "exposes": [ + { + "exposeId": "profiles_table", + "kind": "table", + "version": "v1.2.3", + "binding": { + "platform": "gcp", + "format": "bigquery_table", + "location": { "project": "acme-prod", "dataset": "customers", "table": "profiles" } + }, + "contract": { "schema": [{ "name": "customer_id", "type": "string" }] } + } + ] + }, + "valid": false, + "errors": [{ "pointer": "/exposes/0/version", "keyword": "pattern" }] + }, + { + "description": "the expose version's major segment carries a leading zero", + "contract": { + "fluidVersion": "0.7.5", + "kind": "DataProduct", + "id": "customer_profiles", + "name": "Customer Profiles", + "metadata": { "owner": { "team": "customer-analytics" } }, + "exposes": [ + { + "exposeId": "profiles_table", + "kind": "table", + "version": "01.2.3", + "binding": { + "platform": "gcp", + "format": "bigquery_table", + "location": { "project": "acme-prod", "dataset": "customers", "table": "profiles" } + }, + "contract": { "schema": [{ "name": "customer_id", "type": "string" }] } + } + ] + }, + "valid": false, + "errors": [{ "pointer": "/exposes/0/version", "keyword": "pattern" }] + }, + { + "description": "the expose lifecycle state is a value outside the vocabulary", + "contract": { + "fluidVersion": "0.7.5", + "kind": "DataProduct", + "id": "customer_profiles", + "name": "Customer Profiles", + "metadata": { "owner": { "team": "customer-analytics" } }, + "exposes": [ + { + "exposeId": "profiles_table", + "kind": "table", + "binding": { + "platform": "gcp", + "format": "bigquery_table", + "location": { "project": "acme-prod", "dataset": "customers", "table": "profiles" } + }, + "contract": { "schema": [{ "name": "customer_id", "type": "string" }] }, + "lifecycle": { "state": "sunset" } + } + ] + }, + "valid": false, + "errors": [{ "pointer": "/exposes/0/lifecycle/state", "keyword": "enum" }] + }, + { + "description": "the expose id ends with a hyphen", + "contract": { + "fluidVersion": "0.7.5", + "kind": "DataProduct", + "id": "customer_profiles", + "name": "Customer Profiles", + "metadata": { "owner": { "team": "customer-analytics" } }, + "exposes": [ + { + "exposeId": "profiles-", + "kind": "table", + "binding": { + "platform": "gcp", + "format": "bigquery_table", + "location": { "project": "acme-prod", "dataset": "customers", "table": "profiles" } + }, + "contract": { "schema": [{ "name": "customer_id", "type": "string" }] } + } + ] + }, + "valid": false, + "errors": [{ "pointer": "/exposes/0/exposeId", "keyword": "pattern" }] + }, + { + "description": "the expose id begins with a dot", + "contract": { + "fluidVersion": "0.7.5", + "kind": "DataProduct", + "id": "customer_profiles", + "name": "Customer Profiles", + "metadata": { "owner": { "team": "customer-analytics" } }, + "exposes": [ + { + "exposeId": ".profiles", + "kind": "table", + "binding": { + "platform": "gcp", + "format": "bigquery_table", + "location": { "project": "acme-prod", "dataset": "customers", "table": "profiles" } + }, + "contract": { "schema": [{ "name": "customer_id", "type": "string" }] } + } + ] + }, + "valid": false, + "errors": [{ "pointer": "/exposes/0/exposeId", "keyword": "pattern" }] + }, + { + "description": "the consumed version constraint is a number instead of a string", + "contract": { + "fluidVersion": "0.7.5", + "kind": "DataProduct", + "id": "customer_profiles", + "name": "Customer Profiles", + "metadata": { "owner": { "team": "customer-analytics" } }, + "exposes": [ + { + "exposeId": "profiles_table", + "kind": "table", + "binding": { + "platform": "gcp", + "format": "bigquery_table", + "location": { "project": "acme-prod", "dataset": "customers", "table": "profiles" } + }, + "contract": { "schema": [{ "name": "customer_id", "type": "string" }] } + } + ], + "consumes": [ + { "productId": "upstream_product", "exposeId": "upstream_expose", "versionConstraint": 42 } + ] + }, + "valid": false, + "errors": [{ "pointer": "/consumes/0/versionConstraint", "keyword": "type" }] + }, + { + "description": "the expose id contains a space in the middle", + "contract": { + "fluidVersion": "0.7.5", + "kind": "DataProduct", + "id": "customer_profiles", + "name": "Customer Profiles", + "metadata": { "owner": { "team": "customer-analytics" } }, + "exposes": [ + { + "exposeId": "profiles table", + "kind": "table", + "binding": { + "platform": "gcp", + "format": "bigquery_table", + "location": { "project": "acme-prod", "dataset": "customers", "table": "profiles" } + }, + "contract": { "schema": [{ "name": "customer_id", "type": "string" }] } + } + ] + }, + "valid": false, + "errors": [{ "pointer": "/exposes/0/exposeId", "keyword": "pattern" }] + }, + { + "description": "the expose id is a single hyphen character", + "contract": { + "fluidVersion": "0.7.5", + "kind": "DataProduct", + "id": "customer_profiles", + "name": "Customer Profiles", + "metadata": { "owner": { "team": "customer-analytics" } }, + "exposes": [ + { + "exposeId": "-", + "kind": "table", + "binding": { + "platform": "gcp", + "format": "bigquery_table", + "location": { "project": "acme-prod", "dataset": "customers", "table": "profiles" } + }, + "contract": { "schema": [{ "name": "customer_id", "type": "string" }] } + } + ] + }, + "valid": false, + "errors": [{ "pointer": "/exposes/0/exposeId", "keyword": "pattern" }] + }, + { + "description": "the expose version omits the patch segment", + "contract": { + "fluidVersion": "0.7.5", + "kind": "DataProduct", + "id": "customer_profiles", + "name": "Customer Profiles", + "metadata": { "owner": { "team": "customer-analytics" } }, + "exposes": [ + { + "exposeId": "profiles_table", + "kind": "table", + "version": "1.2", + "binding": { + "platform": "gcp", + "format": "bigquery_table", + "location": { "project": "acme-prod", "dataset": "customers", "table": "profiles" } + }, + "contract": { "schema": [{ "name": "customer_id", "type": "string" }] } + } + ] + }, + "valid": false, + "errors": [{ "pointer": "/exposes/0/version", "keyword": "pattern" }] + }, + { + "description": "the expose version's prerelease segment contains an underscore", + "contract": { + "fluidVersion": "0.7.5", + "kind": "DataProduct", + "id": "customer_profiles", + "name": "Customer Profiles", + "metadata": { "owner": { "team": "customer-analytics" } }, + "exposes": [ + { + "exposeId": "profiles_table", + "kind": "table", + "version": "1.2.3-beta_1", + "binding": { + "platform": "gcp", + "format": "bigquery_table", + "location": { "project": "acme-prod", "dataset": "customers", "table": "profiles" } + }, + "contract": { "schema": [{ "name": "customer_id", "type": "string" }] } + } + ] + }, + "valid": false, + "errors": [{ "pointer": "/exposes/0/version", "keyword": "pattern" }] + }, + { + "description": "a top-level tag ends with a hyphen", + "contract": { + "fluidVersion": "0.7.5", + "kind": "DataProduct", + "id": "customer_profiles", + "name": "Customer Profiles", + "tags": ["pii-"], + "metadata": { "owner": { "team": "customer-analytics" } }, + "exposes": [ + { + "exposeId": "profiles_table", + "kind": "table", + "binding": { + "platform": "gcp", + "format": "bigquery_table", + "location": { "project": "acme-prod", "dataset": "customers", "table": "profiles" } + }, + "contract": { "schema": [{ "name": "customer_id", "type": "string" }] } + } + ] + }, + "valid": false, + "errors": [{ "pointer": "/tags/0", "keyword": "pattern" }] + }, + { + "description": "top-level tags is a string instead of an array", + "contract": { + "fluidVersion": "0.7.5", + "kind": "DataProduct", + "id": "customer_profiles", + "name": "Customer Profiles", + "tags": "pii", + "metadata": { "owner": { "team": "customer-analytics" } }, + "exposes": [ + { + "exposeId": "profiles_table", + "kind": "table", + "binding": { + "platform": "gcp", + "format": "bigquery_table", + "location": { "project": "acme-prod", "dataset": "customers", "table": "profiles" } + }, + "contract": { "schema": [{ "name": "customer_id", "type": "string" }] } + } + ] + }, + "valid": false, + "errors": [{ "pointer": "/tags", "keyword": "type" }] + }, + { + "description": "top-level labels is an array instead of an object", + "contract": { + "fluidVersion": "0.7.5", + "kind": "DataProduct", + "id": "customer_profiles", + "name": "Customer Profiles", + "labels": ["customer-analytics"], + "metadata": { "owner": { "team": "customer-analytics" } }, + "exposes": [ + { + "exposeId": "profiles_table", + "kind": "table", + "binding": { + "platform": "gcp", + "format": "bigquery_table", + "location": { "project": "acme-prod", "dataset": "customers", "table": "profiles" } + }, + "contract": { "schema": [{ "name": "customer_id", "type": "string" }] } + } + ] + }, + "valid": false, + "errors": [{ "pointer": "/labels", "keyword": "type" }] + }, + { + "description": "the QoS freshness SLO is the bare duration designator with no unit components", + "contract": { + "fluidVersion": "0.7.5", + "kind": "DataProduct", + "id": "customer_profiles", + "name": "Customer Profiles", + "metadata": { "owner": { "team": "customer-analytics" } }, + "exposes": [ + { + "exposeId": "profiles_table", + "kind": "table", + "binding": { + "platform": "gcp", + "format": "bigquery_table", + "location": { "project": "acme-prod", "dataset": "customers", "table": "profiles" } + }, + "contract": { "schema": [{ "name": "customer_id", "type": "string" }] }, + "qos": { "freshnessSLO": "P" } + } + ] + }, + "valid": false, + "errors": [{ "pointer": "/exposes/0/qos/freshnessSLO", "keyword": "pattern" }] + }, + { + "description": "lifecycle retention lists a day component before the year component", + "contract": { + "fluidVersion": "0.7.5", + "kind": "DataProduct", + "id": "customer_profiles", + "name": "Customer Profiles", + "metadata": { "owner": { "team": "customer-analytics" } }, + "exposes": [ + { + "exposeId": "profiles_table", + "kind": "table", + "binding": { + "platform": "gcp", + "format": "bigquery_table", + "location": { "project": "acme-prod", "dataset": "customers", "table": "profiles" } + }, + "contract": { "schema": [{ "name": "customer_id", "type": "string" }] }, + "lifecycle": { "retention": "P1D2Y" } + } + ] + }, + "valid": false, + "errors": [{ "pointer": "/exposes/0/lifecycle/retention", "keyword": "pattern" }] + }, + { + "description": "the QoS availability target omits the percent sign", + "contract": { + "fluidVersion": "0.7.5", + "kind": "DataProduct", + "id": "customer_profiles", + "name": "Customer Profiles", + "metadata": { "owner": { "team": "customer-analytics" } }, + "exposes": [ + { + "exposeId": "profiles_table", + "kind": "table", + "binding": { + "platform": "gcp", + "format": "bigquery_table", + "location": { "project": "acme-prod", "dataset": "customers", "table": "profiles" } + }, + "contract": { "schema": [{ "name": "customer_id", "type": "string" }] }, + "qos": { "availability": "99.9" } + } + ] + }, + "valid": false, + "errors": [{ "pointer": "/exposes/0/qos/availability", "keyword": "pattern" }] + }, + { + "description": "the expose lifecycle state is set to the preview vocabulary value", + "contract": { + "fluidVersion": "0.7.5", + "kind": "DataProduct", + "id": "customer_profiles", + "name": "Customer Profiles", + "metadata": { "owner": { "team": "customer-analytics" } }, + "exposes": [ + { + "exposeId": "profiles_table", + "kind": "table", + "binding": { + "platform": "gcp", + "format": "bigquery_table", + "location": { "project": "acme-prod", "dataset": "customers", "table": "profiles" } + }, + "contract": { "schema": [{ "name": "customer_id", "type": "string" }] }, + "lifecycle": { "state": "preview" } + } + ] + }, + "valid": true + } + ] +} diff --git a/tests/0.7.5/sovereignty.json b/tests/0.7.5/sovereignty.json new file mode 100644 index 0000000..96405a0 --- /dev/null +++ b/tests/0.7.5/sovereignty.json @@ -0,0 +1,711 @@ +{ + "corpusVersion": "0.7.5", + "schema": "https://open-data-protocol.github.io/fluid/schema/fluid-schema-0.7.5.json", + "group": "sovereignty", + "description": "The top-level sovereignty block: its own object type, the jurisdiction and enforcementMode enums, the allowedRegions/deniedRegions/transferMechanisms/regulatoryFramework arrays (their array type, item type/enum, and uniqueItems), the dataResidency/crossBorderTransfer/validationRequired booleans, and the block's closed property set.", + "cases": [ + { + "description": "EU jurisdiction with strict enforcement and no cross-border transfer", + "contract": { + "fluidVersion": "0.7.5", + "kind": "DataProduct", + "id": "customer_profiles", + "name": "Customer Profiles", + "metadata": { "owner": { "team": "customer-analytics" } }, + "sovereignty": { + "jurisdiction": "EU", + "allowedRegions": ["eu-west-1", "eu-central-1"], + "deniedRegions": ["us-east-1"], + "dataResidency": true, + "crossBorderTransfer": false, + "regulatoryFramework": ["GDPR"], + "enforcementMode": "strict", + "validationRequired": true + }, + "exposes": [ + { + "exposeId": "profiles_table", + "kind": "table", + "binding": { + "platform": "gcp", + "format": "bigquery_table", + "location": { "project": "acme-prod", "dataset": "customers", "table": "profiles" } + }, + "contract": { "schema": [{ "name": "customer_id", "type": "string" }] } + } + ] + }, + "valid": true + }, + { + "description": "Multi-Region jurisdiction permits cross-border transfer under every transferMechanisms value", + "contract": { + "fluidVersion": "0.7.5", + "kind": "DataProduct", + "id": "customer_profiles", + "name": "Customer Profiles", + "metadata": { "owner": { "team": "customer-analytics" } }, + "sovereignty": { + "jurisdiction": "Multi-Region", + "crossBorderTransfer": true, + "transferMechanisms": ["SCCs", "BCRs", "Adequacy", "DPF", "Consent", "Derogation"] + }, + "exposes": [ + { + "exposeId": "profiles_table", + "kind": "table", + "binding": { + "platform": "gcp", + "format": "bigquery_table", + "location": { "project": "acme-prod", "dataset": "customers", "table": "profiles" } + }, + "contract": { "schema": [{ "name": "customer_id", "type": "string" }] } + } + ] + }, + "valid": true + }, + { + "description": "CN jurisdiction with audit-only enforcement and validation not required", + "contract": { + "fluidVersion": "0.7.5", + "kind": "DataProduct", + "id": "customer_profiles", + "name": "Customer Profiles", + "metadata": { "owner": { "team": "customer-analytics" } }, + "sovereignty": { + "jurisdiction": "CN", + "enforcementMode": "audit", + "validationRequired": false + }, + "exposes": [ + { + "exposeId": "profiles_table", + "kind": "table", + "binding": { + "platform": "gcp", + "format": "bigquery_table", + "location": { "project": "acme-prod", "dataset": "customers", "table": "profiles" } + }, + "contract": { "schema": [{ "name": "customer_id", "type": "string" }] } + } + ] + }, + "valid": true + }, + { + "description": "JP jurisdiction with advisory enforcement and only deniedRegions declared", + "contract": { + "fluidVersion": "0.7.5", + "kind": "DataProduct", + "id": "customer_profiles", + "name": "Customer Profiles", + "metadata": { "owner": { "team": "customer-analytics" } }, + "sovereignty": { + "jurisdiction": "JP", + "enforcementMode": "advisory", + "deniedRegions": ["cn-north-1"] + }, + "exposes": [ + { + "exposeId": "profiles_table", + "kind": "table", + "binding": { + "platform": "gcp", + "format": "bigquery_table", + "location": { "project": "acme-prod", "dataset": "customers", "table": "profiles" } + }, + "contract": { "schema": [{ "name": "customer_id", "type": "string" }] } + } + ] + }, + "valid": true + }, + { + "description": "regulatoryFramework carries every enum value beyond GDPR", + "contract": { + "fluidVersion": "0.7.5", + "kind": "DataProduct", + "id": "customer_profiles", + "name": "Customer Profiles", + "metadata": { "owner": { "team": "customer-analytics" } }, + "sovereignty": { + "jurisdiction": "US", + "regulatoryFramework": ["CCPA", "CPRA", "HIPAA", "PIPEDA", "LGPD", "PDPA", "POPIA", "DPA", "APPI"] + }, + "exposes": [ + { + "exposeId": "profiles_table", + "kind": "table", + "binding": { + "platform": "gcp", + "format": "bigquery_table", + "location": { "project": "acme-prod", "dataset": "customers", "table": "profiles" } + }, + "contract": { "schema": [{ "name": "customer_id", "type": "string" }] } + } + ] + }, + "valid": true + }, + { + "description": "sovereignty block carries its own tags and labels", + "contract": { + "fluidVersion": "0.7.5", + "kind": "DataProduct", + "id": "customer_profiles", + "name": "Customer Profiles", + "metadata": { "owner": { "team": "customer-analytics" } }, + "sovereignty": { + "jurisdiction": "BR", + "tags": ["pii", "cross-border"], + "labels": { "policy-owner": "legal-team" } + }, + "exposes": [ + { + "exposeId": "profiles_table", + "kind": "table", + "binding": { + "platform": "gcp", + "format": "bigquery_table", + "location": { "project": "acme-prod", "dataset": "customers", "table": "profiles" } + }, + "contract": { "schema": [{ "name": "customer_id", "type": "string" }] } + } + ] + }, + "valid": true + }, + { + "description": "jurisdiction value not in the enum", + "contract": { + "fluidVersion": "0.7.5", + "kind": "DataProduct", + "id": "customer_profiles", + "name": "Customer Profiles", + "metadata": { "owner": { "team": "customer-analytics" } }, + "sovereignty": { + "jurisdiction": "DE" + }, + "exposes": [ + { + "exposeId": "profiles_table", + "kind": "table", + "binding": { + "platform": "gcp", + "format": "bigquery_table", + "location": { "project": "acme-prod", "dataset": "customers", "table": "profiles" } + }, + "contract": { "schema": [{ "name": "customer_id", "type": "string" }] } + } + ] + }, + "valid": false, + "errors": [{ "pointer": "/sovereignty/jurisdiction", "keyword": "enum" }] + }, + { + "description": "enforcementMode value not in the enum", + "contract": { + "fluidVersion": "0.7.5", + "kind": "DataProduct", + "id": "customer_profiles", + "name": "Customer Profiles", + "metadata": { "owner": { "team": "customer-analytics" } }, + "sovereignty": { + "jurisdiction": "EU", + "enforcementMode": "block" + }, + "exposes": [ + { + "exposeId": "profiles_table", + "kind": "table", + "binding": { + "platform": "gcp", + "format": "bigquery_table", + "location": { "project": "acme-prod", "dataset": "customers", "table": "profiles" } + }, + "contract": { "schema": [{ "name": "customer_id", "type": "string" }] } + } + ] + }, + "valid": false, + "errors": [{ "pointer": "/sovereignty/enforcementMode", "keyword": "enum" }] + }, + { + "description": "transferMechanisms carries a mechanism outside the enum", + "contract": { + "fluidVersion": "0.7.5", + "kind": "DataProduct", + "id": "customer_profiles", + "name": "Customer Profiles", + "metadata": { "owner": { "team": "customer-analytics" } }, + "sovereignty": { + "jurisdiction": "EU", + "crossBorderTransfer": true, + "transferMechanisms": ["Handshake"] + }, + "exposes": [ + { + "exposeId": "profiles_table", + "kind": "table", + "binding": { + "platform": "gcp", + "format": "bigquery_table", + "location": { "project": "acme-prod", "dataset": "customers", "table": "profiles" } + }, + "contract": { "schema": [{ "name": "customer_id", "type": "string" }] } + } + ] + }, + "valid": false, + "errors": [{ "pointer": "/sovereignty/transferMechanisms/0", "keyword": "enum" }] + }, + { + "description": "regulatoryFramework carries a framework outside the enum", + "contract": { + "fluidVersion": "0.7.5", + "kind": "DataProduct", + "id": "customer_profiles", + "name": "Customer Profiles", + "metadata": { "owner": { "team": "customer-analytics" } }, + "sovereignty": { + "jurisdiction": "US", + "regulatoryFramework": ["SOX"] + }, + "exposes": [ + { + "exposeId": "profiles_table", + "kind": "table", + "binding": { + "platform": "gcp", + "format": "bigquery_table", + "location": { "project": "acme-prod", "dataset": "customers", "table": "profiles" } + }, + "contract": { "schema": [{ "name": "customer_id", "type": "string" }] } + } + ] + }, + "valid": false, + "errors": [{ "pointer": "/sovereignty/regulatoryFramework/0", "keyword": "enum" }] + }, + { + "description": "dataResidency given as a string instead of a boolean", + "contract": { + "fluidVersion": "0.7.5", + "kind": "DataProduct", + "id": "customer_profiles", + "name": "Customer Profiles", + "metadata": { "owner": { "team": "customer-analytics" } }, + "sovereignty": { + "jurisdiction": "EU", + "dataResidency": "true" + }, + "exposes": [ + { + "exposeId": "profiles_table", + "kind": "table", + "binding": { + "platform": "gcp", + "format": "bigquery_table", + "location": { "project": "acme-prod", "dataset": "customers", "table": "profiles" } + }, + "contract": { "schema": [{ "name": "customer_id", "type": "string" }] } + } + ] + }, + "valid": false, + "errors": [{ "pointer": "/sovereignty/dataResidency", "keyword": "type" }] + }, + { + "description": "crossBorderTransfer given as a number instead of a boolean", + "contract": { + "fluidVersion": "0.7.5", + "kind": "DataProduct", + "id": "customer_profiles", + "name": "Customer Profiles", + "metadata": { "owner": { "team": "customer-analytics" } }, + "sovereignty": { + "jurisdiction": "EU", + "crossBorderTransfer": 0 + }, + "exposes": [ + { + "exposeId": "profiles_table", + "kind": "table", + "binding": { + "platform": "gcp", + "format": "bigquery_table", + "location": { "project": "acme-prod", "dataset": "customers", "table": "profiles" } + }, + "contract": { "schema": [{ "name": "customer_id", "type": "string" }] } + } + ] + }, + "valid": false, + "errors": [{ "pointer": "/sovereignty/crossBorderTransfer", "keyword": "type" }] + }, + { + "description": "validationRequired given as a string instead of a boolean", + "contract": { + "fluidVersion": "0.7.5", + "kind": "DataProduct", + "id": "customer_profiles", + "name": "Customer Profiles", + "metadata": { "owner": { "team": "customer-analytics" } }, + "sovereignty": { + "jurisdiction": "EU", + "validationRequired": "yes" + }, + "exposes": [ + { + "exposeId": "profiles_table", + "kind": "table", + "binding": { + "platform": "gcp", + "format": "bigquery_table", + "location": { "project": "acme-prod", "dataset": "customers", "table": "profiles" } + }, + "contract": { "schema": [{ "name": "customer_id", "type": "string" }] } + } + ] + }, + "valid": false, + "errors": [{ "pointer": "/sovereignty/validationRequired", "keyword": "type" }] + }, + { + "description": "allowedRegions repeats the same region twice", + "contract": { + "fluidVersion": "0.7.5", + "kind": "DataProduct", + "id": "customer_profiles", + "name": "Customer Profiles", + "metadata": { "owner": { "team": "customer-analytics" } }, + "sovereignty": { + "jurisdiction": "EU", + "allowedRegions": ["eu-west-1", "eu-west-1"] + }, + "exposes": [ + { + "exposeId": "profiles_table", + "kind": "table", + "binding": { + "platform": "gcp", + "format": "bigquery_table", + "location": { "project": "acme-prod", "dataset": "customers", "table": "profiles" } + }, + "contract": { "schema": [{ "name": "customer_id", "type": "string" }] } + } + ] + }, + "valid": false, + "errors": [{ "pointer": "/sovereignty/allowedRegions", "keyword": "uniqueItems" }] + }, + { + "description": "deniedRegions repeats the same region twice", + "contract": { + "fluidVersion": "0.7.5", + "kind": "DataProduct", + "id": "customer_profiles", + "name": "Customer Profiles", + "metadata": { "owner": { "team": "customer-analytics" } }, + "sovereignty": { + "jurisdiction": "EU", + "deniedRegions": ["us-east-1", "us-east-1"] + }, + "exposes": [ + { + "exposeId": "profiles_table", + "kind": "table", + "binding": { + "platform": "gcp", + "format": "bigquery_table", + "location": { "project": "acme-prod", "dataset": "customers", "table": "profiles" } + }, + "contract": { "schema": [{ "name": "customer_id", "type": "string" }] } + } + ] + }, + "valid": false, + "errors": [{ "pointer": "/sovereignty/deniedRegions", "keyword": "uniqueItems" }] + }, + { + "description": "transferMechanisms repeats the same mechanism twice", + "contract": { + "fluidVersion": "0.7.5", + "kind": "DataProduct", + "id": "customer_profiles", + "name": "Customer Profiles", + "metadata": { "owner": { "team": "customer-analytics" } }, + "sovereignty": { + "jurisdiction": "EU", + "crossBorderTransfer": true, + "transferMechanisms": ["SCCs", "SCCs"] + }, + "exposes": [ + { + "exposeId": "profiles_table", + "kind": "table", + "binding": { + "platform": "gcp", + "format": "bigquery_table", + "location": { "project": "acme-prod", "dataset": "customers", "table": "profiles" } + }, + "contract": { "schema": [{ "name": "customer_id", "type": "string" }] } + } + ] + }, + "valid": false, + "errors": [{ "pointer": "/sovereignty/transferMechanisms", "keyword": "uniqueItems" }] + }, + { + "description": "regulatoryFramework repeats the same framework twice", + "contract": { + "fluidVersion": "0.7.5", + "kind": "DataProduct", + "id": "customer_profiles", + "name": "Customer Profiles", + "metadata": { "owner": { "team": "customer-analytics" } }, + "sovereignty": { + "jurisdiction": "US", + "regulatoryFramework": ["CCPA", "CCPA"] + }, + "exposes": [ + { + "exposeId": "profiles_table", + "kind": "table", + "binding": { + "platform": "gcp", + "format": "bigquery_table", + "location": { "project": "acme-prod", "dataset": "customers", "table": "profiles" } + }, + "contract": { "schema": [{ "name": "customer_id", "type": "string" }] } + } + ] + }, + "valid": false, + "errors": [{ "pointer": "/sovereignty/regulatoryFramework", "keyword": "uniqueItems" }] + }, + { + "description": "sovereignty carries an unrecognized property", + "contract": { + "fluidVersion": "0.7.5", + "kind": "DataProduct", + "id": "customer_profiles", + "name": "Customer Profiles", + "metadata": { "owner": { "team": "customer-analytics" } }, + "sovereignty": { + "jurisdiction": "EU", + "prohibitTransferTo": ["CN", "RU"] + }, + "exposes": [ + { + "exposeId": "profiles_table", + "kind": "table", + "binding": { + "platform": "gcp", + "format": "bigquery_table", + "location": { "project": "acme-prod", "dataset": "customers", "table": "profiles" } + }, + "contract": { "schema": [{ "name": "customer_id", "type": "string" }] } + } + ] + }, + "valid": false, + "errors": [{ "pointer": "/sovereignty", "keyword": "additionalProperties", "detail": "prohibitTransferTo" }] + }, + { + "description": "sovereignty given as an array instead of an object", + "contract": { + "fluidVersion": "0.7.5", + "kind": "DataProduct", + "id": "customer_profiles", + "name": "Customer Profiles", + "metadata": { "owner": { "team": "customer-analytics" } }, + "sovereignty": ["EU"], + "exposes": [ + { + "exposeId": "profiles_table", + "kind": "table", + "binding": { + "platform": "gcp", + "format": "bigquery_table", + "location": { "project": "acme-prod", "dataset": "customers", "table": "profiles" } + }, + "contract": { "schema": [{ "name": "customer_id", "type": "string" }] } + } + ] + }, + "valid": false, + "errors": [{ "pointer": "/sovereignty", "keyword": "type" }] + }, + { + "description": "allowedRegions given as a string instead of an array", + "contract": { + "fluidVersion": "0.7.5", + "kind": "DataProduct", + "id": "customer_profiles", + "name": "Customer Profiles", + "metadata": { "owner": { "team": "customer-analytics" } }, + "sovereignty": { + "jurisdiction": "EU", + "allowedRegions": "eu-west-1" + }, + "exposes": [ + { + "exposeId": "profiles_table", + "kind": "table", + "binding": { + "platform": "gcp", + "format": "bigquery_table", + "location": { "project": "acme-prod", "dataset": "customers", "table": "profiles" } + }, + "contract": { "schema": [{ "name": "customer_id", "type": "string" }] } + } + ] + }, + "valid": false, + "errors": [{ "pointer": "/sovereignty/allowedRegions", "keyword": "type" }] + }, + { + "description": "allowedRegions item given as a number instead of a string", + "contract": { + "fluidVersion": "0.7.5", + "kind": "DataProduct", + "id": "customer_profiles", + "name": "Customer Profiles", + "metadata": { "owner": { "team": "customer-analytics" } }, + "sovereignty": { + "jurisdiction": "EU", + "allowedRegions": [123] + }, + "exposes": [ + { + "exposeId": "profiles_table", + "kind": "table", + "binding": { + "platform": "gcp", + "format": "bigquery_table", + "location": { "project": "acme-prod", "dataset": "customers", "table": "profiles" } + }, + "contract": { "schema": [{ "name": "customer_id", "type": "string" }] } + } + ] + }, + "valid": false, + "errors": [{ "pointer": "/sovereignty/allowedRegions/0", "keyword": "type" }] + }, + { + "description": "deniedRegions given as a string instead of an array", + "contract": { + "fluidVersion": "0.7.5", + "kind": "DataProduct", + "id": "customer_profiles", + "name": "Customer Profiles", + "metadata": { "owner": { "team": "customer-analytics" } }, + "sovereignty": { + "jurisdiction": "EU", + "deniedRegions": "us-east-1" + }, + "exposes": [ + { + "exposeId": "profiles_table", + "kind": "table", + "binding": { + "platform": "gcp", + "format": "bigquery_table", + "location": { "project": "acme-prod", "dataset": "customers", "table": "profiles" } + }, + "contract": { "schema": [{ "name": "customer_id", "type": "string" }] } + } + ] + }, + "valid": false, + "errors": [{ "pointer": "/sovereignty/deniedRegions", "keyword": "type" }] + }, + { + "description": "deniedRegions item given as a boolean instead of a string", + "contract": { + "fluidVersion": "0.7.5", + "kind": "DataProduct", + "id": "customer_profiles", + "name": "Customer Profiles", + "metadata": { "owner": { "team": "customer-analytics" } }, + "sovereignty": { + "jurisdiction": "EU", + "deniedRegions": [true] + }, + "exposes": [ + { + "exposeId": "profiles_table", + "kind": "table", + "binding": { + "platform": "gcp", + "format": "bigquery_table", + "location": { "project": "acme-prod", "dataset": "customers", "table": "profiles" } + }, + "contract": { "schema": [{ "name": "customer_id", "type": "string" }] } + } + ] + }, + "valid": false, + "errors": [{ "pointer": "/sovereignty/deniedRegions/0", "keyword": "type" }] + }, + { + "description": "transferMechanisms given as a string instead of an array", + "contract": { + "fluidVersion": "0.7.5", + "kind": "DataProduct", + "id": "customer_profiles", + "name": "Customer Profiles", + "metadata": { "owner": { "team": "customer-analytics" } }, + "sovereignty": { + "jurisdiction": "EU", + "crossBorderTransfer": true, + "transferMechanisms": "SCCs" + }, + "exposes": [ + { + "exposeId": "profiles_table", + "kind": "table", + "binding": { + "platform": "gcp", + "format": "bigquery_table", + "location": { "project": "acme-prod", "dataset": "customers", "table": "profiles" } + }, + "contract": { "schema": [{ "name": "customer_id", "type": "string" }] } + } + ] + }, + "valid": false, + "errors": [{ "pointer": "/sovereignty/transferMechanisms", "keyword": "type" }] + }, + { + "description": "regulatoryFramework given as a string instead of an array", + "contract": { + "fluidVersion": "0.7.5", + "kind": "DataProduct", + "id": "customer_profiles", + "name": "Customer Profiles", + "metadata": { "owner": { "team": "customer-analytics" } }, + "sovereignty": { + "jurisdiction": "US", + "regulatoryFramework": "GDPR" + }, + "exposes": [ + { + "exposeId": "profiles_table", + "kind": "table", + "binding": { + "platform": "gcp", + "format": "bigquery_table", + "location": { "project": "acme-prod", "dataset": "customers", "table": "profiles" } + }, + "contract": { "schema": [{ "name": "customer_id", "type": "string" }] } + } + ] + }, + "valid": false, + "errors": [{ "pointer": "/sovereignty/regulatoryFramework", "keyword": "type" }] + } + ] +} diff --git a/tests/README.md b/tests/README.md new file mode 100644 index 0000000..9ab40fa --- /dev/null +++ b/tests/README.md @@ -0,0 +1,159 @@ +# The FLUID conformance corpus + +This directory is the operative definition of "FLUID-conformant". + +A schema tells you what shape a document has. It does not tell you whether two +implementations agree about it. FLUID already has more than one first-party +reader of the same contract — `FluidSchemaManager` in forge-cli, the FLUX +engine's `seam/`, and the Command Center — and until this corpus existed, +nothing proved they agreed. The corpus is how they are held to one answer, and +how a third party can demonstrate conformance without asking our permission. + +**The corpus is data.** Reading it requires only a JSON parser. The runner in +`../conformance/` is a convenience for Python; it is not the definition, and an +implementation in any language is conformant if it reproduces these verdicts. + +## Layout + +``` +tests/ + / # core tier: every conformant validator must agree + envelope.json + expose.json + ... + optional// # behaviour an implementation may legitimately not have + metadata.json + meta_test.py # proves the gates can fail +``` + +The core/optional split follows +[JSON-Schema-Test-Suite](https://github.com/json-schema-org/JSON-Schema-Test-Suite). +The core tier asserts only what every conformant JSON Schema 2020-12 validator +must do. The optional tier asserts behaviour the specification permits but does +not require — today, `format` assertion (see "Known ambiguities" below). + +## File format + +```json +{ + "corpusVersion": "0.7.5", + "schema": "https://open-data-protocol.github.io/fluid/schema/fluid-schema-0.7.5.json", + "group": "envelope", + "description": "What this group pins down.", + "cases": [ + { + "description": "minimal document carrying exactly the required members", + "contract": { "fluidVersion": "0.7.5", "...": "..." }, + "valid": true + }, + { + "description": "metadata present but owner absent", + "contract": { "...": "..." }, + "valid": false, + "errors": [{ "pointer": "/metadata", "keyword": "required", "detail": "owner" }] + } + ] +} +``` + +| Field | Meaning | +|---|---| +| `contract` | A complete FLUID document. Not a fragment. | +| `valid` | Whether a conformant implementation accepts it. | +| `errors` | Required when `valid` is false. Why it must be rejected. | +| `pointer` | RFC 6901 pointer to the failing instance location. `""` is the root. | +| `keyword` | The JSON Schema keyword that must fail: `required`, `enum`, `pattern`, `type`, `additionalProperties`, `minItems`, … | +| `detail` | For `required`, the missing property. For `additionalProperties`, the offending one. | + +Three rules make the assertions portable and durable: + +1. **Never assert on message text.** Wording is a rendering choice and every + validator words it differently. Keyword and pointer are interoperable facts. +2. **Expectations are partial.** A case names only the fields it means to pin, + so a case does not break when an unrelated part of the schema moves. +3. **An invalid case must say why.** A case that only asserts "rejected" passes + for any reason at all, including reasons its author never intended. The + runner refuses to load one. + +Two further rules make cases meaningful rather than merely present: + +4. **Minimality.** An invalid case differs from a valid document in exactly the + one way under test. Two defects in one document prove neither. +5. **Falsifiability.** For every constraint the schema expresses, some case must + go red if that constraint were deleted. That is the coverage bar — not + "there are tests" but "weakening this definition is caught". + +## Running it + +```bash +# the [format] extra matters: without it jsonschema registers no +# date-time checker, and the optional tier passes vacuously +pip install "jsonschema[format]>=4.22" + +python3 conformance/run.py # every published version +python3 conformance/run.py --version 0.7.5 # one version +python3 conformance/run.py --group agentpolicy # one group +python3 conformance/run.py --junit report.xml # for CI +python3 conformance/run.py --list # print test ids +``` + +Against the reference implementation, which is implementation #1 under test and +never the referee: + +```bash +pip install data-product-forge +python3 conformance/check_reference.py +``` + +And to prove the gates can actually fail: + +```bash +python3 tests/meta_test.py +``` + +## Known failures + +`../conformance/known-failures.txt` lists test ids that are expected to fail: +one per line, `#` comments, kept sorted. Known-failing cases are still +**executed**, so a fix flips them green loudly rather than silently — the +[connectrpc/conformance](https://github.com/connectrpc/conformance) refinement +of protobuf's `failure_list`. A listed test that starts passing is an error, +and so is an entry naming a test that no longer exists. + +The file is how a strict gate stays adoptable on the day it lands: a real +shortcoming is recorded in the repository instead of being hidden by weakening +the corpus. + +## Known ambiguities the corpus has surfaced + +Recorded here because a conformance suite's job is to turn disagreements into +written questions rather than silent divergence. + +**Is `format` assertive in FLUID?** The specification does not say. In JSON +Schema 2020-12, `format` is an annotation by default — a conformant validator +may accept `"not-an-email"` for `{"format": "email"}`. The reference +implementation does not assert formats. Until FLUID states a position, the core +tier does not assert them either, and the cases that depend on assertion live +in `optional/`. A FLUID document is therefore **not** guaranteed to have a +well-formed `metadata.owner.email` merely because it validates. + +**The reference implementation validates a 2020-12 schema with a Draft 7 +validator.** `fluid_build/schema_manager.py` constructs a +`jsonschema.Draft7Validator` while every published schema declares +`"$schema": "https://json-schema.org/draft/2020-12/schema"`. Today this is +latent rather than active: the schemas use only `$defs` from the 2020-12-only +keyword set, and `#/$defs/...` references resolve under Draft 7 as ordinary +JSON pointers. It becomes a silent-acceptance bug the moment a schema uses +`prefixItems`, `unevaluatedProperties`, `dependentRequired`, `minContains` or +`maxContains` — Draft 7 ignores unknown keywords, so the implementation would +accept documents the published standard rejects, with no error anywhere. + +## Adding a version + +1. Publish `schema/fluid-schema-.json` (authored in forge-cli, synced + here by `.github/workflows/schema-sync.yml`). +2. Create `tests//` and port the previous version's groups. +3. Run `scripts/check-compat.py --from --to `. If it + reports a narrowing, either the change is a mistake or it is deliberate and + belongs in `scripts/compat-waivers.txt` **with the evidence that settled + it** — the waiver file is a decision record, not a mute button. diff --git a/tests/meta_test.py b/tests/meta_test.py new file mode 100644 index 0000000..aca0d59 --- /dev/null +++ b/tests/meta_test.py @@ -0,0 +1,315 @@ +#!/usr/bin/env python3 +"""Meta-test: prove the gates can actually fail. + +A green gate is not evidence that the thing it guards is sound -- it is only +evidence that the gate said nothing. This file breaks things on purpose and +asserts that each gate goes red, so "conformance passed" and "compat passed" +carry information. + +Every check runs against a disposable copy of the repo, so nothing here can +touch the real corpus, schemas or waivers. + + python3 tests/meta_test.py (exit 0 = the gates are load-bearing) + +Discipline borrowed from flux-spec's tests/test_regression.py (Apache-2.0, +same owner), which pairs its conformance vectors with a meta-test proving a +broken gate fails. +""" + +from __future__ import annotations + +import copy +import json +import shutil +import subprocess +import sys +import tempfile +from pathlib import Path +from typing import Any, Callable, Dict, List, Tuple + +REPO = Path(__file__).resolve().parent.parent +PYTHON = sys.executable + +RESULTS: List[Tuple[bool, str]] = [] + + +def check(name: str, condition: bool, detail: str = "") -> None: + RESULTS.append((condition, name if not detail else f"{name} -- {detail}")) + print(f" {'ok ' if condition else 'FAIL'} {name}") + if not condition and detail: + print(f" {detail}") + + +def sandbox() -> Path: + """A disposable copy of everything the gates read.""" + tmp = Path(tempfile.mkdtemp(prefix="fluid-meta-")) + for sub in ("schema", "tests", "conformance", "scripts"): + src = REPO / sub + if src.is_dir(): + shutil.copytree( + src, tmp / sub, ignore=shutil.ignore_patterns("__pycache__", "*.pyc") + ) + return tmp + + +def run(root: Path, script: str, *args: str) -> subprocess.CompletedProcess: + return subprocess.run( + [PYTHON, str(root / script), *args], + capture_output=True, + text=True, + cwd=root, + ) + + +def edit_json(path: Path, mutate: Callable[[Any], Any]) -> None: + doc = json.loads(path.read_text()) + path.write_text(json.dumps(mutate(doc), indent=2)) + + +# -------------------------------------------------------------------------- +# the conformance runner must fail on a broken corpus +# -------------------------------------------------------------------------- + + +def conformance_gate_is_load_bearing() -> None: + print("\nconformance runner:") + + baseline = run(sandbox_root, "conformance/run.py", "-q") + check( + "clean corpus passes", + baseline.returncode == 0, + f"exit={baseline.returncode} {baseline.stdout[-300:]}", + ) + + # 1. a document that does not validate, asserted valid + root = sandbox() + envelope = root / "tests" / "0.7.5" / "envelope.json" + edit_json( + envelope, + lambda d: { + **d, + "cases": d["cases"] + + [ + { + "description": "META truly invalid asserted valid", + "contract": {"fluidVersion": "0.7.5"}, + "valid": True, + } + ], + }, + ) + r = run(root, "conformance/run.py", "-q") + check("rejects an invalid document asserted valid", r.returncode == 1, f"exit={r.returncode}") + shutil.rmtree(root, ignore_errors=True) + + # 2. a valid document asserted invalid + root = sandbox() + edit_json( + root / "tests" / "0.7.5" / "envelope.json", + lambda d: { + **d, + "cases": [ + {**c, "valid": False, "errors": [{"keyword": "required"}]} + if c["description"].startswith("minimal document") + else c + for c in d["cases"] + ], + }, + ) + r = run(root, "conformance/run.py", "-q") + check("rejects a valid document asserted invalid", r.returncode == 1, f"exit={r.returncode}") + shutil.rmtree(root, ignore_errors=True) + + # 3. right verdict, wrong declared reason + root = sandbox() + edit_json( + root / "tests" / "0.7.5" / "envelope.json", + lambda d: { + **d, + "cases": [ + {**c, "errors": [{"pointer": "/nonexistent", "keyword": "maxLength"}]} + if not c["valid"] + else c + for c in d["cases"] + ], + }, + ) + r = run(root, "conformance/run.py", "-q") + check( + "rejects a case that fails for a reason other than the declared one", + r.returncode == 1, + f"exit={r.returncode}", + ) + shutil.rmtree(root, ignore_errors=True) + + # 4. an invalid case that declares no reason is a corpus error, not a pass + root = sandbox() + edit_json( + root / "tests" / "0.7.5" / "envelope.json", + lambda d: { + **d, + "cases": d["cases"] + + [{"description": "META no reason", "contract": {}, "valid": False}], + }, + ) + r = run(root, "conformance/run.py", "-q") + check("refuses an invalid case with no declared reason", r.returncode == 2, f"exit={r.returncode}") + shutil.rmtree(root, ignore_errors=True) + + # 5. a known-failure that starts passing must be reported, not silently absorbed + root = sandbox() + listing = run(root, "conformance/run.py", "--list") + first_id = listing.stdout.splitlines()[0].strip() + (root / "conformance" / "known-failures.txt").write_text(first_id + "\n") + r = run(root, "conformance/run.py", "-q") + check( + "reports a known-failure that unexpectedly passes", + r.returncode == 1, + f"exit={r.returncode} for id {first_id!r}", + ) + shutil.rmtree(root, ignore_errors=True) + + # 6. a known-failure naming no existing test is stale and must be reported + root = sandbox() + (root / "conformance" / "known-failures.txt").write_text("0.7.5/nosuch/case\n") + r = run(root, "conformance/run.py", "-q") + check("reports a stale known-failure entry", r.returncode == 1, f"exit={r.returncode}") + shutil.rmtree(root, ignore_errors=True) + + # 7. an unsorted known-failures file is refused + root = sandbox() + (root / "conformance" / "known-failures.txt").write_text("z/case\na/case\n") + r = run(root, "conformance/run.py", "-q") + check("refuses an unsorted known-failures file", r.returncode == 2, f"exit={r.returncode}") + shutil.rmtree(root, ignore_errors=True) + + +# -------------------------------------------------------------------------- +# the compatibility gate must fail on each class of narrowing +# -------------------------------------------------------------------------- + + +NARROWINGS: List[Tuple[str, Callable[[Dict[str, Any]], Dict[str, Any]]]] = [ + ( + "a newly required member", + lambda s: {**s, "required": s["required"] + ["domain"]}, + ), + ( + "a removed enum value", + lambda s: _set_in(s, ["properties", "kind", "enum"], ["DataProduct"]), + ), + ( + "a removed property on a closed object", + lambda s: _drop_in(s, ["properties", "metadata", "properties"], "layer"), + ), + ( + "a tightened lower bound", + lambda s: _set_in(s, ["properties", "exposes", "minItems"], 2), + ), + ( + "a narrowed type", + lambda s: _set_in(s, ["properties", "name", "type"], "null"), + ), + ( + "a removed $defs definition", + lambda s: _drop_in(s, ["$defs"], "sovereignty"), + ), + ( + "an object becoming closed", + lambda s: _set_in(s, ["$defs", "labels", "additionalProperties"], False), + ), +] + + +def _set_in(schema: Dict[str, Any], path: List[str], value: Any) -> Dict[str, Any]: + out = copy.deepcopy(schema) + node = out + for key in path[:-1]: + node = node[key] + node[path[-1]] = value + return out + + +def _drop_in(schema: Dict[str, Any], path: List[str], key: str) -> Dict[str, Any]: + out = copy.deepcopy(schema) + node = out + for part in path: + node = node[part] + node.pop(key, None) + return out + + +def compat_gate_is_load_bearing() -> None: + print("\ncompatibility gate:") + + baseline = run(sandbox_root, "scripts/check-compat.py", "--from", "0.7.4", "--to", "0.7.5") + check( + "0.7.4 -> 0.7.5 passes as shipped", + baseline.returncode == 0, + f"exit={baseline.returncode}", + ) + + for name, mutate in NARROWINGS: + root = sandbox() + target = root / "schema" / "fluid-schema-0.7.5.json" + target.write_text(json.dumps(mutate(json.loads(target.read_text())), indent=2)) + r = run(root, "scripts/check-compat.py", "--from", "0.7.4", "--to", "0.7.5") + check(f"catches {name}", r.returncode == 1, f"exit={r.returncode}") + shutil.rmtree(root, ignore_errors=True) + + # a waiver must silence exactly its own pointer and nothing else + root = sandbox() + target = root / "schema" / "fluid-schema-0.7.5.json" + target.write_text( + json.dumps(_set_in(json.loads(target.read_text()), ["properties", "exposes", "minItems"], 2), indent=2) + ) + waivers = root / "scripts" / "compat-waivers.txt" + waivers.write_text(waivers.read_text() + "0.7.4->0.7.5 /properties/exposes\n") + r = run(root, "scripts/check-compat.py", "--from", "0.7.4", "--to", "0.7.5") + check("a waiver silences its own pointer", r.returncode == 0, f"exit={r.returncode}") + + # ... but not a different narrowing elsewhere + target.write_text( + json.dumps(_set_in(json.loads(target.read_text()), ["properties", "kind", "enum"], ["DataProduct"]), indent=2) + ) + r = run(root, "scripts/check-compat.py", "--from", "0.7.4", "--to", "0.7.5") + check("a waiver does NOT silence an unrelated narrowing", r.returncode == 1, f"exit={r.returncode}") + shutil.rmtree(root, ignore_errors=True) + + # the empirical pass must catch a narrowing the static pass would miss: + # a pattern that rejects a value the corpus uses, with no structural change. + root = sandbox() + target = root / "schema" / "fluid-schema-0.7.5.json" + schema = json.loads(target.read_text()) + schema["properties"]["name"]["pattern"] = "^ZZZ" + target.write_text(json.dumps(schema, indent=2)) + (root / "tests" / "0.7.4").mkdir(parents=True, exist_ok=True) + shutil.copy( + root / "tests" / "0.7.5" / "envelope.json", root / "tests" / "0.7.4" / "envelope.json" + ) + r = run(root, "scripts/check-compat.py", "--from", "0.7.4", "--to", "0.7.5") + check( + "the empirical pass catches a previously-valid contract being rejected", + r.returncode == 1 and "now rejected" in r.stdout, + f"exit={r.returncode}", + ) + shutil.rmtree(root, ignore_errors=True) + + +if __name__ == "__main__": + sandbox_root = sandbox() + try: + conformance_gate_is_load_bearing() + compat_gate_is_load_bearing() + finally: + shutil.rmtree(sandbox_root, ignore_errors=True) + + failed = [name for ok, name in RESULTS if not ok] + print( + f"\nmeta-test: {len(RESULTS) - len(failed)}/{len(RESULTS)} checks passed" + ) + if failed: + print("\nThese gates are NOT load-bearing:") + for name in failed: + print(f" - {name}") + sys.exit(1 if failed else 0) diff --git a/tests/optional/0.7.5/metadata.json b/tests/optional/0.7.5/metadata.json new file mode 100644 index 0000000..9871bd0 --- /dev/null +++ b/tests/optional/0.7.5/metadata.json @@ -0,0 +1,56 @@ +{ + "corpusVersion": "0.7.5", + "schema": "https://open-data-protocol.github.io/fluid/schema/fluid-schema-0.7.5.json", + "group": "metadata", + "description": "Format-assertion cases for the metadata object: `format` is an annotation, not an assertion, in JSON Schema Draft 2020-12, so an implementation may legitimately accept these documents. These cases exist for implementations that opt into asserting owner.email and metadata.createdAt.", + "cases": [ + { + "description": "owner email is not a well-formed email address", + "contract": { + "fluidVersion": "0.7.5", + "kind": "DataProduct", + "id": "customer_profiles", + "name": "Customer Profiles", + "metadata": { "owner": { "team": "customer-analytics", "email": "not-an-email" } }, + "exposes": [ + { + "exposeId": "profiles_table", + "kind": "table", + "binding": { + "platform": "gcp", + "format": "bigquery_table", + "location": { "project": "acme-prod", "dataset": "customers", "table": "profiles" } + }, + "contract": { "schema": [{ "name": "customer_id", "type": "string" }] } + } + ] + }, + "valid": false, + "errors": [{ "pointer": "/metadata/owner/email", "keyword": "format" }] + }, + { + "description": "createdAt holds a bare date with no time component", + "contract": { + "fluidVersion": "0.7.5", + "kind": "DataProduct", + "id": "customer_profiles", + "name": "Customer Profiles", + "metadata": { "owner": { "team": "customer-analytics" }, "createdAt": "2024-03-01" }, + "exposes": [ + { + "exposeId": "profiles_table", + "kind": "table", + "binding": { + "platform": "gcp", + "format": "bigquery_table", + "location": { "project": "acme-prod", "dataset": "customers", "table": "profiles" } + }, + "contract": { "schema": [{ "name": "customer_id", "type": "string" }] } + } + ] + }, + "valid": false, + "errors": [{ "pointer": "/metadata/createdAt", "keyword": "format" }] + } + ] +}