|
11 | 11 | import subprocess |
12 | 12 | import sys |
13 | 13 | from dataclasses import dataclass |
| 14 | +from datetime import date |
14 | 15 | from pathlib import Path |
15 | 16 | from typing import Any |
16 | 17 | from zoneinfo import ZoneInfo, ZoneInfoNotFoundError |
|
53 | 54 | } |
54 | 55 | SCHEDULER_FIELDS = frozenset({"timezone", "main_time", "probe_time", "precheck_time"}) |
55 | 56 | MARKET_FIELDS = ("market", "market_calendar", "market_timezone") |
| 57 | +STRATEGY_RELEASE_REQUIRED_FIELDS = ( |
| 58 | + "release_id", |
| 59 | + "manifest_sha256", |
| 60 | + "strategy_revision", |
| 61 | + "config_sha256", |
| 62 | + "risk_policy_sha256", |
| 63 | + "evidence_sha256", |
| 64 | + "plugin_bundle_sha256", |
| 65 | + "effective_session", |
| 66 | +) |
| 67 | +STRATEGY_RELEASE_DIGEST_FIELDS = frozenset( |
| 68 | + { |
| 69 | + "manifest_sha256", |
| 70 | + "config_sha256", |
| 71 | + "risk_policy_sha256", |
| 72 | + "evidence_sha256", |
| 73 | + "plugin_bundle_sha256", |
| 74 | + } |
| 75 | +) |
| 76 | +STRATEGY_RELEASE_ID_PATTERN = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{2,127}$") |
| 77 | +SHA256_PATTERN = re.compile(r"^(?:sha256:)?[0-9a-fA-F]{64}$") |
56 | 78 | GENERATED_VARIABLES = {"RUNTIME_TARGET_JSON", "STRATEGY_PROFILE"} |
57 | 79 | SECRET_MARKERS = ("PASSWORD", "PRIVATE_KEY", "TOKEN", "API_KEY", "ACCESS_KEY", "CLIENT_SECRET", "SECRET") |
58 | 80 | LEGACY_INCOME_LAYER_VARIABLES = frozenset( |
@@ -425,6 +447,45 @@ def validate_runtime_target(target: dict[str, Any], errors: list[str]) -> None: |
425 | 447 | ZoneInfo(market_timezone) |
426 | 448 | except (ZoneInfoNotFoundError, ValueError): |
427 | 449 | errors.append(f"runtime_target.market_timezone is invalid: {market_timezone!r}") |
| 450 | + validate_strategy_release(runtime_target, errors) |
| 451 | + |
| 452 | + |
| 453 | +def validate_strategy_release(runtime_target: dict[str, Any], errors: list[str]) -> None: |
| 454 | + """Validate an optional immutable release identity without enabling it. |
| 455 | +
|
| 456 | + Existing targets intentionally remain valid without ``strategy_release`` |
| 457 | + during the read-only migration. Once present, however, a partial identity |
| 458 | + is never accepted because it could be mistaken for a verified release. |
| 459 | + """ |
| 460 | + |
| 461 | + release = runtime_target.get("strategy_release") |
| 462 | + if release is None: |
| 463 | + return |
| 464 | + if not isinstance(release, dict): |
| 465 | + errors.append("runtime_target.strategy_release must be an object when present") |
| 466 | + return |
| 467 | + unexpected = sorted(set(release) - set(STRATEGY_RELEASE_REQUIRED_FIELDS)) |
| 468 | + if unexpected: |
| 469 | + errors.append( |
| 470 | + "runtime_target.strategy_release contains unsupported fields: " |
| 471 | + + ", ".join(unexpected) |
| 472 | + ) |
| 473 | + for field in STRATEGY_RELEASE_REQUIRED_FIELDS: |
| 474 | + value = release.get(field) |
| 475 | + if not isinstance(value, str) or not value.strip(): |
| 476 | + errors.append(f"runtime_target.strategy_release.{field} is required") |
| 477 | + continue |
| 478 | + if field == "release_id" and not STRATEGY_RELEASE_ID_PATTERN.fullmatch(value.strip()): |
| 479 | + errors.append("runtime_target.strategy_release.release_id has invalid characters") |
| 480 | + if field in STRATEGY_RELEASE_DIGEST_FIELDS and not SHA256_PATTERN.fullmatch(value.strip()): |
| 481 | + errors.append(f"runtime_target.strategy_release.{field} must be a SHA-256 digest") |
| 482 | + if field == "effective_session": |
| 483 | + try: |
| 484 | + date.fromisoformat(value.strip()) |
| 485 | + except ValueError: |
| 486 | + errors.append( |
| 487 | + "runtime_target.strategy_release.effective_session must be an ISO-8601 date" |
| 488 | + ) |
428 | 489 |
|
429 | 490 |
|
430 | 491 | def validate_live_ibkr_us_scheduler( |
|
0 commit comments