validibot-shared 0.14.0__tar.gz → 0.16.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 (40) hide show
  1. {validibot_shared-0.14.0 → validibot_shared-0.16.0}/PKG-INFO +18 -6
  2. {validibot_shared-0.14.0 → validibot_shared-0.16.0}/README.md +17 -5
  3. {validibot_shared-0.14.0 → validibot_shared-0.16.0}/pyproject.toml +1 -1
  4. {validibot_shared-0.14.0 → validibot_shared-0.16.0}/tests/test_artifacts.py +23 -0
  5. {validibot_shared-0.14.0 → validibot_shared-0.16.0}/tests/test_energyplus_envelopes.py +13 -0
  6. {validibot_shared-0.14.0 → validibot_shared-0.16.0}/tests/test_fmu_envelopes.py +15 -1
  7. {validibot_shared-0.14.0 → validibot_shared-0.16.0}/tests/test_schematron_envelopes.py +12 -0
  8. {validibot_shared-0.14.0 → validibot_shared-0.16.0}/tests/test_shacl_envelopes.py +28 -7
  9. {validibot_shared-0.14.0 → validibot_shared-0.16.0}/tests/test_validations_envelopes.py +144 -0
  10. {validibot_shared-0.14.0 → validibot_shared-0.16.0}/validibot_shared/energyplus/envelopes.py +2 -2
  11. {validibot_shared-0.14.0 → validibot_shared-0.16.0}/validibot_shared/energyplus/models.py +4 -4
  12. {validibot_shared-0.14.0 → validibot_shared-0.16.0}/validibot_shared/fmu/envelopes.py +17 -0
  13. {validibot_shared-0.14.0 → validibot_shared-0.16.0}/validibot_shared/fmu/models.py +1 -1
  14. {validibot_shared-0.14.0 → validibot_shared-0.16.0}/validibot_shared/schematron/envelopes.py +20 -3
  15. {validibot_shared-0.14.0 → validibot_shared-0.16.0}/validibot_shared/shacl/envelopes.py +22 -5
  16. {validibot_shared-0.14.0 → validibot_shared-0.16.0}/validibot_shared/validations/artifacts.py +20 -4
  17. {validibot_shared-0.14.0 → validibot_shared-0.16.0}/validibot_shared/validations/envelopes.py +116 -12
  18. {validibot_shared-0.14.0 → validibot_shared-0.16.0}/.gitignore +0 -0
  19. {validibot_shared-0.14.0 → validibot_shared-0.16.0}/LICENSE +0 -0
  20. {validibot_shared-0.14.0 → validibot_shared-0.16.0}/NOTICE +0 -0
  21. {validibot_shared-0.14.0 → validibot_shared-0.16.0}/tests/__init__.py +0 -0
  22. {validibot_shared-0.14.0 → validibot_shared-0.16.0}/tests/test_canonicalization.py +0 -0
  23. {validibot_shared-0.14.0 → validibot_shared-0.16.0}/tests/test_energyplus_models.py +0 -0
  24. {validibot_shared-0.14.0 → validibot_shared-0.16.0}/tests/test_evidence_manifest.py +0 -0
  25. {validibot_shared-0.14.0 → validibot_shared-0.16.0}/tests/test_fmu_models.py +0 -0
  26. {validibot_shared-0.14.0 → validibot_shared-0.16.0}/tests/test_package_init.py +0 -0
  27. {validibot_shared-0.14.0 → validibot_shared-0.16.0}/tests/test_schematron_svrl.py +0 -0
  28. {validibot_shared-0.14.0 → validibot_shared-0.16.0}/validibot_shared/__init__.py +0 -0
  29. {validibot_shared-0.14.0 → validibot_shared-0.16.0}/validibot_shared/_vendor/__init__.py +0 -0
  30. {validibot_shared-0.14.0 → validibot_shared-0.16.0}/validibot_shared/_vendor/jcs.py +0 -0
  31. {validibot_shared-0.14.0 → validibot_shared-0.16.0}/validibot_shared/canonicalization.py +0 -0
  32. {validibot_shared-0.14.0 → validibot_shared-0.16.0}/validibot_shared/energyplus/__init__.py +0 -0
  33. {validibot_shared-0.14.0 → validibot_shared-0.16.0}/validibot_shared/evidence/__init__.py +0 -0
  34. {validibot_shared-0.14.0 → validibot_shared-0.16.0}/validibot_shared/evidence/manifest.py +0 -0
  35. {validibot_shared-0.14.0 → validibot_shared-0.16.0}/validibot_shared/fmu/__init__.py +0 -0
  36. {validibot_shared-0.14.0 → validibot_shared-0.16.0}/validibot_shared/py.typed +0 -0
  37. {validibot_shared-0.14.0 → validibot_shared-0.16.0}/validibot_shared/schematron/__init__.py +0 -0
  38. {validibot_shared-0.14.0 → validibot_shared-0.16.0}/validibot_shared/schematron/svrl.py +0 -0
  39. {validibot_shared-0.14.0 → validibot_shared-0.16.0}/validibot_shared/shacl/__init__.py +0 -0
  40. {validibot_shared-0.14.0 → validibot_shared-0.16.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.14.0
3
+ Version: 0.16.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
@@ -166,8 +166,8 @@ Supporting models include:
166
166
 
167
167
  | Class | Purpose |
168
168
  |-------|---------|
169
- | `InputFileItem` | File reference with URI, MIME type, and role |
170
- | `ResourceFileItem` | Managed auxiliary file reference with URI and resource type |
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 |
171
171
  | `ValidatorInfo` | Validator identification (ID, type, version) |
172
172
  | `ExecutionContext` | Callback URL, execution bundle URI, timeout |
173
173
  | `ValidationMessage` | Individual finding (error, warning, info) |
@@ -199,8 +199,10 @@ for bytes. For example, EnergyPlus timestep settings belong in
199
199
  `EnergyPlusInputs`; the IDF/epJSON model and EPW weather file belong in file
200
200
  ports rendered to `input_files` / `resource_files`.
201
201
 
202
- Backends should read files by role, and by future optional `port_key` when
203
- available, not by assuming `input_files[0]` forever.
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.
204
206
 
205
207
  `ArtifactRef` and `FilePortContract` live in
206
208
  `validibot_shared.validations.artifacts`. `InputFileItem` and
@@ -256,6 +258,7 @@ validibot_shared/
256
258
  ```python
257
259
  from validibot_shared.energyplus import EnergyPlusInputEnvelope, EnergyPlusInputs
258
260
  from validibot_shared.validations.envelopes import (
261
+ ATTEMPT_CONTRACT_VERSION,
259
262
  ExecutionContext,
260
263
  InputFileItem,
261
264
  OrganizationInfo,
@@ -284,12 +287,21 @@ envelope = EnergyPlusInputEnvelope(
284
287
  mime_type=SupportedMimeType.ENERGYPLUS_IDF,
285
288
  role="primary-model",
286
289
  uri="gs://bucket/model.idf",
290
+ size_bytes=12345,
291
+ sha256="0123456789abcdef" * 4,
292
+ storage_version="1700000000000000",
287
293
  ),
288
294
  ],
289
295
  inputs=EnergyPlusInputs(timestep_per_hour=4),
290
296
  context=ExecutionContext(
291
297
  callback_url="https://api.example.com/callback",
292
- execution_bundle_uri="gs://bucket/run-123/",
298
+ execution_bundle_uri="gs://bucket/runs/org-123/run-123/attempts/attempt-123/",
299
+ execution_attempt_id="attempt-123",
300
+ step_run_id="step-run-789",
301
+ attempt_contract_version=ATTEMPT_CONTRACT_VERSION,
302
+ expected_output_uri=(
303
+ "gs://bucket/runs/org-123/run-123/attempts/attempt-123/output.json"
304
+ ),
293
305
  ),
294
306
  )
295
307
 
@@ -133,8 +133,8 @@ Supporting models include:
133
133
 
134
134
  | Class | Purpose |
135
135
  |-------|---------|
136
- | `InputFileItem` | File reference with URI, MIME type, and role |
137
- | `ResourceFileItem` | Managed auxiliary file reference with URI and resource type |
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 |
138
138
  | `ValidatorInfo` | Validator identification (ID, type, version) |
139
139
  | `ExecutionContext` | Callback URL, execution bundle URI, timeout |
140
140
  | `ValidationMessage` | Individual finding (error, warning, info) |
@@ -166,8 +166,10 @@ for bytes. For example, EnergyPlus timestep settings belong in
166
166
  `EnergyPlusInputs`; the IDF/epJSON model and EPW weather file belong in file
167
167
  ports rendered to `input_files` / `resource_files`.
168
168
 
169
- Backends should read files by role, and by future optional `port_key` when
170
- available, not by assuming `input_files[0]` forever.
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.
171
173
 
172
174
  `ArtifactRef` and `FilePortContract` live in
173
175
  `validibot_shared.validations.artifacts`. `InputFileItem` and
@@ -223,6 +225,7 @@ validibot_shared/
223
225
  ```python
224
226
  from validibot_shared.energyplus import EnergyPlusInputEnvelope, EnergyPlusInputs
225
227
  from validibot_shared.validations.envelopes import (
228
+ ATTEMPT_CONTRACT_VERSION,
226
229
  ExecutionContext,
227
230
  InputFileItem,
228
231
  OrganizationInfo,
@@ -251,12 +254,21 @@ envelope = EnergyPlusInputEnvelope(
251
254
  mime_type=SupportedMimeType.ENERGYPLUS_IDF,
252
255
  role="primary-model",
253
256
  uri="gs://bucket/model.idf",
257
+ size_bytes=12345,
258
+ sha256="0123456789abcdef" * 4,
259
+ storage_version="1700000000000000",
254
260
  ),
255
261
  ],
256
262
  inputs=EnergyPlusInputs(timestep_per_hour=4),
257
263
  context=ExecutionContext(
258
264
  callback_url="https://api.example.com/callback",
259
- execution_bundle_uri="gs://bucket/run-123/",
265
+ execution_bundle_uri="gs://bucket/runs/org-123/run-123/attempts/attempt-123/",
266
+ execution_attempt_id="attempt-123",
267
+ step_run_id="step-run-789",
268
+ attempt_contract_version=ATTEMPT_CONTRACT_VERSION,
269
+ expected_output_uri=(
270
+ "gs://bucket/runs/org-123/run-123/attempts/attempt-123/output.json"
271
+ ),
260
272
  ),
261
273
  )
