validibot-shared 0.16.0__tar.gz → 0.18.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.16.0 → validibot_shared-0.18.0}/PKG-INFO +10 -2
  2. {validibot_shared-0.16.0 → validibot_shared-0.18.0}/README.md +9 -1
  3. {validibot_shared-0.16.0 → validibot_shared-0.18.0}/pyproject.toml +1 -1
  4. validibot_shared-0.18.0/tests/test_callback_nonce_contract.py +144 -0
  5. {validibot_shared-0.16.0 → validibot_shared-0.18.0}/tests/test_canonicalization.py +63 -0
  6. {validibot_shared-0.16.0 → validibot_shared-0.18.0}/tests/test_energyplus_envelopes.py +8 -0
  7. {validibot_shared-0.16.0 → validibot_shared-0.18.0}/tests/test_evidence_manifest.py +100 -0
  8. {validibot_shared-0.16.0 → validibot_shared-0.18.0}/tests/test_fmu_envelopes.py +11 -0
  9. {validibot_shared-0.16.0 → validibot_shared-0.18.0}/tests/test_schematron_envelopes.py +8 -0
  10. {validibot_shared-0.16.0 → validibot_shared-0.18.0}/tests/test_shacl_envelopes.py +8 -0
  11. {validibot_shared-0.16.0 → validibot_shared-0.18.0}/tests/test_validations_envelopes.py +21 -3
  12. {validibot_shared-0.16.0 → validibot_shared-0.18.0}/validibot_shared/__init__.py +8 -0
  13. validibot_shared-0.18.0/validibot_shared/canonicalization.py +131 -0
  14. {validibot_shared-0.16.0 → validibot_shared-0.18.0}/validibot_shared/evidence/__init__.py +6 -0
  15. {validibot_shared-0.16.0 → validibot_shared-0.18.0}/validibot_shared/evidence/manifest.py +197 -4
  16. {validibot_shared-0.16.0 → validibot_shared-0.18.0}/validibot_shared/fmu/envelopes.py +12 -0
  17. {validibot_shared-0.16.0 → validibot_shared-0.18.0}/validibot_shared/schematron/envelopes.py +6 -0
  18. {validibot_shared-0.16.0 → validibot_shared-0.18.0}/validibot_shared/shacl/envelopes.py +6 -0
  19. {validibot_shared-0.16.0 → validibot_shared-0.18.0}/validibot_shared/validations/envelopes.py +69 -6
  20. validibot_shared-0.16.0/validibot_shared/canonicalization.py +0 -57
  21. {validibot_shared-0.16.0 → validibot_shared-0.18.0}/.gitignore +0 -0
  22. {validibot_shared-0.16.0 → validibot_shared-0.18.0}/LICENSE +0 -0
  23. {validibot_shared-0.16.0 → validibot_shared-0.18.0}/NOTICE +0 -0
  24. {validibot_shared-0.16.0 → validibot_shared-0.18.0}/tests/__init__.py +0 -0
  25. {validibot_shared-0.16.0 → validibot_shared-0.18.0}/tests/test_artifacts.py +0 -0
  26. {validibot_shared-0.16.0 → validibot_shared-0.18.0}/tests/test_energyplus_models.py +0 -0
  27. {validibot_shared-0.16.0 → validibot_shared-0.18.0}/tests/test_fmu_models.py +0 -0
  28. {validibot_shared-0.16.0 → validibot_shared-0.18.0}/tests/test_package_init.py +0 -0
  29. {validibot_shared-0.16.0 → validibot_shared-0.18.0}/tests/test_schematron_svrl.py +0 -0
  30. {validibot_shared-0.16.0 → validibot_shared-0.18.0}/validibot_shared/_vendor/__init__.py +0 -0
  31. {validibot_shared-0.16.0 → validibot_shared-0.18.0}/validibot_shared/_vendor/jcs.py +0 -0
  32. {validibot_shared-0.16.0 → validibot_shared-0.18.0}/validibot_shared/energyplus/__init__.py +0 -0
  33. {validibot_shared-0.16.0 → validibot_shared-0.18.0}/validibot_shared/energyplus/envelopes.py +0 -0
  34. {validibot_shared-0.16.0 → validibot_shared-0.18.0}/validibot_shared/energyplus/models.py +0 -0
  35. {validibot_shared-0.16.0 → validibot_shared-0.18.0}/validibot_shared/fmu/__init__.py +0 -0
  36. {validibot_shared-0.16.0 → validibot_shared-0.18.0}/validibot_shared/fmu/models.py +0 -0
  37. {validibot_shared-0.16.0 → validibot_shared-0.18.0}/validibot_shared/py.typed +0 -0
  38. {validibot_shared-0.16.0 → validibot_shared-0.18.0}/validibot_shared/schematron/__init__.py +0 -0
  39. {validibot_shared-0.16.0 → validibot_shared-0.18.0}/validibot_shared/schematron/svrl.py +0 -0
  40. {validibot_shared-0.16.0 → validibot_shared-0.18.0}/validibot_shared/shacl/__init__.py +0 -0
  41. {validibot_shared-0.16.0 → validibot_shared-0.18.0}/validibot_shared/validations/__init__.py +0 -0
  42. {validibot_shared-0.16.0 → validibot_shared-0.18.0}/validibot_shared/validations/artifacts.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: validibot-shared
