|
4 | 4 | from __future__ import annotations |
5 | 5 |
|
6 | 6 | import argparse |
| 7 | +import hashlib |
7 | 8 | import json |
8 | 9 | import os |
9 | 10 | import re |
10 | 11 | import shlex |
11 | 12 | import subprocess |
12 | 13 | import sys |
13 | 14 | from dataclasses import dataclass |
| 15 | +from datetime import date |
14 | 16 | from pathlib import Path |
15 | 17 | from typing import Any |
16 | 18 | from zoneinfo import ZoneInfo, ZoneInfoNotFoundError |
|
53 | 55 | } |
54 | 56 | SCHEDULER_FIELDS = frozenset({"timezone", "main_time", "probe_time", "precheck_time"}) |
55 | 57 | MARKET_FIELDS = ("market", "market_calendar", "market_timezone") |
| 58 | +STRATEGY_RELEASE_REQUIRED_FIELDS = ( |
| 59 | + "release_id", |
| 60 | + "manifest_sha256", |
| 61 | + "strategy_revision", |
| 62 | + "config_sha256", |
| 63 | + "risk_policy_sha256", |
| 64 | + "evidence_sha256", |
| 65 | + "plugin_bundle_sha256", |
| 66 | + "effective_session", |
| 67 | +) |
| 68 | +STRATEGY_RELEASE_DIGEST_FIELDS = frozenset( |
| 69 | + { |
| 70 | + "manifest_sha256", |
| 71 | + "config_sha256", |
| 72 | + "risk_policy_sha256", |
| 73 | + "evidence_sha256", |
| 74 | + "plugin_bundle_sha256", |
| 75 | + } |
| 76 | +) |
| 77 | +STRATEGY_RELEASE_ID_PATTERN = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{2,127}$") |
| 78 | +SHA256_PATTERN = re.compile(r"^(?:sha256:)?[0-9a-fA-F]{64}$") |
| 79 | +LIVE_CONTINUITY_STATES = frozenset( |
| 80 | + { |
| 81 | + "ACTIVE_LKG", |
| 82 | + "ACTIVE_REDUCED", |
| 83 | + "RECONCILE_ONLY", |
| 84 | + "RISK_REDUCTION_ONLY", |
| 85 | + "PAUSED", |
| 86 | + "ROLLBACK_LKG", |
| 87 | + } |
| 88 | +) |
| 89 | +LIVE_CONTINUITY_BASELINE_KINDS = frozenset({"legacy_authorized", "release_attested"}) |
| 90 | +LIVE_CONTINUITY_FIELDS = frozenset( |
| 91 | + { |
| 92 | + "state", |
| 93 | + "baseline_kind", |
| 94 | + "baseline_id", |
| 95 | + "baseline_target_sha256", |
| 96 | + "captured_at", |
| 97 | + } |
| 98 | +) |
| 99 | +LIVE_CONTINUITY_BASELINE_ID_PATTERN = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{2,127}$") |
56 | 100 | GENERATED_VARIABLES = {"RUNTIME_TARGET_JSON", "STRATEGY_PROFILE"} |
57 | 101 | SECRET_MARKERS = ("PASSWORD", "PRIVATE_KEY", "TOKEN", "API_KEY", "ACCESS_KEY", "CLIENT_SECRET", "SECRET") |
58 | 102 | LEGACY_INCOME_LAYER_VARIABLES = frozenset( |
@@ -165,6 +209,15 @@ def compact_json(value: Any) -> str: |
165 | 209 | return json.dumps(value, ensure_ascii=False, separators=(",", ":")) |
166 | 210 |
|
167 | 211 |
|
| 212 | +def runtime_target_fingerprint(runtime_target: dict[str, Any]) -> str: |
| 213 | + """Fingerprint every frozen target field except its current run state.""" |
| 214 | + |
| 215 | + payload = dict(runtime_target) |
| 216 | + payload.pop("live_continuity", None) |
| 217 | + encoded = json.dumps(payload, ensure_ascii=True, sort_keys=True, separators=(",", ":")) |
| 218 | + return hashlib.sha256(encoded.encode("utf-8")).hexdigest() |
| 219 | + |
| 220 | + |
168 | 221 | def env_string(value: Any) -> str: |
169 | 222 | if isinstance(value, str): |
170 | 223 | return value |
@@ -425,8 +478,102 @@ def validate_runtime_target(target: dict[str, Any], errors: list[str]) -> None: |
425 | 478 | ZoneInfo(market_timezone) |
426 | 479 | except (ZoneInfoNotFoundError, ValueError): |
427 | 480 | errors.append(f"runtime_target.market_timezone is invalid: {market_timezone!r}") |
| 481 | + validate_strategy_release(runtime_target, errors) |
| 482 | + validate_live_continuity(runtime_target, errors) |
| 483 | + |
| 484 | + |
| 485 | +def validate_strategy_release(runtime_target: dict[str, Any], errors: list[str]) -> None: |
| 486 | + """Validate an optional immutable release identity without enabling it. |
428 | 487 |
|
| 488 | + Existing targets intentionally remain valid without ``strategy_release`` |
| 489 | + during the read-only migration. Once present, however, a partial identity |
| 490 | + is never accepted because it could be mistaken for a verified release. |
| 491 | + """ |
| 492 | + |
| 493 | + release = runtime_target.get("strategy_release") |
| 494 | + if release is None: |
| 495 | + return |
| 496 | + if not isinstance(release, dict): |
| 497 | + errors.append("runtime_target.strategy_release must be an object when present") |
| 498 | + return |
| 499 | + unexpected = sorted(set(release) - set(STRATEGY_RELEASE_REQUIRED_FIELDS)) |
| 500 | + if unexpected: |
| 501 | + errors.append( |
| 502 | + "runtime_target.strategy_release contains unsupported fields: " |
| 503 | + + ", ".join(unexpected) |
| 504 | + ) |
| 505 | + for field in STRATEGY_RELEASE_REQUIRED_FIELDS: |
| 506 | + value = release.get(field) |
| 507 | + if not isinstance(value, str) or not value.strip(): |
| 508 | + errors.append(f"runtime_target.strategy_release.{field} is required") |
| 509 | + continue |
| 510 | + if field == "release_id" and not STRATEGY_RELEASE_ID_PATTERN.fullmatch(value.strip()): |
| 511 | + errors.append("runtime_target.strategy_release.release_id has invalid characters") |
| 512 | + if field in STRATEGY_RELEASE_DIGEST_FIELDS and not SHA256_PATTERN.fullmatch(value.strip()): |
| 513 | + errors.append(f"runtime_target.strategy_release.{field} must be a SHA-256 digest") |
| 514 | + if field == "effective_session": |
| 515 | + try: |
| 516 | + date.fromisoformat(value.strip()) |
| 517 | + except ValueError: |
| 518 | + errors.append( |
| 519 | + "runtime_target.strategy_release.effective_session must be an ISO-8601 date" |
| 520 | + ) |
429 | 521 |
|
| 522 | + |
| 523 | +def validate_live_continuity(runtime_target: dict[str, Any], errors: list[str]) -> None: |
| 524 | + """Validate a frozen incumbent baseline independently of P0--P6 policy.""" |
| 525 | + |
| 526 | + continuity = runtime_target.get("live_continuity") |
| 527 | + if continuity is None: |
| 528 | + return |
| 529 | + if not isinstance(continuity, dict): |
| 530 | + errors.append("runtime_target.live_continuity must be an object when present") |
| 531 | + return |
| 532 | + unsupported = sorted(set(continuity) - LIVE_CONTINUITY_FIELDS) |
| 533 | + if unsupported: |
| 534 | + errors.append( |
| 535 | + "runtime_target.live_continuity contains unsupported fields: " + ", ".join(unsupported) |
| 536 | + ) |
| 537 | + for field in sorted(LIVE_CONTINUITY_FIELDS): |
| 538 | + value = continuity.get(field) |
| 539 | + if not isinstance(value, str) or not value.strip(): |
| 540 | + errors.append(f"runtime_target.live_continuity.{field} is required") |
| 541 | + |
| 542 | + state = str(continuity.get("state") or "").strip().upper() |
| 543 | + if state not in LIVE_CONTINUITY_STATES: |
| 544 | + errors.append( |
| 545 | + "runtime_target.live_continuity.state must be one of " |
| 546 | + + ", ".join(sorted(LIVE_CONTINUITY_STATES)) |
| 547 | + ) |
| 548 | + baseline_kind = str(continuity.get("baseline_kind") or "").strip() |
| 549 | + if baseline_kind not in LIVE_CONTINUITY_BASELINE_KINDS: |
| 550 | + errors.append( |
| 551 | + "runtime_target.live_continuity.baseline_kind must be one of " |
| 552 | + + ", ".join(sorted(LIVE_CONTINUITY_BASELINE_KINDS)) |
| 553 | + ) |
| 554 | + baseline_id = str(continuity.get("baseline_id") or "").strip() |
| 555 | + if baseline_id and not LIVE_CONTINUITY_BASELINE_ID_PATTERN.fullmatch(baseline_id): |
| 556 | + errors.append("runtime_target.live_continuity.baseline_id has invalid characters") |
| 557 | + digest = str(continuity.get("baseline_target_sha256") or "").strip().lower() |
| 558 | + digest = digest.removeprefix("sha256:") |
| 559 | + if not SHA256_PATTERN.fullmatch(digest): |
| 560 | + errors.append("runtime_target.live_continuity.baseline_target_sha256 must be a SHA-256 digest") |
| 561 | + elif digest != runtime_target_fingerprint(runtime_target): |
| 562 | + errors.append( |
| 563 | + "runtime_target.live_continuity.baseline_target_sha256 does not match the runtime target" |
| 564 | + ) |
| 565 | + captured_at = str(continuity.get("captured_at") or "").strip() |
| 566 | + if captured_at: |
| 567 | + try: |
| 568 | + date.fromisoformat(captured_at) |
| 569 | + except ValueError: |
| 570 | + errors.append("runtime_target.live_continuity.captured_at must be an ISO-8601 date") |
| 571 | + if baseline_kind == "release_attested" and not isinstance(runtime_target.get("strategy_release"), dict): |
| 572 | + errors.append("release_attested live_continuity requires runtime_target.strategy_release") |
| 573 | + |
| 574 | + |
| 575 | +def is_live_continuity_target(runtime_target: dict[str, Any]) -> bool: |
| 576 | + return isinstance(runtime_target.get("live_continuity"), dict) |
430 | 577 | def validate_live_ibkr_us_scheduler( |
431 | 578 | runtime_target: dict[str, Any], |
432 | 579 | scheduler: dict[str, Any], |
@@ -521,12 +668,26 @@ def validate_runtime_target_strategy_policy(runtime_target: dict[str, Any], erro |
521 | 668 | and deployment.get("live_configured") is False |
522 | 669 | ): |
523 | 670 | errors.append(f"platform {platform_id} has no live runtime configuration") |
| 671 | + continuity_target = is_live_continuity_target(runtime_target) |
524 | 672 | allowed_modes = normalize_allowed_execution_modes(strategy.get("allowed_execution_modes")) |
525 | | - if allowed_modes and execution_mode not in allowed_modes: |
| 673 | + if allowed_modes and execution_mode not in allowed_modes and not continuity_target: |
526 | 674 | errors.append(f"runtime_target.strategy_profile {profile} does not allow {execution_mode} execution") |
527 | 675 |
|
528 | 676 | if execution_mode != "live": |
529 | 677 | return |
| 678 | + if continuity_target: |
| 679 | + continuity_policy = strategy.get("live_continuity") |
| 680 | + if not isinstance(continuity_policy, dict) or continuity_policy.get("eligible") is not True: |
| 681 | + errors.append( |
| 682 | + f"runtime_target.strategy_profile {profile} is not eligible for live continuity" |
| 683 | + ) |
| 684 | + return |
| 685 | + allowed_platforms = continuity_policy.get("allowed_platforms") |
| 686 | + if not isinstance(allowed_platforms, list) or platform_id not in allowed_platforms: |
| 687 | + errors.append( |
| 688 | + f"runtime_target.strategy_profile {profile} live continuity is not allowed on {platform_id}" |
| 689 | + ) |
| 690 | + return |
530 | 691 | lifecycle_stage = str(strategy.get("lifecycle_stage") or "").strip() |
531 | 692 | if strategy.get("runtime_enabled") is not True: |
532 | 693 | errors.append(f"runtime_target.strategy_profile {profile} is not runtime_enabled") |
|
0 commit comments