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.
- {validibot_shared-0.16.0 → validibot_shared-0.18.0}/PKG-INFO +10 -2
- {validibot_shared-0.16.0 → validibot_shared-0.18.0}/README.md +9 -1
- {validibot_shared-0.16.0 → validibot_shared-0.18.0}/pyproject.toml +1 -1
- validibot_shared-0.18.0/tests/test_callback_nonce_contract.py +144 -0
- {validibot_shared-0.16.0 → validibot_shared-0.18.0}/tests/test_canonicalization.py +63 -0
- {validibot_shared-0.16.0 → validibot_shared-0.18.0}/tests/test_energyplus_envelopes.py +8 -0
- {validibot_shared-0.16.0 → validibot_shared-0.18.0}/tests/test_evidence_manifest.py +100 -0
- {validibot_shared-0.16.0 → validibot_shared-0.18.0}/tests/test_fmu_envelopes.py +11 -0
- {validibot_shared-0.16.0 → validibot_shared-0.18.0}/tests/test_schematron_envelopes.py +8 -0
- {validibot_shared-0.16.0 → validibot_shared-0.18.0}/tests/test_shacl_envelopes.py +8 -0
- {validibot_shared-0.16.0 → validibot_shared-0.18.0}/tests/test_validations_envelopes.py +21 -3
- {validibot_shared-0.16.0 → validibot_shared-0.18.0}/validibot_shared/__init__.py +8 -0
- validibot_shared-0.18.0/validibot_shared/canonicalization.py +131 -0
- {validibot_shared-0.16.0 → validibot_shared-0.18.0}/validibot_shared/evidence/__init__.py +6 -0
- {validibot_shared-0.16.0 → validibot_shared-0.18.0}/validibot_shared/evidence/manifest.py +197 -4
- {validibot_shared-0.16.0 → validibot_shared-0.18.0}/validibot_shared/fmu/envelopes.py +12 -0
- {validibot_shared-0.16.0 → validibot_shared-0.18.0}/validibot_shared/schematron/envelopes.py +6 -0
- {validibot_shared-0.16.0 → validibot_shared-0.18.0}/validibot_shared/shacl/envelopes.py +6 -0
- {validibot_shared-0.16.0 → validibot_shared-0.18.0}/validibot_shared/validations/envelopes.py +69 -6
- validibot_shared-0.16.0/validibot_shared/canonicalization.py +0 -57
- {validibot_shared-0.16.0 → validibot_shared-0.18.0}/.gitignore +0 -0
- {validibot_shared-0.16.0 → validibot_shared-0.18.0}/LICENSE +0 -0
- {validibot_shared-0.16.0 → validibot_shared-0.18.0}/NOTICE +0 -0
- {validibot_shared-0.16.0 → validibot_shared-0.18.0}/tests/__init__.py +0 -0
- {validibot_shared-0.16.0 → validibot_shared-0.18.0}/tests/test_artifacts.py +0 -0
- {validibot_shared-0.16.0 → validibot_shared-0.18.0}/tests/test_energyplus_models.py +0 -0
- {validibot_shared-0.16.0 → validibot_shared-0.18.0}/tests/test_fmu_models.py +0 -0
- {validibot_shared-0.16.0 → validibot_shared-0.18.0}/tests/test_package_init.py +0 -0
- {validibot_shared-0.16.0 → validibot_shared-0.18.0}/tests/test_schematron_svrl.py +0 -0
- {validibot_shared-0.16.0 → validibot_shared-0.18.0}/validibot_shared/_vendor/__init__.py +0 -0
- {validibot_shared-0.16.0 → validibot_shared-0.18.0}/validibot_shared/_vendor/jcs.py +0 -0
- {validibot_shared-0.16.0 → validibot_shared-0.18.0}/validibot_shared/energyplus/__init__.py +0 -0
- {validibot_shared-0.16.0 → validibot_shared-0.18.0}/validibot_shared/energyplus/envelopes.py +0 -0
- {validibot_shared-0.16.0 → validibot_shared-0.18.0}/validibot_shared/energyplus/models.py +0 -0
- {validibot_shared-0.16.0 → validibot_shared-0.18.0}/validibot_shared/fmu/__init__.py +0 -0
- {validibot_shared-0.16.0 → validibot_shared-0.18.0}/validibot_shared/fmu/models.py +0 -0
- {validibot_shared-0.16.0 → validibot_shared-0.18.0}/validibot_shared/py.typed +0 -0
- {validibot_shared-0.16.0 → validibot_shared-0.18.0}/validibot_shared/schematron/__init__.py +0 -0
- {validibot_shared-0.16.0 → validibot_shared-0.18.0}/validibot_shared/schematron/svrl.py +0 -0
- {validibot_shared-0.16.0 → validibot_shared-0.18.0}/validibot_shared/shacl/__init__.py +0 -0
- {validibot_shared-0.16.0 → validibot_shared-0.18.0}/validibot_shared/validations/__init__.py +0 -0
- {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.
|
|
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` |
|
|
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` |
|
|
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.
|
|
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
|
|
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
|
-
"
|
|
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
|
-
"
|
|
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",
|