3
- Version: 0.16.0
3
+ Version: 0.18.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
@@ -169,7 +169,7 @@ Supporting models include:
169
169
  | `InputFileItem` | File reference with URI, MIME type, role, exact size, SHA-256, and immutable storage version |
170
170
  | `ResourceFileItem` | Managed auxiliary file reference with safe name, URI, resource type, and byte identity |
171
171
  | `ValidatorInfo` | Validator identification (ID, type, version) |
172
- | `ExecutionContext` | Callback URL, execution bundle URI, timeout |
172
+ | `ExecutionContext` | Attempt identity, callback nonce commitment, bundle URI, and timeout |
173
173
  | `ValidationMessage` | Individual finding (error, warning, info) |
174
174
  | `ValidationMetric` | Named numeric metric with optional unit |
175
175
  | `ValidationArtifact` | Output file reference (reports, logs, etc.) |
@@ -256,6 +256,9 @@ validibot_shared/
256
256
  ### Creating an Input Envelope
257
257
 
258
258
  ```python
259
+ import secrets
260
+
261
+ from validibot_shared.canonicalization import compute_callback_nonce_commitment
259
262
  from validibot_shared.energyplus import EnergyPlusInputEnvelope, EnergyPlusInputs
260
263
  from validibot_shared.validations.envelopes import (
261
264
  ATTEMPT_CONTRACT_VERSION,
@@ -268,6 +271,8 @@ from validibot_shared.validations.envelopes import (
268
271
  WorkflowInfo,
269
272
  )
270
273
 
274
+ callback_nonce = secrets.token_urlsafe(32)
275
+
271
276
  envelope = EnergyPlusInputEnvelope(
272
277
  run_id="run-123",
273
278
  validator=ValidatorInfo(
@@ -295,6 +300,9 @@ envelope = EnergyPlusInputEnvelope(
295
300
  inputs=EnergyPlusInputs(timestep_per_hour=4),
296
301
  context=ExecutionContext(
297
302
  callback_url="https://api.example.com/callback",
303
+ callback_id="execution-attempt-attempt-123",
304
+ callback_nonce=callback_nonce,
305
+ callback_nonce_commitment=compute_callback_nonce_commitment(callback_nonce),
298
306
  execution_bundle_uri="gs://bucket/runs/org-123/run-123/attempts/attempt-123/",
299
307
  execution_attempt_id="attempt-123",
300
308
  step_run_id="step-run-789",
@@ -136,7 +136,7 @@ Supporting models include:
136
136
  | `InputFileItem` | File reference with URI, MIME type, role, exact size, SHA-256, and immutable storage version |
137
137
  | `ResourceFileItem` | Managed auxiliary file reference with safe name, URI, resource type, and byte identity |
138
138
  | `ValidatorInfo` | Validator identification (ID, type, version) |
139
- | `ExecutionContext` | Callback URL, execution bundle URI, timeout |
139
+ | `ExecutionContext` | Attempt identity, callback nonce commitment, bundle URI, and timeout |
140
140
  | `ValidationMessage` | Individual finding (error, warning, info) |
141
141
  | `ValidationMetric` | Named numeric metric with optional unit |
142
142
  | `ValidationArtifact` | Output file reference (reports, logs, etc.) |
@@ -223,6 +223,9 @@ validibot_shared/
223
223
  ### Creating an Input Envelope
224
224
 
225
225
  ```python
