syntara-plugin-contracts 0.1.0a1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. syntara_plugin_contracts-0.1.0a1/.gitignore +66 -0
  2. syntara_plugin_contracts-0.1.0a1/PKG-INFO +37 -0
  3. syntara_plugin_contracts-0.1.0a1/README.md +13 -0
  4. syntara_plugin_contracts-0.1.0a1/hatch_build.py +31 -0
  5. syntara_plugin_contracts-0.1.0a1/pyproject.toml +44 -0
  6. syntara_plugin_contracts-0.1.0a1/src/syntara_plugin/contracts/__init__.py +45 -0
  7. syntara_plugin_contracts-0.1.0a1/src/syntara_plugin/contracts/bundle.py +277 -0
  8. syntara_plugin_contracts-0.1.0a1/src/syntara_plugin/contracts/canonical.py +60 -0
  9. syntara_plugin_contracts-0.1.0a1/src/syntara_plugin/contracts/descriptor.py +154 -0
  10. syntara_plugin_contracts-0.1.0a1/src/syntara_plugin/contracts/fixtures/action.invalid.json +17 -0
  11. syntara_plugin_contracts-0.1.0a1/src/syntara_plugin/contracts/fixtures/catalog/broken-predecessor.json +6 -0
  12. syntara_plugin_contracts-0.1.0a1/src/syntara_plugin/contracts/fixtures/catalog/duplicate-identity.json +6 -0
  13. syntara_plugin_contracts-0.1.0a1/src/syntara_plugin/contracts/fixtures/catalog/expired.json +6 -0
  14. syntara_plugin_contracts-0.1.0a1/src/syntara_plugin/contracts/fixtures/catalog/replayed.json +6 -0
  15. syntara_plugin_contracts-0.1.0a1/src/syntara_plugin/contracts/fixtures/catalog/unsupported-contract.json +6 -0
  16. syntara_plugin_contracts-0.1.0a1/src/syntara_plugin/contracts/fixtures/catalog/valid-20.json +36 -0
  17. syntara_plugin_contracts-0.1.0a1/src/syntara_plugin/contracts/fixtures/catalog/wrong-source.json +6 -0
  18. syntara_plugin_contracts-0.1.0a1/src/syntara_plugin/contracts/fixtures/catalog-fixture-bundle.json +26 -0
  19. syntara_plugin_contracts-0.1.0a1/src/syntara_plugin/contracts/fixtures/catalog-source-status.valid.json +10 -0
  20. syntara_plugin_contracts-0.1.0a1/src/syntara_plugin/contracts/fixtures/catalog-source.valid.json +17 -0
  21. syntara_plugin_contracts-0.1.0a1/src/syntara_plugin/contracts/fixtures/control.invalid.json +15 -0
  22. syntara_plugin_contracts-0.1.0a1/src/syntara_plugin/contracts/fixtures/control.valid.json +37 -0
  23. syntara_plugin_contracts-0.1.0a1/src/syntara_plugin/contracts/fixtures/credential-recipe.valid.json +12 -0
  24. syntara_plugin_contracts-0.1.0a1/src/syntara_plugin/contracts/fixtures/installed-identity.valid.json +11 -0
  25. syntara_plugin_contracts-0.1.0a1/src/syntara_plugin/contracts/fixtures/integration.valid.json +11 -0
  26. syntara_plugin_contracts-0.1.0a1/src/syntara_plugin/contracts/fixtures/plugin-descriptor.valid.json +118 -0
  27. syntara_plugin_contracts-0.1.0a1/src/syntara_plugin/contracts/fixtures/plugin.valid.json +15 -0
  28. syntara_plugin_contracts-0.1.0a1/src/syntara_plugin/contracts/fixtures/runtime-result-failure.valid.json +11 -0
  29. syntara_plugin_contracts-0.1.0a1/src/syntara_plugin/contracts/fixtures/runtime-result-reference.valid.json +9 -0
  30. syntara_plugin_contracts-0.1.0a1/src/syntara_plugin/contracts/fixtures/runtime-result.valid.json +9 -0
  31. syntara_plugin_contracts-0.1.0a1/src/syntara_plugin/contracts/fixtures/trigger-driver.valid.json +6 -0
  32. syntara_plugin_contracts-0.1.0a1/src/syntara_plugin/contracts/fixtures/trigger-event-mapping.valid.json +11 -0
  33. syntara_plugin_contracts-0.1.0a1/src/syntara_plugin/contracts/fixtures/trigger.invalid.json +14 -0
  34. syntara_plugin_contracts-0.1.0a1/src/syntara_plugin/contracts/fixtures/trigger.valid.json +32 -0
  35. syntara_plugin_contracts-0.1.0a1/src/syntara_plugin/contracts/fixtures.py +112 -0
  36. syntara_plugin_contracts-0.1.0a1/src/syntara_plugin/contracts/image.py +19 -0
  37. syntara_plugin_contracts-0.1.0a1/src/syntara_plugin/contracts/proto/syntara_plugin/runtime/protocol/container.proto +99 -0
  38. syntara_plugin_contracts-0.1.0a1/src/syntara_plugin/contracts/py.typed +1 -0
  39. syntara_plugin_contracts-0.1.0a1/src/syntara_plugin/contracts/schemas/v1alpha1/action.schema.json +64 -0
  40. syntara_plugin_contracts-0.1.0a1/src/syntara_plugin/contracts/schemas/v1alpha1/catalog-index.schema.json +57 -0
  41. syntara_plugin_contracts-0.1.0a1/src/syntara_plugin/contracts/schemas/v1alpha1/catalog-source-status.schema.json +41 -0
  42. syntara_plugin_contracts-0.1.0a1/src/syntara_plugin/contracts/schemas/v1alpha1/catalog-source.schema.json +29 -0
  43. syntara_plugin_contracts-0.1.0a1/src/syntara_plugin/contracts/schemas/v1alpha1/common.schema.json +56 -0
  44. syntara_plugin_contracts-0.1.0a1/src/syntara_plugin/contracts/schemas/v1alpha1/control.schema.json +24 -0
  45. syntara_plugin_contracts-0.1.0a1/src/syntara_plugin/contracts/schemas/v1alpha1/credential-recipe.schema.json +30 -0
  46. syntara_plugin_contracts-0.1.0a1/src/syntara_plugin/contracts/schemas/v1alpha1/http-operation.schema.json +59 -0
  47. syntara_plugin_contracts-0.1.0a1/src/syntara_plugin/contracts/schemas/v1alpha1/http-runner-context.schema.json +30 -0
  48. syntara_plugin_contracts-0.1.0a1/src/syntara_plugin/contracts/schemas/v1alpha1/installed-identity.schema.json +41 -0
  49. syntara_plugin_contracts-0.1.0a1/src/syntara_plugin/contracts/schemas/v1alpha1/integration.schema.json +23 -0
  50. syntara_plugin_contracts-0.1.0a1/src/syntara_plugin/contracts/schemas/v1alpha1/plugin-build-settings.schema.json +181 -0
  51. syntara_plugin_contracts-0.1.0a1/src/syntara_plugin/contracts/schemas/v1alpha1/plugin-descriptor.schema.json +284 -0
  52. syntara_plugin_contracts-0.1.0a1/src/syntara_plugin/contracts/schemas/v1alpha1/plugin.schema.json +57 -0
  53. syntara_plugin_contracts-0.1.0a1/src/syntara_plugin/contracts/schemas/v1alpha1/provider.schema.json +20 -0
  54. syntara_plugin_contracts-0.1.0a1/src/syntara_plugin/contracts/schemas/v1alpha1/runtime.schema.json +59 -0
  55. syntara_plugin_contracts-0.1.0a1/src/syntara_plugin/contracts/schemas/v1alpha1/step.schema.json +64 -0
  56. syntara_plugin_contracts-0.1.0a1/src/syntara_plugin/contracts/schemas/v1alpha1/trigger-driver.schema.json +22 -0
  57. syntara_plugin_contracts-0.1.0a1/src/syntara_plugin/contracts/schemas/v1alpha1/trigger-event-mapping.schema.json +23 -0
  58. syntara_plugin_contracts-0.1.0a1/src/syntara_plugin/contracts/schemas/v1alpha1/trigger.schema.json +25 -0
  59. syntara_plugin_contracts-0.1.0a1/src/syntara_plugin/runtime/__init__.py +192 -0
  60. syntara_plugin_contracts-0.1.0a1/src/syntara_plugin/runtime/abi.py +95 -0
  61. syntara_plugin_contracts-0.1.0a1/src/syntara_plugin/runtime/adapter.py +287 -0
  62. syntara_plugin_contracts-0.1.0a1/src/syntara_plugin/runtime/bindings.py +688 -0
  63. syntara_plugin_contracts-0.1.0a1/src/syntara_plugin/runtime/container.py +714 -0
  64. syntara_plugin_contracts-0.1.0a1/src/syntara_plugin/runtime/context.py +36 -0
  65. syntara_plugin_contracts-0.1.0a1/src/syntara_plugin/runtime/contract_validation.py +2002 -0
  66. syntara_plugin_contracts-0.1.0a1/src/syntara_plugin/runtime/execution.py +97 -0
  67. syntara_plugin_contracts-0.1.0a1/src/syntara_plugin/runtime/http.py +234 -0
  68. syntara_plugin_contracts-0.1.0a1/src/syntara_plugin/runtime/manifest_validation.py +103 -0
  69. syntara_plugin_contracts-0.1.0a1/src/syntara_plugin/runtime/outcome.py +56 -0
  70. syntara_plugin_contracts-0.1.0a1/src/syntara_plugin/runtime/protocol/__init__.py +1 -0
  71. syntara_plugin_contracts-0.1.0a1/src/syntara_plugin/runtime/protocol/container_pb2.py +54 -0
  72. syntara_plugin_contracts-0.1.0a1/src/syntara_plugin/runtime/protocol/container_pb2_grpc.py +192 -0
  73. syntara_plugin_contracts-0.1.0a1/src/syntara_plugin/runtime/py.typed +1 -0
  74. syntara_plugin_contracts-0.1.0a1/src/syntara_plugin/runtime/server.py +334 -0
  75. syntara_plugin_contracts-0.1.0a1/src/syntara_plugin/runtime/step.py +169 -0
  76. syntara_plugin_contracts-0.1.0a1/src/syntara_plugin/runtime/validation.py +97 -0