262
274
 
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "validibot-shared"
7
- version = "0.14.0"
7
+ version = "0.16.0"
8
8
  description = "Shared library for data interchange between Validibot and validator containers"
9
9
  readme = "README.md"
10
10
  license = { text = "MIT" }
@@ -37,6 +37,7 @@ def test_artifact_ref_serializes_as_json_safe_control_plane_object():
37
37
  filename="model.epjson",
38
38
  size_bytes=1234,
39
39
  sha256="a" * 64,
40
+ storage_version="1700000000000000",
40
41
  uri="gs://bucket/runs/run-1/model.epjson",
41
42
  producer_validator_type="BUILDINGSYNC_TO_ENERGYPLUS",
42
43
  producer_validator_version="1",
@@ -50,6 +51,28 @@ def test_artifact_ref_serializes_as_json_safe_control_plane_object():
50
51
  assert dumped["kind"] == "file"
51
52
  assert dumped["producer_step_key"] == "build_model"
52
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)
53
76
 
54
77
 
55
78
  def test_file_port_contract_serializes_shared_vocabulary():
@@ -10,6 +10,7 @@ from validibot_shared.energyplus.envelopes import (
10
10
  EnergyPlusOutputs,
11
11
  )
12
12
  from validibot_shared.validations.envelopes import (
13
+ ATTEMPT_CONTRACT_VERSION,
13
14
  ExecutionContext,
14
15
  InputFileItem,
15
16
  SupportedMimeType,
@@ -41,11 +42,18 @@ def _base_input_envelope_kwargs():
41
42
  mime_type=SupportedMimeType.ENERGYPLUS_IDF,
42
43
  role="primary-model",
43
44
  uri="gs://bucket/model.idf",
45
+ size_bytes=123,
46
+ sha256="1" * 64,
47
+ storage_version="1700000000000000",
44
48
  )
45
49
  ],
46
50
  "context": ExecutionContext(
47
51
  callback_url="https://example.com/callback",
48
52
  execution_bundle_uri="gs://bucket/run-1/",
53
+ execution_attempt_id="attempt-1",
54
+ step_run_id="step-run-1",
55
+ attempt_contract_version=ATTEMPT_CONTRACT_VERSION,
56
+ expected_output_uri="gs://bucket/run-1/output.json",
49
57
  timeout_seconds=600,
50
58
  tags=["smoke"],
51
59
  ),
@@ -125,6 +133,11 @@ def test_energyplus_output_envelope_accepts_typed_outputs():
125
133
  """EnergyPlusOutputEnvelope should accept typed outputs."""
