stepfork 0.1.0a2__py3-none-any.whl

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 (49) hide show
  1. stepfork/__init__.py +32 -0
  2. stepfork/__main__.py +6 -0
  3. stepfork/cli/__init__.py +1 -0
  4. stepfork/cli/diff.py +156 -0
  5. stepfork/cli/export.py +210 -0
  6. stepfork/cli/inspect.py +155 -0
  7. stepfork/cli/main.py +54 -0
  8. stepfork/cli/replay.py +221 -0
  9. stepfork/cli/validate.py +160 -0
  10. stepfork/diff/__init__.py +25 -0
  11. stepfork/diff/compare.py +144 -0
  12. stepfork/diff/engine.py +267 -0
  13. stepfork/diff/models.py +63 -0
  14. stepfork/export/__init__.py +20 -0
  15. stepfork/export/entrypoint.py +58 -0
  16. stepfork/export/generator.py +178 -0
  17. stepfork/export/runtime.py +101 -0
  18. stepfork/fork/__init__.py +4 -0
  19. stepfork/inspect/__init__.py +11 -0
  20. stepfork/inspect/inspector.py +143 -0
  21. stepfork/inspect/models.py +42 -0
  22. stepfork/minimize/__init__.py +4 -0
  23. stepfork/py.typed +0 -0
  24. stepfork/recorder/__init__.py +21 -0
  25. stepfork/recorder/llm.py +68 -0
  26. stepfork/recorder/session.py +500 -0
  27. stepfork/recorder/tooling.py +208 -0
  28. stepfork/replay/__init__.py +32 -0
  29. stepfork/replay/exceptions.py +37 -0
  30. stepfork/replay/plan.py +282 -0
  31. stepfork/replay/session.py +338 -0
  32. stepfork/trace/__init__.py +115 -0
  33. stepfork/trace/canonical.py +55 -0
  34. stepfork/trace/hashing.py +75 -0
  35. stepfork/trace/integrity.py +196 -0
  36. stepfork/trace/jsonable.py +97 -0
  37. stepfork/trace/manifest.py +85 -0
  38. stepfork/trace/models.py +271 -0
  39. stepfork/trace/redaction.py +294 -0
  40. stepfork/trace/replay_policy.py +18 -0
  41. stepfork/trace/schema.py +25 -0
  42. stepfork/trace/storage.py +250 -0
  43. stepfork/trace/validation.py +285 -0
  44. stepfork/version.py +5 -0
  45. stepfork-0.1.0a2.dist-info/METADATA +377 -0
  46. stepfork-0.1.0a2.dist-info/RECORD +49 -0
  47. stepfork-0.1.0a2.dist-info/WHEEL +4 -0
  48. stepfork-0.1.0a2.dist-info/entry_points.txt +2 -0
  49. stepfork-0.1.0a2.dist-info/licenses/LICENSE +184 -0
