validibot-shared 0.10.0__tar.gz → 0.17.0__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 (42) hide show
  1. {validibot_shared-0.10.0 → validibot_shared-0.17.0}/PKG-INFO +69 -8
  2. {validibot_shared-0.10.0 → validibot_shared-0.17.0}/README.md +66 -6
  3. {validibot_shared-0.10.0 → validibot_shared-0.17.0}/pyproject.toml +3 -3
  4. validibot_shared-0.17.0/tests/test_artifacts.py +124 -0
  5. validibot_shared-0.17.0/tests/test_callback_nonce_contract.py +144 -0
  6. validibot_shared-0.17.0/tests/test_canonicalization.py +163 -0
  7. {validibot_shared-0.10.0 → validibot_shared-0.17.0}/tests/test_energyplus_envelopes.py +21 -0
  8. {validibot_shared-0.10.0 → validibot_shared-0.17.0}/tests/test_evidence_manifest.py +78 -0
  9. {validibot_shared-0.10.0 → validibot_shared-0.17.0}/tests/test_fmu_envelopes.py +26 -1
  10. validibot_shared-0.17.0/tests/test_schematron_envelopes.py +224 -0
  11. validibot_shared-0.17.0/tests/test_schematron_svrl.py +288 -0
  12. {validibot_shared-0.10.0 → validibot_shared-0.17.0}/tests/test_shacl_envelopes.py +36 -7
  13. validibot_shared-0.17.0/tests/test_validations_envelopes.py +333 -0
  14. {validibot_shared-0.10.0 → validibot_shared-0.17.0}/validibot_shared/__init__.py +12 -0
  15. validibot_shared-0.17.0/validibot_shared/_vendor/__init__.py +1 -0
  16. validibot_shared-0.17.0/validibot_shared/_vendor/jcs.py +191 -0
  17. validibot_shared-0.17.0/validibot_shared/canonicalization.py +131 -0
  18. {validibot_shared-0.10.0 → validibot_shared-0.17.0}/validibot_shared/energyplus/envelopes.py +2 -2
  19. {validibot_shared-0.10.0 → validibot_shared-0.17.0}/validibot_shared/energyplus/models.py +4 -4
  20. {validibot_shared-0.10.0 → validibot_shared-0.17.0}/validibot_shared/evidence/__init__.py +6 -0
  21. {validibot_shared-0.10.0 → validibot_shared-0.17.0}/validibot_shared/evidence/manifest.py +180 -0
  22. {validibot_shared-0.10.0 → validibot_shared-0.17.0}/validibot_shared/fmu/envelopes.py +29 -0
  23. {validibot_shared-0.10.0 → validibot_shared-0.17.0}/validibot_shared/fmu/models.py +1 -1
  24. validibot_shared-0.17.0/validibot_shared/schematron/__init__.py +49 -0
  25. validibot_shared-0.17.0/validibot_shared/schematron/envelopes.py +319 -0
  26. validibot_shared-0.17.0/validibot_shared/schematron/svrl.py +277 -0
  27. {validibot_shared-0.10.0 → validibot_shared-0.17.0}/validibot_shared/shacl/envelopes.py +28 -5
  28. validibot_shared-0.17.0/validibot_shared/validations/artifacts.py +171 -0
  29. {validibot_shared-0.10.0 → validibot_shared-0.17.0}/validibot_shared/validations/envelopes.py +196 -12
  30. validibot_shared-0.10.0/tests/test_validations_envelopes.py +0 -144
  31. {validibot_shared-0.10.0 → validibot_shared-0.17.0}/.gitignore +0 -0
  32. {validibot_shared-0.10.0 → validibot_shared-0.17.0}/LICENSE +0 -0
  33. {validibot_shared-0.10.0 → validibot_shared-0.17.0}/NOTICE +0 -0
  34. {validibot_shared-0.10.0 → validibot_shared-0.17.0}/tests/__init__.py +0 -0
  35. {validibot_shared-0.10.0 → validibot_shared-0.17.0}/tests/test_energyplus_models.py +0 -0
  36. {validibot_shared-0.10.0 → validibot_shared-0.17.0}/tests/test_fmu_models.py +0 -0
  37. {validibot_shared-0.10.0 → validibot_shared-0.17.0}/tests/test_package_init.py +0 -0
  38. {validibot_shared-0.10.0 → validibot_shared-0.17.0}/validibot_shared/energyplus/__init__.py +0 -0
  39. {validibot_shared-0.10.0 → validibot_shared-0.17.0}/validibot_shared/fmu/__init__.py +0 -0
  40. {validibot_shared-0.10.0 → validibot_shared-0.17.0}/validibot_shared/py.typed +0 -0
  41. {validibot_shared-0.10.0 → validibot_shared-0.17.0}/validibot_shared/shacl/__init__.py +0 -0
  42. {validibot_shared-0.10.0 → validibot_shared-0.17.0}/validibot_shared/validations/__init__.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: validibot-shared