126
134
  envelope = EnergyPlusOutputEnvelope(
127
135
  run_id="run-2",
136
+ step_run_id="step-run-2",
137
+ execution_attempt_id="attempt-2",
138
+ attempt_contract_version=ATTEMPT_CONTRACT_VERSION,
139
+ input_envelope_sha256="a" * 64,
140
+ output_uri="gs://bucket/run-2/output.json",
128
141
  validator={
129
142
  "id": "val-1",
130
143
  "type": ValidatorType.ENERGYPLUS,
@@ -9,7 +9,11 @@ from validibot_shared.fmu.envelopes import (
9
9
  FMUSimulationConfig,
10
10
  build_fmu_input_envelope,
11
11
  )
12
- from validibot_shared.validations.envelopes import ExecutionContext, ValidatorType
12
+ from validibot_shared.validations.envelopes import (
13
+ ATTEMPT_CONTRACT_VERSION,
14
+ ExecutionContext,
15
+ ValidatorType,
16
+ )
13
17
 
14
18
  # Test constants
15
19
  TEST_EXECUTION_SECONDS = 2.5
@@ -25,6 +29,10 @@ def _base_kwargs():
25
29
  "context": ExecutionContext(
26
30
  callback_url="https://example.com/cb",
27
31
  execution_bundle_uri="gs://bucket/run-123/",
32
+ execution_attempt_id="attempt-123",
33
+ step_run_id="step-run-123",
34
+ attempt_contract_version=ATTEMPT_CONTRACT_VERSION,
35
+ expected_output_uri="gs://bucket/run-123/output.json",
28
36
  ),
29
37
  }
30
38
 
@@ -90,9 +98,15 @@ def test_build_fmu_input_envelope_constructs_expected_payload():
90
98
  step_id="step-1",
91
99
  step_name="Simulate",
92
100
  fmu_uri="gs://bucket/model.fmu",
101
+ fmu_size_bytes=456,
102
+ fmu_sha256="1" * 64,
103
+ fmu_storage_version="1700000000000000",
93
104
  input_values={"u1": 1.0},
94
105
  callback_url="https://example.com/callback",
95
106
  execution_bundle_uri="gs://bucket/run-1/",
107
+ execution_attempt_id="attempt-1",
108
+ step_run_id="step-run-1",
109
+ expected_output_uri="gs://bucket/run-1/output.json",
96
110
  output_variables=["y"],
97
111
  )
98
112
 
@@ -24,6 +24,7 @@ from validibot_shared.schematron.envelopes import (
24
24
  build_schematron_input_envelope,
25
25
  )
26
26
  from validibot_shared.validations.envelopes import (
27
+ ATTEMPT_CONTRACT_VERSION,
27
28
  SupportedMimeType,
28
29
  ValidationStatus,
29
30
  ValidatorType,
@@ -121,6 +122,11 @@ def test_engine_failure_shapes_round_trip():
121
122
  ):
122
123
  envelope = SchematronOutputEnvelope(
123
124
  run_id="run-1",
125
+ step_run_id="step-run-1",
126
+ execution_attempt_id="attempt-1",
127
+ attempt_contract_version=ATTEMPT_CONTRACT_VERSION,
128
+ input_envelope_sha256="a" * 64,
129
+ output_uri="gs://bucket/run-1/output.json",
124
130
  validator={"id": "v1", "type": ValidatorType.SCHEMATRON, "version": "1"},
125
131
  status=status,
126
132
  timing={},
@@ -190,9 +196,15 @@ def test_build_schematron_input_envelope_assembles_the_xml_submission():
190
196
  step_id="step-1",
191
197
  step_name="Peppol rules",
192
198
  submission_uri="gs://bucket/run-1/submission.xml",
199
+ submission_size_bytes=321,
200
+ submission_sha256="1" * 64,
201
+ submission_storage_version="1700000000000000",
193
202
  inputs=_inputs(),
194
203
  callback_url="https://example.com/callback",
195
204
  execution_bundle_uri="gs://bucket/run-1/",
205
+ execution_attempt_id="attempt-1",
206
+ step_run_id="step-run-1",
207
+ expected_output_uri="gs://bucket/run-1/output.json",
196
208
  )
197
209
 
198
210
  assert isinstance(envelope, SchematronInputEnvelope)
@@ -13,8 +13,8 @@ We therefore assert three things:
13
13
  (Django serializes the input; the container serializes the output).
14
14
  2. ``extra="forbid"`` is honoured so a drifting producer/consumer fails loudly
15
15
  rather than silently dropping fields across the trust boundary.
16
- 3. The ``SHACLOutputs`` signal fields stay aligned with what Django's
17
- ``extract_output_signals`` expects (the ``o.*`` catalog keys).
16
+ 3. The ``SHACLOutputs`` output-value fields stay aligned with what Django's
17
+ ``extract_output_values`` expects (the ``o.*`` catalog keys).
18
18
  """
19
19
 
20
20
  import pytest
@@ -31,13 +31,14 @@ from validibot_shared.shacl.envelopes import (
31
31
  mime_type_for_rdf_format,
32
32
  )
33
33
  from validibot_shared.validations.envelopes import (
34
+ ATTEMPT_CONTRACT_VERSION,
34
35
  ExecutionContext,
35
36
  SupportedMimeType,
36
37
  ValidationStatus,
37
38
  ValidatorType,
38
39
  )
39
40
 
40
- # The o.* signal keys Django's SHACL catalog declares. If SHACLOutputs ever
41
+ # The o.* output-value keys Django's SHACL catalog declares. If SHACLOutputs ever
41
42
  # drops one of these, the Django extractor would silently lose a CEL signal —
42
43
  # this list is the canary.
43
44
  EXPECTED_SIGNAL_KEYS = {
@@ -65,6 +66,10 @@ def _base_kwargs():
65
66
  "context": ExecutionContext(
66
67
  callback_url="https://example.com/cb",
67
68
  execution_bundle_uri="gs://bucket/run-123/",
69
+ execution_attempt_id="attempt-123",
70
+ step_run_id="step-run-123",
71
+ attempt_contract_version=ATTEMPT_CONTRACT_VERSION,
72
+ expected_output_uri="gs://bucket/run-123/output.json",
68
73
  ),
69
74
  }
70
75
 
@@ -154,9 +159,15 @@ def test_build_shacl_input_envelope_constructs_expected_payload():
154
159
  step_id="step-1",
155
160
  step_name="Validate graph",
156
161
  submission_uri="gs://bucket/submission.jsonld",
162
+ submission_size_bytes=789,
163
+ submission_sha256="1" * 64,
164
+ submission_storage_version="1700000000000000",
157
165
  inputs=inputs,
158
166
  callback_url="https://example.com/callback",
159
167
  execution_bundle_uri="gs://bucket/run-1/",
168
+ execution_attempt_id="attempt-1",
169
+ step_run_id="step-run-1",
170
+ expected_output_uri="gs://bucket/run-1/output.json",
160
171
  skip_callback=True,
161
172
  )
162
173
 
@@ -197,21 +208,21 @@ def test_sparql_assertion_spec_defaults():
197
208
 
198
209
 
199
210
  # ── Output envelope ─────────────────────────────────────────────────────────
200
- # The output envelope is what the container writes back. Its signal fields feed
211
+ # The output envelope is what the container writes back. Its output-value fields feed
201
212
  # Django's CEL/Basic assertion evaluation; its assertion tallies are folded into
202
213
  # the final AssertionStats.
203
214
 
204
215
 
205
216
  def test_shacl_outputs_signal_keys_cover_catalog():
206
- """Every catalog signal key must exist as a field on ``SHACLOutputs``.
217
+ """Every catalog output-value key must exist as a field on ``SHACLOutputs``.
207
218
 
208
- Django's ``extract_output_signals`` reads these keys off the envelope; a
219
+ Django's ``extract_output_values`` reads these keys off the envelope; a
209
220
  missing field here would surface as a null CEL signal at runtime with no
210
221
  error. This test pins the alignment at the contract layer.
211
222
  """