@@ -0,0 +1,115 @@
1
+ """Portable Stepfork trace format and serialization."""
2
+
3
+ from stepfork.trace.canonical import CANONICAL_JSON_PROFILE, canonical_json_bytes
4
+ from stepfork.trace.hashing import hash_event_payloads, hash_json
5
+ from stepfork.trace.integrity import (
6
+ IntegrityIssue,
7
+ IntegrityRecord,
8
+ IntegrityResult,
9
+ IntegrityStatus,
10
+ verify_bundle_integrity,
11
+ )
12
+ from stepfork.trace.jsonable import (
13
+ Serializer,
14
+ TraceSerializationError,
15
+ to_json_value,
16
+ )
17
+ from stepfork.trace.manifest import (
18
+ EnvironmentInfo,
19
+ FailureInfo,
20
+ RunStatus,
21
+ TraceManifest,
22
+ TraceTotals,
23
+ )
24
+ from stepfork.trace.models import (
25
+ ErrorEvent,
26
+ Event,
27
+ EventBase,
28
+ EventStatus,
29
+ EventType,
30
+ JsonValue,
31
+ LLMRequest,
32
+ LLMResponse,
33
+ RunEnd,
34
+ RunStart,
35
+ StateChange,
36
+ ToolCall,
37
+ ToolResult,
38
+ Trace,
39
+ )
40
+ from stepfork.trace.redaction import (
41
+ REDACTION_REPLACEMENT,
42
+ RedactionEntry,
43
+ RedactionManifest,
44
+ RedactionResult,
45
+ redact_json,
46
+ )
47
+ from stepfork.trace.replay_policy import ReplayPolicy
48
+ from stepfork.trace.schema import (
49
+ event_json_schema,
50
+ manifest_json_schema,
51
+ trace_json_schema,
52
+ )
53
+ from stepfork.trace.storage import (
54
+ TraceStorageError,
55
+ build_manifest,
56
+ load_trace,
57
+ save_trace,
58
+ )
59
+ from stepfork.trace.validation import (
60
+ ValidationIssue,
61
+ ValidationResult,
62
+ validate_bundle,
63
+ validate_trace,
64
+ )
65
+
66
+ __all__ = [
67
+ "CANONICAL_JSON_PROFILE",
68
+ "REDACTION_REPLACEMENT",
69
+ "EnvironmentInfo",
70
+ "ErrorEvent",
71
+ "Event",
72
+ "EventBase",
73
+ "EventStatus",
74
+ "EventType",
75
+ "FailureInfo",
76
+ "IntegrityIssue",
77
+ "IntegrityRecord",
78
+ "IntegrityResult",
79
+ "IntegrityStatus",
80
+ "JsonValue",
81
+ "LLMRequest",
82
+ "LLMResponse",
83
+ "RedactionEntry",
84
+ "RedactionManifest",
85
+ "RedactionResult",
86
+ "ReplayPolicy",
87
+ "RunEnd",
88
+ "RunStart",
89
+ "RunStatus",
90
+ "Serializer",
91
+ "StateChange",
92
+ "ToolCall",
93
+ "ToolResult",
94
+ "Trace",
95
+ "TraceManifest",
96
+ "TraceSerializationError",
97
+ "TraceStorageError",
98
+ "TraceTotals",
99
+ "ValidationIssue",
100
+ "ValidationResult",
101
+ "build_manifest",
102
+ "canonical_json_bytes",
103
+ "event_json_schema",
104
+ "hash_event_payloads",
105
+ "hash_json",
106
+ "load_trace",
107
+ "manifest_json_schema",
108
+ "redact_json",
109
+ "save_trace",
110
+ "to_json_value",
111
+ "trace_json_schema",
112
+ "validate_bundle",
113
+ "validate_trace",
114
+ "verify_bundle_integrity",
115
+ ]
@@ -0,0 +1,55 @@
1
+ """Deterministic JSON serialization for Stepfork hashes.
2
+
3
+ Stepfork v0.1 uses a documented Python JSON profile for canonicalization:
4
+ sorted object keys, compact separators, UTF-8 bytes, non-ASCII characters
5
+ preserved, and non-finite floats rejected. This is not full RFC 8785.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import math
12
+ from typing import Any
13
+
14
+ from stepfork.trace.models import JsonValue
15
+
16
+ CANONICAL_JSON_PROFILE = "stepfork-json-v0.1"
17
+
18
+
19
+ class CanonicalJsonError(TypeError):
20
+ """Raised when a value cannot be canonically serialized as JSON."""
21
+
22
+
23
+ def canonical_json_bytes(value: JsonValue) -> bytes:
24
+ """Return deterministic UTF-8 JSON bytes for a JSON-compatible value."""
25
+ _validate_json_value(value)
26
+ try:
27
+ return json.dumps(
28
+ value,
29
+ ensure_ascii=False,
30
+ sort_keys=True,
31
+ separators=(",", ":"),
32
+ allow_nan=False,
33
+ ).encode("utf-8")
34
+ except (TypeError, ValueError) as exc:
35
+ raise CanonicalJsonError("value is not canonical JSON serializable") from exc
36
+
37
+
38
+ def _validate_json_value(value: Any) -> None:
39
+ if value is None or isinstance(value, str | bool | int):
40
+ return
41
+ if isinstance(value, float):
42
+ if not math.isfinite(value):
43
+ raise CanonicalJsonError("non-finite floats are not supported")
44
+ return
45
+ if isinstance(value, list):
46
+ for item in value:
47
+ _validate_json_value(item)
48
+ return
49
+ if isinstance(value, dict):
50
+ for key, item in value.items():
51
+ if not isinstance(key, str):
52
+ raise CanonicalJsonError("JSON object keys must be strings")
53
+ _validate_json_value(item)
54
+ return
55
+ raise CanonicalJsonError(f"unsupported JSON value type: {type(value).__name__}")
@@ -0,0 +1,75 @@
1
+ """SHA-256 helpers for sanitized JSON payloads."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+
7
+ from stepfork.trace.canonical import canonical_json_bytes
8
+ from stepfork.trace.models import (
9
+ ErrorEvent,
10
+ Event,
11
+ JsonValue,
12
+ LLMRequest,
13
+ LLMResponse,
14
+ RunEnd,
15
+ RunStart,
16
+ StateChange,
17
+ ToolCall,
18
+ ToolResult,
19
+ )
20
+
21
+
22
+ def hash_json(value: JsonValue) -> str:
23
+ """Return the SHA-256 hex digest for a canonical JSON value."""
24
+ return hashlib.sha256(canonical_json_bytes(value)).hexdigest()
25
+
26
+
27
+ def hash_event_payloads(event: Event) -> Event:
28
+ """Return an event copy with payload hashes populated where applicable."""
29
+ updates: dict[str, str | None] = {"input_hash": None, "output_hash": None}
30
+
31
+ if isinstance(event, RunStart):
32
+ if "input" in event.model_fields_set:
33
+ updates["input_hash"] = hash_json(event.input)
34
+ elif isinstance(event, LLMRequest | ToolCall):
35
+ updates["input_hash"] = hash_json(event.input)
36
+ elif isinstance(event, LLMResponse | ToolResult):
37
+ updates["output_hash"] = hash_json(event.output)
38
+ elif isinstance(event, StateChange):
39
+ if "before" in event.model_fields_set:
40
+ updates["input_hash"] = hash_json(event.before)
41
+ if "after" in event.model_fields_set:
42
+ updates["output_hash"] = hash_json(event.after)
43
+ elif isinstance(event, ErrorEvent):
44
+ if event.details is not None or "details" in event.model_fields_set:
45
+ updates["output_hash"] = hash_json(event.details)
46
+ elif isinstance(event, RunEnd) and (
47
+ event.output is not None or "output" in event.model_fields_set
48
+ ):
49
+ updates["output_hash"] = hash_json(event.output)
50
+
51
+ return event.model_copy(update=updates)
52
+
53
+
54
+ def verify_event_payload_hashes(event: Event) -> list[str]:
55
+ """Return payload hash mismatch issue fields for an event."""
56
+ expected = hash_event_payloads(event)
57
+ mismatches: list[str] = []
58
+
59
+ if event.input_hash is not None and event.input_hash != expected.input_hash:
60
+ mismatches.append("input_hash")
61
+ if event.output_hash is not None and event.output_hash != expected.output_hash:
62
+ mismatches.append("output_hash")
63
+
64
+ return mismatches
65
+
66
+
67
+ def event_payload_hash_fields(event: Event) -> set[str]:
68
+ """Return hash fields that are expected for the event's payload shape."""
69
+ expected = hash_event_payloads(event)
70
+ fields: set[str] = set()
71
+ if expected.input_hash is not None:
72
+ fields.add("input_hash")
73
+ if expected.output_hash is not None:
74
+ fields.add("output_hash")
75
+ return fields
@@ -0,0 +1,196 @@
1
+ """Tamper-evident integrity records for `.sftrace` bundles."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import hmac
7
+ import json
8
+ import re
9
+ from enum import StrEnum
10
+ from pathlib import Path
11
+ from typing import Literal
12
+
13
+ from pydantic import BaseModel, ConfigDict, Field, ValidationError
14
+
15
+ from stepfork.trace.storage import EVENTS_FILE, MANIFEST_FILE, REDACTIONS_FILE
16
+
17
+ INTEGRITY_FILE = "integrity.json"
18
+ INTEGRITY_ALGORITHM = "sha256"
19
+ INTEGRITY_FILES = (MANIFEST_FILE, EVENTS_FILE, REDACTIONS_FILE)
20
+ _SHA256_RE = re.compile(r"^[0-9a-f]{64}$")
21
+
22
+
23
+ class IntegrityStatus(StrEnum):
24
+ """Result status for bundle integrity verification."""
25
+
26
+ VERIFIED = "verified"
27
+ MISMATCH = "mismatch"
28
+ UNVERIFIED_LEGACY = "unverified_legacy"
29
+
30
+
31
+ class IntegrityIssue(BaseModel):
32
+ """Machine-readable integrity issue."""
33
+
34
+ model_config = ConfigDict(extra="forbid")
35
+
36
+ code: str
37
+ message: str
38
+ file: str | None = None
39
+
40
+
41
+ class IntegrityRecord(BaseModel):
42
+ """Persisted integrity metadata for a `.sftrace` bundle."""
43
+
44
+ model_config = ConfigDict(extra="forbid")
45
+
46
+ schema_version: Literal["0.1"] = "0.1"
47
+ algorithm: str = INTEGRITY_ALGORITHM
48
+ files: dict[str, str] = Field(default_factory=dict)
49
+
50
+
51
+ class IntegrityResult(BaseModel):
52
+ """Structured bundle integrity verification result."""
53
+
54
+ model_config = ConfigDict(extra="forbid")
55
+
56
+ status: IntegrityStatus
57
+ issues: list[IntegrityIssue] = Field(default_factory=list)
58
+ verified_files: list[str] = Field(default_factory=list)
59
+
60
+
61
+ def write_bundle_integrity(path: Path) -> IntegrityRecord:
62
+ """Write integrity metadata for the finalized bundle payload files."""
63
+ record = IntegrityRecord(
64
+ files={filename: _hash_file(path / filename) for filename in INTEGRITY_FILES}
65
+ )
66
+ (path / INTEGRITY_FILE).write_text(
67
+ record.model_dump_json(indent=2) + "\n",
68
+ encoding="utf-8",
69
+ )
70
+ return record
71
+
72
+
73
+ def verify_bundle_integrity(path: str | Path) -> IntegrityResult:
74
+ """Verify a bundle's integrity record."""
75
+ bundle_path = Path(path)
76
+ integrity_path = bundle_path / INTEGRITY_FILE
77
+ if not integrity_path.exists():
78
+ return IntegrityResult(
79
+ status=IntegrityStatus.UNVERIFIED_LEGACY,
80
+ issues=[
81
+ IntegrityIssue(
82
+ code="integrity_unverified",
83
+ message="Bundle has no integrity.json metadata.",
84
+ file=INTEGRITY_FILE,
85
+ )
86
+ ],
87
+ )
88
+ if not integrity_path.is_file():
89
+ return IntegrityResult(
90
+ status=IntegrityStatus.MISMATCH,
91
+ issues=[
92
+ IntegrityIssue(
93
+ code="integrity_metadata_invalid",
94
+ message="integrity.json is not a file.",
95
+ file=INTEGRITY_FILE,
96
+ )
97
+ ],
98
+ )
99
+
100
+ try:
101
+ payload = json.loads(integrity_path.read_text(encoding="utf-8"))
102
+ record = IntegrityRecord.model_validate(payload)
103
+ except json.JSONDecodeError:
104
+ return _metadata_invalid("integrity.json is not valid JSON.")
105
+ except ValidationError:
106
+ return _metadata_invalid("integrity.json metadata is invalid.")
107
+
108
+ issues = _validate_record(record)
109
+ verified_files: list[str] = []
110
+ if issues:
111
+ return IntegrityResult(status=IntegrityStatus.MISMATCH, issues=issues)
112
+
113
+ for filename in INTEGRITY_FILES:
114
+ file_path = bundle_path / filename
115
+ if not file_path.is_file():
116
+ issues.append(
117
+ IntegrityIssue(
118
+ code="missing_file",
119
+ message=f"{filename} is missing.",
120
+ file=filename,
121
+ )
122
+ )
123
+ continue
124
+
125
+ actual = _hash_file(file_path)
126
+ expected = record.files[filename]
127
+ if hmac.compare_digest(actual, expected):
128
+ verified_files.append(filename)
129
+ else:
130
+ issues.append(
131
+ IntegrityIssue(
132
+ code="integrity_mismatch",
133
+ message="SHA-256 mismatch.",
134
+ file=filename,
135
+ )
136
+ )
137
+
138
+ return IntegrityResult(
139
+ status=IntegrityStatus.VERIFIED if not issues else IntegrityStatus.MISMATCH,
140
+ issues=issues,
141
+ verified_files=verified_files,
142
+ )
143
+
144
+
145
+ def _validate_record(record: IntegrityRecord) -> list[IntegrityIssue]:
146
+ issues: list[IntegrityIssue] = []
147
+ if record.algorithm != INTEGRITY_ALGORITHM:
148
+ issues.append(
149
+ IntegrityIssue(
150
+ code="integrity_algorithm_unsupported",
151
+ message=f"Unsupported integrity algorithm {record.algorithm!r}.",
152
+ file=INTEGRITY_FILE,
153
+ )
154
+ )
155
+ return issues
156
+
157
+ for filename in INTEGRITY_FILES:
158
+ digest = record.files.get(filename)
159
+ if digest is None:
160
+ issues.append(
161
+ IntegrityIssue(
162
+ code="integrity_metadata_invalid",
163
+ message=f"Missing digest for {filename}.",
164
+ file=filename,
165
+ )
166
+ )
167
+ elif _SHA256_RE.fullmatch(digest) is None:
168
+ issues.append(
169
+ IntegrityIssue(
170
+ code="integrity_metadata_invalid",
171
+ message=f"Invalid SHA-256 digest for {filename}.",
172
+ file=filename,
173
+ )
174
+ )
175
+ return issues
176
+
177
+
178
+ def _metadata_invalid(message: str) -> IntegrityResult:
179
+ return IntegrityResult(
180
+ status=IntegrityStatus.MISMATCH,
181
+ issues=[
182
+ IntegrityIssue(
183
+ code="integrity_metadata_invalid",
184
+ message=message,
185
+ file=INTEGRITY_FILE,
186
+ )
187
+ ],
188
+ )
189
+
190
+
191
+ def _hash_file(path: Path) -> str:
192
+ digest = hashlib.sha256()
193
+ with path.open("rb") as file:
194
+ for chunk in iter(lambda: file.read(1024 * 1024), b""):
195
+ digest.update(chunk)
196
+ return digest.hexdigest()
@@ -0,0 +1,97 @@
1
+ """Conversion of runtime values into JSON-compatible trace payloads.
2
+
3
+ Stepfork never falls back to ``repr()`` when recording payloads. Values must
4
+ already be JSON-compatible (``None``, ``str``, ``bool``, ``int``, finite
5
+ ``float``, ``list``/``tuple``, ``dict`` with ``str`` keys) or the caller must
6
+ provide an explicit serializer hook.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import math
12
+ from collections.abc import Callable
13
+ from typing import Any, TypeAlias
14
+
15
+ from stepfork.trace.models import JsonValue
16
+
17
+ Serializer: TypeAlias = Callable[[Any], Any]
18
+
19
+ MAX_DEPTH = 100
20
+
21
+
22
+ class TraceSerializationError(TypeError):
23
+ """Raised when a runtime value cannot be represented as JSON."""
24
+
25
+
26
+ def to_json_value(
27
+ value: Any,
28
+ *,
29
+ serializer: Serializer | None = None,
30
+ path: str = "$",
31
+ depth: int = 0,
32
+ ) -> JsonValue:
33
+ """Convert ``value`` into a JSON-compatible value or raise a clear error.
34
+
35
+ Non-JSON values are first passed through the optional ``serializer`` hook.
36
+ The hook must return a JSON-compatible value; returning the input object
37
+ unchanged (or another unsupported value) is an error.
38
+ """
39
+ if depth > MAX_DEPTH:
40
+ raise TraceSerializationError(
41
+ f"value at {path} exceeds the maximum nesting depth of {MAX_DEPTH}; "
42
+ "this usually means a cyclic structure"
43
+ )
44
+
45
+ if value is None or isinstance(value, str | bool | int):
46
+ return value
47
+ if isinstance(value, float):
48
+ if not math.isfinite(value):
49
+ raise TraceSerializationError(
50
+ f"non-finite float at {path} cannot be recorded as JSON"
51
+ )
52
+ return value
53
+ if isinstance(value, list | tuple):
54
+ return [
55
+ to_json_value(
56
+ item,
57
+ serializer=serializer,
58
+ path=f"{path}[{index}]",
59
+ depth=depth + 1,
60
+ )
61
+ for index, item in enumerate(value)
62
+ ]
63
+ if isinstance(value, dict):
64
+ converted: dict[str, JsonValue] = {}
65
+ for key, item in value.items():
66
+ if not isinstance(key, str):
67
+ raise TraceSerializationError(
68
+ f"object key at {path} must be a string, got "
69
+ f"{type(key).__name__}: {key!r}"
70
+ )
71
+ converted[key] = to_json_value(
72
+ item,
73
+ serializer=serializer,
74
+ path=f"{path}.{key}",
75
+ depth=depth + 1,
76
+ )
77
+ return converted
78
+
79
+ if serializer is not None:
80
+ converted_value = serializer(value)
81
+ if converted_value is value:
82
+ raise TraceSerializationError(
83
+ f"serializer returned the original {type(value).__name__} "
84
+ f"unchanged at {path}"
85
+ )
86
+ return to_json_value(
87
+ converted_value,
88
+ serializer=serializer,
89
+ path=path,
90
+ depth=depth + 1,
91
+ )
92
+
93
+ raise TraceSerializationError(
94
+ f"cannot serialize {type(value).__name__} at {path}; pass "
95
+ "JSON-compatible values or provide a serializer hook via "
96
+ "record(..., serializer=...) or trace_tool(..., serializer=...)"
97
+ )
@@ -0,0 +1,85 @@
1
+ """Manifest models for the Stepfork `.sftrace` v0.1 contract."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import datetime
6
+ from enum import StrEnum
7
+ from typing import Literal, Self
8
+
9
+ from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
10
+
11
+
12
+ class RunStatus(StrEnum):
13
+ """Lifecycle status for an agent run."""
14
+
15
+ RUNNING = "running"
16
+ COMPLETED = "completed"
17
+ FAILED = "failed"
18
+
19
+
20
+ class FailureInfo(BaseModel):
21
+ """Failure summary stored in a trace manifest."""
22
+
23
+ model_config = ConfigDict(extra="forbid")
24
+
25
+ type: str = Field(min_length=1)
26
+ message: str = Field(min_length=1)
27
+ step: int = Field(ge=0)
28
+
29
+
30
+ class EnvironmentInfo(BaseModel):
31
+ """Optional environment metadata captured by future recorders."""
32
+
33
+ model_config = ConfigDict(extra="forbid")
34
+
35
+ python: str | None = None
36
+ os: str | None = None
37
+ stepfork_version: str | None = None
38
+
39
+
40
+ class TraceTotals(BaseModel):
41
+ """Aggregate counters for a trace."""
42
+
43
+ model_config = ConfigDict(extra="forbid")
44
+
45
+ events: int = Field(default=0, ge=0)
46
+ llm_calls: int = Field(default=0, ge=0)
47
+ tool_calls: int = Field(default=0, ge=0)
48
+ cost_usd: float | None = Field(default=None, ge=0)
49
+ duration_ms: int | None = Field(default=None, ge=0)
50
+
51
+
52
+ class TraceManifest(BaseModel):
53
+ """Top-level manifest for a portable `.sftrace` directory."""
54
+
55
+ model_config = ConfigDict(extra="forbid")
56
+
57
+ format: Literal["stepfork"] = "stepfork"
58
+ schema_version: Literal["0.1"] = "0.1"
59
+
60
+ run_id: str = Field(min_length=1)
61
+ agent_name: str = Field(min_length=1)
62
+ created_at: datetime
63
+
64
+ status: RunStatus
65
+
66
+ failure: FailureInfo | None = None
67
+ environment: EnvironmentInfo = Field(default_factory=EnvironmentInfo)
68
+ totals: TraceTotals = Field(default_factory=TraceTotals)
69
+
70
+ @field_validator("created_at")
71
+ @classmethod
72
+ def created_at_must_be_timezone_aware(cls, value: datetime) -> datetime:
73
+ """Reject naive datetimes instead of assuming UTC."""
74
+ if value.tzinfo is None or value.utcoffset() is None:
75
+ raise ValueError("created_at must be timezone-aware")
76
+ return value
77
+
78
+ @model_validator(mode="after")
79
+ def failure_must_match_status(self) -> Self:
80
+ """Keep manifest failure details aligned with run status."""
81
+ if self.status is RunStatus.FAILED and self.failure is None:
82
+ raise ValueError("failure is required when status is failed")
83
+ if self.status is not RunStatus.FAILED and self.failure is not None:
84
+ raise ValueError("failure must be omitted unless status is failed")
85
+ return self