3
- Version: 0.10.0
3
+ Version: 0.17.0
4
4
  Summary: Shared library for data interchange between Validibot and validator containers
5
5
  Project-URL: Homepage, https://validibot.com
6
6
  Project-URL: Documentation, https://docs.validibot.com
@@ -24,10 +24,11 @@ Classifier: Programming Language :: Python :: 3.13
24
24
  Classifier: Topic :: Scientific/Engineering
25
25
  Classifier: Topic :: Software Development :: Libraries :: Python Modules
26
26
  Requires-Python: >=3.10
27
+ Requires-Dist: defusedxml<0.8,>=0.7.1
27
28
  Requires-Dist: pydantic<3.0,>=2.13
28
29
  Provides-Extra: dev
29
30
  Requires-Dist: pytest==9.1.1; extra == 'dev'
30
- Requires-Dist: ruff==0.15.18; extra == 'dev'
31
+ Requires-Dist: ruff==0.15.21; extra == 'dev'
31
32
  Description-Content-Type: text/markdown
32
33
 
33
34
  <div align="center">
@@ -165,12 +166,49 @@ Supporting models include:
165
166
 
166
167
  | Class | Purpose |
167
168
  |-------|---------|
168
- | `InputFileItem` | File reference with URI, MIME type, and role |
169
+ | `InputFileItem` | File reference with URI, MIME type, role, exact size, SHA-256, and immutable storage version |
170
+ | `ResourceFileItem` | Managed auxiliary file reference with safe name, URI, resource type, and byte identity |
169
171
  | `ValidatorInfo` | Validator identification (ID, type, version) |
170
- | `ExecutionContext` | Callback URL, execution bundle URI, timeout |
172
+ | `ExecutionContext` | Attempt identity, callback nonce commitment, bundle URI, and timeout |
171
173
  | `ValidationMessage` | Individual finding (error, warning, info) |
172
174
  | `ValidationMetric` | Named numeric metric with optional unit |
173
175
  | `ValidationArtifact` | Output file reference (reports, logs, etc.) |