212
223
  field_names = set(SHACLOutputs.model_fields)
213
224
  missing = EXPECTED_SIGNAL_KEYS - field_names
214
- assert not missing, f"SHACLOutputs missing signal fields: {missing}"
225
+ assert not missing, f"SHACLOutputs missing output-value fields: {missing}"
215
226
 
216
227
 
217
228
  def test_shacl_output_envelope_round_trips():
@@ -223,6 +234,11 @@ def test_shacl_output_envelope_round_trips():
223
234
  """
224
235
  envelope = SHACLOutputEnvelope(
225
236
  run_id="run-123",
237
+ step_run_id="step-run-123",
238
+ execution_attempt_id="attempt-123",
239
+ attempt_contract_version=ATTEMPT_CONTRACT_VERSION,
240
+ input_envelope_sha256="a" * 64,
241
+ output_uri="gs://bucket/run-123/output.json",
226
242
  validator={"id": "val-1", "type": ValidatorType.SHACL, "version": "1"},
227
243
  status=ValidationStatus.FAILED_VALIDATION,
228
244
  timing={},
@@ -259,6 +275,11 @@ def test_shacl_output_envelope_allows_none_outputs():
259
275
  """
260
276
  envelope = SHACLOutputEnvelope(
261
277
  run_id="run-123",
278
+ step_run_id="step-run-123",
279
+ execution_attempt_id="attempt-123",
280
+ attempt_contract_version=ATTEMPT_CONTRACT_VERSION,
281
+ input_envelope_sha256="a" * 64,
282
+ output_uri="gs://bucket/run-123/output.json",
262
283
  validator={"id": "val-1", "type": ValidatorType.SHACL, "version": "1"},
263
284
  status=ValidationStatus.FAILED_RUNTIME,
264
285
  timing={},
@@ -5,13 +5,16 @@ from datetime import UTC, datetime
5
5
  import pytest
6
6
  from pydantic import ValidationError
7
7
 
8
+ from validibot_shared.canonicalization import sha256_hex_for_model
8
9
  from validibot_shared.validations.envelopes import (
10
+ ATTEMPT_CONTRACT_VERSION,
9
11
  ExecutionContext,
10
12
  InputFileItem,
11
13
  MessageLocation,
12
14
  ResourceFileItem,
13
15
  Severity,
14
16
  SupportedMimeType,
17
+ ValidationArtifact,
15
18
  ValidationCallback,
16
19
  ValidationInputEnvelope,
17
20
  ValidationMessage,
@@ -37,15 +40,33 @@ def _base_input_envelope_kwargs():
37
40
  mime_type=SupportedMimeType.ENERGYPLUS_IDF,
38
41
  role="primary-model",
39
42
  uri="gs://bucket/model.idf",
43
+ size_bytes=123,
44
+ sha256="1" * 64,
45
+ storage_version="1700000000000000",
40
46
  )
41
47
  ],
42
48
  "context": ExecutionContext(
43
49
  callback_url="https://example.com/callback",
44
50
  execution_bundle_uri="gs://bucket/run-42/",
51
+ execution_attempt_id="attempt-42",
52
+ step_run_id="step-run-42",
53
+ attempt_contract_version=ATTEMPT_CONTRACT_VERSION,
54
+ expected_output_uri="gs://bucket/run-42/output.json",
45
55
  ),
46
56
  }
47
57
 
48
58
 
59
+ def _output_identity_kwargs():
60
+ """Return the immutable execution-attempt identity required on outputs."""
61
+ return {
62
+ "step_run_id": "step-run-99",
63
+ "execution_attempt_id": "attempt-99",
64
+ "attempt_contract_version": ATTEMPT_CONTRACT_VERSION,
65
+ "input_envelope_sha256": "a" * 64,
66
+ "output_uri": "gs://bucket/run-99/output.json",
67
+ }
68
+
69
+
49
70
  def test_validation_input_envelope_defaults_schema_version():
50
71
  """ValidationInputEnvelope should have default schema version."""
51
72
  envelope = ValidationInputEnvelope(**_base_input_envelope_kwargs())
@@ -56,6 +77,49 @@ def test_validation_input_envelope_defaults_schema_version():
56
77
  assert envelope.inputs == {}
57
78
 
58
79
 
80
+ def test_strict_attempt_fixture_has_the_cross_repo_canonical_digest():
81
+ """All producers and consumers must hash the same attempt fixture bytes."""
82
+ envelope = ValidationInputEnvelope(
83
+ run_id="run-fixture",
84
+ validator={
85
+ "id": "validator-fixture",
86
+ "type": ValidatorType.FMU,
87
+ "version": "1",
88
+ },
89
+ org={"id": "org-fixture", "name": "Fixture Org"},
90
+ workflow={
91
+ "id": "workflow-fixture",
92
+ "step_id": "step-fixture",
93
+ "step_name": "Fixture Step",
94
+ },
95
+ input_files=[
96
+ {
97
+ "name": "model.fmu",
98
+ "mime_type": SupportedMimeType.FMU,
99
+ "role": "fmu",
100
+ "port_key": "fmu_model",
101
+ "uri": "gs://fixture/runs/run-fixture/model.fmu",
102
+ "size_bytes": 12,
103
+ "sha256": "1" * 64,
104
+ "storage_version": "1700000000000000",
105
+ },
106
+ ],
107
+ inputs={"alpha": 1},
108
+ context={
109
+ "execution_attempt_id": "attempt-fixture",
110
+ "step_run_id": "step-run-fixture",
111
+ "attempt_contract_version": ATTEMPT_CONTRACT_VERSION,
112
+ "expected_output_uri": "gs://fixture/runs/run-fixture/output.json",
113
+ "execution_bundle_uri": "gs://fixture/runs/run-fixture/",
114
+ "skip_callback": True,
115
+ },
116
+ )
117
+
118
+ assert sha256_hex_for_model(envelope) == (
119
+ "e17c5dae05c58f4d6034806e3f5e7a7602013d03f27ec811a97a9fc49f9d88d5"
120
+ )
121
+
122
+
59
123
  def test_input_file_item_accepts_optional_port_key():
60
124
  """InputFileItem should carry declared file-port identity when available."""
61
125
  item = InputFileItem(
@@ -64,6 +128,9 @@ def test_input_file_item_accepts_optional_port_key():
64
128
  role="primary-model",
65
129
  port_key="primary_model",
66
130
  uri="gs://bucket/model.idf",
131
+ size_bytes=123,
132
+ sha256="1" * 64,
133
+ storage_version="1700000000000000",
67
134
  )
68
135
 
69
136
  assert item.port_key == "primary_model"
@@ -73,9 +140,13 @@ def test_resource_file_item_accepts_optional_port_key():
73
140
  """ResourceFileItem should carry declared file-port identity when available."""
