runproof-engine 0.1.0__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.
@@ -0,0 +1,33 @@
1
+ from .core import (
2
+ CheckRecord,
3
+ ReplayUnavailable,
4
+ RunContext,
5
+ RunProofError,
6
+ RunResult,
7
+ StepRecord,
8
+ verified,
9
+ )
10
+ from .diff import Difference, RunDiff, compare_manifests
11
+ from .policy import Policy, PolicyDenied, safe_default_policy
12
+ from .replay import LoadedRun, ReplayReport, load_run
13
+
14
+ __all__ = [
15
+ "CheckRecord",
16
+ "Difference",
17
+ "LoadedRun",
18
+ "Policy",
19
+ "PolicyDenied",
20
+ "ReplayReport",
21
+ "ReplayUnavailable",
22
+ "RunContext",
23
+ "RunDiff",
24
+ "RunProofError",
25
+ "RunResult",
26
+ "StepRecord",
27
+ "compare_manifests",
28
+ "load_run",
29
+ "safe_default_policy",
30
+ "verified",
31
+ ]
32
+
33
+ __version__ = "0.1.0"
runproof_engine/cli.py ADDED
@@ -0,0 +1,69 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import json
5
+ import sys
6
+ from pathlib import Path
7
+
8
+ from .replay import LoadedRun, load_run
9
+
10
+
11
+ def build_parser() -> argparse.ArgumentParser:
12
+ parser = argparse.ArgumentParser(prog="runproof", description="Inspect and compare RunProof artifacts")
13
+ subparsers = parser.add_subparsers(dest="command", required=True)
14
+
15
+ inspect_parser = subparsers.add_parser("inspect", help="inspect a run artifact")
16
+ inspect_parser.add_argument("path", type=Path)
17
+ inspect_parser.add_argument("--json", action="store_true", dest="as_json")
18
+
19
+ verify_parser = subparsers.add_parser("verify", help="verify input and output integrity")
20
+ verify_parser.add_argument("path", type=Path)
21
+ verify_parser.add_argument("--json", action="store_true", dest="as_json")
22
+
23
+ diff_parser = subparsers.add_parser("diff", help="compare two run artifacts")
24
+ diff_parser.add_argument("left", type=Path)
25
+ diff_parser.add_argument("right", type=Path)
26
+ diff_parser.add_argument("--json", action="store_true", dest="as_json")
27
+ return parser
28
+
29
+
30
+ def main(argv: list[str] | None = None) -> int:
31
+ parser = build_parser()
32
+ args = parser.parse_args(argv)
33
+ try:
34
+ if args.command == "inspect":
35
+ run = load_run(args.path)
36
+ payload = run.manifest
37
+ text = json.dumps(payload, indent=2, ensure_ascii=False) if args.as_json else _inspect_text(run)
38
+ elif args.command == "verify":
39
+ run = load_run(args.path)
40
+ report = run.verify_integrity()
41
+ payload = report.to_dict()
42
+ text = json.dumps(payload, indent=2, ensure_ascii=False) if args.as_json else str(report)
43
+ print(text)
44
+ return 0 if report.status == "verified" else 2
45
+ else:
46
+ left = load_run(args.left)
47
+ right = load_run(args.right)
48
+ comparison = left.diff(right)
49
+ payload = comparison.to_dict()
50
+ text = json.dumps(payload, indent=2, ensure_ascii=False) if args.as_json else comparison.render()
51
+ print(text)
52
+ return 0
53
+ except (OSError, ValueError, KeyError) as error:
54
+ print(f"runproof: {error}", file=sys.stderr)
55
+ return 2
56
+
57
+
58
+ def _inspect_text(run: LoadedRun) -> str:
59
+ manifest = run.manifest
60
+ run_info = manifest.get("run", {})
61
+ return "\n".join([
62
+ f"name: {run_info.get('name')}",
63
+ f"run_id: {run_info.get('run_id')}",
64
+ f"status: {run_info.get('status')}",
65
+ f"inputs: {len(manifest.get('inputs', []))}",
66
+ f"steps: {len(manifest.get('steps', []))}",
67
+ f"outputs: {len(manifest.get('outputs', []))}",
68
+ f"checks: {len(manifest.get('checks', []))}",
69
+ ])
@@ -0,0 +1,557 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import shutil
5
+ import traceback
6
+ import uuid
7
+ from collections.abc import Callable
8
+ from dataclasses import dataclass, field
9
+ from pathlib import Path
10
+ from time import perf_counter
11
+ from types import TracebackType
12
+ from typing import Any, Self
13
+ from urllib.error import HTTPError, URLError
14
+ from urllib.request import Request, urlopen
15
+
16
+ from .policy import Policy, PolicyDenied, safe_default_policy
17
+ from .utils import (
18
+ environment_snapshot,
19
+ file_metadata,
20
+ fingerprint,
21
+ function_descriptor,
22
+ safe_value,
23
+ sha256_bytes,
24
+ sha256_file,
25
+ summarize,
26
+ utc_now,
27
+ write_json,
28
+ )
29
+
30
+
31
+ class RunProofError(RuntimeError):
32
+ """Base exception for RunProof failures."""
33
+
34
+
35
+ class ReplayUnavailable(RunProofError):
36
+ """Raised when an artifact does not contain enough information for replay."""
37
+
38
+
39
+ @dataclass
40
+ class CheckRecord:
41
+ name: str
42
+ passed: bool
43
+ message: str
44
+ created_at: str = field(default_factory=utc_now)
45
+
46
+ def to_dict(self) -> dict[str, Any]:
47
+ return {
48
+ "name": self.name,
49
+ "passed": self.passed,
50
+ "message": self.message,
51
+ "created_at": self.created_at,
52
+ }
53
+
54
+
55
+ @dataclass
56
+ class StepRecord:
57
+ name: str
58
+ status: str
59
+ started_at: str
60
+ finished_at: str
61
+ duration_ms: float
62
+ function: dict[str, Any]
63
+ input_summaries: list[dict[str, Any]]
64
+ output_summary: dict[str, Any] | None = None
65
+ error: dict[str, Any] | None = None
66
+ replayable: bool = False
67
+
68
+ def to_dict(self) -> dict[str, Any]:
69
+ return {
70
+ "name": self.name,
71
+ "status": self.status,
72
+ "started_at": self.started_at,
73
+ "finished_at": self.finished_at,
74
+ "duration_ms": round(self.duration_ms, 3),
75
+ "function": self.function,
76
+ "inputs": self.input_summaries,
77
+ "output": self.output_summary,
78
+ "error": self.error,
79
+ "replayable": self.replayable,
80
+ }
81
+
82
+
83
+ @dataclass
84
+ class RunResult:
85
+ run_id: str
86
+ name: str
87
+ status: str
88
+ artifact_dir: Path
89
+ started_at: str
90
+ finished_at: str | None = None
91
+ error: dict[str, Any] | None = None
92
+
93
+ def to_dict(self) -> dict[str, Any]:
94
+ return {
95
+ "run_id": self.run_id,
96
+ "name": self.name,
97
+ "status": self.status,
98
+ "artifact_dir": str(self.artifact_dir),
99
+ "started_at": self.started_at,
100
+ "finished_at": self.finished_at,
101
+ "error": self.error,
102
+ }
103
+
104
+ def summary(self) -> str:
105
+ return (
106
+ f"status={self.status} name={self.name} run_id={self.run_id} "
107
+ f"artifact_dir={self.artifact_dir}"
108
+ )
109
+
110
+
111
+ class RunContext:
112
+ """Record a declared Python workflow as a real, inspectable run artifact."""
113
+
114
+ def __init__(
115
+ self,
116
+ name: str,
117
+ *,
118
+ root: str | Path = "runs",
119
+ copy_inputs: bool = False,
120
+ capture_environment: bool = True,
121
+ fail_on_check: bool = False,
122
+ policy: Policy | None = None,
123
+ ) -> None:
124
+ self.name = name
125
+ self.root = Path(root)
126
+ self.copy_inputs = copy_inputs
127
+ self.capture_environment = capture_environment
128
+ self.fail_on_check = fail_on_check
129
+ self.policy = policy or safe_default_policy()
130
+ self.run_id = f"{utc_now().replace(':', '').replace('-', '')}-{uuid.uuid4().hex[:10]}"
131
+ self.artifact_dir = self.root / self._safe_name(name) / self.run_id
132
+ self._started_at = utc_now()
133
+ self._started_clock = 0.0
134
+ self._finished_at: str | None = None
135
+ self._steps: list[StepRecord] = []
136
+ self._checks: list[CheckRecord] = []
137
+ self._inputs: list[dict[str, Any]] = []
138
+ self._outputs: list[dict[str, Any]] = []
139
+ self._observations: list[dict[str, Any]] = []
140
+ self._events: list[dict[str, Any]] = []
141
+ self._environment: dict[str, Any] | None = None
142
+ self._status = "running"
143
+ self._error: dict[str, Any] | None = None
144
+ self.result: RunResult = RunResult(
145
+ run_id=self.run_id,
146
+ name=self.name,
147
+ status=self._status,
148
+ artifact_dir=self.artifact_dir,
149
+ started_at=self._started_at,
150
+ )
151
+
152
+ @staticmethod
153
+ def _safe_name(value: str) -> str:
154
+ safe = "".join(character if character.isalnum() or character in "-_" else "_" for character in value)
155
+ return safe.strip("_") or "run"
156
+
157
+ def __enter__(self) -> Self:
158
+ self.artifact_dir.mkdir(parents=True, exist_ok=False)
159
+ for directory in ("inputs", "outputs", "checks", "execution", "environment"):
160
+ (self.artifact_dir / directory).mkdir()
161
+ self._started_clock = perf_counter()
162
+ self._event("run_started", {"name": self.name})
163
+ if self.capture_environment:
164
+ self._environment = environment_snapshot()
165
+ write_json(self.artifact_dir / "environment" / "snapshot.json", self._environment)
166
+ return self
167
+
168
+ def __exit__(self, exc_type: type[BaseException] | None, exc: BaseException | None, tb: TracebackType | None) -> bool:
169
+ if isinstance(exc, PolicyDenied):
170
+ self._status = "blocked"
171
+ self._error = {
172
+ "type": f"{type(exc).__module__}.{type(exc).__qualname__}",
173
+ "message": str(exc),
174
+ }
175
+ self._event("action_blocked", self._error)
176
+ elif exc is not None:
177
+ self._status = "failed"
178
+ self._error = {
179
+ "type": f"{type(exc).__module__}.{type(exc).__qualname__}",
180
+ "message": str(exc),
181
+ "traceback": "".join(traceback.format_exception(type(exc), exc, tb)),
182
+ }
183
+ self._event("run_failed", self._error)
184
+ elif any(not check.passed for check in self._checks):
185
+ self._status = "failed" if self.fail_on_check else "verified_with_warnings"
186
+ self._event("checks_completed", {"failed": True})
187
+ else:
188
+ self._status = "verified"
189
+ self._event("checks_completed", {"failed": False})
190
+ self._finalize()
191
+ return False
192
+
193
+ def _event(self, event_type: str, payload: dict[str, Any]) -> None:
194
+ self._events.append({
195
+ "event": event_type,
196
+ "at": utc_now(),
197
+ "payload": safe_value(payload),
198
+ })
199
+
200
+ def authorize(self, action: str, *, approved: bool = False, target: str | None = None) -> dict[str, Any]:
201
+ """Authorize a sensitive action and record the decision in the trace."""
202
+ try:
203
+ decision = self.policy.authorize(action, approved=approved, target=target)
204
+ except PolicyDenied as error:
205
+ self._event("action_blocked", {"action": action, "target": target, "reason": str(error)})
206
+ raise
207
+ self._event("action_authorized", decision)
208
+ return decision
209
+
210
+ def input(self, path: str | Path, *, name: str | None = None, copy: bool | None = None) -> Path:
211
+ """Register a real file input and return its resolved path."""
212
+ should_copy = self.copy_inputs if copy is None else copy
213
+ record_name = name or Path(path).stem or "input"
214
+ metadata = file_metadata(path)
215
+ destination: str | None = None
216
+ if should_copy:
217
+ target = self.artifact_dir / "inputs" / Path(path).name
218
+ if target.exists():
219
+ target = self.artifact_dir / "inputs" / f"{fingerprint(str(path))[:8]}-{Path(path).name}"
220
+ shutil.copy2(path, target)
221
+ destination = str(target.relative_to(self.artifact_dir))
222
+ metadata["captured_copy"] = destination
223
+ metadata["name"] = record_name
224
+ self._inputs.append(metadata)
225
+ self._event("input_registered", metadata)
226
+ write_json(self.artifact_dir / "inputs" / f"{self._safe_name(record_name)}.json", metadata)
227
+ return Path(metadata["path"])
228
+
229
+ def step(self, name: str, function: Callable[..., Any], *args: Any, **kwargs: Any) -> Any:
230
+ """Execute a callable and record a bounded, privacy-safe step trace."""
231
+ started_at = utc_now()
232
+ clock = perf_counter()
233
+ descriptor = function_descriptor(function)
234
+ self._event("step_started", {"name": name, "function": descriptor})
235
+ try:
236
+ output = function(*args, **kwargs)
237
+ finished_at = utc_now()
238
+ record = StepRecord(
239
+ name=name,
240
+ status="completed",
241
+ started_at=started_at,
242
+ finished_at=finished_at,
243
+ duration_ms=(perf_counter() - clock) * 1000,
244
+ function=descriptor,
245
+ input_summaries=[summarize(value) for value in args],
246
+ output_summary=summarize(output),
247
+ replayable=self._is_json_replayable(args, kwargs, output),
248
+ )
249
+ self._steps.append(record)
250
+ self._event("step_completed", {"name": name, "output": record.output_summary})
251
+ return output
252
+ except Exception as error:
253
+ finished_at = utc_now()
254
+ record = StepRecord(
255
+ name=name,
256
+ status="failed",
257
+ started_at=started_at,
258
+ finished_at=finished_at,
259
+ duration_ms=(perf_counter() - clock) * 1000,
260
+ function=descriptor,
261
+ input_summaries=[summarize(value) for value in args],
262
+ error={
263
+ "type": f"{type(error).__module__}.{type(error).__qualname__}",
264
+ "message": str(error),
265
+ },
266
+ )
267
+ self._steps.append(record)
268
+ self._event("step_failed", {"name": name, "error": record.error or {}})
269
+ raise
270
+
271
+ def output(self, path: str | Path, value: Any, *, name: str | None = None) -> Path:
272
+ """Write a JSON-safe output inside the artifact and record its fingerprint."""
273
+ target = self._output_target(path)
274
+ if target.suffix.lower() not in {".json", ".jsonl"}:
275
+ raise RunProofError("RunContext.output currently supports .json or .jsonl; use save_file for binary/text artifacts")
276
+ target.parent.mkdir(parents=True, exist_ok=True)
277
+ target.write_text(json.dumps(value, indent=2, ensure_ascii=False, default=str) + "\n", encoding="utf-8")
278
+ record = {
279
+ "name": name or target.stem,
280
+ "path": str(target.relative_to(self.artifact_dir)),
281
+ "size_bytes": target.stat().st_size,
282
+ "sha256": file_metadata(target)["sha256"],
283
+ "value_summary": summarize(value),
284
+ }
285
+ self._outputs.append(record)
286
+ self._event("output_saved", record)
287
+ return target
288
+
289
+ def save_file(self, path: str | Path, *, source: str | Path | None = None, content: str | bytes | None = None) -> Path:
290
+ """Save a real text or binary artifact and register its hash."""
291
+ target = self._output_target(path)
292
+ target.parent.mkdir(parents=True, exist_ok=True)
293
+ if source is not None and content is not None:
294
+ raise ValueError("provide source or content, not both")
295
+ if source is not None:
296
+ shutil.copy2(source, target)
297
+ elif isinstance(content, bytes):
298
+ target.write_bytes(content)
299
+ elif isinstance(content, str):
300
+ target.write_text(content, encoding="utf-8")
301
+ else:
302
+ raise ValueError("source or content is required")
303
+ record = {
304
+ "name": target.stem,
305
+ "path": str(target.relative_to(self.artifact_dir)),
306
+ "size_bytes": target.stat().st_size,
307
+ "sha256": file_metadata(target)["sha256"],
308
+ }
309
+ self._outputs.append(record)
310
+ self._event("file_saved", record)
311
+ return target
312
+
313
+ def observe(self, value: Any, *, name: str) -> Any:
314
+ """Record a bounded summary of an in-memory value without copying its full contents."""
315
+ record = {"name": name, "summary": summarize(value)}
316
+ self._observations.append(record)
317
+ self._event("value_observed", record)
318
+ return value
319
+
320
+ def external_call(
321
+ self,
322
+ name: str,
323
+ *,
324
+ provider: str,
325
+ request: Any,
326
+ response: Any,
327
+ status_code: int | None = None,
328
+ approved: bool = False,
329
+ _authorized: bool = False,
330
+ ) -> Any:
331
+ """Record a completed real external call after policy authorization.
332
+
333
+ The core package never performs the network request and never stores
334
+ credentials. The caller performs the request, then gives RunProof a
335
+ privacy-safe request and the response to fingerprint and summarize.
336
+ """
337
+ if not _authorized:
338
+ self.authorize("network", approved=approved, target=provider)
339
+ response_summary = summarize(response)
340
+ response_summary.pop("value", None)
341
+ record = {
342
+ "name": name,
343
+ "provider": provider,
344
+ "request": safe_value(request),
345
+ "response": response_summary,
346
+ "response_fingerprint": fingerprint(response),
347
+ "status_code": status_code,
348
+ }
349
+ self._event("external_call", record)
350
+ return response
351
+
352
+ def request(
353
+ self,
354
+ name: str,
355
+ url: str,
356
+ *,
357
+ method: str = "GET",
358
+ headers: dict[str, str] | None = None,
359
+ body: bytes | str | dict[str, Any] | None = None,
360
+ timeout: float = 30.0,
361
+ approved: bool = False,
362
+ ) -> Any:
363
+ """Perform and record a real HTTP request after explicit authorization."""
364
+ self.authorize("network", approved=approved, target=url)
365
+ request_headers = dict(headers or {})
366
+ safe_headers = {
367
+ key: "[REDACTED]" if key.lower() in {"authorization", "proxy-authorization", "cookie", "set-cookie"} else value
368
+ for key, value in request_headers.items()
369
+ }
370
+ if isinstance(body, dict):
371
+ request_body = json.dumps(body, ensure_ascii=False).encode("utf-8")
372
+ request_headers.setdefault("Content-Type", "application/json")
373
+ elif isinstance(body, str):
374
+ request_body = body.encode("utf-8")
375
+ else:
376
+ request_body = body
377
+ request = Request(url, data=request_body, headers=request_headers, method=method.upper())
378
+ request_meta = {
379
+ "method": method.upper(),
380
+ "url": url,
381
+ "headers": safe_headers,
382
+ "body_sha256": sha256_bytes(request_body) if request_body else None,
383
+ }
384
+ try:
385
+ with urlopen(request, timeout=timeout) as response:
386
+ raw = response.read(10 * 1024 * 1024 + 1)
387
+ truncated = len(raw) > 10 * 1024 * 1024
388
+ raw = raw[:10 * 1024 * 1024]
389
+ content_type = response.headers.get("Content-Type", "")
390
+ text = raw.decode("utf-8", errors="replace")
391
+ parsed: Any = text
392
+ if "json" in content_type.lower():
393
+ try:
394
+ parsed = json.loads(text)
395
+ except json.JSONDecodeError:
396
+ parsed = text
397
+ self.external_call(
398
+ name,
399
+ provider=url,
400
+ request=request_meta,
401
+ response=parsed,
402
+ status_code=getattr(response, "status", None),
403
+ approved=True,
404
+ _authorized=True,
405
+ )
406
+ self._event("external_response", {"name": name, "content_type": content_type, "truncated": truncated, "bytes": len(raw)})
407
+ return parsed
408
+ except (HTTPError, URLError, TimeoutError) as error:
409
+ self._event("external_call_failed", {
410
+ "name": name,
411
+ "provider": url,
412
+ "error_type": type(error).__name__,
413
+ "message": str(error),
414
+ })
415
+ raise
416
+
417
+ def check_schema(self, value: Any, *, required_columns: list[str], types: dict[str, str] | None = None) -> bool:
418
+ columns = getattr(value, "columns", None)
419
+ actual = [str(column) for column in list(columns)] if columns is not None else []
420
+ missing = [column for column in required_columns if column not in actual]
421
+ passed = not missing
422
+ message = "schema contains required columns" if passed else f"missing columns: {missing}"
423
+ if passed and types:
424
+ dtypes = getattr(value, "dtypes", {})
425
+ mismatches = []
426
+ for column, expected in types.items():
427
+ if column in dtypes and not self._type_matches(dtypes[column], expected):
428
+ mismatches.append(f"{column}: expected {expected}, got {dtypes[column]}")
429
+ if mismatches:
430
+ passed = False
431
+ message = "; ".join(mismatches)
432
+ return self._record_check("schema", passed, message)
433
+
434
+ @staticmethod
435
+ def _type_matches(actual: Any, expected: str) -> bool:
436
+ actual_text = str(actual).lower()
437
+ expected_text = expected.lower()
438
+ aliases = {
439
+ "number": ("int", "float", "decimal", "double", "number"),
440
+ "integer": ("int", "integer"),
441
+ "float": ("float", "double"),
442
+ "string": ("object", "string", "str", "unicode"),
443
+ "datetime": ("datetime", "date", "time"),
444
+ "boolean": ("bool", "boolean"),
445
+ }
446
+ tokens = aliases.get(expected_text, (expected_text,))
447
+ return any(token in actual_text for token in tokens)
448
+
449
+ def assert_true(self, condition: bool, message: str = "assertion passed", *, name: str | None = None) -> bool:
450
+ return self._record_check(name or "assert_true", bool(condition), message if condition else f"FAILED: {message}")
451
+
452
+ def assert_columns(self, value: Any, columns: list[str]) -> bool:
453
+ actual = [str(column) for column in list(getattr(value, "columns", []))]
454
+ missing = [column for column in columns if column not in actual]
455
+ return self._record_check("columns", not missing, "columns present" if not missing else f"missing columns: {missing}")
456
+
457
+ def assert_non_negative(self, value: Any, *, name: str = "non_negative") -> bool:
458
+ try:
459
+ passed = bool((value >= 0).all()) if hasattr(value, "all") else all(item >= 0 for item in value)
460
+ except (AttributeError, TypeError, ValueError) as error:
461
+ return self._record_check(name, False, f"unable to evaluate: {error}")
462
+ return self._record_check(name, passed, "all values are non-negative" if passed else "negative value found")
463
+
464
+ def assert_file(self, path: str | Path) -> bool:
465
+ target = self._artifact_target(path)
466
+ passed = target.is_file()
467
+ return self._record_check("file_exists", passed, f"file exists: {target.name}" if passed else f"missing file: {target}")
468
+
469
+ def _record_check(self, name: str, passed: bool, message: str) -> bool:
470
+ check = CheckRecord(name=name, passed=passed, message=message)
471
+ self._checks.append(check)
472
+ self._event("check", check.to_dict())
473
+ return passed
474
+
475
+ def _output_target(self, path: str | Path) -> Path:
476
+ relative = Path(path)
477
+ if relative.is_absolute():
478
+ raise RunProofError("output paths must be relative")
479
+ if relative.parts and relative.parts[0] == "outputs":
480
+ relative = Path(*relative.parts[1:])
481
+ output_root = (self.artifact_dir / "outputs").resolve()
482
+ candidate = (output_root / relative).resolve()
483
+ if candidate != output_root and output_root not in candidate.parents:
484
+ raise RunProofError("output path escapes the outputs directory")
485
+ return candidate
486
+
487
+ def _artifact_target(self, path: str | Path) -> Path:
488
+ target = Path(path)
489
+ if target.is_absolute():
490
+ raise RunProofError("artifact paths must be relative to the run artifact directory")
491
+ candidate = (self.artifact_dir / target).resolve()
492
+ if self.artifact_dir.resolve() not in candidate.parents and candidate != self.artifact_dir.resolve():
493
+ raise RunProofError("artifact path escapes the run directory")
494
+ return candidate
495
+
496
+ @staticmethod
497
+ def _is_json_replayable(args: tuple[Any, ...], kwargs: dict[str, Any], output: Any) -> bool:
498
+ try:
499
+ json.dumps({"args": args, "kwargs": kwargs, "output": output}, default=lambda value: safe_value(value, max_items=20, max_text=100))
500
+ return all(isinstance(value, (str, int, float, bool, type(None), list, tuple, dict)) for value in args)
501
+ except (TypeError, ValueError, OverflowError):
502
+ return False
503
+
504
+ def _finalize(self) -> None:
505
+ self._finished_at = utc_now()
506
+ self._event("run_finished", {"status": self._status})
507
+ self.result = RunResult(
508
+ run_id=self.run_id,
509
+ name=self.name,
510
+ status=self._status,
511
+ artifact_dir=self.artifact_dir,
512
+ started_at=self._started_at,
513
+ finished_at=self._finished_at,
514
+ error=self._error,
515
+ )
516
+ manifest = {
517
+ "schema_version": "0.1",
518
+ "run": self.result.to_dict(),
519
+ "inputs": self._inputs,
520
+ "outputs": self._outputs,
521
+ "observations": self._observations,
522
+ "steps": [step.to_dict() for step in self._steps],
523
+ "checks": [check.to_dict() for check in self._checks],
524
+ "environment": self._environment,
525
+ "policy": {
526
+ "allowed_actions": sorted(self.policy.allowed_actions),
527
+ "approval_required": sorted(self.policy.approval_required),
528
+ "denied_actions": sorted(self.policy.denied_actions),
529
+ },
530
+ "replay": {
531
+ "possible_steps": sum(step.replayable for step in self._steps),
532
+ "total_steps": len(self._steps),
533
+ "note": "Replayability is reported from captured evidence; external sources may remain non-deterministic.",
534
+ },
535
+ }
536
+ write_json(self.artifact_dir / "manifest.json", manifest)
537
+ write_json(self.artifact_dir / "execution" / "trace.json", self._events)
538
+ write_json(self.artifact_dir / "checks" / "results.json", [check.to_dict() for check in self._checks])
539
+ write_json(self.artifact_dir / "execution" / "steps.json", [step.to_dict() for step in self._steps])
540
+ write_json(self.artifact_dir / "execution" / "outputs.json", self._outputs)
541
+ integrity_records = []
542
+ for artifact in sorted(self.artifact_dir.rglob("*")):
543
+ if artifact.is_file() and artifact.name != "integrity.json":
544
+ integrity_records.append({
545
+ "path": str(artifact.relative_to(self.artifact_dir)),
546
+ "sha256": sha256_file(artifact),
547
+ "size_bytes": artifact.stat().st_size,
548
+ })
549
+ write_json(self.artifact_dir / "integrity.json", {
550
+ "schema_version": "0.1",
551
+ "files": integrity_records,
552
+ "note": "Keep an external copy of this file or its digest for tamper evidence against the artifact itself.",
553
+ })
554
+
555
+
556
+ def verified(name: str, **kwargs: Any) -> RunContext:
557
+ return RunContext(name, **kwargs)