176
+ | `ArtifactRef` | Indexed run artifact reference for workflow/evidence control planes |
177
+ | `FilePortContract` | Shared file-port vocabulary for declared validator file inputs/outputs |
178
+
179
+ ### Designing File Inputs
180
+
181
+ The shared envelope exposes file inputs as `input_files` and `resource_files`.
182
+ Validator authors should still design those files as **declared ports** in the
183
+ core platform.
184
+
185
+ A file port answers:
186
+
187
+ - what the file means to the validator (`primary_model`, `weather_file`,
188
+ `data_graph`, `xml_document`, `schema_file`, `fmu_model`);
189
+ - how many files are valid (`1..1`, `0..1`, or a future collection);
190
+ - which envelope channel it renders into (`input_files` or `resource_files`);
191
+ - which backend role/type it uses (`primary-model`, `weather`, `fmu`,
192
+ `data-graph`);
193
+ - which formats and MIME types are accepted;
194
+ - whether the source may be a submitted file, workflow resource, upstream
195
+ artifact, or signal containing an artifact reference.
196
+
197
+ Keep small configuration in typed `inputs`. Use file/resource/artifact ports
198
+ for bytes. For example, EnergyPlus timestep settings belong in
199
+ `EnergyPlusInputs`; the IDF/epJSON model and EPW weather file belong in file
200
+ ports rendered to `input_files` / `resource_files`.
201
+
202
+ Backends should read files by role and `port_key` when available, not by
203
+ assuming `input_files[0]` forever. Every file item commits to an exact size,
204
+ SHA-256, and provider-specific immutable storage version; runtimes must verify
205
+ those fields while streaming before a validator parses or executes the bytes.
206
+
207
+ `ArtifactRef` and `FilePortContract` live in
208
+ `validibot_shared.validations.artifacts`. `InputFileItem` and
209
+ `ResourceFileItem` also accept an optional `port_key` so a backend or evidence
210
+ builder can correlate an envelope item back to the declared Validibot port
211
+ without relying only on backend role/type strings.
174
212
 
175
213
  ### Typed Subclassing Pattern
176
214
 
@@ -203,9 +241,14 @@ validibot_shared/
203
241
  ├── energyplus/ # EnergyPlus-specific models and envelopes
204
242
  │ ├── models.py # Simulation output models (metrics, results)
205
243
  │ └── envelopes.py # Typed envelope subclasses
206
- └── fmu/ # FMU-specific models
207
- ├── models.py # Probe result models
208
- └── envelopes.py # FMU envelope subclasses
244
+ ├── fmu/ # FMU-specific models
245
+ ├── models.py # Probe/simulation result models
246
+ └── envelopes.py # FMU envelope subclasses
247
+ ├── shacl/ # SHACL isolated-backend envelopes
248
+ │ └── envelopes.py # SHACL inputs/outputs and builder
249
+ └── schematron/ # Schematron isolated-backend envelopes
250
+ ├── envelopes.py # Schematron inputs/outputs and builder
251
+ └── svrl.py # SVRL parsing helpers
209
252
  ```
210
253
 
211
254
  ## Usage Examples
@@ -213,8 +256,12 @@ validibot_shared/
213
256
  ### Creating an Input Envelope
214
257
 
215
258
  ```python
259
+ import secrets
260
+
261
+ from validibot_shared.canonicalization import compute_callback_nonce_commitment
216
262
  from validibot_shared.energyplus import EnergyPlusInputEnvelope, EnergyPlusInputs
