feat: validate workflow contract references

This commit is contained in:
lda
2026-08-03 07:13:29 +07:00 Verified
parent ad030f8074
commit ccddfdfcf8
3 changed files with 255 additions and 8 deletions
+52
View File
@@ -0,0 +1,52 @@
# Task 2 Report
## Scope
Implemented fail-closed contract and `$ref` validation for
`wf_contract_manifest.manifest_from_openrpc()` without changing the Task 1
public interface. JSON Schema keywords remain opaque; validation is limited to
the OpenRPC envelope, normalized operation/component values, and their `$ref`
graph.
## TDD Evidence
- Added tests for unsupported OpenRPC versions, malformed envelopes, duplicate
methods, malformed dotted names, invalid parameters, invalid result shapes,
and non-component success results.
- Added tests for external, unsupported-local, unsupported-component,
dangling, and escaped component references.
- Added a passing nested schema reference case while retaining the existing
error-component reference assertion.
- Ran the new tests before implementation: 10 cases failed for the intended
missing validation behaviors; existing Task 1 coverage remained green.
## Implementation
- Pins `$.openrpc` to `1.2.6`.
- Rejects duplicate method names at the later method path.
- Uses the strict `malformed dotted method name` diagnostic.
- Requires success results to be exactly a local `components/schemas` `$ref`
object.
- Walks every operation parameter/result/error and every normalized component,
recursively inspecting only `$ref` values.
- Rejects external refs, unsupported namespaces, escaped component keys, and
dangling refs; forward refs are accepted after the complete manifest is
assembled.
## Verification
```text
39 passed in 0.10s
ruff check: All checks passed!
basedpyright --level error: 0 errors, 0 warnings, 0 notes
git diff --check: passed (only Git line-ending warnings)
```
## Commit
The Task 2 commit is recorded by the final response after the final verification
and scope audit.
## Concerns
None identified.
+95 -4
View File
@@ -1,6 +1,6 @@
from __future__ import annotations from __future__ import annotations
from collections.abc import Mapping from collections.abc import Iterator, Mapping
from .model import ( from .model import (
ContractManifest, ContractManifest,
@@ -11,6 +11,8 @@ from .model import (
ManifestParameter, ManifestParameter,
) )
type ComponentIndex = dict[str, set[str]]
def _mapping(value: object, path: str) -> Mapping[str, object]: def _mapping(value: object, path: str) -> Mapping[str, object]:
if not isinstance(value, Mapping): if not isinstance(value, Mapping):
@@ -60,15 +62,82 @@ def _schema(value: object, path: str) -> JsonSchema:
return normalized return normalized
def _walk_references(value: JsonValue, path: str) -> Iterator[tuple[str, str]]:
"""Yield every ``$ref`` while treating JSON Schema vocabulary as opaque."""
if isinstance(value, dict):
for key, child in value.items():
child_path = f"{path}.{key}"
if key == "$ref":
if not isinstance(child, str):
raise ManifestError(child_path, "expected a reference string")
yield child_path, child
else:
yield from _walk_references(child, child_path)
elif isinstance(value, list):
for index, child in enumerate(value):
yield from _walk_references(child, f"{path}[{index}]")
def _validate_references(
manifest: ContractManifest, component_index: ComponentIndex
) -> None:
values: list[tuple[str, JsonValue]] = []
for operation_index, operation in enumerate(manifest["operations"]):
operation_path = f"$.operations[{operation_index}]"
for parameter_index, parameter in enumerate(operation["params"]):
values.append(
(
f"{operation_path}.params[{parameter_index}].schema",
parameter["schema"],
)
)
values.append((f"{operation_path}.result.schema", operation["result"]["schema"]))
for error_index, error in enumerate(operation["errors"]):
values.append((f"{operation_path}.errors[{error_index}]", error))
for key, component in manifest["components"]["schemas"].items():
values.append((f"$.components.schemas.{key}", component))
for key, component in manifest["components"]["errors"].items():
values.append((f"$.components.errors.{key}", component))
for value_path, value in values:
for reference_path, reference in _walk_references(value, value_path):
if not reference.startswith("#/"):
raise ManifestError(reference_path, "external references are not supported")
parts = reference[2:].split("/")
if len(parts) != 3 or parts[0] != "components":
raise ManifestError(
reference_path, "unsupported local reference namespace"
)
namespace, key = parts[1], parts[2]
if namespace not in component_index:
raise ManifestError(
reference_path, "unsupported component reference namespace"
)
if "~0" in key or "~1" in key:
raise ManifestError(
reference_path, "unsupported escaped component reference"
)
if key not in component_index[namespace]:
raise ManifestError(reference_path, "dangling local reference")
def manifest_from_openrpc(document: Mapping[str, object]) -> ContractManifest: def manifest_from_openrpc(document: Mapping[str, object]) -> ContractManifest:
"""Normalize an OpenRPC document into the stable workflow contract shape.""" """Normalize an OpenRPC document into the stable workflow contract shape."""
openrpc_version = _string(document.get("openrpc"), "$.openrpc") openrpc_version = _string(document.get("openrpc"), "$.openrpc")
if openrpc_version != "1.2.6":
raise ManifestError(
"$.openrpc",
f"unsupported OpenRPC version '{openrpc_version}'; expected '1.2.6'",
)
methods = _list(document.get("methods"), "$.methods") methods = _list(document.get("methods"), "$.methods")
components = _mapping(document.get("components"), "$.components") components = _mapping(document.get("components"), "$.components")
schemas = _mapping(components.get("schemas"), "$.components.schemas") schemas = _mapping(components.get("schemas"), "$.components.schemas")
component_errors = _mapping(components.get("errors"), "$.components.errors") component_errors = _mapping(components.get("errors"), "$.components.errors")
operations: list[ManifestOperation] = [] operations: list[ManifestOperation] = []
seen_methods: set[str] = set()
for method_index, method_value in enumerate(methods): for method_index, method_value in enumerate(methods):
method_path = f"$.methods[{method_index}]" method_path = f"$.methods[{method_index}]"
method = _mapping(method_value, method_path) method = _mapping(method_value, method_path)
@@ -77,8 +146,13 @@ def manifest_from_openrpc(document: Mapping[str, object]) -> ContractManifest:
if len(segments) < 2 or any(not segment for segment in segments): if len(segments) < 2 or any(not segment for segment in segments):
raise ManifestError( raise ManifestError(
f"{method_path}.name", f"{method_path}.name",
"expected a method name with non-empty dot-separated segments", "malformed dotted method name",
) )
if method_name in seen_methods:
raise ManifestError(
f"{method_path}.name", f"duplicate method '{method_name}'"
)
seen_methods.add(method_name)
params: list[ManifestParameter] = [] params: list[ManifestParameter] = []
for parameter_index, parameter_value in enumerate( for parameter_index, parameter_value in enumerate(
@@ -100,6 +174,17 @@ def manifest_from_openrpc(document: Mapping[str, object]) -> ContractManifest:
result_path = f"{method_path}.result" result_path = f"{method_path}.result"
result = _mapping(method.get("result"), result_path) result = _mapping(method.get("result"), result_path)
if "schema" not in result:
raise ManifestError(f"{result_path}.schema", "expected an object")
result_schema = _schema(result["schema"], f"{result_path}.schema")
if set(result_schema) != {"$ref"} or not (
isinstance(result_schema["$ref"], str)
and result_schema["$ref"].startswith("#/components/schemas/")
):
raise ManifestError(
f"{result_path}.schema",
"success result must reference a named schema component",
)
errors: list[JsonSchema] = [] errors: list[JsonSchema] = []
for error_index, error_value in enumerate( for error_index, error_value in enumerate(
_list(method.get("errors"), f"{method_path}.errors") _list(method.get("errors"), f"{method_path}.errors")
@@ -114,7 +199,7 @@ def manifest_from_openrpc(document: Mapping[str, object]) -> ContractManifest:
"namespace": segments[:-1], "namespace": segments[:-1],
"action": segments[-1], "action": segments[-1],
"params": params, "params": params,
"result": {"schema": _schema(result.get("schema"), f"{result_path}.schema")}, "result": {"schema": result_schema},
"errors": errors, "errors": errors,
} }
) )
@@ -128,9 +213,15 @@ def manifest_from_openrpc(document: Mapping[str, object]) -> ContractManifest:
for key in sorted(component_errors) for key in sorted(component_errors)
} }
return { manifest: ContractManifest = {
"manifest_version": 1, "manifest_version": 1,
"source": {"format": "openrpc", "openrpc_version": openrpc_version}, "source": {"format": "openrpc", "openrpc_version": openrpc_version},
"operations": sorted(operations, key=lambda operation: operation["method"]), "operations": sorted(operations, key=lambda operation: operation["method"]),
"components": {"schemas": normalized_schemas, "errors": normalized_errors}, "components": {"schemas": normalized_schemas, "errors": normalized_errors},
} }
component_index = {
"schemas": set(manifest["components"]["schemas"]),
"errors": set(manifest["components"]["errors"]),
}
_validate_references(manifest, component_index)
return manifest
+108 -4
View File
@@ -1,5 +1,6 @@
from __future__ import annotations from __future__ import annotations
from copy import deepcopy
from typing import Any from typing import Any
import pytest import pytest
@@ -152,18 +153,18 @@ def test_rejects_invalid_or_missing_top_level_envelope_values(
("name", "message"), ("name", "message"),
[ [
("", "expected a non-empty string"), ("", "expected a non-empty string"),
("workflow", "expected a method name with non-empty dot-separated segments"), ("workflow", "malformed dotted method name"),
( (
"workflow..run", "workflow..run",
"expected a method name with non-empty dot-separated segments", "malformed dotted method name",
), ),
( (
".workflow.run", ".workflow.run",
"expected a method name with non-empty dot-separated segments", "malformed dotted method name",
), ),
( (
"workflow.run.", "workflow.run.",
"expected a method name with non-empty dot-separated segments", "malformed dotted method name",
), ),
], ],
) )
@@ -222,3 +223,106 @@ def test_rejects_invalid_method_error_schema_shape() -> None:
"$.methods[1].errors[0]", "$.methods[1].errors[0]",
"expected a schema object", "expected a schema object",
) )
@pytest.mark.parametrize(
("mutate", "path", "message"),
[
(
lambda document: document.update({"openrpc": "2.0.0"}),
"$.openrpc",
"unsupported OpenRPC version '2.0.0'; expected '1.2.6'",
),
(lambda document: document.update({"methods": {}}), "$.methods", "expected an array"),
(
lambda document: document["methods"].append(
deepcopy(document["methods"][0])
),
"$.methods[2].name",
"duplicate method 'workflow.zeta.run'",
),
(
lambda document: document["methods"][0].update({"name": "workflow..run"}),
"$.methods[0].name",
"malformed dotted method name",
),
(
lambda document: document["methods"][0].update({"params": {}}),
"$.methods[0].params",
"expected an array",
),
(
lambda document: document["methods"][0]["params"].append(
{"name": "value", "required": "yes", "schema": {"type": "string"}}
),
"$.methods[0].params[0].required",
"expected a boolean",
),
(
lambda document: document["methods"][0].update({"result": {}}),
"$.methods[0].result.schema",
"expected an object",
),
(
lambda document: document["methods"][0]["result"].update(
{
"schema": {
"type": "object",
"properties": {"ok": {"type": "boolean"}},
}
}
),
"$.methods[0].result.schema",
"success result must reference a named schema component",
),
],
)
def test_rejects_malformed_openrpc_contracts(
mutate: Any, path: str, message: str
) -> None:
document = synthetic_openrpc_document()
mutate(document)
assert_manifest_error(document, path, message)
@pytest.mark.parametrize(
("reference", "message"),
[
("https://example.test/schema.json", "external references are not supported"),
("#/definitions/Result", "unsupported local reference namespace"),
(
"#/components/parameters/Value",
"unsupported component reference namespace",
),
("#/components/schemas/Missing", "dangling local reference"),
(
"#/components/schemas/A~0B",
"unsupported escaped component reference",
),
],
)
def test_rejects_unsupported_or_dangling_references(
reference: str, message: str
) -> None:
document = synthetic_openrpc_document()
document["components"]["schemas"]["AlphaResult"]["properties"]["linked"] = {
"$ref": reference
}
with pytest.raises(ManifestError) as exc_info:
manifest_from_openrpc(document)
assert exc_info.value.path.endswith(".properties.linked.$ref")
assert exc_info.value.message == message
def test_accepts_nested_schema_and_error_component_references() -> None:
document = synthetic_openrpc_document()
document["components"]["schemas"]["AlphaResult"]["properties"]["linked"] = {
"$ref": "#/components/schemas/ZetaResult"
}
manifest = manifest_from_openrpc(document)
assert manifest["operations"][0]["errors"] == [{"$ref": "#/components/errors/5000"}]