@@ -0,0 +1,66 @@
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *$py.class
5
+ *.so
6
+ .Python
7
+ build/
8
+ develop-eggs/
9
+ dist/
10
+ downloads/
11
+ eggs/
12
+ .eggs/
13
+ lib/
14
+ lib64/
15
+ parts/
16
+ sdist/
17
+ var/
18
+ wheels/
19
+ pip-wheel-metadata/
20
+ share/python-wheels/
21
+ *.egg-info/
22
+ .installed.cfg
23
+ *.egg
24
+ MANIFEST
25
+
26
+ # Virtual environments
27
+ venv/
28
+ ENV/
29
+ env/
30
+ .venv
31
+
32
+ # IDEs
33
+ .vscode/
34
+ .idea/
35
+ *.swp
36
+ *.swo
37
+ *~
38
+ .DS_Store
39
+
40
+ # Testing
41
+ .pytest_cache/
42
+ .coverage
43
+ htmlcov/
44
+ .tox/
45
+ .nox/
46
+
47
+ # uv
48
+ .uv/
49
+
50
+ # Step SDK specific
51
+ *.compiled.json
52
+ step-definition.json
53
+ examples/*/step-definition.json
54
+
55
+ # Databases
56
+ *.db
57
+ *.sqlite
58
+ *.sqlite3
59
+
60
+ # Logs
61
+ *.log
62
+
63
+ # Environment variables
64
+ .env
65
+ .env.local
66
+ coverage.xml
@@ -0,0 +1,37 @@
1
+ Metadata-Version: 2.5
2
+ Name: syntara-plugin-contracts
3
+ Version: 0.1.0a1
4
+ Summary: Versioned contracts and deterministic bundles for Syntara plugins
5
+ Project-URL: Homepage, https://github.com/syntara-orchestration/syntara-plugin-sdk
6
+ Project-URL: Repository, https://github.com/syntara-orchestration/syntara-plugin-sdk
7
+ Project-URL: Issues, https://github.com/syntara-orchestration/syntara-plugin-sdk/issues
8
+ License-Expression: Apache-2.0
9
+ Classifier: Development Status :: 2 - Pre-Alpha
10
+ Classifier: License :: OSI Approved :: Apache Software License
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Programming Language :: Python :: 3 :: Only
13
+ Classifier: Programming Language :: Python :: 3.12
14
+ Classifier: Programming Language :: Python :: 3.13
15
+ Classifier: Programming Language :: Python :: 3.14
16
+ Classifier: Typing :: Typed
17
+ Requires-Python: >=3.12
18
+ Requires-Dist: grpcio>=1.78.0
19
+ Requires-Dist: jsonschema>=4.23
20
+ Requires-Dist: protobuf>=6.31.1
21
+ Requires-Dist: pydantic>=2.0
22
+ Requires-Dist: referencing>=0.37
23
+ Description-Content-Type: text/markdown
24
+
25
+ # Syntara Plugin Contracts
26
+
27
+ `syntara-plugin-contracts` distributes the versioned Syntara Plugin contract
28
+ bundle and runtime APIs. It contains the canonical JSON Schemas, conformance
29
+ fixtures, protocol definitions, and the Python APIs used by plugin workload
30
+ implementers and the SDK. It also installs the `syntara-plugin-runtime-server`
31
+ console script, which a final Python workload image uses to serve the container
32
+ ABI.
33
+
34
+ The source of truth is maintained in the
35
+ [Syntara Plugin SDK repository](https://github.com/syntara-orchestration/syntara-plugin-sdk).
36
+ Install `syntara-plugin-sdk` to author plugins. Depend on this package directly
37
+ when implementing another contract-aware tool or plugin workload runtime.
@@ -0,0 +1,13 @@
1
+ # Syntara Plugin Contracts
2
+
3
+ `syntara-plugin-contracts` distributes the versioned Syntara Plugin contract
4
+ bundle and runtime APIs. It contains the canonical JSON Schemas, conformance
5
+ fixtures, protocol definitions, and the Python APIs used by plugin workload
6
+ implementers and the SDK. It also installs the `syntara-plugin-runtime-server`
7
+ console script, which a final Python workload image uses to serve the container
8
+ ABI.
9
+
10
+ The source of truth is maintained in the
11
+ [Syntara Plugin SDK repository](https://github.com/syntara-orchestration/syntara-plugin-sdk).
12
+ Install `syntara-plugin-sdk` to author plugins. Depend on this package directly
13
+ when implementing another contract-aware tool or plugin workload runtime.
@@ -0,0 +1,31 @@
1
+ """Stage canonical SDK contracts in both wheels and source distributions."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ from hatchling.builders.hooks.plugin.interface import BuildHookInterface
9
+
10
+
11
+ class CustomBuildHook(BuildHookInterface):
12
+ """Include the one canonical contract tree without a checked-in copy.
13
+
14
+ A source distribution must rebuild without a checkout of the whole SDK
15
+ workspace. During an sdist build this hook maps canonical repository files
16
+ into the Python package path. During a wheel build from that sdist those
17
+ staged files already exist in the package, so no external path is needed.
18
+ """
19
+
20
+ def initialize(self, version: str, build_data: dict[str, Any]) -> None:
21
+ package_contracts = Path(self.root) / "src/syntara_plugin/contracts"
22
+ required = ("schemas", "fixtures", "proto")
23
+ if all((package_contracts / name).is_dir() for name in required):
24
+ return
25
+
26
+ canonical_contracts = Path(self.root).parents[3] / "contracts"
27
+ for name in required:
28
+ source = canonical_contracts / name
29
+ if not source.is_dir():
30
+ raise RuntimeError(f"canonical contract directory is missing: {source}")
31
+ build_data["force_include"][str(source)] = f"src/syntara_plugin/contracts/{name}"
@@ -0,0 +1,44 @@
1
+ [project]
2
+ name = "syntara-plugin-contracts"
3
+ version = "0.1.0a1"
4
+ description = "Versioned contracts and deterministic bundles for Syntara plugins"
5
+ readme = "README.md"
6
+ license = "Apache-2.0"
7
+ requires-python = ">=3.12"
8
+ dependencies = [
9
+ "grpcio>=1.78.0",
10
+ "jsonschema>=4.23",
11
+ "protobuf>=6.31.1",
12
+ "pydantic>=2.0",
13
+ "referencing>=0.37",
14
+ ]
15
+ classifiers = [
16
+ "Development Status :: 2 - Pre-Alpha",
17
+ "License :: OSI Approved :: Apache Software License",
18
+ "Programming Language :: Python :: 3",
19
+ "Programming Language :: Python :: 3 :: Only",
20
+ "Programming Language :: Python :: 3.12",
21
+ "Programming Language :: Python :: 3.13",
22
+ "Programming Language :: Python :: 3.14",
23
+ "Typing :: Typed",
24
+ ]
25
+
26
+ [project.scripts]
27
+ syntara-plugin-runtime-server = "syntara_plugin.runtime.server:main"
28
+
29
+ [project.urls]
30
+ Homepage = "https://github.com/syntara-orchestration/syntara-plugin-sdk"
31
+ Repository = "https://github.com/syntara-orchestration/syntara-plugin-sdk"
32
+ Issues = "https://github.com/syntara-orchestration/syntara-plugin-sdk/issues"
33
+
34
+ [build-system]
35
+ # Keep release builds reproducible; upgrades are reviewed like any other
36
+ # build-tool supply-chain change.
37
+ requires = ["hatchling==1.32.4"]
38
+ build-backend = "hatchling.build"
39
+
40
+ [tool.hatch.build.targets.wheel]
41
+ packages = ["src/syntara_plugin"]
42
+
43
+ [tool.hatch.build.hooks.custom]
44
+ path = "hatch_build.py"
@@ -0,0 +1,45 @@
1
+ """Normative, versioned Syntara plugin contracts."""
2
+
3
+ from .bundle import (
4
+ CatalogCheckpoint,
5
+ CatalogDiagnostic,
6
+ ContractBundle,
7
+ bundle_digest,
8
+ load_bundle,
9
+ validate_catalog_index,
10
+ validate_document,
11
+ )
12
+ from .canonical import (
13
+ CanonicalJsonError,
14
+ canonical_json_bytes,
15
+ canonical_json_digest,
16
+ container_workload_request_digest,
17
+ )
18
+ from .descriptor import (
19
+ materialized_schemas,
20
+ schema_digests,
21
+ validate_plugin_descriptor_semantics,
22
+ )
23
+ from .fixtures import FixtureBundle, fixture_content_digest, load_fixture_bundle
24
+ from .image import is_digest_image_reference
25
+
26
+ __all__ = [
27
+ "CatalogCheckpoint",
28
+ "CatalogDiagnostic",
29
+ "CanonicalJsonError",
30
+ "ContractBundle",
31
+ "FixtureBundle",
32
+ "bundle_digest",
33
+ "canonical_json_bytes",
34
+ "canonical_json_digest",
35
+ "container_workload_request_digest",
36
+ "fixture_content_digest",
37
+ "load_bundle",
38
+ "load_fixture_bundle",
39
+ "materialized_schemas",
40
+ "schema_digests",
41
+ "validate_plugin_descriptor_semantics",
42
+ "is_digest_image_reference",
43
+ "validate_catalog_index",
44
+ "validate_document",
45
+ ]
@@ -0,0 +1,277 @@
1
+ """Load and validate the self-contained v1alpha1 contract bundle."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import json
7
+ import re
8
+ from collections.abc import Mapping
9
+ from dataclasses import dataclass
10
+ from datetime import datetime
11
+ from importlib.resources import files
12
+ from pathlib import Path
13
+ from typing import Any, Literal
14
+
15
+ from jsonschema import Draft202012Validator, FormatChecker
16
+ from referencing import Registry, Resource
17
+
18
+ ContractKind = Literal[
19
+ "plugin",
20
+ "plugin_build_settings",
21
+ "action",
22
+ "control",
23
+ "trigger",
24
+ "step",
25
+ "plugin_descriptor",
26
+ "runtime",
27
+ "provider",
28
+ "catalog_index",
29
+ "catalog_source",
30
+ "catalog_source_status",
31
+ "integration",
32
+ "http_mapping",
33
+ "http_runner_context",
34
+ "credential_recipe",
35
+ "trigger_driver",
36
+ "trigger_event_mapping",
37
+ "installed_identity",
38
+ ]
39
+ _SCHEMA_FILES: dict[ContractKind, str] = {
40
+ "plugin": "plugin.schema.json",
41
+ "plugin_build_settings": "plugin-build-settings.schema.json",
42
+ "action": "action.schema.json",
43
+ "control": "control.schema.json",
44
+ "trigger": "trigger.schema.json",
45
+ "step": "step.schema.json",
46
+ "plugin_descriptor": "plugin-descriptor.schema.json",
47
+ "runtime": "runtime.schema.json",
48
+ "provider": "provider.schema.json",
49
+ "catalog_index": "catalog-index.schema.json",
50
+ "catalog_source": "catalog-source.schema.json",
51
+ "catalog_source_status": "catalog-source-status.schema.json",
52
+ "integration": "integration.schema.json",
53
+ "http_mapping": "http-operation.schema.json",
54
+ "http_runner_context": "http-runner-context.schema.json",
55
+ "credential_recipe": "credential-recipe.schema.json",
56
+ "trigger_driver": "trigger-driver.schema.json",
57
+ "trigger_event_mapping": "trigger-event-mapping.schema.json",
58
+ "installed_identity": "installed-identity.schema.json",
59
+ }
60
+ _RFC3339_DATETIME = re.compile(
61
+ r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$"
62
+ )
63
+ _FORMAT_CHECKER = FormatChecker()
64
+
65
+
66
+ @_FORMAT_CHECKER.checks("date-time", raises=ValueError)
67
+ def _is_rfc3339_datetime(value: object) -> bool:
68
+ """Accept the RFC 3339 subset used by Syntara runtime envelopes."""
69
+
70
+ if not isinstance(value, str) or not _RFC3339_DATETIME.fullmatch(value):
71
+ return False
72
+ datetime.fromisoformat(value.removesuffix("Z") + "+00:00" if value.endswith("Z") else value)
73
+ return True
74
+
75
+
76
+ @dataclass(frozen=True)
77
+ class ContractBundle:
78
+ """An immutable in-process view of the packaged contract documents."""
79
+
80
+ version: str
81
+ digest: str
82
+ documents: dict[str, dict[str, Any]]
83
+
84
+ def validator(self, kind: ContractKind) -> Draft202012Validator:
85
+ """Return a validator with every local schema in this bundle registered."""
86
+ target = self.documents[_SCHEMA_FILES[kind]]
87
+ registry = Registry().with_resources(
88
+ (document["$id"], Resource.from_contents(document))
89
+ for document in self.documents.values()
90
+ )
91
+ return Draft202012Validator(
92
+ target,
93
+ registry=registry,
94
+ format_checker=_FORMAT_CHECKER,
95
+ )
96
+
97
+
98
+ def _schema_directory() -> Any:
99
+ packaged = files("syntara_plugin.contracts").joinpath("schemas", "v1alpha1")
100
+ if packaged.is_dir():
101
+ return packaged
102
+ # Editable source installs consume the repository's language-neutral source.
103
+ for parent in Path(__file__).resolve().parents:
104
+ source = parent / "contracts" / "schemas" / "v1alpha1"
105
+ if source.is_dir():
106
+ return source
107
+ raise RuntimeError("cannot locate packaged or repository contract schemas")
108
+
109
+
110
+ def _canonical_bytes(documents: dict[str, dict[str, Any]]) -> bytes:
111
+ payload = {path: documents[path] for path in sorted(documents)}
112
+ return json.dumps(payload, ensure_ascii=False, separators=(",", ":"), sort_keys=True).encode(
113
+ "utf-8"
114
+ )
115
+
116
+
117
+ def bundle_digest(documents: dict[str, dict[str, Any]]) -> str:
118
+ """Return the stable SHA-256 digest of a complete contract bundle."""
119
+ return "sha256:" + hashlib.sha256(_canonical_bytes(documents)).hexdigest()
120
+
121
+
122
+ def load_bundle() -> ContractBundle:
123
+ """Load the packaged, local-only `syntara.io/v1alpha1` contract bundle."""
124
+ directory = _schema_directory()
125
+ documents: dict[str, dict[str, Any]] = {}
126
+ for entry in sorted(directory.iterdir(), key=lambda item: item.name):
127
+ if entry.name.endswith(".schema.json"):
128
+ documents[entry.name] = json.loads(entry.read_text(encoding="utf-8"))
129
+ if set(_SCHEMA_FILES.values()) - set(documents):
130
+ raise RuntimeError("contract bundle is missing a required manifest or runtime schema")
131
+ return ContractBundle(
132
+ version="syntara.io/v1alpha1",
133
+ digest=bundle_digest(documents),
134
+ documents=documents,
135
+ )
136
+
137
+
138
+ def validate_document(bundle: ContractBundle, kind: ContractKind, document: object) -> list[str]:
139
+ """Return stable, path-prefixed JSON Schema validation errors."""
140
+ errors = sorted(
141
+ bundle.validator(kind).iter_errors(document), key=lambda error: list(error.path)
142
+ )
143
+ return [
144
+ f"{'/'.join(str(part) for part in error.path) or '<root>'}: {error.message}"
145
+ for error in errors
146
+ ]
147
+
148
+
149
+ @dataclass(frozen=True)
150
+ class CatalogCheckpoint:
151
+ """Last accepted immutable catalog state for one configured source.
152
+
153
+ The caller persists this state. The contracts package only compares a
154
+ candidate index to it; it does not fetch, verify signatures, or write data.
155
+ """
156
+
157
+ generation: int = 0
158
+ index_digest: str | None = None
159
+
160
+
161
+ @dataclass(frozen=True, order=True)
162
+ class CatalogDiagnostic:
163
+ """Stable, non-secret reason returned when a catalog candidate is rejected."""
164
+
165
+ code: str
166
+ path: str
167
+ message: str
168
+
169
+
170
+ def validate_catalog_index(
171
+ bundle: ContractBundle,
172
+ document: Mapping[str, Any],
173
+ *,
174
+ source_id: str,
175
+ now: datetime,
176
+ checkpoint: CatalogCheckpoint | None = None,
177
+ ) -> tuple[CatalogDiagnostic, ...]:
178
+ """Validate a catalog index against one source's replay checkpoint.
179
+
180
+ This is intentionally a pure control-plane contract check. OCI resolution,
181
+ signature verification, repository-scope checks, and persistence are owned
182
+ by the catalog service that calls it.
183
+ """
184
+
185
+ checkpoint = checkpoint or CatalogCheckpoint()
186
+ schema_errors = validate_document(bundle, "catalog_index", document)
187
+ if schema_errors:
188
+ return tuple(
189
+ CatalogDiagnostic(
190
+ code="CATALOG_INDEX_SCHEMA_INVALID",
191
+ path=message.partition(":")[0],
192
+ message=message,
193
+ )
194
+ for message in schema_errors
195
+ )
196
+ if now.tzinfo is None or now.utcoffset() is None:
197
+ raise ValueError("now must be timezone-aware")
198
+
199
+ metadata = document["metadata"]
200
+ specification = document["spec"]
201
+ diagnostics: list[CatalogDiagnostic] = []
202
+ if metadata["sourceId"] != source_id:
203
+ diagnostics.append(
204
+ CatalogDiagnostic(
205
+ code="CATALOG_INDEX_SOURCE_MISMATCH",
206
+ path="metadata/sourceId",
207
+ message="Catalog index sourceId does not match the configured source.",
208
+ )
209
+ )
210
+ if specification["contractVersion"] != bundle.version:
211
+ diagnostics.append(
212
+ CatalogDiagnostic(
213
+ code="CATALOG_INDEX_CONTRACT_UNSUPPORTED",
214
+ path="spec/contractVersion",
215
+ message=f"Catalog index contract must be {bundle.version!r}.",
216
+ )
217
+ )
218
+
219
+ issued_at = _parse_rfc3339(metadata["issuedAt"])
220
+ expires_at = _parse_rfc3339(metadata["expiresAt"])
221
+ if expires_at <= issued_at:
222
+ diagnostics.append(
223
+ CatalogDiagnostic(
224
+ code="CATALOG_INDEX_TIME_WINDOW_INVALID",
225
+ path="metadata/expiresAt",
226
+ message="Catalog index expiresAt must be later than issuedAt.",
227
+ )
228
+ )
229
+ if expires_at <= now:
230
+ diagnostics.append(
231
+ CatalogDiagnostic(
232
+ code="CATALOG_INDEX_EXPIRED",
233
+ path="metadata/expiresAt",
234
+ message="Catalog index is expired at the supplied reconciliation time.",
235
+ )
236
+ )
237
+ if metadata["generation"] <= checkpoint.generation:
238
+ diagnostics.append(
239
+ CatalogDiagnostic(
240
+ code="CATALOG_INDEX_GENERATION_REPLAY",
241
+ path="metadata/generation",
242
+ message="Catalog index generation must be greater than the accepted generation.",
243
+ )
244
+ )
245
+ if (
246
+ checkpoint.index_digest is not None
247
+ and metadata.get("previousIndexDigest") != checkpoint.index_digest
248
+ ):
249
+ diagnostics.append(
250
+ CatalogDiagnostic(
251
+ code="CATALOG_INDEX_PREDECESSOR_MISMATCH",
252
+ path="metadata/previousIndexDigest",
253
+ message="Catalog index predecessor does not match the accepted index digest.",
254
+ )
255
+ )
256
+
257
+ seen_identities: set[tuple[str, str, str]] = set()
258
+ for index, entry in enumerate(specification["entries"]):
259
+ identity = (entry["namespace"], entry["name"], entry["version"])
260
+ if identity in seen_identities:
261
+ diagnostics.append(
262
+ CatalogDiagnostic(
263
+ code="CATALOG_ENTRY_IDENTITY_DUPLICATE",
264
+ path=f"spec/entries/{index}",
265
+ message="Catalog index entries must not repeat namespace, name, and version.",
266
+ )
267
+ )
268
+ seen_identities.add(identity)
269
+ return tuple(sorted(diagnostics))
270
+
271
+
272
+ def _parse_rfc3339(value: str) -> datetime:
273
+ """Parse a value already checked by the bundle's strict date-time format."""
274
+
275
+ return datetime.fromisoformat(
276
+ value.removesuffix("Z") + "+00:00" if value.endswith("Z") else value
277
+ )
@@ -0,0 +1,60 @@
1
+ """Normative canonical JSON and generic-container request hashing helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import json
7
+ from collections.abc import Mapping
8
+ from copy import deepcopy
9
+ from typing import Any
10
+
11
+
12
+ class CanonicalJsonError(ValueError):
13
+ """A value cannot be represented by the Syntara canonical JSON form."""
14
+
15
+
16
+ def canonical_json_bytes(value: object) -> bytes:
17
+ """Return the strict deterministic JSON form used by versioned contracts.
18
+
19
+ The format is UTF-8 JSON with sorted object keys, compact separators, and
20
+ no non-finite numbers. Other language SDKs must implement this exact form
21
+ before they calculate a compatible request hash.
22
+ """
23
+
24
+ try:
25
+ return json.dumps(
26
+ value,
27
+ allow_nan=False,
28
+ ensure_ascii=False,
29
+ separators=(",", ":"),
30
+ sort_keys=True,
31
+ ).encode("utf-8")
32
+ except (TypeError, ValueError) as error:
33
+ raise CanonicalJsonError("value is not canonical JSON") from error
34
+
35
+
36
+ def canonical_json_digest(value: object) -> str:
37
+ """Return the SHA-256 digest of one canonical JSON value."""
38
+
39
+ return f"sha256:{hashlib.sha256(canonical_json_bytes(value)).hexdigest()}"
40
+
41
+
42
+ def container_workload_request_digest(request: Mapping[str, Any]) -> str:
43
+ """Hash a container workload request without its two mirrored hash fields.
44
+
45
+ The work-item and policy snapshot each carry the resulting digest. Removing
46
+ both fields before hashing avoids a circular representation while ensuring
47
+ every other execution, policy, and runtime value is committed.
48
+ """
49
+
50
+ try:
51
+ canonical_request = deepcopy(dict(request))
52
+ for section_name in ("workItem", "policySnapshot"):
53
+ section = canonical_request.get(section_name)
54
+ if isinstance(section, Mapping):
55
+ section_copy = dict(section)
56
+ section_copy.pop("requestHash", None)
57
+ canonical_request[section_name] = section_copy
58
+ except (TypeError, ValueError) as error:
59
+ raise CanonicalJsonError("container workload request is not canonical JSON") from error
60
+ return canonical_json_digest(canonical_request)