74
141
  item = ResourceFileItem(
75
142
  id="resource-1",
143
+ name="weather.epw",
76
144
  type="energyplus_weather",
77
145
  port_key="weather_file",
78
146
  uri="gs://bucket/weather.epw",
147
+ size_bytes=456,
148
+ sha256="2" * 64,
149
+ storage_version="1700000000000001",
79
150
  )
80
151
 
81
152
  assert item.port_key == "weather_file"
@@ -89,10 +160,81 @@ def test_input_file_item_forbids_extra_fields():
89
160
  mime_type=SupportedMimeType.ENERGYPLUS_IDF,
90
161
  role="primary-model",
91
162
  uri="gs://bucket/model.idf",
163
+ size_bytes=123,
164
+ sha256="1" * 64,
165
+ storage_version="1700000000000000",
92
166
  extra_field="not-allowed",
93
167
  )
94
168
 
95
169
 
170
+ @pytest.mark.parametrize("missing", ["size_bytes", "sha256", "storage_version"])
171
+ def test_input_file_item_requires_every_integrity_field(missing):
172
+ """A producer cannot emit a weaker file contract by omitting identity."""
173
+ payload = {
174
+ "name": "model.idf",
175
+ "mime_type": SupportedMimeType.ENERGYPLUS_IDF,
176
+ "role": "primary-model",
177
+ "uri": "gs://bucket/model.idf",
178
+ "size_bytes": 123,
179
+ "sha256": "1" * 64,
180
+ "storage_version": "1700000000000000",
181
+ }
182
+ payload.pop(missing)
183
+
184
+ with pytest.raises(ValidationError):
185
+ InputFileItem(**payload)
186
+
187
+
188
+ @pytest.mark.parametrize("name", ["", ".", "..", "../model.idf", "a/b.idf", "a\\b.idf"])
189
+ def test_file_item_names_must_be_safe_leaf_names(name):
190
+ """Logical names cannot redirect backend materialization outside its workspace."""
191
+ with pytest.raises(ValidationError):
192
+ InputFileItem(
193
+ name=name,
194
+ mime_type=SupportedMimeType.ENERGYPLUS_IDF,
195
+ uri="gs://bucket/model.idf",
196
+ size_bytes=123,
197
+ sha256="1" * 64,
198
+ storage_version="1700000000000000",
199
+ )
200
+
201
+
202
+ def test_file_item_rejects_malformed_integrity_values():
203
+ """Negative sizes, non-SHA digests, and empty versions fail at parsing."""
204
+ with pytest.raises(ValidationError):
205
+ InputFileItem(
206
+ name="model.idf",
207
+ mime_type=SupportedMimeType.ENERGYPLUS_IDF,
208
+ uri="gs://bucket/model.idf",
209
+ size_bytes=-1,
210
+ sha256="not-a-sha256",
211
+ storage_version="",
212
+ )
213
+
214
+
215
+ def test_validation_artifact_requires_byte_and_storage_identity():
216
+ """Output artifacts use the same mandatory integrity contract as inputs."""
217
+ artifact = ValidationArtifact(
218
+ name="report.csv",
219
+ type="timeseries-csv",
220
+ mime_type="text/csv",
221
+ uri="gs://bucket/outputs/report.csv",
222
+ size_bytes=456,
223
+ sha256="2" * 64,
224
+ storage_version="1700000000000001",
225
+ )
226
+
227
+ assert artifact.size_bytes == 456
228
+ assert artifact.sha256 == "2" * 64
229
+
230
+ with pytest.raises(ValidationError):
231
+ ValidationArtifact(
232
+ name="report.csv",
233
+ type="timeseries-csv",
234
+ uri="gs://bucket/outputs/report.csv",
235
+ )
236
+
237
+
96
238
  def test_validation_message_location_optional_fields():
97
239
  """MessageLocation should support optional fields."""