226
+ import secrets
227
+
228
+ from validibot_shared.canonicalization import compute_callback_nonce_commitment
226
229
  from validibot_shared.energyplus import EnergyPlusInputEnvelope, EnergyPlusInputs
227
230
  from validibot_shared.validations.envelopes import (
228
231
  ATTEMPT_CONTRACT_VERSION,
@@ -235,6 +238,8 @@ from validibot_shared.validations.envelopes import (
235
238
  WorkflowInfo,
236
239
  )
237
240
 
241
+ callback_nonce = secrets.token_urlsafe(32)
242
+
238
243
  envelope = EnergyPlusInputEnvelope(
239
244
  run_id="run-123",
240
245
  validator=ValidatorInfo(
@@ -262,6 +267,9 @@ envelope = EnergyPlusInputEnvelope(
262
267
  inputs=EnergyPlusInputs(timestep_per_hour=4),
263
268
  context=ExecutionContext(
264
269
  callback_url="https://api.example.com/callback",
270
+ callback_id="execution-attempt-attempt-123",
271
+ callback_nonce=callback_nonce,
272
+ callback_nonce_commitment=compute_callback_nonce_commitment(callback_nonce),
265
273
  execution_bundle_uri="gs://bucket/runs/org-123/run-123/attempts/attempt-123/",
266
274
  execution_attempt_id="attempt-123",
267
275
  step_run_id="step-run-789",
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "validibot-shared"
7
- version = "0.16.0"
7
+ version = "0.18.0"
8
8
  description = "Shared library for data interchange between Validibot and validator containers"
9
9
  readme = "README.md"
10
10
  license = { text = "MIT" }
@@ -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
@@ -12,16 +12,22 @@ from __future__ import annotations
12
12
  import hashlib
13
13
  import json
14
14
 
15
+ import pytest
15
16
  from pydantic import BaseModel, ConfigDict
16
17
 
17
18
  from validibot_shared.canonicalization import (
18
19
  canonicalize_dict,
19
20
  canonicalize_model,
21
+ compute_callback_nonce_commitment,
20
22
  sha256_hex_for_dict,
21
23
  sha256_hex_for_model,
22
24
  )
23
25
 
24
26
  UNSAFE_INTEGER = 9007199254740993
27
+ FIXED_CALLBACK_NONCE = "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8"
28
+ FIXED_CALLBACK_NONCE_COMMITMENT = (
29
+ "c3ce1f94c76a98b82472f8611c2a56ff24ce2dc318a09c6b4972286ca5fae512"
30
+ )
25
31
 
26
32
 
27
33
  class SampleSchema(BaseModel):
@@ -98,3 +104,60 @@ def test_sha256_for_model_matches_manual_hash() -> None:
98
104
  schema = SampleSchema(alpha="test", beta=99, gamma="yes")
99
105
  expected = hashlib.sha256(canonicalize_model(schema)).hexdigest()
100
106
  assert sha256_hex_for_model(schema) == expected
107
+
108
+
109
+ # ── Callback nonce commitments ──────────────────────────────────────────────
110
+ # Input envelopes transport a live authentication secret, but evidence and
111
+ # output identity must hash only its public domain-separated commitment.
112
+
113
+
114
+ def test_callback_nonce_commitment_matches_fixed_cross_repo_vector() -> None:
115
+ """The domain separator and UTF-8 preimage must remain byte-for-byte stable."""
116
+ assert (
117
+ compute_callback_nonce_commitment(FIXED_CALLBACK_NONCE)
118
+ == FIXED_CALLBACK_NONCE_COMMITMENT
119
+ )
120
+
121
+
122
+ def test_canonicalization_replaces_raw_nonce_with_commitment() -> None:
123
+ """Canonical bytes must bind the nonce without exposing its secret value."""
124
+ canonical = canonicalize_dict(
125
+ {
126
+ "context": {
127
+ "callback_nonce": FIXED_CALLBACK_NONCE,
128
+ "callback_nonce_commitment": FIXED_CALLBACK_NONCE_COMMITMENT,
129
+ },
130
+ },
131
+ )
132
+
133
+ assert FIXED_CALLBACK_NONCE.encode() not in canonical
134
+ assert FIXED_CALLBACK_NONCE_COMMITMENT.encode() in canonical
135
+ assert b'"callback_nonce"' not in canonical
136
+
137
+
138
+ def test_secret_and_public_forms_have_the_same_canonical_identity() -> None:
139
+ """A verifier retaining only the commitment must reproduce the input digest."""
140
+ secret_form = {
141
+ "context": {
142
+ "callback_nonce": FIXED_CALLBACK_NONCE,
143
+ "callback_nonce_commitment": FIXED_CALLBACK_NONCE_COMMITMENT,
144
+ },
145
+ }
146
+ public_form = {
147
+ "context": {
148
+ "callback_nonce_commitment": FIXED_CALLBACK_NONCE_COMMITMENT,
149
+ },
150
+ }
151
+
152
+ assert canonicalize_dict(secret_form) == canonicalize_dict(public_form)
153
+
154
+
155
+ def test_canonicalization_rejects_a_false_declared_commitment() -> None:
156
+ """A producer cannot bind the digest to a commitment unrelated to its nonce."""
157
+ with pytest.raises(ValueError, match="does not match"):
158
+ canonicalize_dict(
159
+ {
160
+ "callback_nonce": FIXED_CALLBACK_NONCE,
161
+ "callback_nonce_commitment": "0" * 64,
162
+ },
163
+ )
@@ -3,6 +3,7 @@ from pathlib import Path
3
3
  import pytest
4
4
  from pydantic import ValidationError
5
5
 
6
+ from validibot_shared.canonicalization import compute_callback_nonce_commitment
6
7
  from validibot_shared.energyplus.envelopes import (
7
8
  EnergyPlusInputEnvelope,
8
9
  EnergyPlusInputs,
@@ -24,6 +25,10 @@ TEST_TIMESTEP_PER_HOUR = 6
24
25
  TEST_ELECTRICITY_KWH = 123.4
25
26
  TEST_EXECUTION_SECONDS = 12.5
26
27
  TEST_EUI_KWH_M2 = 10.5
28
+ TEST_CALLBACK_NONCE = "A" * 43
29
+ TEST_CALLBACK_NONCE_COMMITMENT = compute_callback_nonce_commitment(
30
+ TEST_CALLBACK_NONCE,
31
+ )
27
32
 
28
33
 
29
34
  def _base_input_envelope_kwargs():
@@ -49,6 +54,9 @@ def _base_input_envelope_kwargs():
49
54
  ],
50
55
  "context": ExecutionContext(
51
56
  callback_url="https://example.com/callback",
57
+ callback_id="execution-attempt-attempt-1",
58
+ callback_nonce=TEST_CALLBACK_NONCE,
59
+ callback_nonce_commitment=TEST_CALLBACK_NONCE_COMMITMENT,
52
60
  execution_bundle_uri="gs://bucket/run-1/",
53
61
  execution_attempt_id="attempt-1",
54
62
  step_run_id="step-run-1",
@@ -19,6 +19,8 @@ What this file covers
19
19
  5. Artifact lineage fields are additive, evidence-safe projections:
20
20
  hashes and stable IDs are allowed; private storage URIs are not part
21
21
  of the public schema.
22
+ 6. Attempt-bound execution evidence records canonical envelopes, verified
23
+ input identities, and explicit preprocessing relationships without URIs.
22
24
  """
23
25
 
24
26
  from __future__ import annotations
@@ -33,6 +35,9 @@ from validibot_shared.evidence import (
33
35
  EvidenceManifest,
34
36
  ManifestArtifactInputBinding,
35
37
  ManifestArtifactLineageEdge,
38
+ ManifestExecutionAttempt,
39
+ ManifestExecutionInput,
40
+ ManifestInputRelationship,
36
41
  ManifestPayloadDigests,
37
42
  ManifestProducedArtifact,
38
43
  ManifestRetentionInfo,
@@ -207,6 +212,7 @@ class TestArtifactLineageFields:
207
212
  contract_key="generated_model",
208
213
  filename="model.epjson",
209
214
  sha256="a" * 64,
215
+ storage_version="42",
210
216
  )
211
217
  binding = ManifestArtifactInputBinding(
212
218
  target_step_id=20,
@@ -217,6 +223,7 @@ class TestArtifactLineageFields:
217
223
  source_artifact_id="artifact-1",
218
224
  source_filename="model.epjson",
219
225
  source_sha256="a" * 64,
226
+ source_storage_version="42",
220
227
  producer_step_key="build_model",
221
228
  producer_contract_key="generated_model",
222
229
  resolved=True,
@@ -245,6 +252,99 @@ class TestArtifactLineageFields:
245
252
  assert not hasattr(restored.artifact_input_bindings[0], "uri")
246
253
 
247
254
 
255
+ # ──────────────────────────────────────────────────────────────────────
256
+ # Attempt-bound execution evidence — immutable files and transformations
257
+ # ──────────────────────────────────────────────────────────────────────
258
+
259
+
260
+ class TestExecutionAttemptEvidence:
261
+ """Pin the additive strict-execution evidence contract for verifiers."""
262
+
263
+ def test_execution_attempts_default_to_empty(self):
264
+ """Inline validators and older producers remain valid without attempts."""
265
+ manifest = EvidenceManifest(**_minimal_manifest_kwargs())
266
+
267
+ assert manifest.execution_attempts == []
268
+ assert manifest.schema_version == "validibot.evidence.v1"
269
+
270
+ def test_attempt_records_round_trip_without_storage_uris(self):
271
+ """Portable evidence keeps immutable identities but exposes no file URI."""
272
+ execution_input = ManifestExecutionInput(
273
+ channel="input_files",
274
+ name="generated.epjson",
275
+ role="primary-model",
276
+ port_key="primary_model",
277
+ media_type="application/json",
278
+ size_bytes=123,
279
+ sha256="b" * 64,
280
+ storage_version="sha256:" + "b" * 64,
281
+ )
282
+ relationship = ManifestInputRelationship(
283
+ source_kind="submission",
284
+ source_id="submission-1",
285
+ source_name="template.epjson",
286
+ source_size_bytes=100,
287
+ source_sha256="a" * 64,
288
+ target_channel="input_files",
289
+ target_name="generated.epjson",
290
+ target_port_key="primary_model",
291
+ target_sha256="b" * 64,
292
+ relationship="transformed",
293
+ transformation="energyplus-template-substitution.v1",
294
+ )
295
+ attempt = ManifestExecutionAttempt(
296
+ execution_attempt_id="attempt-1",
297
+ step_run_id="step-run-1",
298
+ attempt_number=1,
299
+ state="COMPLETED",
300
+ runner_type="cloud_run_job",
301
+ provider_execution_id="execution-1",
302
+ attempt_contract_version="validibot.attempt.v2",
303
+ input_envelope_sha256="c" * 64,
304
+ output_envelope_sha256="d" * 64,
305
+ backend_image_digest="registry/energyplus@sha256:" + "e" * 64,
306
+ inputs_verified=True,
307
+ input_files=[execution_input],
308
+ input_relationships=[relationship],
309
+ )
310
+
311
+ manifest = EvidenceManifest(
312
+ **_minimal_manifest_kwargs(),
313
+ execution_attempts=[attempt],
314
+ )
315
+ restored = EvidenceManifest.model_validate(manifest.model_dump(mode="json"))
316
+
317
+ assert restored == manifest
318
+ assert restored.execution_attempts[0].inputs_verified is True
319
+ assert not hasattr(restored.execution_attempts[0].input_files[0], "uri")
320
+
321
+ def test_relationship_keeps_original_and_executed_digests_distinct(self):
322
+ """Preprocessing evidence cannot imply original bytes were executed."""
323
+ relationship = ManifestInputRelationship(
324
+ source_kind="submission",
325
+ source_sha256="a" * 64,
326
+ target_channel="input_files",
327
+ target_name="generated.idf",
328
+ target_sha256="b" * 64,
329
+ relationship="transformed",
330
+ transformation="energyplus-template-substitution.v1",
331
+ )
332
+
333
+ assert relationship.source_sha256 != relationship.target_sha256
334
+ assert relationship.relationship == "transformed"
335
+
336
+ def test_execution_input_rejects_invalid_integrity_identity(self):
337
+ """Evidence cannot accept an invalid digest or negative byte length."""
338
+ with pytest.raises(ValidationError):
339
+ ManifestExecutionInput(
340
+ channel="input_files",
341
+ name="model.idf",
342
+ size_bytes=-1,
343
+ sha256="not-a-sha256",
344
+ storage_version="generation-1",
345
+ )
346
+
347
+
248
348
  # ──────────────────────────────────────────────────────────────────────
249
349
  # WorkflowContractSnapshot — constants, signal-mapping defs, definition hash
250
350
  # ──────────────────────────────────────────────────────────────────────
@@ -3,6 +3,7 @@
3
3
  import pytest
4
4
  from pydantic import ValidationError
5
5
 
6
+ from validibot_shared.canonicalization import compute_callback_nonce_commitment
6
7
  from validibot_shared.fmu.envelopes import (
7
8
  FMUInputEnvelope,
8
9
  FMUOutputs,
@@ -18,6 +19,10 @@ from validibot_shared.validations.envelopes import (
18
19
  # Test constants
19
20
  TEST_EXECUTION_SECONDS = 2.5
20
21
  TEST_STOP_TIME = 10
22
+ TEST_CALLBACK_NONCE = "A" * 43
23
+ TEST_CALLBACK_NONCE_COMMITMENT = compute_callback_nonce_commitment(
24
+ TEST_CALLBACK_NONCE,
25
+ )
21
26
 
22
27
 
23
28
  def _base_kwargs():
@@ -28,6 +33,9 @@ def _base_kwargs():
28
33
  "workflow": {"id": "wf-1", "step_id": "step-1", "step_name": "FMU"},
29
34
  "context": ExecutionContext(
30
35
  callback_url="https://example.com/cb",
36
+ callback_id="execution-attempt-attempt-123",
37
+ callback_nonce=TEST_CALLBACK_NONCE,
38
+ callback_nonce_commitment=TEST_CALLBACK_NONCE_COMMITMENT,
31
39
  execution_bundle_uri="gs://bucket/run-123/",
32
40
  execution_attempt_id="attempt-123",
33
41
  step_run_id="step-run-123",
@@ -103,6 +111,9 @@ def test_build_fmu_input_envelope_constructs_expected_payload():
103
111
  fmu_storage_version="1700000000000000",
104
112
  input_values={"u1": 1.0},
105
113
  callback_url="https://example.com/callback",
114
+ callback_id="execution-attempt-attempt-1",
115
+ callback_nonce=TEST_CALLBACK_NONCE,
116
+ callback_nonce_commitment=TEST_CALLBACK_NONCE_COMMITMENT,
106
117
  execution_bundle_uri="gs://bucket/run-1/",
107
118
  execution_attempt_id="attempt-1",
108
119
  step_run_id="step-run-1",
@@ -12,6 +12,7 @@ against exactly this shape.
12
12
  import pytest
13
13
  from pydantic import ValidationError
14
14
 
15
+ from validibot_shared.canonicalization import compute_callback_nonce_commitment
15
16
  from validibot_shared.schematron.envelopes import (
16
17
  ENGINE_ERROR_RULES_INVALID,
17
18
  ENGINE_STATUS_OK,
@@ -35,6 +36,10 @@ DEFAULT_MAX_FINDINGS = 500
35
36
  DEFAULT_XSLT_TIMEOUT = 60
36
37
  TEST_ERROR_COUNT = 3
37
38
  RULES_SHA = "b" * 64
39
+ TEST_CALLBACK_NONCE = "A" * 43
40
+ TEST_CALLBACK_NONCE_COMMITMENT = compute_callback_nonce_commitment(
41
+ TEST_CALLBACK_NONCE,
42
+ )
38
43
 
39
44
  SCH_SOURCE = (
40
45
  '<schema xmlns="http://purl.oclc.org/dsdl/schematron">'
@@ -201,6 +206,9 @@ def test_build_schematron_input_envelope_assembles_the_xml_submission():
201
206
  submission_storage_version="1700000000000000",
202
207
  inputs=_inputs(),
203
208
  callback_url="https://example.com/callback",
209
+ callback_id="execution-attempt-attempt-1",
210
+ callback_nonce=TEST_CALLBACK_NONCE,
211
+ callback_nonce_commitment=TEST_CALLBACK_NONCE_COMMITMENT,
204
212
  execution_bundle_uri="gs://bucket/run-1/",
205
213
  execution_attempt_id="attempt-1",
206
214
  step_run_id="step-run-1",
@@ -20,6 +20,7 @@ We therefore assert three things:
20
20
  import pytest
21
21
  from pydantic import ValidationError
22
22
 
23
+ from validibot_shared.canonicalization import compute_callback_nonce_commitment
23
24
  from validibot_shared.shacl.envelopes import (
24
25
  SHACL_RESULT_REPORT_ONLY,
25
26
  SHACLInputEnvelope,
@@ -54,6 +55,10 @@ EXPECTED_SIGNAL_KEYS = {
54
55
  "shacl_info_count",
55
56
  "shacl_total_count",
56
57
  }
58
+ TEST_CALLBACK_NONCE = "A" * 43
59
+ TEST_CALLBACK_NONCE_COMMITMENT = compute_callback_nonce_commitment(
60
+ TEST_CALLBACK_NONCE,
61
+ )
57
62
 
58
63
 
59
64
  def _base_kwargs():
@@ -65,6 +70,9 @@ def _base_kwargs():
65
70
  "workflow": {"id": "wf-1", "step_id": "step-1", "step_name": "SHACL"},
66
71
  "context": ExecutionContext(
67
72
  callback_url="https://example.com/cb",
73
+ callback_id="execution-attempt-attempt-123",
74
+ callback_nonce=TEST_CALLBACK_NONCE,
75
+ callback_nonce_commitment=TEST_CALLBACK_NONCE_COMMITMENT,
68
76
  execution_bundle_uri="gs://bucket/run-123/",
69
77
  execution_attempt_id="attempt-123",
70
78
  step_run_id="step-run-123",
@@ -5,7 +5,10 @@ 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
+ from validibot_shared.canonicalization import (
9
+ compute_callback_nonce_commitment,
10
+ sha256_hex_for_model,
11
+ )
9
12
  from validibot_shared.validations.envelopes import (
10
13
  ATTEMPT_CONTRACT_VERSION,
11
14
  ExecutionContext,
@@ -23,6 +26,11 @@ from validibot_shared.validations.envelopes import (
23
26
  ValidatorType,
24
27
  )
25
28
 
29
+ TEST_CALLBACK_NONCE = "A" * 43
30
+ TEST_CALLBACK_NONCE_COMMITMENT = compute_callback_nonce_commitment(
31
+ TEST_CALLBACK_NONCE,
32
+ )
33
+
26
34
 
27
35
  def _base_input_envelope_kwargs():
28
36
  return {
@@ -47,6 +55,9 @@ def _base_input_envelope_kwargs():
47
55
  ],
48
56
  "context": ExecutionContext(
49
57
  callback_url="https://example.com/callback",
58
+ callback_id="execution-attempt-attempt-42",
59
+ callback_nonce=TEST_CALLBACK_NONCE,
60
+ callback_nonce_commitment=TEST_CALLBACK_NONCE_COMMITMENT,
50
61
  execution_bundle_uri="gs://bucket/run-42/",
51
62
  execution_attempt_id="attempt-42",
52
63
  step_run_id="step-run-42",
@@ -111,12 +122,17 @@ def test_strict_attempt_fixture_has_the_cross_repo_canonical_digest():
111
122
  "attempt_contract_version": ATTEMPT_CONTRACT_VERSION,
112
123
  "expected_output_uri": "gs://fixture/runs/run-fixture/output.json",
113
124
  "execution_bundle_uri": "gs://fixture/runs/run-fixture/",
114
- "skip_callback": True,
125
+ "callback_url": "https://example.com/callback",
126
+ "callback_id": "execution-attempt-attempt-fixture",
127
+ "callback_nonce": ("AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8"),
128
+ "callback_nonce_commitment": compute_callback_nonce_commitment(
129
+ "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8",
130
+ ),
115
131
  },
116
132
  )
117
133
 
118
134
  assert sha256_hex_for_model(envelope) == (
119
- "e17c5dae05c58f4d6034806e3f5e7a7602013d03f27ec811a97a9fc49f9d88d5"
135
+ "a212b9aaad3aca508a88608d70fd75b5642a8c0e20876308887789dc5bbfb64d"
120
136
  )
121
137
 
122
138
 
@@ -294,6 +310,8 @@ def test_validation_callback_serialization():
294
310
  """ValidationCallback should serialize correctly."""
295
311
  callback = ValidationCallback(
296
312
  run_id="run-101",
313
+ callback_id="execution-attempt-attempt-101",
314
+ callback_nonce=TEST_CALLBACK_NONCE,
297
315
  status=ValidationStatus.FAILED_RUNTIME,
298
316
  result_uri="gs://bucket/run-101/output.json",
299
317
  )
@@ -3,6 +3,7 @@
3
3
  from validibot_shared.canonicalization import (
4
4
  canonicalize_dict,
5
5
  canonicalize_model,
6
+ compute_callback_nonce_commitment,
6
7
  sha256_hex_for_dict,
7
8
  sha256_hex_for_model,
8
9
  )
@@ -18,6 +19,9 @@ from validibot_shared.evidence import (
18
19
  ContractConstant,
19
20
  ContractSignalMapping,
20
21
  EvidenceManifest,
22
+ ManifestExecutionAttempt,
23
+ ManifestExecutionInput,
24
+ ManifestInputRelationship,
21
25
  ManifestPayloadDigests,
22
26
  ManifestRetentionInfo,
23
27
  StepValidatorRecord,
@@ -38,6 +42,7 @@ __all__ = [
38
42
  "ContractSignalMapping",
39
43
  "canonicalize_dict",
40
44
  "canonicalize_model",
45
+ "compute_callback_nonce_commitment",
41
46
  "EnergyPlusSimulationLogs",
42
47
  "EnergyPlusSimulationMetrics",
43
48
  "EnergyPlusSimulationOutputs",
@@ -45,6 +50,9 @@ __all__ = [
45
50
  "FMUProbeResult",
46
51
  "FMUVariableMeta",
47
52
  "ManifestPayloadDigests",
53
+ "ManifestExecutionAttempt",
54
+ "ManifestExecutionInput",
55
+ "ManifestInputRelationship",
48
56
  "ManifestRetentionInfo",
49
57
  "SHACLInputEnvelope",
50
58
  "SHACLInputs",