217
263
  from validibot_shared.validations.envelopes import (
264
+ ATTEMPT_CONTRACT_VERSION,
218
265
  ExecutionContext,
219
266
  InputFileItem,
220
267
  OrganizationInfo,
@@ -224,6 +271,8 @@ from validibot_shared.validations.envelopes import (
224
271
  WorkflowInfo,
225
272
  )
226
273
 
274
+ callback_nonce = secrets.token_urlsafe(32)
275
+
227
276
  envelope = EnergyPlusInputEnvelope(
228
277
  run_id="run-123",
229
278
  validator=ValidatorInfo(
@@ -243,12 +292,24 @@ envelope = EnergyPlusInputEnvelope(
243
292
  mime_type=SupportedMimeType.ENERGYPLUS_IDF,
244
293
  role="primary-model",
245
294
  uri="gs://bucket/model.idf",
295
+ size_bytes=12345,
296
+ sha256="0123456789abcdef" * 4,
297
+ storage_version="1700000000000000",
246
298
  ),
247
299
  ],
248
300
  inputs=EnergyPlusInputs(timestep_per_hour=4),
249
301
  context=ExecutionContext(
250
302
  callback_url="https://api.example.com/callback",
251
- execution_bundle_uri="gs://bucket/run-123/",
303
+ callback_id="execution-attempt-attempt-123",
304
+ callback_nonce=callback_nonce,
305
+ callback_nonce_commitment=compute_callback_nonce_commitment(callback_nonce),
306
+ execution_bundle_uri="gs://bucket/runs/org-123/run-123/attempts/attempt-123/",
307
+ execution_attempt_id="attempt-123",
308
+ step_run_id="step-run-789",
309
+ attempt_contract_version=ATTEMPT_CONTRACT_VERSION,
310
+ expected_output_uri=(
311
+ "gs://bucket/runs/org-123/run-123/attempts/attempt-123/output.json"
312
+ ),
252
313
  ),
253
314
  )
254
315
 
@@ -133,12 +133,49 @@ Supporting models include:
133
133
 
134
134
  | Class | Purpose |
135
135
  |-------|---------|
136
- | `InputFileItem` | File reference with URI, MIME type, and role |
136
+ | `InputFileItem` | File reference with URI, MIME type, role, exact size, SHA-256, and immutable storage version |
137
+ | `ResourceFileItem` | Managed auxiliary file reference with safe name, URI, resource type, and byte identity |
137
138
  | `ValidatorInfo` | Validator identification (ID, type, version) |
138
- | `ExecutionContext` | Callback URL, execution bundle URI, timeout |
139
+ | `ExecutionContext` | Attempt identity, callback nonce commitment, bundle URI, and timeout |
139
140
  | `ValidationMessage` | Individual finding (error, warning, info) |
140
141
  | `ValidationMetric` | Named numeric metric with optional unit |
141
142
  | `ValidationArtifact` | Output file reference (reports, logs, etc.) |
143
+ | `ArtifactRef` | Indexed run artifact reference for workflow/evidence control planes |
144
+ | `FilePortContract` | Shared file-port vocabulary for declared validator file inputs/outputs |
145
+
146
+ ### Designing File Inputs
147
+
148
+ The shared envelope exposes file inputs as `input_files` and `resource_files`.
149
+ Validator authors should still design those files as **declared ports** in the
150
+ core platform.
151
+
152
+ A file port answers:
153
+
154
+ - what the file means to the validator (`primary_model`, `weather_file`,
155
+ `data_graph`, `xml_document`, `schema_file`, `fmu_model`);
156
+ - how many files are valid (`1..1`, `0..1`, or a future collection);
157
+ - which envelope channel it renders into (`input_files` or `resource_files`);
158
+ - which backend role/type it uses (`primary-model`, `weather`, `fmu`,
159
+ `data-graph`);
160
+ - which formats and MIME types are accepted;
161
+ - whether the source may be a submitted file, workflow resource, upstream
162
+ artifact, or signal containing an artifact reference.
163
+
164
+ Keep small configuration in typed `inputs`. Use file/resource/artifact ports
165
+ for bytes. For example, EnergyPlus timestep settings belong in
166
+ `EnergyPlusInputs`; the IDF/epJSON model and EPW weather file belong in file
167
+ ports rendered to `input_files` / `resource_files`.
168
+
169
+ Backends should read files by role and `port_key` when available, not by
170
+ assuming `input_files[0]` forever. Every file item commits to an exact size,
171
+ SHA-256, and provider-specific immutable storage version; runtimes must verify
172
+ those fields while streaming before a validator parses or executes the bytes.
173
+
174
+ `ArtifactRef` and `FilePortContract` live in
175
+ `validibot_shared.validations.artifacts`. `InputFileItem` and
176
+ `ResourceFileItem` also accept an optional `port_key` so a backend or evidence
177
+ builder can correlate an envelope item back to the declared Validibot port
178
+ without relying only on backend role/type strings.
142
179
 
143
180
  ### Typed Subclassing Pattern
144
181
 
@@ -171,9 +208,14 @@ validibot_shared/
171
208
  ├── energyplus/ # EnergyPlus-specific models and envelopes
172
209
  │ ├── models.py # Simulation output models (metrics, results)
173
210
  │ └── envelopes.py # Typed envelope subclasses
174
- └── fmu/ # FMU-specific models
175
- ├── models.py # Probe result models
176
- └── envelopes.py # FMU envelope subclasses
211
+ ├── fmu/ # FMU-specific models
212
+ ├── models.py # Probe/simulation result models
213
+ └── envelopes.py # FMU envelope subclasses
214
+ ├── shacl/ # SHACL isolated-backend envelopes
215
+ │ └── envelopes.py # SHACL inputs/outputs and builder
216
+ └── schematron/ # Schematron isolated-backend envelopes
217
+ ├── envelopes.py # Schematron inputs/outputs and builder
218
+ └── svrl.py # SVRL parsing helpers
177
219
  ```
178
220
 
179
221
  ## Usage Examples
@@ -181,8 +223,12 @@ validibot_shared/
181
223
  ### Creating an Input Envelope
182
224
 
183
225
  ```python
226
+ import secrets
227
+
228
+ from validibot_shared.canonicalization import compute_callback_nonce_commitment
184
229
  from validibot_shared.energyplus import EnergyPlusInputEnvelope, EnergyPlusInputs
185
230
  from validibot_shared.validations.envelopes import (
231
+ ATTEMPT_CONTRACT_VERSION,
186
232
  ExecutionContext,
187
233
  InputFileItem,
188
234
  OrganizationInfo,
@@ -192,6 +238,8 @@ from validibot_shared.validations.envelopes import (
192
238
  WorkflowInfo,
193
239
  )
194
240
 
241
+ callback_nonce = secrets.token_urlsafe(32)
242
+
195
243
  envelope = EnergyPlusInputEnvelope(
196
244
  run_id="run-123",
197
245
  validator=ValidatorInfo(
@@ -211,12 +259,24 @@ envelope = EnergyPlusInputEnvelope(
211
259
  mime_type=SupportedMimeType.ENERGYPLUS_IDF,
212
260
  role="primary-model",
213
261
  uri="gs://bucket/model.idf",
262
+ size_bytes=12345,
263
+ sha256="0123456789abcdef" * 4,
264
+ storage_version="1700000000000000",
214
265
  ),
215
266
  ],
216
267
  inputs=EnergyPlusInputs(timestep_per_hour=4),
217
268
  context=ExecutionContext(
218
269
  callback_url="https://api.example.com/callback",
219
- execution_bundle_uri="gs://bucket/run-123/",
270
+ callback_id="execution-attempt-attempt-123",
271
+ callback_nonce=callback_nonce,
272
+ callback_nonce_commitment=compute_callback_nonce_commitment(callback_nonce),
273
+ execution_bundle_uri="gs://bucket/runs/org-123/run-123/attempts/attempt-123/",
274
+ execution_attempt_id="attempt-123",
275
+ step_run_id="step-run-789",
276
+ attempt_contract_version=ATTEMPT_CONTRACT_VERSION,
277
+ expected_output_uri=(
278
+ "gs://bucket/runs/org-123/run-123/attempts/attempt-123/output.json"
279
+ ),
220
280
  ),
221
281
  )
222
282
 
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "validibot-shared"
7
- version = "0.10.0"
7
+ version = "0.17.0"
8
8
  description = "Shared library for data interchange between Validibot and validator containers"
9
9
  readme = "README.md"
10
10
  license = { text = "MIT" }
@@ -34,10 +34,10 @@ classifiers = [
34
34
  "Topic :: Software Development :: Libraries :: Python Modules",
35
35
  "Topic :: Scientific/Engineering",
36
36
  ]
37
- dependencies = ["pydantic>=2.13,<3.0"]
37
+ dependencies = ["pydantic>=2.13,<3.0", "defusedxml>=0.7.1,<0.8"]
38
38
 
39
39
  [project.optional-dependencies]
40
- dev = ["pytest==9.1.1", "ruff==0.15.18"]
40
+ dev = ["pytest==9.1.1", "ruff==0.15.21"]
41
41
 
42
42
  [project.urls]
43
43
  Homepage = "https://validibot.com"
@@ -0,0 +1,124 @@
1
+ """Tests for shared artifact references and file-port contracts.
2
+
3
+ These schemas form the cross-repo contract between Django, validator
4
+ backends, evidence builders, and future UI/API projections. The tests focus
5
+ on JSON shape stability and enum serialization because those are the pieces
6
+ that must remain portable across repos.
7
+ """
8
+
9
+ import pytest
10
+ from pydantic import ValidationError
11
+
12
+ from validibot_shared.validations.artifacts import (
13
+ ArtifactKind,
14
+ ArtifactRef,
15
+ BindingSourceScope,
16
+ DefaultSourceStrategy,
17
+ EnvelopeChannel,
18
+ FilePortContract,
19
+ StepIODirection,
20
+ StepIOMedium,
21
+ )
22
+
23
+
24
+ def test_artifact_ref_serializes_as_json_safe_control_plane_object():
25
+ """ArtifactRef should carry lineage metadata without embedding bytes."""
26
+ ref = ArtifactRef(
27
+ artifact_id="artifact-1",
28
+ run_id="run-1",
29
+ step_run_id="step-run-1",
30
+ producer_step_key="build_model",
31
+ contract_key="generated_model",
32
+ name="model.epjson",
33
+ role="primary-model",
34
+ kind=ArtifactKind.FILE,
35
+ media_type="application/vnd.energyplus.epjson",
36
+ data_format="energyplus_epjson",
37
+ filename="model.epjson",
38
+ size_bytes=1234,
39
+ sha256="a" * 64,
40
+ storage_version="1700000000000000",
41
+ uri="gs://bucket/runs/run-1/model.epjson",
42
+ producer_validator_type="BUILDINGSYNC_TO_ENERGYPLUS",
43
+ producer_validator_version="1",
44
+ producer_backend_image_digest="repo/image@sha256:" + "b" * 64,
45
+ retention_class="standard",
46
+ )
47
+
48
+ dumped = ref.model_dump(mode="json")
49
+
50
+ assert dumped["schema_version"] == "validibot.artifact_ref.v1"
51
+ assert dumped["kind"] == "file"
52
+ assert dumped["producer_step_key"] == "build_model"
53
+ assert dumped["uri"].startswith("gs://")
54
+ assert dumped["storage_version"] == "1700000000000000"
55
+
56
+
57
+ @pytest.mark.parametrize("missing", ["size_bytes", "sha256", "storage_version", "uri"])
58
+ def test_artifact_ref_requires_complete_storage_identity(missing):
59
+ """Artifact consumers must never receive a partial or mutable file pointer."""
60
+ payload = {
61
+ "artifact_id": "artifact-1",
62
+ "run_id": "run-1",
63
+ "step_run_id": "step-run-1",
64
+ "producer_step_key": "build_model",
65
+ "contract_key": "generated_model",
66
+ "name": "model.epjson",
67
+ "size_bytes": 1234,
68
+ "sha256": "a" * 64,
69
+ "storage_version": "1700000000000000",
70
+ "uri": "gs://bucket/runs/run-1/model.epjson",
71
+ }
72
+ payload.pop(missing)
73
+
74
+ with pytest.raises(ValidationError):
75
+ ArtifactRef(**payload)
76
+
77
+
78
+ def test_file_port_contract_serializes_shared_vocabulary():
79
+ """File-port contracts should expose stable enum values to every repo."""
80
+ contract = FilePortContract(
81
+ contract_key="primary_model",
82
+ label="Primary Model",
83
+ direction=StepIODirection.INPUT,
84
+ io_medium=StepIOMedium.ARTIFACT,
85
+ envelope_channel=EnvelopeChannel.INPUT_FILES,
86
+ role="primary-model",
87
+ artifact_kind=ArtifactKind.FILE,
88
+ min_items=1,
89
+ max_items=1,
90
+ accepted_data_formats=["energyplus_idf", "energyplus_epjson"],
91
+ accepted_media_types=[
92
+ "application/vnd.energyplus.idf",
93
+ "application/vnd.energyplus.epjson",
94
+ ],
95
+ allowed_source_scopes=[
96
+ BindingSourceScope.SUBMISSION_FILE,
97
+ BindingSourceScope.UPSTREAM_ARTIFACT,
98
+ BindingSourceScope.SIGNAL,
99
+ ],
100
+ default_source_strategy=DefaultSourceStrategy.SUBMITTED_FILE_FIRST,
101
+ )
102
+
103
+ dumped = contract.model_dump(mode="json")
104
+
105
+ assert dumped["contract_key"] == "primary_model"
106
+ assert dumped["io_medium"] == "artifact"
107
+ assert dumped["envelope_channel"] == "input_files"
108
+ assert dumped["allowed_source_scopes"] == [
109
+ "submission_file",
110
+ "upstream_artifact",
111
+ "signal",
112
+ ]
113
+ assert dumped["default_source_strategy"] == "submitted_file_first"
114
+
115
+
116
+ def test_file_port_contract_rejects_extra_fields():
117
+ """The contract should stay explicit rather than absorbing typos."""
118
+ with pytest.raises(ValidationError):
119
+ FilePortContract(
120
+ contract_key="primary_model",
121
+ direction=StepIODirection.INPUT,
122
+ envelope_channel=EnvelopeChannel.INPUT_FILES,
123
+ unknown=True,
124
+ )
@@ -0,0 +1,144 @@
1
+ """Tests for the strict callback nonce portion of the attempt contract.
2
+
3
+ Asynchronous validator callbacks need both delivery idempotency and proof that
4
+ the sender received the exact input envelope for one execution attempt. The
5
+ callback ID supplies the first property; the per-attempt nonce supplies the
6
+ second. These tests keep raw secrets out of model representations, require a
7
+ matching public commitment, preserve synchronous no-callback operation, and
8
+ reject the older attempt-v1 contract instead of silently accepting weaker
9
+ authentication.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import pytest
15
+ from pydantic import ValidationError
16
+
17
+ from validibot_shared.canonicalization import compute_callback_nonce_commitment
18
+ from validibot_shared.validations.envelopes import (
19
+ ATTEMPT_CONTRACT_VERSION,
20
+ ExecutionContext,
21
+ ValidationCallback,
22
+ ValidationOutputEnvelope,
23
+ ValidationStatus,
24
+ )
25
+
26
+ CALLBACK_NONCE = "A" * 43
27
+ CALLBACK_COMMITMENT = compute_callback_nonce_commitment(CALLBACK_NONCE)
28
+
29
+
30
+ def _context_kwargs() -> dict[str, object]:
31
+ """Return the non-secret fields required by one asynchronous attempt."""
32
+ return {
33
+ "execution_attempt_id": "attempt-1",
34
+ "step_run_id": "step-run-1",
35
+ "attempt_contract_version": ATTEMPT_CONTRACT_VERSION,
36
+ "expected_output_uri": "gs://bucket/attempt-1/output.json",
37
+ "callback_url": "https://example.com/callback",
38
+ "execution_bundle_uri": "gs://bucket/attempt-1/",
39
+ }
40
+
41
+
42
+ def test_async_context_requires_callback_id_and_nonce_pair() -> None:
43
+ """An enabled callback must not validate with only public routing metadata."""
44
+ with pytest.raises(ValidationError, match="callback_id is required"):
45
+ ExecutionContext(**_context_kwargs())
46
+
47
+ with pytest.raises(ValidationError, match="required when callbacks are enabled"):
48
+ ExecutionContext(
49
+ **_context_kwargs(),
50
+ callback_id="execution-attempt-attempt-1",
51
+ )
52
+
53
+
54
+ def test_context_requires_nonce_and_commitment_together() -> None:
55
+ """Neither half of the binding is meaningful without its matching partner."""
56
+ with pytest.raises(ValidationError, match="must be provided together"):
57
+ ExecutionContext(
58
+ **_context_kwargs(),
59
+ callback_id="execution-attempt-attempt-1",
60
+ callback_nonce=CALLBACK_NONCE,
61
+ )
62
+
63
+
64
+ def test_context_rejects_commitment_unrelated_to_nonce() -> None:
65
+ """A mismatched commitment must fail before an envelope can be dispatched."""
66
+ with pytest.raises(ValidationError, match="does not match"):
67
+ ExecutionContext(
68
+ **_context_kwargs(),
69
+ callback_id="execution-attempt-attempt-1",
70
+ callback_nonce=CALLBACK_NONCE,
71
+ callback_nonce_commitment="0" * 64,
72
+ )
73
+
74
+
75
+ def test_async_context_accepts_matching_pair_without_displaying_secret() -> None:
76
+ """A valid pair binds dispatch while keeping ordinary diagnostics redacted."""
77
+ context = ExecutionContext(
78
+ **_context_kwargs(),
79
+ callback_id="execution-attempt-attempt-1",
80
+ callback_nonce=CALLBACK_NONCE,
81
+ callback_nonce_commitment=CALLBACK_COMMITMENT,
82
+ )
83
+
84
+ assert context.callback_nonce_commitment == CALLBACK_COMMITMENT
85
+ assert CALLBACK_NONCE not in repr(context)
86
+
87
+
88
+ def test_sync_context_does_not_require_an_unused_callback_secret() -> None:
89
+ """Local synchronous execution remains valid when callback delivery is skipped."""
90
+ context = ExecutionContext(
91
+ **_context_kwargs(),
92
+ callback_id="execution-attempt-attempt-1",
93
+ skip_callback=True,
94
+ )
95
+
96
+ assert context.callback_nonce is None
97
+ assert context.callback_nonce_commitment is None
98
+
99
+
100
+ def test_attempt_v1_is_rejected_after_the_strict_contract_cutover() -> None:
101
+ """Mixed runtime versions must fail explicitly instead of losing nonce fields."""
102
+ with pytest.raises(ValidationError, match="validibot.attempt.v2"):
103
+ ExecutionContext(
104
+ **(
105
+ _context_kwargs() | {"attempt_contract_version": "validibot.attempt.v1"}
106
+ ),
107
+ callback_id="execution-attempt-attempt-1",
108
+ callback_nonce=CALLBACK_NONCE,
109
+ callback_nonce_commitment=CALLBACK_COMMITMENT,
110
+ )
111
+
112
+
113
+ def test_callback_payload_transports_secret_without_displaying_it() -> None:
114
+ """The backend must send the nonce, while ordinary repr output stays redacted."""
115
+ callback = ValidationCallback(
116
+ run_id="run-1",
117
+ callback_id="execution-attempt-attempt-1",
118
+ callback_nonce=CALLBACK_NONCE,
119
+ status=ValidationStatus.SUCCESS,
120
+ result_uri="gs://bucket/attempt-1/output.json",
121
+ )
122
+
123
+ assert callback.model_dump()["callback_nonce"] == CALLBACK_NONCE
124
+ assert CALLBACK_NONCE not in repr(callback)
125
+
126
+
127
+ def test_callback_payload_rejects_missing_or_short_nonce() -> None:
128
+ """A callback without a full-strength attempt secret is not authenticated."""
129
+ base = {
130
+ "run_id": "run-1",
131
+ "callback_id": "execution-attempt-attempt-1",
132
+ "status": ValidationStatus.SUCCESS,
133
+ "result_uri": "gs://bucket/attempt-1/output.json",
134
+ }
135
+
136
+ with pytest.raises(ValidationError, match="callback_nonce"):
137
+ ValidationCallback(**base)
138
+ with pytest.raises(ValidationError, match="at least 43 characters"):
139
+ ValidationCallback(**base, callback_nonce="too-short")
140
+
141
+
142
+ def test_output_envelope_has_no_callback_secret_field() -> None:
143
+ """Public results must never expose the live secret used for callback auth."""
144
+ assert "callback_nonce" not in ValidationOutputEnvelope.model_fields