98
240
  location = MessageLocation(
@@ -114,6 +256,7 @@ def test_validation_output_envelope_defaults_and_status_enum():
114
256
 
115
257
  envelope = ValidationOutputEnvelope(
116
258
  run_id="run-99",
259
+ **_output_identity_kwargs(),
117
260
  validator={
118
261
  "id": "val-1",
119
262
  "type": ValidatorType.ENERGYPLUS,
@@ -136,6 +279,7 @@ def test_validation_output_envelope_rejects_invalid_status():
136
279
  with pytest.raises(ValidationError):
137
280
  ValidationOutputEnvelope(
138
281
  run_id="run-100",
282
+ **_output_identity_kwargs(),
139
283
  validator={
140
284
  "id": "val-1",
141
285
  "type": ValidatorType.ENERGYPLUS,
@@ -81,9 +81,9 @@ class EnergyPlusInputs(BaseModel):
81
81
  Using a typed Pydantic model provides compile-time type checking and
82
82
  runtime validation of all configuration parameters.
83
83
 
84
- Note: The validator always returns a fixed set of output signals defined
84
+ Note: The validator always returns a fixed set of output values defined
85
85
  in its catalog. Users don't need to specify which outputs they want -
86
- they get all defined signals and write assertions against the ones they
86
+ they get all defined outputs and write assertions against the ones they
87
87
  care about.
88
88
  """
89
89
 
@@ -47,12 +47,12 @@ class EnergyPlusSimulationMetrics(BaseModel):
47
47
  """
48
48
  Extracted EnergyPlus simulation metrics.
49
49
 
50
- These are the core output signals extracted from EnergyPlus simulation results.
51
- Field names here are the canonical EnergyPlus signal names expected by core
52
- Validibot. The core Django app maps these names to SignalDefinition rows.
50
+ These are the core output values extracted from EnergyPlus simulation results.
51
+ Field names here are the canonical EnergyPlus output names expected by core
52
+ Validibot. The core Django app maps these names to StepIODefinition rows.
53
53
 
54
54
  The validator extracts these values from the EnergyPlus SQL database
55
- (eplusout.sql) and they become available as output signals for assertions.
55
+ (eplusout.sql) and they become available as output values for assertions.
56
56
  """
57
57
 
58
58
  model_config = ConfigDict(extra="forbid")
@@ -19,6 +19,7 @@ from typing import Any
19
19
  from pydantic import BaseModel, Field
20
20
 
21
21
  from validibot_shared.validations.envelopes import (
22
+ ATTEMPT_CONTRACT_VERSION,
22
23
  ExecutionContext,
23
24
  InputFileItem,
24
25
  SupportedMimeType,
@@ -128,9 +129,15 @@ def build_fmu_input_envelope(
128
129
  step_id: str,
129
130
  step_name: str | None,
130
131
  fmu_uri: str,
132
+ fmu_size_bytes: int,
133
+ fmu_sha256: str,
134
+ fmu_storage_version: str,
131
135
  input_values: dict[str, Any],
132
136
  callback_url: str,
133
137
  execution_bundle_uri: str,
138
+ execution_attempt_id: str,
139
+ step_run_id: str,
140
+ expected_output_uri: str,
134
141
  simulation: FMUSimulationConfig | None = None,
135
142
  output_variables: list[str] | None = None,
136
143
  ) -> FMUInputEnvelope:
@@ -146,6 +153,9 @@ def build_fmu_input_envelope(
146
153
  step_id: Workflow step ID
147
154
  step_name: Optional step name
148
155
  fmu_uri: FMU storage URI (gs://... or local path in dev)
156
+ fmu_size_bytes: Expected exact FMU size and streaming ceiling
157
+ fmu_sha256: Expected SHA-256 of the FMU bytes
158
+ fmu_storage_version: Immutable provider version for the FMU object
149
159
  input_values: Resolved inputs keyed by native FMU variable name
150
160
  callback_url: URL to POST callback
151
161
  execution_bundle_uri: Base URI/path for this run's files
@@ -160,6 +170,9 @@ def build_fmu_input_envelope(
160
170
  mime_type=SupportedMimeType.FMU,
161
171
  role="fmu",
162
172
  uri=fmu_uri,
173
+ size_bytes=fmu_size_bytes,
174
+ sha256=fmu_sha256,
175
+ storage_version=fmu_storage_version,
163
176
  )
164
177
  ]
165
178
 
@@ -170,6 +183,10 @@ def build_fmu_input_envelope(
170
183
  )
171
184
 
172
185
  context = ExecutionContext(
186
+ execution_attempt_id=execution_attempt_id,
187
+ step_run_id=step_run_id,
188
+ attempt_contract_version=ATTEMPT_CONTRACT_VERSION,
189
+ expected_output_uri=expected_output_uri,
173
190
  callback_url=callback_url,
174
191
  execution_bundle_uri=execution_bundle_uri,
175
192
  )
@@ -2,7 +2,7 @@
2
2
  FMU probe result models.
3
3
 
4
4
  These models define the contract for FMU probing operations - extracting
5
- metadata from modelDescription.xml. Probe results feed into SignalDefinition
5
+ metadata from modelDescription.xml. Probe results feed into StepIODefinition
6
6
  rows in the core Django app.
7
7
 
8
8
  Probing is done in-process in the Django worker (not in containers) since
@@ -30,6 +30,7 @@ from __future__ import annotations
30
30
  from pydantic import BaseModel, Field
31
31
 
32
32
  from validibot_shared.validations.envelopes import (
33
+ ATTEMPT_CONTRACT_VERSION,
33
34
  ExecutionContext,
34
35
  InputFileItem,
35
36
  SupportedMimeType,
@@ -125,9 +126,9 @@ class SchematronFinding(BaseModel):
125
126
  class SchematronOutputs(BaseModel):
126
127
  """Schematron results: engine status, SVRL summary, and provenance.
127
128
 
128
- The signal fields (``passed`` … ``engine``) mirror the catalog entries in
129
+ The output-value fields (``passed`` … ``engine``) mirror the catalog entries in
129
130
  the Django Schematron ``ValidatorConfig`` — Django's
130
- ``extract_output_signals`` pulls exactly those keys for CEL assertion
131
+ ``extract_output_values`` pulls exactly those keys for CEL assertion
131
132
  evaluation.
132
133
 
133
134
  D9 contract: when ``engine_status != "ok"`` the run never evaluated the
@@ -153,7 +154,7 @@ class SchematronOutputs(BaseModel):
153
154
  ),
154
155
  )
155
156
 
156
- # ── o.* signals (must stay aligned with the ValidatorConfig catalog) ──
157
+ # ── o.* output values (must stay aligned with the ValidatorConfig catalog) ──
157
158
  passed: bool | None = Field(
158
159
  default=None,
159
160
  description=(
@@ -241,9 +242,15 @@ def build_schematron_input_envelope(
241
242
  step_id: str,
242
243
  step_name: str | None,
243
244
  submission_uri: str,
245
+ submission_size_bytes: int,
246
+ submission_sha256: str,
247
+ submission_storage_version: str,
244
248
  inputs: SchematronInputs,
245
249
  callback_url: str,
246
250
  execution_bundle_uri: str,
251
+ execution_attempt_id: str,
252
+ step_run_id: str,
253
+ expected_output_uri: str,
247
254
  callback_id: str | None = None,
248
255
  skip_callback: bool = False,
249
256
  ) -> SchematronInputEnvelope:
@@ -255,6 +262,9 @@ def build_schematron_input_envelope(
255
262
  org_id / org_name: Organization identity.
256
263
  workflow_id / step_id / step_name: Workflow context.
257
264
  submission_uri: Storage URI for the XML submission (gs:// or file://).
265
+ submission_size_bytes: Expected exact submission size and byte ceiling.
266
+ submission_sha256: Expected SHA-256 of the XML submission.
267
+ submission_storage_version: Immutable provider version for the object.
258
268
  inputs: Fully-resolved ``SchematronInputs`` (inline rules + limits).
259
269
  callback_url: URL the container POSTs to on completion.
260
270
  execution_bundle_uri: Base URI for this run's bundle.
@@ -267,10 +277,17 @@ def build_schematron_input_envelope(
267
277
  mime_type=SupportedMimeType.APPLICATION_XML,
268
278
  role="primary-model",
269
279
  uri=submission_uri,
280
+ size_bytes=submission_size_bytes,
281
+ sha256=submission_sha256,
282
+ storage_version=submission_storage_version,
270
283
  ),
271
284
  ]
272
285
 
273
286
  context = ExecutionContext(
287
+ execution_attempt_id=execution_attempt_id,
288
+ step_run_id=step_run_id,
289
+ attempt_contract_version=ATTEMPT_CONTRACT_VERSION,
290
+ expected_output_uri=expected_output_uri,
274
291
  callback_id=callback_id,
275
292
  callback_url=callback_url,
276
293
  execution_bundle_uri=execution_bundle_uri,
@@ -15,7 +15,7 @@ These schemas define the contract between Django and the SHACL container:
15
15
  the database (rulesets, ``RulesetAssertion`` rows, settings) before dispatch —
16
16
  the container receives only this serialized boundary.
17
17
  - **Output envelope**: the SHACL findings (as ``ValidationMessage`` rows), the
18
- ``o.*`` signal dict (for CEL/Basic output assertions evaluated back in Django),
18
+ ``o.*`` output-value dict (for CEL/Basic output assertions evaluated back in Django),
19
19
  the serialized ``sh:ValidationReport`` for evidence download, and the
20
20
  container-side SPARQL-ASK assertion counts (folded into the final assertion
21
21
  totals by ``AdvancedValidator.post_execute_validate``).
@@ -31,6 +31,7 @@ from typing import Any
31
31
  from pydantic import BaseModel, Field
32
32
 
33
33
  from validibot_shared.validations.envelopes import (
34
+ ATTEMPT_CONTRACT_VERSION,
34
35
  ExecutionContext,
35
36
  InputFileItem,
36
37
  SupportedMimeType,
@@ -192,11 +193,11 @@ class SHACLFinding(BaseModel):
192
193
 
193
194
 
194
195
  class SHACLOutputs(BaseModel):
195
- """SHACL validation results and the ``o.*`` signal dict.
196
+ """SHACL validation results and the ``o.*`` output-value dict.
196
197
 
197
- The signal fields (``parse_ok`` … ``shacl_total_count``) mirror the catalog
198
+ The output-value fields (``parse_ok`` … ``shacl_total_count``) mirror the catalog
198
199
  entries declared in the Django SHACL ``ValidatorConfig`` — Django's
199
- ``extract_output_signals`` pulls exactly those keys for CEL/Basic assertion
200
+ ``extract_output_values`` pulls exactly those keys for CEL/Basic assertion
200
201
  evaluation. The remaining fields carry evidence (the serialized report) and
201
202
  the container-side SPARQL-ASK assertion counts.
202
203
  """
@@ -212,7 +213,7 @@ class SHACLOutputs(BaseModel):
212
213
  ),
213
214
  )
214
215
 
215
- # ── o.* signals (must stay aligned with the SHACL ValidatorConfig catalog) ──
216
+ # ── o.* output values (must stay aligned with the SHACL ValidatorConfig catalog) ──
216
217
  parse_ok: bool = Field(description="Whether the submitted RDF parsed.")
217
218
  parse_serialization: str = Field(description="rdflib format used to parse.")
218
219
  triple_count: int = Field(default=0, ge=0)
@@ -289,9 +290,15 @@ def build_shacl_input_envelope(
289
290
  step_id: str,
290
291
  step_name: str | None,
291
292
  submission_uri: str,
293
+ submission_size_bytes: int,
294
+ submission_sha256: str,
295
+ submission_storage_version: str,
292
296
  inputs: SHACLInputs,
293
297
  callback_url: str,
294
298
  execution_bundle_uri: str,
299
+ execution_attempt_id: str,
300
+ step_run_id: str,
301
+ expected_output_uri: str,
295
302
  callback_id: str | None = None,
296
303
  skip_callback: bool = False,
297
304
  ) -> SHACLInputEnvelope:
@@ -303,6 +310,9 @@ def build_shacl_input_envelope(
303
310
  org_id / org_name: Organization identity.
304
311
  workflow_id / step_id / step_name: Workflow context.
305
312
  submission_uri: Storage URI for the RDF submission (gs:// or file://).
313
+ submission_size_bytes: Expected exact submission size and byte ceiling.
314
+ submission_sha256: Expected SHA-256 of the RDF submission.
315
+ submission_storage_version: Immutable provider version for the object.
306
316
  inputs: Fully-resolved ``SHACLInputs`` (shapes/ontology/settings/asks).
307
317
  callback_url: URL the container POSTs to on completion.
308
318
  execution_bundle_uri: Base URI for this run's bundle.
@@ -315,10 +325,17 @@ def build_shacl_input_envelope(
315
325
  mime_type=mime_type_for_rdf_format(inputs.rdf_format),
316
326
  role="primary-model",
317
327
  uri=submission_uri,
328
+ size_bytes=submission_size_bytes,
329
+ sha256=submission_sha256,
330
+ storage_version=submission_storage_version,
318
331
  ),
319
332
  ]
320
333
 
321
334
  context = ExecutionContext(
335
+ execution_attempt_id=execution_attempt_id,
336
+ step_run_id=step_run_id,
337
+ attempt_contract_version=ATTEMPT_CONTRACT_VERSION,
338
+ expected_output_uri=expected_output_uri,
322
339
  callback_id=callback_id,
323
340
  callback_url=callback_url,
324
341
  execution_bundle_uri=execution_bundle_uri,
@@ -10,7 +10,7 @@ from __future__ import annotations
10
10
  from enum import Enum
11
11
  from typing import Any, Literal
12
12
 
13
- from pydantic import BaseModel, Field
13
+ from pydantic import BaseModel, Field, field_validator
14
14
 
15
15
  ARTIFACT_REF_SCHEMA_VERSION = "validibot.artifact_ref.v1"
16
16
 
@@ -134,11 +134,12 @@ class ArtifactRef(BaseModel):
134
134
  media_type: str = ""
135
135
  data_format: str = ""
136
136
  filename: str = ""
137
- size_bytes: int | None = None
138
- sha256: str = ""
137
+ size_bytes: int = Field(ge=0)
138
+ sha256: str = Field(pattern=r"^[0-9a-f]{64}$")
139
+ storage_version: str = Field(min_length=1, max_length=512)
139
140
 
140
141
  uri: str = Field(
141
- default="",
142
+ min_length=1,
142
143
  description="Internal storage URI; not a public download URL.",
143
144
  )
144
145
  manifest_uri: str = ""
@@ -153,3 +154,18 @@ class ArtifactRef(BaseModel):
153
154
  metadata: dict[str, Any] = Field(default_factory=dict)
154
155
 
155
156
  model_config = {"extra": "forbid"}
157
+
158
+ @field_validator("name")
159
+ @classmethod
160
+ def _validate_safe_name(cls, value: str) -> str:
161
+ """Artifact refs carry logical names, never materialization paths."""
162
+ if (
163
+ not value
164
+ or value in {".", ".."}
165
+ or "/" in value
166
+ or "\\" in value
167
+ or "\x00" in value
168
+ ):
169
+ msg = f"Artifact name must be a safe logical leaf name: {value!r}"
170
+ raise ValueError(msg)
171
+ return value
@@ -117,7 +117,9 @@ from datetime import datetime # noqa: TC003
117
117
  from enum import Enum
118
118
  from typing import Any, Literal
119
119
 
120
- from pydantic import BaseModel, Field, HttpUrl
120
+ from pydantic import BaseModel, Field, HttpUrl, field_validator
121
+
122
+ ATTEMPT_CONTRACT_VERSION = "validibot.attempt.v1"
121
123
 
122
124
  # ==============================================================================
123
125
  # Shared Enums
@@ -186,7 +188,52 @@ class SupportedMimeType(str, Enum):
186
188
  RDF_N_QUADS = "application/n-quads"
187
189
 
188
190
 
189
- class InputFileItem(BaseModel):
191
+ def _safe_leaf_name(value: str) -> str:
192
+ """Return ``value`` when it is one safe logical filename.
193
+
194
+ Storage URIs carry provider paths; item names are deliberately only leaf
195
+ names so a backend can materialize them below its own attempt workspace.
196
+ """
197
+ if (
198
+ not value
199
+ or value in {".", ".."}
200
+ or "/" in value
201
+ or "\\" in value
202
+ or "\x00" in value
203
+ ):
204
+ msg = f"File item name must be a safe logical leaf name: {value!r}"
205
+ raise ValueError(msg)
206
+ return value
207
+
208
+
209
+ class IntegrityBoundFile(BaseModel):
210
+ """Required byte identity shared by every file-bearing envelope item.
211
+
212
+ ``size_bytes`` is both the expected exact length and the runtime's hard
213
+ streaming ceiling. ``sha256`` identifies the bytes the producer committed
214
+ to. ``storage_version`` pins the provider-specific immutable object
215
+ identity (for example a GCS generation or ``sha256:<digest>`` for an
216
+ attempt-local read-only file).
217
+ """
218
+
219
+ size_bytes: int = Field(
220
+ ge=0,
221
+ description="Expected exact file size and hard streaming ceiling.",
222
+ )
223
+ sha256: str = Field(
224
+ pattern=r"^[0-9a-f]{64}$",
225
+ description="Lowercase SHA-256 hex digest of the exact file bytes.",
226
+ )
227
+ storage_version: str = Field(
228
+ min_length=1,
229
+ max_length=512,
230
+ description="Provider-specific immutable object/version identity.",
231
+ )
232
+
233
+ model_config = {"extra": "forbid"}
234
+
235
+
236
+ class InputFileItem(IntegrityBoundFile):
190
237
  """
191
238
  A user-submitted file input for the validator.
192
239
 
@@ -196,7 +243,7 @@ class InputFileItem(BaseModel):
196
243
  Note: Auxiliary files like weather files are in resource_files, not input_files.
197
244
  """
198
245
 
199
- name: str = Field(description="Human-readable name of the file")
246
+ name: str = Field(description="Safe logical leaf name of the file")
200
247
 
201
248
  mime_type: SupportedMimeType = Field(description="MIME type of the file")
202
249
 
@@ -209,7 +256,7 @@ class InputFileItem(BaseModel):
209
256
  default=None,
210
257
  description=(
211
258
  "Declared Validibot file-port key that produced this envelope item "
212
- "(e.g., 'primary_model'). Optional for backwards-compatible rollout."
259
+ "(e.g., 'primary_model')."
213
260
  ),
214
261
  )
215
262
 
@@ -217,10 +264,14 @@ class InputFileItem(BaseModel):
217
264
  description="Storage URI to the file (gs:// or file:// for self-hosted)"
218
265
  )
219
266
 
220
- model_config = {"extra": "forbid"}
267
+ @field_validator("name")
268
+ @classmethod
269
+ def _validate_safe_name(cls, value: str) -> str:
270
+ """Reject paths so runtimes choose the destination directory."""
271
+ return _safe_leaf_name(value)
221
272
 
222
273
 
223
- class ResourceFileItem(BaseModel):
274
+ class ResourceFileItem(IntegrityBoundFile):
224
275
  """
225
276
  A resource file needed by the validator.
226
277
 
@@ -236,6 +287,8 @@ class ResourceFileItem(BaseModel):
236
287
 
237
288
  id: str = Field(description="Resource file UUID from Django database")
238
289
 
290
+ name: str = Field(description="Safe logical leaf name of the resource")
291
+
239
292
  type: str = Field(
240
293
  description="Resource type (e.g., 'weather', 'library', 'config')"
241
294
  )
@@ -244,7 +297,7 @@ class ResourceFileItem(BaseModel):
244
297
  default=None,
245
298
  description=(
246
299
  "Declared Validibot file-port key that produced this resource item "
247
- "(e.g., 'weather_file'). Optional for backwards-compatible rollout."
300
+ "(e.g., 'weather_file')."
248
301
  ),
249
302
  )
250
303
 
@@ -252,6 +305,12 @@ class ResourceFileItem(BaseModel):
252
305
  description="Storage URI to the file (gs:// or file:// for self-hosted)"
253
306
  )
254
307
 
308
+ @field_validator("name")
309
+ @classmethod
310
+ def _validate_safe_name(cls, value: str) -> str:
311
+ """Reject resource names that could escape the runtime workspace."""
312
+ return _safe_leaf_name(value)
313
+
255
314
  model_config = {"extra": "forbid"}
256
315
 
257
316
 
@@ -326,6 +385,25 @@ class ExecutionContext(BaseModel):
326
385
  4. Respect timeout constraints (timeout_seconds)
327
386
  """
328
387
 
388
+ execution_attempt_id: str = Field(
389
+ min_length=1,
390
+ description="ExecutionAttempt UUID selected by the trusted application.",
391
+ )
392
+
393
+ step_run_id: str = Field(
394
+ min_length=1,
395
+ description="ValidationStepRun identity this attempt executes.",
396
+ )
397
+
398
+ attempt_contract_version: Literal["validibot.attempt.v1"] = Field(
399
+ description="Strict attempt I/O contract version.",
400
+ )
401
+
402
+ expected_output_uri: str = Field(
403
+ min_length=1,
404
+ description="Exact output-envelope identity selected before dispatch.",
405
+ )
406
+
329
407
  callback_id: str | None = Field(
330
408
  default=None,
331
409
  description=(
@@ -498,10 +576,10 @@ class ValidationMetric(BaseModel):
498
576
  model_config = {"extra": "forbid"}
499
577
 
500
578
 
501
- class ValidationArtifact(BaseModel):
579
+ class ValidationArtifact(IntegrityBoundFile):
502
580
  """A file artifact produced by the validator."""
503
581
 
504
- name: str = Field(description="Artifact name")
582
+ name: str = Field(description="Safe logical artifact leaf name")
505
583
 
506
584
  type: str = Field(
507
585
  description=(
@@ -517,9 +595,11 @@ class ValidationArtifact(BaseModel):
517
595
  description="Storage URI to the artifact (gs:// or file:// for self-hosted)"
518
596
  )
519
597
 
520
- size_bytes: int | None = Field(default=None, description="File size in bytes")
521
-
522
- model_config = {"extra": "forbid"}
598
+ @field_validator("name")
599
+ @classmethod
600
+ def _validate_safe_name(cls, value: str) -> str:
601
+ """Reject artifact names that could be interpreted as paths."""
602
+ return _safe_leaf_name(value)
523
603
 
524
604
 
525
605
  class RawOutputs(BaseModel):
@@ -617,6 +697,30 @@ class ValidationOutputEnvelope(BaseModel):
617
697
 
618
698
  run_id: str = Field(description="Unique run identifier (matches input)")
619
699
 
700
+ step_run_id: str = Field(
701
+ min_length=1,
702
+ description="ValidationStepRun identity echoed from the input contract.",
703
+ )
704
+
705
+ execution_attempt_id: str = Field(
706
+ min_length=1,
707
+ description="ExecutionAttempt identity echoed from the input contract.",
708
+ )
709
+
710
+ attempt_contract_version: Literal["validibot.attempt.v1"] = Field(
711
+ description="Strict attempt I/O contract version echoed from input.",
712
+ )
713
+
714
+ input_envelope_sha256: str = Field(
715
+ pattern=r"^[0-9a-f]{64}$",
716
+ description="Canonical SHA-256 of the exact parsed input envelope.",
717
+ )
718
+
719
+ output_uri: str = Field(
720
+ min_length=1,
721
+ description="Exact output-envelope URI selected by the input contract.",
722
+ )
723
+
620
724
  validator: ValidatorInfo
621
725
 
622
726
  status: ValidationStatus