From ae6e6150f39ee057e5bb0dbdc12e5851113e4963 Mon Sep 17 00:00:00 2001 From: yunjinqi Date: Mon, 28 Sep 2026 23:15:12 +0800 Subject: [PATCH] feat(iteration41): add parent execution and runtime contracts --- bt_api_py/__init__.py | 42 + bt_api_py/_contracts/models.py | 64 +- bt_api_py/_ctp_credential_binding.py | 465 +++ bt_api_py/_ctp_execution_authorization.py | 344 ++- .../_ctp_production_execution_approval.py | 868 ++++++ bt_api_py/_direct_backend.py | 12 + bt_api_py/_execution_session.py | 1032 ++++++- bt_api_py/_normalization.py | 78 +- bt_api_py/_venue_mappers/ctp.py | 6 + bt_api_py/bt_api.py | 1003 +++++- bt_api_py/configs/exchange-bundles.toml | 2 +- bt_api_py/ctp_simnow_execution.py | 737 +++++ bt_api_py/runtime_plugins/__init__.py | 132 + .../runtime_plugins/cancellation_control.py | 1390 +++++++++ bt_api_py/runtime_plugins/catalog.py | 138 + bt_api_py/runtime_plugins/contracts.py | 200 ++ bt_api_py/runtime_plugins/gateway_dispatch.py | 1251 ++++++++ .../gateway_transport_server.py | 203 ++ bt_api_py/runtime_plugins/instrument_risk.py | 765 +++++ bt_api_py/runtime_plugins/managed.py | 919 ++++++ bt_api_py/runtime_plugins/managed_recovery.py | 874 ++++++ .../runtime_plugins/reconcile_control.py | 1417 +++++++++ conftest.py | 9 + pyproject.toml | 5 +- scripts/ci/submodule_validation.py | 2 +- .../verify_iteration41_capability_bundle.py | 2691 +++++++++++++++++ scripts/install_bt_api_submodules.py | 5 +- .../test_ctp_dependency_version_pin.py | 50 + .../test_ctp_entry_approval_arm.py | 8 +- .../test_ctp_entry_write_guard.py | 498 +++ .../test_ctp_execution_approval.py | 931 +++++- .../test_ctp_execution_recovery_approval.py | 5 +- .../bt_api_contract/test_ctp_order_mapping.py | 36 + .../test_ctp_production_execution_approval.py | 449 +++ .../test_ctp_simnow_public_adapter.py | 465 +++ .../test_ctp_simnow_read_observation.py | 253 ++ .../test_direct_backend_runtime_contract.py | 44 +- .../bt_api_contract/test_execution_arming.py | 149 +- .../test_execution_recovery.py | 1000 +++++- .../bt_api_contract/test_execution_session.py | 238 +- .../test_cancellation_control.py | 538 ++++ .../test_capability_composition.py | 838 +++++ .../test_gateway_managed_dispatch.py | 772 +++++ .../test_gateway_transport_server.py | 272 ++ .../test_instrument_risk_admission.py | 599 ++++ .../test_managed_recovery_coordinator.py | 600 ++++ .../runtime_plugins/test_reconcile_control.py | 595 ++++ tests/test_iteration41_capability_bundle.py | 806 +++++ 48 files changed, 23539 insertions(+), 261 deletions(-) create mode 100644 bt_api_py/_ctp_credential_binding.py create mode 100644 bt_api_py/_ctp_production_execution_approval.py create mode 100644 bt_api_py/ctp_simnow_execution.py create mode 100644 bt_api_py/runtime_plugins/__init__.py create mode 100644 bt_api_py/runtime_plugins/cancellation_control.py create mode 100644 bt_api_py/runtime_plugins/catalog.py create mode 100644 bt_api_py/runtime_plugins/contracts.py create mode 100644 bt_api_py/runtime_plugins/gateway_dispatch.py create mode 100644 bt_api_py/runtime_plugins/gateway_transport_server.py create mode 100644 bt_api_py/runtime_plugins/instrument_risk.py create mode 100644 bt_api_py/runtime_plugins/managed.py create mode 100644 bt_api_py/runtime_plugins/managed_recovery.py create mode 100644 bt_api_py/runtime_plugins/reconcile_control.py create mode 100644 scripts/ci/verify_iteration41_capability_bundle.py create mode 100644 tests/bt_api_contract/test_ctp_dependency_version_pin.py create mode 100644 tests/bt_api_contract/test_ctp_entry_write_guard.py create mode 100644 tests/bt_api_contract/test_ctp_production_execution_approval.py create mode 100644 tests/bt_api_contract/test_ctp_simnow_public_adapter.py create mode 100644 tests/bt_api_contract/test_ctp_simnow_read_observation.py create mode 100644 tests/runtime_plugins/test_cancellation_control.py create mode 100644 tests/runtime_plugins/test_capability_composition.py create mode 100644 tests/runtime_plugins/test_gateway_managed_dispatch.py create mode 100644 tests/runtime_plugins/test_gateway_transport_server.py create mode 100644 tests/runtime_plugins/test_instrument_risk_admission.py create mode 100644 tests/runtime_plugins/test_managed_recovery_coordinator.py create mode 100644 tests/runtime_plugins/test_reconcile_control.py create mode 100644 tests/test_iteration41_capability_bundle.py diff --git a/bt_api_py/__init__.py b/bt_api_py/__init__.py index 542210c3..ba3f273a 100644 --- a/bt_api_py/__init__.py +++ b/bt_api_py/__init__.py @@ -111,10 +111,18 @@ evaluate_ctp_budget, evaluate_ctp_budget_numbers, ) +from bt_api_py._ctp_credential_binding import ( + CtpCredentialBindingScope, + CtpCredentialBindingVerifier, +) from bt_api_py._ctp_execution_authorization import ( + ENTRY_APPROVAL_SCHEMA_VERSION, RECOVERY_APPROVAL_PURPOSE, RECOVERY_APPROVAL_SCHEMA_VERSION, RECOVERY_APPROVAL_SCOPE_VERSION, + SIMNOW_APPROVAL_SCHEMA_VERSION, + SIMNOW_ENTRY_APPROVAL_SCHEMA_VERSION, + SIMNOW_RECOVERY_APPROVAL_SCHEMA_VERSION, CtpExecutionApproval, CtpExecutionApprovalCapability, CtpExecutionApprovalContext, @@ -156,6 +164,21 @@ round_trip_cost, signed_funding_cashflow, ) +from bt_api_py.ctp_simnow_execution import ( + OFFICIAL_SET1_PROFILES, + CtpSimNowCancelResult, + CtpSimNowExecutionAdapter, + CtpSimNowExecutionError, + CtpSimNowOrderIdentity, + CtpSimNowOrderRequest, + CtpSimNowOrderResult, + CtpSimNowQueryResult, + CtpSimNowReadObservation, + CtpSimNowSessionIdentity, + build_ctp_simnow_cancel_request, + map_ctp_simnow_cancel_result, + map_ctp_simnow_order_result, +) from bt_api_py.forwarding import ( MAX_MESSAGE_BYTES, BtApiForwardingAdapter, @@ -207,9 +230,28 @@ def __getattr__(name: str): "CtpExecutionApproval", "CtpExecutionApprovalCapability", "CtpExecutionApprovalContext", + "CtpCredentialBindingScope", + "CtpCredentialBindingVerifier", + "CtpSimNowExecutionAdapter", + "CtpSimNowExecutionError", + "CtpSimNowSessionIdentity", + "CtpSimNowOrderRequest", + "CtpSimNowOrderIdentity", + "CtpSimNowOrderResult", + "CtpSimNowCancelResult", + "CtpSimNowQueryResult", + "CtpSimNowReadObservation", + "OFFICIAL_SET1_PROFILES", + "build_ctp_simnow_cancel_request", + "map_ctp_simnow_cancel_result", + "map_ctp_simnow_order_result", + "ENTRY_APPROVAL_SCHEMA_VERSION", "RECOVERY_APPROVAL_PURPOSE", "RECOVERY_APPROVAL_SCHEMA_VERSION", "RECOVERY_APPROVAL_SCOPE_VERSION", + "SIMNOW_APPROVAL_SCHEMA_VERSION", + "SIMNOW_ENTRY_APPROVAL_SCHEMA_VERSION", + "SIMNOW_RECOVERY_APPROVAL_SCHEMA_VERSION", "recovery_action_digest", "recovery_plan_digest", "verify_ctp_execution_recovery_approval", diff --git a/bt_api_py/_contracts/models.py b/bt_api_py/_contracts/models.py index 6820810c..bea2e90f 100644 --- a/bt_api_py/_contracts/models.py +++ b/bt_api_py/_contracts/models.py @@ -432,6 +432,9 @@ class OrderRequest: execution_cycle_id: str | None = None execution_role: str | None = None strategy_identity_sha256: str | None = None + managed_intent_id: str | None = None + hedge_flag: str | None = None + runtime_order_id: str | None = None def __post_init__(self) -> None: self._validate_core_fields() @@ -460,6 +463,11 @@ def _validate_intent_fields(self) -> None: raise ValueError("offset must be open, close, close_today or close_yesterday") if self.position_mode not in {None, "net", "dual_side"}: raise ValueError("position_mode must be net or dual_side") + if self.hedge_flag is not None and ( + not isinstance(self.hedge_flag, str) + or self.hedge_flag not in {"1", "2", "3", "5", "6", "7"} + ): + raise ValueError("hedge_flag must be a supported CTP hedge code") if not self.account_id: raise ValueError("account_id must be a non-empty string") if not self.client_order_id: @@ -484,6 +492,25 @@ def _validate_intent_fields(self) -> None: raise ValueError( "strategy_identity_sha256 must be a lowercase SHA-256 hex digest or None" ) + if self.runtime_order_id is not None and ( + not isinstance(self.runtime_order_id, str) + or not self.runtime_order_id + or self.runtime_order_id != self.runtime_order_id.strip() + or len(self.runtime_order_id.encode("utf-8")) > 256 + ): + raise ValueError("runtime_order_id must be a bounded non-empty string or None") + if self.managed_intent_id is not None and ( + not isinstance(self.managed_intent_id, str) + or not self.managed_intent_id + or self.managed_intent_id != self.managed_intent_id.strip() + or len(self.managed_intent_id.encode("utf-8")) > 256 + ): + raise ValueError("managed_intent_id must be a bounded non-empty string or None") + if self.managed_intent_id is not None: + if self.runtime_order_id is None: + raise ValueError("runtime_order_id is required with managed_intent_id") + if self.hedge_flag not in {"1", "2", "3"}: + raise ValueError("managed CTP requests require a supported exact hedge_flag") if self.order_type is OrderType.LIMIT and self.price is None: raise ValueError("limit order requires a price") if self.order_type is OrderType.MARKET and self.price is not None: @@ -501,14 +528,47 @@ class CancelOrderRequest: front_id: int | None = None session_id: int | None = None order_ref: str | None = None + runtime_action_id: str | None = None + runtime_order_id: str | None = None + managed_cancel_intent_id: str | None = None def __post_init__(self) -> None: if not self.symbol: raise ValueError("symbol must be a non-empty string") if not self.account_id: raise ValueError("account_id must be a non-empty string") - if not self.order_id and not self.client_order_id and not self.order_ref: - raise ValueError("order_id, client_order_id or order_ref must be provided") + if ( + not self.order_id + and not self.client_order_id + and not self.order_ref + and not self.runtime_order_id + ): + raise ValueError( + "order_id, client_order_id, order_ref or runtime_order_id must be provided" + ) + if self.runtime_action_id is not None and ( + not isinstance(self.runtime_action_id, str) + or not self.runtime_action_id + or self.runtime_action_id != self.runtime_action_id.strip() + or len(self.runtime_action_id.encode("utf-8")) > 256 + ): + raise ValueError("runtime_action_id must be a bounded non-empty string or None") + if self.runtime_order_id is not None and ( + not isinstance(self.runtime_order_id, str) + or not self.runtime_order_id + or self.runtime_order_id != self.runtime_order_id.strip() + or len(self.runtime_order_id.encode("utf-8")) > 256 + ): + raise ValueError("runtime_order_id must be a bounded non-empty string or None") + if self.managed_cancel_intent_id is not None and ( + not isinstance(self.managed_cancel_intent_id, str) + or not self.managed_cancel_intent_id + or self.managed_cancel_intent_id != self.managed_cancel_intent_id.strip() + or len(self.managed_cancel_intent_id.encode("utf-8")) > 256 + ): + raise ValueError("managed_cancel_intent_id must be a bounded non-empty string or None") + if self.managed_cancel_intent_id is not None and self.runtime_order_id is None: + raise ValueError("managed_cancel_intent_id requires runtime_order_id") @dataclass(frozen=True) diff --git a/bt_api_py/_ctp_credential_binding.py b/bt_api_py/_ctp_credential_binding.py new file mode 100644 index 00000000..48dea8fe --- /dev/null +++ b/bt_api_py/_ctp_credential_binding.py @@ -0,0 +1,465 @@ +"""Typed SDK boundary for deployment-owned CTP credential bindings. + +This module deliberately imports only the standard library and the SDK error +type. The deployment package that implements the reviewed adapter is imported +only when ``BtApi.create_ctp_credential_binding_verifier`` is called. + +The nominal types below make accidental or compatibility-level substitution +with a lambda or a mapping fail closed. They are deployment provenance +contracts, not a sandbox against hostile code already running in this process. +""" + +from __future__ import annotations + +import hashlib +import importlib +import inspect +import json +import re +import threading +from collections.abc import Mapping +from dataclasses import dataclass, field +from pathlib import Path +from types import MappingProxyType +from typing import Any + +from ._contracts.errors import NormalizedApiError + +_SCOPE_SEAL = object() +_VERIFIER_SEAL = object() +_TEST_VERIFIER_SEAL = object() +_HEX64 = re.compile(r"^[0-9a-f]{64}$") +_SAFE_ID = re.compile(r"^[\w][\w.:-]{0,127}$", re.ASCII) + +_ADAPTER_MODULE = "backtrader_runtime._ctp_credential_binding" +_ADAPTER_CLASS = "CtpReviewedCredentialBindingRefreshAdapter" +_RESULT_CLASS = "CtpReviewedCredentialBindingRefreshResult" +_SCOPE_FIELDS = frozenset( + { + "account_fingerprint", + "trading_day", + "connection_generation", + "environment_profile", + "td_front", + "md_front", + "td_front_sha256", + "md_front_sha256", + "backtrader_sha256", + "backtrader_runtime_sha256", + "bt_api_py_sha256", + "bt_api_ctp_sha256", + "bt_api_base_sha256", + "native_sha256", + "dependency_hashes_sha256", + "configuration_sha256", + "strategy_identity_sha256", + "preflight_sha256", + "evidence_sha256", + "md_connection_generation", + "md_stream_generation", + } +) +_RESULT_FIELDS = frozenset( + { + "scope_sha256", + "key_id", + "hmac_sha256", + "account_fingerprint", + "td_front", + "md_front", + "runtime_config_sha256", + "registration_sha256", + "backtrader_runtime_sha256", + } +) + + +def _reject(operation: str, code: str) -> None: + raise NormalizedApiError(operation, code, definite_reject=True) + + +def _canonical_json(value: Any) -> bytes: + return json.dumps( + value, + ensure_ascii=False, + sort_keys=True, + separators=(",", ":"), + allow_nan=False, + ).encode("utf-8", "strict") + + +def _manifest_digest(package_root: Path) -> str: + """Hash a bounded Python/native source manifest rooted at one package.""" + + try: + paths = [] + for path in package_root.rglob("*"): + if ( + not path.is_file() + or "__pycache__" in path.parts + or path.suffix.lower() not in {".py", ".so", ".dylib", ".pyd"} + ): + continue + resolved = path.resolve(strict=True) + try: + resolved.relative_to(package_root) + except ValueError: + continue + paths.append(resolved) + paths.sort(key=lambda path: path.relative_to(package_root).as_posix()) + if not paths: + raise ValueError + manifest = [ + { + "path": path.relative_to(package_root).as_posix(), + "sha256": hashlib.sha256(path.read_bytes()).hexdigest(), + } + for path in paths + ] + return hashlib.sha256(_canonical_json(manifest)).hexdigest() + except Exception: + raise ValueError("credential_binding_package_identity_unavailable") from None + + +def _strict_text(value: Any, *, operation: str, code: str, limit: int = 4096) -> str: + if ( + not isinstance(value, str) + or not value + or value != value.strip() + or len(value) > limit + or "\x00" in value + ): + _reject(operation, code) + return value + + +def _validate_scope(values: Mapping[str, Any], operation: str) -> dict[str, Any]: + if not isinstance(values, Mapping) or set(values) != _SCOPE_FIELDS: + _reject(operation, "ctp_credential_binding_scope_invalid") + result = dict(values) + for name in ( + "account_fingerprint", + "environment_profile", + "td_front", + "md_front", + ): + _strict_text(result[name], operation=operation, code="ctp_credential_binding_scope_invalid") + if not re.fullmatch(r"[0-9]{8}", str(result["trading_day"])): + _reject(operation, "ctp_credential_binding_scope_invalid") + if ( + type(result["connection_generation"]) is not int + or result["connection_generation"] <= 0 + or type(result["md_connection_generation"]) is not int + or result["md_connection_generation"] <= 0 + or type(result["md_stream_generation"]) is not int + or result["md_stream_generation"] <= 0 + ): + _reject(operation, "ctp_credential_binding_scope_invalid") + for name in _SCOPE_FIELDS - { + "account_fingerprint", + "trading_day", + "connection_generation", + "environment_profile", + "td_front", + "md_front", + "md_connection_generation", + "md_stream_generation", + }: + value = result[name] + if not isinstance(value, str) or not _HEX64.fullmatch(value): + _reject(operation, "ctp_credential_binding_scope_invalid") + for name in ("td_front", "md_front"): + digest_name = f"{name}_sha256" + if ( + hashlib.sha256(result[name].encode("utf-8", "strict")).hexdigest() + != result[digest_name] + ): + _reject(operation, "ctp_credential_binding_scope_invalid") + return result + + +@dataclass(frozen=True, slots=True, init=False) +class CtpCredentialBindingScope: + """Immutable SDK observation supplied to a reviewed deployment adapter.""" + + values: Mapping[str, Any] = field(repr=False) + _seal: object = field(repr=False, compare=False) + + def __init__(self, *, values: Mapping[str, Any], _seal: object) -> None: + if _seal is not _SCOPE_SEAL: + raise TypeError("SDK-created CTP credential binding scope required") + checked = _validate_scope(values, "build_ctp_execution_approval_context") + object.__setattr__(self, "values", MappingProxyType(checked)) + object.__setattr__(self, "_seal", _seal) + + @property + def scope_sha256(self) -> str: + return hashlib.sha256(_canonical_json(dict(self.values))).hexdigest() + + def as_dict(self) -> dict[str, Any]: + return dict(self.values) + + def __getattr__(self, name: str) -> Any: + if name in _SCOPE_FIELDS: + return self.values[name] + raise AttributeError(name) + + +class CtpCredentialBindingVerifier: + """SDK-sealed, owner-bound refresh verifier for one reviewed adapter.""" + + __slots__ = ( + "_seal", + "_owner", + "_adapter", + "_package_root", + "_package_sha256", + "_refresh_method", + "_refresh_code", + "_test_provider", + "_test_seal", + "_pinned_runtime_config_sha256", + "_pinned_registration_sha256", + "_lock", + ) + + def __init__( + self, + *, + owner: object, + adapter: object, + package_root: Path | None, + package_sha256: str, + refresh_method: object | None, + test_provider: object | None = None, + seal: object, + test_seal: object | None = None, + ) -> None: + if seal is not _VERIFIER_SEAL: + raise TypeError("SDK-created CTP credential binding verifier required") + if test_provider is None and ( + package_root is None + or not isinstance(package_sha256, str) + or not _HEX64.fullmatch(package_sha256) + or not callable(refresh_method) + ): + raise TypeError("reviewed CTP credential binding adapter required") + if test_provider is not None and test_seal is not _TEST_VERIFIER_SEAL: + raise TypeError("controlled CTP binding test seam required") + self._seal = seal + self._owner = owner + self._adapter = adapter + self._package_root = package_root + self._package_sha256 = package_sha256 + self._refresh_method = refresh_method + self._refresh_code = getattr(refresh_method, "__code__", None) + self._test_provider = test_provider + self._test_seal = test_seal + self._pinned_runtime_config_sha256: str | None = None + self._pinned_registration_sha256: str | None = None + self._lock = threading.RLock() + + @property + def package_sha256(self) -> str: + return self._package_sha256 + + @property + def _is_controlled_test_verifier(self) -> bool: + return self._test_seal is _TEST_VERIFIER_SEAL + + def refresh( + self, + scope: CtpCredentialBindingScope, + *, + owner: object, + operation: str, + ) -> dict[str, str]: + if ( + type(self) is not CtpCredentialBindingVerifier + or self._seal is not _VERIFIER_SEAL + or self._owner is not owner + or type(scope) is not CtpCredentialBindingScope + or scope._seal is not _SCOPE_SEAL + ): + _reject(operation, "ctp_credential_binding_trust_required") + with self._lock: + if self._test_seal is _TEST_VERIFIER_SEAL: + return self._refresh_test(scope, operation) + return self._refresh_reviewed(scope, operation) + + def _refresh_test(self, scope: CtpCredentialBindingScope, operation: str) -> dict[str, str]: + try: + provider = self._test_provider + if not callable(provider): + _reject(operation, "ctp_credential_binding_invalid") + binding = provider() + except NormalizedApiError: + raise + except Exception: + _reject(operation, "ctp_credential_binding_unavailable") + if not isinstance(binding, Mapping) or set(binding) != { + "credential_binding_key_id", + "credential_binding_hmac_sha256", + }: + _reject(operation, "ctp_credential_binding_invalid") + key_id = binding.get("credential_binding_key_id") + mac = binding.get("credential_binding_hmac_sha256") + if ( + not isinstance(key_id, str) + or not _SAFE_ID.fullmatch(key_id) + or not isinstance(mac, str) + or not _HEX64.fullmatch(mac) + ): + _reject(operation, "ctp_credential_binding_invalid") + # The controlled seam is only a deterministic contract-test fixture. + # Still scope its output so replay/config/front drift exercises the + # same binding behavior as the reviewed adapter contract. + mac = hashlib.sha256( + _canonical_json({"scope_sha256": scope.scope_sha256, "test_seed": mac}) + ).hexdigest() + return { + "credential_binding_key_id": key_id, + "credential_binding_hmac_sha256": mac, + } + + def _refresh_reviewed(self, scope: CtpCredentialBindingScope, operation: str) -> dict[str, str]: + try: + if ( + self._adapter_refresh_method() is not self._refresh_method + or getattr(self._refresh_method, "__code__", None) is not self._refresh_code + ): + _reject(operation, "ctp_credential_binding_source_changed") + result = self._refresh_method(self._adapter, scope) + except NormalizedApiError: + raise + except Exception: + _reject(operation, "ctp_credential_binding_unavailable") + if ( + type(result).__module__ != _ADAPTER_MODULE + or type(result).__name__ != _RESULT_CLASS + or frozenset(getattr(type(result), "__dataclass_fields__", {})) != _RESULT_FIELDS + or getattr( + getattr(type(result), "__dataclass_params__", None), "frozen", False + ) + is not True + ): + _reject(operation, "ctp_credential_binding_invalid") + fields = {name: getattr(result, name, None) for name in _RESULT_FIELDS} + if any(type(fields[name]) is not str for name in _RESULT_FIELDS): + _reject(operation, "ctp_credential_binding_invalid") + if ( + fields["scope_sha256"] != scope.scope_sha256 + or fields["account_fingerprint"] != scope.account_fingerprint + or fields["td_front"] != scope.td_front + or fields["md_front"] != scope.md_front + or fields["backtrader_runtime_sha256"] != self._package_sha256 + or self._package_sha256 != scope.backtrader_runtime_sha256 + ): + _reject(operation, "ctp_credential_binding_scope_mismatch") + for name in ("runtime_config_sha256", "registration_sha256"): + value = fields[name] + if not isinstance(value, str) or not _HEX64.fullmatch(value): + _reject(operation, "ctp_credential_binding_invalid") + if self._pinned_runtime_config_sha256 is None: + self._pinned_runtime_config_sha256 = fields["runtime_config_sha256"] + self._pinned_registration_sha256 = fields["registration_sha256"] + elif ( + fields["runtime_config_sha256"] != self._pinned_runtime_config_sha256 + or fields["registration_sha256"] != self._pinned_registration_sha256 + ): + _reject(operation, "ctp_credential_binding_scope_mismatch") + key_id = fields["key_id"] + mac = fields["hmac_sha256"] + if ( + not isinstance(key_id, str) + or not _SAFE_ID.fullmatch(key_id) + or not isinstance(mac, str) + or not _HEX64.fullmatch(mac) + ): + _reject(operation, "ctp_credential_binding_invalid") + return { + "credential_binding_key_id": key_id, + "credential_binding_hmac_sha256": mac, + } + + def _adapter_refresh_method(self) -> object: + if ( + type(self._adapter).__module__ != _ADAPTER_MODULE + or type(self._adapter).__name__ != _ADAPTER_CLASS + or getattr(type(self._adapter), "refresh", None) is not self._refresh_method + ): + _reject( + "create_ctp_credential_binding_verifier", "ctp_credential_binding_trust_required" + ) + return type(self._adapter).refresh + + +def _new_scope(values: Mapping[str, Any]) -> CtpCredentialBindingScope: + return CtpCredentialBindingScope(values=values, _seal=_SCOPE_SEAL) + + +def _new_test_verifier(owner: object, provider: object) -> CtpCredentialBindingVerifier: + return CtpCredentialBindingVerifier( + owner=owner, + adapter=None, + package_root=None, + package_sha256="0" * 64, + refresh_method=None, + test_provider=provider, + seal=_VERIFIER_SEAL, + test_seal=_TEST_VERIFIER_SEAL, + ) + + +def _new_reviewed_verifier(owner: object, adapter: object) -> CtpCredentialBindingVerifier: + """Validate package provenance and seal a reviewed runtime adapter.""" + + operation = "create_ctp_credential_binding_verifier" + try: + package = importlib.import_module("backtrader_runtime") + module = importlib.import_module(_ADAPTER_MODULE) + package_file = Path(str(getattr(package, "__file__", "") or "")).resolve(strict=True) + module_file = Path(str(getattr(module, "__file__", "") or "")).resolve(strict=True) + adapter_file = Path(inspect.getfile(type(adapter))).resolve(strict=True) + refresh_method = getattr(type(adapter), "refresh", None) + refresh_file = Path(inspect.getfile(refresh_method)).resolve(strict=True) + package_root = package_file.parent + module_adapter = getattr(module, _ADAPTER_CLASS, None) + module_spec = getattr(module, "__spec__", None) + module_origin = Path(str(getattr(module_spec, "origin", "") or "")).resolve(strict=True) + if ( + module_adapter is None + or type(adapter) is not module_adapter + or type(adapter).__module__ != _ADAPTER_MODULE + or type(adapter).__name__ != _ADAPTER_CLASS + or module_origin != module_file + or adapter_file != module_file + or refresh_file != module_file + or package_root not in module_file.parents + or not callable(refresh_method) + ): + _reject(operation, "ctp_credential_binding_trust_required") + package_sha256 = _manifest_digest(package_root) + except NormalizedApiError: + raise + except Exception: + _reject(operation, "ctp_credential_binding_trust_required") + return CtpCredentialBindingVerifier( + owner=owner, + adapter=adapter, + package_root=package_root, + package_sha256=package_sha256, + refresh_method=refresh_method, + seal=_VERIFIER_SEAL, + ) + + +def _is_verifier(value: object, *, owner: object | None = None) -> bool: + return bool( + type(value) is CtpCredentialBindingVerifier + and value._seal is _VERIFIER_SEAL + and (owner is None or value._owner is owner) + ) + + +__all__ = ["CtpCredentialBindingScope", "CtpCredentialBindingVerifier"] diff --git a/bt_api_py/_ctp_execution_authorization.py b/bt_api_py/_ctp_execution_authorization.py index ba845f7c..9fa5bf90 100644 --- a/bt_api_py/_ctp_execution_authorization.py +++ b/bt_api_py/_ctp_execution_authorization.py @@ -33,6 +33,11 @@ APPROVAL_SCHEMA_VERSION = "ctp-execution-approval-v1" RECOVERY_APPROVAL_SCHEMA_VERSION = "ctp-execution-recovery-approval-v1" ENTRY_APPROVAL_SCHEMA_VERSION = "ctp-execution-entry-approval-v1" +SIMNOW_APPROVAL_SCHEMA_VERSION = "ctp-execution-approval-v2-simnow-binding" +SIMNOW_RECOVERY_APPROVAL_SCHEMA_VERSION = ( + "ctp-execution-recovery-approval-v2-simnow-binding" +) +SIMNOW_ENTRY_APPROVAL_SCHEMA_VERSION = "ctp-execution-entry-approval-v2-simnow-binding" TRUST_ROOT_SCHEMA_VERSION = "ctp-execution-trust-root-v1" APPROVAL_ALGORITHM = "Ed25519" APPROVAL_PURPOSE = "ctp_execution_approval" @@ -78,9 +83,15 @@ "revocation_snapshot_version", } ) +_CREDENTIAL_BINDING_FIELDS = frozenset( + {"credential_binding_key_id", "credential_binding_hmac_sha256"} +) +_SIMNOW_APPROVAL_FIELDS = _APPROVAL_FIELDS | _CREDENTIAL_BINDING_FIELDS _ARTIFACT_FIELDS = frozenset({"schema_version", "algorithm", "payload", "signature"}) _TRUST_ROOT_FIELDS = frozenset({"schema_version", "keys", "revocation_snapshot"}) -_TRUST_KEY_FIELDS = frozenset({"public_key", "role", "purposes", "not_before", "expires_at"}) +_TRUST_KEY_FIELDS = frozenset( + {"public_key", "role", "purposes", "not_before", "expires_at"} +) _REVOCATION_FIELDS = frozenset( { "version", @@ -123,6 +134,9 @@ "recovery_actions", } ) +_SIMNOW_RECOVERY_APPROVAL_FIELDS = _SIMNOW_APPROVAL_FIELDS | ( + _RECOVERY_APPROVAL_FIELDS - _APPROVAL_FIELDS +) # The entry variant keeps the ordinary execution purpose and adds exactly the # proof-bound material hashes the V2 bundle arm contract requires; a base # ordinary approval stays audit-only and can never mint an arm token. @@ -133,6 +147,9 @@ "ctp_package_sha256", } ) +_SIMNOW_ENTRY_APPROVAL_FIELDS = _SIMNOW_APPROVAL_FIELDS | ( + _ENTRY_APPROVAL_FIELDS - _APPROVAL_FIELDS +) _CONTEXT_FIELDS = frozenset( { "source", @@ -156,6 +173,35 @@ ), } ) +_SIMNOW_CONTEXT_FIELDS = _CONTEXT_FIELDS | _CREDENTIAL_BINDING_FIELDS +_SIMNOW_BOUND_PROFILES = frozenset( + {"config_front_pair", "set1_group1", "set1_group2"} +) +_SIMNOW_RESTRICTED_PROFILES = _SIMNOW_BOUND_PROFILES | frozenset( + { + "set2_7x24", + "set1_group1_vpn", + "set2_7x24_4000x", + "set2_7x24_vpn", + "set1", + "set2", + } +) +_SIMNOW_SCHEMA_BASES = { + SIMNOW_APPROVAL_SCHEMA_VERSION: APPROVAL_SCHEMA_VERSION, + SIMNOW_RECOVERY_APPROVAL_SCHEMA_VERSION: RECOVERY_APPROVAL_SCHEMA_VERSION, + SIMNOW_ENTRY_APPROVAL_SCHEMA_VERSION: ENTRY_APPROVAL_SCHEMA_VERSION, +} +_SIMNOW_SCHEMA_FIELDS = { + SIMNOW_APPROVAL_SCHEMA_VERSION: _SIMNOW_APPROVAL_FIELDS, + SIMNOW_RECOVERY_APPROVAL_SCHEMA_VERSION: _SIMNOW_RECOVERY_APPROVAL_FIELDS, + SIMNOW_ENTRY_APPROVAL_SCHEMA_VERSION: _SIMNOW_ENTRY_APPROVAL_FIELDS, +} +_SIMNOW_SCHEMA_PURPOSES = { + SIMNOW_APPROVAL_SCHEMA_VERSION: APPROVAL_PURPOSE, + SIMNOW_RECOVERY_APPROVAL_SCHEMA_VERSION: RECOVERY_APPROVAL_PURPOSE, + SIMNOW_ENTRY_APPROVAL_SCHEMA_VERSION: APPROVAL_PURPOSE, +} _HASH_FIELDS = frozenset( { "strategy_identity_sha256", @@ -177,6 +223,7 @@ _MAX_APPROVAL_LIFETIME = timedelta(days=366) _CAPABILITY_SEAL = object() _CONTEXT_SEAL = object() +_ENTRY_WRITE_GUARD_SEAL = object() class _DuplicateJsonKeyError(ValueError): @@ -277,7 +324,9 @@ def _parse_time(value: Any, *, operation: str, code: str) -> datetime: _reject(operation, code) if parsed.tzinfo is None or parsed.utcoffset() != timedelta(0): _reject(operation, code) - canonical = parsed.astimezone(UTC).isoformat(timespec="microseconds").replace("+00:00", "Z") + canonical = ( + parsed.astimezone(UTC).isoformat(timespec="microseconds").replace("+00:00", "Z") + ) if canonical != text: _reject(operation, code) return parsed.astimezone(UTC) @@ -320,7 +369,9 @@ def _decode_signature(value: Any) -> bytes: def _instrument(value: Any) -> dict[str, str]: - value = _strict_mapping(value, APPROVAL_OPERATION, "ctp_approval_invalid_instrument_scope") + value = _strict_mapping( + value, APPROVAL_OPERATION, "ctp_approval_invalid_instrument_scope" + ) if set(value) != _INSTRUMENT_FIELDS: _reject(APPROVAL_OPERATION, "ctp_approval_invalid_instrument_scope") instrument_id = _strict_string( @@ -370,6 +421,46 @@ def _decimal_string(value: Any) -> str: def _normalize_payload(value: Any) -> dict[str, Any]: value = _strict_mapping(value, APPROVAL_OPERATION, "ctp_approval_invalid_artifact") + schema_version = value.get("schema_version") + if schema_version in _SIMNOW_SCHEMA_BASES: + expected_fields = _SIMNOW_SCHEMA_FIELDS[schema_version] + if set(value) != expected_fields: + unknown = set(value) - expected_fields + _reject( + APPROVAL_OPERATION, + ( + "ctp_approval_unknown_payload_key" + if unknown + else "ctp_approval_missing_field" + ), + ) + if value.get("purpose") != _SIMNOW_SCHEMA_PURPOSES[schema_version]: + _reject(APPROVAL_OPERATION, "ctp_approval_purpose_unsupported") + binding_key_id = _strict_string( + value.get("credential_binding_key_id"), + operation=APPROVAL_OPERATION, + code="ctp_credential_binding_invalid", + pattern=_SAFE_ID, + max_length=128, + ) + binding_hmac = _hash( + value.get("credential_binding_hmac_sha256"), operation=APPROVAL_OPERATION + ) + base = { + key: item + for key, item in value.items() + if key not in _CREDENTIAL_BINDING_FIELDS + } + base["schema_version"] = _SIMNOW_SCHEMA_BASES[schema_version] + normalized = _normalize_payload(base) + normalized.update( + { + "schema_version": schema_version, + "credential_binding_key_id": binding_key_id, + "credential_binding_hmac_sha256": binding_hmac, + } + ) + return normalized if value.get("schema_version") == RECOVERY_APPROVAL_SCHEMA_VERSION: return _normalize_recovery_payload(value) if value.get("schema_version") == ENTRY_APPROVAL_SCHEMA_VERSION: @@ -378,7 +469,11 @@ def _normalize_payload(value: Any) -> dict[str, Any]: unknown = set(value) - _APPROVAL_FIELDS _reject( APPROVAL_OPERATION, - ("ctp_approval_unknown_payload_key" if unknown else "ctp_approval_missing_field"), + ( + "ctp_approval_unknown_payload_key" + if unknown + else "ctp_approval_missing_field" + ), ) if value["schema_version"] != APPROVAL_SCHEMA_VERSION: _reject(APPROVAL_OPERATION, "ctp_approval_unknown_schema_version") @@ -629,7 +724,9 @@ def recovery_plan_digest(plan: Any) -> str: if not isinstance(plan, Mapping) or "recovery_token_sha256" not in plan: _reject(APPROVAL_OPERATION, "ctp_recovery_plan_required") material = { - key: _jsonable(value) for key, value in plan.items() if key != "recovery_token_sha256" + key: _jsonable(value) + for key, value in plan.items() + if key != "recovery_token_sha256" } return hashlib.sha256(_canonical_json(material)).hexdigest() @@ -639,7 +736,11 @@ def _normalize_recovery_payload(value: Mapping[str, Any]) -> dict[str, Any]: unknown = set(value) - _RECOVERY_APPROVAL_FIELDS _reject( APPROVAL_OPERATION, - ("ctp_approval_unknown_payload_key" if unknown else "ctp_approval_missing_field"), + ( + "ctp_approval_unknown_payload_key" + if unknown + else "ctp_approval_missing_field" + ), ) if value["purpose"] != RECOVERY_APPROVAL_PURPOSE: _reject(APPROVAL_OPERATION, "ctp_approval_purpose_unsupported") @@ -679,7 +780,8 @@ def _normalize_recovery_payload(value: Mapping[str, Any]) -> dict[str, Any]: if len(action_ids) != len(set(action_ids)): _reject(APPROVAL_OPERATION, "ctp_recovery_duplicate_action_id") authorized = { - (item["exchange_id"], item["instrument_id"]) for item in result["authorized_instruments"] + (item["exchange_id"], item["instrument_id"]) + for item in result["authorized_instruments"] } for action in normalized_actions: if (action["exchange_id"], action["instrument_id"]) not in authorized: @@ -696,11 +798,13 @@ def _normalize_recovery_payload(value: Mapping[str, Any]) -> dict[str, Any]: _reject(APPROVAL_OPERATION, "ctp_recovery_action_identity_mismatch") if action["execution_cycle_id"] != result["execution_cycle_id"]: _reject(APPROVAL_OPERATION, "ctp_recovery_action_identity_mismatch") - if datetime.fromisoformat(action["expires_at"][:-1] + "+00:00") > datetime.fromisoformat( - result["expires_at"][:-1] + "+00:00" - ): + if datetime.fromisoformat( + action["expires_at"][:-1] + "+00:00" + ) > datetime.fromisoformat(result["expires_at"][:-1] + "+00:00"): _reject(APPROVAL_OPERATION, "ctp_recovery_action_expiry_mismatch") - if value["recovery_action_sha256"] != _recovery_action_digest_value(normalized_actions): + if value["recovery_action_sha256"] != _recovery_action_digest_value( + normalized_actions + ): _reject(APPROVAL_OPERATION, "ctp_recovery_action_digest_mismatch") result["recovery_actions"] = normalized_actions return result @@ -713,7 +817,11 @@ def _normalize_entry_payload(value: Mapping[str, Any]) -> dict[str, Any]: unknown = set(value) - _ENTRY_APPROVAL_FIELDS _reject( APPROVAL_OPERATION, - ("ctp_approval_unknown_payload_key" if unknown else "ctp_approval_missing_field"), + ( + "ctp_approval_unknown_payload_key" + if unknown + else "ctp_approval_missing_field" + ), ) if value["purpose"] != APPROVAL_PURPOSE: _reject(APPROVAL_OPERATION, "ctp_approval_purpose_unsupported") @@ -729,7 +837,9 @@ def _normalize_entry_payload(value: Mapping[str, Any]) -> dict[str, Any]: def _iso(value: datetime) -> str: - return value.astimezone(UTC).isoformat(timespec="microseconds").replace("+00:00", "Z") + return ( + value.astimezone(UTC).isoformat(timespec="microseconds").replace("+00:00", "Z") + ) def _normalize_context(value: Any) -> dict[str, Any]: @@ -739,8 +849,11 @@ def _normalize_context(value: Any) -> dict[str, Any]: _reject(APPROVAL_OPERATION, "ctp_approval_context_untrusted") value = _thaw(value.values) else: - value = _strict_mapping(value, APPROVAL_OPERATION, "ctp_approval_context_required") - if set(value) != _CONTEXT_FIELDS: + value = _strict_mapping( + value, APPROVAL_OPERATION, "ctp_approval_context_required" + ) + bound_context = set(value) == _SIMNOW_CONTEXT_FIELDS + if not bound_context and set(value) != _CONTEXT_FIELDS: _reject(APPROVAL_OPERATION, "ctp_approval_context_incomplete") source = _strict_string( value["source"], @@ -752,13 +865,19 @@ def _normalize_context(value: Any) -> dict[str, Any]: if source != "synthetic_test" and not sealed: _reject(APPROVAL_OPERATION, "ctp_approval_context_untrusted") result = {"source": source} + payload_fields = _SIMNOW_CONTEXT_FIELDS if bound_context else _CONTEXT_FIELDS + payload_schema = ( + SIMNOW_APPROVAL_SCHEMA_VERSION if bound_context else APPROVAL_SCHEMA_VERSION + ) payload_like = { - field_name: value[field_name] for field_name in _CONTEXT_FIELDS if field_name != "source" + field_name: value[field_name] + for field_name in payload_fields + if field_name != "source" } normalized = _normalize_payload( { **payload_like, - "schema_version": APPROVAL_SCHEMA_VERSION, + "schema_version": payload_schema, "algorithm": APPROVAL_ALGORITHM, "approval_id": "context-approval", "nonce": "context-nonce", @@ -772,13 +891,15 @@ def _normalize_context(value: Any) -> dict[str, Any]: "revocation_snapshot_version": 1, } ) - for field_name in _CONTEXT_FIELDS - {"source"}: + for field_name in payload_fields - {"source"}: result[field_name] = normalized[field_name] return result def _normalize_revocation(value: Any) -> dict[str, Any]: - value = _strict_mapping(value, APPROVAL_OPERATION, "ctp_approval_trust_root_invalid") + value = _strict_mapping( + value, APPROVAL_OPERATION, "ctp_approval_trust_root_invalid" + ) if set(value) != _REVOCATION_FIELDS: _reject(APPROVAL_OPERATION, "ctp_approval_trust_root_invalid") version = value["version"] @@ -841,7 +962,9 @@ def _decode_public_key(value: Any) -> bytes: def _normalize_trust_root(value: Any) -> dict[str, Any]: if value is None: _reject(APPROVAL_OPERATION, "BLOCKED_OPERATOR_TRUST_ROOT") - value = _strict_mapping(value, APPROVAL_OPERATION, "ctp_approval_trust_root_invalid") + value = _strict_mapping( + value, APPROVAL_OPERATION, "ctp_approval_trust_root_invalid" + ) if set(value) != _TRUST_ROOT_FIELDS: _reject(APPROVAL_OPERATION, "ctp_approval_trust_root_invalid") if value["schema_version"] != TRUST_ROOT_SCHEMA_VERSION: @@ -857,7 +980,9 @@ def _normalize_trust_root(value: Any) -> dict[str, Any]: code="ctp_approval_trust_root_invalid", pattern=_SAFE_ID, ) - entry = _strict_mapping(entry, APPROVAL_OPERATION, "ctp_approval_trust_root_invalid") + entry = _strict_mapping( + entry, APPROVAL_OPERATION, "ctp_approval_trust_root_invalid" + ) if set(entry) != _TRUST_KEY_FIELDS: _reject(APPROVAL_OPERATION, "ctp_approval_trust_root_invalid") role = _strict_string( @@ -870,7 +995,9 @@ def _normalize_trust_root(value: Any) -> dict[str, Any]: if ( not isinstance(purposes, list) or not purposes - or any(not isinstance(item, str) or item != item.strip() for item in purposes) + or any( + not isinstance(item, str) or item != item.strip() for item in purposes + ) ): _reject(APPROVAL_OPERATION, "ctp_approval_trust_root_invalid") if len(purposes) != len(set(purposes)): @@ -890,7 +1017,10 @@ def _normalize_trust_root(value: Any) -> dict[str, Any]: code="ctp_approval_trust_root_invalid", ), } - if not normalized_keys[key_id]["not_before"] <= normalized_keys[key_id]["expires_at"]: + if ( + not normalized_keys[key_id]["not_before"] + <= normalized_keys[key_id]["expires_at"] + ): _reject(APPROVAL_OPERATION, "ctp_approval_trust_root_invalid") revocation = _normalize_revocation(value["revocation_snapshot"]) result = { @@ -941,7 +1071,7 @@ class CtpExecutionApprovalContext: copying those values into a mapping cannot establish runtime provenance. """ - values: Mapping[str, Any] + values: Mapping[str, Any] = field(repr=False) _seal: object = field(repr=False, compare=False) _owner: object = field(repr=False, compare=False) _refresh: object = field(repr=False, compare=False) @@ -1018,7 +1148,7 @@ def _refresh_runtime_context( class CtpExecutionApproval: """Immutable, cryptographically verified approval evidence.""" - payload: Mapping[str, Any] + payload: Mapping[str, Any] = field(repr=False) payload_sha256: str trust_root_sha256: str signature: bytes = field(repr=False, compare=False) @@ -1099,10 +1229,15 @@ def recovery_actions(self) -> tuple[Mapping[str, Any], ...]: @property def bindings(self) -> Mapping[str, Any]: + fields = ( + _SIMNOW_CONTEXT_FIELDS + if self.payload.keys() >= _CREDENTIAL_BINDING_FIELDS + else _CONTEXT_FIELDS + ) return MappingProxyType( { field_name: self.payload[field_name] - for field_name in _CONTEXT_FIELDS + for field_name in fields if field_name != "source" and field_name in self.payload } ) @@ -1144,6 +1279,7 @@ class CtpExecutionApprovalCapability: "_entry_used", "_settlement_used", "_context", + "_trust_root", ) def __init__( @@ -1153,6 +1289,7 @@ def __init__( owner: object, approval: CtpExecutionApproval, context: Mapping[str, Any] | CtpExecutionApprovalContext | None = None, + trust_root: Mapping[str, Any] | None = None, ): if seal is not _CAPABILITY_SEAL: raise TypeError("opaque CTP approval capability required") @@ -1171,6 +1308,7 @@ def __init__( # references in its private refresh closure; recovery re-collects # those references before native arm and each managed write. self._context = context + self._trust_root = _freeze(trust_root) @property def approval_id(self) -> str: @@ -1225,13 +1363,117 @@ def _new_capability( approval: CtpExecutionApproval, owner: object, context: Mapping[str, Any] | CtpExecutionApprovalContext | None = None, + *, + trust_root: Mapping[str, Any] | None = None, ) -> CtpExecutionApprovalCapability: + normalized_root = _normalize_trust_root(trust_root) + root_sha256 = hashlib.sha256(_canonical_json(_jsonable(normalized_root))).hexdigest() + if root_sha256 != approval.trust_root_sha256: + _reject("redeem_ctp_execution_approval", "ctp_approval_trust_root_mismatch") return CtpExecutionApprovalCapability( - seal=_CAPABILITY_SEAL, owner=owner, approval=approval, context=context + seal=_CAPABILITY_SEAL, + owner=owner, + approval=approval, + context=context, + trust_root=trust_root, ) -def _verify_signature(signature: bytes, payload_bytes: bytes, public_key: bytes) -> None: +class _CtpExecutionEntryWriteGuard: + """SDK-sealed per-write revalidator for one redeemed normal-entry approval.""" + + __slots__ = ( + "_seal", + "_owner", + "_session", + "_capability", + "_context", + "_approval_payload_sha256", + "_approval_signature_sha256", + "_trust_root_sha256", + "_proof_sha256", + ) + + def __init__( + self, + *, + seal: object, + owner: object, + session: object, + capability: CtpExecutionApprovalCapability, + proof_sha256: str, + ) -> None: + if seal is not _ENTRY_WRITE_GUARD_SEAL: + raise TypeError("SDK-issued CTP entry write guard required") + if ( + type(capability) is not CtpExecutionApprovalCapability + or capability._seal is not _CAPABILITY_SEAL + or capability._owner is not owner + or not isinstance(proof_sha256, str) + or not re.fullmatch(r"[0-9a-f]{64}", proof_sha256) + ): + raise TypeError("redeemed CTP entry approval required") + self._seal = seal + self._owner = owner + self._session = session + self._capability = capability + self._context = capability._context + self._approval_payload_sha256 = capability._approval.payload_sha256 + self._approval_signature_sha256 = hashlib.sha256( + capability._approval.signature + ).hexdigest() + self._trust_root_sha256 = capability._approval.trust_root_sha256 + self._proof_sha256 = proof_sha256 + + def validate(self, operation: str, **context: Any) -> None: + if ( + type(self) is not _CtpExecutionEntryWriteGuard + or self._seal is not _ENTRY_WRITE_GUARD_SEAL + or not callable(getattr(self._owner, "_validate_active_ctp_entry_authorization", None)) + ): + _reject(operation, "ctp_entry_authorization_guard_invalid") + self._owner._validate_active_ctp_entry_authorization( + self, + operation=operation, + **context, + ) + + +def _new_ctp_entry_write_guard( + owner: object, + session: object, + capability: CtpExecutionApprovalCapability, + proof_sha256: str, +) -> _CtpExecutionEntryWriteGuard: + """Create a non-forgeable-in-process guard from the SDK's one-shot grant.""" + + return _CtpExecutionEntryWriteGuard( + seal=_ENTRY_WRITE_GUARD_SEAL, + owner=owner, + session=session, + capability=capability, + proof_sha256=proof_sha256, + ) + + +def _is_ctp_entry_write_guard(value: object, *, session: object | None = None) -> bool: + return bool( + type(value) is _CtpExecutionEntryWriteGuard + and value._seal is _ENTRY_WRITE_GUARD_SEAL + and (session is None or value._session is session) + ) + + +def _is_ctp_execution_approval_capability(value: object) -> bool: + return bool( + type(value) is CtpExecutionApprovalCapability + and value._seal is _CAPABILITY_SEAL + ) + + +def _verify_signature( + signature: bytes, payload_bytes: bytes, public_key: bytes +) -> None: try: from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey except (ImportError, ModuleNotFoundError): @@ -1245,7 +1487,11 @@ def _verify_signature(signature: bytes, payload_bytes: bytes, public_key: bytes) def _coerce_now(value: datetime | None) -> datetime: if value is None: return datetime.now(UTC) - if not isinstance(value, datetime) or value.tzinfo is None or value.utcoffset() != timedelta(0): + if ( + not isinstance(value, datetime) + or value.tzinfo is None + or value.utcoffset() != timedelta(0) + ): _reject(APPROVAL_OPERATION, "ctp_approval_clock_untrusted") return value.astimezone(UTC) @@ -1263,7 +1509,23 @@ def _validate_time_window(payload: Mapping[str, Any], now: datetime) -> None: def _compare_context(payload: Mapping[str, Any], context: Mapping[str, Any]) -> None: - for field_name in _CONTEXT_FIELDS - {"source"}: + bound_payload = payload.keys() >= _CREDENTIAL_BINDING_FIELDS + profile = str(payload.get("environment_profile") or "").strip() + if profile in _SIMNOW_RESTRICTED_PROFILES and profile not in _SIMNOW_BOUND_PROFILES: + _reject(APPROVAL_OPERATION, "ctp_credential_binding_scope_unsupported") + if profile in _SIMNOW_BOUND_PROFILES and not bound_payload: + _reject(APPROVAL_OPERATION, "ctp_credential_binding_required") + if bound_payload and profile not in _SIMNOW_BOUND_PROFILES: + _reject(APPROVAL_OPERATION, "ctp_credential_binding_scope_unsupported") + fields = _SIMNOW_CONTEXT_FIELDS if bound_payload else _CONTEXT_FIELDS + if bound_payload and context.keys() < _CREDENTIAL_BINDING_FIELDS: + _reject(APPROVAL_OPERATION, "ctp_credential_binding_required") + if profile in _SIMNOW_BOUND_PROFILES and context.get("source") not in { + "sdk_runtime", + "deployment_manifest", + }: + _reject(APPROVAL_OPERATION, "ctp_approval_context_untrusted") + for field_name in fields - {"source"}: left = payload[field_name] right = context[field_name] if field_name == "authorized_instruments": @@ -1296,7 +1558,9 @@ def verify_ctp_execution_approval( _reject(APPROVAL_OPERATION, "ctp_approval_invalid_artifact") payload_value = artifact_value.get("payload") payload_schema = ( - payload_value.get("schema_version") if isinstance(payload_value, Mapping) else None + payload_value.get("schema_version") + if isinstance(payload_value, Mapping) + else None ) if ( artifact_value["schema_version"] != payload_schema @@ -1305,6 +1569,7 @@ def verify_ctp_execution_approval( APPROVAL_SCHEMA_VERSION, RECOVERY_APPROVAL_SCHEMA_VERSION, ENTRY_APPROVAL_SCHEMA_VERSION, + *(_SIMNOW_SCHEMA_BASES.keys()), } or artifact_value["algorithm"] != APPROVAL_ALGORITHM ): @@ -1343,7 +1608,9 @@ def verify_ctp_execution_approval( normalized_context = _normalize_context(context) _compare_context(normalized_payload, normalized_context) payload_sha256 = hashlib.sha256(payload_bytes).hexdigest() - root_sha256 = hashlib.sha256(_canonical_json(_jsonable(normalized_root))).hexdigest() + root_sha256 = hashlib.sha256( + _canonical_json(_jsonable(normalized_root)) + ).hexdigest() return CtpExecutionApproval( payload=_freeze(normalized_payload), payload_sha256=payload_sha256, @@ -1388,13 +1655,18 @@ def revalidate_ctp_execution_approval( ) -> CtpExecutionApproval: """Recheck a verified result immediately before durable redemption.""" - if type(approval) is not CtpExecutionApproval or approval._seal is not _CAPABILITY_SEAL: + if ( + type(approval) is not CtpExecutionApproval + or approval._seal is not _CAPABILITY_SEAL + ): _reject("redeem_ctp_execution_approval", "ctp_approval_opaque_required") artifact = { "schema_version": approval.payload["schema_version"], "algorithm": APPROVAL_ALGORITHM, "payload": _thaw(approval.payload), - "signature": base64.urlsafe_b64encode(approval.signature).decode("ascii").rstrip("="), + "signature": base64.urlsafe_b64encode(approval.signature) + .decode("ascii") + .rstrip("="), } return verify_ctp_execution_approval( _canonical_json(artifact), trust_root=trust_root, context=context, _now=_now @@ -1428,9 +1700,13 @@ def current_ctp_execution_revocation_snapshot( "APPROVAL_ALGORITHM", "APPROVAL_PURPOSE", "APPROVAL_SCHEMA_VERSION", + "ENTRY_APPROVAL_SCHEMA_VERSION", "RECOVERY_APPROVAL_PURPOSE", "RECOVERY_APPROVAL_SCHEMA_VERSION", "RECOVERY_APPROVAL_SCOPE_VERSION", + "SIMNOW_APPROVAL_SCHEMA_VERSION", + "SIMNOW_ENTRY_APPROVAL_SCHEMA_VERSION", + "SIMNOW_RECOVERY_APPROVAL_SCHEMA_VERSION", "CtpExecutionApproval", "CtpExecutionApprovalCapability", "CtpExecutionApprovalContext", diff --git a/bt_api_py/_ctp_production_execution_approval.py b/bt_api_py/_ctp_production_execution_approval.py new file mode 100644 index 00000000..7b52c847 --- /dev/null +++ b/bt_api_py/_ctp_production_execution_approval.py @@ -0,0 +1,868 @@ +"""Production-only, non-authorizing CTP managed-write approval evidence. + +This module verifies two detached Ed25519 signatures: an independently +provisioned trust anchor signs a short-lived issuer trust root, and an issuer +key from that root signs an exact production action scope. Verification only +returns immutable evidence. This module has no CTP client imports, signer, +approval redemption, arming, order, or cancel path. + +The private context factory is reserved for a future code-owned runtime after +it has collected session identity. A verified object from this module is not +a write capability and is not accepted by any current route. +""" + +from __future__ import annotations + +import base64 +import binascii +import hashlib +import json +import re +import unicodedata +from collections.abc import Mapping +from dataclasses import dataclass, field +from datetime import UTC, datetime, timedelta +from decimal import Decimal, InvalidOperation +from types import MappingProxyType +from typing import Any, NoReturn +from urllib.parse import urlsplit + +from ._contracts.errors import NormalizedApiError + +PRODUCTION_APPROVAL_SCHEMA_VERSION = "ctp-production-managed-write-approval-v1" +PRODUCTION_TRUST_ROOT_SCHEMA_VERSION = "ctp-production-write-trust-root-v1" +PRODUCTION_APPROVAL_PURPOSE = "ctp_production_managed_write" +PRODUCTION_APPROVER_ROLE = "independent_production_approver" +_ALGORITHM = "Ed25519" +_OPERATION = "verify_ctp_production_managed_write_approval" +_MAX_ARTIFACT_BYTES = 64 * 1024 +_MAX_ROOT_LIFETIME = timedelta(days=90) +_MAX_REVOCATION_LIFETIME = timedelta(hours=24) +_MAX_APPROVAL_LIFETIME = timedelta(hours=24) +_MAX_ORDERS = 16 +_MAX_CANCELS = 16 +_MAX_TOTAL_ACTIONS = 24 +_MAX_ORDER_VOLUME = 1000 +_MAX_TOTAL_VOLUME = 5000 +_HEX64 = re.compile(r"^[0-9a-f]{64}$") +_SAFE_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.:-]{0,127}$") +_TRADING_DAY = re.compile(r"^[0-9]{8}$") +_B64URL_32 = re.compile(r"^[A-Za-z0-9_-]{43}$") +_B64URL_64 = re.compile(r"^[A-Za-z0-9_-]{86}$") +_EXCHANGE = re.compile(r"^[A-Z][A-Z0-9_]{1,15}$") +_INSTRUMENT = re.compile(r"^[A-Za-z0-9_.-]{1,80}$") +_ORDER_REF = re.compile(r"^[A-Za-z0-9_.:-]{1,32}$") +_CONTEXT_SEAL = object() +_EVIDENCE_SEAL = object() + +_ARTIFACT_FIELDS = frozenset({"schema_version", "algorithm", "payload", "signature"}) +_ROOT_PAYLOAD_FIELDS = frozenset( + {"schema_version", "root_id", "issued_at", "expires_at", "keys", "revocation_snapshot"} +) +_ROOT_KEY_FIELDS = frozenset({"public_key", "role", "purposes", "not_before", "expires_at"}) +_REVOCATION_FIELDS = frozenset( + {"version", "issued_at", "expires_at", "revoked_approval_ids", "revoked_nonces"} +) +_APPROVAL_FIELDS = frozenset( + { + "schema_version", + "approval_id", + "nonce", + "issuer_key_id", + "issuer_role", + "purpose", + "environment", + "broker_id", + "account_fingerprint", + "md_front", + "td_front", + "trading_day", + "connection_generation", + "strategy_id", + "runtime_id", + "artifact_sha256", + "config_sha256", + "issued_at", + "not_before", + "expires_at", + "revocation_snapshot_version", + "orders", + "cancellations", + } +) +_ORDER_FIELDS = frozenset( + { + "intent_id", + "instrument_id", + "exchange_id", + "side", + "offset", + "hedge_flag", + "volume", + "limit_price", + } +) +_CANCEL_FIELDS = frozenset({"cancel_id", "target_order_ref", "instrument_id", "exchange_id"}) +_CONTEXT_FIELDS = frozenset( + { + "environment", + "broker_id", + "account_fingerprint", + "md_front", + "td_front", + "trading_day", + "connection_generation", + "strategy_id", + "runtime_id", + "artifact_sha256", + "config_sha256", + } +) + + +def _reject(code: str) -> NoReturn: + raise NormalizedApiError(_OPERATION, code, definite_reject=True) + + +class _DuplicateKeyError(ValueError): + pass + + +def _object_pairs(pairs: list[tuple[str, Any]]) -> dict[str, Any]: + result: dict[str, Any] = {} + for key, value in pairs: + if key in result: + raise _DuplicateKeyError(key) + result[key] = value + return result + + +def _reject_constant(value: str) -> NoReturn: + raise ValueError(f"invalid JSON constant: {value}") + + +def _parse_object(value: bytes | str, *, code: str) -> dict[str, Any]: + if isinstance(value, str): + try: + raw = value.encode("utf-8", "strict") + except UnicodeEncodeError: + _reject(code) + elif isinstance(value, bytes): + raw = value + else: + _reject(code) + if len(raw) > _MAX_ARTIFACT_BYTES or raw.startswith(b"\xef\xbb\xbf"): + _reject(code) + try: + parsed = json.loads( + raw.decode("utf-8", "strict"), + object_pairs_hook=_object_pairs, + parse_constant=_reject_constant, + ) + except _DuplicateKeyError: + _reject("ctp_production_approval_duplicate_json_key") + except (UnicodeDecodeError, json.JSONDecodeError, ValueError, RecursionError): + _reject(code) + if type(parsed) is not dict: + _reject(code) + return parsed + + +def _canonical(value: Any) -> bytes: + try: + return json.dumps( + value, + ensure_ascii=False, + sort_keys=True, + separators=(",", ":"), + allow_nan=False, + ).encode("utf-8", "strict") + except (TypeError, UnicodeEncodeError, ValueError, RecursionError): + _reject("ctp_production_approval_invalid_artifact") + + +def _mapping(value: Any, expected: frozenset[str], code: str) -> Mapping[str, Any]: + if not isinstance(value, Mapping) or set(value) != expected: + _reject(code) + return value + + +def _string( + value: Any, + *, + code: str, + pattern: re.Pattern[str] | None = None, + max_length: int = 256, +) -> str: + if type(value) is not str or not value or len(value) > max_length: + _reject(code) + if value != value.strip() or value != unicodedata.normalize("NFC", value): + _reject(code) + if any(ord(char) < 0x20 or ord(char) == 0x7F for char in value): + _reject(code) + try: + value.encode("utf-8", "strict") + except UnicodeEncodeError: + _reject(code) + if pattern is not None and pattern.fullmatch(value) is None: + _reject(code) + return value + + +def _hash(value: Any) -> str: + return _string( + value, code="ctp_production_approval_invalid_hash", pattern=_HEX64, max_length=64 + ) + + +def _timestamp(value: Any, *, code: str) -> datetime: + text = _string(value, code=code, max_length=32) + if not text.endswith("Z"): + _reject(code) + try: + parsed = datetime.fromisoformat(text[:-1] + "+00:00") + except ValueError: + _reject(code) + if parsed.tzinfo is None or parsed.utcoffset() != timedelta(0): + _reject(code) + canonical = parsed.astimezone(UTC).isoformat(timespec="microseconds").replace("+00:00", "Z") + if text != canonical: + _reject(code) + return parsed.astimezone(UTC) + + +def _timestamp_text(value: datetime) -> str: + return value.astimezone(UTC).isoformat(timespec="microseconds").replace("+00:00", "Z") + + +def _b64url(value: Any, *, length: int, code: str) -> bytes: + pattern = _B64URL_32 if length == 32 else _B64URL_64 + text = _string(value, code=code, pattern=pattern, max_length=43 if length == 32 else 86) + try: + result = base64.urlsafe_b64decode(text + ("=" if length == 32 else "==")) + except (ValueError, binascii.Error): + _reject(code) + if len(result) != length: + _reject(code) + return result + + +def _public_key(value: Any) -> bytes: + if isinstance(value, bytes): + if len(value) != 32: + _reject("ctp_production_approval_invalid_trust_anchor") + return value + return _b64url(value, length=32, code="ctp_production_approval_invalid_trust_anchor") + + +def _signature(value: Any) -> bytes: + return _b64url(value, length=64, code="ctp_production_approval_invalid_signature") + + +def _verify_ed25519(signature: bytes, message: bytes, public_key: bytes, *, code: str) -> None: + try: + from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey + except (ImportError, ModuleNotFoundError): + _reject("ctp_production_approval_cryptography_unavailable") + try: + Ed25519PublicKey.from_public_bytes(public_key).verify(signature, message) + except Exception: + _reject(code) + + +def _coerce_now(value: datetime | None) -> datetime: + if value is None: + return datetime.now(UTC) + if not isinstance(value, datetime) or value.tzinfo is None or value.utcoffset() != timedelta(0): + _reject("ctp_production_approval_clock_untrusted") + return value.astimezone(UTC) + + +def _validate_window( + issued: datetime, + not_before: datetime, + expires: datetime, + current: datetime, + *, + max_lifetime: timedelta, + prefix: str, +) -> None: + if not issued <= not_before < expires or expires - issued > max_lifetime: + _reject(f"{prefix}_invalid_time_window") + if current < not_before: + _reject(f"{prefix}_not_yet_valid") + if current >= expires: + _reject(f"{prefix}_expired") + if issued > current + timedelta(seconds=1): + _reject(f"{prefix}_issued_in_future") + + +def _front(value: Any, *, code: str) -> str: + text = _string(value, code=code, max_length=256) + try: + parsed = urlsplit(text) + port = parsed.port + host = parsed.hostname + except ValueError: + _reject(code) + if ( + parsed.scheme not in {"tcp", "ssl"} + or not host + or port is None + or not 1 <= port <= 65535 + or parsed.username is not None + or parsed.password is not None + or parsed.path + or parsed.query + or parsed.fragment + or not text.isascii() + ): + _reject(code) + return text + + +def _trading_day(value: Any, *, code: str) -> str: + text = _string(value, code=code, pattern=_TRADING_DAY, max_length=8) + try: + datetime.strptime(text, "%Y%m%d") + except ValueError: + _reject(code) + return text + + +def _normalize_context(value: Any) -> Mapping[str, Any]: + if type(value) is not CtpProductionWriteContext or value._seal is not _CONTEXT_SEAL: + _reject("ctp_production_approval_context_untrusted") + raw = value.values + normalized = { + "environment": _string( + raw.get("environment"), code="ctp_production_approval_context_invalid" + ), + "broker_id": _string( + raw.get("broker_id"), code="ctp_production_approval_context_invalid", pattern=_SAFE_ID + ), + "account_fingerprint": _hash(raw.get("account_fingerprint")), + "md_front": _front(raw.get("md_front"), code="ctp_production_approval_context_invalid"), + "td_front": _front(raw.get("td_front"), code="ctp_production_approval_context_invalid"), + "trading_day": _trading_day( + raw.get("trading_day"), code="ctp_production_approval_context_invalid" + ), + "connection_generation": _positive_int( + raw.get("connection_generation"), "ctp_production_approval_context_invalid" + ), + "strategy_id": _string( + raw.get("strategy_id"), code="ctp_production_approval_context_invalid", pattern=_SAFE_ID + ), + "runtime_id": _string( + raw.get("runtime_id"), code="ctp_production_approval_context_invalid", pattern=_SAFE_ID + ), + "artifact_sha256": _hash(raw.get("artifact_sha256")), + "config_sha256": _hash(raw.get("config_sha256")), + } + if normalized["environment"] != "production": + _reject("ctp_production_approval_environment_mismatch") + return MappingProxyType(normalized) + + +def _positive_int(value: Any, code: str) -> int: + if type(value) is not int or value <= 0: + _reject(code) + return value + + +def _normalize_revocations(value: Any, *, now: datetime) -> Mapping[str, Any]: + value = _mapping(value, _REVOCATION_FIELDS, "ctp_production_approval_invalid_trust_root") + version = _positive_int(value["version"], "ctp_production_approval_invalid_revocation_version") + issued = _timestamp(value["issued_at"], code="ctp_production_approval_invalid_trust_root") + expires = _timestamp(value["expires_at"], code="ctp_production_approval_invalid_trust_root") + if not issued < expires or expires - issued > _MAX_REVOCATION_LIFETIME: + _reject("ctp_production_approval_invalid_trust_root") + if now < issued or now >= expires: + _reject("ctp_production_approval_revocation_snapshot_stale") + result: dict[str, Any] = { + "version": version, + "issued_at": _timestamp_text(issued), + "expires_at": _timestamp_text(expires), + } + for name in ("revoked_approval_ids", "revoked_nonces"): + entries = value[name] + if type(entries) is not list: + _reject("ctp_production_approval_invalid_trust_root") + normalized = [ + _string(item, code="ctp_production_approval_invalid_trust_root", pattern=_SAFE_ID) + for item in entries + ] + if normalized != sorted(set(normalized)): + _reject("ctp_production_approval_invalid_trust_root") + result[name] = normalized + return MappingProxyType(result) + + +def _normalize_root_payload(value: Any, *, now: datetime) -> Mapping[str, Any]: + value = _mapping(value, _ROOT_PAYLOAD_FIELDS, "ctp_production_approval_invalid_trust_root") + if value["schema_version"] != PRODUCTION_TRUST_ROOT_SCHEMA_VERSION: + _reject("ctp_production_approval_unknown_trust_root_schema") + root_id = _string( + value["root_id"], code="ctp_production_approval_invalid_trust_root", pattern=_SAFE_ID + ) + issued = _timestamp(value["issued_at"], code="ctp_production_approval_invalid_trust_root") + expires = _timestamp(value["expires_at"], code="ctp_production_approval_invalid_trust_root") + _validate_window( + issued, + issued, + expires, + now, + max_lifetime=_MAX_ROOT_LIFETIME, + prefix="ctp_production_approval_trust_root", + ) + keys = value["keys"] + if type(keys) is not dict or not keys: + _reject("ctp_production_approval_invalid_trust_root") + normalized_keys: dict[str, Any] = {} + for key_id, entry in keys.items(): + key_id = _string( + key_id, code="ctp_production_approval_invalid_trust_root", pattern=_SAFE_ID + ) + entry = _mapping(entry, _ROOT_KEY_FIELDS, "ctp_production_approval_invalid_trust_root") + role = _string( + entry["role"], code="ctp_production_approval_invalid_trust_root", pattern=_SAFE_ID + ) + purposes = entry["purposes"] + if type(purposes) is not list or not purposes: + _reject("ctp_production_approval_invalid_trust_root") + normalized_purposes = [ + _string(item, code="ctp_production_approval_invalid_trust_root", pattern=_SAFE_ID) + for item in purposes + ] + if len(normalized_purposes) != len(set(normalized_purposes)): + _reject("ctp_production_approval_invalid_trust_root") + key_not_before = _timestamp( + entry["not_before"], code="ctp_production_approval_invalid_trust_root" + ) + key_expires = _timestamp( + entry["expires_at"], code="ctp_production_approval_invalid_trust_root" + ) + if not key_not_before < key_expires or now < key_not_before or now >= key_expires: + _reject("ctp_production_approval_issuer_key_expired") + normalized_keys[key_id] = { + "public_key": _string( + entry["public_key"], + code="ctp_production_approval_invalid_trust_root", + pattern=_B64URL_32, + max_length=43, + ), + "role": role, + "purposes": tuple(normalized_purposes), + "not_before": _timestamp_text(key_not_before), + "expires_at": _timestamp_text(key_expires), + } + revocations = _normalize_revocations(value["revocation_snapshot"], now=now) + return MappingProxyType( + { + "schema_version": PRODUCTION_TRUST_ROOT_SCHEMA_VERSION, + "root_id": root_id, + "issued_at": _timestamp_text(issued), + "expires_at": _timestamp_text(expires), + "keys": MappingProxyType(normalized_keys), + "revocation_snapshot": revocations, + } + ) + + +def _normalize_order(value: Any) -> Mapping[str, Any]: + value = _mapping(value, _ORDER_FIELDS, "ctp_production_approval_invalid_order_scope") + result = { + "intent_id": _string( + value["intent_id"], code="ctp_production_approval_invalid_order_scope", pattern=_SAFE_ID + ), + "instrument_id": _string( + value["instrument_id"], + code="ctp_production_approval_invalid_order_scope", + pattern=_INSTRUMENT, + ), + "exchange_id": _string( + value["exchange_id"], + code="ctp_production_approval_invalid_order_scope", + pattern=_EXCHANGE, + ), + "side": _string(value["side"], code="ctp_production_approval_invalid_order_scope"), + "offset": _string(value["offset"], code="ctp_production_approval_invalid_order_scope"), + "hedge_flag": _string( + value["hedge_flag"], code="ctp_production_approval_invalid_order_scope" + ), + "volume": value["volume"], + "limit_price": value["limit_price"], + } + if ( + result["side"] not in {"buy", "sell"} + or result["offset"] not in {"open", "close", "close_today", "close_yesterday"} + or result["hedge_flag"] not in {"1", "2", "3"} + ): + _reject("ctp_production_approval_invalid_order_scope") + if type(result["volume"]) is not int or not 1 <= result["volume"] <= _MAX_ORDER_VOLUME: + _reject("ctp_production_approval_order_volume_out_of_bounds") + price_text = _string( + result["limit_price"], code="ctp_production_approval_invalid_order_scope", max_length=32 + ) + try: + price = Decimal(price_text) + except InvalidOperation: + _reject("ctp_production_approval_invalid_order_scope") + if ( + not price.is_finite() + or price <= 0 + or price > Decimal("1000000000") + or price.as_tuple().exponent < -8 + ): + _reject("ctp_production_approval_invalid_order_scope") + canonical_price = format(price, "f") + if "." in canonical_price: + canonical_price = canonical_price.rstrip("0").rstrip(".") + if canonical_price != price_text: + _reject("ctp_production_approval_invalid_order_scope") + result["limit_price"] = canonical_price + return MappingProxyType(result) + + +def _normalize_cancel(value: Any) -> Mapping[str, Any]: + value = _mapping(value, _CANCEL_FIELDS, "ctp_production_approval_invalid_cancel_scope") + return MappingProxyType( + { + "cancel_id": _string( + value["cancel_id"], + code="ctp_production_approval_invalid_cancel_scope", + pattern=_SAFE_ID, + ), + "target_order_ref": _string( + value["target_order_ref"], + code="ctp_production_approval_invalid_cancel_scope", + pattern=_ORDER_REF, + ), + "instrument_id": _string( + value["instrument_id"], + code="ctp_production_approval_invalid_cancel_scope", + pattern=_INSTRUMENT, + ), + "exchange_id": _string( + value["exchange_id"], + code="ctp_production_approval_invalid_cancel_scope", + pattern=_EXCHANGE, + ), + } + ) + + +def _normalize_approval_payload(value: Any) -> Mapping[str, Any]: + value = _mapping(value, _APPROVAL_FIELDS, "ctp_production_approval_invalid_payload") + if value["schema_version"] != PRODUCTION_APPROVAL_SCHEMA_VERSION: + _reject("ctp_production_approval_unknown_schema") + result: dict[str, Any] = { + "schema_version": PRODUCTION_APPROVAL_SCHEMA_VERSION, + "approval_id": _string( + value["approval_id"], code="ctp_production_approval_invalid_payload", pattern=_SAFE_ID + ), + "nonce": _string( + value["nonce"], code="ctp_production_approval_invalid_payload", pattern=_SAFE_ID + ), + "issuer_key_id": _string( + value["issuer_key_id"], code="ctp_production_approval_invalid_payload", pattern=_SAFE_ID + ), + "issuer_role": _string( + value["issuer_role"], code="ctp_production_approval_invalid_payload", pattern=_SAFE_ID + ), + "purpose": _string( + value["purpose"], code="ctp_production_approval_invalid_payload", pattern=_SAFE_ID + ), + "environment": _string( + value["environment"], code="ctp_production_approval_invalid_payload" + ), + "broker_id": _string( + value["broker_id"], code="ctp_production_approval_invalid_payload", pattern=_SAFE_ID + ), + "account_fingerprint": _hash(value["account_fingerprint"]), + "md_front": _front(value["md_front"], code="ctp_production_approval_invalid_front"), + "td_front": _front(value["td_front"], code="ctp_production_approval_invalid_front"), + "trading_day": _trading_day( + value["trading_day"], code="ctp_production_approval_invalid_payload" + ), + "connection_generation": _positive_int( + value["connection_generation"], "ctp_production_approval_invalid_payload" + ), + "strategy_id": _string( + value["strategy_id"], code="ctp_production_approval_invalid_payload", pattern=_SAFE_ID + ), + "runtime_id": _string( + value["runtime_id"], code="ctp_production_approval_invalid_payload", pattern=_SAFE_ID + ), + "artifact_sha256": _hash(value["artifact_sha256"]), + "config_sha256": _hash(value["config_sha256"]), + "issued_at": _timestamp_text( + _timestamp(value["issued_at"], code="ctp_production_approval_invalid_payload") + ), + "not_before": _timestamp_text( + _timestamp(value["not_before"], code="ctp_production_approval_invalid_payload") + ), + "expires_at": _timestamp_text( + _timestamp(value["expires_at"], code="ctp_production_approval_invalid_payload") + ), + "revocation_snapshot_version": _positive_int( + value["revocation_snapshot_version"], "ctp_production_approval_invalid_payload" + ), + } + if result["environment"] != "production": + _reject("ctp_production_approval_environment_mismatch") + if result["purpose"] != PRODUCTION_APPROVAL_PURPOSE: + _reject("ctp_production_approval_purpose_mismatch") + orders = value["orders"] + cancellations = value["cancellations"] + if type(orders) is not list or not 1 <= len(orders) <= _MAX_ORDERS: + _reject("ctp_production_approval_order_scope_out_of_bounds") + if type(cancellations) is not list or len(cancellations) > _MAX_CANCELS: + _reject("ctp_production_approval_cancel_scope_out_of_bounds") + if len(orders) + len(cancellations) > _MAX_TOTAL_ACTIONS: + _reject("ctp_production_approval_action_scope_out_of_bounds") + normalized_orders = [_normalize_order(item) for item in orders] + normalized_cancels = [_normalize_cancel(item) for item in cancellations] + intent_ids = [item["intent_id"] for item in normalized_orders] + cancel_ids = [item["cancel_id"] for item in normalized_cancels] + if len(intent_ids) != len(set(intent_ids)) or len(cancel_ids) != len(set(cancel_ids)): + _reject("ctp_production_approval_duplicate_action_id") + if sum(item["volume"] for item in normalized_orders) > _MAX_TOTAL_VOLUME: + _reject("ctp_production_approval_total_volume_out_of_bounds") + order_refs = {item["target_order_ref"] for item in normalized_cancels} + if len(order_refs) != len(normalized_cancels): + _reject("ctp_production_approval_duplicate_cancel_target") + result["orders"] = tuple(normalized_orders) + result["cancellations"] = tuple(normalized_cancels) + return MappingProxyType(result) + + +def _freeze(value: Any) -> Any: + if isinstance(value, Mapping): + return MappingProxyType({key: _freeze(item) for key, item in value.items()}) + if isinstance(value, (list, tuple)): + return tuple(_freeze(item) for item in value) + return value + + +def _thaw(value: Any) -> Any: + if isinstance(value, Mapping): + return {key: _thaw(item) for key, item in value.items()} + if isinstance(value, tuple): + return [_thaw(item) for item in value] + return value + + +@dataclass(frozen=True, slots=True, init=False) +class CtpProductionWriteContext: + """Private sealed snapshot of locally collected production identity.""" + + values: Mapping[str, Any] = field(repr=False) + _seal: object = field(repr=False, compare=False) + _owner: object = field(repr=False, compare=False) + + def __init__(self, *, values: Mapping[str, Any], owner: object, _seal: object) -> None: + if _seal is not _CONTEXT_SEAL: + raise TypeError("SDK-collected production CTP context required") + if not isinstance(values, Mapping) or set(values) != _CONTEXT_FIELDS: + raise TypeError("complete production CTP identity required") + object.__setattr__(self, "values", _freeze(dict(values))) + object.__setattr__(self, "_owner", owner) + object.__setattr__(self, "_seal", _seal) + + +def _new_runtime_context(values: Mapping[str, Any], *, owner: object) -> CtpProductionWriteContext: + """Internal constructor for the future code-owned runtime and local tests.""" + + return CtpProductionWriteContext(values=values, owner=owner, _seal=_CONTEXT_SEAL) + + +@dataclass(frozen=True, slots=True, init=False) +class CtpProductionWriteApprovalEvidence: + """Immutable signature evidence with no arming or provider interface.""" + + payload: Mapping[str, Any] = field(repr=False) + payload_sha256: str + trust_root_sha256: str + trust_anchor_key_sha256: str + revocation_snapshot: Mapping[str, Any] + _seal: object = field(repr=False, compare=False) + + def __init__( + self, + *, + payload: Mapping[str, Any], + payload_sha256: str, + trust_root_sha256: str, + trust_anchor_key_sha256: str, + revocation_snapshot: Mapping[str, Any], + _seal: object, + ) -> None: + if _seal is not _EVIDENCE_SEAL: + raise TypeError("verified production CTP approval evidence required") + object.__setattr__(self, "payload", _freeze(payload)) + object.__setattr__(self, "payload_sha256", payload_sha256) + object.__setattr__(self, "trust_root_sha256", trust_root_sha256) + object.__setattr__(self, "trust_anchor_key_sha256", trust_anchor_key_sha256) + object.__setattr__(self, "revocation_snapshot", _freeze(revocation_snapshot)) + object.__setattr__(self, "_seal", _seal) + + @property + def approval_id(self) -> str: + return str(self.payload["approval_id"]) + + @property + def environment(self) -> str: + return str(self.payload["environment"]) + + @property + def expires_at(self) -> str: + return str(self.payload["expires_at"]) + + def as_dict(self) -> dict[str, Any]: + """Return detached audit data; this result grants no write authority.""" + + return { + "approval_id": self.approval_id, + "environment": self.environment, + "payload_sha256": self.payload_sha256, + "trust_root_sha256": self.trust_root_sha256, + "trust_anchor_key_sha256": self.trust_anchor_key_sha256, + "expires_at": self.expires_at, + "payload": _thaw(self.payload), + "revocation_snapshot": _thaw(self.revocation_snapshot), + "authorizes_write": False, + } + + +def verify_ctp_production_managed_write_approval( + artifact: bytes | str, + *, + trust_root_artifact: bytes | str, + trust_anchor_public_key: bytes | str, + context: CtpProductionWriteContext, + owner: object, + minimum_revocation_snapshot_version: int, + _now: datetime | None = None, +) -> CtpProductionWriteApprovalEvidence: + """Verify production-only approval and exact locally collected context. + + The trust anchor is supplied out-of-band and signs the issuer root. The + issuer root signs the bounded approval. The runtime must supply its + durably retained revocation-version floor; an older signed snapshot is + rejected. A SimNow/demo artifact, context, purpose, or issuer trust root + cannot satisfy the schema/environment gates. The returned result is + evidence only and cannot arm, submit, or cancel. + """ + + if type(context) is not CtpProductionWriteContext or context._seal is not _CONTEXT_SEAL: + _reject("ctp_production_approval_context_untrusted") + if context._owner is not owner: + _reject("ctp_production_approval_context_owner_mismatch") + current = _coerce_now(_now) + + root_artifact = _parse_object( + trust_root_artifact, code="ctp_production_approval_invalid_trust_root" + ) + if set(root_artifact) != _ARTIFACT_FIELDS: + _reject("ctp_production_approval_invalid_trust_root") + if ( + root_artifact["schema_version"] != PRODUCTION_TRUST_ROOT_SCHEMA_VERSION + or root_artifact["algorithm"] != _ALGORITHM + ): + _reject("ctp_production_approval_unknown_trust_root_schema") + root_payload = _normalize_root_payload(root_artifact["payload"], now=current) + anchor = _public_key(trust_anchor_public_key) + root_signature = _signature(root_artifact["signature"]) + root_bytes = _canonical(_thaw(root_payload)) + _verify_ed25519( + root_signature, + root_bytes, + anchor, + code="ctp_production_approval_trust_root_signature_invalid", + ) + + approval_artifact = _parse_object(artifact, code="ctp_production_approval_invalid_artifact") + if set(approval_artifact) != _ARTIFACT_FIELDS: + _reject("ctp_production_approval_invalid_artifact") + if ( + approval_artifact["schema_version"] != PRODUCTION_APPROVAL_SCHEMA_VERSION + or approval_artifact["algorithm"] != _ALGORITHM + ): + _reject("ctp_production_approval_unknown_schema") + payload = _normalize_approval_payload(approval_artifact["payload"]) + issued = _timestamp(payload["issued_at"], code="ctp_production_approval_invalid_payload") + not_before = _timestamp(payload["not_before"], code="ctp_production_approval_invalid_payload") + expires = _timestamp(payload["expires_at"], code="ctp_production_approval_invalid_payload") + _validate_window( + issued, + not_before, + expires, + current, + max_lifetime=_MAX_APPROVAL_LIFETIME, + prefix="ctp_production_approval", + ) + snapshot = root_payload["revocation_snapshot"] + minimum_version = _positive_int( + minimum_revocation_snapshot_version, + "ctp_production_approval_invalid_revocation_version_floor", + ) + if snapshot["version"] < minimum_version: + _reject("ctp_production_approval_revocation_version_rollback") + if payload["revocation_snapshot_version"] != snapshot["version"]: + _reject("ctp_production_approval_revocation_version_stale") + if ( + payload["approval_id"] in snapshot["revoked_approval_ids"] + or payload["nonce"] in snapshot["revoked_nonces"] + ): + _reject("ctp_production_approval_revoked") + key = root_payload["keys"].get(payload["issuer_key_id"]) + if key is None: + _reject("ctp_production_approval_issuer_untrusted") + if ( + key["role"] != PRODUCTION_APPROVER_ROLE + or payload["issuer_role"] != PRODUCTION_APPROVER_ROLE + or PRODUCTION_APPROVAL_PURPOSE not in key["purposes"] + ): + _reject("ctp_production_approval_issuer_policy_mismatch") + issuer_public_key = _b64url( + key["public_key"], + length=32, + code="ctp_production_approval_invalid_trust_root", + ) + if issuer_public_key == anchor: + _reject("ctp_production_approval_issuer_key_must_differ_from_trust_anchor") + approval_signature = _signature(approval_artifact["signature"]) + payload_bytes = _canonical(_thaw(payload)) + _verify_ed25519( + approval_signature, + payload_bytes, + issuer_public_key, + code="ctp_production_approval_signature_invalid", + ) + + normalized_context = _normalize_context(context) + for field_name in _CONTEXT_FIELDS: + if payload[field_name] != normalized_context[field_name]: + _reject("ctp_production_approval_context_mismatch") + payload_hash = hashlib.sha256(payload_bytes).hexdigest() + root_hash = hashlib.sha256(_canonical(_thaw(root_payload))).hexdigest() + return CtpProductionWriteApprovalEvidence( + payload=payload, + payload_sha256=payload_hash, + trust_root_sha256=root_hash, + trust_anchor_key_sha256=hashlib.sha256(anchor).hexdigest(), + revocation_snapshot=snapshot, + _seal=_EVIDENCE_SEAL, + ) + + +__all__ = [ + "PRODUCTION_APPROVAL_PURPOSE", + "PRODUCTION_APPROVER_ROLE", + "PRODUCTION_APPROVAL_SCHEMA_VERSION", + "PRODUCTION_TRUST_ROOT_SCHEMA_VERSION", + "CtpProductionWriteApprovalEvidence", + "CtpProductionWriteContext", + "verify_ctp_production_managed_write_approval", +] diff --git a/bt_api_py/_direct_backend.py b/bt_api_py/_direct_backend.py index 442abed9..637a7573 100644 --- a/bt_api_py/_direct_backend.py +++ b/bt_api_py/_direct_backend.py @@ -375,6 +375,12 @@ def cancel_order( value = getattr(request, key, None) if value is not None: kwargs[key] = value + if request.runtime_order_id is not None: + kwargs["runtime_order_id"] = request.runtime_order_id + if request.runtime_action_id is not None: + kwargs["runtime_action_id"] = request.runtime_action_id + if request.managed_cancel_intent_id is not None: + kwargs["managed_cancel_intent_id"] = request.managed_cancel_intent_id if order_id is None: kwargs.setdefault("order_ref", request.order_ref or request.client_order_id) kwargs.update(self._execution_options(exchange_name)) @@ -411,6 +417,12 @@ async def async_cancel_order( value = getattr(request, key, None) if value is not None: kwargs[key] = value + if request.runtime_order_id is not None: + kwargs["runtime_order_id"] = request.runtime_order_id + if request.runtime_action_id is not None: + kwargs["runtime_action_id"] = request.runtime_action_id + if request.managed_cancel_intent_id is not None: + kwargs["managed_cancel_intent_id"] = request.managed_cancel_intent_id if order_id is None: kwargs.setdefault("order_ref", request.order_ref or request.client_order_id) kwargs.update(self._execution_options(exchange_name)) diff --git a/bt_api_py/_execution_session.py b/bt_api_py/_execution_session.py index c74b9974..29b0b980 100644 --- a/bt_api_py/_execution_session.py +++ b/bt_api_py/_execution_session.py @@ -12,6 +12,7 @@ import math import os import re +import stat import time import unicodedata import uuid @@ -39,7 +40,11 @@ budget_evidence_digest, evaluate_ctp_budget, ) -from ._ctp_execution_authorization import recovery_action_digest, recovery_plan_digest +from ._ctp_execution_authorization import ( + _is_ctp_entry_write_guard, + recovery_action_digest, + recovery_plan_digest, +) from ._normalization import _is_definite_reject @@ -77,6 +82,8 @@ def flock(self, fd: int, operation: int) -> None: ... "execution_cycle_id", "execution_role", "strategy_identity_sha256", + "runtime_order_id", + "managed_intent_id", ) _LEDGER_SEMANTIC_IDENTITY = frozenset( { @@ -88,11 +95,13 @@ def flock(self, fd: int, operation: int) -> None: ... "execution_cycle_id", "execution_role", "strategy_identity_sha256", + "managed_intent_id", } ) _EXPLICIT_IDENTITY_FIELDS = "_explicit_identity_fields" _CONFIG: dict[str, Any] = { "order_journal": None, + "windows_ctp_journal_preprovisioned": False, "account_risk_state": None, "require_order_journal": True, "market_data_only": False, @@ -375,7 +384,9 @@ def _execution_arm_proof(value): # native CTP gate always calculate the same proof digest. proof["authorized_instruments"] = list(canonical_authorized) account_fingerprint = proof["account_fingerprint"] - account_digest = account_fingerprint.removeprefix("acct_") + account_digest = ( + account_fingerprint[5:] if account_fingerprint.startswith("acct_") else account_fingerprint + ) if ( account_fingerprint != account_fingerprint.lower() or not account_fingerprint.startswith("acct_") @@ -530,6 +541,73 @@ def _default_journal_path(identities): return (_ledger_registry_root().parent / "execution-journals" / f"{digest}.jsonl").resolve() +def _require_windows_ctp_journal_preprovision( + path, + config, + *, + journal_identity_at_open, + platform_name=None, +): + """Require a pre-existing journal and explicit storage-contract attestation on Windows. + + ``FlushFileBuffers`` flushes a file handle; Windows does not provide a + portable Python-level directory-entry flush contract equivalent to POSIX + directory ``fsync``. The configuration flag is only an operator attestation + that deployment provisioned and reviewed this path. It is not evidence that + a target filesystem survives power loss and does not authorize a CTP write. + """ + if (os.name if platform_name is None else platform_name) != "nt": + return + if config["windows_ctp_journal_preprovisioned"] is not True: + raise NormalizedApiError( + "journal", + "windows_ctp_journal_provisioning_required", + definite_reject=True, + ) + if path is None or journal_identity_at_open is None: + raise NormalizedApiError( + "journal", + "windows_ctp_journal_must_be_preprovisioned", + definite_reject=True, + ) + + +def _journal_file_identity(path): + """Return a regular, non-reparse journal file identity without following links.""" + if path is None: + return None + try: + info = Path(path).lstat() + except OSError: + return None + return _regular_file_identity(info) + + +def _regular_file_identity(info): + reparse_flag = getattr(stat, "FILE_ATTRIBUTE_REPARSE_POINT", 0x400) + if not stat.S_ISREG(info.st_mode) or getattr(info, "st_file_attributes", 0) & reparse_flag: + return None + device = getattr(info, "st_dev", None) + file_id = getattr(info, "st_ino", None) + if device is None or file_id in (None, 0): + return None + return int(device), int(file_id) + + +def _verify_windows_ctp_journal_identity(path, fd, expected_identity): + """Reject path replacement before or during a Windows CTP append.""" + if ( + expected_identity is None + or _regular_file_identity(os.fstat(fd)) != expected_identity + or _journal_file_identity(path) != expected_identity + ): + raise NormalizedApiError( + "journal", + "windows_ctp_journal_identity_changed", + definite_reject=True, + ) + + def _lock_file(path, operation, code): path = Path(path) path.parent.mkdir(parents=True, exist_ok=True) @@ -639,7 +717,14 @@ def session_config(config): if not isinstance(config, dict) or set(config) - set(_CONFIG): raise NormalizedApiError("configure_execution", "invalid_execution_config") result = {**_CONFIG, **config} - if any(type(result[key]) is not bool for key in ("market_data_only", "require_order_journal")): + if any( + type(result[key]) is not bool + for key in ( + "market_data_only", + "require_order_journal", + "windows_ctp_journal_preprovisioned", + ) + ): raise NormalizedApiError("configure_execution", "invalid_execution_config") result["account_currencies"] = dict(result["account_currencies"] or {}) account_ids = result["account_ids"] @@ -954,6 +1039,68 @@ def _migration_receipt_path(destination): return Path(str(destination) + ".cutover.json") +def _migration_platform_name(): + return os.name + + +def _contains_ctp_migration_authority(value): + if isinstance(value, dict): + provider = str(value.get("provider") or "").strip().upper() + exchange = str(value.get("exchange_name") or "").partition("___")[0].strip().upper() + event = str(value.get("event") or "").strip().lower() + if provider == "CTP" or exchange == "CTP" or event.startswith("ctp_"): + return True + if any(str(key).lower().startswith("ctp_") for key in value): + return True + return any(_contains_ctp_migration_authority(item) for item in value.values()) + if isinstance(value, (list, tuple)): + return any(_contains_ctp_migration_authority(item) for item in value) + return False + + +def _journal_payload_contains_ctp_migration_authority(payload): + try: + lines = payload.decode("utf-8").splitlines() + except (AttributeError, UnicodeDecodeError): + return False + for line in lines: + try: + record = json.loads(line) + except (TypeError, ValueError, json.JSONDecodeError): + continue + if _contains_ctp_migration_authority(record): + return True + return False + + +def _journal_file_contains_ctp_migration_authority(path): + if not path: + return False + try: + with Path(path).open("rb") as stream: + return any(_journal_payload_contains_ctp_migration_authority(line) for line in stream) + except OSError: + return False + + +def _require_windows_ctp_migration_durability( + *, authorities=(), payloads=(), paths=(), platform_name=None +): + if (_migration_platform_name() if platform_name is None else platform_name) != "nt": + return + contains_ctp_authority = ( + any(_contains_ctp_migration_authority(value) for value in authorities) + or any(_journal_payload_contains_ctp_migration_authority(payload) for payload in payloads) + or any(_journal_file_contains_ctp_migration_authority(path) for path in paths) + ) + if contains_ctp_authority: + raise NormalizedApiError( + "migrate_journal", + "windows_ctp_migration_durability_unavailable", + definite_reject=True, + ) + + def _complete_migration_files(transaction, reconciliation): source = Path(transaction["source"]) destination = Path(transaction["destination"]) @@ -1025,6 +1172,16 @@ def _recover_migration_transaction(source, destination): "migrate_journal", "unreadable_cutover_transaction", definite_reject=True ) from None + _require_windows_ctp_migration_durability( + authorities=(transaction, identity), + paths=( + source, + destination, + transaction.get("staging"), + transaction.get("sealed_source"), + ), + ) + source_lease = _lock_existing_journal(source) registry_leases = _acquire_identity_registry_leases(identity) try: @@ -1086,6 +1243,7 @@ def _freeze_migration_source( freeze, ): source_bytes = source.read_bytes() + _require_windows_ctp_migration_durability(payloads=(source_bytes,)) source_hash = hashlib.sha256(source_bytes).hexdigest() try: source_epoch = int( @@ -1517,7 +1675,9 @@ def migrate_execution_journal( A durable PREPARED transaction is written before the source is sealed. A later invocation rolls a partial prepare back, or completes publication if - every registry scope already points at the validated destination. + every registry scope already points at the validated destination. CTP + authority migrations are refused on Windows because directory-entry + durability cannot be established there by this implementation. """ source = Path(source).expanduser().resolve() destination = Path(destination).expanduser().resolve() @@ -1536,6 +1696,7 @@ def migrate_execution_journal( if not isinstance(claims, dict): raise NormalizedApiError("migrate_journal", "invalid_claim", definite_reject=True) normalized_claims = {str(key): _claim_identity(value) for key, value in claims.items()} + _require_windows_ctp_migration_durability(authorities=(normalized_claims,)) source_lease = _lock_existing_journal(source) registry_leases = [] staging = None @@ -1657,6 +1818,7 @@ def __init__(self, config, exchange_names=(), credential_fingerprints=None): self.config["order_journal"] = str(_default_journal_path(identities)) path = self.config["order_journal"] self.path = Path(path) if path else None + self._journal_file_identity_at_open = _journal_file_identity(self.path) risk_path = self.config["account_risk_state"] if risk_path is None and self.path is not None: risk_path = str(self.path) + ".account-risk.json" @@ -1673,6 +1835,9 @@ def __init__(self, config, exchange_names=(), credential_fingerprints=None): self.orders = {} self.used_ids = set() self.reserved_ids = set() + self.runtime_order_bindings = {} + self.runtime_action_bindings = {} + self._runtime_action_attempts = defaultdict(int) self._reservation_only_cancel_unknowns = {} self.historical_unknown = set() self.pending = defaultdict(deque) @@ -1713,6 +1878,7 @@ def __init__(self, config, exchange_names=(), credential_fingerprints=None): self._recovery_mode = False self._recovery_dispatch_in_progress = False self._active_recovery_context = None + self._active_managed_write_context = None self._recovery_arm_capability = object() self._recovery_refresh_in_progress = False self._recovery_journal_error = None @@ -1720,6 +1886,7 @@ def __init__(self, config, exchange_names=(), credential_fingerprints=None): self._recovery_pending_tokens = set() self._recovery_authorization_records = {} self._recovery_write_guard = None + self._entry_write_guard = None self._recovery_budget_owner = None self._recovery_budget_request = None self._recovery_budget_enforced = False @@ -3599,6 +3766,24 @@ def _client_key(self, venue, account_id, client_id, row=None): identity = self._ledger_identity(venue, account_id, row) return (*self._ledger_key(identity), str(client_id)) + def _runtime_order_key(self, venue, row, runtime_order_id): + identity = self._ledger_identity(venue, row.get("account_id"), row) + strategy_id = str(row.get("strategy_id") or self.config["strategy_id"]) + return (*self._ledger_key(identity), strategy_id, str(runtime_order_id)) + + def _runtime_action_key(self, venue, row, runtime_action_id): + identity = self._ledger_identity(venue, row.get("account_id"), row) + strategy_id = str(row.get("strategy_id") or self.config["strategy_id"]) + return (*self._ledger_key(identity), strategy_id, str(runtime_action_id)) + + def _runtime_order_binding(self, venue, account_id, runtime_order_id): + key = self._runtime_order_key( + venue, + {"account_id": account_id, "strategy_id": self.config["strategy_id"]}, + runtime_order_id, + ) + return self.runtime_order_bindings.get(key) + def _trade_key(self, venue, row): """Scope venue trade IDs to account, trading day, exchange and symbol.""" identity = self._ledger_identity(venue, row.get("account_id"), row) @@ -3705,6 +3890,50 @@ def _load_journal(self): client_id, row, ) + if client_key in self.reserved_ids or client_key in self.used_ids: + raise ValueError("client_order_id_reservation_collision") + runtime_order_id = row.get("runtime_order_id") + managed_intent_id = row.get("managed_intent_id") + if managed_intent_id is not None and runtime_order_id is None: + raise ValueError("managed_intent_without_runtime_order_id") + if runtime_order_id is not None: + if ( + not isinstance(runtime_order_id, str) + or not runtime_order_id + or runtime_order_id != runtime_order_id.strip() + or len(runtime_order_id.encode("utf-8")) > 256 + ): + raise ValueError("invalid_runtime_order_id") + if managed_intent_id is not None and ( + not isinstance(managed_intent_id, str) + or not managed_intent_id + or managed_intent_id != managed_intent_id.strip() + or len(managed_intent_id.encode("utf-8")) > 256 + ): + raise ValueError("invalid_managed_intent_id") + runtime_key = self._runtime_order_key(venue, row, runtime_order_id) + if runtime_key in self.runtime_order_bindings: + raise ValueError("runtime_order_id_collision") + if managed_intent_id is not None and any( + key[:4] == runtime_key[:4] + and key != runtime_key + and binding.get("managed_intent_id") == managed_intent_id + for key, binding in self.runtime_order_bindings.items() + ): + raise ValueError("managed_intent_id_collision") + self.runtime_order_bindings[runtime_key] = { + "exchange_name": venue, + "account_id": row.get("account_id"), + "strategy_id": row.get("strategy_id"), + "connection_generation": row.get("connection_generation"), + "trading_day": row.get("trading_day"), + "runtime_order_id": runtime_order_id, + "managed_intent_id": row.get("managed_intent_id"), + "client_order_id": client_id, + "symbol": row.get("symbol"), + "status": "reservation_only", + "loaded_from_journal": True, + } reservation_cancel_events.setdefault(client_key, []).append( {"event": event, "exchange_name": venue, "client_order_id": client_id} ) @@ -3753,6 +3982,98 @@ def _load_journal(self): self.reserved_ids.discard(client_key) if not venue or not row.get("symbol"): raise ValueError("missing_order_identity") + runtime_order_id = row.get("runtime_order_id") + managed_intent_id = row.get("managed_intent_id") + if managed_intent_id is not None and ( + not isinstance(managed_intent_id, str) + or not managed_intent_id + or managed_intent_id != managed_intent_id.strip() + or len(managed_intent_id.encode("utf-8")) > 256 + or not isinstance(runtime_order_id, str) + or row.get("hedge_flag") not in {"1", "2", "3"} + ): + raise ValueError("invalid_managed_order_identity") + if ( + event == "intent" + and self._provider(venue) == "CTP" + and not isinstance(runtime_order_id, str) + ): + raise ValueError("ctp_runtime_order_identity_missing") + if event == "intent" and runtime_order_id is not None: + runtime_key = self._runtime_order_key(venue, row, runtime_order_id) + binding = self.runtime_order_bindings.get(runtime_key) + if ( + binding is None + or binding.get("client_order_id") != client_id + or binding.get("managed_intent_id") != row.get("managed_intent_id") + or binding.get("symbol") not in (None, row.get("symbol")) + or binding.get("status") != "reservation_only" + ): + raise ValueError("runtime_order_intent_binding_conflict") + binding["status"] = "unresolved" + binding["symbol"] = row.get("symbol") + if event == "cancel_intent": + if self._provider(venue) == "CTP" and ( + not isinstance(row.get("runtime_order_id"), str) + or not isinstance(row.get("runtime_action_id"), str) + ): + raise ValueError("ctp_runtime_cancel_identity_missing") + runtime_action_id = row.get("runtime_action_id") + if runtime_action_id is not None: + if ( + not isinstance(runtime_action_id, str) + or not runtime_action_id + or runtime_action_id != runtime_action_id.strip() + or len(runtime_action_id.encode("utf-8")) > 256 + ): + raise ValueError("invalid_runtime_action_id") + runtime_key = self._runtime_action_key(venue, row, runtime_action_id) + if runtime_key in self.runtime_action_bindings: + raise ValueError("runtime_action_id_collision") + target_runtime_id = row.get("runtime_order_id") + target = ( + self._runtime_order_binding( + venue, row.get("account_id"), target_runtime_id + ) + if target_runtime_id is not None + else None + ) + if target_runtime_id is not None and ( + target is None + or target.get("client_order_id") != client_id + or ( + target.get("managed_intent_id") is not None + and ( + not isinstance(row.get("managed_cancel_intent_id"), str) + or not row.get("managed_cancel_intent_id") + or row.get("managed_cancel_intent_id") + != row.get("managed_cancel_intent_id").strip() + or len(row.get("managed_cancel_intent_id").encode("utf-8")) + > 256 + ) + ) + ): + raise ValueError("runtime_action_target_conflict") + managed_cancel_intent_id = row.get("managed_cancel_intent_id") + if managed_cancel_intent_id is not None and any( + key[:4] == runtime_key[:4] + and item.get("managed_cancel_intent_id") == managed_cancel_intent_id + for key, item in self.runtime_action_bindings.items() + ): + raise ValueError("managed_cancel_intent_id_collision") + self.runtime_action_bindings[runtime_key] = { + "exchange_name": venue, + "account_id": row.get("account_id"), + "strategy_id": row.get("strategy_id"), + "connection_generation": row.get("connection_generation"), + "runtime_action_id": runtime_action_id, + "runtime_order_id": target_runtime_id, + "client_order_id": client_id, + "managed_cancel_intent_id": managed_cancel_intent_id, + } + if target_runtime_id is not None: + attempt_key = self._runtime_order_key(venue, row, target_runtime_id) + self._runtime_action_attempts[attempt_key] += 1 state = self._state(venue, row, create=True) self._identity( state, @@ -3790,6 +4111,11 @@ def _load_journal(self): } } state["terminal"] = bool(row.get("terminal_confirmed")) + if state.get("runtime_order_id"): + runtime_key = self._runtime_order_key(venue, row, state["runtime_order_id"]) + binding = self.runtime_order_bindings.get(runtime_key) + if binding is not None: + binding["status"] = "terminal" if state["terminal"] else "unresolved" if state["terminal"]: self.historical_unknown.difference_update(state["recovery_ids"]) if row.get("fee_unresolved"): @@ -3865,17 +4191,43 @@ def _journal(self, event, row, *, allow_read_only=False): if self.path is None or (self.config["market_data_only"] and not allow_read_only): return try: + ctp_journal_event = ( + self._provider(row.get("exchange_name")) == "CTP" + or event in _CTP_AUTHORIZATION_EVENTS + ) + windows_ctp_journal = os.name == "nt" and ctp_journal_event + if ctp_journal_event: + _require_windows_ctp_journal_preprovision( + self.path, + self.config, + journal_identity_at_open=self._journal_file_identity_at_open, + ) self._assert_writer_lease("journal") created = False - try: + if windows_ctp_journal: fd = os.open( str(self.path), - os.O_APPEND | os.O_CREAT | os.O_EXCL | os.O_WRONLY, - 0o600, + os.O_APPEND | os.O_WRONLY, ) - created = True - except FileExistsError: - fd = os.open(str(self.path), os.O_APPEND | os.O_WRONLY, 0o600) + try: + _verify_windows_ctp_journal_identity( + self.path, + fd, + self._journal_file_identity_at_open, + ) + except Exception: + os.close(fd) + raise + else: + try: + fd = os.open( + str(self.path), + os.O_APPEND | os.O_CREAT | os.O_EXCL | os.O_WRONLY, + 0o600, + ) + created = True + except FileExistsError: + fd = os.open(str(self.path), os.O_APPEND | os.O_WRONLY, 0o600) with os.fdopen(fd, "a") as stream: venue = row.get("exchange_name") ledger_identity = ( @@ -3926,6 +4278,12 @@ def _journal(self, event, row, *, allow_read_only=False): stream.write(json.dumps(envelope, allow_nan=False) + "\n") stream.flush() os.fsync(stream.fileno()) + if windows_ctp_journal: + _verify_windows_ctp_journal_identity( + self.path, + stream.fileno(), + self._journal_file_identity_at_open, + ) if created and os.name != "nt": flags = os.O_RDONLY | getattr(os, "O_DIRECTORY", 0) directory_fd = os.open(str(self.path.parent), flags) @@ -4269,6 +4627,7 @@ def _revoke_arm(self, reason, *, generation=None): self._recovery_authorized_plan = None self._recovery_remaining_plan = None self._recovery_write_guard = None + self._entry_write_guard = None self._recovery_budget_request = None self._recovery_budget_enforced = False self._recovery_budget_capability = None @@ -4330,6 +4689,7 @@ def prepare_execution_authorization(self, reason="execution_authorization_prepar self._arm_state_reader = None self._ctp_execution_authorization_context = None self._recovery_mode = False + self._entry_write_guard = None if self._arm_revoked_generation is None: self._arm_revoked_reason = None self._arm_revoked_error_code = None @@ -4687,6 +5047,7 @@ def prepare_recovery(self, proof, state_reader, *, venue=None): self._arm_proof_sha256 = None self._arm_state_reader = None self._recovery_mode = False + self._entry_write_guard = None self._recovery_refresh_in_progress = True self._recovery_plan = None self._recovery_authorized_plan = None @@ -4808,9 +5169,10 @@ def note_private_ingress(self, venue, ordered_after_write=False): # write belongs to pre-arm state. Close the SDK lease now; # the queue owner closes the native gate under its transition lock. self.config["market_data_only"] = True - self._arm_proof_sha256 = None - self._recovery_mode = False - self._recovery_refresh_in_progress = True + self._arm_proof_sha256 = None + self._recovery_mode = False + self._entry_write_guard = None + self._recovery_refresh_in_progress = True return should_revoke @staticmethod @@ -5635,6 +5997,7 @@ def _build_bundle_recovery_plan( "client_order_id": self._recovery_value( order, "client_order_id", "OrderRef", "order_ref" ), + "runtime_order_id": self._recovery_value(intent, "runtime_order_id"), "order_id": self._recovery_value( order, "order_id", "OrderSysID", "venue_order_id" ), @@ -6053,6 +6416,7 @@ def build_recovery_plan( "client_order_id": self._recovery_value( order, "client_order_id", "OrderRef", "order_ref" ), + "runtime_order_id": self._recovery_value(intent, "runtime_order_id"), "order_id": self._recovery_value( order, "order_id", "OrderSysID", "venue_order_id" ), @@ -6416,6 +6780,7 @@ def complete_recovery(self, recovery_token_sha256, snapshot, barrier): self._recovery_refresh_in_progress = False self._recovery_plan = verified self._recovery_completed = True + self._entry_write_guard = None return { "completed": True, "armed": False, @@ -6435,6 +6800,7 @@ def pause_recovery(self): self._recovery_remaining_plan = None self._recovery_refresh_in_progress = True self._recovery_write_guard = None + self._entry_write_guard = None self._recovery_budget_request = None self._recovery_budget_enforced = False self._recovery_budget_capability = None @@ -6520,10 +6886,80 @@ def finalize_recovery_dispatch(self, context): ) from None def finalize_dispatch(self, context): - """Run the accepted U1b recovery gate and the bounded O2 gate.""" + """Run the CTP authorization and bounded budget gates at dispatch.""" self.finalize_recovery_dispatch(context) + self.finalize_entry_dispatch(context) self.finalize_budget_dispatch(context) + def finalize_entry_dispatch(self, context): + """Revalidate signed entry authority at the last SDK transport boundary. + + BtApi's synchronous and asynchronous managed CTP submit/cancel paths + route through ``finalize_dispatch`` before the direct backend hands the + request to the CTP feed. Recovery remains under its separate guard. + A legacy or otherwise unguarded ordinary arm may prepare local ledger + state, but it cannot cross this boundary to a native request. + """ + + if not isinstance(context, Mapping): + raise NormalizedApiError( + "execution_dispatch", + "ctp_entry_authorization_guard_invalid", + definite_reject=True, + ) + operation = context.get("operation") + if operation not in {"make_order", "cancel_order"} or context.get("recovery_action"): + return + with self.mutex: + guard = self._entry_write_guard + if ( + guard is None + and self._arm_managed + and not self._recovery_mode + and self._provider(self._arm_venue) == "CTP" + and context.get("_native_ctp_entry_guard_required") is True + ): + self._revoke_arm("ctp_entry_authorization_guard_invalid") + raise NormalizedApiError( + operation, + "ctp_entry_authorization_guard_invalid", + definite_reject=True, + ) + if guard is None: + return + if context.get("_async_handoff") and ( + self._active_managed_write_context is not context + ): + self._revoke_arm("ctp_entry_authorization_guard_invalid") + raise NormalizedApiError( + operation, + "ctp_entry_authorization_guard_invalid", + definite_reject=True, + ) + if not self._arm_managed or self._recovery_mode: + self._revoke_arm("ctp_entry_authorization_guard_invalid") + raise NormalizedApiError( + operation, + "ctp_entry_authorization_guard_invalid", + definite_reject=True, + ) + try: + guard.validate( + operation, + placement=operation == "make_order", + recovery_action=False, + ) + except NormalizedApiError as exc: + self._revoke_arm(exc.code) + raise + except Exception: + self._revoke_arm("ctp_entry_authorization_guard_invalid") + raise NormalizedApiError( + operation, + "ctp_entry_authorization_guard_invalid", + definite_reject=True, + ) from None + def set_recovery_ingress_fence(self, epoch, ingress_revision, event_revision): """Remember the private-event fence established by the native arm.""" @@ -6587,6 +7023,7 @@ def arm_from_preflight( rollback_execution=None, prepare_execution_outside_mutex=False, authorization_context=None, + execution_write_guard=None, ): """Atomically convert one read-only session to durable execution. @@ -6603,6 +7040,7 @@ def arm_from_preflight( rollback_execution=rollback_execution, prepare_execution_outside_mutex=prepare_execution_outside_mutex, authorization_context=authorization_context, + execution_write_guard=execution_write_guard, ) def _arm_from_preflight( @@ -6616,6 +7054,7 @@ def _arm_from_preflight( prepare_execution_outside_mutex=False, _recovery_capability=None, authorization_context=None, + execution_write_guard=None, ): recovery_arm = _recovery_capability is self._recovery_arm_capability operation = "arm_execution_from_preflight" @@ -6627,6 +7066,19 @@ def _arm_from_preflight( previous_venue = self._arm_venue try: normalized, proof_sha256 = _execution_arm_proof(proof) + if execution_write_guard is not None and ( + recovery_arm + or not _is_ctp_entry_write_guard( + execution_write_guard, + session=self, + ) + or execution_write_guard._proof_sha256 != proof_sha256 + ): + raise NormalizedApiError( + operation, + "ctp_entry_authorization_guard_invalid", + definite_reject=True, + ) if authorization_context is not None and ( not isinstance(authorization_context, Mapping) or set(authorization_context) @@ -6814,6 +7266,7 @@ def _arm_from_preflight( self._ctp_execution_authorization_context = ( dict(authorization_context) if authorization_context is not None else None ) + self._entry_write_guard = execution_write_guard self._arm_revoked_reason = None self._arm_revoked_error_code = None self._arm_submit_calls = self.submit_calls @@ -6890,6 +7343,24 @@ def require_write( code = "unresolved_or_undurable_journal" elif placement and self.config["require_order_journal"] and self.path is None: code = "order_journal_required" + elif placement and any( + binding.get("loaded_from_journal") is True + and binding.get("status") == "reservation_only" + and binding.get("exchange_name") == venue + and binding.get("strategy_id") == self.config["strategy_id"] + and ( + not isinstance(self._ctp_execution_identity, Mapping) + or binding.get("account_id") + == self._ctp_execution_identity.get("account_id") + ) + for binding in self.runtime_order_bindings.values() + ): + # The reservation proves the old request never reached + # dispatch, so its OrderRef is a safe burn rather than an + # UNKNOWN order. The runtime order identity is nevertheless + # orphaned; require reviewed reconciliation before replacing + # it with a fresh framework action. + code = "runtime_order_binding_recovery_required" elif ( placement and not recovery_action @@ -6922,6 +7393,28 @@ def require_write( "ctp_recovery_authorization_invalid", definite_reject=True, ) from None + if ( + code is None + and self._arm_managed + and not self._recovery_mode + and self._entry_write_guard is not None + ): + try: + self._entry_write_guard.validate( + operation, + placement=placement, + recovery_action=recovery_action, + ) + except NormalizedApiError as exc: + self._revoke_arm(exc.code) + raise + except Exception: + self._revoke_arm("ctp_entry_authorization_guard_invalid") + raise NormalizedApiError( + operation, + "ctp_entry_authorization_guard_invalid", + definite_reject=True, + ) from None if ( code is None and self._recovery_mode @@ -7670,6 +8163,344 @@ def new_client_order_id(self, venue, account_id=None, strategy_id=None): self.reserved_ids.add(key) return result + def new_runtime_order_binding( + self, + venue, + *, + symbol, + account_id=None, + managed_intent_id=None, + runtime_order_id=None, + budget_capability=None, + recovery_action=False, + ): + """Durably reserve one runtime identity and its native CTP OrderRef together.""" + with self.mutex: + if self._provider(venue) != "CTP": + raise NormalizedApiError( + "new_runtime_order_binding", + "ctp_runtime_order_binding_required", + definite_reject=True, + ) + if type(recovery_action) is not bool: + raise NormalizedApiError( + "new_runtime_order_binding", + "invalid_recovery_action", + definite_reject=True, + ) + self.require_write( + "new_runtime_order_binding", + placement=True, + venue=venue, + recovery_action=recovery_action, + ) + if self._arm_managed: + budget_state = self._require_budget_reservation_locked( + budget_capability, + operation="new_runtime_order_binding", + mode=("recovery" if recovery_action else "ordinary"), + ) + self._budget_context_matches_current_locked( + budget_state, operation="new_runtime_order_binding" + ) + if not isinstance(symbol, str) or not symbol or symbol != symbol.strip(): + raise NormalizedApiError( + "new_runtime_order_binding", + "invalid_runtime_order_symbol", + definite_reject=True, + ) + if managed_intent_id is not None and ( + not isinstance(managed_intent_id, str) + or not managed_intent_id + or managed_intent_id != managed_intent_id.strip() + or len(managed_intent_id.encode("utf-8")) > 256 + ): + raise NormalizedApiError( + "new_runtime_order_binding", + "invalid_managed_intent_id", + definite_reject=True, + ) + generation = ( + self._arm_proof.get("connection_generation") + if isinstance(self._arm_proof, Mapping) + else None + ) + if type(generation) is not int or generation <= 0: + raise NormalizedApiError( + "new_runtime_order_binding", + "ctp_runtime_order_generation_unavailable", + definite_reject=True, + ) + ledger = self._ledger_identity(venue, account_id) + canonical_account = ledger["account_id"] + strategy_id = self.config["strategy_id"] + if runtime_order_id is None: + runtime_order_id = uuid.uuid4().hex + elif ( + not isinstance(runtime_order_id, str) + or not runtime_order_id + or runtime_order_id != runtime_order_id.strip() + or len(runtime_order_id.encode("utf-8")) > 256 + ): + raise NormalizedApiError( + "new_runtime_order_binding", "invalid_runtime_order_id", definite_reject=True + ) + runtime_key = self._runtime_order_key( + venue, + { + "account_id": canonical_account, + "strategy_id": strategy_id, + "connection_generation": generation, + }, + runtime_order_id, + ) + if managed_intent_id is not None and any( + key[:4] == runtime_key[:4] + and key != runtime_key + and binding.get("managed_intent_id") == managed_intent_id + for key, binding in self.runtime_order_bindings.items() + ): + raise NormalizedApiError( + "new_runtime_order_binding", + "managed_intent_runtime_order_conflict", + definite_reject=True, + ) + existing = self.runtime_order_bindings.get(runtime_key) + if existing is not None: + if ( + existing.get("status") == "reserved" + and existing.get("loaded_from_journal") is False + and existing.get("managed_intent_id") == managed_intent_id + and existing.get("symbol") == symbol + and existing.get("connection_generation") == generation + ): + result = { + "runtime_order_id": runtime_order_id, + "client_order_id": existing["client_order_id"], + "ctp_order_ref": existing["client_order_id"], + "connection_generation": generation, + } + if managed_intent_id is not None: + result["managed_intent_id"] = managed_intent_id + return result + raise NormalizedApiError( + "new_runtime_order_binding", + "runtime_order_id_collision_or_recovery_required", + definite_reject=True, + ) + candidate = time.time_ns() % 10**12 + client_id = f"{candidate:012d}" + client_key = self._client_key(venue, canonical_account, client_id) + while client_key in self.used_ids | self.reserved_ids: + candidate = (candidate + 1) % 10**12 + client_id = f"{candidate:012d}" + client_key = self._client_key(venue, canonical_account, client_id) + row = { + "exchange_name": venue, + "account_id": canonical_account, + "client_order_id": client_id, + "runtime_order_id": runtime_order_id, + "managed_intent_id": managed_intent_id, + "symbol": symbol, + "strategy_id": strategy_id, + "connection_generation": generation, + } + runtime_key = self._runtime_order_key(venue, row, runtime_order_id) + # This one fsynced record is the allocation boundary. A crash after + # return but before intent leaves a durable safe-burn mapping; no + # native dispatch can have happened because intents fsync first. + self._journal("client_id_reservation", row) + self.reserved_ids.add(client_key) + self.runtime_order_bindings[runtime_key] = { + "exchange_name": venue, + "account_id": canonical_account, + "strategy_id": strategy_id, + "connection_generation": generation, + "trading_day": self._arm_proof.get("trading_day"), + "runtime_order_id": runtime_order_id, + "managed_intent_id": managed_intent_id, + "client_order_id": client_id, + "symbol": symbol, + "status": "reserved", + "loaded_from_journal": False, + } + result = { + "runtime_order_id": runtime_order_id, + "client_order_id": client_id, + "ctp_order_ref": client_id, + "connection_generation": generation, + } + if managed_intent_id is not None: + result["managed_intent_id"] = managed_intent_id + return result + + def get_runtime_order_bindings(self, venue, *, unresolved_only=True, runtime_order_id=None): + """List durable CTP runtime/OrderRef joins for the authenticated scope only.""" + if type(unresolved_only) is not bool: + raise NormalizedApiError( + "get_runtime_order_bindings", "invalid_unresolved_only", definite_reject=True + ) + with self.mutex: + if self._provider(venue) != "CTP": + raise NormalizedApiError( + "get_runtime_order_bindings", + "ctp_runtime_order_binding_required", + definite_reject=True, + ) + if venue != self._arm_venue or not isinstance(self._ctp_execution_identity, Mapping): + raise NormalizedApiError( + "get_runtime_order_bindings", + "ctp_runtime_order_scope_unavailable", + definite_reject=True, + ) + generation = ( + self._arm_proof.get("connection_generation") + if isinstance(self._arm_proof, Mapping) + else None + ) + if type(generation) is not int or generation <= 0: + raise NormalizedApiError( + "get_runtime_order_bindings", + "ctp_runtime_order_generation_unavailable", + definite_reject=True, + ) + identity = self._ledger_identity(venue) + ledger_key = self._ledger_key(identity) + strategy_id = self.config["strategy_id"] + result = [] + for key, binding in self.runtime_order_bindings.items(): + if ( + key[:3] != ledger_key + or key[3] != strategy_id + or binding.get("exchange_name") != venue + or binding.get("connection_generation") != generation + or ( + runtime_order_id is not None + and binding.get("runtime_order_id") != runtime_order_id + ) + or (unresolved_only and binding.get("status") == "terminal") + ): + continue + status = binding.get("status") + item = { + "runtime_order_id": binding["runtime_order_id"], + "client_order_id": binding["client_order_id"], + "ctp_order_ref": binding["client_order_id"], + "symbol": binding.get("symbol"), + "connection_generation": generation, + "trading_day": binding.get("trading_day"), + "status": status, + "safe_burn": status == "reservation_only", + "recovery_required": status == "reservation_only" + or (status == "unresolved" and binding.get("loaded_from_journal") is True), + } + if binding.get("managed_intent_id") is not None: + item["managed_intent_id"] = binding["managed_intent_id"] + result.append(item) + return sorted(result, key=lambda item: item["runtime_order_id"]) + + def next_runtime_action_id(self, venue, *, account_id, runtime_order_id): + """Return the stable ID for this order's next journaled cancel attempt.""" + with self.mutex: + if self._provider(venue) != "CTP": + raise NormalizedApiError( + "next_runtime_action_id", + "ctp_runtime_action_binding_required", + definite_reject=True, + ) + if not isinstance(runtime_order_id, str) or not runtime_order_id: + raise NormalizedApiError( + "next_runtime_action_id", "invalid_runtime_order_id", definite_reject=True + ) + identity = self._ledger_identity(venue, account_id) + binding_key = ( + *self._ledger_key(identity), + self.config["strategy_id"], + runtime_order_id, + ) + binding = self.runtime_order_bindings.get(binding_key) + if binding is None: + raise NormalizedApiError( + "next_runtime_action_id", + "runtime_order_binding_unknown", + definite_reject=True, + ) + attempt = self._runtime_action_attempts[binding_key] + 1 + scope_digest = hashlib.sha256( + json.dumps(binding_key, separators=(",", ":")).encode("utf-8") + ).hexdigest() + return uuid.uuid5( + uuid.NAMESPACE_URL, + f"bt_api_py:ctp-cancel:{scope_digest}:{attempt}", + ).hex + + def get_runtime_action_bindings(self, venue, *, runtime_order_id=None): + """List durable CTP cancel attempts in the active authenticated scope.""" + if runtime_order_id is not None and ( + not isinstance(runtime_order_id, str) + or not runtime_order_id + or runtime_order_id != runtime_order_id.strip() + or len(runtime_order_id.encode("utf-8")) > 256 + ): + raise NormalizedApiError( + "get_runtime_action_bindings", + "invalid_runtime_order_id", + definite_reject=True, + ) + with self.mutex: + if self._provider(venue) != "CTP": + raise NormalizedApiError( + "get_runtime_action_bindings", + "ctp_runtime_action_binding_required", + definite_reject=True, + ) + if venue != self._arm_venue or not isinstance(self._ctp_execution_identity, Mapping): + raise NormalizedApiError( + "get_runtime_action_bindings", + "ctp_runtime_action_scope_unavailable", + definite_reject=True, + ) + generation = ( + self._arm_proof.get("connection_generation") + if isinstance(self._arm_proof, Mapping) + else None + ) + if type(generation) is not int or generation <= 0: + raise NormalizedApiError( + "get_runtime_action_bindings", + "ctp_runtime_action_generation_unavailable", + definite_reject=True, + ) + identity = self._ledger_identity(venue) + ledger_key = self._ledger_key(identity) + strategy_id = self.config["strategy_id"] + result = [] + for key, binding in self.runtime_action_bindings.items(): + if ( + key[:3] != ledger_key + or key[3] != strategy_id + or binding.get("exchange_name") != venue + or binding.get("connection_generation") != generation + or ( + runtime_order_id is not None + and binding.get("runtime_order_id") != runtime_order_id + ) + ): + continue + item = { + "runtime_action_id": binding["runtime_action_id"], + "runtime_order_id": binding["runtime_order_id"], + "client_order_id": binding["client_order_id"], + "connection_generation": generation, + } + if binding.get("managed_cancel_intent_id") is not None: + item["managed_cancel_intent_id"] = binding["managed_cancel_intent_id"] + result.append(item) + return sorted( + result, + key=lambda item: (item["runtime_order_id"], item["runtime_action_id"]), + ) + def _state(self, venue, row, *, create=False): client_id = str(row.get("client_order_id") or row.get("order_ref") or "") account_id = self._ledger_identity(venue, row.get("account_id"), row)["account_id"] @@ -7941,6 +8772,47 @@ def _order_update(self, state, row): ): if key in previous: result[key] = previous[key] + for key in ("cancel_action_id", "native_request_id", "order_action_ref", "cancel_status"): + value = row.get(key) + if key == "cancel_status": + if value in {"accepted", "rejected", "unknown"}: + result[key] = value + elif ( + isinstance(value, str) + and 0 < len(value) <= 256 + or isinstance(value, int) + and not isinstance(value, bool) + and value >= 0 + ): + result[key] = value + cancel_evidence = row.get("cancel_evidence") + if isinstance(cancel_evidence, Mapping): + safe_evidence = {} + for key in ( + "request_id", + "order_action_ref", + "status", + "account_fingerprint", + "trading_day", + "connection_generation", + "order_ref", + "order_sys_id", + "front_id", + "session_id", + "instrument_id", + "exchange_id", + "action_flag", + "evidence_source", + "evidence_received", + "callback_received", + "error_code", + "reason", + "observed_at_utc", + ): + value = cancel_evidence.get(key) + if value is None or isinstance(value, (str, int, bool)): + safe_evidence[key] = value + result["cancel_evidence"] = safe_evidence return result def _record( @@ -7974,6 +8846,12 @@ def _record( state["last_update"] = dict(update) state["_last_update_origin"] = origin state["_revision"] = state.get("_revision", 0) + 1 + runtime_order_id = state.get("runtime_order_id") + if runtime_order_id: + runtime_key = self._runtime_order_key(state["exchange_name"], state, runtime_order_id) + binding = self.runtime_order_bindings.get(runtime_key) + if binding is not None: + binding["status"] = "terminal" if state["terminal"] else "unresolved" if state["terminal"]: self.historical_unknown.discard(self._identifier(state)) self.historical_unknown.difference_update(state.get("recovery_ids", ())) @@ -8328,6 +9206,37 @@ def _begin_invoke( venue=venue, recovery_action=recovery_allowance is not None, ) + runtime_binding = None + if self._provider(venue) == "CTP" and self._arm_managed: + runtime_order_id = getattr(request, "runtime_order_id", None) + if ( + not isinstance(runtime_order_id, str) + or not runtime_order_id + or runtime_order_id != runtime_order_id.strip() + or len(runtime_order_id.encode("utf-8")) > 256 + ): + raise NormalizedApiError( + operation, + "ctp_runtime_order_identity_required", + definite_reject=True, + ) + runtime_key = self._runtime_order_key(venue, request_row, runtime_order_id) + runtime_binding = self.runtime_order_bindings.get(runtime_key) + current_generation = self._arm_proof.get("connection_generation") + if ( + runtime_binding is None + or runtime_binding.get("client_order_id") != request.client_order_id + or runtime_binding.get("managed_intent_id") + != getattr(request, "managed_intent_id", None) + or runtime_binding.get("symbol") != request.symbol + or runtime_binding.get("connection_generation") != current_generation + or runtime_binding.get("status") != "reserved" + ): + raise NormalizedApiError( + operation, + "ctp_runtime_order_binding_conflict", + definite_reject=True, + ) if self._provider(venue) == "CTP" and self._arm_managed: if budget_capability is None and recovery_allowance is not None: budget_capability = self._recovery_budget_capability @@ -8360,6 +9269,8 @@ def _begin_invoke( if preauthorize is not None: preauthorize() self._journal("intent", row) + if runtime_binding is not None: + runtime_binding["status"] = "unresolved" if budget_state is not None: budget_action_id = f"order:{request.client_order_id}" self.bind_ctp_budget_action( @@ -8380,6 +9291,7 @@ def _begin_invoke( if operation == "cancel_order": self.require_write(operation, venue=venue) self._require_arm_scope(operation, venue, request.symbol, request.exchange_id) + runtime_action_key = None if self._arm_managed: tracked = self._state(venue, request_row, create=False) if not any(tracked is item for item in self.orders.values()): @@ -8405,6 +9317,70 @@ def _begin_invoke( recovery_action=True, ) if self._provider(venue) == "CTP" and self._arm_managed: + runtime_order_id = getattr(request, "runtime_order_id", None) + runtime_action_id = getattr(request, "runtime_action_id", None) + if ( + not isinstance(runtime_order_id, str) + or not runtime_order_id + or runtime_order_id != runtime_order_id.strip() + or len(runtime_order_id.encode("utf-8")) > 256 + or not isinstance(runtime_action_id, str) + or not runtime_action_id + or runtime_action_id != runtime_action_id.strip() + or len(runtime_action_id.encode("utf-8")) > 256 + ): + raise NormalizedApiError( + operation, + "ctp_runtime_cancel_identity_required", + definite_reject=True, + ) + target = self._runtime_order_binding( + venue, request.account_id, runtime_order_id + ) + expected_action_id = self.next_runtime_action_id( + venue, + account_id=request.account_id, + runtime_order_id=runtime_order_id, + ) + if ( + target is None + or target.get("client_order_id") + != (request.client_order_id or request.order_ref) + or target.get("symbol") != request.symbol + or tracked.get("runtime_order_id") != runtime_order_id + or runtime_action_id != expected_action_id + or ( + target.get("managed_intent_id") is not None + and not getattr(request, "managed_cancel_intent_id", None) + ) + ): + raise NormalizedApiError( + operation, + "ctp_runtime_cancel_binding_conflict", + definite_reject=True, + ) + runtime_action_key = self._runtime_action_key( + venue, request_row, runtime_action_id + ) + if runtime_action_key in self.runtime_action_bindings: + raise NormalizedApiError( + operation, + "duplicate_runtime_action_id", + definite_reject=True, + ) + managed_cancel_intent_id = getattr( + request, "managed_cancel_intent_id", None + ) + if managed_cancel_intent_id is not None and any( + key[:4] == runtime_action_key[:4] + and item.get("managed_cancel_intent_id") == managed_cancel_intent_id + for key, item in self.runtime_action_bindings.items() + ): + raise NormalizedApiError( + operation, + "duplicate_managed_cancel_intent_id", + definite_reject=True, + ) if budget_capability is None and recovery_allowance is not None: budget_capability = self._recovery_budget_capability budget_state = self._require_budget_reservation_locked( @@ -8429,6 +9405,25 @@ def _begin_invoke( preauthorize() try: self._journal("cancel_intent", self._identity(state, request_row)) + if runtime_action_key is not None: + self.runtime_action_bindings[runtime_action_key] = { + "exchange_name": venue, + "account_id": request.account_id, + "strategy_id": self.config["strategy_id"], + "connection_generation": ( + self._arm_proof.get("connection_generation") + if isinstance(self._arm_proof, Mapping) + else None + ), + "runtime_action_id": request.runtime_action_id, + "runtime_order_id": request.runtime_order_id, + "client_order_id": request.client_order_id, + "managed_cancel_intent_id": managed_cancel_intent_id, + } + runtime_order_key = self._runtime_order_key( + venue, request_row, request.runtime_order_id + ) + self._runtime_action_attempts[runtime_order_key] += 1 if budget_state is not None: budget_action_id = f"cancel:{request.order_id or request.client_order_id or request.symbol}" self.bind_ctp_budget_action( @@ -8440,6 +9435,8 @@ def _begin_invoke( except NormalizedApiError as exc: if exc.code != "persistence_failed": raise + if self._provider(venue) == "CTP" and self._arm_managed: + raise # Once durable state is unavailable, placements remain # blocked but a cancel request is still a necessary # exposure-reduction action. Its result remains marked @@ -8604,6 +9601,9 @@ async def async_invoke( preauthorize=preauthorize, budget_capability=budget_capability, ) + if on_context is not None: + with self.mutex: + self._active_managed_write_context = context if context["recovery_action"]: with self.mutex: self._active_recovery_context = context @@ -8635,6 +9635,10 @@ async def async_invoke( if self._active_recovery_context is context: self._active_recovery_context = None self._recovery_dispatch_in_progress = False + if on_context is not None: + with self.mutex: + if self._active_managed_write_context is context: + self._active_managed_write_context = None def event(self, venue, event): if self.closed: diff --git a/bt_api_py/_normalization.py b/bt_api_py/_normalization.py index 687c671e..0388849b 100644 --- a/bt_api_py/_normalization.py +++ b/bt_api_py/_normalization.py @@ -2232,6 +2232,69 @@ def trade(row, exchange_name, symbol=None): return result +def _ctp_cancel_metadata(result, request): + """Project only bounded native action identity/evidence into the SDK row.""" + getter = getattr(result, "get_extra_data", None) + if not callable(getter): + return {} + try: + extra = getter() + except Exception: + return {} + if not isinstance(extra, dict): + return {} + envelope = extra.get("ctp_cancel") + if not isinstance(envelope, dict): + return {} + evidence = envelope.get("evidence") + if not isinstance(evidence, dict): + as_dict = getattr(evidence, "as_dict", None) + if callable(as_dict): + try: + evidence = as_dict() + except Exception: + evidence = None + safe_fields = ( + "request_id", + "order_action_ref", + "status", + "account_fingerprint", + "trading_day", + "connection_generation", + "order_ref", + "order_sys_id", + "front_id", + "session_id", + "instrument_id", + "exchange_id", + "action_flag", + "evidence_source", + "evidence_received", + "callback_received", + "error_code", + "reason", + "observed_at_utc", + ) + projected = ( + {key: evidence[key] for key in safe_fields if key in evidence} + if isinstance(evidence, dict) + else {} + ) + # CtpRequestData exposes a redacted dictionary, not the immutable native + # evidence object. Without the raw account binding this cannot be promoted + # into matching callback evidence at the public normalization boundary. + status = "unknown" + if projected: + projected["status"] = status + return { + "cancel_action_id": getattr(request, "idempotency_key", "") or "", + "native_request_id": envelope.get("request_id"), + "order_action_ref": envelope.get("order_action_ref"), + "cancel_status": status, + "cancel_evidence": projected, + } + + def normalize_result(operation, result, exchange_name, symbol=None, request=None): source = rows( result, @@ -2313,7 +2376,20 @@ def normalize_result(operation, result, exchange_name, symbol=None, request=None if operation in {"make_order", "query_order", "cancel_order"}: if not source: raise ValueError("order_response_missing") - return order(source[0], exchange_name, symbol, request, operation) + normalized = order(source[0], exchange_name, symbol, request, operation) + if operation == "cancel_order" and str(exchange_name).startswith("CTP___"): + cancel_metadata = _ctp_cancel_metadata(result, request) + normalized.update(cancel_metadata) + # An order-action callback only resolves that request, never the + # resting order's state. Even a matching rejection does not prove + # the current order state; a separate typed order query must do so. + normalized.update( + status="submitted", + execution_unknown=True, + terminal_confirmed=False, + definite_reject=False, + ) + return normalized if operation == "get_open_orders": return [order(row, exchange_name, symbol) for row in source] if operation == "get_deals": diff --git a/bt_api_py/_venue_mappers/ctp.py b/bt_api_py/_venue_mappers/ctp.py index 1e29b06b..0b541995 100644 --- a/bt_api_py/_venue_mappers/ctp.py +++ b/bt_api_py/_venue_mappers/ctp.py @@ -44,4 +44,10 @@ def map_order_request(request: OrderRequest) -> dict[str, Any]: } if request.exchange_id is not None: result["exchange_id"] = request.exchange_id + if request.hedge_flag is not None: + result["hedge_flag"] = request.hedge_flag + if request.managed_intent_id is not None: + result["managed_intent_id"] = request.managed_intent_id + if request.runtime_order_id is not None: + result["runtime_order_id"] = request.runtime_order_id return result diff --git a/bt_api_py/bt_api.py b/bt_api_py/bt_api.py index 7cb96796..87e808b7 100644 --- a/bt_api_py/bt_api.py +++ b/bt_api_py/bt_api.py @@ -66,14 +66,21 @@ RECOVERY_APPROVAL_PURPOSE, RECOVERY_APPROVAL_SCHEMA_VERSION, RECOVERY_APPROVAL_SCOPE_VERSION, + SIMNOW_APPROVAL_SCHEMA_VERSION, + SIMNOW_ENTRY_APPROVAL_SCHEMA_VERSION, + SIMNOW_RECOVERY_APPROVAL_SCHEMA_VERSION, CtpExecutionApproval, CtpExecutionApprovalCapability, CtpExecutionApprovalContext, + _is_ctp_entry_write_guard, + _is_ctp_execution_approval_capability, _jsonable, _new_capability, + _new_ctp_entry_write_guard, _new_runtime_context, _normalize_context, _refresh_runtime_context, + _thaw, current_ctp_execution_revocation_snapshot, recovery_action_digest, recovery_plan_digest, @@ -89,6 +96,22 @@ DATANAME_SEPARATOR = "___" _NORMALIZED_WRITE_OPERATIONS = frozenset({"make_order", "cancel_order", "set_position_mode"}) +_SIMNOW_BOUND_PROFILES = frozenset( + {"config_front_pair", "set1_group1", "set1_group2"} +) +_SIMNOW_RESTRICTED_PROFILES = _SIMNOW_BOUND_PROFILES | frozenset( + { + "set2_7x24", + "set1_group1_vpn", + "set2_7x24_4000x", + "set2_7x24_vpn", + "set1", + "set2", + } +) +_CTP_CREDENTIAL_BINDING_FIELDS = frozenset( + {"credential_binding_key_id", "credential_binding_hmac_sha256"} +) _CTP_TRANSITION_LOCK_INIT = threading.Lock() _CTP_INTERNAL_AUTHORIZATION_SEAL = object() _CTP_CONTROLLED_TEST_AUTHORITY_SEAL = object() @@ -176,6 +199,7 @@ class _CtpExecutionArmAuthorization: "_preflight_epoch", "_used", "_approval_capability", + "_entry_write_guard", "_recovery_plan_sha256", "_recovery_action_sha256", "_recovery_token_sha256", @@ -193,6 +217,7 @@ def __init__( execution_cycle_id: str, preflight_epoch: int, approval_capability: CtpExecutionApprovalCapability | None = None, + entry_write_guard: object | None = None, recovery_plan_sha256: str | None = None, recovery_action_sha256: str | None = None, recovery_token_sha256: str | None = None, @@ -208,6 +233,7 @@ def __init__( self._preflight_epoch = preflight_epoch self._used = False self._approval_capability = approval_capability + self._entry_write_guard = entry_write_guard self._recovery_plan_sha256 = recovery_plan_sha256 self._recovery_action_sha256 = recovery_action_sha256 self._recovery_token_sha256 = recovery_token_sha256 @@ -655,7 +681,7 @@ def wrapped(self: Any, *args: Any, **kwargs: Any) -> Any: def _canonical_ctp_account_fingerprint(value: Any) -> str: """Map the native short hash to the public receipt identity.""" fingerprint = str(value or "").strip().lower() - digest = fingerprint.removeprefix("acct_") + digest = fingerprint[5:] if fingerprint.startswith("acct_") else fingerprint if len(digest) != 16 or any(character not in "0123456789abcdef" for character in digest): return "" return f"acct_{digest}" @@ -1108,6 +1134,184 @@ def new_client_order_id( return f"{time.time_ns() % 10**12:012d}" + def new_runtime_order_binding( + self, + exchange_name: str, + *, + symbol: str, + account_id: str | None = None, + managed_intent_id: str | None = None, + runtime_order_id: str | None = None, + budget_capability: Any = None, + recovery_action: bool = False, + ) -> dict[str, Any]: + """Atomically reserve a runtime order identity and its 12-digit CTP OrderRef.""" + session = self._execution_session + if session is None: + raise NormalizedApiError( + "new_runtime_order_binding", + "execution_session_required", + definite_reject=True, + ) + binding_options: dict[str, Any] = {} + if managed_intent_id is not None: + binding_options["managed_intent_id"] = managed_intent_id + return session.new_runtime_order_binding( + exchange_name, + symbol=symbol, + account_id=account_id, + runtime_order_id=runtime_order_id, + budget_capability=budget_capability, + recovery_action=recovery_action, + **binding_options, + ) + + @staticmethod + def _require_ctp_managed_order_identity( + operation: str, exchange_name: str, request: OrderRequest, session: Any + ) -> None: + if session is None or str(exchange_name).partition(DATANAME_SEPARATOR)[0].upper() != "CTP": + return + if not request.runtime_order_id: + # Preserve the older runtime-binding error from the session itself. + return + bindings = session.get_runtime_order_bindings( + exchange_name, + unresolved_only=False, + runtime_order_id=request.runtime_order_id, + ) + bound_intent_id = bindings[0].get("managed_intent_id") if len(bindings) == 1 else None + if ( + (bound_intent_id is not None and request.managed_intent_id != bound_intent_id) + or (request.managed_intent_id is not None and bound_intent_id is None) + or (request.managed_intent_id is not None and request.hedge_flag not in {"1", "2", "3"}) + ): + raise NormalizedApiError( + operation, + "ctp_managed_order_identity_mismatch", + definite_reject=True, + ) + + @staticmethod + def _bind_ctp_managed_cancel_identity( + exchange_name: str, request: CancelOrderRequest, session: Any + ) -> CancelOrderRequest: + if session is None or str(exchange_name).partition(DATANAME_SEPARATOR)[0].upper() != "CTP": + return request + if not request.runtime_order_id: + raise NormalizedApiError( + "cancel_order", + "ctp_runtime_cancel_identity_required", + definite_reject=True, + ) + bindings = session.get_runtime_order_bindings( + exchange_name, + unresolved_only=False, + runtime_order_id=request.runtime_order_id, + ) + if len(bindings) != 1: + raise NormalizedApiError( + "cancel_order", + "ctp_runtime_order_binding_unavailable", + definite_reject=True, + ) + binding = bindings[0] + if ( + binding.get("status") != "unresolved" + or not binding.get("client_order_id") + or ( + binding.get("managed_intent_id") is not None + and not request.managed_cancel_intent_id + ) + or ( + binding.get("managed_intent_id") is None + and request.managed_cancel_intent_id is not None + ) + ): + raise NormalizedApiError( + "cancel_order", + "ctp_managed_cancel_identity_required", + definite_reject=True, + ) + durable_order_ref = binding["ctp_order_ref"] + supplied_refs = ( + request.order_id, + request.client_order_id, + request.order_ref, + ) + if any(value is not None and value != durable_order_ref for value in supplied_refs): + raise NormalizedApiError( + "cancel_order", + "ctp_runtime_cancel_reference_mismatch", + definite_reject=True, + ) + if binding.get("managed_intent_id") is not None and ( + request.front_id is not None or request.session_id is not None + ): + raise NormalizedApiError( + "cancel_order", + "ctp_runtime_cancel_native_reference_forbidden", + definite_reject=True, + ) + runtime_action_id = session.next_runtime_action_id( + exchange_name, + account_id=request.account_id, + runtime_order_id=request.runtime_order_id, + ) + if request.runtime_action_id not in (None, runtime_action_id): + raise NormalizedApiError( + "cancel_order", + "ctp_runtime_cancel_action_id_mismatch", + definite_reject=True, + ) + return replace( + request, + order_id=None, + client_order_id=durable_order_ref, + order_ref=durable_order_ref, + runtime_action_id=runtime_action_id, + ) + + def get_runtime_order_bindings( + self, + exchange_name: str, + *, + unresolved_only: bool = True, + runtime_order_id: str | None = None, + ) -> list[dict[str, Any]]: + """Read persisted CTP runtime/OrderRef joins for the active account scope.""" + session = self._execution_session + if session is None: + raise NormalizedApiError( + "get_runtime_order_bindings", + "execution_session_required", + definite_reject=True, + ) + return session.get_runtime_order_bindings( + exchange_name, + unresolved_only=unresolved_only, + runtime_order_id=runtime_order_id, + ) + + def get_runtime_action_bindings( + self, + exchange_name: str, + *, + runtime_order_id: str | None = None, + ) -> list[dict[str, Any]]: + """Read persisted CTP cancel-attempt joins for the active account scope.""" + session = self._execution_session + if session is None: + raise NormalizedApiError( + "get_runtime_action_bindings", + "execution_session_required", + definite_reject=True, + ) + return session.get_runtime_action_bindings( + exchange_name, + runtime_order_id=runtime_order_id, + ) + def get_execution_identity(self, exchange_name: str) -> dict[str, Any]: """Return the SDK-owned ledger identity used for typed order requests.""" session = self._execution_session @@ -2442,6 +2646,9 @@ def _normalized_call( """Opt-in SDK result contract; never expose credentials in normalized errors.""" from ._normalization import normalize_error, normalize_result + def finalize_dispatch(context: Any) -> None: + self._finalize_ctp_execution_dispatch(session, exchange_name, context) + def invoke() -> Any: failure = None try: @@ -2483,6 +2690,8 @@ def invoke() -> Any: } if operation in {"get_position_mode", "get_account_config"}: raise CapabilityNotSupportedError(operation, detail="market-data-only session") + if operation in _NORMALIZED_WRITE_OPERATIONS: + self._reject_simnow_profile_write(exchange_name, operation) self._validate_required_environment(exchange_name, operation=operation) if session is not None and operation in { "make_order", @@ -2505,7 +2714,7 @@ def invoke() -> Any: request, invoke, preauthorize=acquire_write, - pre_dispatch=session.finalize_dispatch, + pre_dispatch=finalize_dispatch, budget_capability=budget_capability, ) finally: @@ -2532,6 +2741,26 @@ def invoke() -> Any: # exception chaining before it crosses the public API boundary. raise failure from None + def _finalize_ctp_execution_dispatch( + self, + session: Any, + exchange_name: str, + context: Any, + ) -> None: + """Run the last SDK gate and require a sealed guard for every managed CTP write. + + Feed implementation details cannot weaken the SDK boundary: legacy and + custom CTP feeds are subject to the same entry authorization check as + the bundled native feed. Recovery dispatch keeps its separate guard. + """ + + if ( + isinstance(context, dict) + and str(exchange_name).partition(DATANAME_SEPARATOR)[0].upper() == "CTP" + ): + context["_native_ctp_entry_guard_required"] = True + session.finalize_dispatch(context) + async def _async_backend_call( self, operation: str, @@ -2651,7 +2880,11 @@ def final_handoff() -> None: # that outlives a cancelled async invocation instead of silently # allowing a stale thread to reach the lower transport. context["_async_handoff"] = True - session.finalize_dispatch(context) + self._finalize_ctp_execution_dispatch( + session, + exchange_name, + context, + ) def bind_handoff_context(context: Any) -> None: handoff_context["context"] = context @@ -2685,6 +2918,8 @@ async def invoke() -> Any: if session is not None and session.config["market_data_only"]: raise NormalizedApiError(operation, "market_data_only", definite_reject=True) + if operation in _NORMALIZED_WRITE_OPERATIONS: + self._reject_simnow_profile_write(exchange_name, operation) self._validate_required_environment(exchange_name, operation=operation) if session is not None: acquire_write = release_write = None @@ -2703,7 +2938,11 @@ async def invoke() -> Any: request, invoke, preauthorize=acquire_write, - pre_dispatch=session.finalize_dispatch, + pre_dispatch=lambda context: self._finalize_ctp_execution_dispatch( + session, + exchange_name, + context, + ), budget_capability=budget_capability, on_context=(bind_handoff_context if needs_managed_handoff else None), ) @@ -3348,6 +3587,40 @@ def _sole_ctp_execution_venue(self, operation: str) -> tuple[Any, str]: raise NormalizedApiError(operation, "single_ctp_session_required", definite_reject=True) return session, exchange_names[0] + def _reject_simnow_native_execution( + self, + environment_profile: Any, + operation: str, + *, + schema_version: str | None = None, + ) -> None: + """Keep all known SimNow profiles outside generic native-write routes.""" + if str( + environment_profile or "" + ).strip() in _SIMNOW_RESTRICTED_PROFILES or schema_version in { + SIMNOW_APPROVAL_SCHEMA_VERSION, + SIMNOW_ENTRY_APPROVAL_SCHEMA_VERSION, + SIMNOW_RECOVERY_APPROVAL_SCHEMA_VERSION, + }: + raise NormalizedApiError( + operation, + "ctp_simnow_execution_not_admitted", + definite_reject=True, + ) + + def _reject_simnow_profile_write(self, exchange_name: str, operation: str) -> None: + """Check cached CTP profile state before a generic write can dispatch.""" + if str(exchange_name).partition(DATANAME_SEPARATOR)[0].upper() != "CTP": + return + exchange_feeds = getattr(self, "exchange_feeds", None) + feed = exchange_feeds.get(exchange_name) if isinstance(exchange_feeds, Mapping) else None + state_reader = getattr(feed, "get_session_state", None) + if not callable(state_reader): + return + state = state_reader() + profile = state.get("environment_profile") if isinstance(state, Mapping) else None + self._reject_simnow_native_execution(profile, operation) + def init_exchange(self, exchange_kwargs: dict[str, Any]) -> None: """根据 exchange_kwargs 初始化并添加交易所。 @@ -3931,6 +4204,7 @@ def _arm_ctp_execution_gate( ) normalized, proof_sha256 = _execution_arm_proof(proof) + self._reject_simnow_native_execution(normalized.get("environment_profile"), operation) feed = self.exchange_feeds.get(exchange_name) capability = getattr(self, "_ctp_execution_capability", None) method = getattr(feed, "arm_execution_gate", None) @@ -4218,6 +4492,8 @@ def build_ctp_execution_approval_context( evidence: Any = None, source: str = "sdk_runtime", deployment_manifest: Mapping[str, Any] | None = None, + credential_binding_verifier: Any = None, + credential_binding_provider: Any = None, ) -> CtpExecutionApprovalContext: """Collect a sealed approval context from this running deployment. @@ -4226,10 +4502,27 @@ def build_ctp_execution_approval_context( identities by copying strings into a mapping. Application material is supplied as raw bytes, paths, or JSON values and hashed by the SDK. ``deployment_manifest`` can pin the locally observed runtime hashes, - but it cannot replace them. + but it cannot replace them. Official SimNow set1 contexts also require + an SDK-sealed ``credential_binding_verifier`` created from the reviewed + deployment adapter. Bare callbacks and mappings are rejected. The + verifier is refreshed with the current native session, front and + artifact scope before approval transitions. This remains a nominal + package-provenance boundary, not an in-process sandbox. """ operation = "build_ctp_execution_approval_context" + from ._ctp_credential_binding import _is_verifier, _new_scope + + if credential_binding_provider is not None: + if credential_binding_verifier is not None or not _is_verifier( + credential_binding_provider, owner=self + ): + raise NormalizedApiError( + operation, + "ctp_credential_binding_trust_required", + definite_reject=True, + ) + credential_binding_verifier = credential_binding_provider if source not in {"sdk_runtime", "deployment_manifest"}: raise NormalizedApiError( operation, "ctp_approval_context_untrusted", definite_reject=True @@ -4348,6 +4641,79 @@ def build_ctp_execution_approval_context( "evidence_sha256": _approval_material_digest(evidence), } ) + except Exception: + raise NormalizedApiError( + operation, + "ctp_approval_context_material_unavailable", + definite_reject=True, + ) from None + if environment_profile in _SIMNOW_BOUND_PROFILES: + if not _is_verifier(credential_binding_verifier, owner=self): + raise NormalizedApiError( + operation, "ctp_credential_binding_required", definite_reject=True + ) + try: + feed = self.exchange_feeds.get(exchange_name) + front_scope = self._ctp_credential_binding_fronts( + exchange_name, + feed, + state, + credential_binding_verifier, + operation=operation, + ) + binding_scope = _new_scope( + { + "account_fingerprint": account_fingerprint, + "trading_day": trading_day, + "connection_generation": generation, + "environment_profile": environment_profile, + "td_front": front_scope["td_front"], + "md_front": front_scope["md_front"], + "td_front_sha256": hashlib.sha256( + front_scope["td_front"].encode("utf-8", "strict") + ).hexdigest(), + "md_front_sha256": hashlib.sha256( + front_scope["md_front"].encode("utf-8", "strict") + ).hexdigest(), + "backtrader_sha256": values["backtrader_sha256"], + "backtrader_runtime_sha256": (credential_binding_verifier.package_sha256), + "bt_api_py_sha256": values["bt_api_py_sha256"], + "bt_api_ctp_sha256": values["bt_api_ctp_sha256"], + "bt_api_base_sha256": values["bt_api_base_sha256"], + "native_sha256": values["native_sha256"], + "dependency_hashes_sha256": values["dependency_hashes_sha256"], + "configuration_sha256": values["configuration_sha256"], + "strategy_identity_sha256": values["strategy_identity_sha256"], + "preflight_sha256": values["preflight_sha256"], + "evidence_sha256": values["evidence_sha256"], + "md_connection_generation": front_scope["md_connection_generation"], + "md_stream_generation": front_scope["md_stream_generation"], + } + ) + binding = credential_binding_verifier.refresh( + binding_scope, + owner=self, + operation=operation, + ) + except NormalizedApiError: + raise + except Exception: + raise NormalizedApiError( + operation, + "ctp_credential_binding_unavailable", + definite_reject=True, + ) from None + values.update({name: binding[name] for name in _CTP_CREDENTIAL_BINDING_FIELDS}) + elif ( + credential_binding_verifier is not None + or environment_profile in _SIMNOW_RESTRICTED_PROFILES + ): + raise NormalizedApiError( + operation, + "ctp_credential_binding_scope_unsupported", + definite_reject=True, + ) + try: normalized = _normalize_context(_new_runtime_context(values)) except NormalizedApiError as exc: raise NormalizedApiError(operation, exc.code, definite_reject=True) from None @@ -4368,6 +4734,7 @@ def refresh_context() -> CtpExecutionApprovalContext: evidence=evidence, source=source, deployment_manifest=deployment_manifest, + credential_binding_verifier=credential_binding_verifier, ) return _new_runtime_context( @@ -4443,6 +4810,237 @@ def _refresh_ctp_execution_approval_context( return _refresh_runtime_context(context, self) return context + def create_ctp_credential_binding_verifier(self, adapter: object) -> Any: + """Seal one reviewed Backtrader runtime adapter for this SDK instance. + + The adapter is imported from the configured deployment package only + when this method is called. Its package identity is then pinned into + each collected binding scope and checked again at refresh boundaries. + This is a reviewed deployment provenance contract, not an in-process + sandbox. + """ + + operation = "create_ctp_credential_binding_verifier" + if self.transport_mode is not TransportMode.DIRECT: + raise NormalizedApiError( + operation, "ctp_credential_binding_trust_required", definite_reject=True + ) + from ._ctp_credential_binding import _new_reviewed_verifier + + return _new_reviewed_verifier(self, adapter) + + def _create_ctp_credential_binding_verifier_for_test( + self, provider: object, *, authority: object + ) -> Any: + """Build the explicit controlled-test binding seam used by contracts.""" + + if not _is_ctp_controlled_test_authority(authority): + raise NormalizedApiError( + "create_ctp_credential_binding_verifier", + "ctp_credential_binding_trust_required", + definite_reject=True, + ) + from ._ctp_credential_binding import _new_test_verifier + + return _new_test_verifier(self, provider) + + def _ctp_credential_binding_fronts( + self, + exchange_name: str, + feed: Any, + state: Mapping[str, Any], + verifier: Any, + *, + operation: str, + ) -> dict[str, Any]: + """Read current active TD/MD front identities and their generations.""" + + code = "ctp_credential_binding_active_front_unavailable" + if getattr(verifier, "_is_controlled_test_verifier", False): + trader = getattr(feed, "_trader", None) + md_client = getattr(feed, "_md_client", None) + td_front = str(getattr(trader, "front", "") or "").strip() + md_front = str(getattr(md_client, "front", "") or "").strip() + td_bound = str(getattr(trader, "_bound_front", "") or "").strip() + td_native = str(getattr(trader, "_session_native_front", "") or "").strip() + feed_td = str(getattr(feed, "_execution_bound_td_front", "") or "").strip() + feed_md = str(getattr(feed, "_execution_bound_md_front", "") or "").strip() + td_generation = getattr(trader, "_connection_generation", None) + md_generation = getattr(md_client, "connection_generation", None) + stream_generation = getattr(feed, "_md_stream_generation", None) + if ( + not td_front + or td_front != td_bound + or td_front != td_native + or td_front != feed_td + or not md_front + or md_front != feed_md + or type(td_generation) is not int + or td_generation != state.get("connection_generation") + or type(md_generation) is not int + or md_generation <= 0 + or type(stream_generation) is not int + or stream_generation <= 0 + ): + raise NormalizedApiError(operation, code, definite_reject=True) + return { + "td_front": td_front, + "md_front": md_front, + "md_connection_generation": md_generation, + "md_stream_generation": stream_generation, + } + + try: + if ( + type(feed).__module__ != "bt_api_ctp.feeds.live_ctp_feed" + or type(feed).__name__ != "CtpRequestDataFuture" + ): + raise ValueError + trader = getattr(feed, "trader_client", None) + if trader is None: + trader = getattr(feed, "_trader", None) + if ( + type(trader).__module__ != "bt_api_ctp.ctp.client" + or type(trader).__name__ != "TraderClient" + ): + raise ValueError + td_front = str(getattr(trader, "front", "") or "").strip() + td_bound = str(getattr(trader, "_bound_front", "") or "").strip() + td_native = str(getattr(trader, "_session_native_front", "") or "").strip() + feed_td = str(getattr(feed, "_execution_bound_td_front", "") or "").strip() + td_generation = getattr(trader, "_connection_generation", None) + if ( + not td_front + or td_front != td_bound + or td_front != td_native + or td_front != feed_td + or type(td_generation) is not int + or td_generation <= 0 + or td_generation != state.get("connection_generation") + ): + raise ValueError + + ingress = getattr(self, "_ctp_market_ingress_queues", {}).get(exchange_name) + streams = getattr(self, "_subscription_streams", None) + if ingress is None or not isinstance(streams, (list, tuple)): + raise ValueError + candidates = [ + stream + for stream in streams + if str(getattr(stream, "stream_name", "")) == "ctp_market_stream" + and getattr(stream, "data_queue", None) is ingress + and getattr(stream, "_running", None) is True + ] + if len(candidates) != 1: + raise ValueError + stream = candidates[0] + if ( + type(stream).__module__ != "bt_api_ctp.feeds.live_ctp_feed" + or type(stream).__name__ != "CtpMarketStream" + or str(getattr(getattr(stream, "state", None), "value", "")) != "authenticated" + ): + raise ValueError + md_client = getattr(stream, "_md_client", None) + if ( + type(md_client).__module__ != "bt_api_ctp.ctp.client" + or type(md_client).__name__ != "MdClient" + or getattr(md_client, "is_ready", False) is not True + ): + raise ValueError + # A connected MD front is not enough: login was previously marked + # ready without retaining a response-bound BrokerID/UserID. The + # native client must publish its exact immutable login observation + # before this route can bind MD and TD to the same account. + try: + from importlib import import_module + + ctp_client_module = import_module("bt_api_ctp.ctp.client") + identity_type = ctp_client_module.MdIdentityObservation + md_identity = getattr(md_client, "active_md_identity", None) + except Exception: + raise NormalizedApiError( + operation, + "ctp_credential_binding_active_md_identity_unavailable", + definite_reject=True, + ) from None + identity_fields = ( + "front", + "broker_id", + "user_id", + "connection_generation", + "request_id", + "trading_day", + "authenticated", + ) + if ( + type(md_identity) is not identity_type + or frozenset(getattr(identity_type, "__dataclass_fields__", {})) + != frozenset(identity_fields) + or getattr(getattr(identity_type, "__dataclass_params__", None), "frozen", False) + is not True + or getattr(md_identity, "authenticated", None) is not True + ): + raise NormalizedApiError( + operation, + "ctp_credential_binding_active_md_identity_unavailable", + definite_reject=True, + ) + md_front = str(getattr(md_client, "front", "") or "").strip() + feed_md = str(getattr(feed, "_execution_bound_md_front", "") or "").strip() + stream_md = str(getattr(stream, "md_front", "") or "").strip() + md_generation = getattr(md_client, "connection_generation", None) + observed_generation = getattr(stream, "_observed_client_generation", None) + stream_generation = getattr(stream, "_connection_generation", None) + account_broker_id = str(getattr(feed, "_execution_bound_broker_id", "") or "").strip() + account_user_id = str(getattr(feed, "_execution_bound_user_id", "") or "").strip() + td_broker_id = str(getattr(trader, "_bound_broker_id", "") or "").strip() + td_user_id = str(getattr(trader, "_bound_user_id", "") or "").strip() + md_broker_id = str(getattr(md_identity, "broker_id", "") or "").strip() + md_user_id = str(getattr(md_identity, "user_id", "") or "").strip() + expected_account_fingerprint = str( + getattr(trader, "_account_fingerprint", "") or "" + ).strip() + if ( + not account_broker_id + or not account_user_id + or account_broker_id != td_broker_id + or account_user_id != td_user_id + or not expected_account_fingerprint + or state.get("account_fingerprint") != f"acct_{expected_account_fingerprint}" + or md_broker_id != account_broker_id + or md_user_id != account_user_id + or getattr(md_identity, "front", None) != md_front + or getattr(md_identity, "trading_day", None) != state.get("trading_day") + ): + raise NormalizedApiError( + operation, + "ctp_credential_binding_scope_mismatch", + definite_reject=True, + ) + if ( + not md_front + or md_front != feed_md + or md_front != stream_md + or type(md_generation) is not int + or md_generation <= 0 + or getattr(md_identity, "connection_generation", None) != md_generation + or getattr(md_identity, "request_id", None) != md_generation + or observed_generation != md_generation + or type(stream_generation) is not int + or stream_generation <= 0 + ): + raise ValueError + return { + "td_front": td_front, + "md_front": md_front, + "md_connection_generation": md_generation, + "md_stream_generation": stream_generation, + } + except NormalizedApiError: + raise + except Exception: + raise NormalizedApiError(operation, code, definite_reject=True) from None + @staticmethod def _canonical_ctp_approval_context_value(value: Any) -> str: """Canonicalize one sealed context field for an exact comparison.""" @@ -4498,6 +5096,246 @@ def _validate_ctp_recovery_material( definite_reject=True, ) + def _ctp_entry_approval_proof( + self, + approval_capability: CtpExecutionApprovalCapability, + ) -> dict[str, Any]: + """Build the exact native arm proof from one redeemed entry approval.""" + + payload = approval_capability._approval.payload + primary = payload["primary_instrument"] + authorized = [ + f"{item['exchange_id']}.{item['instrument_id']}" + for item in payload["authorized_instruments"] + ] + return { + "account_fingerprint": payload["account_fingerprint"], + "trading_day": payload["trading_day"], + "instrument": f"{primary['exchange_id']}.{primary['instrument_id']}", + "connection_generation": payload["connection_generation"], + "environment_profile": payload["environment_profile"], + "receipt_sha256": payload["receipt_sha256"], + "native_sha256": payload["native_sha256"], + "ctp_package_sha256": payload["ctp_package_sha256"], + "source_hashes_sha256": payload["source_hashes_sha256"], + "dependency_hashes_sha256": payload["dependency_hashes_sha256"], + "preflight_sha256": payload["preflight_sha256"], + "scope_version": _CTP_EXECUTION_RECOVERY_BUNDLE_SCOPE_VERSION, + "authorized_instruments": authorized, + } + + def _validate_active_ctp_entry_authorization( + self, + guard: object, + *, + operation: str, + **_write_context: Any, + ) -> None: + """Revalidate one ordinary entry approval at every managed write edge.""" + + if not _is_ctp_entry_write_guard(guard): + raise NormalizedApiError( + operation, + "ctp_entry_authorization_guard_invalid", + definite_reject=True, + ) + session, exchange_name = self._sole_ctp_execution_venue(operation) + if ( + guard._owner is not self + or guard._session is not session + or session._entry_write_guard is not guard + ): + raise NormalizedApiError( + operation, + "ctp_entry_authorization_guard_invalid", + definite_reject=True, + ) + capability = guard._capability + if ( + not _is_ctp_execution_approval_capability(capability) + or capability._owner is not self + or capability._entry_used is not True + or capability.purpose != APPROVAL_PURPOSE + or capability.schema_version != ENTRY_APPROVAL_SCHEMA_VERSION + or capability._context is not guard._context + or capability._context is None + ): + raise NormalizedApiError( + operation, + "ctp_execution_authorization_required", + definite_reject=True, + ) + self._reject_simnow_native_execution( + capability.bindings.get("environment_profile"), + operation, + schema_version=capability.schema_version, + ) + + approval = capability._approval + try: + payload_sha256 = hashlib.sha256( + json.dumps( + _jsonable(approval.payload), + ensure_ascii=False, + sort_keys=True, + separators=(",", ":"), + allow_nan=False, + ).encode("utf-8") + ).hexdigest() + signature_sha256 = hashlib.sha256(approval.signature).hexdigest() + frozen_trust_root = _thaw(capability._trust_root) + trust_root_sha256 = hashlib.sha256( + json.dumps( + _jsonable(frozen_trust_root), + ensure_ascii=False, + sort_keys=True, + separators=(",", ":"), + allow_nan=False, + ).encode("utf-8") + ).hexdigest() + except Exception: + raise NormalizedApiError( + operation, + "ctp_execution_authorization_material_unavailable", + definite_reject=True, + ) from None + if ( + payload_sha256 != approval.payload_sha256 + or payload_sha256 != guard._approval_payload_sha256 + or signature_sha256 != guard._approval_signature_sha256 + or approval.trust_root_sha256 != guard._trust_root_sha256 + or trust_root_sha256 != guard._trust_root_sha256 + ): + raise NormalizedApiError( + operation, + "ctp_execution_authorization_material_mismatch", + definite_reject=True, + ) + + context = capability._context + if type(context) is not CtpExecutionApprovalContext: + raise NormalizedApiError( + operation, + "ctp_approval_context_untrusted", + definite_reject=True, + ) + try: + refreshed_context = _refresh_runtime_context(context, self) + current_approval = self._revalidate_ctp_execution_approval( + approval, + trust_root=frozen_trust_root, + context=refreshed_context, + ) + except NormalizedApiError: + raise + except Exception: + raise NormalizedApiError( + operation, + "ctp_execution_authorization_material_unavailable", + definite_reject=True, + ) from None + if ( + current_approval.payload_sha256 != guard._approval_payload_sha256 + or hashlib.sha256(current_approval.signature).hexdigest() + != guard._approval_signature_sha256 + or current_approval.trust_root_sha256 != guard._trust_root_sha256 + ): + raise NormalizedApiError( + operation, + "ctp_execution_authorization_material_mismatch", + definite_reject=True, + ) + + now = datetime.now(UTC) + snapshot = approval.revocation_snapshot + snapshot_expires = snapshot.get("expires_at") if isinstance(snapshot, Mapping) else None + if not isinstance(snapshot_expires, str): + raise NormalizedApiError( + operation, + "ctp_approval_revocation_snapshot_stale", + definite_reject=True, + ) + try: + if now >= datetime.fromisoformat(snapshot_expires[:-1] + "+00:00"): + raise NormalizedApiError( + operation, + "ctp_approval_revocation_snapshot_stale", + definite_reject=True, + ) + except ValueError: + raise NormalizedApiError( + operation, + "ctp_approval_revocation_snapshot_stale", + definite_reject=True, + ) from None + try: + snapshot_sha256 = hashlib.sha256( + json.dumps( + _jsonable(snapshot), + ensure_ascii=False, + sort_keys=True, + separators=(",", ":"), + allow_nan=False, + ).encode("utf-8") + ).hexdigest() + except Exception: + raise NormalizedApiError( + operation, + "ctp_approval_revocation_snapshot_stale", + definite_reject=True, + ) from None + if ( + approval.approval_id in session._ctp_approval_revoked_ids + or approval.nonce in session._ctp_approval_revoked_nonces + or approval.revocation_snapshot_version + < session._ctp_approval_revocation_snapshot_version + or session._ctp_approval_revocation_snapshot_version + != approval.revocation_snapshot_version + or session._ctp_approval_revocation_snapshot_sha256 != snapshot_sha256 + ): + raise NormalizedApiError( + operation, + "ctp_approval_revoked", + definite_reject=True, + ) + + from ._execution_session import _execution_arm_proof + + proof, proof_sha256 = _execution_arm_proof(self._ctp_entry_approval_proof(capability)) + if ( + proof_sha256 != guard._proof_sha256 + or not isinstance(session._arm_proof, Mapping) + or dict(session._arm_proof) != proof + or session._arm_proof_sha256 != proof_sha256 + ): + raise NormalizedApiError( + operation, + "ctp_execution_authorization_context_mismatch", + definite_reject=True, + ) + current_context = self._ctp_execution_arm_context(exchange_name) + error = session._arm_context_error(proof, current_context, require_account_stream=True) + if error is not None: + raise NormalizedApiError(operation, error, definite_reject=True) + gate = self._ctp_execution_gate_state(exchange_name, operation=operation) + if ( + gate.get("managed") is not True + or gate.get("armed") is not True + or gate.get("connection_generation") != proof["connection_generation"] + or gate.get("trading_day") != proof["trading_day"] + or gate.get("instrument") != proof["instrument"] + or gate.get("scope_version") != proof["scope_version"] + or tuple(gate.get("authorized_instruments") or ()) + != tuple(proof["authorized_instruments"]) + or gate.get("environment_profile") != proof["environment_profile"] + or gate.get("proof_sha256") != proof_sha256 + ): + raise NormalizedApiError( + operation, + "ctp_execution_gate_state_mismatch", + definite_reject=True, + ) + def _latch_ctp_recovery_failure(self, session: Any, error: BaseException) -> None: """Fence the current generation after a recovery authority failure.""" @@ -4631,7 +5469,12 @@ def redeem_ctp_execution_approval( context=context, transition=session.consume_ctp_execution_approval, ) - return _new_capability(approval, self, context=context) + return _new_capability( + approval, + self, + context=context, + trust_root=trust_root, + ) def preauthorize_ctp_execution_approval( self, @@ -5007,11 +5850,20 @@ def _prepare_ctp_recovery_arm_authorization( "ctp_execution_authorization_required", definite_reject=True, ) + self._reject_simnow_native_execution( + approval_capability.bindings.get("environment_profile"), + operation, + schema_version=approval_capability.schema_version, + ) session, exchange_name = self._sole_ctp_execution_venue(operation) if ( approval_capability._owner is not self or approval_capability.purpose != RECOVERY_APPROVAL_PURPOSE - or approval_capability.schema_version != RECOVERY_APPROVAL_SCHEMA_VERSION + or approval_capability.schema_version + not in { + RECOVERY_APPROVAL_SCHEMA_VERSION, + SIMNOW_RECOVERY_APPROVAL_SCHEMA_VERSION, + } or approval_capability.recovery_scope_version != RECOVERY_APPROVAL_SCOPE_VERSION or approval_capability._recovery_used ): @@ -5119,6 +5971,11 @@ def _prepare_ctp_recovery_arm_authorization( "ctp_execution_authorization_unavailable", definite_reject=True, ) + self._reject_simnow_native_execution( + approval_capability.bindings.get("environment_profile"), + operation, + schema_version=approval_capability.schema_version, + ) try: native_authorization = issuer( core_capability, @@ -5163,7 +6020,11 @@ def _validate_active_ctp_recovery_authorization( type(approval_capability) is not CtpExecutionApprovalCapability or approval_capability._owner is not self or approval_capability.purpose != RECOVERY_APPROVAL_PURPOSE - or approval_capability.schema_version != RECOVERY_APPROVAL_SCHEMA_VERSION + or approval_capability.schema_version + not in { + RECOVERY_APPROVAL_SCHEMA_VERSION, + SIMNOW_RECOVERY_APPROVAL_SCHEMA_VERSION, + } or approval_capability.recovery_scope_version != RECOVERY_APPROVAL_SCOPE_VERSION or not approval_capability._recovery_used or approval_capability.recovery_token_sha256 != recovery_token_sha256 @@ -5350,7 +6211,10 @@ def _validate_active_ctp_recovery_authorization( # inspect native/package files through their transitive runtime # identity collectors. The final helper below must remain a pure # comparison of the observed values plus current short-lived state. - final_authorized_plan, final_remaining_plan = session._recovery_authorized_plan_state() + ( + final_authorized_plan, + final_remaining_plan, + ) = session._recovery_authorized_plan_state() final_plan = session._recovery_plan if final_authorized_plan is None: final_authorized_plan = final_plan @@ -5409,7 +6273,11 @@ def _validate_ctp_recovery_dispatch_freshness( type(approval_capability) is not CtpExecutionApprovalCapability or approval_capability._owner is not self or approval_capability.purpose != RECOVERY_APPROVAL_PURPOSE - or approval_capability.schema_version != RECOVERY_APPROVAL_SCHEMA_VERSION + or approval_capability.schema_version + not in { + RECOVERY_APPROVAL_SCHEMA_VERSION, + SIMNOW_RECOVERY_APPROVAL_SCHEMA_VERSION, + } or approval_capability.recovery_scope_version != RECOVERY_APPROVAL_SCOPE_VERSION or not approval_capability._recovery_used or approval_capability.recovery_token_sha256 != recovery_token_sha256 @@ -5419,6 +6287,11 @@ def _validate_ctp_recovery_dispatch_freshness( "ctp_execution_authorization_required", definite_reject=True, ) + self._reject_simnow_native_execution( + approval_capability.bindings.get("environment_profile"), + operation, + schema_version=approval_capability.schema_version, + ) now = datetime.now(UTC) try: @@ -5563,6 +6436,14 @@ def arm_execution_from_preflight(self, authorization: object) -> dict[str, Any]: or authorization._api is not self or authorization._venue != exchange_name or authorization._used + or ( + authorization._approval_capability is not None + and authorization._approval_capability.purpose == APPROVAL_PURPOSE + and not _is_ctp_entry_write_guard( + authorization._entry_write_guard, + session=session, + ) + ) ): raise NormalizedApiError( operation, @@ -5570,6 +6451,9 @@ def arm_execution_from_preflight(self, authorization: object) -> dict[str, Any]: definite_reject=True, ) proof = dict(authorization._proof) + self._reject_simnow_native_execution( + authorization._context.get("environment_profile"), operation + ) # A valid token is one-shot even when a later recovery or stream gate # rejects it. Re-arming requires a newly issued authority after a # fresh state/preflight check. @@ -5599,9 +6483,10 @@ def arm_execution_from_preflight(self, authorization: object) -> dict[str, Any]: ) private_event_revision = session.recovery_private_event_revision() private_ingress_revision = session.recovery_private_ingress_revision() - private_ingress_epoch, private_ingress_pending = self._ctp_private_ingress_snapshot( - exchange_name - ) + ( + private_ingress_epoch, + private_ingress_pending, + ) = self._ctp_private_ingress_snapshot(exchange_name) if private_ingress_pending: raise NormalizedApiError( operation, @@ -5642,6 +6527,7 @@ def rollback_execution() -> None: prepare_execution=prepare_execution, rollback_execution=rollback_execution, prepare_execution_outside_mutex=True, + execution_write_guard=authorization._entry_write_guard, authorization_context={ "strategy_identity_sha256": authorization._strategy_identity_sha256, "execution_cycle_id": authorization._execution_cycle_id, @@ -5719,6 +6605,7 @@ def _issue_ctp_settlement_authorization_for_test( definite_reject=True, ) session, exchange_name = self._sole_ctp_execution_venue(operation) + self._reject_simnow_profile_write(exchange_name, operation) if session.config.get("market_data_only") is True: raise NormalizedApiError( operation, @@ -5773,6 +6660,9 @@ def _confirm_ctp_settlement_for_core( "ctp_settlement_authorization_required", definite_reject=True, ) + self._reject_simnow_native_execution( + authorization._context.get("environment_profile"), operation + ) # A terminal-write grant is one-shot even if a later environment or # transport check rejects it. authorization._used = True @@ -5783,6 +6673,7 @@ def _confirm_ctp_settlement_for_core( definite_reject=True, ) current_context = self._ctp_settlement_context(exchange_name, operation=operation) + self._reject_simnow_native_execution(current_context.get("environment_profile"), operation) if any( current_context.get(field) != authorization._context.get(field) for field in ( @@ -5941,6 +6832,9 @@ def _arm_execution_recovery( "ctp_execution_authorization_required", definite_reject=True, ) + self._reject_simnow_native_execution( + authorization._context.get("environment_profile"), operation + ) authorization._used = True if authorization._preflight_epoch != self._ctp_execution_authorization_epoch_value(): raise NormalizedApiError( @@ -5950,6 +6844,7 @@ def _arm_execution_recovery( ) proof = dict(authorization._proof) current_context = self._ctp_execution_arm_context(exchange_name) + self._reject_simnow_native_execution(current_context.get("environment_profile"), operation) if session._arm_context_error(proof, current_context) is not None or any( current_context.get(field) != authorization._context.get(field) for field in ( @@ -5968,9 +6863,10 @@ def _arm_execution_recovery( ) private_event_revision = session.recovery_private_event_revision() private_ingress_revision = session.recovery_private_ingress_revision() - private_ingress_epoch, private_ingress_pending = self._ctp_private_ingress_snapshot( - exchange_name - ) + ( + private_ingress_epoch, + private_ingress_pending, + ) = self._ctp_private_ingress_snapshot(exchange_name) if private_ingress_pending: raise NormalizedApiError( operation, @@ -6117,6 +7013,11 @@ def arm_execution_recovery( "ctp_execution_authorization_required", definite_reject=True, ) + self._reject_simnow_native_execution( + authorization.bindings.get("environment_profile"), + operation, + schema_version=authorization.schema_version, + ) if budget_capability is not None: session, _exchange_name = self._sole_ctp_execution_venue(operation) session.attach_ctp_budget_reservation( @@ -6156,11 +7057,17 @@ def _prepare_ctp_entry_arm_authorization( "ctp_execution_authorization_required", definite_reject=True, ) + self._reject_simnow_native_execution( + approval_capability.bindings.get("environment_profile"), + operation, + schema_version=approval_capability.schema_version, + ) session, exchange_name = self._sole_ctp_execution_venue(operation) if ( approval_capability._owner is not self or approval_capability.purpose != APPROVAL_PURPOSE - or approval_capability.schema_version != ENTRY_APPROVAL_SCHEMA_VERSION + or approval_capability.schema_version + not in {ENTRY_APPROVAL_SCHEMA_VERSION, SIMNOW_ENTRY_APPROVAL_SCHEMA_VERSION} or approval_capability._entry_used ): raise NormalizedApiError( @@ -6214,27 +7121,14 @@ def _prepare_ctp_entry_arm_authorization( "ctp_execution_authorization_material_mismatch", definite_reject=True, ) - primary = payload["primary_instrument"] - authorized = [ - f"{item['exchange_id']}.{item['instrument_id']}" - for item in payload["authorized_instruments"] - ] - proof = { - "account_fingerprint": payload["account_fingerprint"], - "trading_day": payload["trading_day"], - "instrument": f"{primary['exchange_id']}.{primary['instrument_id']}", - "connection_generation": payload["connection_generation"], - "environment_profile": payload["environment_profile"], - "receipt_sha256": payload["receipt_sha256"], - "native_sha256": payload["native_sha256"], - "ctp_package_sha256": payload["ctp_package_sha256"], - "source_hashes_sha256": payload["source_hashes_sha256"], - "dependency_hashes_sha256": payload["dependency_hashes_sha256"], - "preflight_sha256": payload["preflight_sha256"], - "scope_version": _CTP_EXECUTION_RECOVERY_BUNDLE_SCOPE_VERSION, - "authorized_instruments": authorized, - } - normalized, _proof_sha256 = _execution_arm_proof(proof) + proof = self._ctp_entry_approval_proof(approval_capability) + normalized, proof_sha256 = _execution_arm_proof(proof) + entry_write_guard = _new_ctp_entry_write_guard( + self, + session, + approval_capability, + proof_sha256, + ) if exchange_name.partition(DATANAME_SEPARATOR)[0].upper() != "CTP": raise NormalizedApiError( operation, @@ -6283,6 +7177,7 @@ def _prepare_ctp_entry_arm_authorization( execution_cycle_id=payload["execution_cycle_id"], preflight_epoch=self._ctp_execution_authorization_epoch_value(), approval_capability=approval_capability, + entry_write_guard=entry_write_guard, ) @_serialized_ctp_execution_transition @@ -6327,11 +7222,17 @@ def confirm_ctp_settlement_from_approval( "ctp_settlement_authorization_required", definite_reject=True, ) + self._reject_simnow_native_execution( + approval_capability.bindings.get("environment_profile"), + operation, + schema_version=approval_capability.schema_version, + ) session, exchange_name = self._sole_ctp_execution_venue(operation) if ( approval_capability._owner is not self or approval_capability.purpose != APPROVAL_PURPOSE - or approval_capability.schema_version != ENTRY_APPROVAL_SCHEMA_VERSION + or approval_capability.schema_version + not in {ENTRY_APPROVAL_SCHEMA_VERSION, SIMNOW_ENTRY_APPROVAL_SCHEMA_VERSION} or approval_capability._settlement_used ): raise NormalizedApiError( @@ -6346,6 +7247,7 @@ def confirm_ctp_settlement_from_approval( if now >= expires_at: raise NormalizedApiError(operation, "ctp_approval_expired", definite_reject=True) current_context = self._ctp_settlement_context(exchange_name, operation=operation) + self._reject_simnow_native_execution(current_context.get("environment_profile"), operation) expected = { "account_fingerprint": payload["account_fingerprint"], "trading_day": payload["trading_day"], @@ -6412,8 +7314,12 @@ def _confirm_ctp_settlement_from_public_approval( "ctp_settlement_authorization_required", definite_reject=True, ) + self._reject_simnow_native_execution( + authorization._context.get("environment_profile"), operation + ) authorization._used = True current_context = self._ctp_settlement_context(exchange_name, operation=operation) + self._reject_simnow_native_execution(current_context.get("environment_profile"), operation) if any( current_context.get(field) != authorization._context.get(field) for field in ( @@ -6683,6 +7589,7 @@ def confirm_ctp_settlement( "confirm_ctp_settlement", detail=f"{exchange_name} is not a CTP provider", ) + self._reject_simnow_profile_write(exchange_name, "confirm_ctp_settlement") raise NormalizedApiError( "confirm_ctp_settlement", "ctp_settlement_authorization_required", @@ -8005,6 +8912,7 @@ def make_order( 仅当 order_type 能推导 side(``side-type``)时兼容,裸 ``limit``/``market`` 无法推导 side 时抛 ``LegacyOrderApiError``。 """ + self._reject_simnow_profile_write(exchange_name, "make_order") if kwargs.pop("normalized", False): budget_capability = kwargs.pop("budget_capability", None) if not isinstance(symbol, OrderRequest): @@ -8012,6 +8920,9 @@ def make_order( "make_order", "typed_request_required", definite_reject=True ) request = symbol + self._require_ctp_managed_order_identity( + "make_order", exchange_name, request, self._execution_session + ) resolved_request, mode_guarded = self._begin_position_mode_placement( exchange_name, request, @@ -8058,6 +8969,7 @@ def make_order( self._end_position_mode_placement(exchange_name) def _make_order_typed(self, exchange_name: str, request: OrderRequest) -> Any: + self._reject_simnow_profile_write(exchange_name, "make_order") return self._backend.make_order(exchange_name, request) def _make_order_legacy( @@ -8128,6 +9040,7 @@ def cancel_order( :param symbol: 交易对 :param order_id: 订单ID """ + self._reject_simnow_profile_write(exchange_name, "cancel_order") if kwargs.pop("normalized", False): budget_capability = kwargs.pop("budget_capability", None) request = ( @@ -8135,6 +9048,8 @@ def cancel_order( if isinstance(symbol, CancelOrderRequest) else CancelOrderRequest(symbol=symbol, account_id="legacy", order_id=order_id) ) + session = self._execution_session + request = self._bind_ctp_managed_cancel_identity(exchange_name, request, session) return self._normalized_call( "cancel_order", exchange_name, @@ -8161,6 +9076,7 @@ def _cancel_order_raw( extra_data: Any = None, **kwargs: Any, ) -> Any: + self._reject_simnow_profile_write(exchange_name, "cancel_order") request = ( symbol if isinstance(symbol, CancelOrderRequest) @@ -8182,6 +9098,7 @@ def cancel_all( :param exchange_name: 交易所标识 :param symbol: 交易对 (None 表示所有品种) """ + self._reject_simnow_profile_write(exchange_name, "cancel_all") if self._execution_session is not None: raise CapabilityNotSupportedError( "cancel_all", @@ -8564,6 +9481,7 @@ async def async_get_kline( ) async def async_make_order(self, exchange_name: str, *args: Any, **kwargs: Any) -> Any: + self._reject_simnow_profile_write(exchange_name, "async_make_order") if kwargs.pop("normalized", False): budget_capability = kwargs.pop("budget_capability", None) if len(args) != 1 or not isinstance(args[0], OrderRequest) or kwargs: @@ -8571,6 +9489,9 @@ async def async_make_order(self, exchange_name: str, *args: Any, **kwargs: Any) "async_make_order", "typed_request_required", definite_reject=True ) request = args[0] + self._require_ctp_managed_order_identity( + "async_make_order", exchange_name, request, self._execution_session + ) resolved_request, mode_guarded = await asyncio.to_thread( self._begin_position_mode_placement, exchange_name, @@ -8643,6 +9564,7 @@ async def async_make_order(self, exchange_name: str, *args: Any, **kwargs: Any) ) async def async_cancel_order(self, exchange_name: str, *args: Any, **kwargs: Any) -> Any: + self._reject_simnow_profile_write(exchange_name, "async_cancel_order") if kwargs.pop("normalized", False): budget_capability = kwargs.pop("budget_capability", None) if len(args) != 1 or not isinstance(args[0], CancelOrderRequest) or kwargs: @@ -8650,6 +9572,8 @@ async def async_cancel_order(self, exchange_name: str, *args: Any, **kwargs: Any "async_cancel_order", "typed_request_required", definite_reject=True ) request = args[0] + session = self._execution_session + request = self._bind_ctp_managed_cancel_identity(exchange_name, request, session) return await self._async_normalized_call( "cancel_order", exchange_name, @@ -8691,6 +9615,7 @@ async def async_cancel_order(self, exchange_name: str, *args: Any, **kwargs: Any return result async def async_cancel_all(self, exchange_name: str, *args: Any, **kwargs: Any) -> Any: + self._reject_simnow_profile_write(exchange_name, "async_cancel_all") if self._execution_session is not None: raise CapabilityNotSupportedError( "async_cancel_all", diff --git a/bt_api_py/configs/exchange-bundles.toml b/bt_api_py/configs/exchange-bundles.toml index 417798bb..2bcf252c 100644 --- a/bt_api_py/configs/exchange-bundles.toml +++ b/bt_api_py/configs/exchange-bundles.toml @@ -29,5 +29,5 @@ certification = "experimental" package = "bt_api_ctp" plugin = "ctp" exchange = "CTP___FUTURE" -min_version = "2.0.2" +min_version = "2.0.3" certification = "experimental" diff --git a/bt_api_py/ctp_simnow_execution.py b/bt_api_py/ctp_simnow_execution.py new file mode 100644 index 00000000..a6f7c28c --- /dev/null +++ b/bt_api_py/ctp_simnow_execution.py @@ -0,0 +1,737 @@ +"""Public, read-first contract for the official SimNow set1 execution path. + +This adapter deliberately does not create an order journal or mint execution +authority. It delegates durable order state to ``BtApi``'s existing execution +session. Native writes stay closed until that session can revalidate a sealed +private configuration, a non-public credential-version binding, and a durable +CTP OrderRef mapping on every arm and write. +""" + +from __future__ import annotations + +import re +from collections.abc import Mapping +from dataclasses import dataclass, field +from decimal import Decimal +from hashlib import sha256 +from typing import TYPE_CHECKING, Any + +from bt_api_py._contracts.models import ( + CancelOrderRequest, + OrderRequest, + OrderType, + QueryOrderRequest, + Side, +) + +CTP_FUTURE = "CTP___FUTURE" +OFFICIAL_SET1_PROFILES = frozenset({"set1_group1", "set1_group2"}) +_ACCOUNT_FINGERPRINT = re.compile(r"^acct_[0-9a-f]{16}$") +_TRADING_DAY = re.compile(r"^[0-9]{8}$") +_HEDGE_FLAGS = frozenset({"1", "2", "3"}) +_TERMINAL_ORDER_STATUS = frozenset({"0", "2", "4", "5"}) + +if TYPE_CHECKING: + from bt_api_ctp import CtpNativeQueryCertificate + + +class CtpSimNowExecutionError(ValueError): + """A fail-closed SimNow adapter contract error.""" + + def __init__(self, code: str) -> None: + self.code = code + super().__init__(code) + + +@dataclass(frozen=True) +class CtpSimNowSessionIdentity: + environment: str + profile: str + account_fingerprint: str = field(repr=False) + trading_day: str + connection_generation: int + + +@dataclass(frozen=True) +class CtpSimNowOrderRequest: + client_order_id: str + instrument_id: str + exchange_id: str + side: str + quantity: Decimal + limit_price: Decimal + offset: str = "open" + hedge_flag: str = "1" + runtime_order_id: str | None = None + + def __post_init__(self) -> None: + if not self.client_order_id or self.client_order_id != self.client_order_id.strip(): + raise ValueError("client_order_id must be non-empty and trimmed") + try: + order_ref_bytes = self.client_order_id.encode("ascii") + except UnicodeEncodeError: + raise CtpSimNowExecutionError("ctp_native_order_ref_mapping_unavailable") from None + if b"\x00" in order_ref_bytes or len(order_ref_bytes) > 12: + raise CtpSimNowExecutionError("ctp_native_order_ref_mapping_unavailable") + if self.runtime_order_id is not None and ( + not isinstance(self.runtime_order_id, str) + or not self.runtime_order_id + or self.runtime_order_id != self.runtime_order_id.strip() + or len(self.runtime_order_id.encode("utf-8")) > 256 + ): + raise ValueError("runtime_order_id must be a bounded non-empty string or None") + if not self.instrument_id or self.instrument_id != self.instrument_id.strip(): + raise ValueError("instrument_id must be non-empty and trimmed") + if not self.exchange_id or self.exchange_id != self.exchange_id.strip(): + raise ValueError("exchange_id must be non-empty and trimmed") + if self.side not in {"buy", "sell"}: + raise ValueError("side must be buy or sell") + if not isinstance(self.quantity, Decimal) or not self.quantity.is_finite(): + raise ValueError("quantity must be a finite Decimal") + if self.quantity <= 0 or self.quantity != self.quantity.to_integral_value(): + raise ValueError("quantity must be a positive integer number of lots") + if not isinstance(self.limit_price, Decimal) or not self.limit_price.is_finite(): + raise ValueError("limit_price must be a finite Decimal") + if self.limit_price <= 0: + raise ValueError("limit_price must be positive") + if self.offset not in {"open", "close", "close_today", "close_yesterday"}: + raise ValueError("unsupported CTP offset") + if self.hedge_flag not in _HEDGE_FLAGS: + raise ValueError("unsupported CTP hedge flag") + + +@dataclass(frozen=True) +class CtpSimNowOrderIdentity: + instrument_id: str + exchange_id: str + client_order_id: str + order_ref: str + order_sys_id: str | None + front_id: int | None + session_id: int | None + trading_day: str + runtime_order_id: str | None = None + + +@dataclass(frozen=True) +class CtpSimNowOrderResult: + identity: CtpSimNowOrderIdentity + status: str + execution_unknown: bool + + +@dataclass(frozen=True) +class CtpSimNowCancelResult: + action_id: str + identity: CtpSimNowOrderIdentity + session_identity: CtpSimNowSessionIdentity + status: str + request_id: int | None + order_action_ref: int | None + execution_unknown: bool + + +@dataclass(frozen=True) +class CtpSimNowQueryResult: + complete: bool + identity: CtpSimNowSessionIdentity + records: tuple[CtpSimNowOrderIdentity, ...] + + +@dataclass(frozen=True) +class CtpSimNowReadObservation: + """Digest-only CTP read evidence with no execution or account-order authority.""" + + identity: CtpSimNowSessionIdentity + native_query_certificate: CtpNativeQueryCertificate = field(repr=False) + + @property + def authority_status(self) -> str: + """This observation never authorizes an execution action.""" + + return "NON_AUTHORIZING" + + @property + def execution_authorized(self) -> bool: + return False + + @property + def account_open_orders_complete(self) -> bool: + """A terminal query packet does not prove full account-wide coverage.""" + + return False + + @property + def account_open_orders_status(self) -> str: + return "UNPROVEN" + + def as_public_dict(self) -> dict[str, Any]: + """Expose the certificate digests and scope without native records.""" + + return { + "schema": "ctp_simnow_read_observation.v1", + "authority_status": self.authority_status, + "execution_authorized": False, + "session": { + "environment": self.identity.environment, + "profile": self.identity.profile, + "trading_day": self.identity.trading_day, + "connection_generation": self.identity.connection_generation, + }, + "account_open_orders": { + "complete": False, + "status": "UNPROVEN", + }, + "native_query_certificate": self.native_query_certificate.as_public_dict(), + } + + +def _int_or_none(value: Any) -> int | None: + if isinstance(value, bool) or value in (None, ""): + return None + try: + return int(value) + except (TypeError, ValueError): + return None + + +def _value(row: Any, *names: str) -> Any: + if isinstance(row, Mapping): + for name in names: + if name in row: + return row[name] + for name in names: + value = getattr(row, name, None) + if value not in (None, ""): + return value + return None + + +def _is_order_action_evidence_instance(value: Any) -> bool: + """Recognize the SDK evidence dataclass without authenticating its origin.""" + try: + from bt_api_ctp.order_action import CtpOrderActionEvidence + except ImportError: + return False + return type(value) is CtpOrderActionEvidence + + +def _native_order_identity( + row: Any, + *, + fallback: CtpSimNowOrderIdentity | None = None, + trading_day: str = "", +) -> CtpSimNowOrderIdentity: + fallback = fallback or CtpSimNowOrderIdentity("", "", "", "", None, None, None, trading_day) + return CtpSimNowOrderIdentity( + instrument_id=str(_value(row, "instrument_id", "InstrumentID") or fallback.instrument_id), + exchange_id=str(_value(row, "exchange_id", "ExchangeID") or fallback.exchange_id), + client_order_id=str( + _value(row, "client_order_id", "OrderRef", "order_ref") or fallback.client_order_id + ), + order_ref=str(_value(row, "order_ref", "OrderRef") or fallback.order_ref), + order_sys_id=( + str(_value(row, "order_id", "order_sys_id", "OrderSysID")) + if _value(row, "order_id", "order_sys_id", "OrderSysID") not in (None, "") + else fallback.order_sys_id + ), + front_id=( + _int_or_none(_value(row, "front_id", "FrontID")) + if _int_or_none(_value(row, "front_id", "FrontID")) is not None + else fallback.front_id + ), + session_id=( + _int_or_none(_value(row, "session_id", "SessionID")) + if _int_or_none(_value(row, "session_id", "SessionID")) is not None + else fallback.session_id + ), + trading_day=str(_value(row, "trading_day", "TradingDay") or fallback.trading_day), + runtime_order_id=fallback.runtime_order_id, + ) + + +def map_ctp_simnow_order_result( + row: Any, request: CtpSimNowOrderRequest, identity: CtpSimNowSessionIdentity +) -> CtpSimNowOrderResult: + """Bind CTP's OrderRef/front/session and optional OrderSysID to the request.""" + return _map_order_row( + row, + fallback=CtpSimNowOrderIdentity( + request.instrument_id, + request.exchange_id, + request.client_order_id, + request.client_order_id, + None, + None, + None, + identity.trading_day, + request.runtime_order_id, + ), + identity=identity, + expected_client_order_id=request.client_order_id, + ) + + +def _map_order_row( + row: Any, + *, + fallback: CtpSimNowOrderIdentity, + identity: CtpSimNowSessionIdentity, + expected_client_order_id: str, +) -> CtpSimNowOrderResult: + order_identity = _native_order_identity( + row, fallback=fallback, trading_day=identity.trading_day + ) + if order_identity.client_order_id != expected_client_order_id: + raise CtpSimNowExecutionError("ctp_order_client_reference_mismatch") + if ( + order_identity.instrument_id != fallback.instrument_id + or order_identity.exchange_id != fallback.exchange_id + ): + raise CtpSimNowExecutionError("ctp_order_instrument_identity_mismatch") + if order_identity.trading_day and order_identity.trading_day != identity.trading_day: + raise CtpSimNowExecutionError("ctp_order_trading_day_mismatch") + raw_status = str(_value(row, "status") or "").strip().lower() + if _value(row, "execution_unknown") is True or raw_status in { + "unknown", + "submitted", + "pending", + }: + status = "UNKNOWN" + elif _value(row, "definite_reject") is True or raw_status == "rejected": + status = "REJECTED" + elif raw_status in {"canceled", "cancelled"}: + status = "CANCELED" + elif raw_status == "completed": + status = "FILLED" + elif raw_status == "partial": + status = "PARTIALLY_FILLED" + elif raw_status == "accepted": + status = "ACCEPTED" + else: + status = "UNKNOWN" + return CtpSimNowOrderResult(order_identity, status, status == "UNKNOWN") + + +def build_ctp_simnow_cancel_request( + identity: CtpSimNowOrderIdentity, + *, + account_id: str, + action_id: str, + idempotency_key: str = "", +) -> CancelOrderRequest: + """Map a client/native order identity into the SDK's typed cancel request.""" + if not action_id or action_id != action_id.strip(): + raise ValueError("action_id must be non-empty and trimmed") + if not identity.runtime_order_id: + raise CtpSimNowExecutionError("ctp_runtime_cancel_identity_required") + if not identity.order_sys_id and not ( + identity.order_ref and identity.front_id is not None and identity.session_id is not None + ): + raise CtpSimNowExecutionError("ctp_cancel_native_identity_incomplete") + return CancelOrderRequest( + symbol=identity.instrument_id, + account_id=account_id, + order_id=identity.order_sys_id, + client_order_id=identity.client_order_id or None, + idempotency_key=idempotency_key, + exchange_id=identity.exchange_id, + front_id=identity.front_id, + session_id=identity.session_id, + order_ref=identity.order_ref or None, + runtime_order_id=identity.runtime_order_id, + runtime_action_id=action_id, + ) + + +def map_ctp_simnow_cancel_result( + action_id: str, + identity: CtpSimNowOrderIdentity, + evidence: Any, + session_identity: CtpSimNowSessionIdentity, + *, + request_id: int | None, + order_action_ref: int | str | None, +) -> CtpSimNowCancelResult: + """Project callback evidence; this helper never grants write authority. + + The exact SDK evidence dataclass is recognized for diagnostic projection; + it is publicly constructible, so its type does not authenticate callback + origin. This result cannot grant write or terminal-order authority. + Redacted dictionaries and caller-constructed mappings remain UNKNOWN. + """ + recognized_evidence = _is_order_action_evidence_instance(evidence) + status_value = _value(evidence, "status") + normalized = str(status_value or "unknown").strip().lower() + status = {"accepted": "ACCEPTED", "rejected": "REJECTED"}.get(normalized, "UNKNOWN") + expected_request_id = _int_or_none(request_id) + expected_action_ref = _int_or_none(order_action_ref) + evidence_request_id = _int_or_none(_value(evidence, "request_id")) + evidence_action_ref = _int_or_none(_value(evidence, "order_action_ref")) + # An API return code or an order status is not a matching action callback. + evidence_account = str(_value(evidence, "account_fingerprint") or "").lower() + if evidence_account.startswith("acct_"): + evidence_account = evidence_account[5:] + expected_account = session_identity.account_fingerprint[5:] + matching = bool( + recognized_evidence + and evidence is not None + and expected_request_id is not None + and expected_action_ref is not None + and evidence_request_id == expected_request_id + and evidence_action_ref == expected_action_ref + and _value(evidence, "evidence_received") is True + and _value(evidence, "callback_received") is True + and evidence_account == expected_account + and _int_or_none(_value(evidence, "connection_generation")) + == session_identity.connection_generation + and str(_value(evidence, "order_ref") or "") == identity.order_ref + and str(_value(evidence, "order_sys_id") or "") == str(identity.order_sys_id or "") + and str(_value(evidence, "trading_day") or "") == session_identity.trading_day + and str(_value(evidence, "instrument_id") or "") == identity.instrument_id + and str(_value(evidence, "exchange_id") or "") == identity.exchange_id + and str(_value(evidence, "action_flag") or "0") == "0" + and ( + identity.front_id is None + or _int_or_none(_value(evidence, "front_id")) == identity.front_id + ) + and ( + identity.session_id is None + or _int_or_none(_value(evidence, "session_id")) == identity.session_id + ) + ) + if not matching: + status = "UNKNOWN" + return CtpSimNowCancelResult( + action_id=action_id, + identity=identity, + session_identity=session_identity, + status=status, + request_id=evidence_request_id if matching else None, + order_action_ref=evidence_action_ref if matching else None, + execution_unknown=status in {"UNKNOWN", "ACCEPTED"}, + ) + + +class CtpSimNowExecutionAdapter: + """Public set1-only facade over ``BtApi``; native writes remain fail-closed. + + ``selected_profile`` must come from the caller's sealed runtime + configuration. The adapter verifies that exact profile against current + SDK session evidence and never reads environment variables or substitutes + another SimNow family member. + """ + + def __init__(self, api: Any, *, selected_profile: str, exchange_name: str = CTP_FUTURE): + if selected_profile not in OFFICIAL_SET1_PROFILES: + raise CtpSimNowExecutionError("ctp_simnow_set1_profile_required") + if exchange_name != CTP_FUTURE: + raise CtpSimNowExecutionError("ctp_simnow_exchange_scope_invalid") + self._api = api + self.exchange_name = exchange_name + self.selected_profile = selected_profile + self._require_scope() + + @property + def write_admitted(self) -> bool: + """Real writes are disabled until the SDK binds private credential state.""" + return False + + @property + def write_blockers(self) -> tuple[str, ...]: + """Unresolved SDK prerequisites for any SimNow native write.""" + return ( + "ctp_execution_credential_binding_unavailable", + "ctp_native_order_ref_mapping_unavailable", + ) + + def _require_scope(self) -> tuple[CtpSimNowSessionIdentity, Mapping[str, Any]]: + try: + environment = self._api.get_environment_info(self.exchange_name) + session = self._api.get_ctp_session_state(self.exchange_name) + ledger = self._api.get_execution_identity(self.exchange_name) + except Exception as exc: + raise CtpSimNowExecutionError("ctp_simnow_public_identity_unavailable") from exc + if ( + not isinstance(environment, Mapping) + or environment.get("verified") is not True + or environment.get("environment") != "demo" + or environment.get("transport_mode") != "direct" + ): + raise CtpSimNowExecutionError("ctp_simnow_official_demo_required") + if ( + not isinstance(session, Mapping) + or session.get("environment_profile") != self.selected_profile + or session.get("read_only_ready") is not True + or session.get("auto_settlement_confirm") is not False + ): + raise CtpSimNowExecutionError("ctp_simnow_selected_profile_mismatch") + if not isinstance(ledger, Mapping) or ledger.get("mode") != "direct": + raise CtpSimNowExecutionError("ctp_simnow_sdk_execution_journal_required") + account = str(session.get("account_fingerprint") or "").lower() + if account and not account.startswith("acct_"): + account = f"acct_{account}" + trading_day = str(session.get("trading_day") or "") + generation = session.get("connection_generation") + if ( + not _ACCOUNT_FINGERPRINT.fullmatch(account) + or not _TRADING_DAY.fullmatch(trading_day) + or isinstance(generation, bool) + or not isinstance(generation, int) + or generation <= 0 + ): + raise CtpSimNowExecutionError("ctp_simnow_session_identity_invalid") + if ledger.get("account_fingerprint") != account: + raise CtpSimNowExecutionError("ctp_simnow_journal_account_mismatch") + return ( + CtpSimNowSessionIdentity( + environment="demo", + profile=self.selected_profile, + account_fingerprint=account, + trading_day=trading_day, + connection_generation=generation, + ), + session, + ) + + def get_execution_identity(self) -> CtpSimNowSessionIdentity: + return self._require_scope()[0] + + def build_order_request(self, request: CtpSimNowOrderRequest) -> OrderRequest: + """Build the typed SDK intent without dispatching a native write.""" + identity, _session = self._require_scope() + ledger = self._api.get_execution_identity(self.exchange_name) + account_id = str(ledger.get("account_id") or identity.account_fingerprint) + return OrderRequest( + symbol=request.instrument_id, + side=Side(request.side), + order_type=OrderType.LIMIT, + quantity=request.quantity, + account_id=account_id, + client_order_id=request.client_order_id, + price=request.limit_price, + time_in_force="DAY", + quantity_unit="lots", + offset=request.offset, + exchange_id=request.exchange_id, + hedge_flag=request.hedge_flag, + runtime_order_id=request.runtime_order_id, + ) + + def build_cancel_request( + self, identity: CtpSimNowOrderIdentity, *, action_id: str + ) -> CancelOrderRequest: + current, _session = self._require_scope() + if identity.trading_day != current.trading_day: + raise CtpSimNowExecutionError("ctp_cancel_trading_day_mismatch") + ledger = self._api.get_execution_identity(self.exchange_name) + return build_ctp_simnow_cancel_request( + identity, + account_id=str(ledger.get("account_id") or current.account_fingerprint), + action_id=action_id, + ) + + def arm_from_approval(self, *_: Any, **__: Any) -> None: + """Keep actual arming disabled until private config and key bindings exist.""" + self._require_scope() + raise CtpSimNowExecutionError("ctp_execution_credential_binding_unavailable") + + def submit_order_insert(self, request: CtpSimNowOrderRequest) -> CtpSimNowOrderResult: + """Refuse dispatch until private credentials and native reference mapping bind.""" + self._require_scope() + self.build_order_request(request) + raise CtpSimNowExecutionError("ctp_native_order_ref_mapping_unavailable") + + def submit_order_action( + self, identity: CtpSimNowOrderIdentity, action_id: str + ) -> CtpSimNowCancelResult: + """Refuse dispatch while approval context omits private credential binding.""" + self._require_scope() + self.build_cancel_request(identity, action_id=action_id) + raise CtpSimNowExecutionError("ctp_execution_credential_binding_unavailable") + + def query_order(self, identity: CtpSimNowOrderIdentity) -> CtpSimNowOrderResult: + """Resolve an order through the SDK's public typed query and journal.""" + current, _session = self._require_scope() + if identity.trading_day != current.trading_day: + raise CtpSimNowExecutionError("ctp_order_query_trading_day_mismatch") + ledger = self._api.get_execution_identity(self.exchange_name) + request = QueryOrderRequest( + symbol=identity.instrument_id, + account_id=str(ledger.get("account_id") or current.account_fingerprint), + order_id=identity.order_sys_id, + client_order_id=identity.client_order_id or None, + exchange_id=identity.exchange_id, + front_id=identity.front_id, + session_id=identity.session_id, + order_ref=identity.order_ref or None, + ) + result = self._api.query_order(self.exchange_name, request, normalized=True) + return _map_order_row( + result, + fallback=identity, + identity=current, + expected_client_order_id=identity.client_order_id, + ) + + def query_account_open_orders(self) -> CtpSimNowQueryResult: + """Return observed rows without claiming account-wide completeness. + + A terminal response for one CTP query does not certify account-wide or + pagination completeness. ``complete`` is therefore always false. + """ + identity, _session = self._require_scope() + result = self._api.query_ctp_result(self.exchange_name, "orders") + if getattr(result, "request_type", None) not in {"orders", "order"}: + raise CtpSimNowExecutionError("ctp_orders_query_type_mismatch") + result_identity = ( + str(getattr(result, "account_fingerprint", "") or "").lower(), + getattr(result, "connection_generation", None), + ) + expected_account = ( + identity.account_fingerprint[5:] + if identity.account_fingerprint.startswith("acct_") + else identity.account_fingerprint + ) + if result_identity != (expected_account, identity.connection_generation): + raise CtpSimNowExecutionError("ctp_orders_query_identity_mismatch") + # QueryResult.complete proves a terminal packet, not account-wide + # or pagination completeness. + native_rows = tuple(getattr(result, "records", ())) + if any( + not _value(row, "trading_day", "TradingDay") + or str(_value(row, "trading_day", "TradingDay")) != identity.trading_day + for row in native_rows + ): + raise CtpSimNowExecutionError("ctp_orders_query_trading_day_mismatch") + rows = tuple( + _native_order_identity(row, trading_day=identity.trading_day) for row in native_rows + ) + open_rows = tuple( + row for row, raw in zip(rows, native_rows, strict=True) if _is_open_native_order(raw) + ) + return CtpSimNowQueryResult(False, identity, open_rows) + + def query_native_read_observation( + self, + *, + instrument_id: str, + exchange_id: str, + hedge_flag: str, + ) -> CtpSimNowReadObservation: + """Capture seven same-session native reads as non-authorizing evidence. + + The native certificate proves request provenance, terminal completion, + explicit filters, and stable payloads for these reads. It does not prove + an atomic snapshot or account-wide open-order coverage. + """ + + identity, _session = self._require_scope() + feeds = getattr(self._api, "exchange_feeds", None) + feed = feeds.get(self.exchange_name) if isinstance(feeds, Mapping) else None + client = getattr(feed, "trader_client", None) + if client is None: + raise CtpSimNowExecutionError("ctp_native_query_client_unavailable") + + try: + from bt_api_ctp import CtpNativeQueryCertificateBuilder + from bt_api_ctp.containers.ctp.ctp_native_query_certificate import ( + CtpNativeQueryCertificateError, + ) + except Exception as exc: + raise CtpSimNowExecutionError("ctp_native_query_certificate_unavailable") from exc + + try: + certificate_builder = CtpNativeQueryCertificateBuilder( + client, + instrument_id=instrument_id, + exchange_id=exchange_id, + hedge_flag=hedge_flag, + ) + query_calls = ( + ("account", {}), + ("positions", {}), + ("orders", {}), + ("trades", {}), + ("instruments", {"instrument_id": instrument_id, "exchange_id": exchange_id}), + ( + "margin_rate", + { + "instrument_id": instrument_id, + "exchange_id": exchange_id, + "hedge_flag": hedge_flag, + }, + ), + ( + "commission_rate", + {"instrument_id": instrument_id, "exchange_id": exchange_id}, + ), + ) + for query_type, query_kwargs in query_calls: + result = self._api.query_ctp_result( + self.exchange_name, + query_type, + **query_kwargs, + ) + certificate_builder.add(result) + certificate = certificate_builder.finish() + except CtpNativeQueryCertificateError as exc: + raise CtpSimNowExecutionError(f"ctp_native_query_certificate_{exc.code}") from exc + except CtpSimNowExecutionError: + raise + except Exception as exc: + raise CtpSimNowExecutionError("ctp_native_query_observation_unavailable") from exc + + current_identity, _session = self._require_scope() + expected_account_fingerprint = ( + identity.account_fingerprint[5:] + if identity.account_fingerprint.startswith("acct_") + else identity.account_fingerprint + ) + if ( + current_identity != identity + or certificate.connection_generation != identity.connection_generation + or certificate.trading_day != identity.trading_day + or certificate.account_fingerprint_sha256 + != sha256(expected_account_fingerprint.encode("utf-8")).hexdigest() + or getattr(feeds.get(self.exchange_name), "trader_client", None) is not client + ): + raise CtpSimNowExecutionError("ctp_native_query_session_identity_mismatch") + return CtpSimNowReadObservation(identity, certificate) + + +def _is_open_native_order(row: Any) -> bool: + status = str(_value(row, "OrderStatus", "order_status") or "a").lower() + remaining = _value(row, "VolumeTotal", "volume_total") + if remaining in (None, ""): + original = _value(row, "VolumeTotalOriginal", "volume_total_original") + traded = _value(row, "VolumeTraded", "volume_traded") or 0 + try: + remaining = int(original) - int(traded) + except (TypeError, ValueError): + remaining = 1 + try: + has_remaining = int(remaining) > 0 + except (TypeError, ValueError): + has_remaining = True + # Treat unknown native statuses as potentially open. Only documented CTP + # terminal statuses prove that the order no longer rests in the account. + return status not in _TERMINAL_ORDER_STATUS and has_remaining + + +__all__ = [ + "CTP_FUTURE", + "OFFICIAL_SET1_PROFILES", + "CtpSimNowExecutionAdapter", + "CtpSimNowExecutionError", + "CtpSimNowSessionIdentity", + "CtpSimNowOrderRequest", + "CtpSimNowOrderIdentity", + "CtpSimNowOrderResult", + "CtpSimNowCancelResult", + "CtpSimNowQueryResult", + "CtpSimNowReadObservation", + "build_ctp_simnow_cancel_request", + "map_ctp_simnow_cancel_result", + "map_ctp_simnow_order_result", +] diff --git a/bt_api_py/runtime_plugins/__init__.py b/bt_api_py/runtime_plugins/__init__.py new file mode 100644 index 00000000..36cc9d73 --- /dev/null +++ b/bt_api_py/runtime_plugins/__init__.py @@ -0,0 +1,132 @@ +"""Explicit Iteration 41 managed-runtime capability composition. + +This package never discovers entry points, loads exchange adapters, or reads a +user configuration file. A caller supplies an already-sealed effective +runtime contract and a code-owned catalog of exact capability distributions. +Optional capabilities are imported only after those two checks pass. +""" + +from .cancellation_control import ( + CancellationControlAuditConflictError, + CancellationControlAuditError, + CancellationControlCommandAudit, + CancellationFreezeReleaseResult, + CancellationReconciliationAudit, + CancellationReconciliationEvidence, + CancellationReleaseAuthorizationRequest, + ControlledCancellationReconciliationResult, + DurableCancellationControlAudit, + ManagedCancellationReconciliationControlPort, + ReleaseCancellationFreezeCommand, +) +from .catalog import ( + CapabilityCatalog, + CapabilityPin, + LoadedCapabilities, + RuntimePluginError, +) +from .contracts import ( + CAPABILITY_EXECUTION, + CAPABILITY_GATEWAY, + CAPABILITY_MONITOR, + CAPABILITY_RISK, + CAPABILITY_TRANSPORT_ZMQ, + RuntimeCapabilityContract, +) +from .gateway_dispatch import ( + GatewayExecutionAuthority, + GatewayManagedDispatcher, + GatewayManagedDispatchError, + GatewayManagedExecutionRuntime, + GatewayManagedOutcomeUnknown, + GatewayManagedOutcomeUnknownError, + compose_gateway_execution_authority, + compose_gateway_managed_client, + gateway_command_id, +) +from .instrument_risk import ( + InstrumentRiskAdmission, + NormalizedInstrumentMetadata, + SealedNormalizedInstrumentMetadataSnapshot, + compose_instrument_risk_admission, +) +from .managed import ManagedExecutionRuntime, compose_managed_execution +from .managed_recovery import ( + DurableManagedRecoveryCoordinator, + ManagedRecoveryCoordinatorError, + ManagedRecoveryEvent, + ManagedRecoveryReport, + ManagedRecoveryWork, +) +from .reconcile_control import ( + AuthorizationDecision, + ControlAuditConflictError, + ControlAuditError, + ControlCommandAudit, + ControlCommandStatus, + ControlledReconciliationResult, + DurableReconciliationControlAudit, + FreezeReleaseResult, + ManagedReconciliationControlPort, + ReconciliationAudit, + ReconciliationEvidence, + ReleaseAuthorizationRequest, + ReleaseIntentFreezeCommand, +) + +__all__ = [ + "CAPABILITY_EXECUTION", + "CAPABILITY_GATEWAY", + "CAPABILITY_MONITOR", + "CAPABILITY_RISK", + "CAPABILITY_TRANSPORT_ZMQ", + "AuthorizationDecision", + "CancellationControlAuditConflictError", + "CancellationControlAuditError", + "CancellationControlCommandAudit", + "CancellationFreezeReleaseResult", + "CancellationReconciliationAudit", + "CancellationReconciliationEvidence", + "CancellationReleaseAuthorizationRequest", + "CapabilityCatalog", + "CapabilityPin", + "ControlAuditConflictError", + "ControlAuditError", + "ControlCommandAudit", + "ControlCommandStatus", + "ControlledCancellationReconciliationResult", + "ControlledReconciliationResult", + "DurableReconciliationControlAudit", + "DurableCancellationControlAudit", + "DurableManagedRecoveryCoordinator", + "FreezeReleaseResult", + "GatewayExecutionAuthority", + "GatewayManagedDispatchError", + "GatewayManagedDispatcher", + "GatewayManagedExecutionRuntime", + "GatewayManagedOutcomeUnknown", + "GatewayManagedOutcomeUnknownError", + "InstrumentRiskAdmission", + "NormalizedInstrumentMetadata", + "LoadedCapabilities", + "ManagedReconciliationControlPort", + "ManagedCancellationReconciliationControlPort", + "ManagedExecutionRuntime", + "ManagedRecoveryCoordinatorError", + "ManagedRecoveryEvent", + "ManagedRecoveryReport", + "ManagedRecoveryWork", + "ReconciliationAudit", + "ReconciliationEvidence", + "ReleaseAuthorizationRequest", + "ReleaseIntentFreezeCommand", + "ReleaseCancellationFreezeCommand", + "RuntimeCapabilityContract", + "RuntimePluginError", + "SealedNormalizedInstrumentMetadataSnapshot", + "compose_managed_execution", + "compose_gateway_execution_authority", + "compose_gateway_managed_client", + "compose_instrument_risk_admission", + "gateway_command_id", +] diff --git a/bt_api_py/runtime_plugins/cancellation_control.py b/bt_api_py/runtime_plugins/cancellation_control.py new file mode 100644 index 00000000..0fa32de6 --- /dev/null +++ b/bt_api_py/runtime_plugins/cancellation_control.py @@ -0,0 +1,1390 @@ +"""Fail-closed control for reconciling an unknown managed cancellation. + +The Backtrader bridge creates an account freeze whenever a provider cancellation +attempt has an unknown outcome. A normal cancellation reconciliation records +typed evidence but intentionally cannot clear that latch. This composition +module provides the only local release path: immutable evidence, a durable +audit entry, a durable monitor-outbox fact, and an identity-bound authorization +decision must all be present before one exact cancellation freeze can clear. + +It owns no provider client and does not acquire provider evidence. Callers +must supply a typed ``CancelObservation`` obtained by independently controlled +reconciliation code. Any failure after a local release reasserts the same +freeze; an ``UNKNOWN`` cancellation never releases automatically. +""" + +from __future__ import annotations + +import hashlib +import json +import math +import re +import sqlite3 +import time +from collections.abc import Callable, Iterator, Mapping +from contextlib import contextmanager +from dataclasses import dataclass +from pathlib import Path +from types import MappingProxyType +from typing import Any + +from .catalog import RuntimePluginError +from .reconcile_control import AuthorizationDecision, ControlCommandStatus + +_IDENTIFIER = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$") +_REASON_CODE = re.compile(r"^[a-z][a-z0-9_]{0,127}$") +_SHA256 = re.compile(r"^[0-9a-f]{64}$") + + +class CancellationControlAuditError(RuntimeError): + """Base error for the local cancellation-control audit ledger.""" + + +class CancellationControlAuditConflictError(CancellationControlAuditError): + """An immutable cancellation evidence or command identity was reused.""" + + +def _identifier(value: object, field_name: str) -> str: + if not isinstance(value, str) or value != value.strip() or not _IDENTIFIER.fullmatch(value): + raise ValueError("invalid " + field_name) + return value + + +def _reason_code(value: object, field_name: str) -> str: + if not isinstance(value, str) or not _REASON_CODE.fullmatch(value): + raise ValueError("invalid " + field_name) + return value + + +def _digest(value: object, field_name: str) -> str: + if not isinstance(value, str) or not _SHA256.fullmatch(value): + raise ValueError("invalid " + field_name) + return value + + +def _timestamp(value: object, field_name: str) -> float: + if isinstance(value, bool) or not isinstance(value, (int, float)) or not math.isfinite(value): + raise ValueError("invalid " + field_name) + return float(value) + + +def _canonical_json(value: object) -> str: + return json.dumps( + value, sort_keys=True, separators=(",", ":"), ensure_ascii=True, allow_nan=False + ) + + +def _sha256(value: object) -> str: + return hashlib.sha256(_canonical_json(value).encode("utf-8")).hexdigest() + + +def _observation_projection(observation: object) -> dict[str, object]: + """Return the only redacted cancellation evidence persisted by this module.""" + + try: + state = observation.state.value # type: ignore[attr-defined] + cancel_id = observation.cancel_id # type: ignore[attr-defined] + target_intent_id = observation.target_intent_id # type: ignore[attr-defined] + provider_order_id = observation.provider_order_id # type: ignore[attr-defined] + reason_code = observation.reason_code # type: ignore[attr-defined] + except AttributeError as error: + raise ValueError("invalid typed cancellation observation") from error + return { + "cancel_id": _identifier(cancel_id, "observation cancel_id"), + "target_intent_id": _identifier(target_intent_id, "observation target_intent_id"), + "provider_order_id": _identifier(provider_order_id, "observation provider_order_id"), + "state": _identifier(state, "observation state"), + "reason_code": ( + None if reason_code is None else _reason_code(reason_code, "observation reason_code") + ), + } + + +def _record_projection(record: object) -> dict[str, object]: + """Return a stable, non-secret cancellation record projection for audit checks.""" + + try: + state = record.state.value # type: ignore[attr-defined] + cancel_id = record.cancel_id # type: ignore[attr-defined] + target_intent_id = record.target_intent_id # type: ignore[attr-defined] + provider_order_id = record.provider_order_id # type: ignore[attr-defined] + review_required = record.review_required # type: ignore[attr-defined] + except AttributeError as error: + raise ValueError("invalid cancellation record") from error + if type(review_required) is not bool: + raise ValueError("invalid cancellation record review_required") + return { + "cancel_id": _identifier(cancel_id, "record cancel_id"), + "target_intent_id": _identifier(target_intent_id, "record target_intent_id"), + "provider_order_id": _identifier(provider_order_id, "record provider_order_id"), + "state": _identifier(state, "record state"), + "review_required": review_required, + } + + +@dataclass(frozen=True) +class CancellationReconciliationEvidence: + """Externally obtained typed evidence for one unknown cancellation attempt.""" + + evidence_id: str + cancel_id: str + target_intent_id: str + provider_order_id: str + observation: Any + source_receipt_digest: str + observed_at: float + + def __post_init__(self) -> None: + for name in ("evidence_id", "cancel_id", "target_intent_id", "provider_order_id"): + object.__setattr__(self, name, _identifier(getattr(self, name), name)) + object.__setattr__( + self, + "source_receipt_digest", + _digest(self.source_receipt_digest, "source_receipt_digest"), + ) + object.__setattr__(self, "observed_at", _timestamp(self.observed_at, "observed_at")) + + @property + def fingerprint(self) -> str: + """Return immutable identity binding typed evidence to its receipt digest.""" + + return _sha256( + { + "cancel_id": self.cancel_id, + "evidence_id": self.evidence_id, + "observation": _observation_projection(self.observation), + "observed_at": self.observed_at, + "provider_order_id": self.provider_order_id, + "source_receipt_digest": self.source_receipt_digest, + "target_intent_id": self.target_intent_id, + } + ) + + +@dataclass(frozen=True) +class ReleaseCancellationFreezeCommand: + """One reviewed request to clear exactly one unknown-cancel freeze.""" + + command_id: str + scope: str + cancel_id: str + evidence_id: str + evidence_fingerprint: str + issuer_id: str + reason_code: str + issued_at: float + expires_at: float + + def __post_init__(self) -> None: + for name in ("command_id", "scope", "cancel_id", "evidence_id", "issuer_id"): + object.__setattr__(self, name, _identifier(getattr(self, name), name)) + object.__setattr__( + self, + "evidence_fingerprint", + _digest(self.evidence_fingerprint, "evidence_fingerprint"), + ) + object.__setattr__(self, "reason_code", _reason_code(self.reason_code, "reason_code")) + object.__setattr__(self, "issued_at", _timestamp(self.issued_at, "issued_at")) + object.__setattr__(self, "expires_at", _timestamp(self.expires_at, "expires_at")) + if self.expires_at <= self.issued_at: + raise ValueError("expires_at must be after issued_at") + + @property + def fingerprint(self) -> str: + return _sha256( + { + "cancel_id": self.cancel_id, + "command_id": self.command_id, + "evidence_fingerprint": self.evidence_fingerprint, + "evidence_id": self.evidence_id, + "expires_at": self.expires_at, + "issued_at": self.issued_at, + "issuer_id": self.issuer_id, + "reason_code": self.reason_code, + "scope": self.scope, + } + ) + + +@dataclass(frozen=True) +class CancellationReleaseAuthorizationRequest: + """Redacted context passed to a deployment-owned authorization callback.""" + + command: ReleaseCancellationFreezeCommand + evidence: CancellationReconciliationEvidence + reconciled_record: Mapping[str, object] + + +@dataclass(frozen=True) +class CancellationReconciliationAudit: + """Durable local evidence and monitor-outbox status for one cancellation.""" + + evidence_id: str + fingerprint: str + scope: str + cancel_id: str + target_intent_id: str + provider_order_id: str + observation: Mapping[str, object] + record: Mapping[str, object] + source_receipt_digest: str + observed_at: float + monitor_event_id: str + monitor_published: bool + + +@dataclass(frozen=True) +class CancellationControlCommandAudit: + """Durable state for a requested cancellation-freeze release.""" + + command: ReleaseCancellationFreezeCommand + status: ControlCommandStatus + authorization_subject_id: str | None + authorization_receipt_digest: str | None + authorization_reason_code: str | None + release_applied_at: float | None + released_at: float | None + outcome_code: str | None + + +@dataclass(frozen=True) +class ControlledCancellationReconciliationResult: + """Result of reconciling a cancellation and recording its monitor fact.""" + + record: Any + audit: CancellationReconciliationAudit + + +@dataclass(frozen=True) +class CancellationFreezeReleaseResult: + """Result of a reviewed cancellation-freeze release.""" + + command_id: str + cancel_id: str + released: bool + idempotent: bool + + +class DurableCancellationControlAudit: + """SQLite audit ledger for cancellation evidence and release commands.""" + + def __init__( + self, + database_path: Path | str, + *, + clock: Callable[[], float] | None = None, + timeout_seconds: float = 5.0, + ) -> None: + self._database_path = Path(database_path) + self._clock = clock or time.time + self._timeout_seconds = timeout_seconds + self._database_path.parent.mkdir(parents=True, exist_ok=True) + self._initialize_schema() + + def close(self) -> None: + """Keep a symmetric lifecycle hook; each SQLite operation owns its connection.""" + + def get_reconciliation(self, evidence_id: str) -> CancellationReconciliationAudit | None: + evidence_id = _identifier(evidence_id, "evidence_id") + with self._connection() as connection: + row = connection.execute( + "SELECT * FROM cancellation_control_evidence WHERE evidence_id = ?", (evidence_id,) + ).fetchone() + return None if row is None else self._reconciliation_from_row(row) + + def record_reconciliation( + self, + evidence: CancellationReconciliationEvidence, + *, + scope: str, + observation: Mapping[str, object], + record: Mapping[str, object], + monitor_event_id: str, + ) -> CancellationReconciliationAudit: + """Persist immutable evidence before it can release a risk latch.""" + + scope = _identifier(scope, "scope") + monitor_event_id = _identifier(monitor_event_id, "monitor_event_id") + fingerprint = evidence.fingerprint + observation_json = _canonical_json(observation) + record_json = _canonical_json(record) + now = self._clock() + with self._transaction() as connection: + existing = connection.execute( + "SELECT * FROM cancellation_control_evidence WHERE evidence_id = ?", + (evidence.evidence_id,), + ).fetchone() + if existing is not None: + if ( + str(existing["fingerprint"]) != fingerprint + or str(existing["scope"]) != scope + or str(existing["cancel_id"]) != evidence.cancel_id + or str(existing["target_intent_id"]) != evidence.target_intent_id + or str(existing["provider_order_id"]) != evidence.provider_order_id + or str(existing["monitor_event_id"]) != monitor_event_id + ): + raise CancellationControlAuditConflictError( + "evidence_id was reused with different content" + ) + return self._reconciliation_from_row(existing) + connection.execute( + """ + INSERT INTO cancellation_control_evidence ( + evidence_id, fingerprint, scope, cancel_id, target_intent_id, provider_order_id, + observation_json, record_json, source_receipt_digest, observed_at, + monitor_event_id, monitor_published, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?) + """, + ( + evidence.evidence_id, + fingerprint, + scope, + evidence.cancel_id, + evidence.target_intent_id, + evidence.provider_order_id, + observation_json, + record_json, + evidence.source_receipt_digest, + evidence.observed_at, + monitor_event_id, + now, + now, + ), + ) + row = connection.execute( + "SELECT * FROM cancellation_control_evidence WHERE evidence_id = ?", + (evidence.evidence_id,), + ).fetchone() + assert row is not None + return self._reconciliation_from_row(row) + + def mark_reconciliation_monitor_published( + self, evidence_id: str, monitor_event_id: str + ) -> CancellationReconciliationAudit: + """Record that the exact cancellation fact reached the durable outbox.""" + + evidence_id = _identifier(evidence_id, "evidence_id") + monitor_event_id = _identifier(monitor_event_id, "monitor_event_id") + with self._transaction() as connection: + row = connection.execute( + "SELECT * FROM cancellation_control_evidence WHERE evidence_id = ?", (evidence_id,) + ).fetchone() + if row is None: + raise CancellationControlAuditError("cancellation evidence does not exist") + if str(row["monitor_event_id"]) != monitor_event_id: + raise CancellationControlAuditConflictError( + "monitor event differs from cancellation evidence" + ) + connection.execute( + """ + UPDATE cancellation_control_evidence + SET monitor_published = 1, updated_at = ? WHERE evidence_id = ? + """, + (self._clock(), evidence_id), + ) + updated = connection.execute( + "SELECT * FROM cancellation_control_evidence WHERE evidence_id = ?", (evidence_id,) + ).fetchone() + assert updated is not None + return self._reconciliation_from_row(updated) + + def get_command(self, command_id: str) -> CancellationControlCommandAudit | None: + command_id = _identifier(command_id, "command_id") + with self._connection() as connection: + row = connection.execute( + "SELECT * FROM cancellation_control_commands WHERE command_id = ?", (command_id,) + ).fetchone() + return None if row is None else self._command_from_row(row) + + def record_command( + self, command: ReleaseCancellationFreezeCommand + ) -> CancellationControlCommandAudit: + """Persist an immutable release command before authorization or risk mutation.""" + + now = self._clock() + with self._transaction() as connection: + existing = connection.execute( + "SELECT * FROM cancellation_control_commands WHERE command_id = ?", + (command.command_id,), + ).fetchone() + if existing is not None: + if str(existing["fingerprint"]) != command.fingerprint: + raise CancellationControlAuditConflictError( + "command_id was reused with different content" + ) + return self._command_from_row(existing) + connection.execute( + """ + INSERT INTO cancellation_control_commands ( + command_id, fingerprint, scope, cancel_id, evidence_id, evidence_fingerprint, + issuer_id, reason_code, issued_at, expires_at, status, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + command.command_id, + command.fingerprint, + command.scope, + command.cancel_id, + command.evidence_id, + command.evidence_fingerprint, + command.issuer_id, + command.reason_code, + command.issued_at, + command.expires_at, + ControlCommandStatus.PENDING.value, + now, + ), + ) + self._append_attempt( + connection, command.command_id, "command_recorded", "command_recorded", now + ) + return self._command_from_row(self._command_row(connection, command.command_id)) + + def record_authorization( + self, command_id: str, decision: AuthorizationDecision + ) -> CancellationControlCommandAudit: + """Durably audit an identity-bound authorization before a latch release.""" + + command_id = _identifier(command_id, "command_id") + now = self._clock() + with self._transaction() as connection: + row = self._command_row(connection, command_id) + status = ControlCommandStatus(str(row["status"])) + if status in {ControlCommandStatus.RELEASED, ControlCommandStatus.EXPIRED}: + raise CancellationControlAuditError("command is no longer authorizable") + next_status = ( + ControlCommandStatus.AUTHORIZED + if decision.approved + else ControlCommandStatus.DENIED + ) + connection.execute( + """ + UPDATE cancellation_control_commands + SET status = ?, authorization_subject_id = ?, authorization_receipt_digest = ?, + authorization_reason_code = ?, authorization_at = ?, outcome_code = ?, + updated_at = ? + WHERE command_id = ? + """, + ( + next_status.value, + decision.subject_id, + decision.receipt_digest, + decision.reason_code, + now, + decision.reason_code, + now, + command_id, + ), + ) + self._append_attempt( + connection, + command_id, + "authorization_approved" if decision.approved else "authorization_denied", + decision.reason_code, + now, + ) + return self._command_from_row(self._command_row(connection, command_id)) + + def mark_command_expired(self, command_id: str) -> CancellationControlCommandAudit: + """Durably refuse an expired command without touching the risk latch.""" + + return self._set_command_status( + command_id, + ControlCommandStatus.EXPIRED, + "command_expired", + allowed={ControlCommandStatus.PENDING, ControlCommandStatus.AUTHORIZED}, + ) + + def record_release_applied(self, command_id: str) -> CancellationControlCommandAudit: + """Persist a release preparation before mutating the risk latch. + + The stable ``release_applied_at`` timestamp records only a durable + preparation. It is not a successful release and survives a later + reassertion so a retry uses the same monitor event identity. A new + control port treats every prepared, non-final row as a restart recovery + obligation and restores the cancellation freeze before retrying. + """ + + command_id = _identifier(command_id, "command_id") + now = self._clock() + with self._transaction() as connection: + row = self._command_row(connection, command_id) + if ControlCommandStatus(str(row["status"])) is not ControlCommandStatus.AUTHORIZED: + raise CancellationControlAuditError("command is not authorized") + if row["release_applied_at"] is None: + connection.execute( + """ + UPDATE cancellation_control_commands + SET release_applied_at = ?, outcome_code = ?, updated_at = ? + WHERE command_id = ? + """, + (now, "freeze_release_prepared", now, command_id), + ) + self._append_attempt( + connection, + command_id, + "freeze_release_prepared", + "freeze_release_prepared", + now, + ) + return self._command_from_row(self._command_row(connection, command_id)) + + def mark_command_reasserted( + self, command_id: str, outcome_code: str + ) -> CancellationControlCommandAudit: + """Return a failed release to pending after its safety latch was restored.""" + + command_id = _identifier(command_id, "command_id") + outcome_code = _reason_code(outcome_code, "outcome_code") + now = self._clock() + with self._transaction() as connection: + row = self._command_row(connection, command_id) + if ControlCommandStatus(str(row["status"])) is ControlCommandStatus.RELEASED: + raise CancellationControlAuditError("released command cannot be reasserted") + connection.execute( + """ + UPDATE cancellation_control_commands + SET status = ?, outcome_code = ?, updated_at = ? + WHERE command_id = ? + """, + (ControlCommandStatus.PENDING.value, outcome_code, now, command_id), + ) + self._append_attempt(connection, command_id, "freeze_reasserted", outcome_code, now) + return self._command_from_row(self._command_row(connection, command_id)) + + def unconfirmed_release_commands(self, scope: str) -> list[CancellationControlCommandAudit]: + """Return non-final prepared releases that must re-freeze on restart.""" + + scope = _identifier(scope, "scope") + with self._connection() as connection: + rows = connection.execute( + """ + SELECT * FROM cancellation_control_commands + WHERE scope = ? AND status IN (?, ?) AND release_applied_at IS NOT NULL + ORDER BY command_id ASC + """, + (scope, ControlCommandStatus.PENDING.value, ControlCommandStatus.AUTHORIZED.value), + ).fetchall() + return [self._command_from_row(row) for row in rows] + + def mark_command_released(self, command_id: str) -> CancellationControlCommandAudit: + """Commit a final release only after its monitor fact is durable.""" + + command_id = _identifier(command_id, "command_id") + now = self._clock() + with self._transaction() as connection: + row = self._command_row(connection, command_id) + status = ControlCommandStatus(str(row["status"])) + if status is ControlCommandStatus.RELEASED: + return self._command_from_row(row) + if status is not ControlCommandStatus.AUTHORIZED or row["release_applied_at"] is None: + raise CancellationControlAuditError("command release confirmation is not ready") + connection.execute( + """ + UPDATE cancellation_control_commands + SET status = ?, released_at = ?, outcome_code = ?, updated_at = ? + WHERE command_id = ? + """, + (ControlCommandStatus.RELEASED.value, now, "freeze_released", now, command_id), + ) + self._append_attempt(connection, command_id, "freeze_released", "freeze_released", now) + return self._command_from_row(self._command_row(connection, command_id)) + + def record_pending_failure(self, command_id: str, outcome_code: str) -> None: + """Record a failed attempt while retaining a retryable command and freeze.""" + + command_id = _identifier(command_id, "command_id") + outcome_code = _reason_code(outcome_code, "outcome_code") + now = self._clock() + with self._transaction() as connection: + self._command_row(connection, command_id) + connection.execute( + """ + UPDATE cancellation_control_commands SET outcome_code = ?, updated_at = ? + WHERE command_id = ? + """, + (outcome_code, now, command_id), + ) + self._append_attempt(connection, command_id, "release_not_applied", outcome_code, now) + + def _set_command_status( + self, + command_id: str, + status: ControlCommandStatus, + outcome_code: str, + *, + allowed: set[ControlCommandStatus], + ) -> CancellationControlCommandAudit: + command_id = _identifier(command_id, "command_id") + outcome_code = _reason_code(outcome_code, "outcome_code") + now = self._clock() + with self._transaction() as connection: + row = self._command_row(connection, command_id) + current = ControlCommandStatus(str(row["status"])) + if current is status: + return self._command_from_row(row) + if current not in allowed: + raise CancellationControlAuditError("command has an incompatible lifecycle state") + connection.execute( + """ + UPDATE cancellation_control_commands SET status = ?, outcome_code = ?, updated_at = ? + WHERE command_id = ? + """, + (status.value, outcome_code, now, command_id), + ) + self._append_attempt(connection, command_id, status.value, outcome_code, now) + return self._command_from_row(self._command_row(connection, command_id)) + + @staticmethod + def _command_row(connection: sqlite3.Connection, command_id: str) -> sqlite3.Row: + row = connection.execute( + "SELECT * FROM cancellation_control_commands WHERE command_id = ?", (command_id,) + ).fetchone() + if row is None: + raise CancellationControlAuditError("control command does not exist") + return row + + @staticmethod + def _append_attempt( + connection: sqlite3.Connection, + command_id: str, + event_type: str, + outcome_code: str, + occurred_at: float, + ) -> None: + connection.execute( + """ + INSERT INTO cancellation_control_attempts + (command_id, event_type, outcome_code, occurred_at) + VALUES (?, ?, ?, ?) + """, + (command_id, event_type, outcome_code, occurred_at), + ) + + @staticmethod + def _reconciliation_from_row(row: sqlite3.Row) -> CancellationReconciliationAudit: + return CancellationReconciliationAudit( + evidence_id=str(row["evidence_id"]), + fingerprint=str(row["fingerprint"]), + scope=str(row["scope"]), + cancel_id=str(row["cancel_id"]), + target_intent_id=str(row["target_intent_id"]), + provider_order_id=str(row["provider_order_id"]), + observation=MappingProxyType(json.loads(str(row["observation_json"]))), + record=MappingProxyType(json.loads(str(row["record_json"]))), + source_receipt_digest=str(row["source_receipt_digest"]), + observed_at=float(row["observed_at"]), + monitor_event_id=str(row["monitor_event_id"]), + monitor_published=bool(row["monitor_published"]), + ) + + @staticmethod + def _command_from_row(row: sqlite3.Row) -> CancellationControlCommandAudit: + command = ReleaseCancellationFreezeCommand( + command_id=str(row["command_id"]), + scope=str(row["scope"]), + cancel_id=str(row["cancel_id"]), + evidence_id=str(row["evidence_id"]), + evidence_fingerprint=str(row["evidence_fingerprint"]), + issuer_id=str(row["issuer_id"]), + reason_code=str(row["reason_code"]), + issued_at=float(row["issued_at"]), + expires_at=float(row["expires_at"]), + ) + return CancellationControlCommandAudit( + command=command, + status=ControlCommandStatus(str(row["status"])), + authorization_subject_id=( + None + if row["authorization_subject_id"] is None + else str(row["authorization_subject_id"]) + ), + authorization_receipt_digest=( + None + if row["authorization_receipt_digest"] is None + else str(row["authorization_receipt_digest"]) + ), + authorization_reason_code=( + None + if row["authorization_reason_code"] is None + else str(row["authorization_reason_code"]) + ), + release_applied_at=( + None if row["release_applied_at"] is None else float(row["release_applied_at"]) + ), + released_at=None if row["released_at"] is None else float(row["released_at"]), + outcome_code=None if row["outcome_code"] is None else str(row["outcome_code"]), + ) + + def _initialize_schema(self) -> None: + with self._connection() as connection: + connection.execute("PRAGMA journal_mode = WAL") + connection.executescript( + """ + CREATE TABLE IF NOT EXISTS cancellation_control_evidence ( + evidence_id TEXT PRIMARY KEY, + fingerprint TEXT NOT NULL, + scope TEXT NOT NULL, + cancel_id TEXT NOT NULL, + target_intent_id TEXT NOT NULL, + provider_order_id TEXT NOT NULL, + observation_json TEXT NOT NULL, + record_json TEXT NOT NULL, + source_receipt_digest TEXT NOT NULL, + observed_at REAL NOT NULL, + monitor_event_id TEXT NOT NULL, + monitor_published INTEGER NOT NULL, + created_at REAL NOT NULL, + updated_at REAL NOT NULL + ); + CREATE UNIQUE INDEX IF NOT EXISTS idx_cancellation_control_evidence_fingerprint + ON cancellation_control_evidence(fingerprint); + CREATE TABLE IF NOT EXISTS cancellation_control_commands ( + command_id TEXT PRIMARY KEY, + fingerprint TEXT NOT NULL, + scope TEXT NOT NULL, + cancel_id TEXT NOT NULL, + evidence_id TEXT NOT NULL, + evidence_fingerprint TEXT NOT NULL, + issuer_id TEXT NOT NULL, + reason_code TEXT NOT NULL, + issued_at REAL NOT NULL, + expires_at REAL NOT NULL, + status TEXT NOT NULL, + authorization_subject_id TEXT, + authorization_receipt_digest TEXT, + authorization_reason_code TEXT, + authorization_at REAL, + release_applied_at REAL, + released_at REAL, + outcome_code TEXT, + updated_at REAL NOT NULL + ); + CREATE TABLE IF NOT EXISTS cancellation_control_attempts ( + sequence INTEGER PRIMARY KEY AUTOINCREMENT, + command_id TEXT NOT NULL, + event_type TEXT NOT NULL, + outcome_code TEXT NOT NULL, + occurred_at REAL NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_cancellation_control_attempts_command + ON cancellation_control_attempts(command_id, sequence); + """ + ) + + @contextmanager + def _connection(self) -> Iterator[sqlite3.Connection]: + connection = sqlite3.connect( + str(self._database_path), timeout=self._timeout_seconds, isolation_level=None + ) + connection.row_factory = sqlite3.Row + try: + connection.execute("PRAGMA synchronous = FULL") + yield connection + finally: + connection.close() + + @contextmanager + def _transaction(self) -> Iterator[sqlite3.Connection]: + with self._connection() as connection: + connection.execute("BEGIN IMMEDIATE") + try: + yield connection + except BaseException: + connection.execute("ROLLBACK") + raise + else: + connection.execute("COMMIT") + + +class ManagedCancellationReconciliationControlPort: + """Reconcile a cancellation then release only its audited unknown-outcome latch.""" + + def __init__( + self, + runtime: Any, + cancellation_facade: Any, + *, + state_directory: Path | str, + authorize: Callable[[CancellationReleaseAuthorizationRequest], AuthorizationDecision], + clock: Callable[[], float] | None = None, + ) -> None: + if not callable(authorize): + raise ValueError("authorize callback is required") + self._runtime = runtime + self._cancellation_facade = cancellation_facade + self._authorize = authorize + self._clock = clock or time.time + self._require_runtime_surface() + state_directory = Path(state_directory).resolve(strict=False) + self._audit = DurableCancellationControlAudit( + state_directory / "cancellation_operator_control.sqlite3", clock=self._clock + ) + self._recover_unconfirmed_releases() + + @property + def audit(self) -> DurableCancellationControlAudit: + """Expose read-only audit methods to a deployment-owned monitor adapter.""" + + return self._audit + + def close(self) -> None: + """Close local audit resources; the composed runtime remains caller-owned.""" + + self._audit.close() + + def reconcile( + self, evidence: CancellationReconciliationEvidence + ) -> ControlledCancellationReconciliationResult: + """Apply typed cancellation evidence and publish an immutable monitor fact. + + This method cannot release the unknown-cancel freeze. It accepts only + terminal ``CANCELLED`` or ``REJECTED`` observations; ``ACKED`` means a + cancellation is still in progress and must keep the account frozen. + """ + + observation = self._validated_observation(evidence) + try: + fingerprint = evidence.fingerprint + except ValueError as error: + raise RuntimePluginError( + "CANCELLATION_RECONCILIATION_EVIDENCE_INVALID", + "typed cancellation evidence could not be canonicalized", + ) from error + monitor_event_id = "cancel-reconcile:" + fingerprint + existing = self._audit.get_reconciliation(evidence.evidence_id) + record = self._cancellation_facade.get(evidence.cancel_id) + if record is None: + raise RuntimePluginError( + "CANCELLATION_RECONCILIATION_UNKNOWN", + "cancellation intent is absent from this runtime", + ) + if existing is None: + if record.state.value == "UNKNOWN": + try: + record = self._cancellation_facade.reconcile(observation) + except Exception as error: + raise RuntimePluginError( + "CANCELLATION_RECONCILIATION_PERSISTENCE_FAILED", + "typed cancellation evidence could not be applied", + ) from error + elif not self._record_matches_observation(record, observation): + raise RuntimePluginError( + "CANCELLATION_RECONCILIATION_REQUIRES_UNKNOWN", + "control reconciliation requires an unknown cancel or its exact crash recovery", + ) + if not self._is_reviewed_terminal(record): + raise RuntimePluginError( + "CANCELLATION_RECONCILIATION_INCOMPLETE", + "only terminal cancellation evidence can be reviewed for freeze release", + ) + if not self._record_matches_observation(record, observation): + raise RuntimePluginError( + "CANCELLATION_RECONCILIATION_RESULT_MISMATCH", + "reconciled cancellation record does not match typed provider evidence", + ) + try: + existing = self._audit.record_reconciliation( + evidence, + scope=self._runtime.scope.key, + observation=_observation_projection(observation), + record=_record_projection(record), + monitor_event_id=monitor_event_id, + ) + except CancellationControlAuditConflictError as error: + raise RuntimePluginError( + "CANCELLATION_RECONCILIATION_AUDIT_CONFLICT", + "cancellation evidence identity conflicts with durable audit", + ) from error + except Exception as error: + raise RuntimePluginError( + "CANCELLATION_RECONCILIATION_AUDIT_UNCONFIRMED", + "cancellation evidence could not be durably audited", + ) from error + else: + if ( + existing.fingerprint != fingerprint + or existing.scope != self._runtime.scope.key + or existing.cancel_id != evidence.cancel_id + ): + raise RuntimePluginError( + "CANCELLATION_RECONCILIATION_AUDIT_CONFLICT", + "cancellation evidence differs from the durable audit", + ) + if not self._record_matches_projection(record, existing.record): + raise RuntimePluginError( + "CANCELLATION_RECONCILIATION_EVIDENCE_STALE", + "current cancellation record differs from reviewed evidence", + ) + audit = self._publish_reconciliation_fact(evidence, record, existing) + return ControlledCancellationReconciliationResult(record=record, audit=audit) + + def release_cancel_freeze( + self, command: ReleaseCancellationFreezeCommand + ) -> CancellationFreezeReleaseResult: + """Release exactly one unknown-cancel freeze after all control gates pass.""" + + if command.scope != self._runtime.scope.key: + raise RuntimePluginError( + "CANCELLATION_CONTROL_SCOPE_MISMATCH", + "control command scope does not match the managed runtime", + ) + try: + command_audit = self._audit.record_command(command) + except CancellationControlAuditConflictError as error: + raise RuntimePluginError( + "CANCELLATION_CONTROL_COMMAND_CONFLICT", + "control command identity conflicts with durable audit", + ) from error + except Exception as error: + raise RuntimePluginError( + "CANCELLATION_CONTROL_AUDIT_UNCONFIRMED", + "control command could not be durably recorded", + ) from error + if command_audit.status is ControlCommandStatus.RELEASED: + self._assert_freeze_inactive(command.cancel_id) + return CancellationFreezeReleaseResult( + command.command_id, command.cancel_id, True, True + ) + if command_audit.status is ControlCommandStatus.DENIED: + raise RuntimePluginError( + "CANCELLATION_CONTROL_AUTHORIZATION_DENIED", + "this cancellation control command was durably denied", + ) + if command_audit.status is ControlCommandStatus.EXPIRED: + raise RuntimePluginError( + "CANCELLATION_CONTROL_COMMAND_EXPIRED", "this control command is expired" + ) + if command.expires_at <= self._clock(): + try: + self._audit.mark_command_expired(command.command_id) + except Exception as error: + raise RuntimePluginError( + "CANCELLATION_CONTROL_AUDIT_UNCONFIRMED", + "expired command refusal could not be audited", + ) from error + raise RuntimePluginError( + "CANCELLATION_CONTROL_COMMAND_EXPIRED", "this control command is expired" + ) + + evidence = self._require_published_evidence(command) + record = self._cancellation_facade.get(command.cancel_id) + if record is None or not self._record_matches_projection(record, evidence.record): + self._record_pending_failure(command.command_id, "cancellation_evidence_stale") + raise RuntimePluginError( + "CANCELLATION_RECONCILIATION_EVIDENCE_STALE", + "current cancellation record differs from reviewed evidence", + ) + request = CancellationReleaseAuthorizationRequest( + command=command, + evidence=CancellationReconciliationEvidence( + evidence_id=evidence.evidence_id, + cancel_id=evidence.cancel_id, + target_intent_id=evidence.target_intent_id, + provider_order_id=evidence.provider_order_id, + observation=self._observation_from_audit(evidence), + source_receipt_digest=evidence.source_receipt_digest, + observed_at=evidence.observed_at, + ), + reconciled_record=evidence.record, + ) + decision = self._authorize_command(command, request) + if not decision.approved: + raise RuntimePluginError( + "CANCELLATION_CONTROL_AUTHORIZATION_DENIED", + "cancellation control command was rejected by the authorizer", + ) + self._publish_authorization_fact(command, decision, evidence) + self._assert_freeze_active(command.cancel_id) + try: + applied = self._audit.record_release_applied(command.command_id) + except Exception as error: + raise RuntimePluginError( + "CANCELLATION_CONTROL_AUDIT_UNCONFIRMED", + "cancellation release preparation could not be durably audited", + ) from error + try: + self._runtime.risk_gate.resolve_freeze( + self._runtime.risk_scope, self._freeze_cause(command.cancel_id) + ) + except Exception as error: + self._reassert_after_failed_release( + command.command_id, command.cancel_id, "freeze_resolution_failed" + ) + raise RuntimePluginError( + "CANCELLATION_CONTROL_FREEZE_RESOLUTION_FAILED", + "cancellation freeze could not be resolved", + ) from error + try: + self._assert_freeze_inactive(command.cancel_id) + except RuntimePluginError as error: + self._reassert_after_failed_release( + command.command_id, command.cancel_id, "freeze_resolution_unconfirmed" + ) + raise RuntimePluginError( + "CANCELLATION_CONTROL_FREEZE_RESOLUTION_UNCONFIRMED", + "cancellation freeze release could not be verified", + ) from error + try: + self._append_monitor_event( + event_id="cancel-freeze-release:" + command.command_id, + event_type="cancellation_freeze_released", + data={ + "cancel_id": command.cancel_id, + "command_id": command.command_id, + "evidence_digest": command.evidence_fingerprint, + "issuer_id": command.issuer_id, + "release_applied_at": applied.release_applied_at, + "state": evidence.record["state"], + }, + occurred_at=applied.release_applied_at, + ) + except Exception as error: + self._reassert_after_failed_release( + command.command_id, command.cancel_id, "release_outbox_failed" + ) + raise RuntimePluginError( + "CANCELLATION_CONTROL_MONITOR_OUTBOX_UNCONFIRMED", + "cancellation release monitor fact was not durable; dispatch remains frozen", + ) from error + try: + self._audit.mark_command_released(command.command_id) + except Exception as error: + self._reassert_after_failed_release( + command.command_id, command.cancel_id, "release_audit_failed" + ) + raise RuntimePluginError( + "CANCELLATION_CONTROL_AUDIT_UNCONFIRMED", + "cancellation release confirmation could not be durably audited", + ) from error + return CancellationFreezeReleaseResult(command.command_id, command.cancel_id, True, False) + + def _require_runtime_surface(self) -> None: + required = ( + "outbox", + "risk_gate", + "risk_scope", + "scope", + "execution", + "outbox_event_type", + ) + if any(not hasattr(self._runtime, name) for name in required): + raise ValueError("runtime does not expose the managed cancellation control surface") + if not isinstance(getattr(self._runtime.scope, "key", None), str): + raise ValueError("runtime has an invalid execution scope") + if not callable(getattr(self._cancellation_facade, "get", None)) or not callable( + getattr(self._cancellation_facade, "reconcile", None) + ): + raise ValueError("cancellation facade lacks reconciliation surface") + + def _recover_unconfirmed_releases(self) -> None: + """Reassert prepared-but-unconfirmed cancellation freezes after restart.""" + + try: + commands = self._audit.unconfirmed_release_commands(self._runtime.scope.key) + except Exception as error: + raise RuntimePluginError( + "CANCELLATION_CONTROL_AUDIT_UNCONFIRMED", + "unconfirmed cancellation release records could not be loaded", + ) from error + for audit in commands: + command = audit.command + self._reassert_after_failed_release( + command.command_id, command.cancel_id, "restart_release_unconfirmed" + ) + + def _validated_observation(self, evidence: CancellationReconciliationEvidence) -> Any: + if not isinstance(evidence, CancellationReconciliationEvidence): + raise RuntimePluginError( + "CANCELLATION_RECONCILIATION_EVIDENCE_INVALID", + "CancellationReconciliationEvidence is required", + ) + observation_type = getattr(self._runtime.execution, "CancelObservation", None) + if not isinstance(observation_type, type) or not isinstance( + evidence.observation, observation_type + ): + raise RuntimePluginError( + "CANCELLATION_RECONCILIATION_EVIDENCE_INVALID", + "evidence must contain the runtime execution CancelObservation type", + ) + observation = evidence.observation + if ( + observation.cancel_id != evidence.cancel_id + or observation.target_intent_id != evidence.target_intent_id + or observation.provider_order_id != evidence.provider_order_id + ): + raise RuntimePluginError( + "CANCELLATION_RECONCILIATION_IDENTITY_MISMATCH", + "cancellation observation belongs to another durable target", + ) + if observation.state.value not in {"CANCELLED", "REJECTED"}: + raise RuntimePluginError( + "CANCELLATION_RECONCILIATION_INCOMPLETE", + "only terminal cancellation evidence can support reviewed release", + ) + if observation.state.value == "REJECTED" and observation.reason_code is None: + raise RuntimePluginError( + "CANCELLATION_RECONCILIATION_EVIDENCE_INCOMPLETE", + "rejected cancellation reconciliation requires a provider reason code", + ) + return observation + + def _publish_reconciliation_fact( + self, + evidence: CancellationReconciliationEvidence, + record: Any, + audit: CancellationReconciliationAudit, + ) -> CancellationReconciliationAudit: + if audit.monitor_published: + return audit + try: + projection = _record_projection(record) + self._append_monitor_event( + event_id=audit.monitor_event_id, + event_type="cancellation_reconciled", + data={ + "cancel_id": evidence.cancel_id, + "evidence_digest": audit.fingerprint, + "provider_order_id": projection["provider_order_id"], + "state": projection["state"], + "target_intent_id": evidence.target_intent_id, + }, + occurred_at=evidence.observed_at, + ) + return self._audit.mark_reconciliation_monitor_published( + evidence.evidence_id, audit.monitor_event_id + ) + except Exception as error: + raise RuntimePluginError( + "CANCELLATION_RECONCILIATION_MONITOR_OUTBOX_UNCONFIRMED", + "cancellation reconciliation monitor fact was not durably published", + ) from error + + def _require_published_evidence( + self, command: ReleaseCancellationFreezeCommand + ) -> CancellationReconciliationAudit: + evidence = self._audit.get_reconciliation(command.evidence_id) + if ( + evidence is None + or evidence.scope != self._runtime.scope.key + or evidence.cancel_id != command.cancel_id + or evidence.fingerprint != command.evidence_fingerprint + ): + self._record_pending_failure(command.command_id, "cancellation_evidence_missing") + raise RuntimePluginError( + "CANCELLATION_RECONCILIATION_EVIDENCE_REQUIRED", + "cancellation freeze release requires matching durable evidence", + ) + if not evidence.monitor_published: + self._record_pending_failure(command.command_id, "cancellation_outbox_unconfirmed") + raise RuntimePluginError( + "CANCELLATION_RECONCILIATION_MONITOR_OUTBOX_UNCONFIRMED", + "cancellation release requires durable reconciliation monitor delivery", + ) + return evidence + + def _authorize_command( + self, + command: ReleaseCancellationFreezeCommand, + request: CancellationReleaseAuthorizationRequest, + ) -> AuthorizationDecision: + try: + decision = self._authorize(request) + except Exception as error: + self._record_pending_failure(command.command_id, "authorization_unavailable") + raise RuntimePluginError( + "CANCELLATION_CONTROL_AUTHORIZATION_UNAVAILABLE", + "cancellation control authorization could not be verified", + ) from error + if not isinstance(decision, AuthorizationDecision): + self._record_pending_failure(command.command_id, "authorization_invalid") + raise RuntimePluginError( + "CANCELLATION_CONTROL_AUTHORIZATION_INVALID", + "authorizer must return AuthorizationDecision", + ) + if decision.subject_id != command.issuer_id: + self._record_pending_failure(command.command_id, "authorization_identity_mismatch") + raise RuntimePluginError( + "CANCELLATION_CONTROL_AUTHORIZATION_IDENTITY_MISMATCH", + "authorization identity does not match command issuer", + ) + try: + self._audit.record_authorization(command.command_id, decision) + except Exception as error: + raise RuntimePluginError( + "CANCELLATION_CONTROL_AUDIT_UNCONFIRMED", + "authorization decision could not be durably audited", + ) from error + return decision + + def _publish_authorization_fact( + self, + command: ReleaseCancellationFreezeCommand, + decision: AuthorizationDecision, + evidence: CancellationReconciliationAudit, + ) -> None: + try: + self._append_monitor_event( + event_id="cancel-freeze-release-authorized:" + command.command_id, + event_type="cancellation_freeze_release_authorized", + data={ + "authorization_receipt_digest": decision.receipt_digest, + "cancel_id": command.cancel_id, + "command_id": command.command_id, + "evidence_digest": evidence.fingerprint, + "issuer_id": command.issuer_id, + "state": evidence.record["state"], + }, + occurred_at=command.issued_at, + ) + except Exception as error: + self._record_pending_failure(command.command_id, "authorization_outbox_failed") + raise RuntimePluginError( + "CANCELLATION_CONTROL_MONITOR_OUTBOX_UNCONFIRMED", + "authorization monitor fact was not durable; dispatch remains frozen", + ) from error + + def _append_monitor_event( + self, + *, + event_id: str, + event_type: str, + data: Mapping[str, object], + occurred_at: float | None, + ) -> None: + if occurred_at is None: + raise ValueError("monitor occurred_at is required") + self._runtime.outbox.append( + self._runtime.outbox_event_type( + event_id=event_id, + scope=self._runtime.scope.key, + event_type=event_type, + data=dict(data), + occurred_at=occurred_at, + ) + ) + + def _assert_freeze_active(self, cancel_id: str) -> None: + cause_id = self._freeze_cause(cancel_id) + try: + active_reasons = self._runtime.risk_gate.active_freeze_reasons(self._runtime.risk_scope) + except Exception as error: + raise RuntimePluginError( + "CANCELLATION_CONTROL_FREEZE_STATE_UNAVAILABLE", + "cancellation freeze state could not be verified", + ) from error + if cause_id not in active_reasons: + raise RuntimePluginError( + "CANCELLATION_CONTROL_FREEZE_NOT_ACTIVE", + "unknown cancellation freeze is not active for this cancellation", + ) + + def _assert_freeze_inactive(self, cancel_id: str) -> None: + cause_id = self._freeze_cause(cancel_id) + try: + active_reasons = self._runtime.risk_gate.active_freeze_reasons(self._runtime.risk_scope) + except Exception as error: + raise RuntimePluginError( + "CANCELLATION_CONTROL_FREEZE_STATE_UNAVAILABLE", + "cancellation freeze state could not be verified", + ) from error + if cause_id in active_reasons: + raise RuntimePluginError( + "CANCELLATION_CONTROL_RELEASE_STATE_INCONSISTENT", + "audit says released while the cancellation freeze is active", + ) + + def _reassert_after_failed_release( + self, command_id: str, cancel_id: str, outcome_code: str + ) -> None: + cause_id = self._freeze_cause(cancel_id) + try: + self._runtime.risk_gate.freeze(self._runtime.risk_scope, cause_id, cause_id) + self._assert_freeze_active(cancel_id) + except Exception as error: + raise RuntimePluginError( + "CANCELLATION_CONTROL_FREEZE_REASSERT_FAILED", + "release outcome is uncertain and cancellation freeze could not be restored", + ) from error + try: + self._audit.mark_command_reasserted(command_id, outcome_code) + except Exception as error: + raise RuntimePluginError( + "CANCELLATION_CONTROL_AUDIT_UNCONFIRMED", + "reasserted cancellation freeze could not be durably audited", + ) from error + + def _record_pending_failure(self, command_id: str, outcome_code: str) -> None: + try: + self._audit.record_pending_failure(command_id, outcome_code) + except Exception as error: + raise RuntimePluginError( + "CANCELLATION_CONTROL_AUDIT_UNCONFIRMED", + "control refusal could not be durably audited", + ) from error + + @staticmethod + def _is_reviewed_terminal(record: Any) -> bool: + try: + projection = _record_projection(record) + except ValueError: + return False + return ( + projection["state"] in {"CANCELLED", "REJECTED"} and not projection["review_required"] + ) + + @staticmethod + def _record_matches_observation(record: Any, observation: Any) -> bool: + try: + return _record_projection(record) == { + "cancel_id": _observation_projection(observation)["cancel_id"], + "target_intent_id": _observation_projection(observation)["target_intent_id"], + "provider_order_id": _observation_projection(observation)["provider_order_id"], + "state": _observation_projection(observation)["state"], + "review_required": False, + } + except ValueError: + return False + + @staticmethod + def _record_matches_projection(record: Any, expected: Mapping[str, object]) -> bool: + try: + return _record_projection(record) == dict(expected) + except ValueError: + return False + + def _observation_from_audit(self, evidence: CancellationReconciliationAudit) -> Any: + """Rebuild the package-owned typed observation for the authorizer only.""" + + observation = evidence.observation + return self._runtime.execution.CancelObservation( + cancel_id=observation["cancel_id"], + target_intent_id=observation["target_intent_id"], + provider_order_id=observation["provider_order_id"], + state=observation["state"], + reason_code=observation["reason_code"], + ) + + def _freeze_cause(self, cancel_id: str) -> str: + scope_key = getattr(self._runtime.scope, "key", None) + if not isinstance(scope_key, str) or not scope_key: + raise RuntimePluginError( + "CANCELLATION_CONTROL_SCOPE_MISMATCH", + "runtime cancellation freeze lacks an execution scope", + ) + return "cancel-outcome-unknown:" + scope_key + ":" + _identifier(cancel_id, "cancel_id") + + +__all__ = [ + "CancellationControlAuditConflictError", + "CancellationControlAuditError", + "CancellationControlCommandAudit", + "CancellationFreezeReleaseResult", + "CancellationReconciliationAudit", + "CancellationReconciliationEvidence", + "CancellationReleaseAuthorizationRequest", + "ControlledCancellationReconciliationResult", + "DurableCancellationControlAudit", + "ManagedCancellationReconciliationControlPort", + "ReleaseCancellationFreezeCommand", +] diff --git a/bt_api_py/runtime_plugins/catalog.py b/bt_api_py/runtime_plugins/catalog.py new file mode 100644 index 00000000..eef51674 --- /dev/null +++ b/bt_api_py/runtime_plugins/catalog.py @@ -0,0 +1,138 @@ +"""Explicit distribution pin verification without entry-point discovery.""" + +from __future__ import annotations + +import hashlib +import importlib +import importlib.metadata +from collections.abc import Callable, Mapping +from dataclasses import dataclass +from pathlib import Path +from types import MappingProxyType +from typing import Any + +from .contracts import RuntimeCapabilityContract + + +class RuntimePluginError(RuntimeError): + """Stable fail-closed error for a missing, mismatched, or unsafe capability.""" + + def __init__(self, code: str, message: str) -> None: + super().__init__(message) + self.code = code + + +@dataclass(frozen=True) +class CapabilityPin: + """Code-owned package identity; never populate this from ``config.yaml``.""" + + capability: str + distribution: str + module: str + version: str + module_sha256: str | None = None + + def __post_init__(self) -> None: + if not all( + isinstance(value, str) and value.strip() + for value in (self.capability, self.distribution, self.module, self.version) + ): + raise ValueError("capability pin fields are required") + if self.module_sha256 is not None and ( + len(self.module_sha256) != 64 + or any(char not in "0123456789abcdef" for char in self.module_sha256) + ): + raise ValueError("module_sha256 must be a lower-case SHA-256 digest") + + +@dataclass(frozen=True) +class LoadedCapabilities: + """The only result of catalog loading; access does not trigger imports.""" + + contract: RuntimeCapabilityContract + modules: Mapping[str, Any] + + def __post_init__(self) -> None: + object.__setattr__(self, "modules", MappingProxyType(dict(self.modules))) + + def require(self, capability: str) -> Any: + try: + return self.modules[capability] + except KeyError as error: + raise RuntimePluginError( + "CAPABILITY_NOT_LOADED", "capability was not loaded" + ) from error + + +class CapabilityCatalog: + """A sealed map from allowed capability name to distribution pin.""" + + def __init__( + self, + pins: tuple[CapabilityPin, ...], + *, + importer: Callable[[str], Any] = importlib.import_module, + version_getter: Callable[[str], str] = importlib.metadata.version, + ) -> None: + entries = tuple(pins) + by_capability: dict[str, CapabilityPin] = {} + for pin in entries: + if not isinstance(pin, CapabilityPin): + raise TypeError("catalog entries must be CapabilityPin") + if pin.capability in by_capability: + raise ValueError("catalog has duplicate capability pins") + by_capability[pin.capability] = pin + self._pins = MappingProxyType(by_capability) + self._importer = importer + self._version_getter = version_getter + + @property + def capabilities(self) -> tuple[str, ...]: + return tuple(self._pins) + + def load(self, contract: RuntimeCapabilityContract) -> LoadedCapabilities: + """Verify exact pins then import only the sealed required capabilities.""" + if not isinstance(contract, RuntimeCapabilityContract): + raise TypeError("RuntimeCapabilityContract is required") + modules: dict[str, Any] = {} + for capability in contract.required_capabilities: + pin = self._pins.get(capability) + if pin is None: + raise RuntimePluginError("CAPABILITY_PIN_MISSING", "required capability has no pin") + try: + observed_version = self._version_getter(pin.distribution) + except importlib.metadata.PackageNotFoundError as error: + raise RuntimePluginError( + "CAPABILITY_NOT_INSTALLED", "required capability is not installed" + ) from error + if observed_version != pin.version: + raise RuntimePluginError( + "CAPABILITY_VERSION_MISMATCH", "required capability version differs from pin" + ) + try: + module = self._importer(pin.module) + except Exception as error: + raise RuntimePluginError( + "CAPABILITY_IMPORT_FAILED", "required capability could not be imported" + ) from error + self._verify_module_hash(module, pin) + modules[capability] = module + return LoadedCapabilities(contract=contract, modules=modules) + + @staticmethod + def _verify_module_hash(module: Any, pin: CapabilityPin) -> None: + if pin.module_sha256 is None: + return + filename = getattr(module, "__file__", None) + if not isinstance(filename, str): + raise RuntimePluginError( + "CAPABILITY_HASH_UNAVAILABLE", "module location is unavailable" + ) + try: + actual = hashlib.sha256(Path(filename).read_bytes()).hexdigest() + except OSError as error: + raise RuntimePluginError( + "CAPABILITY_HASH_UNAVAILABLE", "module bytes are unavailable" + ) from error + if actual != pin.module_sha256: + raise RuntimePluginError("CAPABILITY_HASH_MISMATCH", "module hash differs from pin") diff --git a/bt_api_py/runtime_plugins/contracts.py b/bt_api_py/runtime_plugins/contracts.py new file mode 100644 index 00000000..21bff4a5 --- /dev/null +++ b/bt_api_py/runtime_plugins/contracts.py @@ -0,0 +1,200 @@ +"""Sealed-capability contract validation with no optional imports.""" + +from __future__ import annotations + +import hashlib +import json +import re +from collections.abc import Mapping +from dataclasses import dataclass +from typing import cast + +CAPABILITY_EXECUTION = "execution" +CAPABILITY_RISK = "risk" +CAPABILITY_MONITOR = "monitor" +CAPABILITY_GATEWAY = "gateway" +CAPABILITY_TRANSPORT_ZMQ = "transport_zmq" +_ALL_CAPABILITIES = frozenset( + { + CAPABILITY_EXECUTION, + CAPABILITY_RISK, + CAPABILITY_MONITOR, + CAPABILITY_GATEWAY, + CAPABILITY_TRANSPORT_ZMQ, + } +) +_MANAGED_DIRECT_ORDER = (CAPABILITY_EXECUTION, CAPABILITY_RISK, CAPABILITY_MONITOR) +_MANAGED_GATEWAY_ORDER = _MANAGED_DIRECT_ORDER + (CAPABILITY_GATEWAY, CAPABILITY_TRANSPORT_ZMQ) +_ALLOWED_ROUTES = frozenset({None, "read_only", "local_simulation", "managed_execution"}) +_NON_MANAGED_SHAPES = { + "local_backtest": ("backtest", "local", None), + "replay": ("simulation", "offline", None), + "shadow": ("simulation", "public_read", "read_only"), + "paper": ("simulation", "public_read", "local_simulation"), +} +_SHA256 = re.compile(r"^[0-9a-f]{64}$") +_IDENTIFIER = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$") + + +class RuntimeContractError(ValueError): + """A supplied effective contract cannot load a managed capability.""" + + +def _id(value: object, name: str) -> str: + if not isinstance(value, str) or value != value.strip() or not _IDENTIFIER.fullmatch(value): + raise RuntimeContractError("invalid " + name) + return value + + +def _sha(value: object, name: str) -> str: + if not isinstance(value, str) or not _SHA256.fullmatch(value): + raise RuntimeContractError("invalid " + name) + return value + + +def _digest(value: Mapping[str, object]) -> str: + encoded = json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=True).encode( + "utf-8" + ) + return hashlib.sha256(encoded).hexdigest() + + +@dataclass(frozen=True) +class RuntimeCapabilityContract: + """The small, redacted result of configuration-first policy resolution. + + The object is constructed by code after the schema-v4 configuration and its + code-owned registry have been validated. It deliberately cannot express a + user-selectable plugin, module path, provider credential, or route upgrade. + """ + + strategy_id: str + mode: str + preset: str + environment: str + order_route: str | None + required_capabilities: tuple[str, ...] + effective_digest: str + + def __post_init__(self) -> None: + object.__setattr__(self, "strategy_id", _id(self.strategy_id, "strategy_id")) + if self.mode not in {"backtest", "simulation", "live"}: + raise RuntimeContractError("invalid runtime mode") + if self.preset not in { + "local_backtest", + "replay", + "shadow", + "paper", + "sandbox", + "managed_live_direct", + "managed_live_gateway", + }: + raise RuntimeContractError("invalid runtime preset") + if self.environment not in {"local", "offline", "public_read", "sandbox", "production"}: + raise RuntimeContractError("invalid runtime environment") + if self.order_route not in _ALLOWED_ROUTES: + raise RuntimeContractError("invalid runtime order route") + capabilities = tuple(self.required_capabilities) + if len(set(capabilities)) != len(capabilities) or not set(capabilities).issubset( + _ALL_CAPABILITIES + ): + raise RuntimeContractError("invalid required capabilities") + object.__setattr__(self, "required_capabilities", capabilities) + object.__setattr__( + self, "effective_digest", _sha(self.effective_digest, "effective_digest") + ) + self._validate_policy_shape() + + def _validate_policy_shape(self) -> None: + actual = tuple(self.required_capabilities) + if self.preset == "managed_live_direct": + expected = _MANAGED_DIRECT_ORDER + if ( + self.mode != "live" + or self.environment != "production" + or self.order_route != "managed_execution" + or actual != expected + ): + raise RuntimeContractError("managed live contract does not match its sealed policy") + elif self.preset == "managed_live_gateway": + expected = _MANAGED_GATEWAY_ORDER + if ( + self.mode != "live" + or self.environment != "production" + or self.order_route != "managed_execution" + or actual != expected + ): + raise RuntimeContractError("managed live contract does not match its sealed policy") + elif self.preset == "sandbox": + sandbox_default = self.mode == "simulation" and self.environment == "sandbox" + if not sandbox_default: + raise RuntimeContractError("sandbox contract does not match its sealed policy") + if self.order_route is None and not actual: + return + if self.order_route == "managed_execution" and actual == _MANAGED_DIRECT_ORDER: + return + raise RuntimeContractError("sandbox cannot select an unsealed execution route") + elif self.preset == "replay": + if ( + self.mode == "simulation" + and self.environment == "offline" + and self.order_route == "managed_execution" + and actual == _MANAGED_DIRECT_ORDER + ): + # Backtrader only emits this shape for a reviewed + # RegisteredRuntime.offline_managed_execution fixture. The + # contract accepts the sealed projection but cannot make it + # user-selectable: registry resolution remains its authority. + return + expected_shape = _NON_MANAGED_SHAPES["replay"] + if expected_shape != (self.mode, self.environment, self.order_route) or actual: + raise RuntimeContractError("replay contract does not match its sealed policy") + else: + expected_shape = _NON_MANAGED_SHAPES.get(self.preset) + if expected_shape != (self.mode, self.environment, self.order_route) or actual: + raise RuntimeContractError("non-managed contract does not match its sealed policy") + + @property + def is_managed_execution(self) -> bool: + """Return whether this exact resolved policy may load execution packages.""" + + return self.order_route == "managed_execution" + + @property + def is_managed_live(self) -> bool: + return self.preset.startswith("managed_live") + + @classmethod + def from_effective_public_dict(cls, raw: Mapping[str, object]) -> RuntimeCapabilityContract: + """Create a contract from a redacted Backtrader effective-config projection. + + Extra display fields are permitted because ``EffectiveRuntimeConfig`` + deliberately exposes diagnostics. The security-significant subset is + revalidated here and cannot be expanded by those fields. + """ + capabilities = raw.get("required_capabilities") + if not isinstance(capabilities, (list, tuple)): + raise RuntimeContractError("required_capabilities must be an array") + return cls( + strategy_id=cast("str", raw.get("strategy_id")), + mode=cast("str", raw.get("mode")), + preset=cast("str", raw.get("preset")), + environment=cast("str", raw.get("environment")), + order_route=cast("str | None", raw.get("order_route")), + required_capabilities=cast("tuple[str, ...]", tuple(capabilities)), + effective_digest=cast("str", raw.get("effective_digest")), + ) + + def fingerprint(self) -> str: + """Give the composition root a deterministic diagnostic identity.""" + return _digest( + { + "effective_digest": self.effective_digest, + "environment": self.environment, + "mode": self.mode, + "order_route": self.order_route, + "preset": self.preset, + "required_capabilities": list(self.required_capabilities), + "strategy_id": self.strategy_id, + } + ) diff --git a/bt_api_py/runtime_plugins/gateway_dispatch.py b/bt_api_py/runtime_plugins/gateway_dispatch.py new file mode 100644 index 00000000..b35250cf --- /dev/null +++ b/bt_api_py/runtime_plugins/gateway_dispatch.py @@ -0,0 +1,1251 @@ +"""Gateway-only managed submission composition for Iteration 41. + +This module connects the sealed ``managed_live_gateway`` capability shape to +the existing provider-neutral execution, risk, gateway, and ZMQ contracts. A +Backtrader-side client owns only a durable *transport* journal. The gateway +server owns the sole provider dispatch, durable execution authority, and risk +gate. The client never receives a provider port and never invokes the legacy +Store dispatch callback supplied by the framework bridge. + +The implementation is deliberately local-process friendly for acceptance +tests. It does not provision an endpoint, authenticate a real account, or +claim production provider readiness. +""" + +from __future__ import annotations + +import hashlib +import json +import math +import sqlite3 +import time +from collections.abc import Callable, Iterator, Mapping +from contextlib import contextmanager +from dataclasses import dataclass +from decimal import Decimal, InvalidOperation +from pathlib import Path +from threading import RLock +from typing import Any + +from .catalog import LoadedCapabilities, RuntimePluginError +from .contracts import ( + CAPABILITY_EXECUTION, + CAPABILITY_GATEWAY, + CAPABILITY_MONITOR, + CAPABILITY_RISK, + CAPABILITY_TRANSPORT_ZMQ, +) +from .instrument_risk import ( + SealedNormalizedInstrumentMetadataSnapshot, + compose_instrument_risk_admission, +) +from .managed import ManagedExecutionRuntime +from .managed_recovery import DurableManagedRecoveryCoordinator + +_COMMAND_PAYLOAD_SCHEMA = "bt_api_py.iteration41.gateway-managed-command.v1" +_OUTCOME_SCHEMA = "bt_api_py.iteration41.gateway-managed-outcome.v1" +_OUTCOME_FIELDS = frozenset( + { + "average_price", + "command_fingerprint", + "command_id", + "filled_quantity", + "intent_fingerprint", + "intent_id", + "provider_order_id", + "reason_code", + "schema", + "scope_key", + "state", + } +) +_COMMAND_PAYLOAD_FIELDS = frozenset( + { + "contract_fingerprint", + "intent", + "intent_fingerprint", + "intent_id", + "schema", + "scope_key", + } +) + + +class GatewayManagedDispatchError(RuntimeError): + """A gateway-only managed command cannot be safely submitted or decoded.""" + + def __init__(self, code: str, message: str) -> None: + super().__init__(message) + self.code = code + + +class GatewayManagedOutcomeUnknownError(GatewayManagedDispatchError): + """The gateway may have received a command; client execution becomes UNKNOWN.""" + + +# The concise protocol name remains useful to callers. Keep the actual class +# name conventional for exception discovery and tooling. +GatewayManagedOutcomeUnknown = GatewayManagedOutcomeUnknownError + + +@dataclass(frozen=True) +class _GatewayTransportPermit: + """A local journal permit, explicitly not a risk decision. + + ``ManagedExecutionFacade`` requires a typed admission port to advance its + durable client journal. This permit binds one immutable intent to the + gateway transport only. It has no limits, no account authority, and no + provider dispatch path; the server-owned risk gate remains authoritative. + """ + + permit_id: str + + +class _GatewayTransportAdmission: + """Structural local admission for a gateway transport journal only.""" + + def reserve(self, intent: Any) -> _GatewayTransportPermit: + return _GatewayTransportPermit(_transport_permit_id(intent)) + + def validate(self, permit_reference: str, intent: Any) -> _GatewayTransportPermit: + expected = _transport_permit_id(intent) + if permit_reference != expected: + raise GatewayManagedDispatchError( + "GATEWAY_TRANSPORT_PERMIT_MISMATCH", + "gateway transport journal permit does not match intent", + ) + return _GatewayTransportPermit(expected) + + def settle(self, permit_reference: str) -> _GatewayTransportPermit: + return _GatewayTransportPermit(permit_reference) + + def release(self, permit_reference: str, reason: str) -> None: + del permit_reference, reason + + +class _GatewayClientCommandJournal: + """Persist exact command timestamps so restart/reconciliation keeps its fingerprint.""" + + def __init__(self, database_path: Path, clock: Callable[[], float]) -> None: + self._database_path = Path(database_path) + self._clock = clock + self._lock = RLock() + self._database_path.parent.mkdir(parents=True, exist_ok=True) + self._initialize_schema() + + def command_for( + self, + intent: Any, + *, + contract: Any, + gateway: Any, + ttl_seconds: float, + ) -> Any: + """Return the one persisted gateway command for an immutable intent.""" + + _validate_gateway_intent(intent) + scope_key = intent.scope.key + intent_fingerprint = intent.fingerprint + command_id = gateway_command_id(intent) + contract_fingerprint = contract.fingerprint() + with self._transaction() as connection: + row = connection.execute( + """ + SELECT intent_fingerprint, contract_fingerprint, command_payload_json, + command_id, command_fingerprint + FROM gateway_client_commands WHERE scope_key = ? AND intent_id = ? + """, + (scope_key, intent.intent_id), + ).fetchone() + if row is not None: + if ( + str(row["intent_fingerprint"]) != intent_fingerprint + or str(row["contract_fingerprint"]) != contract_fingerprint + ): + raise GatewayManagedDispatchError( + "GATEWAY_COMMAND_ID_REUSED", + "gateway client journal saw a changed intent or effective contract", + ) + try: + payload = json.loads(str(row["command_payload_json"])) + command = gateway.gateway_command_from_wire_payload(payload) + except Exception as error: + raise GatewayManagedDispatchError( + "GATEWAY_COMMAND_JOURNAL_CORRUPT", + "gateway client command journal cannot prove command identity", + ) from error + if ( + command.command_id != command_id + or command.command_id != str(row["command_id"]) + or command.fingerprint != payload["command_fingerprint"] + or command.fingerprint != str(row["command_fingerprint"]) + ): + raise GatewayManagedDispatchError( + "GATEWAY_COMMAND_JOURNAL_CORRUPT", + "gateway client command identity does not match the intent", + ) + return command + + issued_at = _finite_clock(self._clock) + command = gateway.GatewayCommand( + command_id=command_id, + account_scope=intent.scope.account_key, + strategy_scope=scope_key, + kind=gateway.GatewayCommandKind.SUBMIT, + payload=_command_payload(intent, contract_fingerprint), + receipt_digest=contract.effective_digest, + issued_at=issued_at, + expires_at=issued_at + ttl_seconds, + ) + payload = gateway.gateway_command_to_wire_payload(command) + connection.execute( + """ + INSERT INTO gateway_client_commands ( + scope_key, intent_id, intent_fingerprint, contract_fingerprint, + command_id, command_fingerprint, command_payload_json, created_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + scope_key, + intent.intent_id, + intent_fingerprint, + contract_fingerprint, + command.command_id, + command.fingerprint, + _canonical_json(payload), + issued_at, + ), + ) + return command + + def _initialize_schema(self) -> None: + with self._connection() as connection: + connection.execute("PRAGMA journal_mode = WAL") + connection.execute("PRAGMA synchronous = FULL") + connection.executescript( + """ + CREATE TABLE IF NOT EXISTS gateway_client_commands ( + scope_key TEXT NOT NULL, + intent_id TEXT NOT NULL, + intent_fingerprint TEXT NOT NULL, + contract_fingerprint TEXT NOT NULL, + command_id TEXT NOT NULL, + command_fingerprint TEXT NOT NULL, + command_payload_json TEXT NOT NULL, + created_at REAL NOT NULL, + PRIMARY KEY(scope_key, intent_id), + UNIQUE(command_id) + ); + """ + ) + + @contextmanager + def _connection(self) -> Iterator[sqlite3.Connection]: + with self._lock: + connection = sqlite3.connect( + str(self._database_path), timeout=5.0, isolation_level=None + ) + connection.row_factory = sqlite3.Row + try: + yield connection + finally: + connection.close() + + @contextmanager + def _transaction(self) -> Iterator[sqlite3.Connection]: + with self._connection() as connection: + connection.execute("BEGIN IMMEDIATE") + try: + yield connection + except BaseException: + connection.execute("ROLLBACK") + raise + else: + connection.execute("COMMIT") + + +@dataclass +class GatewayManagedDispatcher: + """Client dispatch port that can only send one strict gateway wire command.""" + + execution: Any + gateway: Any + transport: Any + contract: Any + scope: Any + client: Any + command_journal: _GatewayClientCommandJournal + timeout_ms: int + command_ttl_seconds: float + instrument_metadata_snapshot: SealedNormalizedInstrumentMetadataSnapshot + instrument_clock_ns: Callable[[], int] + clock: Callable[[], float] = time.time + + def __post_init__(self) -> None: + _validate_gateway_contract(self.contract) + _validate_scope(self.scope, self.contract) + if not isinstance( + self.instrument_metadata_snapshot, SealedNormalizedInstrumentMetadataSnapshot + ): + raise GatewayManagedDispatchError( + "INSTRUMENT_SNAPSHOT_INVALID", + "gateway dispatcher needs sealed instrument metadata", + ) + self.instrument_metadata_snapshot.require_execution_scope(self.scope) + self.instrument_metadata_snapshot.require_live_metadata() + if not callable(getattr(self.client, "request", None)): + raise GatewayManagedDispatchError( + "GATEWAY_CLIENT_REQUIRED", "gateway dispatcher needs a typed command client" + ) + if type(self.timeout_ms) is not int or self.timeout_ms <= 0: + raise GatewayManagedDispatchError( + "GATEWAY_TIMEOUT_INVALID", "gateway timeout must be positive" + ) + if ( + isinstance(self.command_ttl_seconds, bool) + or not isinstance(self.command_ttl_seconds, (int, float)) + or not math.isfinite(float(self.command_ttl_seconds)) + or self.command_ttl_seconds <= 0 + ): + raise GatewayManagedDispatchError( + "GATEWAY_COMMAND_TTL_INVALID", "gateway command TTL must be positive and finite" + ) + if not callable(self.instrument_clock_ns): + raise GatewayManagedDispatchError( + "INSTRUMENT_SNAPSHOT_CLOCK_INVALID", + "gateway metadata clock must be callable", + ) + + def command_for_intent(self, intent: Any) -> Any: + """Return the persisted command used for this intent without sending it.""" + + self._require_scope(intent) + self._require_bound_metadata(intent) + return self.command_journal.command_for( + intent, + contract=self.contract, + gateway=self.gateway, + ttl_seconds=float(self.command_ttl_seconds), + ) + + def submit(self, intent: Any) -> Any: + """Send one durable command and return only typed provider evidence. + + Any client timeout, malformed response, or transport exception raises. + ``ManagedExecutionFacade`` then records the already-claimed client + intent as ``UNKNOWN``. There is intentionally no direct SDK/Broker + fallback and no retry loop here. + """ + + command = self.command_for_intent(intent) + message = self.transport.WireMessage( + message_id=command.command_id, + channel=self.transport.WireChannel.COMMAND, + scope=command.strategy_scope, + sequence=_wire_sequence(command), + sent_at=_finite_clock(self.clock), + payload=self.gateway.gateway_command_to_wire_payload(command), + ) + try: + response = self.client.request(message, timeout_ms=self.timeout_ms) + except Exception as error: + raise GatewayManagedOutcomeUnknown( + "GATEWAY_TRANSPORT_OUTCOME_UNKNOWN", + "gateway command result requires reconciliation", + ) from error + return _provider_observation_from_wire_response( + execution=self.execution, + gateway=self.gateway, + transport=self.transport, + command=command, + intent=intent, + response=response, + ) + + def _require_scope(self, intent: Any) -> None: + _validate_gateway_intent(intent) + if intent.scope != self.scope: + raise GatewayManagedDispatchError( + "GATEWAY_INTENT_SCOPE_MISMATCH", "gateway intent scope differs from runtime scope" + ) + + def _require_bound_metadata(self, intent: Any) -> None: + """Verify client-side metadata before a command can leave this process.""" + + effect = getattr(getattr(intent, "position_effect", None), "value", None) + self.instrument_metadata_snapshot.require_bound_intent( + intent, + now_ns=self.instrument_clock_ns(), + require_fresh=effect == "OPEN", + ) + + +@dataclass +class GatewayManagedExecutionRuntime: + """Backtrader-bindable client runtime with a local durable UNKNOWN journal. + + ``submit`` accepts the framework bridge's legacy callback only to retain + that bridge's small protocol. It deliberately never invokes the callback. + The only dispatch port is :attr:`dispatcher`, which sends an authenticated + gateway command. Provider execution and risk admission belong exclusively + to :class:`GatewayExecutionAuthority` on the server side. + """ + + contract: Any + execution: Any + scope: Any + dispatcher: GatewayManagedDispatcher + execution_store: Any + _facade: Any + instrument_metadata_snapshot: SealedNormalizedInstrumentMetadataSnapshot + + gateway_dispatch: str = "zmq_gateway_v1" + + def __post_init__(self) -> None: + _validate_gateway_contract(self.contract) + _validate_scope(self.scope, self.contract) + if ( + self.dispatcher.contract != self.contract + or self.dispatcher.scope != self.scope + or self.dispatcher.execution is not self.execution + or self.dispatcher.instrument_metadata_snapshot is not self.instrument_metadata_snapshot + or not callable(getattr(self._facade, "submit", None)) + ): + raise GatewayManagedDispatchError( + "GATEWAY_CLIENT_RUNTIME_INVALID", + "gateway client runtime components do not share one sealed scope", + ) + self.instrument_metadata_snapshot.require_execution_scope(self.scope) + self.instrument_metadata_snapshot.require_live_metadata() + + def submit(self, intent: Any, legacy_dispatch: Callable[[Any], Any]) -> Any: + """Record/send through the gateway while intentionally discarding direct dispatch.""" + + if not callable(legacy_dispatch): + raise GatewayManagedDispatchError( + "GATEWAY_LEGACY_PORT_INVALID", "framework bridge did not supply a dispatch callback" + ) + del legacy_dispatch + if getattr(intent, "scope", None) != self.scope: + raise GatewayManagedDispatchError( + "GATEWAY_INTENT_SCOPE_MISMATCH", "gateway intent scope differs from runtime scope" + ) + self.recover() + return self._facade.submit(intent, self.dispatcher) + + def recover(self) -> tuple[str, ...]: + """Turn a client-side pre-authority dispatch gap into ``UNKNOWN``. + + The gateway client does not own provider risk or a provider port, so it + cannot resolve an uncertain command. It can only fence its local + writer, record ``UNKNOWN``, and leave the server authority to reconcile + any command that may already have crossed the transport boundary. + This method deliberately performs no gateway or provider I/O. + """ + + try: + writer_lease = self._facade.acquire_writer_lease() + dispatching = self.execution_store.list_dispatching(self.scope) + recovered: list[str] = [] + for record in dispatching: + unknown = self.execution_store.mark_unknown( + record.intent_id, + self.scope, + "gateway_client_dispatch_reconciliation_required", + writer_lease=writer_lease, + ) + if unknown.state.value != "UNKNOWN": + raise GatewayManagedDispatchError( + "GATEWAY_CLIENT_RECOVERY_INVALID", + "gateway client recovery did not retain an unknown command", + ) + recovered.append(record.intent_id) + return tuple(recovered) + except GatewayManagedDispatchError: + raise + except Exception as error: + raise GatewayManagedDispatchError( + "GATEWAY_CLIENT_RECOVERY_FAILED", + "gateway client could not durably retain an uncertain command", + ) from error + + def close(self) -> None: + """Release the local writer lease and close only the client journal.""" + + first_error: Exception | None = None + for component in (self._facade, self.execution_store): + try: + component.close() + except Exception as error: + if first_error is None: + first_error = error + if first_error is not None: + raise first_error + + +class GatewayExecutionAuthority: + """Server-side gateway authority for one scope and one provider dispatch port.""" + + def __init__( + self, + *, + server_runtime: ManagedExecutionRuntime, + gateway: Any, + transport: Any, + router_database: Path, + provider_dispatch: Any, + server_admission: Callable[[Any, Any], bool] | None = None, + writer_authority: Any | None = None, + clock: Callable[[], float] = time.time, + ) -> None: + _validate_gateway_contract(server_runtime.contract) + _validate_scope(server_runtime.scope, server_runtime.contract) + if not ( + callable(provider_dispatch) or callable(getattr(provider_dispatch, "submit", None)) + ): + raise GatewayManagedDispatchError( + "GATEWAY_PROVIDER_PORT_INVALID", + "gateway authority needs a server provider dispatch port", + ) + if server_admission is not None and not callable(server_admission): + raise GatewayManagedDispatchError( + "GATEWAY_SERVER_ADMISSION_INVALID", + "gateway authority server admission must be callable", + ) + if writer_authority is not None and not isinstance( + writer_authority, gateway.GatewayAccountWriterAuthority + ): + raise GatewayManagedDispatchError( + "GATEWAY_WRITER_AUTHORITY_INVALID", + "gateway writer authority must be an explicit server-owned authority", + ) + self.contract = server_runtime.contract + self.execution = server_runtime.execution + self.scope = server_runtime.scope + self.server_runtime = server_runtime + self.gateway = gateway + self.transport = transport + self._provider_dispatch = provider_dispatch + self._clock = clock + self.router = gateway.GatewayCommandRouter( + router_database, + self._execute_command, + clock=clock, + admission=server_admission, + writer_authority=writer_authority, + ) + + def handle(self, principal: Any, message: Any) -> Mapping[str, Any]: + """Handle one authenticated wire message through the durable router only.""" + + if not isinstance(principal, self.gateway.GatewayPrincipal): + raise GatewayManagedDispatchError( + "GATEWAY_PRINCIPAL_REQUIRED", "gateway principal must be derived by the server" + ) + if not isinstance(message, self.transport.WireMessage): + raise GatewayManagedDispatchError("GATEWAY_WIRE_INVALID", "gateway message is invalid") + if message.channel is not self.transport.WireChannel.COMMAND: + raise GatewayManagedDispatchError( + "GATEWAY_COMMAND_CHANNEL_REQUIRED", + "gateway authority accepts command messages only", + ) + try: + command = self.gateway.gateway_command_from_wire_payload(message.payload) + except Exception as error: + raise GatewayManagedDispatchError( + "GATEWAY_COMMAND_WIRE_INVALID", "gateway command mapping is invalid" + ) from error + intent = self._intent_from_command(command, message) + try: + result = self.router.dispatch(principal, command) + except Exception: + # The command may already be journaled/issued to the provider. The + # transport server converts this to a bounded rejection; the client + # treats that response as UNKNOWN rather than a safe retry signal. + raise + if result.status is self.gateway.GatewayCommandStatus.SUCCEEDED: + return _validated_gateway_outcome( + result.outcome, + command=command, + intent=intent, + contract=self.contract, + ) + return _unknown_gateway_outcome(command, intent) + + def create_zmq_server( + self, + endpoint: str, + authenticator: Callable[[bytes, Any], Any], + **kwargs: Any, + ) -> Any: + """Create a transport server wired to this authority's sole handler.""" + + return self.transport.ZmqCommandServer(endpoint, authenticator, self.handle, **kwargs) + + def close(self) -> None: + """Close the server execution/risk journals; transport lifetime is caller-owned.""" + + self.server_runtime.close() + + def _intent_from_command(self, command: Any, message: Any) -> Any: + if command.kind is not self.gateway.GatewayCommandKind.SUBMIT: + raise GatewayManagedDispatchError( + "GATEWAY_COMMAND_KIND_INVALID", "managed authority accepts submit commands only" + ) + if command.manual_resume_authorized: + raise GatewayManagedDispatchError( + "GATEWAY_MANUAL_RESUME_INVALID", + "managed submission cannot carry resume authorization", + ) + if message.scope != command.strategy_scope or message.sequence != _wire_sequence(command): + raise GatewayManagedDispatchError( + "GATEWAY_WIRE_IDENTITY_MISMATCH", + "gateway wire stream does not match command identity", + ) + if ( + command.receipt_digest != self.contract.effective_digest + or command.account_scope != self.scope.account_key + or command.strategy_scope != self.scope.key + ): + raise GatewayManagedDispatchError( + "GATEWAY_CONTRACT_SCOPE_MISMATCH", + "gateway command does not match the server effective contract and scope", + ) + payload = command.payload + _require_exact_mapping(payload, _COMMAND_PAYLOAD_FIELDS, "gateway command payload") + if ( + payload["schema"] != _COMMAND_PAYLOAD_SCHEMA + or payload["contract_fingerprint"] != self.contract.fingerprint() + or payload["scope_key"] != self.scope.key + or not isinstance(payload["intent"], Mapping) + or not isinstance(payload["intent_id"], str) + or not isinstance(payload["intent_fingerprint"], str) + ): + raise GatewayManagedDispatchError( + "GATEWAY_COMMAND_PAYLOAD_INVALID", + "gateway command payload is not an exact managed intent", + ) + try: + intent = self.execution.order_intent_from_payload(payload["intent"]) + except Exception as error: + raise GatewayManagedDispatchError( + "GATEWAY_INTENT_PAYLOAD_INVALID", "gateway command intent is invalid" + ) from error + if ( + intent.scope != self.scope + or intent.intent_id != payload["intent_id"] + or intent.fingerprint != payload["intent_fingerprint"] + or command.command_id != gateway_command_id(intent) + ): + raise GatewayManagedDispatchError( + "GATEWAY_INTENT_IDENTITY_MISMATCH", + "gateway command changed intent identity or scope", + ) + return intent + + def _execute_command(self, command: Any) -> Mapping[str, Any]: + """The router's only server-side provider/risk execution callback.""" + + # The router has already authenticated/authorized scope and durably + # persisted ``command``. Revalidate the payload independent of the + # transport before using the server-owned execution runtime. + intent = self._intent_from_command( + command, + _SyntheticWireIdentity(command.strategy_scope, _wire_sequence(command)), + ) + record = self.server_runtime.submit(intent, self._provider_dispatch) + return _gateway_outcome_from_record(command, intent, record) + + +@dataclass(frozen=True) +class _SyntheticWireIdentity: + """Minimal internal identity used after the router has accepted a command.""" + + scope: str + sequence: int + + +def compose_gateway_managed_client( + capabilities: LoadedCapabilities, + *, + state_directory: Path, + provider: str, + environment: str, + account_ref: str, + strategy_id: str, + writer_id: str, + client: Any, + timeout_ms: int = 5_000, + command_ttl_seconds: float = 30.0, + clock: Callable[[], float] = time.time, + trading_day: str | None = None, + instrument_metadata_snapshot: Any | None = None, + instrument_clock_ns: Callable[[], int] | None = None, +) -> GatewayManagedExecutionRuntime: + """Create the client-only durable gateway submission runtime. + + The returned object has the same ``contract``, ``execution``, ``scope``, + ``submit`` and ``close`` shape that Backtrader's managed bridge requires. + Its ``submit`` method cannot reach a legacy Store provider callback. + """ + + contract = capabilities.contract + _validate_gateway_contract(contract) + if strategy_id != contract.strategy_id: + raise RuntimePluginError( + "STRATEGY_SCOPE_MISMATCH", "gateway client strategy does not match effective contract" + ) + if environment != contract.environment: + raise RuntimePluginError( + "ENVIRONMENT_SCOPE_MISMATCH", + "gateway client environment does not match effective contract", + ) + snapshot = _require_gateway_snapshot(instrument_metadata_snapshot) + _validate_gateway_capabilities(capabilities) + execution = capabilities.require(CAPABILITY_EXECUTION) + gateway = capabilities.require(CAPABILITY_GATEWAY) + transport = capabilities.require(CAPABILITY_TRANSPORT_ZMQ) + state_directory = Path(state_directory).resolve(strict=False) + scope = execution.ExecutionScope( + provider=provider, + environment=environment, + account_ref=account_ref, + strategy_id=strategy_id, + trading_day=trading_day, + ) + snapshot.require_execution_scope(scope) + snapshot.require_live_metadata() + store = execution.SqliteExecutionStore(state_directory / "gateway_client_execution.sqlite3") + try: + journal = _GatewayClientCommandJournal( + state_directory / "gateway_client_commands.sqlite3", clock + ) + dispatcher = GatewayManagedDispatcher( + execution=execution, + gateway=gateway, + transport=transport, + contract=contract, + scope=scope, + client=client, + command_journal=journal, + timeout_ms=timeout_ms, + command_ttl_seconds=command_ttl_seconds, + instrument_metadata_snapshot=snapshot, + instrument_clock_ns=instrument_clock_ns or time.time_ns, + clock=clock, + ) + facade = execution.ManagedExecutionFacade( + store, + scope, + writer_id=writer_id, + admission_gate=_GatewayTransportAdmission(), + ) + except Exception: + store.close() + raise + return GatewayManagedExecutionRuntime( + contract=contract, + execution=execution, + scope=scope, + dispatcher=dispatcher, + execution_store=store, + _facade=facade, + instrument_metadata_snapshot=snapshot, + ) + + +def compose_gateway_execution_authority( + capabilities: LoadedCapabilities, + *, + state_directory: Path, + provider: str, + environment: str, + account_ref: str, + strategy_id: str, + writer_id: str, + policy_id: str, + max_increase_notional: Decimal, + max_increase_count: int, + provider_dispatch: Any, + server_admission: Callable[[Any, Any], bool] | None = None, + writer_authority: Any | None = None, + permit_ttl_seconds: float = 30.0, + clock: Callable[[], float] = time.time, + trading_day: str | None = None, + instrument_metadata_snapshot: Any | None = None, + instrument_clock_ns: Callable[[], int] | None = None, +) -> GatewayExecutionAuthority: + """Create the sole server-side execution/risk authority for a gateway route.""" + + contract = capabilities.contract + _validate_gateway_contract(contract) + if strategy_id != contract.strategy_id: + raise RuntimePluginError( + "STRATEGY_SCOPE_MISMATCH", + "gateway authority strategy does not match effective contract", + ) + if environment != contract.environment: + raise RuntimePluginError( + "ENVIRONMENT_SCOPE_MISMATCH", + "gateway authority environment does not match effective contract", + ) + snapshot = _require_gateway_snapshot(instrument_metadata_snapshot) + _validate_gateway_capabilities(capabilities) + execution = capabilities.require(CAPABILITY_EXECUTION) + risk = capabilities.require(CAPABILITY_RISK) + monitor = capabilities.require(CAPABILITY_MONITOR) + gateway = capabilities.require(CAPABILITY_GATEWAY) + transport = capabilities.require(CAPABILITY_TRANSPORT_ZMQ) + state_directory = Path(state_directory).resolve(strict=False) + scope = execution.ExecutionScope( + provider=provider, + environment=environment, + account_ref=account_ref, + strategy_id=strategy_id, + trading_day=trading_day, + ) + snapshot.require_execution_scope(scope) + snapshot.require_live_metadata() + risk_scope = risk.AccountScope( + provider=provider, + environment=environment, + account_id=account_ref, + ) + risk_policy = risk.RiskPolicy( + policy_id=policy_id, + max_increase_notional=max_increase_notional, + max_increase_count=max_increase_count, + permit_ttl_seconds=permit_ttl_seconds, + ) + risk_gate = risk.DurableRiskGate(state_directory / "risk.sqlite3", risk_policy, clock=clock) + + execution_store = execution.SqliteExecutionStore(state_directory / "execution.sqlite3") + try: + # The facade invokes this adapter's ``claim_for_dispatch`` immediately + # before the sole provider port. The shared risk gate performs the + # validation plus ``dispatch-inflight`` freeze in one transaction; do + # not install a second check-then-freeze guard in this composition. + instrument_admission = compose_instrument_risk_admission( + capabilities, + risk_gate=risk_gate, + risk_scope=risk_scope, + normalized_snapshot=snapshot, + execution_scope=scope, + clock_ns=instrument_clock_ns, + ) + + facade = execution.ManagedExecutionFacade( + execution_store, + scope, + writer_id=writer_id, + admission_gate=instrument_admission.admission_gate, + ) + outbox = monitor.DurableOutbox(state_directory / "monitor.sqlite3") + recovery_coordinator = DurableManagedRecoveryCoordinator( + state_directory / "managed_recovery.sqlite3" + ) + runtime = ManagedExecutionRuntime( + contract=contract, + facade=facade, + execution_store=execution_store, + outbox=outbox, + risk_gate=risk_gate, + risk_scope=risk_scope, + scope=scope, + execution=execution, + outbox_event_type=monitor.OutboxEvent, + state_directory=state_directory, + instrument_admission=instrument_admission, + instrument_metadata_snapshot=snapshot, + recovery_coordinator=recovery_coordinator, + ) + return GatewayExecutionAuthority( + server_runtime=runtime, + gateway=gateway, + transport=transport, + router_database=state_directory / "gateway_router.sqlite3", + provider_dispatch=provider_dispatch, + server_admission=server_admission, + writer_authority=writer_authority, + clock=clock, + ) + except Exception: + execution_store.close() + risk_gate.close() + raise + + +def gateway_command_id(intent: Any) -> str: + """Return a scope-qualified stable command ID for one immutable intent.""" + + _validate_gateway_intent(intent) + digest = hashlib.sha256( + _canonical_json( + { + "intent_fingerprint": intent.fingerprint, + "intent_id": intent.intent_id, + "scope_key": intent.scope.key, + } + ).encode("utf-8") + ).hexdigest() + return "iteration41.gateway." + digest + + +def _validate_gateway_capabilities(capabilities: LoadedCapabilities) -> None: + _validate_gateway_contract(capabilities.contract) + for capability in ( + CAPABILITY_EXECUTION, + CAPABILITY_RISK, + CAPABILITY_MONITOR, + CAPABILITY_GATEWAY, + CAPABILITY_TRANSPORT_ZMQ, + ): + capabilities.require(capability) + + +def _validate_gateway_contract(contract: Any) -> None: + if ( + getattr(contract, "preset", None) != "managed_live_gateway" + or getattr(contract, "mode", None) != "live" + or getattr(contract, "environment", None) != "production" + or getattr(contract, "order_route", None) != "managed_execution" + or tuple(getattr(contract, "required_capabilities", ())) + != ( + CAPABILITY_EXECUTION, + CAPABILITY_RISK, + CAPABILITY_MONITOR, + CAPABILITY_GATEWAY, + CAPABILITY_TRANSPORT_ZMQ, + ) + or not callable(getattr(contract, "fingerprint", None)) + ): + raise RuntimePluginError( + "GATEWAY_CONTRACT_REQUIRED", "gateway composition requires the sealed gateway contract" + ) + + +def _require_gateway_snapshot( + snapshot: Any | None, +) -> SealedNormalizedInstrumentMetadataSnapshot: + """Require the same sealed metadata authority as direct managed execution.""" + + if snapshot is None: + raise RuntimePluginError( + "INSTRUMENT_SNAPSHOT_REQUIRED", + "managed live gateway composition requires sealed instrument metadata", + ) + if not isinstance(snapshot, SealedNormalizedInstrumentMetadataSnapshot): + raise RuntimePluginError( + "INSTRUMENT_SNAPSHOT_INVALID", + "managed live gateway composition needs a sealed normalized metadata snapshot", + ) + return snapshot + + +def _validate_scope(scope: Any, contract: Any) -> None: + if ( + scope is None + or getattr(scope, "strategy_id", None) != contract.strategy_id + or getattr(scope, "environment", None) != contract.environment + or not isinstance(getattr(scope, "key", None), str) + or not isinstance(getattr(scope, "account_key", None), str) + ): + raise GatewayManagedDispatchError( + "GATEWAY_SCOPE_INVALID", "gateway execution scope does not match the effective contract" + ) + + +def _validate_gateway_intent(intent: Any) -> None: + if ( + not isinstance(getattr(intent, "intent_id", None), str) + or not isinstance(getattr(intent, "fingerprint", None), str) + or getattr(intent, "scope", None) is None + or not callable(getattr(intent, "to_payload", None)) + ): + raise GatewayManagedDispatchError( + "GATEWAY_INTENT_INVALID", "gateway dispatch needs an immutable typed execution intent" + ) + + +def _command_payload(intent: Any, contract_fingerprint: str) -> dict[str, Any]: + return { + "contract_fingerprint": contract_fingerprint, + "intent": intent.to_payload(), + "intent_fingerprint": intent.fingerprint, + "intent_id": intent.intent_id, + "schema": _COMMAND_PAYLOAD_SCHEMA, + "scope_key": intent.scope.key, + } + + +def _transport_permit_id(intent: Any) -> str: + return ( + "gateway-transport." + + hashlib.sha256( + _canonical_json( + { + "intent_fingerprint": intent.fingerprint, + "intent_id": intent.intent_id, + "scope_key": intent.scope.key, + } + ).encode("utf-8") + ).hexdigest() + ) + + +def _wire_sequence(command: Any) -> int: + return int(command.fingerprint[:16], 16) + + +def _gateway_outcome_from_record(command: Any, intent: Any, record: Any) -> dict[str, Any]: + return { + "average_price": _decimal_text_or_none(getattr(record, "average_price", None)), + "command_fingerprint": command.fingerprint, + "command_id": command.command_id, + "filled_quantity": _decimal_text_or_zero(getattr(record, "filled_quantity", None)), + "intent_fingerprint": intent.fingerprint, + "intent_id": intent.intent_id, + "provider_order_id": _text_or_none(getattr(record, "provider_order_id", None)), + "reason_code": _text_or_none(getattr(record, "unknown_reason", None)), + "schema": _OUTCOME_SCHEMA, + "scope_key": intent.scope.key, + "state": str(getattr(getattr(record, "state", None), "value", "UNKNOWN")), + } + + +def _unknown_gateway_outcome(command: Any, intent: Any) -> dict[str, Any]: + return { + "average_price": None, + "command_fingerprint": command.fingerprint, + "command_id": command.command_id, + "filled_quantity": "0", + "intent_fingerprint": intent.fingerprint, + "intent_id": intent.intent_id, + "provider_order_id": None, + "reason_code": "gateway_reconciliation_required", + "schema": _OUTCOME_SCHEMA, + "scope_key": intent.scope.key, + "state": "UNKNOWN", + } + + +def _provider_observation_from_wire_response( + *, + execution: Any, + gateway: Any, + transport: Any, + command: Any, + intent: Any, + response: Any, +) -> Any: + if not isinstance(response, transport.WireMessage): + raise GatewayManagedOutcomeUnknown( + "GATEWAY_RESPONSE_INVALID", "gateway response is not a typed wire message" + ) + if ( + response.channel is not transport.WireChannel.RESPONSE + or response.message_id != command.command_id + or response.scope != command.strategy_scope + or response.sequence != _wire_sequence(command) + or not isinstance(response.payload, Mapping) + or set(response.payload) != {"accepted", "outcome", "status"} + ): + raise GatewayManagedOutcomeUnknown( + "GATEWAY_RESPONSE_IDENTITY_MISMATCH", "gateway response cannot prove command identity" + ) + if response.payload["accepted"] is not True or response.payload["status"] != "handled": + raise GatewayManagedOutcomeUnknown( + "GATEWAY_RESPONSE_UNCONFIRMED", "gateway did not return a confirmed command outcome" + ) + return _provider_observation_from_outcome( + execution=execution, + command=command, + intent=intent, + outcome=response.payload["outcome"], + ) + + +def _provider_observation_from_outcome( + *, execution: Any, command: Any, intent: Any, outcome: Any +) -> Any: + _validated_gateway_outcome(outcome, command=command, intent=intent, contract=None) + state = outcome["state"] + if state == "UNKNOWN": + raise GatewayManagedOutcomeUnknown( + "GATEWAY_EXECUTION_UNKNOWN", "gateway execution needs reconciliation" + ) + if state in {"REJECTED", "BLOCKED"}: + reason = "gateway_rejected" if state == "REJECTED" else "gateway_blocked" + return execution.ProviderObservation.rejected(intent.intent_id, reason) + if state not in {"ACKED", "PARTIALLY_FILLED", "FILLED", "CANCELLED"}: + raise GatewayManagedOutcomeUnknown( + "GATEWAY_EXECUTION_STATE_UNPROVEN", "gateway outcome does not prove provider state" + ) + provider_order_id = outcome["provider_order_id"] + if not isinstance(provider_order_id, str) or not provider_order_id.strip(): + raise GatewayManagedOutcomeUnknown( + "GATEWAY_PROVIDER_ID_UNPROVEN", "gateway outcome lacks provider order identity" + ) + if state == "ACKED": + if outcome["filled_quantity"] != "0" or outcome["average_price"] is not None: + raise GatewayManagedOutcomeUnknown( + "GATEWAY_ACK_EVIDENCE_INVALID", + "gateway acknowledgement includes unsupported fill evidence", + ) + return execution.ProviderObservation.accepted(intent.intent_id, provider_order_id) + filled_quantity = _parse_nonnegative_decimal(outcome["filled_quantity"], "filled_quantity") + average_price = _parse_positive_decimal(outcome["average_price"], "average_price") + try: + return execution.ProviderObservation( + intent.intent_id, + execution.ExecutionState(state), + provider_order_id=provider_order_id, + filled_quantity=filled_quantity, + average_price=average_price, + ) + except Exception as error: + raise GatewayManagedOutcomeUnknown( + "GATEWAY_PROVIDER_EVIDENCE_INVALID", "gateway outcome is not valid provider evidence" + ) from error + + +def _validated_gateway_outcome( + outcome: Any, + *, + command: Any, + intent: Any, + contract: Any | None, +) -> dict[str, Any]: + if not isinstance(outcome, Mapping): + raise GatewayManagedDispatchError( + "GATEWAY_OUTCOME_INVALID", "gateway outcome must be an object" + ) + _require_exact_mapping(outcome, _OUTCOME_FIELDS, "gateway outcome") + if ( + outcome["schema"] != _OUTCOME_SCHEMA + or outcome["command_id"] != command.command_id + or outcome["command_fingerprint"] != command.fingerprint + or outcome["intent_id"] != intent.intent_id + or outcome["intent_fingerprint"] != intent.fingerprint + or outcome["scope_key"] != intent.scope.key + or not isinstance(outcome["state"], str) + or not isinstance(outcome["filled_quantity"], str) + or outcome["provider_order_id"] is not None + and not isinstance(outcome["provider_order_id"], str) + or outcome["average_price"] is not None + and not isinstance(outcome["average_price"], str) + or outcome["reason_code"] is not None + and not isinstance(outcome["reason_code"], str) + ): + raise GatewayManagedDispatchError( + "GATEWAY_OUTCOME_IDENTITY_MISMATCH", "gateway outcome cannot prove command identity" + ) + del contract + return dict(outcome) + + +def _require_exact_mapping(value: Mapping[str, Any], fields: frozenset[str], name: str) -> None: + if set(value) != fields: + raise GatewayManagedDispatchError( + "GATEWAY_MAPPING_FIELDS_INVALID", name + " contains missing or unknown fields" + ) + + +def _canonical_json(value: Any) -> str: + return json.dumps( + value, sort_keys=True, separators=(",", ":"), ensure_ascii=True, allow_nan=False + ) + + +def _finite_clock(clock: Callable[[], float]) -> float: + value = clock() + if ( + isinstance(value, bool) + or not isinstance(value, (int, float)) + or not math.isfinite(float(value)) + ): + raise GatewayManagedDispatchError( + "GATEWAY_CLOCK_INVALID", "gateway clock must return a finite number" + ) + return float(value) + + +def _decimal_text_or_zero(value: Any) -> str: + if value is None: + return "0" + return _decimal_text(value) + + +def _decimal_text_or_none(value: Any) -> str | None: + return None if value is None else _decimal_text(value) + + +def _decimal_text(value: Any) -> str: + try: + decimal_value = Decimal(str(value)) + except (InvalidOperation, TypeError, ValueError) as error: + raise GatewayManagedDispatchError( + "GATEWAY_DECIMAL_INVALID", "gateway outcome has an invalid decimal" + ) from error + if not decimal_value.is_finite(): + raise GatewayManagedDispatchError( + "GATEWAY_DECIMAL_INVALID", "gateway outcome has a non-finite decimal" + ) + return format(decimal_value, "f") + + +def _parse_nonnegative_decimal(value: Any, name: str) -> Decimal: + parsed = _parse_decimal(value, name) + if parsed < 0: + raise GatewayManagedOutcomeUnknown( + "GATEWAY_DECIMAL_INVALID", "gateway " + name + " must be non-negative" + ) + return parsed + + +def _parse_positive_decimal(value: Any, name: str) -> Decimal: + parsed = _parse_decimal(value, name) + if parsed <= 0: + raise GatewayManagedOutcomeUnknown( + "GATEWAY_DECIMAL_INVALID", "gateway " + name + " must be positive" + ) + return parsed + + +def _parse_decimal(value: Any, name: str) -> Decimal: + if not isinstance(value, str): + raise GatewayManagedOutcomeUnknown( + "GATEWAY_DECIMAL_INVALID", "gateway " + name + " must be a decimal string" + ) + try: + parsed = Decimal(value) + except (InvalidOperation, ValueError) as error: + raise GatewayManagedOutcomeUnknown( + "GATEWAY_DECIMAL_INVALID", "gateway " + name + " is invalid" + ) from error + if not parsed.is_finite(): + raise GatewayManagedOutcomeUnknown( + "GATEWAY_DECIMAL_INVALID", "gateway " + name + " must be finite" + ) + return parsed + + +def _text_or_none(value: Any) -> str | None: + if value is None: + return None + if not isinstance(value, str) or not value.strip(): + return None + return value + + +__all__ = [ + "GatewayExecutionAuthority", + "GatewayManagedDispatchError", + "GatewayManagedDispatcher", + "GatewayManagedExecutionRuntime", + "GatewayManagedOutcomeUnknown", + "GatewayManagedOutcomeUnknownError", + "compose_gateway_execution_authority", + "compose_gateway_managed_client", + "gateway_command_id", +] diff --git a/bt_api_py/runtime_plugins/gateway_transport_server.py b/bt_api_py/runtime_plugins/gateway_transport_server.py new file mode 100644 index 00000000..0b1c3362 --- /dev/null +++ b/bt_api_py/runtime_plugins/gateway_transport_server.py @@ -0,0 +1,203 @@ +"""Authenticated transport-to-gateway server composition. + +The ZMQ transport authenticates remote peers and returns a +``RemotePrincipalGrant``. The gateway router intentionally accepts a +different, typed ``GatewayPrincipal``. This module is the narrow server-side +bridge between those contracts: it converts only a transport-issued grant, +strictly decodes the canonical gateway command envelope, and delegates to an +already composed gateway authority. + +This module does not supply permissions, admission, a writer lease, or a +provider. Non-read commands remain governed by ``GatewayCommandRouter`` and +are rejected there unless its server-owned admission and writer authority are +present and current. +""" + +from __future__ import annotations + +from collections.abc import Callable, Mapping +from typing import Any + + +class GatewayTransportCompositionError(RuntimeError): + """The server bridge lacks a required typed gateway or transport contract.""" + + def __init__(self, code: str, message: str) -> None: + super().__init__(message) + self.code = code + + +def gateway_principal_from_remote_grant(gateway: Any, transport: Any, grant: Any) -> Any: + """Convert one authenticated transport grant to the gateway's exact DTO. + + The transport's identity and scope labels are server-owned ACL values. + Command kinds are converted by exact enum value; unknown or differently + typed values fail closed rather than being copied into the gateway DTO. + """ + + grant_type = getattr(transport, "RemotePrincipalGrant", None) + principal_type = getattr(gateway, "GatewayPrincipal", None) + command_kind_type = getattr(gateway, "GatewayCommandKind", None) + if grant_type is None or principal_type is None or command_kind_type is None: + raise GatewayTransportCompositionError( + "GATEWAY_TRANSPORT_CONTRACT_MISSING", + "gateway and transport principal contracts are required", + ) + if not isinstance(grant, grant_type): + raise GatewayTransportCompositionError( + "REMOTE_PRINCIPAL_GRANT_REQUIRED", + "remote gateway requests require a transport ACL grant", + ) + try: + kinds = frozenset(command_kind_type(kind) for kind in grant.allowed_kinds) + except (TypeError, ValueError) as error: + raise GatewayTransportCompositionError( + "REMOTE_PRINCIPAL_KIND_INVALID", + "transport ACL contains a command kind unsupported by the gateway", + ) from error + try: + return principal_type( + principal_id=grant.principal_id, + account_scopes=frozenset({grant.account_scope}), + strategy_scopes=frozenset({grant.strategy_scope}), + allowed_kinds=kinds, + ) + except (TypeError, ValueError) as error: + raise GatewayTransportCompositionError( + "GATEWAY_PRINCIPAL_INVALID", + "authenticated transport grant cannot form a gateway principal", + ) from error + + +class GatewayTransportCommandHandler: + """Strictly adapt authenticated transport requests to a gateway authority. + + ``authority`` is the existing server-side composition (for example + ``GatewayExecutionAuthority``). Its ``handle`` method owns the final + command-specific admission and calls ``GatewayCommandRouter.dispatch``. + The adapter validates the canonical envelope before handing control to + that authority, so transport ACL acceptance alone can never dispatch a + malformed gateway command. + """ + + def __init__(self, *, gateway: Any, transport: Any, authority: Any) -> None: + required_gateway = ( + "GatewayCommandKind", + "GatewayCommandRouter", + "GatewayPrincipal", + "GatewayWireMappingError", + "gateway_command_from_wire_payload", + ) + required_transport = ("RemotePrincipalGrant", "WireChannel", "WireMessage") + if any(getattr(gateway, name, None) is None for name in required_gateway): + raise GatewayTransportCompositionError( + "GATEWAY_CONTRACT_MISSING", "canonical gateway command contracts are required" + ) + if any(getattr(transport, name, None) is None for name in required_transport): + raise GatewayTransportCompositionError( + "TRANSPORT_CONTRACT_MISSING", "authenticated transport contracts are required" + ) + if getattr(authority, "gateway", None) is not gateway: + raise GatewayTransportCompositionError( + "GATEWAY_AUTHORITY_MISMATCH", + "authority must be composed with the supplied gateway module", + ) + if getattr(authority, "transport", None) is not transport: + raise GatewayTransportCompositionError( + "TRANSPORT_AUTHORITY_MISMATCH", + "authority must be composed with the supplied transport module", + ) + if not isinstance(getattr(authority, "router", None), gateway.GatewayCommandRouter): + raise GatewayTransportCompositionError( + "GATEWAY_ROUTER_REQUIRED", + "authority must expose the server-side GatewayCommandRouter", + ) + if not callable(getattr(authority, "handle", None)): + raise GatewayTransportCompositionError( + "GATEWAY_AUTHORITY_REQUIRED", "a server-side gateway authority is required" + ) + self.gateway = gateway + self.transport = transport + self.authority = authority + + def __call__(self, grant: Any, message: Any) -> Mapping[str, Any]: + """Validate principal and command before delegating to the authority.""" + + principal = gateway_principal_from_remote_grant(self.gateway, self.transport, grant) + if not isinstance(message, self.transport.WireMessage): + raise GatewayTransportCompositionError( + "GATEWAY_WIRE_INVALID", "gateway message must be a validated WireMessage" + ) + if message.channel is not self.transport.WireChannel.COMMAND: + raise GatewayTransportCompositionError( + "GATEWAY_COMMAND_CHANNEL_REQUIRED", + "gateway authority accepts command messages only", + ) + try: + command = self.gateway.gateway_command_from_wire_payload(message.payload) + except Exception as error: + raise GatewayTransportCompositionError( + "GATEWAY_COMMAND_WIRE_INVALID", + "gateway command schema or fingerprint is invalid", + ) from error + if message.scope != command.strategy_scope: + raise GatewayTransportCompositionError( + "GATEWAY_WIRE_SCOPE_MISMATCH", + "transport stream scope does not match the canonical command", + ) + if command.account_scope not in principal.account_scopes: + raise GatewayTransportCompositionError( + "GATEWAY_ACCOUNT_SCOPE_DENIED", "authenticated principal lacks account scope" + ) + if command.strategy_scope not in principal.strategy_scopes: + raise GatewayTransportCompositionError( + "GATEWAY_STRATEGY_SCOPE_DENIED", "authenticated principal lacks strategy scope" + ) + if command.kind not in principal.allowed_kinds: + raise GatewayTransportCompositionError( + "GATEWAY_COMMAND_KIND_DENIED", "authenticated principal lacks command permission" + ) + outcome = self.authority.handle(principal, message) + if not isinstance(outcome, Mapping): + raise GatewayTransportCompositionError( + "GATEWAY_OUTCOME_INVALID", "gateway authority must return an outcome mapping" + ) + return outcome + + +def create_gateway_zmq_server( + *, + gateway: Any, + transport: Any, + authority: Any, + endpoint: str, + authenticator: Callable[[bytes, Any], Any], + **server_options: Any, +) -> Any: + """Create a ZMQ server wired through the strict gateway command handler. + + All security-sensitive dependencies are explicit. In remote TCP mode the + transport additionally requires CurveZMQ credentials and a non-empty + ``remote_principals`` ACL; the injected authenticator is used only by the + transport's local fake/in-process path. + """ + + handler = GatewayTransportCommandHandler( + gateway=gateway, + transport=transport, + authority=authority, + ) + server_type = getattr(transport, "ZmqCommandServer", None) + if server_type is None: + raise GatewayTransportCompositionError( + "TRANSPORT_SERVER_MISSING", "transport ZMQ command server is unavailable" + ) + return server_type(endpoint, authenticator, handler, **server_options) + + +__all__ = [ + "GatewayTransportCommandHandler", + "GatewayTransportCompositionError", + "create_gateway_zmq_server", + "gateway_principal_from_remote_grant", +] diff --git a/bt_api_py/runtime_plugins/instrument_risk.py b/bt_api_py/runtime_plugins/instrument_risk.py new file mode 100644 index 00000000..2dffde87 --- /dev/null +++ b/bt_api_py/runtime_plugins/instrument_risk.py @@ -0,0 +1,765 @@ +"""Sealed normalized metadata composition for instrument-aware managed admission. + +The provider-neutral bt_api_risk package owns deterministic calculations. This +module converts a reviewed, normalized provider snapshot into that calculation +without constructing a provider client or reading user configuration. +""" + +from __future__ import annotations + +import hashlib +import json +import re +from collections.abc import Callable, Iterable, Mapping +from dataclasses import dataclass, field +from decimal import Decimal, InvalidOperation +from types import MappingProxyType +from typing import Any + +from .catalog import LoadedCapabilities, RuntimePluginError +from .contracts import CAPABILITY_EXECUTION, CAPABILITY_RISK + +_SNAPSHOT_SCHEMA = "bt_api_py.normalized-instrument-snapshot.v1" +_RECORD_SCHEMA = "bt_api_py.normalized-instrument-record.v1" +_IDENTIFIER = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$") +_INSTRUMENT = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:/-]{0,191}$") +_CURRENCY = re.compile(r"^[A-Z0-9][A-Z0-9._-]{1,15}$") +_TRADING_DAY = re.compile(r"^[0-9]{8}$") +_SHA256 = re.compile(r"^[0-9a-f]{64}$") +_QUANTITY_UNIT = re.compile(r"^[a-z][a-z0-9_-]{0,31}$") +_INSTRUMENT_METADATA_DIGEST_TAG = "instrument_metadata_digest" +_QUANTITY_UNIT_TAG = "quantity_unit" + + +def _canonical_json(value: object) -> str: + return json.dumps(value, ensure_ascii=True, separators=(",", ":"), sort_keys=True) + + +def _sha256(value: str) -> str: + return hashlib.sha256(value.encode("utf-8")).hexdigest() + + +def _decimal_text(value: Decimal) -> str: + return format(value, "f") + + +def _text(value: object, name: str, *, pattern: re.Pattern[str] = _IDENTIFIER) -> str: + if ( + not isinstance(value, str) + or value != value.strip() + or not pattern.fullmatch(value) + ): + raise ValueError("invalid " + name) + return value + + +def _currency(value: object, name: str) -> str: + return _text(value, name, pattern=_CURRENCY) + + +def _quantity_unit(value: object, name: str) -> str: + return _text(value, name, pattern=_QUANTITY_UNIT) + + +def _decimal( + value: object, + name: str, + *, + positive: bool = False, + non_negative: bool = False, +) -> Decimal: + if isinstance(value, (bool, float)): + raise ValueError("invalid " + name) + try: + result = value if isinstance(value, Decimal) else Decimal(str(value)) + except (InvalidOperation, TypeError, ValueError) as error: + raise ValueError("invalid " + name) from error + if ( + not result.is_finite() + or (positive and result <= 0) + or (non_negative and result < 0) + ): + raise ValueError("invalid " + name) + return result + + +def _timestamp_ns(value: object, name: str) -> int: + if type(value) is not int or value <= 0: + raise ValueError("invalid " + name) + return value + + +def _optional_decimal(value: object, name: str) -> Decimal | None: + return None if value is None else _decimal(value, name, positive=True) + + +def _require_exact_keys( + value: Mapping[str, object], + *, + required: frozenset[str], + optional: frozenset[str] = frozenset(), + name: str, +) -> None: + keys = set(value) + if required - keys or keys - required - optional: + raise ValueError("invalid " + name + " fields") + + +@dataclass(frozen=True) +class NormalizedInstrumentMetadata: + """One provider-normalized, account-currency instrument risk record.""" + + instrument: str + tick_size: Decimal + lot_size: Decimal + contract_multiplier: Decimal + max_gross_notional_account: Decimal + quote_currency: str + fee_currency: str + account_currency: str + quote_to_account_fx: Decimal + fee_to_account_fx: Decimal + taker_fee_bps: Decimal + fixed_fee: Decimal + max_slippage_bps: Decimal + min_quantity: Decimal | None = None + max_quantity: Decimal | None = None + quantity_unit: str | None = None + + def __post_init__(self) -> None: + object.__setattr__( + self, + "instrument", + _text(self.instrument, "instrument", pattern=_INSTRUMENT), + ) + for name in ("quote_currency", "fee_currency", "account_currency"): + object.__setattr__(self, name, _currency(getattr(self, name), name)) + for name in ( + "tick_size", + "lot_size", + "contract_multiplier", + "max_gross_notional_account", + "quote_to_account_fx", + "fee_to_account_fx", + ): + object.__setattr__( + self, name, _decimal(getattr(self, name), name, positive=True) + ) + for name in ("taker_fee_bps", "fixed_fee", "max_slippage_bps"): + object.__setattr__( + self, name, _decimal(getattr(self, name), name, non_negative=True) + ) + for name in ("min_quantity", "max_quantity"): + object.__setattr__(self, name, _optional_decimal(getattr(self, name), name)) + if self.quantity_unit is not None: + object.__setattr__( + self, + "quantity_unit", + _quantity_unit(self.quantity_unit, "quantity_unit"), + ) + if ( + self.min_quantity is not None + and self.max_quantity is not None + and self.min_quantity > self.max_quantity + ): + raise ValueError("min_quantity cannot exceed max_quantity") + if ( + self.quote_currency == self.account_currency + and self.quote_to_account_fx != Decimal("1") + ): + raise ValueError("quote_to_account_fx must be one for account currency") + if ( + self.fee_currency == self.account_currency + and self.fee_to_account_fx != Decimal("1") + ): + raise ValueError("fee_to_account_fx must be one for account currency") + + @classmethod + def from_normalized_payload( + cls, value: Mapping[str, object], *, account_currency: str + ) -> NormalizedInstrumentMetadata: + """Parse one exact normalized-provider record without provider I/O.""" + + if not isinstance(value, Mapping): + raise ValueError("normalized instrument metadata must be a mapping") + required = frozenset( + { + "instrument", + "tick_size", + "lot_size", + "contract_multiplier", + "max_gross_notional_account", + "quote_currency", + "fee_currency", + "quote_to_account_fx", + "fee_to_account_fx", + "taker_fee_bps", + "fixed_fee", + "max_slippage_bps", + } + ) + _require_exact_keys( + value, + required=required, + # Raw normalized provider facts deliberately do not need the + # serialization-only fields. A sealed snapshot's ``to_payload`` + # does include them, however, so accept and verify those fields + # rather than making a serialized snapshot impossible to reload. + optional=frozenset( + { + "min_quantity", + "max_quantity", + "quantity_unit", + "schema", + "account_currency", + } + ), + name="normalized metadata", + ) + schema = value.get("schema") + if schema is not None and schema != _RECORD_SCHEMA: + raise ValueError("invalid normalized metadata schema") + serialized_currency = value.get("account_currency") + if serialized_currency is not None and _currency( + serialized_currency, "account_currency" + ) != account_currency: + raise ValueError("record account currency does not match snapshot") + return cls( + instrument=value["instrument"], + tick_size=value["tick_size"], + lot_size=value["lot_size"], + contract_multiplier=value["contract_multiplier"], + max_gross_notional_account=value["max_gross_notional_account"], + quote_currency=value["quote_currency"], + fee_currency=value["fee_currency"], + account_currency=account_currency, + quote_to_account_fx=value["quote_to_account_fx"], + fee_to_account_fx=value["fee_to_account_fx"], + taker_fee_bps=value["taker_fee_bps"], + fixed_fee=value["fixed_fee"], + max_slippage_bps=value["max_slippage_bps"], + min_quantity=value.get("min_quantity"), + max_quantity=value.get("max_quantity"), + quantity_unit=value.get("quantity_unit"), + ) + + def to_payload(self) -> dict[str, object]: + payload: dict[str, object] = { + "account_currency": self.account_currency, + "contract_multiplier": _decimal_text(self.contract_multiplier), + "fee_currency": self.fee_currency, + "fee_to_account_fx": _decimal_text(self.fee_to_account_fx), + "fixed_fee": _decimal_text(self.fixed_fee), + "instrument": self.instrument, + "lot_size": _decimal_text(self.lot_size), + "max_gross_notional_account": _decimal_text( + self.max_gross_notional_account + ), + "max_quantity": None + if self.max_quantity is None + else _decimal_text(self.max_quantity), + "max_slippage_bps": _decimal_text(self.max_slippage_bps), + "min_quantity": None + if self.min_quantity is None + else _decimal_text(self.min_quantity), + "quote_currency": self.quote_currency, + "quote_to_account_fx": _decimal_text(self.quote_to_account_fx), + "schema": _RECORD_SCHEMA, + "taker_fee_bps": _decimal_text(self.taker_fee_bps), + "tick_size": _decimal_text(self.tick_size), + } + if self.quantity_unit is not None: + payload["quantity_unit"] = self.quantity_unit + return payload + + +@dataclass(frozen=True) +class SealedNormalizedInstrumentMetadataSnapshot: + """Scope-bound, time-bounded normalized provider metadata.""" + + provider: str + environment: str + account_ref: str + trading_day: str + metadata_version: str + as_of_ns: int + expires_at_ns: int + account_currency: str + instruments: tuple[NormalizedInstrumentMetadata, ...] + + def __post_init__(self) -> None: + for name in ("provider", "environment", "account_ref", "metadata_version"): + object.__setattr__(self, name, _text(getattr(self, name), name)) + object.__setattr__( + self, + "trading_day", + _text(self.trading_day, "trading_day", pattern=_TRADING_DAY), + ) + object.__setattr__( + self, + "account_currency", + _currency(self.account_currency, "account_currency"), + ) + object.__setattr__(self, "as_of_ns", _timestamp_ns(self.as_of_ns, "as_of_ns")) + object.__setattr__( + self, "expires_at_ns", _timestamp_ns(self.expires_at_ns, "expires_at_ns") + ) + if self.expires_at_ns <= self.as_of_ns: + raise ValueError("expires_at_ns must be after as_of_ns") + records = tuple(self.instruments) + if not records: + raise ValueError("normalized metadata snapshot needs instruments") + by_instrument: dict[str, NormalizedInstrumentMetadata] = {} + for record in records: + if not isinstance(record, NormalizedInstrumentMetadata): + raise ValueError("normalized metadata records are required") + if record.account_currency != self.account_currency: + raise ValueError("record account currency does not match snapshot") + if record.instrument in by_instrument: + raise ValueError("duplicate normalized instrument metadata") + by_instrument[record.instrument] = record + object.__setattr__( + self, + "instruments", + tuple(by_instrument[key] for key in sorted(by_instrument)), + ) + + @classmethod + def from_normalized_payload( + cls, value: Mapping[str, object] + ) -> SealedNormalizedInstrumentMetadataSnapshot: + """Seal an exact normalized SDK/fake-provider payload without network I/O.""" + + if not isinstance(value, Mapping): + raise ValueError("normalized metadata snapshot must be a mapping") + required = frozenset( + { + "provider", + "environment", + "account_ref", + "trading_day", + "metadata_version", + "as_of_ns", + "expires_at_ns", + "account_currency", + "instruments", + } + ) + _require_exact_keys( + value, + required=required, + optional=frozenset({"schema"}), + name="normalized metadata snapshot", + ) + schema = value.get("schema") + if schema is not None and schema != _SNAPSHOT_SCHEMA: + raise ValueError("invalid normalized metadata snapshot schema") + records = value["instruments"] + if isinstance(records, (str, bytes)): + raise ValueError("normalized metadata instruments must be iterable") + try: + payloads = tuple(records) + except TypeError as error: + raise ValueError( + "normalized metadata instruments must be iterable" + ) from error + account_currency = _currency(value["account_currency"], "account_currency") + return cls( + provider=value["provider"], + environment=value["environment"], + account_ref=value["account_ref"], + trading_day=value["trading_day"], + metadata_version=value["metadata_version"], + as_of_ns=value["as_of_ns"], + expires_at_ns=value["expires_at_ns"], + account_currency=account_currency, + instruments=tuple( + NormalizedInstrumentMetadata.from_normalized_payload( + item, account_currency=account_currency + ) + for item in payloads + ), + ) + + def to_payload(self) -> dict[str, object]: + return { + "account_currency": self.account_currency, + "account_ref": self.account_ref, + "as_of_ns": self.as_of_ns, + "environment": self.environment, + "expires_at_ns": self.expires_at_ns, + "instruments": [item.to_payload() for item in self.instruments], + "metadata_version": self.metadata_version, + "provider": self.provider, + "schema": _SNAPSHOT_SCHEMA, + "trading_day": self.trading_day, + } + + @property + def digest(self) -> str: + """Bind scope, freshness, fees, FX, lot, multiplier, and trading day.""" + + return _sha256(_canonical_json(self.to_payload())) + + def instrument_digest(self, instrument: str) -> str: + record = self._record(instrument) + return _sha256( + _canonical_json( + { + "instrument": record.instrument, + "schema": _RECORD_SCHEMA, + "snapshot_digest": self.digest, + } + ) + ) + + def instrument_metadata(self, instrument: str) -> NormalizedInstrumentMetadata: + """Return the immutable normalized record for one registered instrument.""" + + return self._record(instrument) + + def require_execution_scope(self, scope: Any) -> None: + """Reject a snapshot whose provider/account/day is not the execution scope.""" + + expected = { + "provider": self.provider, + "environment": self.environment, + "account_ref": self.account_ref, + "trading_day": self.trading_day, + } + for name, value in expected.items(): + if getattr(scope, name, None) != value: + code = ( + "INSTRUMENT_SNAPSHOT_TRADING_DAY_MISMATCH" + if name == "trading_day" + else "INSTRUMENT_SNAPSHOT_SCOPE_MISMATCH" + ) + raise RuntimePluginError( + code, "instrument metadata snapshot does not match execution scope" + ) + + def require_live_metadata(self) -> None: + """Require provider quantity semantics before a live route can exist.""" + + if any(record.quantity_unit is None for record in self.instruments): + raise RuntimePluginError( + "INSTRUMENT_SNAPSHOT_QUANTITY_UNIT_REQUIRED", + "managed live metadata must seal each instrument quantity unit", + ) + + def require_bound_intent( + self, intent: Any, *, now_ns: int, require_fresh: bool + ) -> NormalizedInstrumentMetadata: + """Validate the exact snapshot digest before risk admission.""" + + self.require_execution_scope(getattr(intent, "scope", None)) + record = self._record(getattr(intent, "instrument", None)) + if getattr(intent, "metadata_version", None) != self.metadata_version: + raise RuntimePluginError( + "INSTRUMENT_SNAPSHOT_METADATA_VERSION_MISMATCH", + "intent metadata version differs from the sealed snapshot", + ) + tags = getattr(intent, "tags", None) + if not isinstance(tags, Mapping): + raise RuntimePluginError( + "INSTRUMENT_SNAPSHOT_METADATA_DIGEST_REQUIRED", + "intent lacks the sealed instrument metadata digest", + ) + observed = tags.get(_INSTRUMENT_METADATA_DIGEST_TAG) + if not isinstance(observed, str) or not _SHA256.fullmatch(observed): + raise RuntimePluginError( + "INSTRUMENT_SNAPSHOT_METADATA_DIGEST_REQUIRED", + "intent lacks the sealed instrument metadata digest", + ) + if observed != self.instrument_digest(record.instrument): + raise RuntimePluginError( + "INSTRUMENT_SNAPSHOT_METADATA_DIGEST_MISMATCH", + "intent metadata digest differs from the sealed instrument snapshot", + ) + if record.quantity_unit is not None: + observed_unit = tags.get(_QUANTITY_UNIT_TAG) + if observed_unit is None: + raise RuntimePluginError( + "INSTRUMENT_SNAPSHOT_QUANTITY_UNIT_REQUIRED", + "intent lacks the sealed instrument quantity unit", + ) + if observed_unit != record.quantity_unit: + raise RuntimePluginError( + "INSTRUMENT_SNAPSHOT_QUANTITY_UNIT_MISMATCH", + "intent quantity unit differs from the sealed instrument snapshot", + ) + if require_fresh: + now_ns = _timestamp_ns(now_ns, "metadata clock") + if now_ns < self.as_of_ns: + raise RuntimePluginError( + "INSTRUMENT_SNAPSHOT_METADATA_NOT_ACTIVE", + "instrument metadata snapshot is not active yet", + ) + if now_ns >= self.expires_at_ns: + raise RuntimePluginError( + "INSTRUMENT_SNAPSHOT_METADATA_STALE", + "instrument metadata snapshot is stale", + ) + return record + + def to_risk_metadata(self, risk: Any) -> tuple[Any, ...]: + """Convert normalized facts to provider-neutral account-risk records.""" + + factory = getattr(risk, "InstrumentRiskMetadata", None) + if not callable(factory): + raise RuntimePluginError( + "INSTRUMENT_RISK_METADATA_FACTORY_MISSING", + "loaded risk capability lacks InstrumentRiskMetadata", + ) + try: + records = [ + factory( + instrument=item.instrument, + metadata_version=self.metadata_version, + as_of_ns=self.as_of_ns, + expires_at_ns=self.expires_at_ns, + tick_size=item.tick_size, + quantity_step=item.lot_size, + contract_multiplier=item.contract_multiplier + * item.quote_to_account_fx, + max_gross_notional=item.max_gross_notional_account, + min_quantity=item.min_quantity, + max_quantity=item.max_quantity, + taker_fee_bps=item.taker_fee_bps, + fixed_fee=item.fixed_fee * item.fee_to_account_fx, + max_slippage_bps=item.max_slippage_bps, + ) + for item in self.instruments + ] + except (TypeError, ValueError) as error: + raise RuntimePluginError( + "INSTRUMENT_SNAPSHOT_RISK_CONVERSION_INVALID", + "sealed normalized metadata cannot form account-risk facts", + ) from error + return tuple(records) + + def _record(self, instrument: object) -> NormalizedInstrumentMetadata: + if not isinstance(instrument, str): + raise RuntimePluginError( + "INSTRUMENT_SNAPSHOT_INSTRUMENT_UNREGISTERED", + "intent instrument is absent from the sealed snapshot", + ) + for record in self.instruments: + if record.instrument == instrument: + return record + raise RuntimePluginError( + "INSTRUMENT_SNAPSHOT_INSTRUMENT_UNREGISTERED", + "intent instrument is absent from the sealed snapshot", + ) + + +class _IntentWithRiskMetadataDigest: + """Delegate an intent while exposing only the internal risk digest.""" + + def __init__(self, intent: Any, risk_digest: str) -> None: + self._intent = intent + tags = dict(getattr(intent, "tags", {})) + tags[_INSTRUMENT_METADATA_DIGEST_TAG] = risk_digest + self.tags = MappingProxyType(tags) + + def __getattr__(self, name: str) -> Any: + return getattr(self._intent, name) + + +class _SnapshotBoundInstrumentRiskMapper: + """Bind a sealed snapshot before delegating math to bt_api_risk.""" + + def __init__( + self, + *, + risk: Any, + snapshot: SealedNormalizedInstrumentMetadataSnapshot, + mapper: Any, + risk_metadata: Mapping[str, Any], + clock_ns: Callable[[], int], + ) -> None: + self._risk = risk + self._snapshot = snapshot + self._mapper = mapper + self._risk_metadata = MappingProxyType(dict(risk_metadata)) + self._clock_ns = clock_ns + + def __call__(self, intent: Any) -> Any: + effect = getattr(getattr(intent, "position_effect", None), "value", None) + record = self._snapshot.require_bound_intent( + intent, + now_ns=self._clock_ns(), + # Existing safe-reduction semantics permit an expired quote/FX + # snapshot for a close, while still requiring scope/day/digest/lattice. + require_fresh=effect == "OPEN", + ) + metadata = self._risk_metadata[record.instrument] + mapped = self._mapper(_IntentWithRiskMetadataDigest(intent, metadata.digest)) + return self._risk.RiskIntent( + intent_id=mapped.intent_id, + scope=mapped.scope, + action=mapped.action, + notional=mapped.notional, + payload_fingerprint=_sha256( + _canonical_json( + { + "instrument_digest": self._snapshot.instrument_digest( + record.instrument + ), + "risk_payload_fingerprint": mapped.payload_fingerprint, + "schema": _SNAPSHOT_SCHEMA, + "snapshot_digest": self._snapshot.digest, + "trading_day": self._snapshot.trading_day, + } + ) + ), + ) + + +@dataclass(frozen=True) +class InstrumentRiskAdmission: + """Immutable objects installed on one managed execution facade.""" + + registry: Any + mapper: Any + admission_gate: Any + snapshot: SealedNormalizedInstrumentMetadataSnapshot | None = None + metadata_digests: Mapping[str, str] = field(default_factory=dict) + + def __post_init__(self) -> None: + object.__setattr__( + self, "metadata_digests", MappingProxyType(dict(self.metadata_digests)) + ) + + def map_execution_intent(self, intent: Any) -> Any: + return self.mapper(intent) + + def metadata_digest_for(self, instrument: str) -> str: + try: + return self.metadata_digests[instrument] + except KeyError as error: + raise RuntimePluginError( + "INSTRUMENT_SNAPSHOT_INSTRUMENT_UNREGISTERED", + "instrument is absent from the sealed metadata admission", + ) from error + + +def compose_instrument_risk_admission( + capabilities: LoadedCapabilities, + *, + risk_gate: Any, + risk_scope: Any, + metadata: Iterable[Any] | None = None, + normalized_snapshot: SealedNormalizedInstrumentMetadataSnapshot | None = None, + execution_scope: Any | None = None, + clock_ns: Callable[[], int] | None = None, +) -> InstrumentRiskAdmission: + """Build a fail-closed instrument-aware shared admission gate. + + Existing callers may pass provider-neutral metadata directly. New managed + composition passes normalized_snapshot plus the exact execution_scope. + """ + + if not isinstance(capabilities, LoadedCapabilities): + raise TypeError("LoadedCapabilities is required") + if not capabilities.contract.is_managed_execution: + raise RuntimePluginError( + "MANAGED_CONTRACT_REQUIRED", + "instrument risk admission requires a sealed managed execution contract", + ) + execution = capabilities.require(CAPABILITY_EXECUTION) + risk = capabilities.require(CAPABILITY_RISK) + if not isinstance(risk_gate, risk.DurableRiskGate): + raise RuntimePluginError( + "INSTRUMENT_RISK_GATE_INVALID", + "instrument admission requires the loaded durable risk gate", + ) + if not isinstance(risk_scope, risk.AccountScope): + raise RuntimePluginError( + "INSTRUMENT_RISK_SCOPE_INVALID", + "instrument admission requires the loaded account risk scope", + ) + if metadata is not None and normalized_snapshot is not None: + raise RuntimePluginError( + "INSTRUMENT_RISK_METADATA_SOURCE_AMBIGUOUS", + "instrument admission accepts direct metadata or one normalized snapshot", + ) + if metadata is None and normalized_snapshot is None: + raise RuntimePluginError( + "INSTRUMENT_RISK_METADATA_SOURCE_REQUIRED", + "instrument admission requires sealed metadata", + ) + if clock_ns is not None and not callable(clock_ns): + raise RuntimePluginError( + "INSTRUMENT_RISK_CLOCK_INVALID", "clock_ns must be callable" + ) + admission_clock = clock_ns or _wall_clock_ns + snapshot: SealedNormalizedInstrumentMetadataSnapshot | None = None + metadata_digests: Mapping[str, str] = {} + try: + if normalized_snapshot is not None: + if not isinstance( + normalized_snapshot, SealedNormalizedInstrumentMetadataSnapshot + ): + raise RuntimePluginError( + "INSTRUMENT_SNAPSHOT_INVALID", + "normalized_snapshot must be a sealed normalized metadata snapshot", + ) + if execution_scope is None: + raise RuntimePluginError( + "INSTRUMENT_SNAPSHOT_SCOPE_REQUIRED", + "sealed normalized metadata needs the exact execution scope", + ) + normalized_snapshot.require_execution_scope(execution_scope) + source_metadata = normalized_snapshot.to_risk_metadata(risk) + registry = risk.InstrumentRiskRegistry(source_metadata) + base_mapper = risk.InstrumentRiskAdmissionMapper( + risk_scope, registry, clock_ns=admission_clock + ) + by_instrument = {item.instrument: item for item in source_metadata} + mapper = _SnapshotBoundInstrumentRiskMapper( + risk=risk, + snapshot=normalized_snapshot, + mapper=base_mapper, + risk_metadata=by_instrument, + clock_ns=admission_clock, + ) + snapshot = normalized_snapshot + metadata_digests = { + item.instrument: normalized_snapshot.instrument_digest(item.instrument) + for item in normalized_snapshot.instruments + } + else: + registry = risk.InstrumentRiskRegistry(metadata) + mapper = risk.InstrumentRiskAdmissionMapper( + risk_scope, registry, clock_ns=admission_clock + ) + except RuntimePluginError: + raise + except (TypeError, ValueError) as error: + raise RuntimePluginError( + "INSTRUMENT_RISK_METADATA_INVALID", + "instrument risk metadata cannot form a sealed admission policy", + ) from error + admission_gate = execution.SharedRiskAdmissionAdapter(risk_gate, mapper) + return InstrumentRiskAdmission( + registry=registry, + mapper=mapper, + admission_gate=admission_gate, + snapshot=snapshot, + metadata_digests=metadata_digests, + ) + + +def _wall_clock_ns() -> int: + import time + + return time.time_ns() + + +__all__ = [ + "InstrumentRiskAdmission", + "NormalizedInstrumentMetadata", + "SealedNormalizedInstrumentMetadataSnapshot", + "compose_instrument_risk_admission", +] diff --git a/bt_api_py/runtime_plugins/managed.py b/bt_api_py/runtime_plugins/managed.py new file mode 100644 index 00000000..7893046d --- /dev/null +++ b/bt_api_py/runtime_plugins/managed.py @@ -0,0 +1,919 @@ +"""Managed execution composition after config and capability pins are accepted.""" + +from __future__ import annotations + +import uuid +from collections.abc import Callable +from dataclasses import dataclass, field +from decimal import Decimal +from pathlib import Path +from typing import Any + +from .catalog import LoadedCapabilities, RuntimePluginError +from .contracts import CAPABILITY_EXECUTION, CAPABILITY_MONITOR, CAPABILITY_RISK +from .managed_recovery import ( + DurableManagedRecoveryCoordinator, + ManagedRecoveryCoordinatorError, + ManagedRecoveryReport, +) + + +def _requires_sealed_instrument_metadata(contract: Any) -> bool: + """Return whether this managed route could leave the offline fixture boundary. + + The one reviewed offline managed-replay shape remains a deterministic, + zero-provider-I/O fixture and intentionally has no provider metadata + requirement. Every other managed-execution shape is treated as a + potentially external write route: it must use the normalized, sealed + admission path instead of the legacy ``quantity * price`` mapper. + """ + + if not bool(getattr(contract, "is_managed_execution", False)): + return False + return not ( + getattr(contract, "preset", None) == "replay" + and getattr(contract, "environment", None) == "offline" + ) + + +@dataclass(frozen=True) +class ManagedExecutionRuntime: + """A code-owned composition of independent execution, risk, and monitor packages. + + This object owns no provider client. The caller injects a provider dispatch + port for each submit, keeping provider ownership in the existing SDK/Broker + adapter and ensuring that a missing capability cannot downgrade to direct. + Its facade uses the shared risk gate's atomic dispatch claim, so direct + facade submission cannot bypass the durable account freeze. Direct facade + submission does not publish the monitor fact or automatically release that + freeze; callers use :meth:`submit` for the complete managed path. + """ + + contract: Any + facade: Any + execution_store: Any + outbox: Any + risk_gate: Any + risk_scope: Any + scope: Any + execution: Any + outbox_event_type: Any + state_directory: Path | None = None + instrument_admission: Any | None = None + instrument_metadata_snapshot: Any | None = None + recovery_coordinator: DurableManagedRecoveryCoordinator | None = None + # This identity belongs to one composed Python runtime, rather than the + # durable SDK writer lease. A Backtrader projection receipt may be + # applied once again after an actual process restart because the prior + # framework position/order/observer state no longer exists; duplicate + # callbacks within this process retain the same identity and are blocked. + framework_projection_session_id: str = field(default_factory=lambda: uuid.uuid4().hex) + # Backtrader owns the projection sink, so this SDK module deliberately + # stores only a generic closeable reference rather than importing that + # package. The composition root still owns shutdown ordering: close the + # local framework receipt before closing the execution SQLite store that + # provides its durable evidence. + _framework_projection_closeables: tuple[Any, ...] = field( + default=(), init=False, repr=False, compare=False + ) + + def __post_init__(self) -> None: + """Keep ad-hoc construction from weakening a managed-live composition.""" + + if ( + not isinstance(self.framework_projection_session_id, str) + or len(self.framework_projection_session_id) != 32 + or any(character not in "0123456789abcdef" for character in self.framework_projection_session_id) + ): + raise RuntimePluginError( + "FRAMEWORK_PROJECTION_SESSION_INVALID", + "managed runtime requires a fresh framework projection session identity", + ) + + if not _requires_sealed_instrument_metadata(self.contract): + return + from .instrument_risk import SealedNormalizedInstrumentMetadataSnapshot + + snapshot = self.instrument_metadata_snapshot + if not isinstance(snapshot, SealedNormalizedInstrumentMetadataSnapshot): + raise RuntimePluginError( + "INSTRUMENT_SNAPSHOT_REQUIRED", + "managed live runtime requires sealed instrument metadata", + ) + snapshot.require_execution_scope(self.scope) + snapshot.require_live_metadata() + if getattr(self.instrument_admission, "snapshot", None) is not snapshot: + raise RuntimePluginError( + "INSTRUMENT_ADMISSION_REQUIRED", + "managed live runtime requires admission bound to its sealed snapshot", + ) + if self.recovery_coordinator is None: + raise RuntimePluginError( + "MANAGED_RECOVERY_COORDINATOR_REQUIRED", + "managed live runtime requires durable recovery coordination", + ) + + def close(self) -> None: + """Release the writer lease and close caller-owned local durability state. + + Composition has no network worker to stop, but the execution store owns + a persistent SQLite connection. Exposing its lifecycle here prevents a + Windows process from retaining the journal file after a short-lived + runtime or acceptance run finishes. + """ + + first_error: Exception | None = None + for component in ( + *self._framework_projection_closeables, + self.facade, + self.outbox, + self.execution_store, + self.risk_gate, + self.recovery_coordinator, + ): + close = getattr(component, "close", None) + if not callable(close): + continue + try: + close() + except Exception as error: + if first_error is None: + first_error = error + object.__setattr__(self, "_framework_projection_closeables", ()) + if first_error is not None: + raise first_error + + def register_framework_projection_closeable(self, closeable: Any) -> None: + """Register a Backtrader-owned local receipt for ordered shutdown. + + The execution runtime must not import or construct framework code. A + validated bridge can nevertheless register its local SQLite receipt so + a normal runtime close releases the Windows file handle before cleanup. + """ + + if not callable(getattr(closeable, "close", None)): + raise RuntimePluginError( + "FRAMEWORK_PROJECTION_CLOSEABLE_INVALID", + "framework projection closeable must expose close()", + ) + current = self._framework_projection_closeables + if any(existing is closeable for existing in current): + return + object.__setattr__(self, "_framework_projection_closeables", (*current, closeable)) + + def submit(self, intent: Any, dispatcher: Callable[[Any], Any]) -> Any: + """Submit once through the durable facade and publish a redacted fact. + + The runtime persists a unique account freeze immediately before the + injected provider port can run. A directly observed, settled provider + outcome clears that freeze only after its monitor fact is durable. A + provider exception, unknown result, settlement failure, monitor-outbox + failure, or process crash leaves the freeze active; callers must never + retry that intent. + """ + coordinator = self.recovery_coordinator + if coordinator is None: + return self._submit_without_recovery_coordinator(intent, dispatcher) + + # A restart cannot be treated as permission to resend a prepared + # provider request. Recover prior work before admitting a new intent; + # this pass performs local journal/outbox work only, never provider I/O. + self.recover() + + def mark_provider_dispatch(current_intent: Any) -> None: + if current_intent.intent_id != intent.intent_id: + raise RuntimePluginError( + "DISPATCH_IDENTITY_MISMATCH", + "facade pre-dispatch hook received another intent", + ) + try: + coordinator.prepare_dispatch(current_intent) + except ManagedRecoveryCoordinatorError as error: + raise RuntimePluginError( + "MANAGED_RECOVERY_PREPARE_FAILED", + "provider dispatch was not durably prepared by the recovery authority", + ) from error + + record = self.facade.submit( + intent, dispatcher, before_dispatch=mark_provider_dispatch + ) + try: + coordinator.record_result(intent, record) + coordinator.append_pending_monitor_events( + self.scope.key, self.outbox, self.outbox_event_type + ) + except Exception as error: + raise RuntimePluginError( + "MONITOR_OUTBOX_UNCONFIRMED", + "execution result exists but monitor delivery requires reconciliation", + ) from error + self._finish_coordinated_freeze_if_ready(intent.intent_id, record) + return record + + def _submit_without_recovery_coordinator( + self, intent: Any, dispatcher: Callable[[Any], Any] + ) -> Any: + """Preserve the explicit legacy/gateway composition behavior. + + Gateway composition owns a different server-side authority. It may + construct this runtime without the local direct-dispatch coordinator, + so retain its established monitor and latch behavior until that + separate path explicitly adopts the same journal. + """ + + provider_dispatch_imminent = False + + def mark_provider_dispatch(current_intent: Any) -> None: + nonlocal provider_dispatch_imminent + if current_intent.intent_id != intent.intent_id: + raise RuntimePluginError( + "DISPATCH_IDENTITY_MISMATCH", + "facade pre-dispatch hook received another intent", + ) + provider_dispatch_imminent = True + + record = self.facade.submit( + intent, dispatcher, before_dispatch=mark_provider_dispatch + ) + event_id = ( + "execution." + intent.intent_id + "." + str(record.state.value).lower() + ) + try: + self.outbox.append( + self.outbox_event_type( + event_id=event_id, + scope=self.scope.key, + event_type="execution_state", + data={ + "intent_id": intent.intent_id, + "scope_digest": self._scope_digest(self.scope.key), + "state": record.state.value, + }, + occurred_at=record.updated_at_ns / 1_000_000_000, + ) + ) + except Exception as error: + raise RuntimePluginError( + "MONITOR_OUTBOX_UNCONFIRMED", + "execution result exists but monitor delivery requires reconciliation", + ) from error + if provider_dispatch_imminent and self._has_confirmed_settled_evidence(record): + self._resolve_active_dispatch_freeze( + intent.intent_id, reassert_on_failure=True + ) + return record + + def recover(self) -> ManagedRecoveryReport: + """Recover local prepared work without retrying any provider dispatch. + + The facade's existing scope writer lease fences concurrent runtimes. + A crash after the coordinator's pre-dispatch commit is converted to a + durable ``UNKNOWN`` execution state where possible, its account freeze + is reasserted, and reconciliation remains required. Known execution + results only replay their deterministic monitor fact; a provider port + is never touched here. + """ + + coordinator = self.recovery_coordinator + if coordinator is None: + return ManagedRecoveryReport((), (), (), ()) + writer_lease = self.facade.acquire_writer_lease() + recovered_unknown: list[str] = [] + reconciliation_required: list[str] = [] + # The facade claims ``DISPATCHING`` before it invokes the coordinator's + # pre-dispatch hook. Discover the crash window where that hook never + # committed its companion work row. This is local journal work only; + # it cannot call a provider or manufacture a retry permission. + prepared_ids = { + work.intent_id for work in coordinator.pending_work(self.scope.key) + } + for dispatching in self.execution_store.list_dispatching(self.scope): + if dispatching.intent_id in prepared_ids: + continue + self._recover_untracked_dispatching_record( + coordinator, + dispatching, + writer_lease, + recovered_unknown, + reconciliation_required, + ) + for work in coordinator.pending_work(self.scope.key): + try: + intent = self.execution_store.get_intent( + work.intent_id, scope=self.scope + ) + record = self.facade.get(work.intent_id) + except Exception: + self._require_reconciliation_and_freeze( + coordinator, work.intent_id, "recovery_execution_record_unavailable" + ) + reconciliation_required.append(work.intent_id) + continue + if ( + intent is None + or record is None + or record.payload_sha256 != work.payload_sha256 + ): + self._require_reconciliation_and_freeze( + coordinator, + work.intent_id, + "recovery_execution_identity_unavailable", + ) + reconciliation_required.append(work.intent_id) + continue + state_value = record.state.value + if work.phase == "DISPATCH_PREPARED" and state_value == "DISPATCHING": + try: + record = self.execution_store.mark_unknown( + work.intent_id, + self.scope, + "coordinator_recovery_unknown", + writer_lease=writer_lease, + ) + except Exception: + self._require_reconciliation_and_freeze( + coordinator, + work.intent_id, + "recovery_unknown_transition_failed", + ) + reconciliation_required.append(work.intent_id) + continue + recovered_unknown.append(work.intent_id) + try: + coordinator.record_result(intent, record) + except ManagedRecoveryCoordinatorError as error: + raise RuntimePluginError( + "MANAGED_RECOVERY_RESULT_UNAVAILABLE", + "execution result could not be durably linked to recovery work", + ) from error + if record.state.value == "UNKNOWN": + self._require_reconciliation_and_freeze( + coordinator, + work.intent_id, + "coordinator_recovery_unknown" + if work.phase == "DISPATCH_PREPARED" + else "unknown_provider_outcome", + ) + reconciliation_required.append(work.intent_id) + elif record.review_required: + # Reconciled evidence deliberately keeps this review latch. + # The reconciliation-control port, rather than this automatic + # restart pass, owns any reviewed freeze release. Reasserting + # here could undo a just-completed audited control release. + reconciliation_required.append(work.intent_id) + elif work.phase == "DISPATCH_PREPARED" and record.state.value not in { + "ACKED", + "PARTIALLY_FILLED", + "FILLED", + "CANCELLED", + "REJECTED", + }: + # A prepared row must never be silently downgraded by an + # inconsistent local execution state. + self._require_reconciliation_and_freeze( + coordinator, work.intent_id, "recovery_prepared_state_unproven" + ) + reconciliation_required.append(work.intent_id) + try: + emitted = coordinator.append_pending_monitor_events( + self.scope.key, self.outbox, self.outbox_event_type + ) + except ManagedRecoveryCoordinatorError as error: + raise RuntimePluginError( + "MONITOR_OUTBOX_UNCONFIRMED", + "recovery monitor delivery requires reconciliation", + ) from error + resolved: list[str] = [] + for work in coordinator.pending_work(self.scope.key): + if coordinator.freeze_status_for(work.scope_key, work.intent_id) not in { + "PENDING", + "RELEASE_ATTEMPTED", + }: + continue + record = self.facade.get(work.intent_id) + if record is None or not self._has_confirmed_settled_evidence(record): + continue + self._finish_coordinated_freeze_if_ready(work.intent_id, record) + resolved.append(work.intent_id) + return ManagedRecoveryReport( + tuple(recovered_unknown), + tuple(dict.fromkeys(reconciliation_required)), + emitted, + tuple(resolved), + ) + + def _require_reconciliation_and_freeze( + self, + coordinator: DurableManagedRecoveryCoordinator, + intent_id: str, + reason: str, + ) -> None: + try: + coordinator.require_reconciliation( + scope_key=self.scope.key, intent_id=intent_id, reason=reason + ) + self._ensure_dispatch_freeze(intent_id) + except ManagedRecoveryCoordinatorError as error: + raise RuntimePluginError( + "MANAGED_RECOVERY_RECONCILIATION_LATCH_FAILED", + "prepared provider dispatch could not be durably frozen for reconciliation", + ) from error + + def _recover_untracked_dispatching_record( + self, + coordinator: DurableManagedRecoveryCoordinator, + dispatching: Any, + writer_lease: Any, + recovered_unknown: list[str], + reconciliation_required: list[str], + ) -> None: + """Fail closed when a crash preceded the recovery prepare hook. + + The durable execution claim proves that a provider dispatch might have + started, even if the separate coordinator row was never committed. + Convert it to ``UNKNOWN`` and reassert its named account freeze without + calling any provider. Missing/corrupt intent payloads remain frozen + too; they simply cannot be linked to a typed monitor work item. + """ + + intent_id = getattr(dispatching, "intent_id", None) + if not isinstance(intent_id, str) or not intent_id: + raise RuntimePluginError( + "MANAGED_RECOVERY_DISPATCHING_RECORD_INVALID", + "dispatching execution record lacks a usable intent identity", + ) + try: + intent = self.execution_store.get_intent(intent_id, scope=self.scope) + except Exception: + intent = None + if ( + intent is None + or getattr(intent, "scope", None) != self.scope + or getattr(intent, "fingerprint", None) != getattr(dispatching, "payload_sha256", None) + ): + try: + self.execution_store.mark_unknown( + intent_id, + self.scope, + "recovery_prepare_identity_unavailable", + writer_lease=writer_lease, + ) + except Exception as error: + raise RuntimePluginError( + "MANAGED_RECOVERY_UNKNOWN_TRANSITION_FAILED", + "untracked dispatching execution could not become unknown", + ) from error + self._ensure_dispatch_freeze(intent_id) + recovered_unknown.append(intent_id) + reconciliation_required.append(intent_id) + return + try: + coordinator.prepare_dispatch(intent) + unknown = self.execution_store.mark_unknown( + intent_id, + self.scope, + "recovery_prepare_hook_missing", + writer_lease=writer_lease, + ) + coordinator.record_result(intent, unknown) + except ManagedRecoveryCoordinatorError as error: + self._ensure_dispatch_freeze(intent_id) + raise RuntimePluginError( + "MANAGED_RECOVERY_PREPARE_DISCOVERY_FAILED", + "untracked dispatching execution could not be linked for recovery", + ) from error + except Exception as error: + self._ensure_dispatch_freeze(intent_id) + raise RuntimePluginError( + "MANAGED_RECOVERY_UNKNOWN_TRANSITION_FAILED", + "untracked dispatching execution could not become unknown", + ) from error + self._require_reconciliation_and_freeze( + coordinator, intent_id, "recovery_prepare_hook_missing" + ) + recovered_unknown.append(intent_id) + reconciliation_required.append(intent_id) + + def _ensure_dispatch_freeze(self, intent_id: str) -> None: + """Reassert the local account latch for recovered uncertain work.""" + + cause_id = self._dispatch_freeze_cause(intent_id) + try: + self._assert_current_writer() + active_reasons = self.risk_gate.active_freeze_reasons(self.risk_scope) + if cause_id not in active_reasons: + self.risk_gate.freeze(self.risk_scope, cause_id, cause_id) + except Exception as error: + raise RuntimePluginError( + "MANAGED_RECOVERY_FREEZE_REASSERT_FAILED", + "recovered provider dispatch could not be kept frozen", + ) from error + + def _finish_coordinated_freeze_if_ready(self, intent_id: str, record: Any) -> None: + """Resolve one known local latch only after its monitor fact is durable.""" + + coordinator = self.recovery_coordinator + if coordinator is None or not self._has_confirmed_settled_evidence(record): + return + try: + status = coordinator.freeze_status_for(self.scope.key, intent_id) + except ManagedRecoveryCoordinatorError as error: + raise RuntimePluginError( + "MANAGED_RECOVERY_FREEZE_STATUS_UNAVAILABLE", + "dispatch-freeze resolution state could not be verified", + ) from error + if status == "RESOLVED": + return + if status not in {"PENDING", "RELEASE_ATTEMPTED"}: + return + self._assert_current_writer() + self._ensure_coordinated_risk_settlement(intent_id, record) + try: + coordinator.mark_freeze_release_attempted(self.scope.key, intent_id) + except ManagedRecoveryCoordinatorError as error: + raise RuntimePluginError( + "MANAGED_RECOVERY_FREEZE_STATUS_UNAVAILABLE", + "dispatch-freeze resolution could not be durably prepared", + ) from error + cause_id = self._dispatch_freeze_cause(intent_id) + try: + self._assert_current_writer() + active_reasons = self.risk_gate.active_freeze_reasons(self.risk_scope) + if cause_id in active_reasons: + self.risk_gate.resolve_freeze(self.risk_scope, cause_id) + except Exception as error: + self._reassert_dispatch_freeze(cause_id, error, True) + raise RuntimePluginError( + "DISPATCH_FREEZE_RESOLUTION_FAILED", + "dispatch freeze could not be resolved", + ) from error + try: + coordinator.mark_freeze_resolved(self.scope.key, intent_id) + except ManagedRecoveryCoordinatorError as error: + # The provider outcome and monitor fact remain known, but retain a + # conservative local latch until a later recovery pass can finish. + self._ensure_dispatch_freeze(intent_id) + raise RuntimePluginError( + "MANAGED_RECOVERY_FREEZE_STATUS_UNAVAILABLE", + "dispatch-freeze resolution completion was not durable", + ) from error + + def _ensure_coordinated_risk_settlement(self, intent_id: str, record: Any) -> None: + """Prove the risk reservation cannot disappear before latch release. + + Execution and risk ownership use distinct SQLite databases. A crash + after the execution record reaches ``ACKED``/fill but before the risk + permit settles must therefore be recoverable without relying on permit + TTL. The risk owner's idempotent proof accepts active or settled only; + expired, released, or unknown permits leave the dispatch freeze active. + """ + + coordinator = self.recovery_coordinator + if coordinator is None: + return + try: + status = coordinator.risk_settlement_status_for(self.scope.key, intent_id) + except ManagedRecoveryCoordinatorError as error: + raise RuntimePluginError( + "MANAGED_RECOVERY_RISK_SETTLEMENT_UNAVAILABLE", + "risk-settlement proof state could not be verified", + ) from error + if status == "SETTLED": + return + if status != "PENDING": + self._ensure_dispatch_freeze(intent_id) + raise RuntimePluginError( + "MANAGED_RECOVERY_RISK_SETTLEMENT_REQUIRED", + "dispatch freeze cannot release without a pending risk-settlement proof", + ) + permit_reference = getattr(record, "permit_reference", None) + if not isinstance(permit_reference, str) or not permit_reference.strip(): + self._ensure_dispatch_freeze(intent_id) + raise RuntimePluginError( + "MANAGED_RECOVERY_RISK_SETTLEMENT_REQUIRED", + "confirmed execution record lacks the risk permit reference", + ) + settle = getattr(self.risk_gate, "ensure_settled", None) + if not callable(settle): + self._ensure_dispatch_freeze(intent_id) + raise RuntimePluginError( + "MANAGED_RECOVERY_RISK_SETTLEMENT_UNAVAILABLE", + "loaded risk capability lacks idempotent settlement proof", + ) + try: + self._assert_current_writer() + settle(permit_reference) + except Exception as error: + self._ensure_dispatch_freeze(intent_id) + raise RuntimePluginError( + "MANAGED_RECOVERY_RISK_SETTLEMENT_FAILED", + "risk permit settlement is unproven; dispatch freeze remains active", + ) from error + try: + coordinator.mark_risk_settlement_confirmed(self.scope.key, intent_id) + except ManagedRecoveryCoordinatorError as error: + self._ensure_dispatch_freeze(intent_id) + raise RuntimePluginError( + "MANAGED_RECOVERY_RISK_SETTLEMENT_UNAVAILABLE", + "risk settlement proof could not be made durable", + ) from error + + def resolve_confirmed_dispatch_freeze(self, intent_id: str) -> None: + """Reject the retired unaudited manual freeze-release shortcut. + + A direct facade reconciliation can turn an ``UNKNOWN`` record into a + terminal state, so inspecting its current fields is not a sufficient + authorization to clear the account latch. A deployment must use + :meth:`create_reconciliation_control`, which requires immutable + evidence, durable monitor facts, an identity-bound authorization + callback, and an operator-control audit record. + """ + + del intent_id + raise RuntimePluginError( + "CONTROLLED_FREEZE_RELEASE_REQUIRED", + "manual dispatch-freeze release requires the reconciliation control port", + ) + + def create_reconciliation_control( + self, + *, + authorize: Callable[[Any], Any], + clock: Callable[[], float] | None = None, + ) -> Any: + """Create the only public control path for an unknown-dispatch latch. + + Importing the control module is delayed until a deployment explicitly + supplies its authorization adapter. Normal replay and read-only + runtime paths therefore retain no control-plane import or database + side effects. + """ + + if self.state_directory is None: + raise RuntimePluginError( + "CONTROL_STATE_DIRECTORY_REQUIRED", + "managed runtime was not composed with a control state directory", + ) + from .reconcile_control import ManagedReconciliationControlPort + + return ManagedReconciliationControlPort( + self, + state_directory=self.state_directory, + authorize=authorize, + clock=clock, + ) + + def _has_confirmed_settled_evidence(self, record: Any) -> bool: + """Return whether known, settled evidence can release one local latch.""" + + confirmed_states = { + self.execution.ExecutionState.ACKED, + self.execution.ExecutionState.PARTIALLY_FILLED, + self.execution.ExecutionState.FILLED, + self.execution.ExecutionState.CANCELLED, + self.execution.ExecutionState.REJECTED, + } + return ( + record is not None + and record.state in confirmed_states + and not record.review_required + and record.permit_reference is not None + ) + + def _resolve_active_dispatch_freeze( + self, + intent_id: str, + *, + reassert_on_failure: bool = False, + ) -> None: + """Resolve one already-verified active freeze or surface a durable error.""" + + cause_id = self._dispatch_freeze_cause(intent_id) + try: + self._assert_current_writer() + active_reasons = self.risk_gate.active_freeze_reasons(self.risk_scope) + except Exception as error: + self._reassert_dispatch_freeze(cause_id, error, reassert_on_failure) + raise RuntimePluginError( + "DISPATCH_FREEZE_RESOLUTION_UNAVAILABLE", + "dispatch freeze state could not be verified", + ) from error + if cause_id not in active_reasons: + self._reassert_dispatch_freeze(cause_id, None, reassert_on_failure) + raise RuntimePluginError( + "DISPATCH_FREEZE_NOT_ACTIVE", + "dispatch freeze is not active for this intent", + ) + try: + self._assert_current_writer() + self.risk_gate.resolve_freeze(self.risk_scope, cause_id) + except Exception as error: + self._reassert_dispatch_freeze(cause_id, error, reassert_on_failure) + raise RuntimePluginError( + "DISPATCH_FREEZE_RESOLUTION_FAILED", + "dispatch freeze could not be resolved", + ) from error + + def _reassert_dispatch_freeze( + self, + cause_id: str, + resolution_error: Exception | None, + enabled: bool, + ) -> None: + """Restore the safety latch if automatic resolution cannot prove success.""" + + if not enabled: + return + try: + self._assert_current_writer() + self.risk_gate.freeze(self.risk_scope, cause_id, cause_id) + except Exception as reassert_error: + raise RuntimePluginError( + "DISPATCH_FREEZE_REASSERT_FAILED", + "dispatch freeze resolution failed and the safety latch could not be reasserted", + ) from (resolution_error or reassert_error) + + def _assert_current_writer(self) -> None: + """Fence every cross-store risk mutation with the execution authority.""" + + try: + writer_lease = self.facade.acquire_writer_lease() + self.execution_store.assert_writer_lease(self.scope, writer_lease) + except Exception as error: + raise RuntimePluginError( + "MANAGED_WRITER_FENCE_UNAVAILABLE", + "managed runtime lost its execution writer authority", + ) from error + + @staticmethod + def _dispatch_freeze_cause(intent_id: str) -> str: + return "dispatch-inflight:" + intent_id + + @staticmethod + def _scope_digest(scope_key: str) -> str: + """Keep the public redaction format compatible with Python 3.8.""" + + return scope_key[6:] if scope_key.startswith("scope:") else scope_key + + +def compose_managed_execution( + capabilities: LoadedCapabilities, + *, + state_directory: Path, + provider: str, + environment: str, + account_ref: str, + strategy_id: str, + writer_id: str, + policy_id: str, + max_increase_notional: Decimal, + max_increase_count: int, + permit_ttl_seconds: float = 30.0, + trading_day: str | None = None, + instrument_metadata_snapshot: Any | None = None, + instrument_clock_ns: Callable[[], int] | None = None, +) -> ManagedExecutionRuntime: + """Create an explicit local stack for an already-approved managed contract. + + ``state_directory`` and all limits are supplied by reviewed operator code, + not by a user-editable strategy config. The function performs no network + I/O and it never substitutes an unprotected or direct route on failure. + """ + contract = capabilities.contract + if not contract.is_managed_execution: + raise RuntimePluginError( + "MANAGED_CONTRACT_REQUIRED", + "managed composition requires a sealed managed route", + ) + if contract.preset == "managed_live_gateway": + raise RuntimePluginError( + "GATEWAY_DISPATCH_UNSUPPORTED", + "managed_live_gateway requires a dedicated gateway dispatch port", + ) + if strategy_id != contract.strategy_id: + raise RuntimePluginError( + "STRATEGY_SCOPE_MISMATCH", + "managed scope strategy does not match the effective contract", + ) + if environment != contract.environment: + raise RuntimePluginError( + "ENVIRONMENT_SCOPE_MISMATCH", + "managed scope environment does not match the effective contract", + ) + if _requires_sealed_instrument_metadata(contract): + # Every managed route that can leave the explicitly offline replay + # fixture must be bound to typed, sealed provider metadata. Falling + # through to the legacy quantity * price mapper would make lot, + # multiplier, fee, FX, scope and provenance facts advisory at exactly + # the route which may write externally. Offline replay remains an + # offline fixture and is not reinterpreted as live. + from .instrument_risk import SealedNormalizedInstrumentMetadataSnapshot + + if instrument_metadata_snapshot is None: + raise RuntimePluginError( + "INSTRUMENT_SNAPSHOT_REQUIRED", + "managed live composition requires sealed instrument metadata", + ) + if not isinstance( + instrument_metadata_snapshot, SealedNormalizedInstrumentMetadataSnapshot + ): + raise RuntimePluginError( + "INSTRUMENT_SNAPSHOT_INVALID", + "managed live composition needs a sealed normalized metadata snapshot", + ) + execution = capabilities.require(CAPABILITY_EXECUTION) + risk = capabilities.require(CAPABILITY_RISK) + monitor = capabilities.require(CAPABILITY_MONITOR) + state_directory = Path(state_directory).resolve(strict=False) + scope = execution.ExecutionScope( + provider=provider, + environment=environment, + account_ref=account_ref, + strategy_id=strategy_id, + trading_day=trading_day, + ) + if _requires_sealed_instrument_metadata(contract): + # The snapshot digest is not enough when the provider's quantity + # semantics are absent. Live admission must know whether the intent + # quantity is contracts, base units, or another canonical unit. + instrument_metadata_snapshot.require_live_metadata() + risk_scope = risk.AccountScope( + provider=provider, environment=environment, account_id=account_ref + ) + risk_policy = risk.RiskPolicy( + policy_id=policy_id, + max_increase_notional=max_increase_notional, + max_increase_count=max_increase_count, + permit_ttl_seconds=permit_ttl_seconds, + ) + risk_gate = risk.DurableRiskGate(state_directory / "risk.sqlite3", risk_policy) + + def map_intent(intent: Any) -> Any: + if intent.position_effect.value == "OPEN": + if intent.price is None: + raise RuntimePluginError( + "RISK_NOTIONAL_UNPROVEN", + "managed opening needs reviewed executable notional", + ) + action = risk.IntentAction.INCREASE + notional = intent.quantity * intent.price + else: + action = risk.IntentAction.REDUCE + notional = Decimal("0") + return risk.RiskIntent( + intent_id=intent.intent_id, + scope=risk_scope, + action=action, + notional=notional, + payload_fingerprint=intent.fingerprint, + ) + + admission = execution.SharedRiskAdmissionAdapter(risk_gate, map_intent) + instrument_admission = None + if instrument_metadata_snapshot is not None: + # This import remains local so generic/replay composition does not + # load the normalized metadata machinery unless a reviewed caller + # explicitly binds one sealed snapshot. + from .instrument_risk import compose_instrument_risk_admission + + instrument_admission = compose_instrument_risk_admission( + capabilities, + risk_gate=risk_gate, + risk_scope=risk_scope, + normalized_snapshot=instrument_metadata_snapshot, + execution_scope=scope, + clock_ns=instrument_clock_ns, + ) + admission = instrument_admission.admission_gate + execution_store = execution.SqliteExecutionStore( + state_directory / "execution.sqlite3" + ) + + facade = execution.ManagedExecutionFacade( + execution_store, + scope, + writer_id=writer_id, + admission_gate=admission, + ) + outbox = monitor.DurableOutbox(state_directory / "monitor.sqlite3") + # This fourth local journal is the authority for cross-component recovery + # work. It does not claim a distributed transaction across the three + # package-owned stores and it never encloses a provider call. + recovery_coordinator = DurableManagedRecoveryCoordinator( + state_directory / "managed_recovery.sqlite3" + ) + return ManagedExecutionRuntime( + contract=contract, + facade=facade, + execution_store=execution_store, + outbox=outbox, + risk_gate=risk_gate, + risk_scope=risk_scope, + scope=scope, + execution=execution, + outbox_event_type=monitor.OutboxEvent, + state_directory=state_directory, + instrument_admission=instrument_admission, + instrument_metadata_snapshot=instrument_metadata_snapshot, + recovery_coordinator=recovery_coordinator, + ) diff --git a/bt_api_py/runtime_plugins/managed_recovery.py b/bt_api_py/runtime_plugins/managed_recovery.py new file mode 100644 index 00000000..19f7f13e --- /dev/null +++ b/bt_api_py/runtime_plugins/managed_recovery.py @@ -0,0 +1,874 @@ +"""Local durable recovery authority for managed execution. + +The execution, risk, and monitor packages intentionally keep independent data +models. This coordinator does *not* turn their local transactions, or a +provider request, into one distributed transaction. Instead it owns a small, +local work journal that closes the dangerous recovery gap: + +* the exact intent/scope/fingerprint is durable before the provider port runs; +* an interrupted prepared dispatch is never replayed blindly; +* a provider result has a deterministic monitor fact which can be re-appended + after a crash; and +* a confirmed dispatch freeze is resolved only after that fact is durable. + +Every provider effect remains at-least-unknown across a process crash. The +journal is deliberately provider-free and stores only redacted identifiers and +execution state. +""" + +from __future__ import annotations + +import hashlib +import json +import sqlite3 +import time +from collections.abc import Iterator +from contextlib import contextmanager +from dataclasses import dataclass +from pathlib import Path +from typing import Any + + +class ManagedRecoveryCoordinatorError(RuntimeError): + """The local managed-recovery authority cannot prove a safe transition.""" + + +@dataclass(frozen=True) +class ManagedRecoveryWork: + """One immutable provider-dispatch work item in the local authority journal.""" + + scope_key: str + intent_id: str + payload_sha256: str + phase: str + record_state: str | None + review_required: bool + freeze_status: str + risk_settlement_status: str + recovery_reason: str | None + created_at_ns: int + updated_at_ns: int + + @property + def reconciliation_required(self) -> bool: + """Whether a provider read/reconciliation is required before a retry.""" + + return self.phase == "RECONCILIATION_REQUIRED" + + +@dataclass(frozen=True) +class ManagedRecoveryEvent: + """One deterministic monitor fact that may be appended idempotently.""" + + event_id: str + scope_key: str + intent_id: str + state: str + data: dict[str, str] + occurred_at: float + emitted: bool + + +@dataclass(frozen=True) +class ManagedRecoveryReport: + """Result of a local no-provider recovery pass.""" + + recovered_unknown_intent_ids: tuple[str, ...] + reconciliation_required_intent_ids: tuple[str, ...] + emitted_event_ids: tuple[str, ...] + resolved_freeze_intent_ids: tuple[str, ...] + + +class DurableManagedRecoveryCoordinator: + """SQLite authority journal for one or more managed execution scopes. + + ``ManagedExecutionFacade`` continues to own the durable single provider + dispatch claim and its writer lease. Callers must hold that facade lease + while mutating this coordinator. The coordinator deliberately has no + provider client and never performs network I/O. + """ + + _SCHEMA_VERSION = 2 + _PREPARED = "DISPATCH_PREPARED" + _RESULT_RECORDED = "RESULT_RECORDED" + _RECONCILIATION_REQUIRED = "RECONCILIATION_REQUIRED" + _FREEZE_PENDING = "PENDING" + _FREEZE_ATTEMPTED = "RELEASE_ATTEMPTED" + _FREEZE_RESOLVED = "RESOLVED" + _FREEZE_NOT_APPLICABLE = "NOT_APPLICABLE" + # A confirmed execution record and the risk reservation live in separate + # local databases. This explicit journal state is the proof boundary that + # prevents a restart from releasing a dispatch freeze merely because the + # execution record is known. The permit must be durably settled first. + _SETTLEMENT_PENDING = "PENDING" + _SETTLEMENT_CONFIRMED = "SETTLED" + _SETTLEMENT_NOT_APPLICABLE = "NOT_APPLICABLE" + _CONFIRMED_STATES = frozenset( + {"ACKED", "PARTIALLY_FILLED", "FILLED", "CANCELLED", "REJECTED"} + ) + + def __init__( + self, database_path: str | Path, *, timeout_seconds: float = 5.0 + ) -> None: + if not isinstance(timeout_seconds, (int, float)) or timeout_seconds <= 0: + raise ValueError("timeout_seconds must be positive") + self._database_path = Path(database_path) + self._timeout_seconds = float(timeout_seconds) + self._database_path.parent.mkdir(parents=True, exist_ok=True) + self._initialize_schema() + + @property + def database_path(self) -> Path: + """Return the one local authority journal path.""" + + return self._database_path + + def close(self) -> None: + """Keep lifecycle symmetry; this coordinator owns no open connection.""" + + def prepare_dispatch(self, intent: Any) -> ManagedRecoveryWork: + """Durably record the sole provider-dispatch work item before provider I/O. + + The facade invokes this through its pre-dispatch hook only after its + execution claim and risk dispatch latch are durable. Re-entering a + prepared work item is rejected so a corrupted or alternate caller + cannot reinterpret a restart as permission to send again. + """ + + scope_key, intent_id, fingerprint = self._intent_identity(intent) + now_ns = time.time_ns() + with self._transaction() as connection: + existing = self._work_row(connection, scope_key, intent_id) + if existing is not None: + self._assert_work_identity(existing, fingerprint) + raise ManagedRecoveryCoordinatorError( + "provider dispatch is already durably prepared; reconciliation is required" + ) + connection.execute( + """ + INSERT INTO managed_recovery_work( + scope_key, intent_id, payload_sha256, phase, record_state, + review_required, freeze_status, risk_settlement_status, + created_at_ns, updated_at_ns + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + scope_key, + intent_id, + fingerprint, + self._PREPARED, + "DISPATCHING", + 0, + self._FREEZE_NOT_APPLICABLE, + self._SETTLEMENT_NOT_APPLICABLE, + now_ns, + now_ns, + ), + ) + row = self._work_row(connection, scope_key, intent_id) + assert row is not None + return self._work_from_row(row) + + def record_result(self, intent: Any, record: Any) -> ManagedRecoveryWork: + """Persist an execution result and its deterministic monitor event. + + This operation contains no outbox call. Therefore a crash after this + commit leaves a replayable local fact instead of losing the monitor + transition. Repeating the same result preserves the first immutable + event payload and cannot create another event id. + """ + + scope_key, intent_id, fingerprint = self._intent_identity(intent) + record_intent_id = getattr(record, "intent_id", None) + record_scope_key = getattr(record, "scope_key", None) + record_fingerprint = getattr(record, "payload_sha256", None) + if ( + record_intent_id != intent_id + or record_scope_key != scope_key + or record_fingerprint != fingerprint + ): + raise ManagedRecoveryCoordinatorError( + "execution result identity does not match intent" + ) + state_value = self._state_value(record) + review_required = bool(getattr(record, "review_required", False)) + permit_reference = getattr(record, "permit_reference", None) + occurred_at = self._record_occurred_at(record) + event_id = self.event_id(scope_key, intent_id, state_value) + event_data = { + "intent_id": intent_id, + "scope_digest": self._scope_digest(scope_key), + "state": state_value, + } + now_ns = time.time_ns() + freeze_status = self._freeze_status_for_result( + state_value, review_required, permit_reference + ) + settlement_status = self._settlement_status_for_result( + state_value, review_required, permit_reference + ) + phase = ( + self._RECONCILIATION_REQUIRED + if state_value == "UNKNOWN" or review_required + else self._RESULT_RECORDED + ) + recovery_reason = ( + "unknown_provider_outcome" + if state_value == "UNKNOWN" + else "review_required" + if review_required + else None + ) + with self._transaction() as connection: + existing = self._work_row(connection, scope_key, intent_id) + if existing is None: + connection.execute( + """ + INSERT INTO managed_recovery_work( + scope_key, intent_id, payload_sha256, phase, record_state, + review_required, freeze_status, risk_settlement_status, + recovery_reason, created_at_ns, updated_at_ns + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + scope_key, + intent_id, + fingerprint, + phase, + state_value, + int(review_required), + freeze_status, + settlement_status, + recovery_reason, + now_ns, + now_ns, + ), + ) + else: + self._assert_work_identity(existing, fingerprint) + # A resolved confirmed dispatch must never regress because a + # repeated submit only rereads the same immutable execution record. + prior_freeze = str(existing["freeze_status"]) + effective_freeze = ( + prior_freeze + if prior_freeze in {self._FREEZE_ATTEMPTED, self._FREEZE_RESOLVED} + else freeze_status + ) + effective_phase = ( + self._RECONCILIATION_REQUIRED + if phase == self._RECONCILIATION_REQUIRED + else str(existing["phase"]) + if str(existing["phase"]) == self._FREEZE_RESOLVED + else phase + ) + prior_settlement = str(existing["risk_settlement_status"]) + effective_settlement = ( + self._SETTLEMENT_CONFIRMED + if prior_settlement == self._SETTLEMENT_CONFIRMED + else settlement_status + ) + connection.execute( + """ + UPDATE managed_recovery_work + SET phase = ?, record_state = ?, review_required = ?, freeze_status = ?, + risk_settlement_status = ?, recovery_reason = ?, updated_at_ns = ? + WHERE scope_key = ? AND intent_id = ? + """, + ( + effective_phase, + state_value, + int(review_required), + effective_freeze, + effective_settlement, + recovery_reason + if recovery_reason is not None + else existing["recovery_reason"], + now_ns, + scope_key, + intent_id, + ), + ) + self._insert_event_if_absent( + connection, + event_id=event_id, + scope_key=scope_key, + intent_id=intent_id, + payload_sha256=fingerprint, + state=state_value, + data=event_data, + occurred_at=occurred_at, + created_at_ns=now_ns, + ) + row = self._work_row(connection, scope_key, intent_id) + assert row is not None + return self._work_from_row(row) + + def require_reconciliation( + self, + *, + scope_key: str, + intent_id: str, + reason: str, + ) -> ManagedRecoveryWork: + """Latch an existing prepared work item for typed provider reconciliation.""" + + if not isinstance(reason, str) or not reason.replace("_", "").isalnum(): + raise ValueError("invalid recovery reason") + with self._transaction() as connection: + row = self._work_row(connection, scope_key, intent_id) + if row is None: + raise ManagedRecoveryCoordinatorError("unknown recovery work item") + connection.execute( + """ + UPDATE managed_recovery_work + SET phase = ?, recovery_reason = ?, updated_at_ns = ? + WHERE scope_key = ? AND intent_id = ? + """, + ( + self._RECONCILIATION_REQUIRED, + reason, + time.time_ns(), + scope_key, + intent_id, + ), + ) + updated = self._work_row(connection, scope_key, intent_id) + assert updated is not None + return self._work_from_row(updated) + + def work_for(self, scope_key: str, intent_id: str) -> ManagedRecoveryWork | None: + """Read one local work item without provider or outbox activity.""" + + with self._connection() as connection: + row = self._work_row(connection, scope_key, intent_id) + return None if row is None else self._work_from_row(row) + + def pending_work(self, scope_key: str) -> tuple[ManagedRecoveryWork, ...]: + """Return all recoverable local work for exactly one execution scope.""" + + with self._connection() as connection: + rows = connection.execute( + """ + SELECT * FROM managed_recovery_work + WHERE scope_key = ? + ORDER BY created_at_ns ASC, intent_id ASC + """, + (scope_key,), + ).fetchall() + return tuple(self._work_from_row(row) for row in rows) + + def append_pending_monitor_events( + self, scope_key: str, outbox: Any, event_type: Any + ) -> tuple[str, ...]: + """Append pending facts through the monitor package's idempotent outbox. + + The outbox owns delivery semantics. A crash after ``append`` but before + this journal marks the item emitted is safe because the next append uses + the same immutable event id and the monitor outbox de-duplicates it. + """ + + emitted: list[str] = [] + for event in self.pending_events(scope_key): + try: + monitor_event = event_type( + event_id=event.event_id, + scope=event.scope_key, + event_type="execution_state", + data=event.data, + occurred_at=event.occurred_at, + ) + outbox.append(monitor_event) + except Exception as error: + raise ManagedRecoveryCoordinatorError( + "monitor outbox did not confirm the deterministic execution fact" + ) from error + self._mark_event_emitted(event.event_id) + emitted.append(event.event_id) + return tuple(emitted) + + def pending_events(self, scope_key: str) -> tuple[ManagedRecoveryEvent, ...]: + """Return monitor facts not yet locally confirmed in the monitor outbox.""" + + with self._connection() as connection: + rows = connection.execute( + """ + SELECT * FROM managed_recovery_events + WHERE scope_key = ? AND emitted = 0 + ORDER BY created_at_ns ASC, event_id ASC + """, + (scope_key,), + ).fetchall() + return tuple(self._event_from_row(row) for row in rows) + + def freeze_status_for(self, scope_key: str, intent_id: str) -> str: + """Return the durable auto-resolution status for one confirmed work item.""" + + with self._connection() as connection: + row = self._work_row(connection, scope_key, intent_id) + if row is None: + raise ManagedRecoveryCoordinatorError("unknown recovery work item") + return str(row["freeze_status"]) + + def mark_freeze_release_attempted(self, scope_key: str, intent_id: str) -> None: + """Persist that recovery may safely finish an interrupted local release.""" + + self._update_freeze_status( + scope_key, + intent_id, + expected={self._FREEZE_PENDING, self._FREEZE_ATTEMPTED}, + target=self._FREEZE_ATTEMPTED, + ) + + def mark_freeze_resolved(self, scope_key: str, intent_id: str) -> None: + """Persist completion after the named local risk latch was resolved.""" + + self._update_freeze_status( + scope_key, + intent_id, + expected={ + self._FREEZE_PENDING, + self._FREEZE_ATTEMPTED, + self._FREEZE_RESOLVED, + }, + target=self._FREEZE_RESOLVED, + ) + + def risk_settlement_status_for(self, scope_key: str, intent_id: str) -> str: + """Return whether the execution-linked risk permit is durably settled.""" + + with self._connection() as connection: + row = self._work_row(connection, scope_key, intent_id) + if row is None: + raise ManagedRecoveryCoordinatorError("unknown recovery work item") + return str(row["risk_settlement_status"]) + + def mark_risk_settlement_confirmed(self, scope_key: str, intent_id: str) -> None: + """Persist proof that a confirmed dispatch's permit cannot later expire. + + This must occur only after the risk owner has atomically reported the + permit as settled (or already settled). It is intentionally separate + from the execution result because those two component stores cannot + share one transaction. + """ + + with self._transaction() as connection: + row = self._work_row(connection, scope_key, intent_id) + if row is None: + raise ManagedRecoveryCoordinatorError("unknown recovery work item") + actual = str(row["risk_settlement_status"]) + if actual not in {self._SETTLEMENT_PENDING, self._SETTLEMENT_CONFIRMED}: + raise ManagedRecoveryCoordinatorError( + "recovery work is not eligible for risk-settlement confirmation" + ) + connection.execute( + """ + UPDATE managed_recovery_work + SET risk_settlement_status = ?, updated_at_ns = ? + WHERE scope_key = ? AND intent_id = ? + """, + (self._SETTLEMENT_CONFIRMED, time.time_ns(), scope_key, intent_id), + ) + + @staticmethod + def event_id(scope_key: str, intent_id: str, state: str) -> str: + """Return a scope-qualified deterministic monitor event id.""" + + digest = hashlib.sha256(scope_key.encode("utf-8")).hexdigest()[:24] + return "managed.execution." + digest + "." + intent_id + "." + state.lower() + + def _update_freeze_status( + self, + scope_key: str, + intent_id: str, + *, + expected: set[str], + target: str, + ) -> None: + with self._transaction() as connection: + row = self._work_row(connection, scope_key, intent_id) + if row is None: + raise ManagedRecoveryCoordinatorError("unknown recovery work item") + actual = str(row["freeze_status"]) + if actual not in expected: + raise ManagedRecoveryCoordinatorError( + "recovery work is not eligible for dispatch-freeze resolution" + ) + if ( + target == self._FREEZE_RESOLVED + and str(row["risk_settlement_status"]) != self._SETTLEMENT_CONFIRMED + ): + raise ManagedRecoveryCoordinatorError( + "dispatch freeze cannot resolve before risk settlement is proven" + ) + phase = ( + self._FREEZE_RESOLVED + if target == self._FREEZE_RESOLVED + else str(row["phase"]) + ) + connection.execute( + """ + UPDATE managed_recovery_work + SET freeze_status = ?, phase = ?, updated_at_ns = ? + WHERE scope_key = ? AND intent_id = ? + """, + (target, phase, time.time_ns(), scope_key, intent_id), + ) + + def _mark_event_emitted(self, event_id: str) -> None: + with self._transaction() as connection: + result = connection.execute( + "UPDATE managed_recovery_events SET emitted = 1 WHERE event_id = ?", + (event_id,), + ) + if result.rowcount != 1: + raise ManagedRecoveryCoordinatorError("unknown recovery monitor event") + + def _insert_event_if_absent( + self, + connection: sqlite3.Connection, + *, + event_id: str, + scope_key: str, + intent_id: str, + payload_sha256: str, + state: str, + data: dict[str, str], + occurred_at: float, + created_at_ns: int, + ) -> None: + data_json = self._canonical_json(data) + fingerprint = self._event_fingerprint(scope_key, state, data_json, occurred_at) + existing = connection.execute( + "SELECT fingerprint FROM managed_recovery_events WHERE event_id = ?", + (event_id,), + ).fetchone() + if existing is not None: + if str(existing["fingerprint"]) != fingerprint: + raise ManagedRecoveryCoordinatorError( + "deterministic monitor event id conflicts with another result" + ) + return + connection.execute( + """ + INSERT INTO managed_recovery_events( + event_id, scope_key, intent_id, payload_sha256, state, data_json, + occurred_at, fingerprint, emitted, created_at_ns + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0, ?) + """, + ( + event_id, + scope_key, + intent_id, + payload_sha256, + state, + data_json, + occurred_at, + fingerprint, + created_at_ns, + ), + ) + + def _initialize_schema(self) -> None: + # sqlite3.executescript() manages its own transaction boundary. Do + # not wrap it in _transaction(), otherwise SQLite commits before our + # context manager reaches COMMIT and reports "no transaction is active". + with self._connection() as connection: + connection.executescript( + """ + CREATE TABLE IF NOT EXISTS managed_recovery_meta ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL + ); + CREATE TABLE IF NOT EXISTS managed_recovery_work ( + scope_key TEXT NOT NULL, + intent_id TEXT NOT NULL, + payload_sha256 TEXT NOT NULL, + phase TEXT NOT NULL, + record_state TEXT, + review_required INTEGER NOT NULL DEFAULT 0, + freeze_status TEXT NOT NULL, + risk_settlement_status TEXT NOT NULL DEFAULT 'NOT_APPLICABLE', + recovery_reason TEXT, + created_at_ns INTEGER NOT NULL, + updated_at_ns INTEGER NOT NULL, + PRIMARY KEY(scope_key, intent_id) + ); + CREATE TABLE IF NOT EXISTS managed_recovery_events ( + event_id TEXT PRIMARY KEY, + scope_key TEXT NOT NULL, + intent_id TEXT NOT NULL, + payload_sha256 TEXT NOT NULL, + state TEXT NOT NULL, + data_json TEXT NOT NULL, + occurred_at REAL NOT NULL, + fingerprint TEXT NOT NULL, + emitted INTEGER NOT NULL DEFAULT 0, + created_at_ns INTEGER NOT NULL, + FOREIGN KEY(scope_key, intent_id) + REFERENCES managed_recovery_work(scope_key, intent_id) + ); + CREATE INDEX IF NOT EXISTS managed_recovery_pending_events + ON managed_recovery_events(scope_key, emitted, created_at_ns); + """ + ) + columns = { + str(row["name"]) + for row in connection.execute("PRAGMA table_info(managed_recovery_work)").fetchall() + } + if "risk_settlement_status" not in columns: + connection.execute( + """ + ALTER TABLE managed_recovery_work + ADD COLUMN risk_settlement_status TEXT NOT NULL DEFAULT 'NOT_APPLICABLE' + """ + ) + connection.execute( + "INSERT OR IGNORE INTO managed_recovery_meta(key, value) VALUES (?, ?)", + ("schema_version", str(self._SCHEMA_VERSION)), + ) + row = connection.execute( + "SELECT value FROM managed_recovery_meta WHERE key = ?", + ("schema_version",), + ).fetchone() + if row is None: + raise ManagedRecoveryCoordinatorError("unsupported managed recovery schema") + if str(row["value"]) == "1": + # Version 1 has no durable proof that a confirmed record's + # permit was settled. Treat every non-review confirmed record + # as pending, which is conservative: restart must re-check the + # risk owner before it may release any freeze. + connection.execute( + """ + UPDATE managed_recovery_work + SET risk_settlement_status = CASE + WHEN record_state IN ('ACKED', 'PARTIALLY_FILLED', 'FILLED', + 'CANCELLED', 'REJECTED') + AND review_required = 0 + THEN ? + ELSE ? + END + """, + (self._SETTLEMENT_PENDING, self._SETTLEMENT_NOT_APPLICABLE), + ) + connection.execute( + "UPDATE managed_recovery_meta SET value = ? WHERE key = ?", + (str(self._SCHEMA_VERSION), "schema_version"), + ) + elif str(row["value"]) != str(self._SCHEMA_VERSION): + raise ManagedRecoveryCoordinatorError( + "unsupported managed recovery schema" + ) + + @contextmanager + def _connection(self) -> Iterator[sqlite3.Connection]: + connection = sqlite3.connect( + str(self._database_path), + timeout=self._timeout_seconds, + isolation_level=None, + ) + connection.row_factory = sqlite3.Row + try: + connection.execute("PRAGMA foreign_keys = ON") + connection.execute("PRAGMA synchronous = FULL") + connection.execute("PRAGMA journal_mode = WAL") + yield connection + except sqlite3.Error as error: + raise ManagedRecoveryCoordinatorError( + "managed recovery journal is unavailable" + ) from error + finally: + connection.close() + + @contextmanager + def _transaction(self) -> Iterator[sqlite3.Connection]: + with self._connection() as connection: + connection.execute("BEGIN IMMEDIATE") + try: + yield connection + except BaseException: + connection.execute("ROLLBACK") + raise + else: + try: + connection.execute("COMMIT") + except sqlite3.Error as error: + connection.execute("ROLLBACK") + raise ManagedRecoveryCoordinatorError( + "managed recovery journal commit failed" + ) from error + + @staticmethod + def _work_row( + connection: sqlite3.Connection, scope_key: str, intent_id: str + ) -> sqlite3.Row | None: + return connection.execute( + """ + SELECT * FROM managed_recovery_work + WHERE scope_key = ? AND intent_id = ? + """, + (scope_key, intent_id), + ).fetchone() + + @classmethod + def _work_from_row(cls, row: sqlite3.Row) -> ManagedRecoveryWork: + return ManagedRecoveryWork( + scope_key=str(row["scope_key"]), + intent_id=str(row["intent_id"]), + payload_sha256=str(row["payload_sha256"]), + phase=str(row["phase"]), + record_state=None + if row["record_state"] is None + else str(row["record_state"]), + review_required=bool(row["review_required"]), + freeze_status=str(row["freeze_status"]), + risk_settlement_status=str(row["risk_settlement_status"]), + recovery_reason=( + None if row["recovery_reason"] is None else str(row["recovery_reason"]) + ), + created_at_ns=int(row["created_at_ns"]), + updated_at_ns=int(row["updated_at_ns"]), + ) + + @classmethod + def _event_from_row(cls, row: sqlite3.Row) -> ManagedRecoveryEvent: + try: + data = json.loads(str(row["data_json"])) + except (TypeError, json.JSONDecodeError) as error: + raise ManagedRecoveryCoordinatorError( + "stored monitor event is unreadable" + ) from error + if not isinstance(data, dict) or not all(isinstance(key, str) for key in data): + raise ManagedRecoveryCoordinatorError("stored monitor event is invalid") + return ManagedRecoveryEvent( + event_id=str(row["event_id"]), + scope_key=str(row["scope_key"]), + intent_id=str(row["intent_id"]), + state=str(row["state"]), + data={str(key): str(value) for key, value in data.items()}, + occurred_at=float(row["occurred_at"]), + emitted=bool(row["emitted"]), + ) + + @staticmethod + def _intent_identity(intent: Any) -> tuple[str, str, str]: + scope = getattr(intent, "scope", None) + scope_key = getattr(scope, "key", None) + intent_id = getattr(intent, "intent_id", None) + fingerprint = getattr(intent, "fingerprint", None) + if ( + not isinstance(scope_key, str) + or not scope_key.strip() + or not isinstance(intent_id, str) + or not intent_id.strip() + or not isinstance(fingerprint, str) + or not fingerprint.strip() + ): + raise ManagedRecoveryCoordinatorError( + "invalid managed execution intent identity" + ) + if len(fingerprint) != 64 or any( + char not in "0123456789abcdef" for char in fingerprint + ): + raise ManagedRecoveryCoordinatorError( + "invalid managed execution intent fingerprint" + ) + return scope_key, intent_id, fingerprint + + @staticmethod + def _state_value(record: Any) -> str: + state = getattr(record, "state", None) + value = getattr(state, "value", state) + if not isinstance(value, str) or not value.strip() or value != value.upper(): + raise ManagedRecoveryCoordinatorError("invalid durable execution state") + return value + + @staticmethod + def _record_occurred_at(record: Any) -> float: + updated_at_ns = getattr(record, "updated_at_ns", None) + if not isinstance(updated_at_ns, int) or updated_at_ns <= 0: + raise ManagedRecoveryCoordinatorError( + "execution result lacks durable update time" + ) + return updated_at_ns / 1_000_000_000 + + @classmethod + def _freeze_status_for_result( + cls, + state_value: str, + review_required: bool, + permit_reference: Any, + ) -> str: + if ( + state_value in cls._CONFIRMED_STATES + and not review_required + and isinstance(permit_reference, str) + and permit_reference.strip() + ): + return cls._FREEZE_PENDING + return cls._FREEZE_NOT_APPLICABLE + + @classmethod + def _settlement_status_for_result( + cls, + state_value: str, + review_required: bool, + permit_reference: Any, + ) -> str: + if ( + state_value in cls._CONFIRMED_STATES + and not review_required + and isinstance(permit_reference, str) + and permit_reference.strip() + ): + return cls._SETTLEMENT_PENDING + return cls._SETTLEMENT_NOT_APPLICABLE + + @staticmethod + def _scope_digest(scope_key: str) -> str: + """Strip the display-only scope prefix on Python 3.8 and newer.""" + + return scope_key[6:] if scope_key.startswith("scope:") else scope_key + + @staticmethod + def _assert_work_identity(row: sqlite3.Row, fingerprint: str) -> None: + if str(row["payload_sha256"]) != fingerprint: + raise ManagedRecoveryCoordinatorError( + "intent id was reused with another payload" + ) + + @staticmethod + def _canonical_json(data: dict[str, str]) -> str: + try: + return json.dumps( + data, sort_keys=True, separators=(",", ":"), ensure_ascii=True + ) + except (TypeError, ValueError) as error: + raise ManagedRecoveryCoordinatorError( + "monitor event data is not serializable" + ) from error + + @staticmethod + def _event_fingerprint( + scope_key: str, state: str, data_json: str, occurred_at: float + ) -> str: + canonical = json.dumps( + { + "scope_key": scope_key, + "state": state, + "data": json.loads(data_json), + "occurred_at": occurred_at, + }, + sort_keys=True, + separators=(",", ":"), + ensure_ascii=True, + ) + return hashlib.sha256(canonical.encode("utf-8")).hexdigest() + + +__all__ = [ + "DurableManagedRecoveryCoordinator", + "ManagedRecoveryCoordinatorError", + "ManagedRecoveryEvent", + "ManagedRecoveryReport", + "ManagedRecoveryWork", +] diff --git a/bt_api_py/runtime_plugins/reconcile_control.py b/bt_api_py/runtime_plugins/reconcile_control.py new file mode 100644 index 00000000..6afa6b80 --- /dev/null +++ b/bt_api_py/runtime_plugins/reconcile_control.py @@ -0,0 +1,1417 @@ +"""Fail-closed operator control for managed unknown-dispatch reconciliation. + +The execution facade deliberately owns only an immutable local order journal. +It can record provider evidence, but it has no operator identity, authorization, +or monitor-delivery authority. This module is the small composition-layer +control port that joins those independent concerns without adding an execution +package import edge to the risk or monitor packages. + +It accepts only an already composed managed runtime, a typed provider +observation, and a code-owned authorization callback. Reconciliation evidence, +authorization decisions, and release attempts are durably audited locally. A +per-intent dispatch freeze remains active whenever evidence, audit, monitor +outbox, authorization, or release confirmation is unavailable. + +The port never opens a socket or obtains provider evidence itself. The caller +must supply evidence obtained through an independently controlled reconciliation +adapter. Calling ``ManagedExecutionFacade.reconcile`` directly remains a +ledger-only operation and never clears a dispatch freeze. +""" + +from __future__ import annotations + +import hashlib +import json +import math +import re +import sqlite3 +import time +from collections.abc import Callable, Iterator, Mapping +from contextlib import contextmanager +from dataclasses import dataclass +from decimal import Decimal +from enum import StrEnum +from pathlib import Path +from types import MappingProxyType +from typing import Any + +from .catalog import RuntimePluginError + +_IDENTIFIER = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$") +_REASON_CODE = re.compile(r"^[a-z][a-z0-9_]{0,127}$") +_SHA256 = re.compile(r"^[0-9a-f]{64}$") + + +class ControlAuditError(RuntimeError): + """Base error for the local reconciliation-control audit ledger.""" + + +class ControlAuditConflictError(ControlAuditError): + """An immutable evidence or control-command id was reused differently.""" + + +class ControlCommandStatus(StrEnum): + """Durable lifecycle of one intent-freeze release command.""" + + PENDING = "pending" + AUTHORIZED = "authorized" + DENIED = "denied" + RELEASED = "released" + EXPIRED = "expired" + + +def _identifier(value: object, field_name: str) -> str: + if not isinstance(value, str) or value != value.strip() or not _IDENTIFIER.fullmatch(value): + raise ValueError("invalid " + field_name) + return value + + +def _reason_code(value: object, field_name: str) -> str: + if not isinstance(value, str) or not _REASON_CODE.fullmatch(value): + raise ValueError("invalid " + field_name) + return value + + +def _digest(value: object, field_name: str) -> str: + if not isinstance(value, str) or not _SHA256.fullmatch(value): + raise ValueError("invalid " + field_name) + return value + + +def _timestamp(value: object, field_name: str) -> float: + if isinstance(value, bool) or not isinstance(value, (int, float)) or not math.isfinite(value): + raise ValueError("invalid " + field_name) + return float(value) + + +def _canonical_json(value: object) -> str: + return json.dumps( + value, sort_keys=True, separators=(",", ":"), ensure_ascii=True, allow_nan=False + ) + + +def _sha256(value: object) -> str: + return hashlib.sha256(_canonical_json(value).encode("utf-8")).hexdigest() + + +def _decimal_text(value: object, field_name: str) -> str: + try: + result = format(value, "f") + except (TypeError, ValueError) as error: + raise ValueError("invalid " + field_name) from error + return result + + +def _observation_projection(observation: object) -> dict[str, object]: + """Create the only redacted observation form that enters the audit ledger.""" + + try: + state = observation.state.value # type: ignore[attr-defined] + intent_id = observation.intent_id # type: ignore[attr-defined] + provider_order_id = observation.provider_order_id # type: ignore[attr-defined] + filled_quantity = observation.filled_quantity # type: ignore[attr-defined] + average_price = observation.average_price # type: ignore[attr-defined] + reason_code = observation.reason_code # type: ignore[attr-defined] + except AttributeError as error: + raise ValueError("invalid typed provider observation") from error + return { + "intent_id": _identifier(intent_id, "observation intent_id"), + "state": _identifier(state, "observation state"), + "provider_order_id": ( + None + if provider_order_id is None + else _identifier(provider_order_id, "provider_order_id") + ), + "filled_quantity": _decimal_text(filled_quantity, "filled_quantity"), + "average_price": ( + None if average_price is None else _decimal_text(average_price, "average_price") + ), + "reason_code": ( + None if reason_code is None else _reason_code(reason_code, "observation reason_code") + ), + } + + +def _record_projection(record: object) -> dict[str, object]: + """Return a non-secret execution-record projection for audit comparisons.""" + + try: + state = record.state.value # type: ignore[attr-defined] + provider_order_id = record.provider_order_id # type: ignore[attr-defined] + filled_quantity = record.filled_quantity # type: ignore[attr-defined] + average_price = record.average_price # type: ignore[attr-defined] + review_required = record.review_required # type: ignore[attr-defined] + except AttributeError as error: + raise ValueError("invalid execution record") from error + if type(review_required) is not bool: + raise ValueError("invalid execution record review_required") + return { + "state": _identifier(state, "record state"), + "provider_order_id": ( + None + if provider_order_id is None + else _identifier(provider_order_id, "record provider_order_id") + ), + "filled_quantity": _decimal_text(filled_quantity, "record filled_quantity"), + "average_price": ( + None if average_price is None else _decimal_text(average_price, "record average_price") + ), + "review_required": review_required, + } + + +@dataclass(frozen=True) +class ReconciliationEvidence: + """Typed, externally obtained evidence for one previously unknown dispatch. + + ``source_receipt_digest`` is an opaque SHA-256 reference to the reviewed + provider response retained by the deployment's evidence system. This + module deliberately stores no raw provider payload or credential. + """ + + evidence_id: str + intent_id: str + observation: Any + source_receipt_digest: str + observed_at: float + + def __post_init__(self) -> None: + object.__setattr__(self, "evidence_id", _identifier(self.evidence_id, "evidence_id")) + object.__setattr__(self, "intent_id", _identifier(self.intent_id, "intent_id")) + object.__setattr__( + self, + "source_receipt_digest", + _digest(self.source_receipt_digest, "source_receipt_digest"), + ) + object.__setattr__(self, "observed_at", _timestamp(self.observed_at, "observed_at")) + + @property + def fingerprint(self) -> str: + """Return a deterministic identity that binds typed evidence to its receipt.""" + + return _sha256( + { + "evidence_id": self.evidence_id, + "intent_id": self.intent_id, + "observation": _observation_projection(self.observation), + "observed_at": self.observed_at, + "source_receipt_digest": self.source_receipt_digest, + } + ) + + +@dataclass(frozen=True) +class ReleaseIntentFreezeCommand: + """One operator request to release a single durable dispatch freeze.""" + + command_id: str + scope: str + intent_id: str + evidence_id: str + evidence_fingerprint: str + issuer_id: str + reason_code: str + issued_at: float + expires_at: float + + def __post_init__(self) -> None: + for name in ("command_id", "scope", "intent_id", "evidence_id", "issuer_id"): + object.__setattr__(self, name, _identifier(getattr(self, name), name)) + object.__setattr__( + self, + "evidence_fingerprint", + _digest(self.evidence_fingerprint, "evidence_fingerprint"), + ) + object.__setattr__(self, "reason_code", _reason_code(self.reason_code, "reason_code")) + object.__setattr__(self, "issued_at", _timestamp(self.issued_at, "issued_at")) + object.__setattr__(self, "expires_at", _timestamp(self.expires_at, "expires_at")) + if self.expires_at <= self.issued_at: + raise ValueError("expires_at must be after issued_at") + + @property + def fingerprint(self) -> str: + return _sha256( + { + "command_id": self.command_id, + "evidence_fingerprint": self.evidence_fingerprint, + "evidence_id": self.evidence_id, + "expires_at": self.expires_at, + "intent_id": self.intent_id, + "issued_at": self.issued_at, + "issuer_id": self.issuer_id, + "reason_code": self.reason_code, + "scope": self.scope, + } + ) + + +@dataclass(frozen=True) +class AuthorizationDecision: + """The only accepted result from a code-owned release authorizer.""" + + approved: bool + subject_id: str + receipt_digest: str + reason_code: str + + def __post_init__(self) -> None: + if type(self.approved) is not bool: + raise ValueError("approved must be a boolean") + object.__setattr__(self, "subject_id", _identifier(self.subject_id, "subject_id")) + object.__setattr__(self, "receipt_digest", _digest(self.receipt_digest, "receipt_digest")) + object.__setattr__(self, "reason_code", _reason_code(self.reason_code, "reason_code")) + + +@dataclass(frozen=True) +class ReleaseAuthorizationRequest: + """Redacted context supplied to the deployment's authorization callback.""" + + command: ReleaseIntentFreezeCommand + evidence: ReconciliationEvidence + reconciled_record: Mapping[str, object] + + +@dataclass(frozen=True) +class ReconciliationAudit: + """Durable local proof of one reconciliation fact and its monitor delivery.""" + + evidence_id: str + fingerprint: str + scope: str + intent_id: str + observation: Mapping[str, object] + record: Mapping[str, object] + source_receipt_digest: str + observed_at: float + monitor_event_id: str + monitor_published: bool + + +@dataclass(frozen=True) +class ControlCommandAudit: + """Durable audit projection for a release command.""" + + command: ReleaseIntentFreezeCommand + status: ControlCommandStatus + authorization_subject_id: str | None + authorization_receipt_digest: str | None + authorization_reason_code: str | None + release_applied_at: float | None + released_at: float | None + outcome_code: str | None + + +@dataclass(frozen=True) +class ControlledReconciliationResult: + """Result of a runtime-owned reconcile plus monitor publication.""" + + record: Any + audit: ReconciliationAudit + + +@dataclass(frozen=True) +class FreezeReleaseResult: + """Result of a reviewed operator release operation.""" + + command_id: str + intent_id: str + released: bool + idempotent: bool + + +class DurableReconciliationControlAudit: + """SQLite audit ledger for evidence and reviewed freeze-release commands. + + This is intentionally a separate local ledger from execution, risk, and + monitor. It does not invent distributed atomicity: callers persist audit + and monitor facts before clearing the risk latch, and reassert the latch if + a post-clear confirmation cannot be persisted. + """ + + def __init__( + self, + database_path: Path | str, + *, + clock: Callable[[], float] | None = None, + timeout_seconds: float = 5.0, + ) -> None: + self._database_path = Path(database_path) + self._clock = clock or time.time + self._timeout_seconds = timeout_seconds + self._database_path.parent.mkdir(parents=True, exist_ok=True) + self._initialize_schema() + + def close(self) -> None: + """Keep a symmetric lifecycle hook; each SQLite operation owns its connection.""" + + def get_reconciliation(self, evidence_id: str) -> ReconciliationAudit | None: + evidence_id = _identifier(evidence_id, "evidence_id") + with self._connection() as connection: + row = connection.execute( + "SELECT * FROM reconciliation_control_evidence WHERE evidence_id = ?", + (evidence_id,), + ).fetchone() + return None if row is None else self._reconciliation_from_row(row) + + def record_reconciliation( + self, + evidence: ReconciliationEvidence, + *, + scope: str, + observation: Mapping[str, object], + record: Mapping[str, object], + monitor_event_id: str, + ) -> ReconciliationAudit: + """Persist exact reconciliation evidence before it can release a latch.""" + + scope = _identifier(scope, "scope") + monitor_event_id = _identifier(monitor_event_id, "monitor_event_id") + fingerprint = evidence.fingerprint + observation_json = _canonical_json(observation) + record_json = _canonical_json(record) + now = self._clock() + with self._transaction() as connection: + existing = connection.execute( + "SELECT * FROM reconciliation_control_evidence WHERE evidence_id = ?", + (evidence.evidence_id,), + ).fetchone() + if existing is not None: + if ( + str(existing["fingerprint"]) != fingerprint + or str(existing["scope"]) != scope + or str(existing["intent_id"]) != evidence.intent_id + or str(existing["monitor_event_id"]) != monitor_event_id + ): + raise ControlAuditConflictError("evidence_id was reused with different content") + return self._reconciliation_from_row(existing) + connection.execute( + """ + INSERT INTO reconciliation_control_evidence ( + evidence_id, fingerprint, scope, intent_id, observation_json, record_json, + source_receipt_digest, observed_at, monitor_event_id, monitor_published, + created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?) + """, + ( + evidence.evidence_id, + fingerprint, + scope, + evidence.intent_id, + observation_json, + record_json, + evidence.source_receipt_digest, + evidence.observed_at, + monitor_event_id, + now, + now, + ), + ) + row = connection.execute( + "SELECT * FROM reconciliation_control_evidence WHERE evidence_id = ?", + (evidence.evidence_id,), + ).fetchone() + assert row is not None + return self._reconciliation_from_row(row) + + def mark_reconciliation_monitor_published( + self, + evidence_id: str, + monitor_event_id: str, + ) -> ReconciliationAudit: + """Record that the exact reconcile fact reached the durable monitor outbox.""" + + evidence_id = _identifier(evidence_id, "evidence_id") + monitor_event_id = _identifier(monitor_event_id, "monitor_event_id") + with self._transaction() as connection: + row = connection.execute( + "SELECT * FROM reconciliation_control_evidence WHERE evidence_id = ?", + (evidence_id,), + ).fetchone() + if row is None: + raise ControlAuditError("reconciliation evidence does not exist") + if str(row["monitor_event_id"]) != monitor_event_id: + raise ControlAuditConflictError("monitor event differs from reconciled evidence") + connection.execute( + """ + UPDATE reconciliation_control_evidence + SET monitor_published = 1, updated_at = ? WHERE evidence_id = ? + """, + (self._clock(), evidence_id), + ) + updated = connection.execute( + "SELECT * FROM reconciliation_control_evidence WHERE evidence_id = ?", + (evidence_id,), + ).fetchone() + assert updated is not None + return self._reconciliation_from_row(updated) + + def get_command(self, command_id: str) -> ControlCommandAudit | None: + command_id = _identifier(command_id, "command_id") + with self._connection() as connection: + row = connection.execute( + "SELECT * FROM reconciliation_control_commands WHERE command_id = ?", (command_id,) + ).fetchone() + return None if row is None else self._command_from_row(row) + + def record_command(self, command: ReleaseIntentFreezeCommand) -> ControlCommandAudit: + """Persist an immutable command before authorization or risk mutation.""" + + now = self._clock() + with self._transaction() as connection: + existing = connection.execute( + "SELECT * FROM reconciliation_control_commands WHERE command_id = ?", + (command.command_id,), + ).fetchone() + if existing is not None: + if str(existing["fingerprint"]) != command.fingerprint: + raise ControlAuditConflictError("command_id was reused with different content") + return self._command_from_row(existing) + connection.execute( + """ + INSERT INTO reconciliation_control_commands ( + command_id, fingerprint, scope, intent_id, evidence_id, evidence_fingerprint, + issuer_id, reason_code, issued_at, expires_at, status, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + command.command_id, + command.fingerprint, + command.scope, + command.intent_id, + command.evidence_id, + command.evidence_fingerprint, + command.issuer_id, + command.reason_code, + command.issued_at, + command.expires_at, + ControlCommandStatus.PENDING.value, + now, + ), + ) + self._append_attempt( + connection, + command.command_id, + "command_recorded", + "command_recorded", + now, + ) + row = connection.execute( + "SELECT * FROM reconciliation_control_commands WHERE command_id = ?", + (command.command_id,), + ).fetchone() + assert row is not None + return self._command_from_row(row) + + def record_authorization( + self, + command_id: str, + decision: AuthorizationDecision, + ) -> ControlCommandAudit: + """Persist the identity-bound authorization decision before any release.""" + + command_id = _identifier(command_id, "command_id") + now = self._clock() + with self._transaction() as connection: + row = self._command_row(connection, command_id) + status = ControlCommandStatus(str(row["status"])) + if status in {ControlCommandStatus.RELEASED, ControlCommandStatus.EXPIRED}: + raise ControlAuditError("command is no longer authorizable") + next_status = ( + ControlCommandStatus.AUTHORIZED + if decision.approved + else ControlCommandStatus.DENIED + ) + connection.execute( + """ + UPDATE reconciliation_control_commands + SET status = ?, authorization_subject_id = ?, authorization_receipt_digest = ?, + authorization_reason_code = ?, authorization_at = ?, outcome_code = ?, + updated_at = ? + WHERE command_id = ? + """, + ( + next_status.value, + decision.subject_id, + decision.receipt_digest, + decision.reason_code, + now, + decision.reason_code, + now, + command_id, + ), + ) + self._append_attempt( + connection, + command_id, + "authorization_approved" if decision.approved else "authorization_denied", + decision.reason_code, + now, + ) + updated = self._command_row(connection, command_id) + return self._command_from_row(updated) + + def mark_command_expired(self, command_id: str) -> ControlCommandAudit: + """Durably refuse an expired command without touching the risk latch.""" + + return self._set_command_status( + command_id, + ControlCommandStatus.EXPIRED, + "command_expired", + allowed={ControlCommandStatus.PENDING, ControlCommandStatus.AUTHORIZED}, + ) + + def record_release_applied(self, command_id: str) -> ControlCommandAudit: + """Persist a release preparation before mutating the risk latch. + + ``release_applied_at`` is retained as a stable command-local event + timestamp even if a later failure reasserts the latch. It is a durable + *preparation*, never proof that the freeze was released. Recording it + before ``resolve_freeze`` lets a restarted control port detect a crash + in that cross-store interval and restore the fail-closed latch. + """ + + command_id = _identifier(command_id, "command_id") + now = self._clock() + with self._transaction() as connection: + row = self._command_row(connection, command_id) + if ControlCommandStatus(str(row["status"])) is not ControlCommandStatus.AUTHORIZED: + raise ControlAuditError("command is not authorized") + applied_at = row["release_applied_at"] + if applied_at is None: + connection.execute( + """ + UPDATE reconciliation_control_commands + SET release_applied_at = ?, outcome_code = ?, updated_at = ? + WHERE command_id = ? + """, + (now, "freeze_release_prepared", now, command_id), + ) + self._append_attempt( + connection, + command_id, + "freeze_release_prepared", + "freeze_release_prepared", + now, + ) + updated = self._command_row(connection, command_id) + return self._command_from_row(updated) + + def mark_command_reasserted(self, command_id: str, outcome_code: str) -> ControlCommandAudit: + """Return a failed release to pending after the dispatch freeze was restored.""" + + outcome_code = _reason_code(outcome_code, "outcome_code") + command_id = _identifier(command_id, "command_id") + now = self._clock() + with self._transaction() as connection: + row = self._command_row(connection, command_id) + if ControlCommandStatus(str(row["status"])) is ControlCommandStatus.RELEASED: + raise ControlAuditError("released command cannot be reasserted") + connection.execute( + """ + UPDATE reconciliation_control_commands + SET status = ?, outcome_code = ?, updated_at = ? + WHERE command_id = ? + """, + (ControlCommandStatus.PENDING.value, outcome_code, now, command_id), + ) + self._append_attempt(connection, command_id, "freeze_reasserted", outcome_code, now) + updated = self._command_row(connection, command_id) + return self._command_from_row(updated) + + def unconfirmed_release_commands(self, scope: str) -> list[ControlCommandAudit]: + """Return prepared, non-final releases that must be re-frozen on restart. + + A row here is deliberately not a success record. It says only that a + process durably intended to clear a latch before it could atomically + confirm monitor delivery and the final audit. Callers must reassert + the matching freeze before allowing any retry. + """ + + scope = _identifier(scope, "scope") + with self._connection() as connection: + rows = connection.execute( + """ + SELECT * FROM reconciliation_control_commands + WHERE scope = ? AND status IN (?, ?) AND release_applied_at IS NOT NULL + ORDER BY command_id ASC + """, + (scope, ControlCommandStatus.PENDING.value, ControlCommandStatus.AUTHORIZED.value), + ).fetchall() + return [self._command_from_row(row) for row in rows] + + def mark_command_released(self, command_id: str) -> ControlCommandAudit: + """Commit the final successful release after monitor publication succeeds.""" + + command_id = _identifier(command_id, "command_id") + now = self._clock() + with self._transaction() as connection: + row = self._command_row(connection, command_id) + status = ControlCommandStatus(str(row["status"])) + if status is ControlCommandStatus.RELEASED: + return self._command_from_row(row) + if status is not ControlCommandStatus.AUTHORIZED or row["release_applied_at"] is None: + raise ControlAuditError("command release confirmation is not ready") + connection.execute( + """ + UPDATE reconciliation_control_commands + SET status = ?, released_at = ?, outcome_code = ?, updated_at = ? + WHERE command_id = ? + """, + (ControlCommandStatus.RELEASED.value, now, "freeze_released", now, command_id), + ) + self._append_attempt(connection, command_id, "freeze_released", "freeze_released", now) + updated = self._command_row(connection, command_id) + return self._command_from_row(updated) + + def record_pending_failure(self, command_id: str, outcome_code: str) -> None: + """Add a durable failed-attempt fact while retaining a retryable pending command.""" + + command_id = _identifier(command_id, "command_id") + outcome_code = _reason_code(outcome_code, "outcome_code") + now = self._clock() + with self._transaction() as connection: + self._command_row(connection, command_id) + connection.execute( + """ + UPDATE reconciliation_control_commands SET outcome_code = ?, updated_at = ? + WHERE command_id = ? + """, + (outcome_code, now, command_id), + ) + self._append_attempt(connection, command_id, "release_not_applied", outcome_code, now) + + def _set_command_status( + self, + command_id: str, + status: ControlCommandStatus, + outcome_code: str, + *, + allowed: set[ControlCommandStatus], + ) -> ControlCommandAudit: + command_id = _identifier(command_id, "command_id") + outcome_code = _reason_code(outcome_code, "outcome_code") + now = self._clock() + with self._transaction() as connection: + row = self._command_row(connection, command_id) + current = ControlCommandStatus(str(row["status"])) + if current is status: + return self._command_from_row(row) + if current not in allowed: + raise ControlAuditError("command has an incompatible lifecycle state") + connection.execute( + """ + UPDATE reconciliation_control_commands SET status = ?, outcome_code = ?, updated_at = ? + WHERE command_id = ? + """, + (status.value, outcome_code, now, command_id), + ) + self._append_attempt(connection, command_id, status.value, outcome_code, now) + updated = self._command_row(connection, command_id) + return self._command_from_row(updated) + + @staticmethod + def _command_row(connection: sqlite3.Connection, command_id: str) -> sqlite3.Row: + row = connection.execute( + "SELECT * FROM reconciliation_control_commands WHERE command_id = ?", (command_id,) + ).fetchone() + if row is None: + raise ControlAuditError("control command does not exist") + return row + + @staticmethod + def _append_attempt( + connection: sqlite3.Connection, + command_id: str, + event_type: str, + outcome_code: str, + occurred_at: float, + ) -> None: + connection.execute( + """ + INSERT INTO reconciliation_control_attempts + (command_id, event_type, outcome_code, occurred_at) + VALUES (?, ?, ?, ?) + """, + (command_id, event_type, outcome_code, occurred_at), + ) + + @staticmethod + def _reconciliation_from_row(row: sqlite3.Row) -> ReconciliationAudit: + return ReconciliationAudit( + evidence_id=str(row["evidence_id"]), + fingerprint=str(row["fingerprint"]), + scope=str(row["scope"]), + intent_id=str(row["intent_id"]), + observation=MappingProxyType(json.loads(str(row["observation_json"]))), + record=MappingProxyType(json.loads(str(row["record_json"]))), + source_receipt_digest=str(row["source_receipt_digest"]), + observed_at=float(row["observed_at"]), + monitor_event_id=str(row["monitor_event_id"]), + monitor_published=bool(row["monitor_published"]), + ) + + @staticmethod + def _command_from_row(row: sqlite3.Row) -> ControlCommandAudit: + command = ReleaseIntentFreezeCommand( + command_id=str(row["command_id"]), + scope=str(row["scope"]), + intent_id=str(row["intent_id"]), + evidence_id=str(row["evidence_id"]), + evidence_fingerprint=str(row["evidence_fingerprint"]), + issuer_id=str(row["issuer_id"]), + reason_code=str(row["reason_code"]), + issued_at=float(row["issued_at"]), + expires_at=float(row["expires_at"]), + ) + return ControlCommandAudit( + command=command, + status=ControlCommandStatus(str(row["status"])), + authorization_subject_id=( + None + if row["authorization_subject_id"] is None + else str(row["authorization_subject_id"]) + ), + authorization_receipt_digest=( + None + if row["authorization_receipt_digest"] is None + else str(row["authorization_receipt_digest"]) + ), + authorization_reason_code=( + None + if row["authorization_reason_code"] is None + else str(row["authorization_reason_code"]) + ), + release_applied_at=( + None if row["release_applied_at"] is None else float(row["release_applied_at"]) + ), + released_at=None if row["released_at"] is None else float(row["released_at"]), + outcome_code=None if row["outcome_code"] is None else str(row["outcome_code"]), + ) + + def _initialize_schema(self) -> None: + with self._connection() as connection: + connection.execute("PRAGMA journal_mode = WAL") + connection.executescript( + """ + CREATE TABLE IF NOT EXISTS reconciliation_control_evidence ( + evidence_id TEXT PRIMARY KEY, + fingerprint TEXT NOT NULL, + scope TEXT NOT NULL, + intent_id TEXT NOT NULL, + observation_json TEXT NOT NULL, + record_json TEXT NOT NULL, + source_receipt_digest TEXT NOT NULL, + observed_at REAL NOT NULL, + monitor_event_id TEXT NOT NULL, + monitor_published INTEGER NOT NULL, + created_at REAL NOT NULL, + updated_at REAL NOT NULL + ); + CREATE UNIQUE INDEX IF NOT EXISTS idx_reconciliation_control_evidence_fingerprint + ON reconciliation_control_evidence(fingerprint); + CREATE TABLE IF NOT EXISTS reconciliation_control_commands ( + command_id TEXT PRIMARY KEY, + fingerprint TEXT NOT NULL, + scope TEXT NOT NULL, + intent_id TEXT NOT NULL, + evidence_id TEXT NOT NULL, + evidence_fingerprint TEXT NOT NULL, + issuer_id TEXT NOT NULL, + reason_code TEXT NOT NULL, + issued_at REAL NOT NULL, + expires_at REAL NOT NULL, + status TEXT NOT NULL, + authorization_subject_id TEXT, + authorization_receipt_digest TEXT, + authorization_reason_code TEXT, + authorization_at REAL, + release_applied_at REAL, + released_at REAL, + outcome_code TEXT, + updated_at REAL NOT NULL + ); + CREATE TABLE IF NOT EXISTS reconciliation_control_attempts ( + sequence INTEGER PRIMARY KEY AUTOINCREMENT, + command_id TEXT NOT NULL, + event_type TEXT NOT NULL, + outcome_code TEXT NOT NULL, + occurred_at REAL NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_reconciliation_control_attempts_command + ON reconciliation_control_attempts(command_id, sequence); + """ + ) + + @contextmanager + def _connection(self) -> Iterator[sqlite3.Connection]: + connection = sqlite3.connect( + str(self._database_path), timeout=self._timeout_seconds, isolation_level=None + ) + connection.row_factory = sqlite3.Row + try: + connection.execute("PRAGMA synchronous = FULL") + yield connection + finally: + connection.close() + + @contextmanager + def _transaction(self) -> Iterator[sqlite3.Connection]: + with self._connection() as connection: + connection.execute("BEGIN IMMEDIATE") + try: + yield connection + except BaseException: + connection.execute("ROLLBACK") + raise + else: + connection.execute("COMMIT") + + +class ManagedReconciliationControlPort: + """Runtime-owned reconciliation and reviewed per-intent freeze release. + + A deployment must pass an authorization callback implemented by trusted + operator code. It may verify a signature, identity provider decision, or + offline dual-review receipt, but must return :class:`AuthorizationDecision`. + There is intentionally no allow-by-default mode. + """ + + def __init__( + self, + runtime: Any, + *, + state_directory: Path | str, + authorize: Callable[[ReleaseAuthorizationRequest], AuthorizationDecision], + clock: Callable[[], float] | None = None, + ) -> None: + if not callable(authorize): + raise ValueError("authorize callback is required") + self._runtime = runtime + self._authorize = authorize + self._clock = clock or time.time + self._require_runtime_surface() + state_directory = Path(state_directory).resolve(strict=False) + self._audit = DurableReconciliationControlAudit( + state_directory / "operator_control.sqlite3", clock=self._clock + ) + self._recover_unconfirmed_releases() + + @property + def audit(self) -> DurableReconciliationControlAudit: + """Expose read-only durable audit methods to a code-owned monitor adapter.""" + + return self._audit + + def close(self) -> None: + """Close the local audit resource; the composed runtime remains caller-owned.""" + + self._audit.close() + + def reconcile(self, evidence: ReconciliationEvidence) -> ControlledReconciliationResult: + """Record typed unknown-dispatch evidence and publish its monitor fact. + + The method never calls the risk gate's ``resolve_freeze``. A direct + execution-facade reconcile also remains unable to release a freeze. + """ + + observation = self._validated_observation(evidence) + try: + fingerprint = evidence.fingerprint + except ValueError as error: + raise RuntimePluginError( + "RECONCILIATION_EVIDENCE_INVALID", + "typed reconciliation evidence could not be canonicalized", + ) from error + monitor_event_id = "reconcile:" + fingerprint + existing = self._audit.get_reconciliation(evidence.evidence_id) + record = self._runtime.facade.get(evidence.intent_id) + if record is None: + raise RuntimePluginError( + "RECONCILIATION_INTENT_UNKNOWN", "reconciliation intent is absent from this runtime" + ) + if existing is None: + if record.state.value == "UNKNOWN": + try: + record = self._runtime.facade.reconcile(observation) + except Exception as error: + raise RuntimePluginError( + "RECONCILIATION_PERSISTENCE_FAILED", + "typed reconciliation evidence could not be applied", + ) from error + elif not self._record_matches_observation(record, observation): + raise RuntimePluginError( + "RECONCILIATION_REQUIRES_UNKNOWN", + "control reconciliation requires an unknown record or its exact crash recovery", + ) + if not self._record_matches_observation(record, observation): + raise RuntimePluginError( + "RECONCILIATION_RESULT_MISMATCH", + "reconciled record does not match the typed provider evidence", + ) + try: + existing = self._audit.record_reconciliation( + evidence, + scope=self._runtime.scope.key, + observation=_observation_projection(observation), + record=_record_projection(record), + monitor_event_id=monitor_event_id, + ) + except ControlAuditConflictError as error: + raise RuntimePluginError( + "RECONCILIATION_AUDIT_CONFLICT", + "reconciliation evidence identity conflicts with durable audit", + ) from error + except Exception as error: + raise RuntimePluginError( + "RECONCILIATION_AUDIT_UNCONFIRMED", + "reconciliation evidence could not be durably audited", + ) from error + else: + if existing.fingerprint != fingerprint or existing.scope != self._runtime.scope.key: + raise RuntimePluginError( + "RECONCILIATION_AUDIT_CONFLICT", + "reconciliation evidence differs from the durable audit", + ) + if not self._record_matches_projection(record, existing.record): + raise RuntimePluginError( + "RECONCILIATION_EVIDENCE_STALE", + "current execution record differs from reviewed reconciliation evidence", + ) + audit = self._publish_reconciliation_fact(evidence, record, existing) + return ControlledReconciliationResult(record=record, audit=audit) + + def release_intent_freeze(self, command: ReleaseIntentFreezeCommand) -> FreezeReleaseResult: + """Release exactly one freeze after evidence, audit, monitor, and authorization pass.""" + + if command.scope != self._runtime.scope.key: + raise RuntimePluginError( + "CONTROL_SCOPE_MISMATCH", "control command scope does not match the managed runtime" + ) + try: + command_audit = self._audit.record_command(command) + except ControlAuditConflictError as error: + raise RuntimePluginError( + "CONTROL_COMMAND_CONFLICT", "control command identity conflicts with durable audit" + ) from error + except Exception as error: + raise RuntimePluginError( + "CONTROL_AUDIT_UNCONFIRMED", "control command could not be durably recorded" + ) from error + if command_audit.status is ControlCommandStatus.RELEASED: + self._assert_freeze_inactive(command.intent_id) + return FreezeReleaseResult(command.command_id, command.intent_id, True, True) + if command_audit.status is ControlCommandStatus.DENIED: + raise RuntimePluginError( + "CONTROL_AUTHORIZATION_DENIED", "this control command was durably denied" + ) + if command_audit.status is ControlCommandStatus.EXPIRED: + raise RuntimePluginError("CONTROL_COMMAND_EXPIRED", "this control command is expired") + if command.expires_at <= self._clock(): + try: + self._audit.mark_command_expired(command.command_id) + except Exception as error: + raise RuntimePluginError( + "CONTROL_AUDIT_UNCONFIRMED", "expired command refusal could not be audited" + ) from error + raise RuntimePluginError("CONTROL_COMMAND_EXPIRED", "this control command is expired") + + evidence = self._require_published_evidence(command) + record = self._runtime.facade.get(command.intent_id) + if record is None or not self._record_matches_projection(record, evidence.record): + self._record_pending_failure(command.command_id, "reconciliation_evidence_stale") + raise RuntimePluginError( + "RECONCILIATION_EVIDENCE_STALE", + "current execution record differs from the reconciled evidence", + ) + request = ReleaseAuthorizationRequest( + command=command, + evidence=ReconciliationEvidence( + evidence_id=evidence.evidence_id, + intent_id=evidence.intent_id, + observation=self._observation_from_audit(evidence), + source_receipt_digest=evidence.source_receipt_digest, + observed_at=evidence.observed_at, + ), + reconciled_record=evidence.record, + ) + decision = self._authorize_command(command, request) + if not decision.approved: + raise RuntimePluginError( + "CONTROL_AUTHORIZATION_DENIED", "control command was rejected by the authorizer" + ) + self._publish_authorization_fact(command, decision, evidence) + self._assert_freeze_active(command.intent_id) + try: + applied = self._audit.record_release_applied(command.command_id) + except Exception as error: + raise RuntimePluginError( + "CONTROL_AUDIT_UNCONFIRMED", + "dispatch freeze release preparation could not be durably audited", + ) from error + try: + self._runtime.risk_gate.resolve_freeze( + self._runtime.risk_scope, self._dispatch_freeze_cause(command.intent_id) + ) + except Exception as error: + self._reassert_after_failed_release( + command.command_id, command.intent_id, "freeze_resolution_failed" + ) + raise RuntimePluginError( + "CONTROL_FREEZE_RESOLUTION_FAILED", "dispatch freeze could not be resolved" + ) from error + try: + self._assert_freeze_inactive(command.intent_id) + except RuntimePluginError as error: + self._reassert_after_failed_release( + command.command_id, command.intent_id, "freeze_resolution_unconfirmed" + ) + raise RuntimePluginError( + "CONTROL_FREEZE_RESOLUTION_UNCONFIRMED", + "dispatch freeze release could not be verified", + ) from error + try: + self._append_monitor_event( + event_id="freeze-release:" + command.command_id, + event_type="execution_freeze_released", + data={ + "command_id": command.command_id, + "intent_id": command.intent_id, + "evidence_digest": command.evidence_fingerprint, + "issuer_id": command.issuer_id, + "release_applied_at": applied.release_applied_at, + "state": evidence.record["state"], + }, + occurred_at=applied.release_applied_at, + ) + except Exception as error: + self._reassert_after_failed_release( + command.command_id, command.intent_id, "release_outbox_failed" + ) + raise RuntimePluginError( + "CONTROL_MONITOR_OUTBOX_UNCONFIRMED", + "freeze release monitor fact was not durable; dispatch remains frozen", + ) from error + try: + self._audit.mark_command_released(command.command_id) + except Exception as error: + self._reassert_after_failed_release( + command.command_id, command.intent_id, "release_audit_failed" + ) + raise RuntimePluginError( + "CONTROL_AUDIT_UNCONFIRMED", + "freeze release confirmation could not be durably audited", + ) from error + return FreezeReleaseResult(command.command_id, command.intent_id, True, False) + + def _require_runtime_surface(self) -> None: + required = ( + "facade", + "outbox", + "risk_gate", + "risk_scope", + "scope", + "execution", + "outbox_event_type", + ) + if any(not hasattr(self._runtime, name) for name in required): + raise ValueError("runtime does not expose the managed control surface") + if not isinstance(getattr(self._runtime.scope, "key", None), str): + raise ValueError("runtime has an invalid execution scope") + + def _recover_unconfirmed_releases(self) -> None: + """Reassert every prepared-but-unconfirmed latch after process restart.""" + + try: + commands = self._audit.unconfirmed_release_commands(self._runtime.scope.key) + except Exception as error: + raise RuntimePluginError( + "CONTROL_AUDIT_UNCONFIRMED", + "unconfirmed release audit records could not be loaded", + ) from error + for audit in commands: + command = audit.command + self._reassert_after_failed_release( + command.command_id, command.intent_id, "restart_release_unconfirmed" + ) + + def _validated_observation(self, evidence: ReconciliationEvidence) -> Any: + if not isinstance(evidence, ReconciliationEvidence): + raise RuntimePluginError( + "RECONCILIATION_EVIDENCE_INVALID", "ReconciliationEvidence is required" + ) + observation_type = getattr(self._runtime.execution, "ProviderObservation", None) + if not isinstance(observation_type, type) or not isinstance( + evidence.observation, observation_type + ): + raise RuntimePluginError( + "RECONCILIATION_EVIDENCE_INVALID", + "evidence must contain the runtime execution ProviderObservation type", + ) + observation = evidence.observation + if observation.intent_id != evidence.intent_id: + raise RuntimePluginError( + "RECONCILIATION_IDENTITY_MISMATCH", "provider observation belongs to another intent" + ) + if observation.state.value == "REJECTED": + if observation.reason_code is None: + raise RuntimePluginError( + "RECONCILIATION_EVIDENCE_INCOMPLETE", + "rejected reconciliation requires a provider reason code", + ) + elif observation.provider_order_id is None: + raise RuntimePluginError( + "RECONCILIATION_EVIDENCE_INCOMPLETE", + "non-rejected reconciliation requires a provider order identity", + ) + return observation + + def _publish_reconciliation_fact( + self, + evidence: ReconciliationEvidence, + record: Any, + audit: ReconciliationAudit, + ) -> ReconciliationAudit: + if audit.monitor_published: + return audit + try: + self._append_monitor_event( + event_id=audit.monitor_event_id, + event_type="execution_reconciled", + data={ + "evidence_digest": audit.fingerprint, + "intent_id": evidence.intent_id, + "provider_order_id": _record_projection(record)["provider_order_id"], + "review_required": _record_projection(record)["review_required"], + "state": _record_projection(record)["state"], + }, + occurred_at=evidence.observed_at, + ) + return self._audit.mark_reconciliation_monitor_published( + evidence.evidence_id, audit.monitor_event_id + ) + except Exception as error: + raise RuntimePluginError( + "RECONCILIATION_MONITOR_OUTBOX_UNCONFIRMED", + "reconciliation monitor fact was not durably published", + ) from error + + def _require_published_evidence( + self, command: ReleaseIntentFreezeCommand + ) -> ReconciliationAudit: + evidence = self._audit.get_reconciliation(command.evidence_id) + if ( + evidence is None + or evidence.scope != self._runtime.scope.key + or evidence.intent_id != command.intent_id + or evidence.fingerprint != command.evidence_fingerprint + ): + self._record_pending_failure(command.command_id, "reconciliation_evidence_missing") + raise RuntimePluginError( + "RECONCILIATION_EVIDENCE_REQUIRED", + "control release requires matching durable reconciliation evidence", + ) + if not evidence.monitor_published: + self._record_pending_failure(command.command_id, "reconciliation_outbox_unconfirmed") + raise RuntimePluginError( + "RECONCILIATION_MONITOR_OUTBOX_UNCONFIRMED", + "control release requires durable reconciliation monitor delivery", + ) + return evidence + + def _authorize_command( + self, + command: ReleaseIntentFreezeCommand, + request: ReleaseAuthorizationRequest, + ) -> AuthorizationDecision: + try: + decision = self._authorize(request) + except Exception as error: + self._record_pending_failure(command.command_id, "authorization_unavailable") + raise RuntimePluginError( + "CONTROL_AUTHORIZATION_UNAVAILABLE", "control authorization could not be verified" + ) from error + if not isinstance(decision, AuthorizationDecision): + self._record_pending_failure(command.command_id, "authorization_invalid") + raise RuntimePluginError( + "CONTROL_AUTHORIZATION_INVALID", + "authorizer must return AuthorizationDecision", + ) + if decision.subject_id != command.issuer_id: + self._record_pending_failure(command.command_id, "authorization_identity_mismatch") + raise RuntimePluginError( + "CONTROL_AUTHORIZATION_IDENTITY_MISMATCH", + "authorization identity does not match command issuer", + ) + try: + self._audit.record_authorization(command.command_id, decision) + except Exception as error: + raise RuntimePluginError( + "CONTROL_AUDIT_UNCONFIRMED", "authorization decision could not be durably audited" + ) from error + return decision + + def _publish_authorization_fact( + self, + command: ReleaseIntentFreezeCommand, + decision: AuthorizationDecision, + evidence: ReconciliationAudit, + ) -> None: + try: + self._append_monitor_event( + event_id="freeze-release-authorized:" + command.command_id, + event_type="execution_freeze_release_authorized", + data={ + "authorization_receipt_digest": decision.receipt_digest, + "command_id": command.command_id, + "evidence_digest": evidence.fingerprint, + "intent_id": command.intent_id, + "issuer_id": command.issuer_id, + "state": evidence.record["state"], + }, + occurred_at=command.issued_at, + ) + except Exception as error: + self._record_pending_failure(command.command_id, "authorization_outbox_failed") + raise RuntimePluginError( + "CONTROL_MONITOR_OUTBOX_UNCONFIRMED", + "authorization monitor fact was not durable; dispatch remains frozen", + ) from error + + def _append_monitor_event( + self, + *, + event_id: str, + event_type: str, + data: Mapping[str, object], + occurred_at: float | None, + ) -> None: + if occurred_at is None: + raise ValueError("monitor occurred_at is required") + self._runtime.outbox.append( + self._runtime.outbox_event_type( + event_id=event_id, + scope=self._runtime.scope.key, + event_type=event_type, + data=dict(data), + occurred_at=occurred_at, + ) + ) + + def _assert_freeze_active(self, intent_id: str) -> None: + cause_id = self._dispatch_freeze_cause(intent_id) + try: + active_reasons = self._runtime.risk_gate.active_freeze_reasons(self._runtime.risk_scope) + except Exception as error: + raise RuntimePluginError( + "CONTROL_FREEZE_STATE_UNAVAILABLE", "dispatch freeze state could not be verified" + ) from error + if cause_id not in active_reasons: + raise RuntimePluginError( + "CONTROL_FREEZE_NOT_ACTIVE", "dispatch freeze is not active for this intent" + ) + + def _assert_freeze_inactive(self, intent_id: str) -> None: + cause_id = self._dispatch_freeze_cause(intent_id) + try: + active_reasons = self._runtime.risk_gate.active_freeze_reasons(self._runtime.risk_scope) + except Exception as error: + raise RuntimePluginError( + "CONTROL_FREEZE_STATE_UNAVAILABLE", "dispatch freeze state could not be verified" + ) from error + if cause_id in active_reasons: + raise RuntimePluginError( + "CONTROL_RELEASE_STATE_INCONSISTENT", + "audit says released while the intent dispatch freeze is active", + ) + + def _reassert_after_failed_release( + self, + command_id: str, + intent_id: str, + outcome_code: str, + ) -> None: + cause_id = self._dispatch_freeze_cause(intent_id) + try: + self._runtime.risk_gate.freeze(self._runtime.risk_scope, cause_id, cause_id) + self._assert_freeze_active(intent_id) + except Exception as error: + raise RuntimePluginError( + "CONTROL_FREEZE_REASSERT_FAILED", + "release outcome is uncertain and the dispatch freeze could not be restored", + ) from error + try: + self._audit.mark_command_reasserted(command_id, outcome_code) + except Exception as error: + raise RuntimePluginError( + "CONTROL_AUDIT_UNCONFIRMED", + "reasserted dispatch freeze could not be durably audited", + ) from error + + def _record_pending_failure(self, command_id: str, outcome_code: str) -> None: + try: + self._audit.record_pending_failure(command_id, outcome_code) + except Exception as error: + raise RuntimePluginError( + "CONTROL_AUDIT_UNCONFIRMED", "control refusal could not be durably audited" + ) from error + + def _dispatch_freeze_cause(self, intent_id: str) -> str: + return "dispatch-inflight:" + _identifier(intent_id, "intent_id") + + @staticmethod + def _record_matches_observation(record: Any, observation: Any) -> bool: + try: + projection = _record_projection(record) + observed = _observation_projection(observation) + except ValueError: + return False + if projection["state"] != observed["state"]: + return False + if projection["filled_quantity"] != observed["filled_quantity"]: + return False + if projection["average_price"] != observed["average_price"]: + return False + provider_order_id = observed["provider_order_id"] + return provider_order_id is None or projection["provider_order_id"] == provider_order_id + + @staticmethod + def _record_matches_projection(record: Any, expected: Mapping[str, object]) -> bool: + try: + return _record_projection(record) == dict(expected) + except ValueError: + return False + + def _observation_from_audit(self, evidence: ReconciliationAudit) -> Any: + """Rebuild the package-owned typed observation for the authorizer's context only.""" + + observation = evidence.observation + provider_observation = self._runtime.execution.ProviderObservation + return provider_observation( + intent_id=observation["intent_id"], + state=observation["state"], + provider_order_id=observation["provider_order_id"], + filled_quantity=Decimal(observation["filled_quantity"]), + average_price=( + None + if observation["average_price"] is None + else Decimal(observation["average_price"]) + ), + reason_code=observation["reason_code"], + ) + + +__all__ = [ + "AuthorizationDecision", + "ControlAuditConflictError", + "ControlAuditError", + "ControlCommandAudit", + "ControlCommandStatus", + "ControlledReconciliationResult", + "DurableReconciliationControlAudit", + "FreezeReleaseResult", + "ManagedReconciliationControlPort", + "ReconciliationAudit", + "ReconciliationEvidence", + "ReleaseAuthorizationRequest", + "ReleaseIntentFreezeCommand", +] diff --git a/conftest.py b/conftest.py index 5effa708..d38bfc55 100644 --- a/conftest.py +++ b/conftest.py @@ -8,6 +8,7 @@ keeping a thin root bootstrap is the conventional placement. """ +import inspect import os import sys import warnings @@ -142,6 +143,14 @@ def pytest_configure(config): def pytest_collection_modifyitems(config, items): """Modify test collection to add markers automatically and skip tests without API keys.""" + if any(inspect.iscoroutinefunction(item.obj) for item in items) and not config.pluginmanager.hasplugin( + "asyncio" + ): + raise pytest.UsageError( + "async tests were collected without pytest-asyncio; install the dev dependencies " + "and do not pass -p no:asyncio" + ) + skip_live = should_skip_live_tests() for item in items: diff --git a/pyproject.toml b/pyproject.toml index adcdee41..0ecaff5a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -80,7 +80,6 @@ markers = [ "auth_position: Position management tests (auth required)", "auth_private_trade: Private trade history tests (auth required)", ] -asyncio_default_fixture_loop_scope = "function" testpaths = ["tests"] [tool.coverage.run] @@ -244,7 +243,7 @@ dev = [ "types-pytz>=2023.3", "types-requests>=2.31.0", "pytest>=7.0", - "pytest-asyncio>=0.21.0", + "pytest-asyncio>=0.23.8,<0.24", "pytest-sugar>=1.0.0", "pytest-cov>=4.0.0", "pytest-timeout>=2.1.0", @@ -303,5 +302,5 @@ all = [ ] core-reference = [ "bt_api_binance>=2.0.0", - "bt_api_ctp>=2.0.2,<3.0", + "bt_api_ctp>=2.0.3,<3.0", ] diff --git a/scripts/ci/submodule_validation.py b/scripts/ci/submodule_validation.py index 261e9775..6a4ae550 100644 --- a/scripts/ci/submodule_validation.py +++ b/scripts/ci/submodule_validation.py @@ -400,7 +400,7 @@ def validate_package( *pip_source_args(wheelhouse), str(base_wheel), "pytest>=7.0", - "pytest-asyncio>=0.21.0", + "pytest-asyncio>=0.23.8,<0.24", "pytest-socket>=0.7.0", ], cwd=repository_root, diff --git a/scripts/ci/verify_iteration41_capability_bundle.py b/scripts/ci/verify_iteration41_capability_bundle.py new file mode 100644 index 00000000..d776c5b3 --- /dev/null +++ b/scripts/ci/verify_iteration41_capability_bundle.py @@ -0,0 +1,2691 @@ +#!/usr/bin/env python3 +"""Verify local Iteration 41 wheel-install and isolated-consumer mechanics. + +This verifier is deliberately narrower than a release process. It creates a +fresh local wheelhouse from the checked-out sources and the controller's +already-installed distributions, installs only from that wheelhouse into a +new virtual environment, and runs a Python-socket-guarded consumer probe. Its receipt +records dirty, untracked, and missing-superproject-pin states verbatim. + +The receipt can prove only that a particular local source snapshot built and +that selected local wheel payloads were installed and exercised without an +editable checkout. It cannot prove a reviewed commit, artifact provenance, +publication, signature, provider connectivity, account authority, or live +trading admission. Consequently a successful run is always labelled +``LOCAL_EVIDENCE_ONLY`` rather than a release or production PASS. +""" + +from __future__ import annotations + +import argparse +import base64 +import binascii +import csv +import hashlib +import io +import json +import os +import re +import shutil +import stat +import subprocess +import sys +import sysconfig +import tempfile +import tomllib +import zipfile +from collections.abc import Iterable, Mapping +from dataclasses import dataclass +from datetime import UTC, datetime +from email.parser import BytesParser +from importlib import metadata +from pathlib import Path +from typing import Any + +from packaging.markers import default_environment +from packaging.requirements import Requirement +from packaging.tags import sys_tags +from packaging.utils import canonicalize_name +from packaging.version import Version +from wheel.wheelfile import WheelFile + +SDK_ROOT = Path(__file__).resolve().parents[2] +SOURCE_DATE_EPOCH = "315532800" # 1980-01-01: valid for ZIP timestamps. +LOCAL_ONLY_RESULT = "LOCAL_EVIDENCE_ONLY" +FAILED_RESULT = "FAILED" +_IGNORED_DIRECTORY_NAMES = frozenset( + { + ".benchmarks", + ".git", + ".mypy_cache", + ".pytest_cache", + ".ruff_cache", + "__pycache__", + "build", + "dist", + } +) +_IGNORED_FILE_SUFFIXES = (".pyc", ".pyo") +_SECRET_ASSIGNMENT_PATTERN = re.compile( + r"(?im)(\b(?:api[_-]?key|authorization|credential|pass(?:word|phrase)?|secret|token)" + r"\b\s*[:=]\s*)([^\s,;]+)" +) +_AUTHORIZATION_HEADER_PATTERN = re.compile(r"(?im)(\bauthorization\s*:\s*)([^\r\n]+)") +_URL_USERINFO_PATTERN = re.compile(r"(?i)(https?://)([^\s/@:]+):([^\s/@]+)@") + + +class BundleVerificationError(RuntimeError): + """A local wheel, dependency, or isolated-consumer contract failed.""" + + +@dataclass(frozen=True) +class ProjectSpec: + """One source tree included in the local-only consumer matrix.""" + + key: str + distribution: str + module: str + source_root: Path + includes: tuple[str, ...] + superproject_relative_path: str | None = None + + +@dataclass(frozen=True) +class SnapshotFile: + """One byte-for-byte file recorded in a frozen source snapshot.""" + + relative_path: str + sha256: str + size: int + + +@dataclass(frozen=True) +class SourceSnapshot: + """A frozen, symlink-free source view used for a wheel build.""" + + project: ProjectSpec + staged_root: Path + files: tuple[SnapshotFile, ...] + digest: str + + +def _sha256_bytes(value: bytes) -> str: + return hashlib.sha256(value).hexdigest() + + +def _sha256_file(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as handle: + for block in iter(lambda: handle.read(1024 * 1024), b""): + digest.update(block) + return digest.hexdigest() + + +def _utc_now() -> str: + return datetime.now(UTC).isoformat().replace("+00:00", "Z") + + +def _is_ignored_directory(path: Path) -> bool: + return path.name in _IGNORED_DIRECTORY_NAMES or path.name.endswith(".egg-info") + + +def _is_ignored_file(path: Path) -> bool: + return path.name.endswith(_IGNORED_FILE_SUFFIXES) + + +def _selected_files(project: ProjectSpec) -> tuple[Path, ...]: + """Return exactly the source files that may enter a staged wheel build. + + Symlinks are rejected rather than followed. A source tree could otherwise + make a replay receipt look local while packaging bytes outside the reviewed + directory. + """ + + source_root = project.source_root.resolve(strict=True) + selected: list[Path] = [] + for include in project.includes: + candidate = source_root / include + if not candidate.exists(): + raise BundleVerificationError( + f"{project.key} required source path is missing: {candidate}" + ) + if candidate.is_symlink(): + raise BundleVerificationError( + f"{project.key} required source path is a symlink: {candidate}" + ) + if candidate.is_file(): + if not _is_ignored_file(candidate): + selected.append(candidate) + continue + for directory, directory_names, file_names in os.walk( + candidate, followlinks=False + ): + current = Path(directory) + safe_names: list[str] = [] + for directory_name in sorted(directory_names): + child = current / directory_name + if child.is_symlink(): + raise BundleVerificationError( + f"{project.key} source tree contains a symlink: {child}" + ) + if not _is_ignored_directory(child): + safe_names.append(directory_name) + directory_names[:] = safe_names + for file_name in sorted(file_names): + child = current / file_name + if child.is_symlink(): + raise BundleVerificationError( + f"{project.key} source tree contains a symlink: {child}" + ) + if child.is_file() and not _is_ignored_file(child): + selected.append(child) + unique = tuple(sorted(set(selected), key=lambda item: item.as_posix())) + if not unique: + raise BundleVerificationError(f"{project.key} source selection is empty") + return unique + + +def _snapshot_digest(files: Iterable[tuple[str, bytes]]) -> str: + digest = hashlib.sha256() + for relative, payload in files: + digest.update(relative.encode("utf-8")) + digest.update(b"\0") + digest.update(payload) + digest.update(b"\0") + return digest.hexdigest() + + +def _read_regular_file(path: Path, *, project: ProjectSpec, role: str) -> bytes: + """Read one regular file without silently following a late symlink swap.""" + + try: + before = path.lstat() + except OSError as error: + raise BundleVerificationError( + f"{project.key} cannot stat {role}: {path}" + ) from error + if stat.S_ISLNK(before.st_mode) or not stat.S_ISREG(before.st_mode): + raise BundleVerificationError( + f"{project.key} {role} is not a regular non-symlink file: {path}" + ) + flags = os.O_RDONLY | getattr(os, "O_BINARY", 0) | getattr(os, "O_NOFOLLOW", 0) + try: + descriptor = os.open(path, flags) + except OSError as error: + raise BundleVerificationError( + f"{project.key} cannot open {role}: {path}" + ) from error + try: + with os.fdopen(descriptor, "rb") as handle: + opened = os.fstat(handle.fileno()) + if not stat.S_ISREG(opened.st_mode): + raise BundleVerificationError( + f"{project.key} {role} changed to a non-regular file: {path}" + ) + payload = handle.read() + except OSError as error: + raise BundleVerificationError( + f"{project.key} cannot read {role}: {path}" + ) from error + try: + after = path.lstat() + except OSError as error: + raise BundleVerificationError( + f"{project.key} {role} disappeared while reading: {path}" + ) from error + if stat.S_ISLNK(after.st_mode) or not stat.S_ISREG(after.st_mode): + raise BundleVerificationError( + f"{project.key} {role} changed while reading: {path}" + ) + if after.st_size != len(payload): + raise BundleVerificationError( + f"{project.key} {role} changed size while reading: {path}" + ) + return payload + + +def _snapshot_relative_path( + path: Path, source_root: Path, *, project: ProjectSpec +) -> str: + try: + relative = path.relative_to(source_root) + except ValueError as error: + raise BundleVerificationError( + f"{project.key} source file escaped source root: {path}" + ) from error + try: + return _safe_relative_path( + relative.as_posix(), context=f"{project.key} snapshot source path" + ).as_posix() + except BundleVerificationError as error: + raise BundleVerificationError( + f"{project.key} unsafe snapshot path: {relative}" + ) from error + + +def _validate_staged_snapshot(snapshot: SourceSnapshot) -> None: + """Verify that the frozen stage still exactly matches its byte manifest.""" + + expected = {item.relative_path: item for item in snapshot.files} + actual: dict[str, Path] = {} + for directory, directory_names, file_names in os.walk( + snapshot.staged_root, followlinks=False + ): + current = Path(directory) + for directory_name in directory_names: + child = current / directory_name + if child.is_symlink(): + raise BundleVerificationError( + f"{snapshot.project.key} frozen snapshot contains a symlink: {child}" + ) + for file_name in file_names: + child = current / file_name + if child.is_symlink(): + raise BundleVerificationError( + f"{snapshot.project.key} frozen snapshot contains a symlink: {child}" + ) + relative = _snapshot_relative_path( + child, snapshot.staged_root, project=snapshot.project + ) + actual[relative] = child + if set(actual) != set(expected): + raise BundleVerificationError( + f"{snapshot.project.key} frozen snapshot file set does not match its manifest" + ) + material = [] + for relative, manifest in sorted(expected.items()): + payload = _read_regular_file( + actual[relative], project=snapshot.project, role="frozen snapshot file" + ) + if len(payload) != manifest.size or _sha256_bytes(payload) != manifest.sha256: + raise BundleVerificationError( + f"{snapshot.project.key} frozen snapshot content changed: {relative}" + ) + material.append((relative, payload)) + if _snapshot_digest(material) != snapshot.digest: + raise BundleVerificationError( + f"{snapshot.project.key} frozen snapshot digest changed" + ) + + +def _recheck_source_snapshot(snapshot: SourceSnapshot) -> None: + """Fail if the original source changed after its bytes were frozen.""" + + source_root = snapshot.project.source_root.resolve(strict=True) + expected = {item.relative_path: item for item in snapshot.files} + current = { + _snapshot_relative_path(path, source_root, project=snapshot.project): path + for path in _selected_files(snapshot.project) + } + if set(current) != set(expected): + raise BundleVerificationError( + f"{snapshot.project.key} source file set changed after frozen snapshot capture" + ) + for relative, manifest in sorted(expected.items()): + payload = _read_regular_file( + current[relative], project=snapshot.project, role="source recheck file" + ) + if len(payload) != manifest.size or _sha256_bytes(payload) != manifest.sha256: + raise BundleVerificationError( + f"{snapshot.project.key} source content changed after frozen snapshot capture: {relative}" + ) + + +def _capture_source_snapshot(project: ProjectSpec, destination: Path) -> SourceSnapshot: + """Materialize source bytes once, then build only from that frozen copy. + + The original source tree is never read by later wheel-build stages. A + recheck catches a mutation that happened while the snapshot was captured. + """ + + source_root = project.source_root.resolve(strict=True) + destination.mkdir(parents=True, exist_ok=False) + manifest: list[SnapshotFile] = [] + material: list[tuple[str, bytes]] = [] + for source in _selected_files(project): + relative = _snapshot_relative_path(source, source_root, project=project) + payload = _read_regular_file(source, project=project, role="source file") + target = destination / relative + target.parent.mkdir(parents=True, exist_ok=True) + with target.open("xb") as handle: + handle.write(payload) + manifest.append(SnapshotFile(relative, _sha256_bytes(payload), len(payload))) + material.append((relative, payload)) + snapshot = SourceSnapshot( + project=project, + staged_root=destination, + files=tuple(sorted(manifest, key=lambda item: item.relative_path)), + digest=_snapshot_digest(sorted(material)), + ) + _validate_staged_snapshot(snapshot) + _recheck_source_snapshot(snapshot) + return snapshot + + +def _stage_snapshot(snapshot: SourceSnapshot, destination: Path) -> None: + """Copy a verified frozen snapshot for one independent wheel build.""" + + _validate_staged_snapshot(snapshot) + destination.mkdir(parents=True, exist_ok=False) + for manifest in snapshot.files: + source = snapshot.staged_root / manifest.relative_path + payload = _read_regular_file( + source, project=snapshot.project, role="frozen snapshot file" + ) + if len(payload) != manifest.size or _sha256_bytes(payload) != manifest.sha256: + raise BundleVerificationError( + f"{snapshot.project.key} frozen snapshot changed before build: {manifest.relative_path}" + ) + target = destination / manifest.relative_path + target.parent.mkdir(parents=True, exist_ok=True) + with target.open("xb") as handle: + handle.write(payload) + + +def _run_git( + path: Path, arguments: list[str] +) -> subprocess.CompletedProcess[str] | None: + executable = shutil.which("git") + if executable is None: + return None + try: + return subprocess.run( # noqa: S603 - fixed Git subcommands inspect local repository state. + [executable, "-C", str(path), *arguments], + capture_output=True, + check=False, + text=True, + encoding="utf-8", + errors="replace", + env=_offline_environment(), + ) + except OSError: + return None + + +def _git_source_state(project: ProjectSpec, sdk_root: Path) -> dict[str, Any]: + """Describe local Git facts without treating them as release provenance.""" + + root = project.source_root.resolve(strict=True) + top_level = _run_git(root, ["rev-parse", "--show-toplevel"]) + if top_level is None or top_level.returncode != 0: + return { + "source_root": str(root), + "git_available": False, + "source_state": "UNTRACKED_OR_NON_GIT_SOURCE", + "superproject_gitlink": None, + "checkout_matches_superproject_gitlink": False, + } + + head = _run_git(root, ["rev-parse", "HEAD"]) + status = _run_git(root, ["status", "--porcelain=v1", "--untracked-files=all"]) + if head is None or head.returncode != 0 or status is None or status.returncode != 0: + raise BundleVerificationError(f"could not inspect Git state for {project.key}") + status_lines = tuple(line for line in status.stdout.splitlines() if line) + state: dict[str, Any] = { + "source_root": str(root), + "git_available": True, + "checkout_head": head.stdout.strip(), + "dirty": bool(status_lines), + "status_entry_count": len(status_lines), + # File names may be locally sensitive. Preserve an auditable change + # signal without publishing their names into a receipt. + "status_sha256": _sha256_bytes(status.stdout.encode("utf-8")), + "superproject_gitlink": None, + "checkout_matches_superproject_gitlink": False, + } + if project.superproject_relative_path is None: + state["source_state"] = ( + "DIRTY_LOCAL_SOURCE" if status_lines else "LOCAL_GIT_SOURCE" + ) + return state + + gitlink = _run_git( + sdk_root, + ["ls-tree", "HEAD", "--", project.superproject_relative_path], + ) + if gitlink is None or gitlink.returncode != 0: + raise BundleVerificationError( + f"could not inspect SDK gitlink for {project.key}" + ) + fields = gitlink.stdout.strip().split() + if len(fields) >= 3 and fields[0] == "160000" and fields[1] == "commit": + state["superproject_gitlink"] = fields[2] + state["checkout_matches_superproject_gitlink"] = ( + fields[2] == state["checkout_head"] + ) + if status_lines: + state["source_state"] = "DIRTY_LOCAL_SOURCE" + elif state["superproject_gitlink"] is None: + state["source_state"] = "UNPINNED_LOCAL_SOURCE" + elif not state["checkout_matches_superproject_gitlink"]: + state["source_state"] = "CHECKOUT_DIFFERS_FROM_SUPERPROJECT_GITLINK" + else: + state["source_state"] = "CLEAN_GITLINK_SOURCE" + return state + + +_MINIMAL_ENVIRONMENT_KEYS = ( + "ALLUSERSPROFILE", + "APPDATA", + "LOCALAPPDATA", + "USERPROFILE", + "HOMEDRIVE", + "HOMEPATH", + "SYSTEMDRIVE", + "PATH", + "SYSTEMROOT", + "WINDIR", + "COMSPEC", + "PATHEXT", + "TEMP", + "TMP", + "TMPDIR", + "LANG", + "LC_ALL", + "LC_CTYPE", + "TZ", +) +_REDACTION_SENTINEL_ENV = "ITERATION41_CONTROLLER_SECRET_SENTINEL" + + +def _environment_value_case_insensitive(name: str) -> str | None: + for key, value in os.environ.items(): + if key.casefold() == name.casefold(): + return value + return None + + +def _offline_environment(extra: Mapping[str, str] | None = None) -> dict[str, str]: + """Return a minimal child environment with no inherited Python/pip state. + + It is an allow-list, rather than a scrubbed copy of the controller + environment. In particular credentials and arbitrary startup variables + cannot reach build, pip, or consumer processes by inheritance. + """ + + environment = { + key: value + for key in _MINIMAL_ENVIRONMENT_KEYS + if (value := _environment_value_case_insensitive(key)) is not None + } + environment.update( + { + "PIP_CONFIG_FILE": os.devnull, + "PIP_NO_INDEX": "1", + "PIP_NO_CACHE_DIR": "1", + "PIP_DISABLE_PIP_VERSION_CHECK": "1", + "PYTHONHASHSEED": "0", + "PYTHONNOUSERSITE": "1", + "PYTHONSAFEPATH": "1", + "PYTHONUTF8": "1", + "PYTHONIOENCODING": "utf-8:replace", + "SOURCE_DATE_EPOCH": SOURCE_DATE_EPOCH, + } + ) + if extra: + environment.update(extra) + return environment + + +def _controller_requirement_receipt( + project: ProjectSpec, *, source_root: Path | None = None +) -> list[dict[str, str]]: + pyproject = (source_root or project.source_root) / "pyproject.toml" + if pyproject.is_file(): + with pyproject.open("rb") as handle: + payload = tomllib.load(handle) + requirements = list((payload.get("build-system") or {}).get("requires") or []) + else: + requirements = ["setuptools"] + observed: list[dict[str, str]] = [] + for raw in requirements: + requirement = Requirement(str(raw)) + try: + version = metadata.version(requirement.name) + except metadata.PackageNotFoundError as error: + raise BundleVerificationError( + f"controller is missing build requirement for {project.key}: {requirement}" + ) from error + if requirement.specifier and Version(version) not in requirement.specifier: + raise BundleVerificationError( + f"controller build requirement does not satisfy {project.key}: " + f"{requirement} (observed {version})" + ) + observed.append({"requirement": str(requirement), "observed_version": version}) + return observed + + +def _redact_text(value: str) -> str: + """Remove likely secret values before persisting or printing diagnostics.""" + + redacted = value + # The verifier deliberately does not inherit arbitrary controller values, + # but a build can still echo a controller-only sentinel or a source-encoded + # credential. Do not let either reach artifacts or stderr. + for key, candidate in os.environ.items(): + normalized = key.casefold() + if candidate and ( + key == _REDACTION_SENTINEL_ENV + or any( + marker in normalized + for marker in ( + "api_key", + "apikey", + "credential", + "pass", + "secret", + "token", + ) + ) + ): + redacted = redacted.replace(candidate, "***REDACTED***") + redacted = _URL_USERINFO_PATTERN.sub(r"\1***REDACTED***:***REDACTED***@", redacted) + redacted = _AUTHORIZATION_HEADER_PATTERN.sub(r"\1***REDACTED***", redacted) + return _SECRET_ASSIGNMENT_PATTERN.sub(r"\1***REDACTED***", redacted) + + +def _run_logged( + command: list[str], + *, + cwd: Path, + environment: Mapping[str, str], + logs_dir: Path, + name: str, +) -> dict[str, Any]: + completed = subprocess.run( # noqa: S603 - all arguments are locally generated. + command, + cwd=cwd, + env=dict(environment), + capture_output=True, + check=False, + text=True, + encoding="utf-8", + errors="replace", + ) + stdout = _redact_text(completed.stdout) + stderr = _redact_text(completed.stderr) + logs_dir.mkdir(parents=True, exist_ok=True) + stdout_path = logs_dir / f"{name}.stdout.log" + stderr_path = logs_dir / f"{name}.stderr.log" + stdout_path.write_text(stdout, encoding="utf-8") + stderr_path.write_text(stderr, encoding="utf-8") + return { + "command": command, + "exit_code": completed.returncode, + "stdout_log": str(stdout_path.relative_to(logs_dir.parent)), + "stderr_log": str(stderr_path.relative_to(logs_dir.parent)), + "stdout_sha256": _sha256_bytes(stdout.encode("utf-8")), + "stderr_sha256": _sha256_bytes(stderr.encode("utf-8")), + "stdout": stdout, + "stderr": stderr, + } + + +def _wheel_metadata(wheel: Path) -> tuple[str, str, list[str], str]: + with zipfile.ZipFile(wheel) as archive: + metadata_members = [ + name + for name in archive.namelist() + if name.endswith(".dist-info/METADATA") and not name.endswith("/") + ] + if len(metadata_members) != 1: + raise BundleVerificationError( + f"{wheel.name} must contain exactly one dist-info/METADATA" + ) + raw = archive.read(metadata_members[0]) + payload = BytesParser().parsebytes(raw) + name = payload.get("Name") + version = payload.get("Version") + if not name or not version: + raise BundleVerificationError( + f"{wheel.name} METADATA is missing Name or Version" + ) + return ( + name, + version, + list(payload.get_all("Requires-Dist") or []), + _sha256_bytes(raw), + ) + + +def _record_contract(wheel: Path, expected_distribution: str) -> dict[str, Any]: + """Validate METADATA and every hashed RECORD member in a built wheel.""" + + distribution, version, requirements, metadata_digest = _wheel_metadata(wheel) + if canonicalize_name(distribution) != canonicalize_name(expected_distribution): + raise BundleVerificationError( + f"{wheel.name} declares {distribution}, expected {expected_distribution}" + ) + with zipfile.ZipFile(wheel) as archive: + members = [item for item in archive.infolist() if not item.is_dir()] + names = {item.filename for item in members} + for name in names: + if _is_python_startup_hook(name): + raise BundleVerificationError( + f"{wheel.name} contains a Python startup hook: {name}" + ) + try: + _safe_relative_path(name, context=f"{wheel.name} archive member") + except BundleVerificationError as error: + raise BundleVerificationError( + f"{wheel.name} contains an unsafe archive member" + ) from error + if any( + name.endswith((".pyc", ".pyo")) or "__pycache__/" in name for name in names + ): + raise BundleVerificationError(f"{wheel.name} contains bytecode") + record_members = [name for name in names if name.endswith(".dist-info/RECORD")] + if len(record_members) != 1: + raise BundleVerificationError( + f"{wheel.name} must contain exactly one RECORD" + ) + record_name = record_members[0] + try: + rows = list( + csv.reader( + io.TextIOWrapper(archive.open(record_name), encoding="utf-8") + ) + ) + except (UnicodeDecodeError, csv.Error) as error: + raise BundleVerificationError( + f"{wheel.name} has an unreadable RECORD" + ) from error + row_by_name = {row[0]: row for row in rows if row} + if len(row_by_name) != len(rows) or set(row_by_name) != names: + raise BundleVerificationError( + f"{wheel.name} RECORD does not cover every archive member" + ) + for member in members: + row = row_by_name[member.filename] + if len(row) != 3: + raise BundleVerificationError( + f"{wheel.name} has a malformed RECORD row" + ) + if member.filename == record_name: + if row[1] or row[2]: + raise BundleVerificationError(f"{wheel.name} hashes its own RECORD") + continue + if not row[1].startswith("sha256="): + raise BundleVerificationError( + f"{wheel.name} RECORD uses a non-SHA256 hash" + ) + encoded_digest = row[1].split("=", 1)[1] + padding = "=" * (-len(encoded_digest) % 4) + try: + expected_digest = base64.urlsafe_b64decode(encoded_digest + padding) + except (binascii.Error, ValueError, TypeError) as error: + raise BundleVerificationError( + f"{wheel.name} has an invalid RECORD digest" + ) from error + actual = hashlib.sha256(archive.read(member.filename)).digest() + if actual != expected_digest or row[2] != str(member.file_size): + raise BundleVerificationError( + f"{wheel.name} RECORD integrity check failed for {member.filename}" + ) + return { + "distribution": distribution, + "version": version, + "metadata_sha256": metadata_digest, + "requirements": requirements, + "record_validated": True, + "wheel_sha256": _sha256_file(wheel), + } + + +def _find_project_wheel(directory: Path, distribution: str) -> Path: + matches = [ + candidate + for candidate in directory.glob("*.whl") + if canonicalize_name(_wheel_metadata(candidate)[0]) + == canonicalize_name(distribution) + ] + if len(matches) != 1: + raise BundleVerificationError( + f"expected exactly one {distribution} wheel in {directory}, found {len(matches)}" + ) + return matches[0] + + +def _build_reproducible_wheel( + snapshot: SourceSnapshot, + *, + controller_python: str, + work_dir: Path, + wheelhouse: Path, + logs_dir: Path, +) -> tuple[Path, dict[str, Any]]: + """Build the same clean snapshot twice and require byte-identical wheels.""" + + _recheck_source_snapshot(snapshot) + build_receipts: list[dict[str, Any]] = [] + built_wheels: list[Path] = [] + for index in (1, 2): + stage = work_dir / f"{snapshot.project.key}-source-{index}" + output = work_dir / f"{snapshot.project.key}-wheel-{index}" + _stage_snapshot(snapshot, stage) + output.mkdir(parents=True, exist_ok=False) + command = [ + controller_python, + "-m", + "pip", + "wheel", + "--no-deps", + "--no-build-isolation", + "--no-index", + "--no-cache-dir", + "--wheel-dir", + str(output), + ".", + ] + run = _run_logged( + command, + cwd=stage, + environment=_offline_environment(), + logs_dir=logs_dir, + name=f"build-{snapshot.project.key}-{index}", + ) + build_receipts.append( + { + key: value + for key, value in run.items() + if key not in {"stdout", "stderr"} + } + ) + if run["exit_code"] != 0: + detail = run["stderr"].strip() or run["stdout"].strip() + raise BundleVerificationError( + f"local wheel build failed for {snapshot.project.key}: {detail}" + ) + built_wheels.append(_find_project_wheel(output, snapshot.project.distribution)) + first_digest, second_digest = (_sha256_file(path) for path in built_wheels) + if first_digest != second_digest: + raise BundleVerificationError( + f"non-reproducible local wheel for {snapshot.project.key}: " + f"{first_digest} != {second_digest}" + ) + _recheck_source_snapshot(snapshot) + wheelhouse.mkdir(parents=True, exist_ok=True) + destination = wheelhouse / built_wheels[0].name + shutil.copy2(built_wheels[0], destination) + evidence = _record_contract(destination, snapshot.project.distribution) + evidence.update( + { + "filename": destination.name, + "source_tree_sha256": snapshot.digest, + "source_file_count": len(snapshot.files), + "source_snapshot": { + "captured_from_bytes": True, + "original_source_recheck": "PASSED_BEFORE_AND_AFTER_BUILD", + }, + "reproducible_build": True, + "builds": build_receipts, + } + ) + return destination, evidence + + +def _site_roots() -> tuple[Path, ...]: + paths = sysconfig.get_paths() + roots = { + Path(paths[key]).resolve() for key in ("purelib", "platlib") if paths.get(key) + } + return tuple(sorted(roots)) + + +_STRIPPED_CONTROLLER_METADATA = frozenset( + {"direct_url.json", "installer", "requested", "record"} +) +_PYTHON_STARTUP_FILENAMES = frozenset({"sitecustomize.py", "usercustomize.py"}) +_WINDOWS_DEVICE_BASENAMES = frozenset( + { + "con", + "prn", + "aux", + "nul", + *(f"com{number}" for number in range(1, 10)), + *(f"lpt{number}" for number in range(1, 10)), + } +) + + +def _windows_normalized_component(component: str) -> str: + """Return the filesystem name Windows would use, without accepting it.""" + + return component.rstrip(" .") + + +def _normalized_path_basename(raw: str | Path) -> str: + raw_text = str(raw).replace("\\", "/") + return _windows_normalized_component(raw_text.rsplit("/", 1)[-1]).casefold() + + +def _safe_relative_path(raw: str, *, context: str) -> Path: + candidate = Path(raw) + raw_parts = raw.split("/") + if ( + not raw + or candidate.is_absolute() + or "\\" in raw + or any(not component or component in {".", ".."} for component in raw_parts) + ): + raise BundleVerificationError(f"{context} has an unsafe relative path: {raw!r}") + normalized_parts = [] + for component in raw_parts: + normalized = _windows_normalized_component(component) + device_basename = normalized.split(".", 1)[0].casefold() + if ( + not normalized + or normalized != component + or ":" in normalized + or device_basename in _WINDOWS_DEVICE_BASENAMES + ): + raise BundleVerificationError( + f"{context} has a Windows-unsafe path component: {raw!r}" + ) + normalized_parts.append(normalized) + return Path(*normalized_parts) + + +def _is_python_startup_hook(relative: Path | str) -> bool: + """Return whether an installed file can change interpreter startup.""" + + name = _normalized_path_basename(relative) + if name.endswith((".pth", ".egg-link")): + return True + return name in _PYTHON_STARTUP_FILENAMES + + +def _is_pip_generated_external_script_member(raw: str) -> bool: + """Recognize only pip's generated venv console-script RECORD entries.""" + + if "\\" in raw: + return False + parts = raw.split("/") + if len(parts) < 3 or not all(part == ".." for part in parts[:-2]): + return False + script_directory = _windows_normalized_component(parts[-2]) + if script_directory != parts[-2] or script_directory.casefold() not in { + "scripts", + "bin", + }: + return False + # This also rejects Windows device names, ADS, and trailing dot/space in + # the generated script filename before it can be silently ignored. + _safe_relative_path(parts[-1], context="pip-generated console script") + return True + + +def _reject_python_startup_hook( + relative: Path | str, *, distribution_name: str +) -> None: + if _is_python_startup_hook(relative): + raise BundleVerificationError( + f"refusing controller startup hook while repackaging {distribution_name}: {relative}" + ) + + +def _controller_record_evidence( + distribution: metadata.Distribution, + *, + metadata_root: Path, + roots: tuple[Path, ...], +) -> dict[str, Any]: + """Validate an installed RECORD when available, without claiming trust. + + Old egg-style installations often have no hashed RECORD. They can still + supply an offline compatibility wheel, but the receipt explicitly labels + their controller origin untrusted rather than treating it as provenance. + """ + + record_path = metadata_root / "RECORD" + baseline = { + "origin": "controller_site_packages_repack", + "metadata_root_sha256": _sha256_bytes(str(metadata_root).encode("utf-8")), + } + if not record_path.is_file(): + return { + **baseline, + "controller_record_status": "UNTRUSTED_CONTROLLER_REPACK", + "controller_record_reason": "MISSING_RECORD", + } + try: + raw_record = record_path.read_bytes() + rows = list(csv.reader(io.StringIO(raw_record.decode("utf-8")))) + seen: set[str] = set() + for row in rows: + if len(row) != 3 or not row[0] or row[0] in seen: + raise ValueError("malformed or duplicate RECORD row") + seen.add(row[0]) + relative = _safe_relative_path(row[0], context="controller RECORD") + _reject_python_startup_hook( + relative, + distribution_name=str(distribution.metadata.get("Name") or "unknown"), + ) + source = Path(str(distribution.locate_file(relative))) + if source.is_symlink() or not source.is_file(): + raise ValueError(f"missing or symlinked RECORD member: {relative}") + resolved = source.resolve() + if not any(resolved.is_relative_to(root) for root in roots): + raise ValueError(f"RECORD member escapes site-packages: {relative}") + if row[0].endswith(".dist-info/RECORD"): + if row[1] or row[2]: + raise ValueError("RECORD hashes itself") + continue + if not row[1].startswith("sha256="): + raise ValueError(f"RECORD member is not SHA-256: {relative}") + encoded = row[1].split("=", 1)[1] + expected = base64.urlsafe_b64decode(encoded + "=" * (-len(encoded) % 4)) + actual = hashlib.sha256(source.read_bytes()).digest() + if actual != expected or row[2] != str(source.stat().st_size): + raise ValueError(f"RECORD integrity mismatch: {relative}") + except BundleVerificationError as error: + if "startup hook" in str(error): + raise + return { + **baseline, + "controller_record_status": "UNTRUSTED_CONTROLLER_REPACK", + "controller_record_reason": type(error).__name__, + "controller_record_sha256": _sha256_file(record_path), + } + except ( + OSError, + UnicodeDecodeError, + csv.Error, + binascii.Error, + ValueError, + ) as error: + return { + **baseline, + "controller_record_status": "UNTRUSTED_CONTROLLER_REPACK", + "controller_record_reason": type(error).__name__, + "controller_record_sha256": _sha256_file(record_path), + } + return { + **baseline, + "controller_record_status": "VALIDATED_CONTROLLER_RECORD", + "controller_record_sha256": _sha256_bytes(raw_record), + } + + +def _assert_not_editable_direct_url(direct_url: Path, *, context: str) -> None: + """Allow a wheel direct URL only when it is not an editable projection.""" + + if not direct_url.is_file(): + return + if direct_url.is_symlink(): + raise BundleVerificationError(f"{context} direct_url metadata is symlinked") + try: + payload = json.loads(direct_url.read_text(encoding="utf-8")) + except (OSError, UnicodeDecodeError, json.JSONDecodeError) as error: + raise BundleVerificationError( + f"cannot safely interpret {context} direct_url metadata" + ) from error + if not isinstance(payload, dict): + raise BundleVerificationError(f"{context} direct_url metadata is not an object") + directory_info = payload.get("dir_info") + if isinstance(directory_info, dict) and directory_info.get("editable") is True: + raise BundleVerificationError(f"refusing editable {context} distribution") + + +def _assert_not_editable_controller_distribution( + metadata_root: Path, name: str +) -> None: + """Reject editable controller projections even if their .pth is absent from RECORD.""" + + _assert_not_editable_direct_url( + metadata_root / "direct_url.json", context=f"controller {name}" + ) + + +def _installed_distributions() -> dict[str, metadata.Distribution]: + roots = _site_roots() + distributions: dict[str, metadata.Distribution] = {} + for distribution in metadata.distributions(): + name = distribution.metadata.get("Name") + if not name: + continue + metadata_file = next( + ( + item + for item in distribution.files or [] + if str(item).endswith(".dist-info/METADATA") + ), + None, + ) + if metadata_file is None: + continue + metadata_path = Path(str(distribution.locate_file(metadata_file))).resolve() + if not any(metadata_path.is_relative_to(root) for root in roots): + continue + key = canonicalize_name(name) + current = distributions.get(key) + if current is None or Version(distribution.version) > Version(current.version): + distributions[key] = distribution + return distributions + + +def _repackage_distribution( + distribution: metadata.Distribution, wheelhouse: Path +) -> tuple[Path, dict[str, Any]]: + """Create a local wheel from an already-installed runtime distribution.""" + + wheelhouse.mkdir(parents=True, exist_ok=True) + name = distribution.metadata.get("Name") + if not name: + raise BundleVerificationError("installed dependency has no distribution name") + roots = _site_roots() + metadata_root = Path(getattr(distribution, "_path", "")).resolve() + if not metadata_root.is_dir() or not any( + metadata_root.is_relative_to(root) for root in roots + ): + raise BundleVerificationError( + f"installed dependency {name} has no local metadata directory" + ) + metadata_source = metadata_root / "METADATA" + if not metadata_source.is_file(): + metadata_source = metadata_root / "PKG-INFO" + if not metadata_source.is_file(): + raise BundleVerificationError( + f"installed dependency {name} has no METADATA or PKG-INFO" + ) + if metadata_source.is_symlink(): + raise BundleVerificationError( + f"installed dependency {name} has symlinked metadata" + ) + _assert_not_editable_controller_distribution(metadata_root, name) + controller_record = _controller_record_evidence( + distribution, + metadata_root=metadata_root, + roots=roots, + ) + if distribution.files is None: + raise BundleVerificationError( + f"installed dependency {name} has no file manifest" + ) + stage_parent = Path( + tempfile.mkdtemp(prefix="iteration41-repack-", dir=wheelhouse.parent) + ) + try: + stage = stage_parent / "wheel" + stage.mkdir() + # A controller RECORD is an input to an offline compatibility build, + # not an authority to copy arbitrary controller files. We copy only + # regular, non-linked files whose resolved location and derived wheel + # path both remain below a controller site-packages root. Every other + # non-hook member is omitted and counted by category; the receipt then + # calls this controller reconstruction untrusted. This lets stale + # headers, data and console wrappers stay out of the wheelhouse + # without allowing an ambient controller path to enter a wheel. + skipped_controller_member_categories: dict[str, int] = {} + + def skip_controller_member(category: str) -> None: + skipped_controller_member_categories[category] = ( + skipped_controller_member_categories.get(category, 0) + 1 + ) + + for item in distribution.files: + relative_text = str(item) + # Check the original spelling first. In particular this catches + # Windows-normalized ``evil.pth `` / ``sitecustomize.py.`` names + # before an unsafe/external RECORD entry can be skipped below. + _reject_python_startup_hook(relative_text, distribution_name=name) + if _is_pip_generated_external_script_member(relative_text): + # Installed distributions commonly list generated console + # scripts using ../../Scripts/. They are not package + # payload and must never be copied out of controller + # site-packages. The origin RECORD evidence is separately + # labelled untrusted for this case. + skip_controller_member("pip_generated_console_script") + continue + try: + relative = _safe_relative_path( + relative_text, context=f"installed dependency {name}" + ) + except BundleVerificationError: + # The raw hook check above is intentionally before this + # branch, so a trailing-dot/space .pth cannot become a + # silently skipped "unsafe" member. + skip_controller_member("unsafe_relative_path") + continue + _reject_python_startup_hook(relative, distribution_name=name) + if "__pycache__" in item.parts or relative_text.endswith((".pyc", ".pyo")): + continue + try: + located = Path(str(distribution.locate_file(item))) + located_absolute = located.absolute() + source = located_absolute.resolve() + except OSError: + skip_controller_member("unresolvable_location") + continue + site_root = next( + (root for root in roots if source.is_relative_to(root)), None + ) + if located.is_symlink() or located_absolute != source: + skip_controller_member("symlinked_member") + continue + if site_root is None: + skip_controller_member("external_member") + continue + if not source.is_file(): + skip_controller_member("missing_or_non_regular_member") + continue + try: + relative = _safe_relative_path( + source.relative_to(site_root).as_posix(), + context=f"installed dependency {name} resolved member", + ) + except BundleVerificationError: + skip_controller_member("unsafe_resolved_relative_path") + continue + if source.is_relative_to(metadata_root): + # Recreate metadata below as a valid dist-info directory even + # when the local install originated from an old egg-info tree. + continue + relative = source.relative_to(site_root) + target = stage / relative + target.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(source, target) + dist_info_name = ( + f"{canonicalize_name(name).replace('-', '_')}-" + f"{distribution.version.replace('-', '_')}.dist-info" + ) + staged_metadata = stage / dist_info_name + staged_metadata.mkdir(parents=True, exist_ok=True) + shutil.copy2(metadata_source, staged_metadata / "METADATA") + for source in metadata_root.rglob("*"): + if source.is_symlink(): + raise BundleVerificationError( + f"installed dependency {name} has symlinked metadata: {source.name}" + ) + if not source.is_file() or source == metadata_source: + continue + relative = source.relative_to(metadata_root) + _reject_python_startup_hook(relative, distribution_name=name) + safe_relative = _safe_relative_path( + relative.as_posix(), context=f"installed dependency {name} metadata" + ) + if safe_relative.name.casefold() in _STRIPPED_CONTROLLER_METADATA: + continue + target = staged_metadata / safe_relative + target.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(source, target) + wheel_file = staged_metadata / "WHEEL" + if not wheel_file.is_file(): + binary = any( + path.suffix.lower() in {".pyd", ".so", ".dylib"} + for path in stage.rglob("*") + if path.is_file() + ) + tag = str(next(sys_tags())) if binary else "py3-none-any" + wheel_file.write_text( + "Wheel-Version: 1.0\n" + "Generator: iteration41 local wheelhouse repack\n" + f"Root-Is-Purelib: {'false' if binary else 'true'}\n" + f"Tag: {tag}\n", + encoding="utf-8", + ) + tags = [ + line.partition(":")[2].strip() + for line in wheel_file.read_text(encoding="utf-8").splitlines() + if line.startswith("Tag:") + ] + if not tags: + raise BundleVerificationError( + f"installed dependency {name} has no wheel tag" + ) + python_tags, abi_tags, platform_tags = zip( + *(tag.split("-", 2) for tag in tags), strict=True + ) + filename = ( + f"{canonicalize_name(name).replace('-', '_')}-" + f"{distribution.version.replace('-', '_')}-" + f"{'.'.join(dict.fromkeys(python_tags))}-" + f"{'.'.join(dict.fromkeys(abi_tags))}-" + f"{'.'.join(dict.fromkeys(platform_tags))}.whl" + ) + target_wheel = wheelhouse / filename + with WheelFile(str(target_wheel), "w") as archive: + for source in sorted(stage.rglob("*")): + if source.is_file() and source.name != "RECORD": + archive.write(source, source.relative_to(stage).as_posix()) + _record_contract(target_wheel, name) + if skipped_controller_member_categories: + controller_record = { + **controller_record, + "controller_record_status": "UNTRUSTED_CONTROLLER_REPACK", + "controller_record_reason": "UNREPACKAGED_CONTROLLER_RECORD_MEMBERS", + "skipped_controller_member_categories": dict( + sorted(skipped_controller_member_categories.items()) + ), + } + return target_wheel, controller_record + finally: + shutil.rmtree(stage_parent, ignore_errors=True) + + +def _marker_applies(marker: object, extras: Iterable[str] = ()) -> bool: + if marker is None: + return True + selected = tuple(extras) or ("") + environment = default_environment() + return any(marker.evaluate({**environment, "extra": extra}) for extra in selected) + + +def _requirements_for_wheel( + wheel: Path, *, extras: Iterable[str] = () +) -> list[Requirement]: + _, _, raw_requirements, _ = _wheel_metadata(wheel) + try: + parsed = [Requirement(item) for item in raw_requirements] + except (TypeError, ValueError) as error: + raise BundleVerificationError( + f"{wheel.name} contains an invalid Requires-Dist value" + ) from error + return [item for item in parsed if _marker_applies(item.marker, extras)] + + +def _repackage_dependency_closure( + *, + local_wheels: Mapping[str, Path], + roots: Iterable[tuple[Path, tuple[str, ...]]], + wheelhouse: Path, +) -> dict[str, dict[str, Any]]: + """Fill a wheelhouse from installed distributions without network resolution.""" + + installed = _installed_distributions() + local_versions = { + canonicalize_name(name): _wheel_metadata(wheel)[1] + for name, wheel in local_wheels.items() + } + copied: dict[str, dict[str, Any]] = {} + pending: list[Requirement] = [] + for wheel, extras in roots: + pending.extend(_requirements_for_wheel(wheel, extras=extras)) + while pending: + requirement = pending.pop() + if not _marker_applies(requirement.marker): + continue + name = canonicalize_name(requirement.name) + local_version = local_versions.get(name) + if local_version is not None: + if ( + requirement.specifier + and Version(local_version) not in requirement.specifier + ): + raise BundleVerificationError( + f"local wheel {name} {local_version} does not satisfy {requirement}" + ) + continue + distribution = installed.get(name) + if distribution is None: + raise BundleVerificationError( + f"required local dependency is not installed: {requirement}" + ) + if ( + requirement.specifier + and Version(distribution.version) not in requirement.specifier + ): + raise BundleVerificationError( + f"installed dependency {distribution.metadata['Name']} {distribution.version} " + f"does not satisfy {requirement}" + ) + if name in copied: + continue + wheel, controller_record = _repackage_distribution(distribution, wheelhouse) + copied[name] = { + "distribution": str(distribution.metadata["Name"]), + "version": distribution.version, + "filename": wheel.name, + "wheel_sha256": _sha256_file(wheel), + **controller_record, + } + for raw in distribution.requires or []: + try: + dependency = Requirement(raw) + except (TypeError, ValueError) as error: + raise BundleVerificationError( + f"installed dependency {distribution.metadata['Name']} has invalid requirement metadata" + ) from error + if _marker_applies(dependency.marker): + pending.append(dependency) + return dict(sorted(copied.items())) + + +def _venv_python(venv_dir: Path) -> Path: + return venv_dir / ("Scripts/python.exe" if os.name == "nt" else "bin/python") + + +_CONSUMER_MODULE_BINDINGS = { + "backtrader": ("backtrader", "backtrader/__init__.py"), + "backtrader_runtime": ("backtrader", "backtrader_runtime/managed_execution.py"), + "backtrader_runtime.cli": ("backtrader", "backtrader_runtime/cli.py"), + "backtrader_runtime.inventory": ("backtrader", "backtrader_runtime/inventory.py"), + "backtrader_runtime.backtest_fixture": ( + "backtrader", + "backtrader_runtime/_iteration41_backtest_fixture/run.py", + ), + "bt_api_base": ("bt_api_base", "bt_api_base/__init__.py"), + "bt_api_execution": ("bt_api_execution", "bt_api_execution/__init__.py"), + "bt_api_gateway": ("bt_api_gateway", "bt_api_gateway/__init__.py"), + "bt_api_monitor": ("bt_api_monitor", "bt_api_monitor/__init__.py"), + "bt_api_risk": ("bt_api_risk", "bt_api_risk/__init__.py"), + "bt_api_transport_zmq": ( + "bt_api_transport_zmq", + "bt_api_transport_zmq/__init__.py", + ), + "bt_api_py": ("bt_api_py", "bt_api_py/__init__.py"), + "bt_api_py.runtime_plugins": ("bt_api_py", "bt_api_py/runtime_plugins/__init__.py"), +} +_L2_FIXTURE_EXPECTED_STATUSES = { + "example.013_3.sa_midfreq_simnow.managed_replay_l2": "LOCAL_MANAGED_FAKE_PROVIDER_L2_PASS", + "example.ctp_options_simnow.mechanical_managed_replay_l2": ( + "LOCAL_CTP_MECHANICAL_MANAGED_FAKE_PROVIDER_L2_PASS" + ), +} +_LOCAL_BACKTEST_EXPECTED_RUNTIME_ID = "backtrader.iteration41.local_backtest_fixture" +_LOCAL_BACKTEST_EXPECTED_STATUS = "LOCAL_BACKTEST_CEREBRO_PASS" +_PYTHON_SOCKET_GUARD_DESCRIPTION = ( + "python_connection_and_dns_entry_points_blocked_before_package_imports; " + "no OS firewall claim" +) + + +def _venv_site_roots(venv_dir: Path) -> tuple[Path, ...]: + """Find actual site-packages directories without trusting a child report.""" + + candidates = [venv_dir / "Lib" / "site-packages"] + candidates.extend((venv_dir / "lib").glob("python*/site-packages")) + roots = tuple(sorted({path.resolve() for path in candidates if path.is_dir()})) + if not roots: + raise BundleVerificationError( + f"isolated consumer has no site-packages directory: {venv_dir}" + ) + return roots + + +def _wheel_payload_manifest(wheel: Path) -> dict[str, tuple[str, int]]: + """Return every installable wheel member after rejecting startup metadata.""" + + payload: dict[str, tuple[str, int]] = {} + with zipfile.ZipFile(wheel) as archive: + for member in archive.infolist(): + if member.is_dir(): + continue + name = member.filename + if _is_python_startup_hook(name): + raise BundleVerificationError( + f"{wheel.name} contains a Python startup hook: {name}" + ) + _safe_relative_path(name, context=f"{wheel.name} payload member") + if name.endswith(".dist-info/RECORD"): + continue + if _normalized_path_basename(name) == "direct_url.json": + raise BundleVerificationError( + f"{wheel.name} contains direct_url metadata" + ) + body = archive.read(name) + payload[name] = (_sha256_bytes(body), len(body)) + if not payload: + raise BundleVerificationError(f"{wheel.name} has no installable payload") + return payload + + +def _installed_payload_path( + relative: str, + *, + roots: tuple[Path, ...], + expected_sha256: str, + expected_size: int, +) -> Path: + safe_relative = _safe_relative_path( + relative, context="selected wheel installation member" + ) + candidates = [root / safe_relative for root in roots] + matches: list[Path] = [] + for candidate in candidates: + if not candidate.exists(): + continue + if candidate.is_symlink() or not candidate.is_file(): + raise BundleVerificationError( + f"selected wheel installation member is not a regular file: {relative}" + ) + if ( + candidate.stat().st_size != expected_size + or _sha256_file(candidate) != expected_sha256 + ): + raise BundleVerificationError( + f"selected wheel installation payload mismatch: {relative}" + ) + matches.append(candidate.resolve()) + if len(matches) != 1: + raise BundleVerificationError( + f"selected wheel installation member is missing or ambiguous: {relative}" + ) + return matches[0] + + +def _installed_record_rows( + metadata_root: Path, *, roots: tuple[Path, ...] +) -> dict[str, list[str]]: + record_path = metadata_root / "RECORD" + if record_path.is_symlink() or not record_path.is_file(): + raise BundleVerificationError( + "selected wheel installation has no regular RECORD" + ) + try: + rows = list(csv.reader(io.StringIO(record_path.read_text(encoding="utf-8")))) + except (OSError, UnicodeDecodeError, csv.Error) as error: + raise BundleVerificationError( + "selected wheel installation has an unreadable RECORD" + ) from error + by_name: dict[str, list[str]] = {} + for row in rows: + if len(row) != 3 or not row[0] or row[0] in by_name: + raise BundleVerificationError( + "selected wheel installation has an invalid RECORD row" + ) + if _is_python_startup_hook(row[0]): + raise BundleVerificationError( + "selected wheel installation RECORD lists a startup hook" + ) + if _is_pip_generated_external_script_member(row[0]): + # Pip creates console wrappers outside site-packages. They are not + # wheel payload and cannot establish an import origin; accept only + # this constrained form after the startup-hook/path checks above. + continue + safe_relative = _safe_relative_path( + row[0], context="selected wheel installation RECORD" + ) + candidate_paths = [root / safe_relative for root in roots] + existing = [path for path in candidate_paths if path.exists()] + if len(existing) != 1 or existing[0].is_symlink() or not existing[0].is_file(): + raise BundleVerificationError( + "selected wheel installation RECORD member is not local and regular" + ) + if _normalized_path_basename(row[0]) == "direct_url.json": + _assert_not_editable_direct_url( + existing[0], context="selected wheel installation" + ) + by_name[row[0]] = row + return by_name + + +def _validate_selected_wheel_installations( + *, + projects: Iterable[ProjectSpec], + local_wheels: Mapping[str, Path], + site_roots: tuple[Path, ...], +) -> dict[str, dict[str, Any]]: + """Bind installed payload bytes and RECORD entries to selected local wheels.""" + + bindings: dict[str, dict[str, Any]] = {} + for project in projects: + wheel = local_wheels[project.key] + distribution, version, _, _ = _wheel_metadata(wheel) + payload = _wheel_payload_manifest(wheel) + payload_paths = { + member: _installed_payload_path( + member, + roots=site_roots, + expected_sha256=digest, + expected_size=size, + ) + for member, (digest, size) in payload.items() + } + metadata_members = [ + member for member in payload_paths if member.endswith(".dist-info/METADATA") + ] + if len(metadata_members) != 1: + raise BundleVerificationError( + f"selected wheel installation has no unique METADATA member: {project.key}" + ) + metadata_root = payload_paths[metadata_members[0]].parent + installed_metadata = BytesParser().parsebytes( + (metadata_root / "METADATA").read_bytes() + ) + if ( + canonicalize_name(str(installed_metadata.get("Name") or "")) + != canonicalize_name(distribution) + or installed_metadata.get("Version") != version + ): + raise BundleVerificationError( + f"selected wheel installation metadata mismatch: {project.key}" + ) + record_rows = _installed_record_rows(metadata_root, roots=site_roots) + for member, (digest, size) in payload.items(): + row = record_rows.get(member) + if row is None or not row[1].startswith("sha256=") or row[2] != str(size): + raise BundleVerificationError( + f"selected wheel installation RECORD does not bind payload: {project.key}/{member}" + ) + encoded = row[1].split("=", 1)[1] + try: + recorded_digest = base64.urlsafe_b64decode( + encoded + "=" * (-len(encoded) % 4) + ).hex() + except (binascii.Error, ValueError, TypeError) as error: + raise BundleVerificationError( + f"selected wheel installation RECORD digest is invalid: {project.key}/{member}" + ) from error + if recorded_digest != digest: + raise BundleVerificationError( + f"selected wheel installation RECORD digest mismatch: {project.key}/{member}" + ) + manifest_material = [ + {"member": member, "sha256": digest, "size": size} + for member, (digest, size) in sorted(payload.items()) + ] + bindings[project.key] = { + "distribution": distribution, + "version": version, + "wheel_sha256": _sha256_file(wheel), + "metadata_path": str(metadata_root), + "payload_paths": { + member: str(path) for member, path in payload_paths.items() + }, + "payload_member_count": len(payload), + "payload_manifest_sha256": _sha256_bytes( + json.dumps( + manifest_material, ensure_ascii=True, separators=(",", ":") + ).encode("utf-8") + ), + "installed_record_validated": True, + } + return bindings + + +def _validated_site_path( + raw_path: object, + *, + roots: tuple[Path, ...], + label: str, + expect_directory: bool = False, +) -> Path: + if not isinstance(raw_path, str) or not raw_path: + raise BundleVerificationError(f"isolated consumer {label} is missing") + path = Path(raw_path) + expected_kind = path.is_dir if expect_directory else path.is_file + if not path.is_absolute() or path.is_symlink() or not expected_kind(): + kind = "directory" if expect_directory else "file" + raise BundleVerificationError( + f"isolated consumer {label} is not a regular absolute {kind}" + ) + resolved = path.resolve() + if not any(resolved.is_relative_to(root) for root in roots): + raise BundleVerificationError( + f"isolated consumer {label} escapes its site-packages roots" + ) + return resolved + + +def _validate_consumer_probe_payload( + payload: Mapping[str, Any], + *, + expected_projects: Mapping[str, Mapping[str, str]], + expected_modules: Mapping[str, tuple[str, str]], + installation_bindings: Mapping[str, Mapping[str, Any]], + site_roots: tuple[Path, ...], +) -> dict[str, Any]: + """Fail closed on the exact isolated-consumer result contract. + + The child probe is useful execution evidence, but its JSON is not trusted + by itself. The controller binds every reported local distribution to the + wheel it selected and independently verifies each reported path below the + new venv's site-packages directories. + """ + + required_keys = { + "module_paths", + "package_versions", + "installed_local_projects", + "fake_provider_calls", + "execution_state", + "local_backtest_report", + "l2_fixture_reports", + "network_guard", + "network_guard_attempts", + "controller_environment_sentinel_absent", + "provider_evidence", + } + if set(payload) != required_keys: + raise BundleVerificationError( + "isolated consumer probe has an unexpected result schema" + ) + module_paths = payload["module_paths"] + if not isinstance(module_paths, Mapping) or set(module_paths) != set( + expected_modules + ): + raise BundleVerificationError( + "isolated consumer probe module-path set is incomplete" + ) + for key, (project_key, payload_member) in expected_modules.items(): + resolved_module_path = _validated_site_path( + module_paths[key], roots=site_roots, label=f"module path {key}" + ) + expected_module_path = Path( + installation_bindings[project_key]["payload_paths"][payload_member] + ) + if resolved_module_path != expected_module_path: + raise BundleVerificationError( + f"isolated consumer module binding mismatch: {key}" + ) + + installed_projects = payload["installed_local_projects"] + if not isinstance(installed_projects, Mapping) or set(installed_projects) != set( + expected_projects + ): + raise BundleVerificationError( + "isolated consumer project binding set is incomplete" + ) + expected_versions: dict[str, str] = {} + for key, expected in expected_projects.items(): + observed = installed_projects[key] + if not isinstance(observed, Mapping): + raise BundleVerificationError( + f"isolated consumer project binding is invalid: {key}" + ) + required_project_keys = { + "distribution", + "version", + "wheel_sha256", + "metadata_path", + } + if set(observed) != required_project_keys: + raise BundleVerificationError( + f"isolated consumer project binding schema is invalid: {key}" + ) + distribution = expected["distribution"] + version = expected["version"] + wheel_sha256 = expected["wheel_sha256"] + if ( + observed["distribution"] != distribution + or observed["version"] != version + or observed["wheel_sha256"] != wheel_sha256 + ): + raise BundleVerificationError( + f"isolated consumer wheel binding mismatch: {key}" + ) + metadata_path = _validated_site_path( + observed["metadata_path"], + roots=site_roots, + label=f"metadata path {key}", + expect_directory=True, + ) + expected_metadata_path = Path(installation_bindings[key]["metadata_path"]) + if metadata_path != expected_metadata_path: + raise BundleVerificationError( + f"isolated consumer metadata path mismatch: {key}" + ) + metadata_payload = BytesParser().parsebytes( + (metadata_path / "METADATA").read_bytes() + ) + if ( + canonicalize_name(str(metadata_payload.get("Name") or "")) + != canonicalize_name(distribution) + or metadata_payload.get("Version") != version + ): + raise BundleVerificationError( + f"isolated consumer metadata binding mismatch: {key}" + ) + expected_versions[distribution] = version + + package_versions = payload["package_versions"] + if ( + not isinstance(package_versions, Mapping) + or dict(package_versions) != expected_versions + ): + raise BundleVerificationError( + "isolated consumer package-version binding mismatch" + ) + if ( + type(payload["fake_provider_calls"]) is not int + or payload["fake_provider_calls"] != 1 + ): + raise BundleVerificationError( + "isolated consumer fake provider was not called exactly once" + ) + if payload["execution_state"] != "ACKED": + raise BundleVerificationError("isolated consumer did not reach ACKED state") + local_backtest_report = payload["local_backtest_report"] + required_local_backtest_keys = { + "status", + "external_network_requests", + "external_write_requests", + "actual_fills", + "provider_submissions", + "actual_pnl", + "pnl_source", + "data_bars", + "network_guard_attempts", + "runtime_config", + } + if not isinstance(local_backtest_report, Mapping) or set(local_backtest_report) != ( + required_local_backtest_keys + ): + raise BundleVerificationError("isolated consumer local-backtest report schema is invalid") + if local_backtest_report["status"] != _LOCAL_BACKTEST_EXPECTED_STATUS: + raise BundleVerificationError("isolated consumer local backtest did not complete") + for field_name in ( + "external_network_requests", + "external_write_requests", + "actual_fills", + "provider_submissions", + ): + if type(local_backtest_report[field_name]) is not int or local_backtest_report[field_name] != 0: + raise BundleVerificationError( + f"isolated consumer local backtest has non-local {field_name}" + ) + if ( + local_backtest_report["actual_pnl"] != "NOT_APPLICABLE" + or local_backtest_report["pnl_source"] != "local_backtest_no_orders" + or type(local_backtest_report["data_bars"]) is not int + or local_backtest_report["data_bars"] != 4 + or local_backtest_report["network_guard_attempts"] != [] + ): + raise BundleVerificationError("isolated consumer local-backtest result is not the reviewed fixture") + expected_local_runtime_config = { + "strategy_id": _LOCAL_BACKTEST_EXPECTED_RUNTIME_ID, + "mode": "backtest", + "preset": "local_backtest", + "environment": "local", + "allows_network": False, + "allows_external_writes": False, + "allows_production_writes": False, + } + if ( + not isinstance(local_backtest_report["runtime_config"], Mapping) + or dict(local_backtest_report["runtime_config"]) != expected_local_runtime_config + ): + raise BundleVerificationError( + "isolated consumer local-backtest configuration is not the reviewed offline shape" + ) + l2_fixture_reports = payload["l2_fixture_reports"] + if not isinstance(l2_fixture_reports, Mapping) or set(l2_fixture_reports) != set( + _L2_FIXTURE_EXPECTED_STATUSES + ): + raise BundleVerificationError("isolated consumer L2 fixture report set is incomplete") + required_l2_report_keys = { + "status", + "external_network_requests", + "external_write_requests", + "actual_fills", + "provider_submissions", + } + for runtime_id, expected_status in _L2_FIXTURE_EXPECTED_STATUSES.items(): + report = l2_fixture_reports[runtime_id] + if not isinstance(report, Mapping) or set(report) != required_l2_report_keys: + raise BundleVerificationError( + f"isolated consumer L2 fixture report schema is invalid: {runtime_id}" + ) + if report["status"] != expected_status: + raise BundleVerificationError( + f"isolated consumer L2 fixture did not reach its expected status: {runtime_id}" + ) + for field_name in ( + "external_network_requests", + "external_write_requests", + "actual_fills", + ): + if type(report[field_name]) is not int or report[field_name] != 0: + raise BundleVerificationError( + f"isolated consumer L2 fixture has non-local {field_name}: {runtime_id}" + ) + if type(report["provider_submissions"]) is not int or report["provider_submissions"] < 1: + raise BundleVerificationError( + f"isolated consumer L2 fixture did not exercise its fake provider: {runtime_id}" + ) + if payload["provider_evidence"] != "fixture_only": + raise BundleVerificationError( + "isolated consumer provider evidence is not fixture-only" + ) + if payload["controller_environment_sentinel_absent"] is not True: + raise BundleVerificationError( + "isolated consumer controller-environment sentinel was not absent" + ) + if payload["network_guard"] != _PYTHON_SOCKET_GUARD_DESCRIPTION: + raise BundleVerificationError( + "isolated consumer Python socket guard is not the expected contract" + ) + if not isinstance(payload["network_guard_attempts"], list) or payload[ + "network_guard_attempts" + ]: + raise BundleVerificationError( + "isolated consumer attempted a guarded Python connection or DNS entry point" + ) + return dict(payload) + + +def _parse_consumer_probe_payload(output: str) -> dict[str, Any]: + try: + payload = json.loads(output) + except (TypeError, json.JSONDecodeError) as error: + raise BundleVerificationError( + "isolated consumer probe did not emit JSON" + ) from error + if not isinstance(payload, dict): + raise BundleVerificationError("isolated consumer probe JSON must be an object") + return payload + + +_CONSUMER_PROBE = r""" +import io +import importlib.metadata as metadata +import json +import os +from decimal import Decimal +from pathlib import Path + +import socket + + +network_guard_attempts = [] + + +def _blocked_python_network(label): + def blocked(*args, **kwargs): + network_guard_attempts.append(label) + raise AssertionError(f"Python network entry point is forbidden in the isolated consumer probe: {label}") + + return blocked + + +_original_socket = socket.socket + + +class _BlockedSocket(_original_socket): + def connect(self, *args, **kwargs): + return _blocked_python_network("socket.connect")(*args, **kwargs) + + def connect_ex(self, *args, **kwargs): + return _blocked_python_network("socket.connect_ex")(*args, **kwargs) + + +socket.socket = _BlockedSocket +for _attribute in ("create_connection", "create_server", "fromfd", "socketpair", "getaddrinfo"): + if hasattr(socket, _attribute): + setattr(socket, _attribute, _blocked_python_network(f"socket.{_attribute}")) +for _attribute in ("gethostbyaddr", "gethostbyname", "getnameinfo"): + if hasattr(socket, _attribute): + setattr(socket, _attribute, _blocked_python_network(f"socket.{_attribute}")) + +if os.environ.get("ITERATION41_CONTROLLER_SECRET_SENTINEL") is not None: + raise AssertionError("controller environment sentinel leaked into the isolated consumer") +try: + expected_projects = json.loads(os.environ["ITERATION41_EXPECTED_LOCAL_PROJECTS"]) +except (KeyError, TypeError, json.JSONDecodeError) as error: + raise AssertionError("missing or malformed expected local-project manifest") from error +if not isinstance(expected_projects, dict): + raise AssertionError("expected local-project manifest must be an object") + +import backtrader_runtime.cli as runtime_cli +import backtrader_runtime.inventory as runtime_inventory + +expected_backtest_runtime_id = "backtrader.iteration41.local_backtest_fixture" +backtest_registry = runtime_inventory.iteration41_backtest_fixture_registry() +if len(backtest_registry.registrations) != 1: + raise AssertionError("packaged local-backtest registry must contain exactly one runtime") +backtest_registration = backtest_registry.registrations[0] +if backtest_registration.runtime_id != expected_backtest_runtime_id: + raise AssertionError("packaged local-backtest registry does not match the reviewed runtime ID") +backtest_stdout = io.StringIO() +backtest_stderr = io.StringIO() +backtest_exit_code = runtime_cli.main( + ["run", "--strategy-dir", str(backtest_registration.runtime_dir), "--full-report"], + registry=backtest_registry, + stdout=backtest_stdout, + stderr=backtest_stderr, +) +if backtest_exit_code != 0: + raise AssertionError( + "packaged local-backtest fixture dispatch failed: " + backtest_stderr.getvalue().strip() + ) +try: + local_backtest_report = json.loads(backtest_stdout.getvalue())["report"]["result"] +except (KeyError, TypeError, json.JSONDecodeError) as error: + raise AssertionError("packaged local-backtest fixture did not emit a complete report") from error +if local_backtest_report.get("status") != "LOCAL_BACKTEST_CEREBRO_PASS": + raise AssertionError("packaged local-backtest fixture did not reach its expected status") + +expected_l2_statuses = { + "example.013_3.sa_midfreq_simnow.managed_replay_l2": "LOCAL_MANAGED_FAKE_PROVIDER_L2_PASS", + "example.ctp_options_simnow.mechanical_managed_replay_l2": "LOCAL_CTP_MECHANICAL_MANAGED_FAKE_PROVIDER_L2_PASS", +} +l2_registry = runtime_inventory.iteration41_l2_fixture_registry() +if {registration.runtime_id for registration in l2_registry.registrations} != set(expected_l2_statuses): + raise AssertionError("packaged L2 fixture registry does not match the reviewed consumer matrix") +l2_fixture_reports = {} +for registration in l2_registry.registrations: + l2_stdout = io.StringIO() + l2_stderr = io.StringIO() + l2_exit_code = runtime_cli.main( + ["run", "--strategy-dir", str(registration.runtime_dir), "--full-report"], + registry=l2_registry, + stdout=l2_stdout, + stderr=l2_stderr, + ) + if l2_exit_code != 0: + raise AssertionError( + "packaged L2 fixture dispatch failed: " + f"{registration.runtime_id}: {l2_stderr.getvalue().strip()}" + ) + try: + l2_payload = json.loads(l2_stdout.getvalue()) + l2_report = l2_payload["report"]["result"] + except (KeyError, TypeError, json.JSONDecodeError) as error: + raise AssertionError( + f"packaged L2 fixture did not emit a complete report: {registration.runtime_id}" + ) from error + if l2_report.get("status") != expected_l2_statuses[registration.runtime_id]: + raise AssertionError( + f"packaged L2 fixture did not reach its expected status: {registration.runtime_id}" + ) + l2_fixture_reports[registration.runtime_id] = { + "status": l2_report["status"], + "external_network_requests": l2_report["external_network_requests"], + "external_write_requests": l2_report["external_write_requests"], + "actual_fills": l2_report["actual_fills"], + "provider_submissions": l2_report["provider_submissions"], + } + +import backtrader +import backtrader_runtime._iteration41_backtest_fixture.run as backtest_fixture +import backtrader_runtime.managed_execution as bridge +import bt_api_base +import bt_api_execution +import bt_api_gateway +import bt_api_monitor +import bt_api_risk +import bt_api_transport_zmq +import bt_api_py +import bt_api_py.runtime_plugins as plugins + +paths = { + "backtrader": backtrader.__file__, + "backtrader_runtime": bridge.__file__, + "backtrader_runtime.cli": runtime_cli.__file__, + "backtrader_runtime.inventory": runtime_inventory.__file__, + "backtrader_runtime.backtest_fixture": backtest_fixture.__file__, + "bt_api_base": bt_api_base.__file__, + "bt_api_execution": bt_api_execution.__file__, + "bt_api_gateway": bt_api_gateway.__file__, + "bt_api_monitor": bt_api_monitor.__file__, + "bt_api_risk": bt_api_risk.__file__, + "bt_api_transport_zmq": bt_api_transport_zmq.__file__, + "bt_api_py": bt_api_py.__file__, + "bt_api_py.runtime_plugins": plugins.__file__, +} +for name, value in paths.items(): + resolved = Path(value).resolve() + if "site-packages" not in {part.lower() for part in resolved.parts}: + raise AssertionError(f"{name} did not import from site-packages: {resolved}") + +observed_projects = {} +for project_key, expected in sorted(expected_projects.items()): + if not isinstance(expected, dict): + raise AssertionError(f"expected local-project entry is not an object: {project_key}") + distribution_name = expected.get("distribution") + expected_version = expected.get("version") + expected_wheel_sha256 = expected.get("wheel_sha256") + if not all(isinstance(value, str) and value for value in ( + distribution_name, + expected_version, + expected_wheel_sha256, + )): + raise AssertionError(f"expected local-project entry is incomplete: {project_key}") + observed_version = metadata.version(distribution_name) + if observed_version != expected_version: + raise AssertionError( + f"installed version mismatch for {distribution_name}: {observed_version} != {expected_version}" + ) + installed_distribution = metadata.distribution(distribution_name) + installed_metadata = Path(str(getattr(installed_distribution, "_path", ""))).resolve() + if "site-packages" not in {part.lower() for part in installed_metadata.parts}: + raise AssertionError( + f"installed metadata escaped site-packages for {distribution_name}: {installed_metadata}" + ) + observed_projects[project_key] = { + "distribution": distribution_name, + "version": observed_version, + "wheel_sha256": expected_wheel_sha256, + "metadata_path": str(installed_metadata), + } + +contract = plugins.RuntimeCapabilityContract( + strategy_id="iteration41.isolated.consumer", + mode="live", + preset="managed_live_direct", + environment="production", + order_route="managed_execution", + required_capabilities=( + plugins.CAPABILITY_EXECUTION, + plugins.CAPABILITY_RISK, + plugins.CAPABILITY_MONITOR, + ), + effective_digest="a" * 64, +) +catalog = plugins.CapabilityCatalog( + ( + plugins.CapabilityPin( + plugins.CAPABILITY_EXECUTION, + "bt_api_execution", + "bt_api_execution", + metadata.version("bt_api_execution"), + ), + plugins.CapabilityPin( + plugins.CAPABILITY_RISK, + "bt_api_risk", + "bt_api_risk", + metadata.version("bt_api_risk"), + ), + plugins.CapabilityPin( + plugins.CAPABILITY_MONITOR, + "bt_api_monitor", + "bt_api_monitor", + metadata.version("bt_api_monitor"), + ), + ) +) +snapshot = plugins.SealedNormalizedInstrumentMetadataSnapshot.from_normalized_payload( + { + "provider": "fixture_provider", + "environment": "production", + "account_ref": "fixture_account", + "trading_day": "20260922", + "metadata_version": "fixture-normalized-v1", + "as_of_ns": 1_000, + "expires_at_ns": 2_000, + "account_currency": "USD", + "instruments": [ + { + "instrument": "fixture/contract", + "tick_size": "0.1", + "lot_size": "1", + "contract_multiplier": "1", + "max_gross_notional_account": "1000", + "quote_currency": "USD", + "fee_currency": "USD", + "quote_to_account_fx": "1", + "fee_to_account_fx": "1", + "taker_fee_bps": "0", + "fixed_fee": "0", + "max_slippage_bps": "0", + "quantity_unit": "contracts", + } + ], + } +) +runtime = plugins.compose_managed_execution( + catalog.load(contract), + state_directory=Path(os.environ["ITERATION41_CONSUMER_STATE"]), + provider="fixture_provider", + environment="production", + account_ref="fixture_account", + strategy_id=contract.strategy_id, + writer_id="consumer-writer", + policy_id="consumer-policy", + max_increase_notional=Decimal("100"), + max_increase_count=1, + trading_day=snapshot.trading_day, + instrument_metadata_snapshot=snapshot, + instrument_clock_ns=lambda: 1_500, +) +provider_calls = [] +try: + intent = runtime.execution.OrderIntent.limit( + intent_id="consumer-intent", + scope=runtime.scope, + signal_id="consumer-signal", + instrument="fixture/contract", + side=runtime.execution.Side.BUY, + quantity=Decimal("1"), + price=Decimal("1"), + metadata_version=snapshot.metadata_version, + tags={ + "instrument_metadata_digest": snapshot.instrument_digest("fixture/contract"), + "quantity_unit": snapshot.instrument_metadata("fixture/contract").quantity_unit, + }, + ) + + def fake_provider(submitted): + provider_calls.append(submitted.intent_id) + return runtime.execution.ProviderObservation.accepted( + submitted.intent_id, + "fixture-provider-order", + ) + + record = runtime.submit(intent, fake_provider) + if record.state.value != "ACKED" or provider_calls != ["consumer-intent"]: + raise AssertionError("local fake-provider composition did not acknowledge exactly once") +finally: + runtime.close() + +print( + json.dumps( + { + "module_paths": {key: str(Path(value).resolve()) for key, value in paths.items()}, + "package_versions": { + item["distribution"]: item["version"] + for item in observed_projects.values() + }, + "installed_local_projects": observed_projects, + "fake_provider_calls": len(provider_calls), + "execution_state": "ACKED", + "local_backtest_report": local_backtest_report, + "l2_fixture_reports": l2_fixture_reports, + "network_guard": "python_connection_and_dns_entry_points_blocked_before_package_imports; no OS firewall claim", + "network_guard_attempts": network_guard_attempts, + "controller_environment_sentinel_absent": True, + "provider_evidence": "fixture_only", + }, + sort_keys=True, + ) +) +""" + + +def _isolated_consumer( + *, + controller_python: str, + artifacts_dir: Path, + wheelhouse: Path, + local_wheels: Mapping[str, Path], + projects: Iterable[ProjectSpec], +) -> dict[str, Any]: + """Install from local wheels only and exercise the installed public boundary.""" + + logs_dir = artifacts_dir / "logs" + # Keep the venv outside a potentially long artifact path. Some valid + # packages (for example statsmodels fixture data) have deep installed + # paths that exceed legacy Windows MAX_PATH when nested under a UUID-named + # receipt directory. The logs and replayable wheelhouse remain under the + # requested artifact directory; the disposable venv does not. + with tempfile.TemporaryDirectory(prefix="i41c-") as temp: + root = Path(temp) + venv_dir = root / "venv" + create = _run_logged( + [controller_python, "-m", "venv", str(venv_dir)], + cwd=root, + environment=_offline_environment(), + logs_dir=logs_dir, + name="consumer-venv", + ) + if create["exit_code"] != 0: + raise BundleVerificationError( + "isolated consumer virtualenv creation failed: " + f"{create['stderr'].strip() or create['stdout'].strip()}" + ) + python = _venv_python(venv_dir) + project_list = tuple(projects) + missing_wheels = [ + project.key for project in project_list if project.key not in local_wheels + ] + if missing_wheels: + raise BundleVerificationError( + f"isolated consumer is missing local wheels: {', '.join(missing_wheels)}" + ) + wheels = [local_wheels[project.key] for project in project_list] + expected_projects = {} + for project, wheel in zip(project_list, wheels, strict=True): + distribution, version, _, _ = _wheel_metadata(wheel) + if canonicalize_name(distribution) != canonicalize_name( + project.distribution + ): + raise BundleVerificationError( + f"local wheel binding mismatch for {project.key}: {distribution}" + ) + expected_projects[project.key] = { + "distribution": distribution, + "version": version, + "wheel_sha256": _sha256_file(wheel), + } + install = _run_logged( + [ + str(python), + "-m", + "pip", + "install", + "--no-index", + "--find-links", + str(wheelhouse), + "--no-cache-dir", + "--disable-pip-version-check", + "--force-reinstall", + *(str(wheel) for wheel in wheels), + ], + cwd=root, + environment=_offline_environment(), + logs_dir=logs_dir, + name="consumer-install", + ) + if install["exit_code"] != 0: + raise BundleVerificationError( + "isolated consumer install failed: " + f"{install['stderr'].strip() or install['stdout'].strip()}" + ) + dependency_check = _run_logged( + [str(python), "-m", "pip", "check"], + cwd=root, + environment=_offline_environment(), + logs_dir=logs_dir, + name="consumer-pip-check", + ) + if dependency_check["exit_code"] != 0: + raise BundleVerificationError( + "isolated consumer dependency check failed: " + f"{dependency_check['stderr'].strip() or dependency_check['stdout'].strip()}" + ) + site_roots = _venv_site_roots(venv_dir) + installation_bindings_before = _validate_selected_wheel_installations( + projects=project_list, + local_wheels=local_wheels, + site_roots=site_roots, + ) + state = root / "consumer-state" + probe = _run_logged( + [str(python), "-I", "-c", _CONSUMER_PROBE], + cwd=root, + environment=_offline_environment( + { + "BT_API_PY_LIGHT_IMPORT": "1", + "ITERATION41_CONSUMER_STATE": str(state), + "ITERATION41_EXPECTED_LOCAL_PROJECTS": json.dumps( + expected_projects, + ensure_ascii=True, + sort_keys=True, + ), + } + ), + logs_dir=logs_dir, + name="consumer-probe", + ) + if probe["exit_code"] != 0: + raise BundleVerificationError( + "isolated consumer probe failed: " + f"{probe['stderr'].strip() or probe['stdout'].strip()}" + ) + payload = _parse_consumer_probe_payload(probe["stdout"]) + payload = _validate_consumer_probe_payload( + payload, + expected_projects=expected_projects, + expected_modules=_CONSUMER_MODULE_BINDINGS, + installation_bindings=installation_bindings_before, + site_roots=site_roots, + ) + installation_bindings_after = _validate_selected_wheel_installations( + projects=project_list, + local_wheels=local_wheels, + site_roots=site_roots, + ) + if installation_bindings_after != installation_bindings_before: + raise BundleVerificationError( + "isolated consumer modified a selected local wheel installation" + ) + return { + "local_wheelhouse_only": True, + "isolated_python": str(python), + "venv": { + key: value + for key, value in create.items() + if key not in {"stdout", "stderr"} + }, + "install": { + key: value + for key, value in install.items() + if key not in {"stdout", "stderr"} + }, + "dependency_check": { + key: value + for key, value in dependency_check.items() + if key not in {"stdout", "stderr"} + }, + "probe": { + key: value + for key, value in probe.items() + if key not in {"stdout", "stderr"} + }, + "probe_payload": payload, + "expected_local_projects": expected_projects, + "local_install_bindings": { + key: { + field: value + for field, value in binding.items() + if field != "payload_paths" + } + for key, binding in installation_bindings_before.items() + }, + } + + +def _project_specs(sdk_root: Path, backtrader_root: Path) -> tuple[ProjectSpec, ...]: + return ( + ProjectSpec( + "backtrader", + "backtrader", + "backtrader", + backtrader_root, + ("setup.py", "README.md", "backtrader", "backtrader_runtime"), + ), + ProjectSpec( + "bt_api_py", + "bt_api_py", + "bt_api_py", + sdk_root, + ("pyproject.toml", "setup.py", "MANIFEST.in", "README.md", "bt_api_py"), + ), + ProjectSpec( + "bt_api_base", + "bt_api_base", + "bt_api_base", + sdk_root / "bt_api" / "bt_api_base", + ("pyproject.toml", "README.md", "src"), + "bt_api/bt_api_base", + ), + ProjectSpec( + "bt_api_execution", + "bt_api_execution", + "bt_api_execution", + sdk_root / "bt_api" / "bt_api_execution", + ("pyproject.toml", "README.md", "src"), + "bt_api/bt_api_execution", + ), + ProjectSpec( + "bt_api_risk", + "bt_api_risk", + "bt_api_risk", + sdk_root / "bt_api" / "bt_api_risk", + ("pyproject.toml", "README.md", "src"), + "bt_api/bt_api_risk", + ), + ProjectSpec( + "bt_api_monitor", + "bt_api_monitor", + "bt_api_monitor", + sdk_root / "bt_api" / "bt_api_monitor", + ("pyproject.toml", "README.md", "src"), + "bt_api/bt_api_monitor", + ), + ProjectSpec( + "bt_api_gateway", + "bt_api_gateway", + "bt_api_gateway", + sdk_root / "bt_api" / "bt_api_gateway", + ("pyproject.toml", "README.md", "src"), + "bt_api/bt_api_gateway", + ), + ProjectSpec( + "bt_api_transport_zmq", + "bt_api_transport_zmq", + "bt_api_transport_zmq", + sdk_root / "bt_api" / "bt_api_transport_zmq", + ("pyproject.toml", "README.md", "src"), + "bt_api/bt_api_transport_zmq", + ), + ) + + +def _release_limitations( + source_states: Mapping[str, Mapping[str, Any]], + dependency_wheels: Mapping[str, Mapping[str, Any]], +) -> list[str]: + limitations = [ + "LOCAL_ONLY_VERIFIER_DOES_NOT_ESTABLISH_REVIEWED_RELEASE_OR_PUBLICATION", + "LOCAL_ONLY_VERIFIER_DOES_NOT_ESTABLISH_SIGNATURE_OR_TRUSTED_ARTIFACT_PROVENANCE", + "LOCAL_GIT_STATE_IS_NOT_FINAL_COMMIT_OR_RELEASE_PROVENANCE", + "LOCAL_ONLY_VERIFIER_DOES_NOT_ESTABLISH_PROVIDER_ACCOUNT_OR_LIVE_TRADING_ADMISSION", + ] + for key, state in source_states.items(): + source_state = str(state.get("source_state") or "") + if source_state != "CLEAN_GITLINK_SOURCE": + limitations.append(f"SOURCE_STATE_{key.upper()}_{source_state}") + for key, evidence in dependency_wheels.items(): + if evidence.get("controller_record_status") != "VALIDATED_CONTROLLER_RECORD": + limitations.append(f"DEPENDENCY_{key.upper()}_CONTROLLER_RECORD_UNTRUSTED") + return limitations + + +def _wheelhouse_manifest(wheelhouse: Path) -> dict[str, Any]: + """Return a deterministic digest of every locally usable wheel artifact.""" + + files = [ + {"filename": path.name, "sha256": _sha256_file(path)} + for path in sorted( + wheelhouse.glob("*.whl"), key=lambda item: item.name.casefold() + ) + ] + encoded = json.dumps( + files, ensure_ascii=True, separators=(",", ":"), sort_keys=True + ).encode("utf-8") + return {"wheel_count": len(files), "sha256": _sha256_bytes(encoded), "files": files} + + +def _write_receipt_atomically(path: Path, receipt: Mapping[str, Any]) -> None: + """Create one complete receipt without overwriting a concurrent writer.""" + + path.parent.mkdir(parents=True, exist_ok=True) + payload = json.dumps(receipt, ensure_ascii=False, indent=2, sort_keys=True) + "\n" + temporary_path: Path | None = None + try: + with tempfile.NamedTemporaryFile( + mode="w", + encoding="utf-8", + dir=path.parent, + prefix=f".{path.name}.", + suffix=".tmp", + delete=False, + ) as handle: + temporary_path = Path(handle.name) + handle.write(payload) + handle.flush() + os.fsync(handle.fileno()) + try: + # A hard-link creation is atomic and fails if another process has + # already created the final name. ``Path.replace`` would silently + # overwrite a concurrent receipt, which invalidates evidence. + os.link(temporary_path, path) + except FileExistsError: + raise + finally: + if temporary_path is not None and temporary_path.exists(): + temporary_path.unlink() + + +def _write_unique_failure_receipt(artifacts: Path, receipt: Mapping[str, Any]) -> Path: + """Create a no-clobber sidecar when the requested artifact path is busy.""" + + for attempt in range(1, 10_001): + suffix = "" if attempt == 1 else f".{attempt}" + candidate = artifacts.parent / f"{artifacts.name}.failure-receipt{suffix}.json" + try: + _write_receipt_atomically(candidate, receipt) + except FileExistsError: + continue + return candidate + raise BundleVerificationError("could not allocate a unique failure receipt path") + + +def verify( + *, + artifacts_dir: Path, + backtrader_root: Path, + sdk_root: Path = SDK_ROOT, + controller_python: str = sys.executable, +) -> dict[str, Any]: + """Build and consume the complete local capability matrix. + + Raises :class:`BundleVerificationError` on a missing dependency, failed + build, non-reproducible wheel, failed installation, or consumer failure. + """ + + artifacts_dir = artifacts_dir.resolve(strict=False) + if artifacts_dir.exists(): + if any(artifacts_dir.iterdir()): + raise BundleVerificationError( + f"artifacts directory must be empty: {artifacts_dir}" + ) + else: + artifacts_dir.mkdir(parents=True, exist_ok=False) + sdk_root = sdk_root.resolve(strict=True) + backtrader_root = backtrader_root.resolve(strict=True) + specs = _project_specs(sdk_root, backtrader_root) + wheelhouse = artifacts_dir / "wheelhouse" + logs_dir = artifacts_dir / "logs" + wheels: dict[str, Path] = {} + wheel_receipts: dict[str, dict[str, Any]] = {} + # As with the installed-consumer venv, stage builds in the system temp + # directory rather than below an arbitrarily deep artifact path. The + # Backtrader source contains legitimate long indicator filenames that can + # exceed legacy Windows MAX_PATH when a pytest tmp directory is nested. + # Wheel/log outputs still remain in ``artifacts_dir``. + with tempfile.TemporaryDirectory(prefix="i41b-") as temp: + work_dir = Path(temp) + frozen_root = work_dir / "frozen-sources" + frozen_root.mkdir() + snapshots = { + project.key: _capture_source_snapshot(project, frozen_root / project.key) + for project in specs + } + controller_requirements = { + project.key: _controller_requirement_receipt( + project, + source_root=snapshots[project.key].staged_root, + ) + for project in specs + } + for project in specs: + wheel, receipt = _build_reproducible_wheel( + snapshots[project.key], + controller_python=controller_python, + work_dir=work_dir, + wheelhouse=wheelhouse, + logs_dir=logs_dir, + ) + wheels[project.key] = wheel + wheel_receipts[project.key] = receipt + dependency_wheels = _repackage_dependency_closure( + local_wheels={ + project.distribution: wheels[project.key] for project in specs + }, + roots=[(wheel, ()) for wheel in wheels.values()], + wheelhouse=wheelhouse, + ) + # A project captured early must also remain unchanged while later + # projects and the controller dependency closure are built. + for snapshot in snapshots.values(): + _recheck_source_snapshot(snapshot) + consumer = _isolated_consumer( + controller_python=controller_python, + artifacts_dir=artifacts_dir, + wheelhouse=wheelhouse, + local_wheels=wheels, + projects=specs, + ) + for snapshot in snapshots.values(): + _recheck_source_snapshot(snapshot) + # Capture Git facts only after the final source-byte recheck. These remain + # local observations, never a reviewed/published provenance assertion. + source_states = { + project.key: _git_source_state(project, sdk_root) for project in specs + } + receipt = { + "schema_version": 2, + "generated_at": _utc_now(), + "purpose": "local-only Iteration 41 wheel-install and isolated-consumer mechanics", + "result": LOCAL_ONLY_RESULT, + "local_validation": "PASSED", + "release_status": "NOT_RELEASE_ELIGIBLE", + "controller_python": controller_python, + "offline_constraints": { + "pip_no_index": True, + "pip_config_file": os.devnull, + "source_date_epoch": SOURCE_DATE_EPOCH, + "consumer_python_isolated": True, + "consumer_inherited_pythonpath": False, + "minimal_child_environment": True, + "controller_environment_redaction_sentinel": _REDACTION_SENTINEL_ENV, + "consumer_python_socket_guard": True, + "os_network_firewall_verified": False, + }, + "source_states": source_states, + "source_snapshots": { + key: { + "sha256": snapshot.digest, + "file_count": len(snapshot.files), + "captured_from_bytes": True, + "original_source_recheck": "PASSED_BEFORE_AND_AFTER_BUILD", + } + for key, snapshot in snapshots.items() + }, + "controller_build_requirements": controller_requirements, + "local_wheels": wheel_receipts, + "repackaged_dependencies": dependency_wheels, + "wheelhouse_manifest": _wheelhouse_manifest(wheelhouse), + "consumer": consumer, + "limitations": _release_limitations(source_states, dependency_wheels), + } + _write_receipt_atomically(artifacts_dir / "receipt.json", receipt) + return receipt + + +def _failed_receipt( + error: BaseException, *, requested_artifacts_dir: Path +) -> dict[str, Any]: + return { + "schema_version": 2, + "generated_at": _utc_now(), + "purpose": "local-only Iteration 41 wheel-install and isolated-consumer mechanics", + "result": FAILED_RESULT, + "local_validation": "FAILED", + "release_status": "NOT_RELEASE_ELIGIBLE", + "error": _redact_text(f"{type(error).__name__}: {error}"), + "requested_artifacts_dir": str(requested_artifacts_dir), + "limitations": [ + "FAILED_LOCAL_VALIDATION_IS_NOT_A_RELEASE_OR_PROVIDER_ADMISSION_DECISION" + ], + } + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--artifacts-dir", type=Path, required=True) + parser.add_argument("--backtrader-root", type=Path, required=True) + parser.add_argument("--sdk-root", type=Path, default=SDK_ROOT) + parser.add_argument("--python", default=sys.executable) + arguments = parser.parse_args(argv) + artifacts = arguments.artifacts_dir.resolve(strict=False) + artifacts_initially_reusable = not artifacts.exists() or ( + artifacts.is_dir() and not any(artifacts.iterdir()) + ) + try: + receipt = verify( + artifacts_dir=arguments.artifacts_dir, + backtrader_root=arguments.backtrader_root, + sdk_root=arguments.sdk_root, + controller_python=arguments.python, + ) + except ( + Exception + ) as error: # A parser or unexpected local failure must remain non-PASS. + receipt = _failed_receipt(error, requested_artifacts_dir=artifacts) + if artifacts_initially_reusable: + artifacts.mkdir(parents=True, exist_ok=True) + receipt_path = artifacts / "receipt.json" + try: + _write_receipt_atomically(receipt_path, receipt) + except FileExistsError: + _write_unique_failure_receipt(artifacts, receipt) + else: + # Never overwrite an operator's nonempty evidence directory. Put + # this invocation's atomic failure receipt beside it instead. + _write_unique_failure_receipt(artifacts, receipt) + print(receipt["error"], file=sys.stderr) + return 1 + print( + json.dumps( + {"result": receipt["result"], "local_validation": "PASSED"}, sort_keys=True + ) + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/install_bt_api_submodules.py b/scripts/install_bt_api_submodules.py index 811b7c20..f6ee5a4b 100755 --- a/scripts/install_bt_api_submodules.py +++ b/scripts/install_bt_api_submodules.py @@ -151,7 +151,10 @@ def filter_packages(specs: list[PackageSpec], selected: list[str] | None) -> lis if not selected: return specs - selected_names = {item.removeprefix("bt_api/").strip() for item in selected} + selected_names = { + (item[len("bt_api/") :] if item.startswith("bt_api/") else item).strip() + for item in selected + } selected_names = { item if item.startswith("bt_api_") else f"bt_api_{item}" for item in selected_names } diff --git a/tests/bt_api_contract/test_ctp_dependency_version_pin.py b/tests/bt_api_contract/test_ctp_dependency_version_pin.py new file mode 100644 index 00000000..d37dec18 --- /dev/null +++ b/tests/bt_api_contract/test_ctp_dependency_version_pin.py @@ -0,0 +1,50 @@ +from __future__ import annotations + +import tomllib +from pathlib import Path + +from packaging.requirements import Requirement +from packaging.version import Version + +ROOT = Path(__file__).resolve().parents[2] +CTP_ROOT = ROOT / "bt_api" / "bt_api_ctp" + + +def test_core_reference_rejects_the_stale_ctp_wheel() -> None: + sdk_metadata = tomllib.loads((ROOT / "pyproject.toml").read_text(encoding="utf-8")) + requirement_text = next( + entry + for entry in sdk_metadata["project"]["optional-dependencies"]["core-reference"] + if entry.lower().startswith("bt_api_ctp") + ) + requirement = Requirement(requirement_text) + + assert requirement.specifier.contains("2.0.2") is False + assert requirement.specifier.contains("2.0.3") is True + + +def test_ctp_candidate_version_matches_the_sdk_minimum() -> None: + ctp_metadata = tomllib.loads((CTP_ROOT / "pyproject.toml").read_text(encoding="utf-8")) + package_init = (CTP_ROOT / "src" / "bt_api_ctp" / "__init__.py").read_text(encoding="utf-8") + bundle_config = tomllib.loads( + (ROOT / "bt_api_py" / "configs" / "exchange-bundles.toml").read_text(encoding="utf-8") + ) + ctp_bundle = next( + venue + for venue in bundle_config["bundles"]["core-reference"]["venues"] + if venue["package"] == "bt_api_ctp" + ) + candidate = Version(ctp_metadata["project"]["version"]) + required = Version(ctp_bundle["min_version"]) + core_reference = next( + Requirement(entry) + for entry in tomllib.loads((ROOT / "pyproject.toml").read_text(encoding="utf-8"))[ + "project" + ]["optional-dependencies"]["core-reference"] + if entry.lower().startswith("bt_api_ctp") + ) + + assert f'__version__ = "{candidate}"' in package_init + assert candidate >= Version("2.0.3") + assert candidate >= required + assert core_reference.specifier.contains(str(candidate)) diff --git a/tests/bt_api_contract/test_ctp_entry_approval_arm.py b/tests/bt_api_contract/test_ctp_entry_approval_arm.py index c09998c8..bd1de43e 100644 --- a/tests/bt_api_contract/test_ctp_entry_approval_arm.py +++ b/tests/bt_api_contract/test_ctp_entry_approval_arm.py @@ -5,6 +5,7 @@ import base64 import hashlib import json +import os from datetime import UTC, datetime, timedelta from decimal import Decimal # noqa: F401 (parity with sibling fixtures) @@ -28,7 +29,7 @@ _install_account_stream, _ManagedFeed, _ready_state, - _session, + _session as _execution_session, ) from .test_execution_recovery import ( CYCLE, @@ -38,6 +39,11 @@ ENTRY_PURPOSE = "ctp_execution_approval" +def _session(tmp_path): + """Build a test session with a pre-created Windows CTP journal when needed.""" + return _execution_session(tmp_path, provisioned=os.name == "nt") + + @pytest.fixture() def signing_material(): ed25519 = pytest.importorskip("cryptography.hazmat.primitives.asymmetric.ed25519") diff --git a/tests/bt_api_contract/test_ctp_entry_write_guard.py b/tests/bt_api_contract/test_ctp_entry_write_guard.py new file mode 100644 index 00000000..7dd14937 --- /dev/null +++ b/tests/bt_api_contract/test_ctp_entry_write_guard.py @@ -0,0 +1,498 @@ +"""Per-write CTP entry-approval revalidation at the session dispatch fence.""" + +from __future__ import annotations + +import hashlib +import os +from dataclasses import replace +from types import SimpleNamespace + +import pytest + +from bt_api_py import NormalizedApiError + +from .test_ctp_entry_approval_arm import ( + ENTRY_PURPOSE, + ENTRY_SCHEMA, + _entry_payload, + _iso, + _signed_entry_artifact, +) +from .test_ctp_execution_approval import _context as _approval_context_seed +from .test_execution_arming import ( + ACCOUNT_FINGERPRINT, + BUNDLE_INSTRUMENTS, + PROFILE, + STRATEGY_IDENTITY, + TRADING_DAY, + VENUE, + _api_for_arm, + _arm, + _bound_order, + _install_account_stream, + _ManagedFeed, + _ready_state, +) +from .test_execution_arming import ( + _proof as _legacy_proof, +) +from .test_execution_recovery import CYCLE + + +@pytest.fixture() +def entry_signing_material(): + from datetime import UTC, datetime, timedelta + + ed25519 = pytest.importorskip("cryptography.hazmat.primitives.asymmetric.ed25519") + private_key = ed25519.Ed25519PrivateKey.generate() + public_key = private_key.public_key().public_bytes_raw() + root = { + "schema_version": "ctp-execution-trust-root-v1", + "keys": { + "operator-entry-1": { + "public_key": __import__("base64") + .urlsafe_b64encode(public_key) + .decode("ascii") + .rstrip("="), + "role": "independent_operator", + "purposes": [ENTRY_PURPOSE, "ctp_execution_recovery"], + "not_before": _iso(datetime.now(UTC) - timedelta(minutes=1)), + "expires_at": _iso(datetime.now(UTC) + timedelta(hours=1)), + } + }, + "revocation_snapshot": { + "version": 1, + "issued_at": _iso(datetime.now(UTC) - timedelta(minutes=1)), + "expires_at": _iso(datetime.now(UTC) + timedelta(hours=1)), + "revoked_approval_ids": [], + "revoked_nonces": [], + }, + } + return private_key, root + + +def _runtime_entry_payload(context_values): + from bt_api_py._ctp_execution_authorization import SIMNOW_ENTRY_APPROVAL_SCHEMA_VERSION + + payload = _entry_payload() + payload.update( + { + key: value + for key, value in context_values.items() + if key != "source" + } + ) + payload["schema_version"] = ( + SIMNOW_ENTRY_APPROVAL_SCHEMA_VERSION + if "credential_binding_key_id" in context_values + else ENTRY_SCHEMA + ) + payload["purpose"] = ENTRY_PURPOSE + payload["candidate_id"] = "candidate-iter23-25" + payload["strategy_id"] = "iter22-midfreq" + payload["execution_cycle_id"] = CYCLE + payload["authorized_instruments"] = [ + {"exchange_id": "CZCE", "instrument_id": "SA701"}, + {"exchange_id": "CZCE", "instrument_id": "SA701C1080"}, + {"exchange_id": "CZCE", "instrument_id": "SA701P1080"}, + ] + payload["primary_instrument"] = {"exchange_id": "CZCE", "instrument_id": "SA701"} + # The proof extension is signed independently from the context collector. + payload.setdefault("receipt_sha256", "1" * 64) + payload.setdefault("source_hashes_sha256", "4" * 64) + payload.setdefault("ctp_package_sha256", "3" * 64) + payload["bt_api_ctp_sha256"] = payload["ctp_package_sha256"] + return payload + + +def _runtime_entry_fixture( + monkeypatch, + tmp_path, + entry_signing_material, + name, + *, + profile=PROFILE, + credential_binding_provider=None, + arm=True, +): + from bt_api_py import _execution_session as session_module + from bt_api_py._contracts import TransportMode + from bt_api_py.bt_api import BtApi + + private_key, trust_root = entry_signing_material + monkeypatch.setattr( + session_module, + "_ledger_registry_root", + lambda: tmp_path / f"{name}-ledger-registry", + ) + strategy_path = tmp_path / f"{name}-strategy.py" + strategy_path.write_text("def strategy(): return 'approved-v1'\n", encoding="utf-8") + strategy_identity = hashlib.sha256(strategy_path.read_bytes()).hexdigest() + journal_path = tmp_path / f"{name}.jsonl" + provisioned = os.name == "nt" + if provisioned: + journal_path.touch() + session = session_module._ExecutionSession( + { + "market_data_only": True, + "require_order_journal": True, + "order_journal": str(journal_path), + "windows_ctp_journal_preprovisioned": provisioned, + "account_ids": {}, + "required_environments": {VENUE: "demo"}, + "strategy_id": "iter22-midfreq", + "strategy_identity_sha256": strategy_identity, + "account_maximum_loss_bps": None, + "account_risk_max_age_seconds": "2", + }, + exchange_names=(VENUE,), + ) + state = _ready_state(environment_profile=profile) + feed = _ManagedFeed(state) + feed.get_environment_info = lambda: { + "verified": True, + "environment": "demo", + "profile": state["environment_profile"], + } + td_front = "tcp://synthetic-td" + md_front = "tcp://synthetic-md" + feed._execution_bound_td_front = td_front + feed._execution_bound_md_front = md_front + feed._trader = SimpleNamespace( + front=td_front, + _bound_front=td_front, + _session_native_front=td_front, + _connection_generation=state["connection_generation"], + ) + feed._md_client = SimpleNamespace(front=md_front, connection_generation=11) + feed._md_stream_generation = 4 + api = object.__new__(BtApi) + api.transport_mode = TransportMode.DIRECT + api.exchange_feeds = {VENUE: feed} + api.exchange_kwargs = {VENUE: {"auto_settlement_confirm": False}} + api.data_queues = {VENUE: __import__("queue").Queue()} + api._subscription_streams = [] + api._subscription_flags = {} + api._execution_session = session + api._ctp_execution_capability = object() + api._ctp_private_ingress_fences = {} + api._ctp_private_ingress_queues = {} + feed.configure_execution_gate(api._ctp_execution_capability) + api.list_exchanges = lambda: [VENUE] + api.get_ctp_session_state = lambda _exchange: feed.get_session_state() + api.get_environment_info = lambda _exchange: feed.get_environment_info() + api._ctp_execution_runtime_identity = lambda: { + "native_sha256": "2" * 64, + "ctp_package_sha256": "3" * 64, + } + api._ctp_execution_runtime_python_identity = lambda: { + "backtrader_sha256": "9" * 64, + "bt_api_py_sha256": "a" * 64, + "bt_api_base_sha256": "b" * 64, + "dependency_hashes_sha256": "5" * 64, + } + + seed = _approval_context_seed() + seed.update( + { + "candidate_id": "candidate-iter23-25", + "strategy_id": "iter22-midfreq", + "strategy_identity_sha256": STRATEGY_IDENTITY, + "execution_cycle_id": CYCLE, + "authorized_instruments": [ + {"exchange_id": "CZCE", "instrument_id": item.split(".", 1)[1]} + for item in BUNDLE_INSTRUMENTS + ], + "primary_instrument": {"exchange_id": "CZCE", "instrument_id": "SA701"}, + "account_fingerprint": ACCOUNT_FINGERPRINT, + "trading_day": TRADING_DAY, + "connection_generation": 3, + "environment_profile": profile, + "native_sha256": "2" * 64, + "bt_api_ctp_sha256": "3" * 64, + "backtrader_sha256": "9" * 64, + "bt_api_py_sha256": "a" * 64, + "bt_api_base_sha256": "b" * 64, + "dependency_hashes_sha256": "5" * 64, + } + ) + credential_binding_verifier = None + if credential_binding_provider is not None: + from bt_api_py.bt_api import _issue_ctp_controlled_test_authority_for_core + + credential_binding_verifier = api._create_ctp_credential_binding_verifier_for_test( + credential_binding_provider, + authority=_issue_ctp_controlled_test_authority_for_core(), + ) + context = api.build_ctp_execution_approval_context( + seed, + exchange_name=VENUE, + configuration={"mode": "synthetic-read-only"}, + strategy_source=strategy_path, + preflight={"complete": True}, + evidence={"complete": True}, + credential_binding_verifier=credential_binding_verifier, + ) + payload = _runtime_entry_payload(context.as_dict()) + artifact = _signed_entry_artifact(payload, private_key) + if payload["schema_version"] != ENTRY_SCHEMA: + artifact_value = __import__("json").loads(artifact) + artifact_value["schema_version"] = payload["schema_version"] + artifact = __import__("json").dumps( + artifact_value, + ensure_ascii=False, + sort_keys=True, + separators=(",", ":"), + ).encode("utf-8") + capability = api.redeem_ctp_execution_approval( + artifact, + trust_root=trust_root, + context=context, + ) + if arm: + _install_account_stream(monkeypatch) + try: + api.arm_execution_from_approval(capability) + except Exception: + session.close() + raise + return api, session, feed, capability, payload, strategy_path + + +def _bound_entry_order(session): + request, budget, binding = _bound_order( + session, + session._arm_proof, + "SA701", + order_ref_number=101, + exchange_id="CZCE", + cycle=CYCLE, + ) + request = replace( + request, + strategy_identity_sha256=session.config["strategy_identity_sha256"], + ) + return request, budget, binding + + +@pytest.mark.parametrize( + ("mutation", "expected_code"), + [ + ("account", "ctp_approval_context_mismatch"), + ("trading_day", "ctp_approval_context_mismatch"), + ("generation", "ctp_approval_context_mismatch"), + ("artifact", "ctp_approval_context_mismatch"), + ("approval", "ctp_execution_authorization_material_mismatch"), + ], +) +def test_entry_submit_revalidates_approval_after_intent_before_req_order_insert( + monkeypatch, tmp_path, entry_signing_material, mutation, expected_code +): + api, session, feed, capability, _payload, strategy_path = _runtime_entry_fixture( + monkeypatch, tmp_path, entry_signing_material, f"submit-{mutation}" + ) + request, budget, _binding = _bound_entry_order(session) + native_calls = [] + + def drift_after_first_gate(): + if mutation == "account": + feed._session_state["account_fingerprint"] = "f" * 16 + elif mutation == "trading_day": + feed._session_state["trading_day"] = "20260910" + elif mutation == "generation": + feed._session_state["connection_generation"] = 4 + elif mutation == "artifact": + strategy_path.write_text("def strategy(): return 'changed-v2'\n", encoding="utf-8") + else: + approval = capability._approval + object.__setattr__(approval, "signature", b"changed-approval-signature") + + def ReqOrderInsert(): + native_calls.append("ReqOrderInsert") + return {"order_id": "must-not-be-created"} + + try: + result = session.invoke( + "make_order", + VENUE, + request, + ReqOrderInsert, + preauthorize=drift_after_first_gate, + pre_dispatch=session.finalize_dispatch, + budget_capability=budget, + ) + assert result["error_code"] == expected_code + assert result["definite_reject"] is True + assert native_calls == [] + assert session.config["market_data_only"] is True + finally: + session.close() + + +def test_entry_cancel_revalidates_live_generation_before_req_order_action( + monkeypatch, tmp_path, entry_signing_material +): + _api, session, feed, _capability, _payload, _strategy_path = _runtime_entry_fixture( + monkeypatch, tmp_path, entry_signing_material, "cancel-generation" + ) + _order_request, budget, binding = _bound_entry_order(session) + client_order_id = binding["client_order_id"] + runtime_order_id = binding["runtime_order_id"] + session.orders[(VENUE, client_order_id)] = { + "symbol": "SA701", + "exchange_name": VENUE, + "exchange_id": "CZCE", + "account_id": ACCOUNT_FINGERPRINT, + "client_order_id": client_order_id, + "runtime_order_id": runtime_order_id, + "execution_cycle_id": CYCLE, + "connection_generation": session._arm_proof["connection_generation"], + "terminal": False, + } + action_id = session.next_runtime_action_id( + VENUE, + account_id=ACCOUNT_FINGERPRINT, + runtime_order_id=runtime_order_id, + ) + from bt_api_py import CancelOrderRequest + + request = CancelOrderRequest( + symbol="SA701", + account_id=ACCOUNT_FINGERPRINT, + client_order_id=client_order_id, + exchange_id="CZCE", + runtime_order_id=runtime_order_id, + runtime_action_id=action_id, + ) + native_calls = [] + + def drift_after_first_gate(): + feed._session_state["connection_generation"] = 4 + + def ReqOrderAction(): + native_calls.append("ReqOrderAction") + return {"order_id": "must-not-be-cancelled"} + + try: + result = session.invoke( + "cancel_order", + VENUE, + request, + ReqOrderAction, + preauthorize=drift_after_first_gate, + pre_dispatch=session.finalize_dispatch, + budget_capability=budget, + ) + assert result["error_code"] == "ctp_approval_context_mismatch" + assert native_calls == [] + finally: + session.close() + + +def test_official_simnow_credential_tag_drift_stays_closed_before_native_write( + monkeypatch, tmp_path, entry_signing_material +): + """A changing credential tag cannot turn the intentionally closed SimNow arm on.""" + binding = { + "credential_binding_key_id": "runtime-binding-key-1", + "credential_binding_hmac_sha256": "a" * 64, + } + api, session, feed, capability, _payload, _strategy_path = _runtime_entry_fixture( + monkeypatch, + tmp_path, + entry_signing_material, + "entry-set1-tag-drift", + profile="set1_group1", + credential_binding_provider=lambda: dict(binding), + arm=False, + ) + binding["credential_binding_hmac_sha256"] = "b" * 64 + try: + with pytest.raises(NormalizedApiError) as raised: + api.arm_execution_from_approval(capability) + assert raised.value.code == "ctp_simnow_execution_not_admitted" + assert feed._session_state["environment_profile"] == "set1_group1" + finally: + session.close() + + +def test_managed_ctp_without_sealed_entry_guard_and_caller_lambda_stay_closed( + monkeypatch, tmp_path +): + from bt_api_py import _execution_session as session_module + + monkeypatch.setattr( + session_module, + "_ledger_registry_root", + lambda: tmp_path / "legacy-entry-ledger-registry", + ) + _install_account_stream(monkeypatch) + api, session, _state = _api_for_arm(tmp_path) + try: + _arm(api, _legacy_proof()) + native_calls = [] + with pytest.raises(NormalizedApiError) as missing: + api._finalize_ctp_execution_dispatch( + session, + VENUE, + {"operation": "make_order"}, + ) + assert missing.value.code == "ctp_entry_authorization_guard_invalid" + assert native_calls == [] + + finally: + session.close() + + forged_api, forged_session, _state = _api_for_arm(tmp_path) + try: + _arm(forged_api, _legacy_proof()) + forged_session._entry_write_guard = lambda *_args, **_kwargs: None + native_calls = [] + with pytest.raises(NormalizedApiError) as forged: + forged_api._finalize_ctp_execution_dispatch( + forged_session, + VENUE, + {"operation": "cancel_order"}, + ) + assert forged.value.code == "ctp_entry_authorization_guard_invalid" + assert native_calls == [] + finally: + forged_session.close() + + +@pytest.mark.asyncio +async def test_async_worker_rechecks_generation_at_req_order_insert_handoff( + monkeypatch, tmp_path, entry_signing_material +): + _api, session, feed, _capability, _payload, _strategy_path = _runtime_entry_fixture( + monkeypatch, tmp_path, entry_signing_material, "async-handoff" + ) + request, budget, _binding = _bound_entry_order(session) + native_calls = [] + held = {} + + def bind_context(context): + held["context"] = context + + async def queued_worker(): + feed._session_state["connection_generation"] = 4 + held["context"]["_async_handoff"] = True + session.finalize_dispatch(held["context"]) + native_calls.append("ReqOrderInsert") + return {"order_id": "must-not-be-created"} + + try: + result = await session.async_invoke( + "make_order", + VENUE, + request, + queued_worker, + pre_dispatch=session.finalize_dispatch, + on_context=bind_context, + budget_capability=budget, + ) + assert result["error_code"] == "ctp_approval_context_mismatch" + assert result["definite_reject"] is True + assert native_calls == [] + finally: + session.close() diff --git a/tests/bt_api_contract/test_ctp_execution_approval.py b/tests/bt_api_contract/test_ctp_execution_approval.py index ce13cd57..6a05dc99 100644 --- a/tests/bt_api_contract/test_ctp_execution_approval.py +++ b/tests/bt_api_contract/test_ctp_execution_approval.py @@ -7,6 +7,7 @@ from __future__ import annotations +import asyncio import base64 import json import os @@ -24,13 +25,13 @@ ALGORITHM = "Ed25519" KEY_ID = "operator-test-1" FIXED_PUBLIC_KEY_B64 = "11qYAYKxCrfVS_7TyWQHOg7hcvPapiMlrwIaaPcHURo" -FIXED_SIGNATURE_B64 = ( - "TwiANcyIexBY9hl7zaPjEXEvWy5jLUX048sPwV9kWL5kQpuSUrexNuWbljub_G0QeGYzdIkSDZFZbhRVAJiyCQ" -) +FIXED_SIGNATURE_B64 = "TwiANcyIexBY9hl7zaPjEXEvWy5jLUX048sPwV9kWL5kQpuSUrexNuWbljub_G0QeGYzdIkSDZFZbhRVAJiyCQ" def _iso(value: datetime) -> str: - return value.astimezone(UTC).isoformat(timespec="microseconds").replace("+00:00", "Z") + return ( + value.astimezone(UTC).isoformat(timespec="microseconds").replace("+00:00", "Z") + ) def _context() -> dict: @@ -99,10 +100,12 @@ def _signed_artifact(payload, private_key) -> bytes: signature = private_key.sign(payload_bytes) return json.dumps( { - "schema_version": SCHEMA, + "schema_version": payload["schema_version"], "algorithm": ALGORITHM, "payload": payload, - "signature": base64.urlsafe_b64encode(signature).decode("ascii").rstrip("="), + "signature": base64.urlsafe_b64encode(signature) + .decode("ascii") + .rstrip("="), }, ensure_ascii=False, sort_keys=True, @@ -119,7 +122,9 @@ def signing_material(): "schema_version": "ctp-execution-trust-root-v1", "keys": { KEY_ID: { - "public_key": base64.urlsafe_b64encode(public_key).decode("ascii").rstrip("="), + "public_key": base64.urlsafe_b64encode(public_key) + .decode("ascii") + .rstrip("="), "role": "independent_operator", "purposes": ["ctp_execution_approval"], "not_before": _iso(datetime.now(UTC) - timedelta(minutes=1)), @@ -137,6 +142,31 @@ def signing_material(): return private_key, root +@pytest.fixture(autouse=True) +def provision_windows_ctp_test_journals(monkeypatch): + """Pre-create test journals structurally for the Windows CTP contract only.""" + if os.name != "nt": + return + + original_init = BtApi.__init__ + + def init_with_provisioned_journal(self, *args, execution_config=None, **kwargs): + if isinstance(execution_config, dict) and execution_config.get("order_journal"): + execution_config = dict(execution_config) + journal = Path(execution_config["order_journal"]) + journal.parent.mkdir(parents=True, exist_ok=True) + journal.touch(exist_ok=True) + execution_config["windows_ctp_journal_preprovisioned"] = True + return original_init( + self, + *args, + execution_config=execution_config, + **kwargs, + ) + + monkeypatch.setattr(BtApi, "__init__", init_with_provisioned_journal) + + def test_positive_verification_binds_complete_context(signing_material): private_key, root = signing_material from bt_api_py._ctp_execution_authorization import ( @@ -150,9 +180,9 @@ def test_positive_verification_binds_complete_context(signing_material): ) assert result.approval_id == "approval-u1a-1" assert result.payload["connection_generation"] == 7 - assert [dict(item) for item in result.payload["authorized_instruments"]] == _context()[ - "authorized_instruments" - ] + assert [ + dict(item) for item in result.payload["authorized_instruments"] + ] == _context()["authorized_instruments"] def test_non_synthetic_context_cannot_self_assert_runtime_identity(signing_material): @@ -172,7 +202,9 @@ def test_non_synthetic_context_cannot_self_assert_runtime_identity(signing_mater assert raised.value.code == "ctp_approval_context_untrusted" -def test_runtime_context_builder_seals_and_recomputes_identity(monkeypatch, signing_material): +def test_runtime_context_builder_seals_and_recomputes_identity( + monkeypatch, signing_material +): from bt_api_py import BtApi from bt_api_py._ctp_execution_authorization import CtpExecutionApprovalContext @@ -237,7 +269,9 @@ def test_runtime_context_builder_seals_and_recomputes_identity(monkeypatch, sign assert values["connection_generation"] == 9 assert values["account_fingerprint"] == "acct_1234567890abcdef" private_key, root = signing_material - payload = _payload(**{field: value for field, value in values.items() if field != "source"}) + payload = _payload( + **{field: value for field, value in values.items() if field != "source"} + ) verified = api.verify_ctp_execution_approval( _signed_artifact(payload, private_key), trust_root=root, context=context ) @@ -390,7 +424,9 @@ def test_tampered_or_wrong_purpose_artifact_is_rejected(signing_material, change ("future_reservation_id", "other-reservation"), ], ) -def test_every_context_binding_is_compared_to_the_signed_payload(signing_material, field, value): +def test_every_context_binding_is_compared_to_the_signed_payload( + signing_material, field, value +): private_key, root = signing_material from bt_api_py._ctp_execution_authorization import verify_ctp_execution_approval @@ -548,7 +584,9 @@ def test_same_journal_allows_one_durable_redemption_then_rejects_restart_replay( verified = api.verify_ctp_execution_approval( _signed_artifact(_payload(), private_key), trust_root=root, context=_context() ) - capability = api.redeem_ctp_execution_approval(verified, trust_root=root, context=_context()) + capability = api.redeem_ctp_execution_approval( + verified, trust_root=root, context=_context() + ) assert capability.approval_id == verified.approval_id assert api.get_execution_summary()["market_data_only"] is True api.close() @@ -560,7 +598,9 @@ def test_same_journal_allows_one_durable_redemption_then_rejects_restart_replay( } ) with pytest.raises(NormalizedApiError) as raised: - recovered.redeem_ctp_execution_approval(verified, trust_root=root, context=_context()) + recovered.redeem_ctp_execution_approval( + verified, trust_root=root, context=_context() + ) assert raised.value.code == "ctp_approval_already_consumed" recovered.close() @@ -584,7 +624,9 @@ def test_public_redeem_can_verify_a_signed_artifact_in_one_explicit_step( api.close() -def test_two_threads_cannot_double_spend_same_approval(signing_material, tmp_path: Path): +def test_two_threads_cannot_double_spend_same_approval( + signing_material, tmp_path: Path +): private_key, root = signing_material api = BtApi( execution_config={ @@ -598,7 +640,9 @@ def test_two_threads_cannot_double_spend_same_approval(signing_material, tmp_pat def redeem(): try: - api.redeem_ctp_execution_approval(verified, trust_root=root, context=_context()) + api.redeem_ctp_execution_approval( + verified, trust_root=root, context=_context() + ) return "ok" except NormalizedApiError as exc: return exc.code @@ -610,11 +654,17 @@ def redeem(): api.close() -def test_two_api_instances_cannot_double_spend_same_journal_nonce(signing_material, tmp_path: Path): +def test_two_api_instances_cannot_double_spend_same_journal_nonce( + signing_material, tmp_path: Path +): private_key, root = signing_material journal = tmp_path / "two-api.jsonl" - first = BtApi(execution_config={"market_data_only": True, "order_journal": str(journal)}) - second = BtApi(execution_config={"market_data_only": True, "order_journal": str(journal)}) + first = BtApi( + execution_config={"market_data_only": True, "order_journal": str(journal)} + ) + second = BtApi( + execution_config={"market_data_only": True, "order_journal": str(journal)} + ) artifact = _signed_artifact(_payload(), private_key) verified = [ api.verify_ctp_execution_approval(artifact, trust_root=root, context=_context()) @@ -623,7 +673,9 @@ def test_two_api_instances_cannot_double_spend_same_journal_nonce(signing_materi def redeem(api, approval): try: - return api.redeem_ctp_execution_approval(approval, trust_root=root, context=_context()) + return api.redeem_ctp_execution_approval( + approval, trust_root=root, context=_context() + ) except NormalizedApiError as exc: return exc @@ -635,7 +687,13 @@ def redeem(api, approval): verified, ) ) - assert sum(type(result).__name__ == "CtpExecutionApprovalCapability" for result in results) == 1 + assert ( + sum( + type(result).__name__ == "CtpExecutionApprovalCapability" + for result in results + ) + == 1 + ) failures = [result for result in results if isinstance(result, NormalizedApiError)] assert len(failures) == 1 assert failures[0].code in { @@ -651,11 +709,15 @@ def test_read_only_preauthorization_is_really_persisted_and_generation_is_not_ov ): private_key, root = signing_material journal = tmp_path / "readonly.jsonl" - api = BtApi(execution_config={"market_data_only": True, "order_journal": str(journal)}) + api = BtApi( + execution_config={"market_data_only": True, "order_journal": str(journal)} + ) verified = api.verify_ctp_execution_approval( _signed_artifact(_payload(), private_key), trust_root=root, context=_context() ) - api.preauthorize_ctp_execution_approval(verified, trust_root=root, context=_context()) + api.preauthorize_ctp_execution_approval( + verified, trust_root=root, context=_context() + ) row = json.loads(journal.read_text().splitlines()[-1]) assert row["event"] == "ctp_execution_approval_pre_authorized" assert row["connection_generation"] == 7 @@ -684,7 +746,9 @@ def test_revocation_snapshot_version_is_fenced_by_the_same_journal( ): private_key, root = signing_material journal = tmp_path / "revocations.jsonl" - api = BtApi(execution_config={"market_data_only": False, "order_journal": str(journal)}) + api = BtApi( + execution_config={"market_data_only": False, "order_journal": str(journal)} + ) first = api.verify_ctp_execution_approval( _signed_artifact(_payload(), private_key), trust_root=root, context=_context() ) @@ -696,19 +760,25 @@ def test_revocation_snapshot_version_is_fenced_by_the_same_journal( newer_payload = _payload( approval_id="approval-u1a-2", nonce="nonce-u1a-2", revocation_snapshot_version=4 ) - newer = BtApi(execution_config={"market_data_only": False, "order_journal": str(journal)}) + newer = BtApi( + execution_config={"market_data_only": False, "order_journal": str(journal)} + ) verified_newer = newer.verify_ctp_execution_approval( _signed_artifact(newer_payload, private_key), trust_root=newer_root, context=_context(), ) - newer.redeem_ctp_execution_approval(verified_newer, trust_root=newer_root, context=_context()) + newer.redeem_ctp_execution_approval( + verified_newer, trust_root=newer_root, context=_context() + ) newer.close() # A restart must retain the higher durable snapshot version. Replaying # an otherwise valid artifact against an older operator snapshot is a # rollback, even though its Ed25519 signature still verifies. - old = BtApi(execution_config={"market_data_only": False, "order_journal": str(journal)}) + old = BtApi( + execution_config={"market_data_only": False, "order_journal": str(journal)} + ) with pytest.raises(NormalizedApiError) as raised: old.redeem_ctp_execution_approval(first, trust_root=root, context=_context()) assert raised.value.code in { @@ -726,7 +796,9 @@ def test_fsync_failure_never_returns_a_consumable_capability( from bt_api_py import _execution_session as session_module journal = tmp_path / "fsync.jsonl" - api = BtApi(execution_config={"market_data_only": False, "order_journal": str(journal)}) + api = BtApi( + execution_config={"market_data_only": False, "order_journal": str(journal)} + ) verified = api.verify_ctp_execution_approval( _signed_artifact(_payload(), private_key), trust_root=root, context=_context() ) @@ -749,7 +821,9 @@ def test_consumption_started_fences_restart_after_completion_failure( ): private_key, root = signing_material journal = tmp_path / "pending.jsonl" - api = BtApi(execution_config={"market_data_only": True, "order_journal": str(journal)}) + api = BtApi( + execution_config={"market_data_only": True, "order_journal": str(journal)} + ) verified = api.verify_ctp_execution_approval( _signed_artifact(_payload(), private_key), trust_root=root, context=_context() ) @@ -758,7 +832,9 @@ def test_consumption_started_fences_restart_after_completion_failure( def fail_completion(event, row, **kwargs): if event == "ctp_execution_approval_consumed": - raise NormalizedApiError("journal", "persistence_failed", definite_reject=True) + raise NormalizedApiError( + "journal", "persistence_failed", definite_reject=True + ) return original_journal(event, row, **kwargs) monkeypatch.setattr(session, "_journal", fail_completion) @@ -769,9 +845,13 @@ def fail_completion(event, row, **kwargs): assert raised.value.code == "ctp_approval_consumption_uncertain" api.close() - recovered = BtApi(execution_config={"market_data_only": True, "order_journal": str(journal)}) + recovered = BtApi( + execution_config={"market_data_only": True, "order_journal": str(journal)} + ) with pytest.raises(NormalizedApiError) as raised: - recovered.redeem_ctp_execution_approval(verified, trust_root=root, context=_context()) + recovered.redeem_ctp_execution_approval( + verified, trust_root=root, context=_context() + ) assert raised.value.code == "ctp_approval_consumption_uncertain" recovered.close() @@ -781,7 +861,9 @@ def test_journal_approval_payload_tampering_is_rejected_after_restart( ): private_key, root = signing_material journal = tmp_path / "tampered.jsonl" - api = BtApi(execution_config={"market_data_only": True, "order_journal": str(journal)}) + api = BtApi( + execution_config={"market_data_only": True, "order_journal": str(journal)} + ) verified = api.verify_ctp_execution_approval( _signed_artifact(_payload(), private_key), trust_root=root, context=_context() ) @@ -791,10 +873,16 @@ def test_journal_approval_payload_tampering_is_rejected_after_restart( for row in rows: if row["event"] == "ctp_execution_approval_consumed": row["connection_generation"] = 999 - journal.write_text("".join(json.dumps(row, separators=(",", ":")) + "\n" for row in rows)) - recovered = BtApi(execution_config={"market_data_only": True, "order_journal": str(journal)}) + journal.write_text( + "".join(json.dumps(row, separators=(",", ":")) + "\n" for row in rows) + ) + recovered = BtApi( + execution_config={"market_data_only": True, "order_journal": str(journal)} + ) with pytest.raises(NormalizedApiError) as raised: - recovered.redeem_ctp_execution_approval(verified, trust_root=root, context=_context()) + recovered.redeem_ctp_execution_approval( + verified, trust_root=root, context=_context() + ) assert raised.value.code == "unreadable_journal" recovered.close() @@ -802,7 +890,9 @@ def test_journal_approval_payload_tampering_is_rejected_after_restart( def test_torn_approval_tail_is_rejected_after_restart(signing_material, tmp_path: Path): private_key, root = signing_material journal = tmp_path / "torn.jsonl" - api = BtApi(execution_config={"market_data_only": False, "order_journal": str(journal)}) + api = BtApi( + execution_config={"market_data_only": False, "order_journal": str(journal)} + ) verified = api.verify_ctp_execution_approval( _signed_artifact(_payload(), private_key), trust_root=root, context=_context() ) @@ -811,14 +901,20 @@ def test_torn_approval_tail_is_rejected_after_restart(signing_material, tmp_path with journal.open("ab") as stream: stream.write(b'{"event":"ctp_execution_approval_consumed"') with pytest.raises(NormalizedApiError) as raised: - BtApi(execution_config={"market_data_only": False, "order_journal": str(journal)}) + BtApi( + execution_config={"market_data_only": False, "order_journal": str(journal)} + ) assert raised.value.code == "unreadable_journal" -def test_public_revocation_snapshot_uses_the_same_journal(signing_material, tmp_path: Path): +def test_public_revocation_snapshot_uses_the_same_journal( + signing_material, tmp_path: Path +): _private_key, root = signing_material journal = tmp_path / "revocation-only.jsonl" - api = BtApi(execution_config={"market_data_only": True, "order_journal": str(journal)}) + api = BtApi( + execution_config={"market_data_only": True, "order_journal": str(journal)} + ) result = api.record_ctp_execution_approval_revocation_snapshot(trust_root=root) assert result["revocation_snapshot_version"] == 3 assert result["updated"] is True @@ -827,7 +923,9 @@ def test_public_revocation_snapshot_uses_the_same_journal(signing_material, tmp_ ) api.close() - recovered = BtApi(execution_config={"market_data_only": True, "order_journal": str(journal)}) + recovered = BtApi( + execution_config={"market_data_only": True, "order_journal": str(journal)} + ) same = recovered.record_ctp_execution_approval_revocation_snapshot(trust_root=root) assert same["updated"] is False recovered.close() @@ -870,7 +968,9 @@ def test_session_rejects_unwritable_journal_before_returning_capability( private_key, root = signing_material journal = tmp_path / "directory" journal.mkdir() - api = BtApi(execution_config={"market_data_only": True, "order_journal": str(journal)}) + api = BtApi( + execution_config={"market_data_only": True, "order_journal": str(journal)} + ) verified = api.verify_ctp_execution_approval( _signed_artifact(_payload(), private_key), trust_root=root, context=_context() ) @@ -892,7 +992,15 @@ def test_old_mapping_recovery_entry_stays_rejected(tmp_path: Path): api.close() -def _runtime_approval_fixture(tmp_path, monkeypatch, name): +def _runtime_approval_fixture( + tmp_path, + monkeypatch, + name, + *, + profile="simnow_demo", + credential_binding_provider=None, + use_controlled_binding_test_seam=True, +): """Build the same sealed runtime fixture used by the independent probes.""" from bt_api_py import _execution_session as session_module @@ -901,10 +1009,15 @@ def _runtime_approval_fixture(tmp_path, monkeypatch, name): "_ledger_registry_root", lambda: tmp_path / "approval-ledger-registry", ) + journal = tmp_path / f"{name}.jsonl" + provisioned = os.name == "nt" + if provisioned: + journal.touch() api = BtApi( execution_config={ "market_data_only": True, - "order_journal": str(tmp_path / f"{name}.jsonl"), + "order_journal": str(journal), + "windows_ctp_journal_preprovisioned": provisioned, } ) feed = SimpleNamespace( @@ -912,9 +1025,22 @@ def _runtime_approval_fixture(tmp_path, monkeypatch, name): "account_fingerprint": "acct_1234567890abcdef", "trading_day": "20260911", "connection_generation": 7, - "environment_profile": "simnow_demo", + "environment_profile": profile, } ) + feed._execution_bound_td_front = "tcp://approved-td" + feed._execution_bound_md_front = "tcp://approved-md" + feed._trader = SimpleNamespace( + front=feed._execution_bound_td_front, + _bound_front=feed._execution_bound_td_front, + _session_native_front=feed._execution_bound_td_front, + _connection_generation=7, + ) + feed._md_client = SimpleNamespace( + front=feed._execution_bound_md_front, + connection_generation=11, + ) + feed._md_stream_generation = 4 feed.get_session_state = lambda: dict(feed.state) feed.get_environment_info = lambda: { "verified": True, @@ -952,14 +1078,32 @@ def _runtime_approval_fixture(tmp_path, monkeypatch, name): ) strategy_file = tmp_path / f"{name}-strategy.txt" strategy_file.write_text("synthetic strategy version 1\n") - context = api.build_ctp_execution_approval_context( - _context(), - exchange_name="CTP___FUTURE", - configuration={"mode": "synthetic-read-only"}, - strategy_source=strategy_file, - preflight={"complete": True}, - evidence={"complete": True}, - ) + verifier = None + if credential_binding_provider is not None and use_controlled_binding_test_seam: + from bt_api_py.bt_api import _issue_ctp_controlled_test_authority_for_core + + verifier = api._create_ctp_credential_binding_verifier_for_test( + credential_binding_provider, + authority=_issue_ctp_controlled_test_authority_for_core(), + ) + try: + context = api.build_ctp_execution_approval_context( + _context(), + exchange_name="CTP___FUTURE", + configuration={"mode": "synthetic-read-only"}, + strategy_source=strategy_file, + preflight={"complete": True}, + evidence={"complete": True}, + credential_binding_verifier=verifier, + credential_binding_provider=( + credential_binding_provider + if not use_controlled_binding_test_seam + else None + ), + ) + except Exception: + api.close() + raise return api, feed, strategy_file, context @@ -967,7 +1111,636 @@ def _runtime_payload(context, now=None, **changes): values = context.as_dict() values.pop("source") values.update(changes) - return _payload(now, **values) + payload = _payload(now, **values) + if ( + "credential_binding_key_id" in values + or "credential_binding_hmac_sha256" in values + ): + from bt_api_py import SIMNOW_APPROVAL_SCHEMA_VERSION + + payload["schema_version"] = SIMNOW_APPROVAL_SCHEMA_VERSION + return payload + + +@pytest.mark.parametrize("profile", ["set1_group1", "config_front_pair"]) +def test_simnow_bounded_context_requires_pathless_binding_provider( + tmp_path, monkeypatch, profile +): + with pytest.raises(NormalizedApiError) as raised: + _runtime_approval_fixture( + tmp_path, + monkeypatch, + f"simnow-missing-binding-{profile}", + profile=profile, + ) + assert raised.value.code == "ctp_credential_binding_required" + + +def test_neutral_config_front_pair_context_verifies_with_fresh_bound_credential_tag( + signing_material, tmp_path, monkeypatch +): + private_key, root = signing_material + tag = { + "credential_binding_key_id": "runtime-binding-key-1", + "credential_binding_hmac_sha256": "a" * 64, + } + api, _feed, _strategy_file, context = _runtime_approval_fixture( + tmp_path, + monkeypatch, + "config-front-pair-bound", + profile="config_front_pair", + credential_binding_provider=lambda: dict(tag), + ) + + approval = api.verify_ctp_execution_approval( + _signed_artifact(_runtime_payload(context), private_key), + trust_root=root, + context=context, + ) + + assert approval.payload["environment_profile"] == "config_front_pair" + assert approval.payload["credential_binding_key_id"] == tag[ + "credential_binding_key_id" + ] + api.close() + + +def test_bare_callable_cannot_satisfy_trusted_credential_binding(tmp_path, monkeypatch): + with pytest.raises(NormalizedApiError) as raised: + _runtime_approval_fixture( + tmp_path, + monkeypatch, + "simnow-bare-callback-rejected", + profile="set1_group1", + credential_binding_provider=lambda: { + "credential_binding_key_id": "runtime-binding-key-1", + "credential_binding_hmac_sha256": "a" * 64, + }, + use_controlled_binding_test_seam=False, + ) + assert raised.value.code == "ctp_credential_binding_trust_required" + + +def test_raw_mapping_cannot_satisfy_trusted_credential_binding(tmp_path, monkeypatch): + with pytest.raises(NormalizedApiError) as raised: + _runtime_approval_fixture( + tmp_path, + monkeypatch, + "simnow-raw-mapping-rejected", + profile="set1_group1", + credential_binding_provider={ + "credential_binding_key_id": "runtime-binding-key-1", + "credential_binding_hmac_sha256": "a" * 64, + }, + use_controlled_binding_test_seam=False, + ) + assert raised.value.code == "ctp_credential_binding_trust_required" + + +def test_credential_binding_verifier_cannot_be_replayed_across_sdk_instances(tmp_path): + from bt_api_py.bt_api import _issue_ctp_controlled_test_authority_for_core + + first = BtApi(execution_config={"market_data_only": True}) + second = BtApi(execution_config={"market_data_only": True}) + try: + verifier = first._create_ctp_credential_binding_verifier_for_test( + lambda: { + "credential_binding_key_id": "runtime-binding-key-1", + "credential_binding_hmac_sha256": "a" * 64, + }, + authority=_issue_ctp_controlled_test_authority_for_core(), + ) + with pytest.raises(NormalizedApiError) as raised: + second.build_ctp_execution_approval_context( + _context(), + credential_binding_provider=verifier, + ) + assert raised.value.code == "ctp_credential_binding_trust_required" + finally: + first.close() + second.close() + + +def test_credential_binding_refresh_rejects_front_and_configuration_drift( + tmp_path, monkeypatch +): + api, feed, strategy_file, context = _runtime_approval_fixture( + tmp_path, + monkeypatch, + "simnow-binding-drift", + profile="set1_group1", + credential_binding_provider=lambda: { + "credential_binding_key_id": "runtime-binding-key-1", + "credential_binding_hmac_sha256": "a" * 64, + }, + ) + try: + feed._trader.front = "tcp://changed-td" + with pytest.raises(NormalizedApiError) as front_raised: + api._refresh_ctp_execution_approval_context(context) + assert front_raised.value.code == "ctp_credential_binding_active_front_unavailable" + + feed._trader.front = feed._execution_bound_td_front + strategy_file.write_text("synthetic strategy version 2\n") + refreshed = api._refresh_ctp_execution_approval_context(context) + assert ( + refreshed.as_dict()["strategy_identity_sha256"] + != context.as_dict()["strategy_identity_sha256"] + ) + assert ( + refreshed.as_dict()["credential_binding_hmac_sha256"] + != context.as_dict()["credential_binding_hmac_sha256"] + ) + finally: + api.close() + + +@pytest.mark.parametrize("field", ["account_fingerprint", "trading_day", "connection_generation"]) +def test_credential_binding_refresh_invalidates_account_day_and_generation_drift( + signing_material, tmp_path, monkeypatch, field +): + private_key, root = signing_material + api, feed, _strategy_file, context = _runtime_approval_fixture( + tmp_path, + monkeypatch, + f"simnow-binding-{field}-drift", + profile="set1_group1", + credential_binding_provider=lambda: { + "credential_binding_key_id": "runtime-binding-key-1", + "credential_binding_hmac_sha256": "a" * 64, + }, + ) + try: + proof = _signed_artifact(_runtime_payload(context), private_key) + api.verify_ctp_execution_approval(proof, trust_root=root, context=context) + + if field == "account_fingerprint": + feed.state[field] = "acct_2234567890abcdef" + elif field == "trading_day": + feed.state[field] = "20260912" + else: + feed.state[field] = 8 + feed._trader._connection_generation = 8 + + refreshed = api._refresh_ctp_execution_approval_context(context) + assert ( + refreshed.as_dict()["credential_binding_hmac_sha256"] + != context.as_dict()["credential_binding_hmac_sha256"] + ) + with pytest.raises(NormalizedApiError) as raised: + api.verify_ctp_execution_approval( + proof, trust_root=root, context=refreshed + ) + assert raised.value.code == "ctp_approval_context_mismatch" + finally: + api.close() + + +def _active_md_identity_fixture(): + """Build real CTP client/stream types without starting native sessions.""" + from bt_api_ctp.ctp.client import MdClient, MdIdentityObservation, TraderClient + from bt_api_ctp.feeds.base_stream import ConnectionState + from bt_api_ctp.feeds.live_ctp_feed import CtpMarketStream, CtpRequestDataFuture + + td_front = "tcp://approved-td" + md_front = "tcp://approved-md" + broker_id = "broker-123" + user_id = "user-456" + td_generation = 7 + md_generation = 11 + trading_day = "20260911" + + trader = TraderClient(td_front, broker_id, user_id, "test-only") + trader._session_native_front = td_front + trader._connection_generation = td_generation + trader._trading_day = trading_day + feed = object.__new__(CtpRequestDataFuture) + feed._trader = trader + feed._execution_bound_td_front = td_front + feed._execution_bound_md_front = md_front + feed._execution_bound_broker_id = broker_id + feed._execution_bound_user_id = user_id + + md_client = MdClient(md_front, broker_id, user_id, "test-only") + md_client._connected = True + md_client._loggedin = True + md_client._connection_generation = md_generation + md_client._active_md_identity = MdIdentityObservation( + front=md_front, + broker_id=broker_id, + user_id=user_id, + connection_generation=md_generation, + request_id=md_generation, + trading_day=trading_day, + authenticated=True, + ) + + ingress = object() + stream = object.__new__(CtpMarketStream) + stream.stream_name = "ctp_market_stream" + stream.data_queue = ingress + stream._running = True + stream._state = ConnectionState.AUTHENTICATED + stream.md_front = md_front + stream._md_client = md_client + stream._observed_client_generation = md_generation + stream._connection_generation = 4 + + api = SimpleNamespace( + _ctp_market_ingress_queues={"CTP___FUTURE": ingress}, + _subscription_streams=[stream], + ) + state = { + "account_fingerprint": "acct_" + trader._account_fingerprint, + "trading_day": trading_day, + "connection_generation": td_generation, + } + return api, feed, stream, md_client, state + + +def test_credential_binding_consumes_native_active_md_identity(): + from bt_api_ctp.ctp.client import MdIdentityObservation + + from bt_api_py.bt_api import BtApi + + api, feed, _stream, md_client, state = _active_md_identity_fixture() + result = BtApi._ctp_credential_binding_fronts( + api, "CTP___FUTURE", feed, state, object(), operation="test" + ) + assert result == { + "td_front": "tcp://approved-td", + "md_front": "tcp://approved-md", + "md_connection_generation": 11, + "md_stream_generation": 4, + } + assert type(md_client.active_md_identity) is MdIdentityObservation + + +@pytest.mark.parametrize( + "identity", + [ + {"authenticated": True}, + SimpleNamespace(authenticated=True), + { + "authenticated": True, + "field": "mapping", + }, + ], +) +def test_credential_binding_rejects_untyped_active_md_identity(identity): + from bt_api_py.bt_api import BtApi + + api, feed, _stream, md_client, state = _active_md_identity_fixture() + md_client._active_md_identity = identity + with pytest.raises(NormalizedApiError) as raised: + BtApi._ctp_credential_binding_fronts( + api, "CTP___FUTURE", feed, state, object(), operation="test" + ) + assert raised.value.code == "ctp_credential_binding_active_md_identity_unavailable" + + +@pytest.mark.parametrize( + ("field", "replacement", "expected_code"), + [ + ("broker_id", "other-broker", "ctp_credential_binding_scope_mismatch"), + ("user_id", "other-user", "ctp_credential_binding_scope_mismatch"), + ("trading_day", "20260912", "ctp_credential_binding_scope_mismatch"), + ("connection_generation", 12, "ctp_credential_binding_active_front_unavailable"), + ("request_id", 12, "ctp_credential_binding_active_front_unavailable"), + ], +) +def test_credential_binding_rejects_md_identity_account_day_and_generation_drift( + field, replacement, expected_code +): + from dataclasses import replace + + from bt_api_py.bt_api import BtApi + + api, feed, _stream, md_client, state = _active_md_identity_fixture() + md_client._active_md_identity = replace( + md_client._active_md_identity, **{field: replacement} + ) + with pytest.raises(NormalizedApiError) as raised: + BtApi._ctp_credential_binding_fronts( + api, "CTP___FUTURE", feed, state, object(), operation="test" + ) + assert raised.value.code == expected_code + + +@pytest.mark.parametrize("profile", ["set1_group1", "config_front_pair"]) +def test_plain_synthetic_mapping_cannot_verify_legacy_bounded_approval( + signing_material, profile +): + private_key, root = signing_material + from bt_api_py._ctp_execution_authorization import verify_ctp_execution_approval + + legacy_context = _context() + legacy_context["environment_profile"] = profile + legacy_payload = _payload(environment_profile=profile) + + with pytest.raises(NormalizedApiError) as raised: + verify_ctp_execution_approval( + _signed_artifact(legacy_payload, private_key), + trust_root=root, + context=legacy_context, + ) + assert raised.value.code == "ctp_credential_binding_required" + + +@pytest.mark.parametrize( + "profile", ["set1_group1", "set1_group2", "config_front_pair"] +) +def test_simnow_set1_legacy_v1_approval_is_rejected( + signing_material, tmp_path, monkeypatch, profile +): + private_key, root = signing_material + tag = { + "credential_binding_key_id": "runtime-binding-key-1", + "credential_binding_hmac_sha256": "a" * 64, + } + api, _feed, _strategy_file, context = _runtime_approval_fixture( + tmp_path, + monkeypatch, + f"legacy-{profile}", + profile=profile, + credential_binding_provider=lambda: dict(tag), + ) + old_payload = _runtime_payload(context) + old_payload.pop("credential_binding_key_id") + old_payload.pop("credential_binding_hmac_sha256") + old_payload["schema_version"] = SCHEMA + with pytest.raises(NormalizedApiError) as raised: + api.verify_ctp_execution_approval( + _signed_artifact(old_payload, private_key), trust_root=root, context=context + ) + assert raised.value.code == "ctp_credential_binding_required" + api.close() + + +@pytest.mark.parametrize( + ("field", "replacement"), + [ + ("credential_binding_key_id", "runtime-binding-key-2"), + ("credential_binding_hmac_sha256", "b" * 64), + ], +) +def test_simnow_set1_tag_rotation_invalidates_signed_approval( + signing_material, tmp_path, monkeypatch, field, replacement +): + private_key, root = signing_material + current = { + "credential_binding_key_id": "runtime-binding-key-1", + "credential_binding_hmac_sha256": "a" * 64, + } + api, _feed, _strategy_file, context = _runtime_approval_fixture( + tmp_path, + monkeypatch, + f"rotation-{field}", + profile="set1_group1", + credential_binding_provider=lambda: dict(current), + ) + payload = _runtime_payload(context) + proof = _signed_artifact(payload, private_key) + api.verify_ctp_execution_approval(proof, trust_root=root, context=context) + assert "runtime-binding-key-1" not in repr(context) + assert current["credential_binding_hmac_sha256"] not in repr(context) + + current[field] = replacement + refreshed = api._refresh_ctp_execution_approval_context(context) + with pytest.raises(NormalizedApiError) as raised: + api.verify_ctp_execution_approval(proof, trust_root=root, context=refreshed) + assert raised.value.code == "ctp_approval_context_mismatch" + api.close() + + +def test_simnow_binding_is_journaled_once_without_credential_projection( + signing_material, tmp_path, monkeypatch +): + private_key, root = signing_material + journal = tmp_path / "simnow-bound.jsonl" + tag = { + "credential_binding_key_id": "runtime-binding-key-1", + "credential_binding_hmac_sha256": "c" * 64, + } + api, _feed, _strategy_file, context = _runtime_approval_fixture( + tmp_path, + monkeypatch, + "simnow-bound", + profile="set1_group2", + credential_binding_provider=lambda: dict(tag), + ) + assert api._execution_session.path == journal + api.preauthorize_ctp_execution_approval( + _signed_artifact(_runtime_payload(context), private_key), + trust_root=root, + context=context, + ) + rows = [json.loads(line) for line in journal.read_text().splitlines()] + record = next( + row for row in rows if row["event"] == "ctp_execution_approval_pre_authorized" + ) + assert record["credential_binding_key_id"] == tag["credential_binding_key_id"] + context_hmac = context.as_dict()["credential_binding_hmac_sha256"] + assert ( + record["credential_binding_hmac_sha256"] + == context_hmac + ) + assert ( + record["approval_payload"]["credential_binding_hmac_sha256"] + == context_hmac + ) + identity = api._execution_session.execution_identity("CTP___FUTURE") + assert not any(name.startswith("credential_binding_") for name in identity) + assert not any(name in record for name in ("password", "investor_id", "auth_code")) + assert list(tmp_path.glob("*.jsonl")) == [journal] + api.close() + + +@pytest.mark.parametrize("profile", ["set2_7x24", "set1_group1_vpn", "set2_7x24_4000x"]) +def test_non_set1_simnow_approval_context_is_unsupported( + tmp_path, monkeypatch, profile +): + with pytest.raises(NormalizedApiError) as raised: + _runtime_approval_fixture( + tmp_path, + monkeypatch, + f"simnow-unsupported-{profile}", + profile=profile, + credential_binding_provider=lambda: { + "credential_binding_key_id": "runtime-binding-key-1", + "credential_binding_hmac_sha256": "a" * 64, + }, + ) + assert raised.value.code == "ctp_credential_binding_scope_unsupported" + + +def test_credential_binding_provider_rejects_raw_credential_fields( + tmp_path, monkeypatch +): + with pytest.raises(NormalizedApiError) as raised: + _runtime_approval_fixture( + tmp_path, + monkeypatch, + "simnow-raw-credential-rejected", + profile="set1_group1", + credential_binding_provider=lambda: { + "credential_binding_key_id": "runtime-binding-key-1", + "credential_binding_hmac_sha256": "a" * 64, + "password": "never-cross-this-boundary", + }, + ) + assert raised.value.code == "ctp_credential_binding_invalid" + + +@pytest.mark.parametrize( + ("helper", "schema_name"), + [ + ("entry", "SIMNOW_ENTRY_APPROVAL_SCHEMA_VERSION"), + ("recovery", "SIMNOW_RECOVERY_APPROVAL_SCHEMA_VERSION"), + ], +) +def test_simnow_entry_and_recovery_payload_variants_preserve_binding_fields( + helper, schema_name +): + from bt_api_py import _ctp_execution_authorization as authorization + + if helper == "entry": + from .test_ctp_entry_approval_arm import _entry_payload + + payload = _entry_payload() + else: + from .test_ctp_execution_recovery_approval import _recovery_payload + + payload = _recovery_payload() + payload["schema_version"] = getattr(authorization, schema_name) + payload["credential_binding_key_id"] = "runtime-binding-key-1" + payload["credential_binding_hmac_sha256"] = "d" * 64 + + normalized = authorization._normalize_payload(payload) + + assert normalized["credential_binding_key_id"] == "runtime-binding-key-1" + assert normalized["credential_binding_hmac_sha256"] == "d" * 64 + + +def test_set1_entry_capability_cannot_reach_generic_native_authority( + signing_material, tmp_path: Path, monkeypatch +): + from bt_api_py import SIMNOW_ENTRY_APPROVAL_SCHEMA_VERSION + + private_key, root = signing_material + api, feed, _strategy_file, context = _runtime_approval_fixture( + tmp_path, + monkeypatch, + "set1-generic-write-closed", + profile="set1_group1", + credential_binding_provider=lambda: { + "credential_binding_key_id": "runtime-binding-key-1", + "credential_binding_hmac_sha256": "b" * 64, + }, + ) + payload = _runtime_payload( + context, + receipt_sha256="c" * 64, + source_hashes_sha256="d" * 64, + ctp_package_sha256="e" * 64, + ) + payload["schema_version"] = SIMNOW_ENTRY_APPROVAL_SCHEMA_VERSION + capability = api.redeem_ctp_execution_approval( + _signed_artifact(payload, private_key), + trust_root=root, + context=context, + ) + native_calls = [] + feed._issue_execution_authorization_for_core = lambda *_a, **_kw: native_calls.append( + "arm-issuer" + ) + feed._issue_settlement_authorization_for_core = lambda *_a, **_kw: native_calls.append( + "settlement-issuer" + ) + feed.arm_execution_gate = lambda *_a, **_kw: native_calls.append("arm-gate") + feed.confirm_settlement = lambda *_a, **_kw: native_calls.append("settlement") + try: + for call in ( + lambda: api.arm_execution_from_approval(capability), + lambda: api.confirm_ctp_settlement_from_approval(capability), + lambda: api.arm_execution_recovery( + authorization=capability, + recovery_token_sha256="f" * 64, + budget_capability=object(), + ), + ): + with pytest.raises(NormalizedApiError) as raised: + call() + assert raised.value.code == "ctp_simnow_execution_not_admitted" + assert native_calls == [] + finally: + api.close() + + +@pytest.mark.parametrize( + "profile", + [ + "set1_group1", + "set1_group2", + "set1_group1_vpn", + "set2_7x24", + "set2_7x24_4000x", + "set2_7x24_vpn", + "set1", + "set2", + ], +) +def test_all_restricted_simnow_profiles_block_generic_order_and_cancel_dispatch( + profile, tmp_path: Path, monkeypatch +): + api, feed, _strategy_file, _context_value = _runtime_approval_fixture( + tmp_path, + monkeypatch, + f"dispatch-closed-{profile}", + profile="set1_group1", + credential_binding_provider=lambda: { + "credential_binding_key_id": "runtime-binding-key-1", + "credential_binding_hmac_sha256": "b" * 64, + }, + ) + feed.state["environment_profile"] = profile + feed.native_calls = [] + feed.make_order = lambda *_a, **_kw: feed.native_calls.append("insert") + feed.cancel_order = lambda *_a, **_kw: feed.native_calls.append("action") + feed.cancel_all = lambda *_a, **_kw: feed.native_calls.append("cancel-all") + + async def async_make_order(*_args, **_kwargs): + feed.native_calls.append("async-insert") + + async def async_cancel_order(*_args, **_kwargs): + feed.native_calls.append("async-action") + + async def async_cancel_all(*_args, **_kwargs): + feed.native_calls.append("async-cancel-all") + + feed.async_make_order = async_make_order + feed.async_cancel_order = async_cancel_order + feed.async_cancel_all = async_cancel_all + try: + calls = ( + lambda: api.make_order("CTP___FUTURE", "CZCE.SA701", normalized=True), + lambda: api.cancel_order("CTP___FUTURE", "CZCE.SA701", normalized=True), + lambda: api.cancel_all("CTP___FUTURE"), + lambda: asyncio.run( + api.async_make_order("CTP___FUTURE", normalized=True) + ), + lambda: asyncio.run( + api.async_cancel_order("CTP___FUTURE", normalized=True) + ), + lambda: asyncio.run(api.async_cancel_all("CTP___FUTURE")), + ) + for call in calls: + with pytest.raises(NormalizedApiError) as raised: + call() + assert raised.value.code == "ctp_simnow_execution_not_admitted" + assert feed.native_calls == [] + finally: + api.close() @pytest.mark.parametrize( @@ -1084,7 +1857,11 @@ def test_runtime_change_during_lease_commit_is_rejected_before_approval_event( def change_at_lease_fsync(fd): nonlocal mutated - if not mutated and lock_path.exists() and os.fstat(fd).st_ino == lock_path.stat().st_ino: + if ( + not mutated + and lock_path.exists() + and os.fstat(fd).st_ino == lock_path.stat().st_ino + ): mutated = True if mutation == "generation": feed.state["connection_generation"] = 8 @@ -1099,7 +1876,11 @@ def change_at_lease_fsync(fd): assert raised.value.code == "ctp_approval_context_mismatch" assert mutated is True if journal.exists(): - events = [json.loads(line)["event"] for line in journal.read_text().splitlines() if line] + events = [ + json.loads(line)["event"] + for line in journal.read_text().splitlines() + if line + ] assert not { "ctp_execution_approval_pre_authorized", "ctp_execution_approval_consumption_started", @@ -1124,8 +1905,14 @@ def test_runtime_change_during_consumed_commit_is_rejected_after_durable_fence( def change_at_consumed_fsync(fd): nonlocal observed - if not observed and journal.exists() and os.fstat(fd).st_ino == journal.stat().st_ino: - rows = [json.loads(line) for line in journal.read_text().splitlines() if line] + if ( + not observed + and journal.exists() + and os.fstat(fd).st_ino == journal.stat().st_ino + ): + rows = [ + json.loads(line) for line in journal.read_text().splitlines() if line + ] if rows and rows[-1]["event"] == "ctp_execution_approval_consumed": observed = True feed.state["connection_generation"] = 8 @@ -1160,8 +1947,14 @@ def test_consumed_fsync_failure_stays_permanently_uncertain_in_process( def fail_after_consumed_flush(fd): nonlocal injected - if not injected and journal.exists() and os.fstat(fd).st_ino == journal.stat().st_ino: - rows = [json.loads(line) for line in journal.read_text().splitlines() if line] + if ( + not injected + and journal.exists() + and os.fstat(fd).st_ino == journal.stat().st_ino + ): + rows = [ + json.loads(line) for line in journal.read_text().splitlines() if line + ] if rows and rows[-1].get("event") == "ctp_execution_approval_consumed": injected = True raise OSError("synthetic fsync failure after consumed row flush") @@ -1203,7 +1996,9 @@ def test_newer_snapshot_cannot_forget_prior_revocation_before_preauthorization( ): private_key, root = signing_material journal = tmp_path / "revocation-monotonic.jsonl" - api = BtApi(execution_config={"market_data_only": True, "order_journal": str(journal)}) + api = BtApi( + execution_config={"market_data_only": True, "order_journal": str(journal)} + ) api.record_ctp_execution_approval_revocation_snapshot( trust_root=_root_with_revocations(root, 4, approval_ids=("revoked-a",)) ) @@ -1225,10 +2020,14 @@ def test_newer_snapshot_cannot_forget_prior_revocation_before_preauthorization( api.close() -def test_nested_revocation_in_preauthorization_survives_restart(signing_material, tmp_path: Path): +def test_nested_revocation_in_preauthorization_survives_restart( + signing_material, tmp_path: Path +): private_key, root = signing_material journal = tmp_path / "revocation-nested.jsonl" - first = BtApi(execution_config={"market_data_only": True, "order_journal": str(journal)}) + first = BtApi( + execution_config={"market_data_only": True, "order_journal": str(journal)} + ) first.preauthorize_ctp_execution_approval( _signed_artifact( _payload( @@ -1242,7 +2041,9 @@ def test_nested_revocation_in_preauthorization_survives_restart(signing_material ) first.close() - recovered = BtApi(execution_config={"market_data_only": True, "order_journal": str(journal)}) + recovered = BtApi( + execution_config={"market_data_only": True, "order_journal": str(journal)} + ) proof = _signed_artifact( _payload( approval_id="revoked-b", diff --git a/tests/bt_api_contract/test_ctp_execution_recovery_approval.py b/tests/bt_api_contract/test_ctp_execution_recovery_approval.py index 25cbde7a..a42a33e0 100644 --- a/tests/bt_api_contract/test_ctp_execution_recovery_approval.py +++ b/tests/bt_api_contract/test_ctp_execution_recovery_approval.py @@ -357,10 +357,13 @@ def test_recovery_capability_retains_one_shot_plan_fields_after_redemption( artifact = _signed_recovery_artifact(payload, private_key) from bt_api_py import BtApi + journal = tmp_path / "recovery-redemption.jsonl" + journal.touch() api = BtApi( execution_config={ "market_data_only": True, - "order_journal": str(tmp_path / "recovery-redemption.jsonl"), + "order_journal": str(journal), + "windows_ctp_journal_preprovisioned": True, } ) capability = api.redeem_ctp_execution_approval( diff --git a/tests/bt_api_contract/test_ctp_order_mapping.py b/tests/bt_api_contract/test_ctp_order_mapping.py index 2ac8c13d..f164c3c3 100644 --- a/tests/bt_api_contract/test_ctp_order_mapping.py +++ b/tests/bt_api_contract/test_ctp_order_mapping.py @@ -43,3 +43,39 @@ def test_market_order_rejected_before_exchange_call() -> None: def test_reduce_only_maps_offset_close() -> None: result = map_order_request(_order(side=Side.SELL, reduce_only=True)) assert result["offset"] == "close" + + +def test_managed_ctp_identity_is_serialized_as_typed_fields() -> None: + result = map_order_request( + _order( + managed_intent_id="managed-intent-7", + runtime_order_id="runtime-order-7", + hedge_flag="2", + ) + ) + assert result["managed_intent_id"] == "managed-intent-7" + assert result["runtime_order_id"] == "runtime-order-7" + assert result["hedge_flag"] == "2" + + +@pytest.mark.parametrize( + "overrides", + [ + {"managed_intent_id": "managed-intent-7"}, + { + "managed_intent_id": "managed-intent-7", + "runtime_order_id": "runtime-order-7", + }, + { + "managed_intent_id": "managed-intent-7", + "runtime_order_id": "runtime-order-7", + "hedge_flag": "5", + }, + ], + ids=["missing-runtime-order-id", "missing-hedge-flag", "unsupported-managed-hedge"], +) +def test_managed_ctp_order_rejects_incomplete_or_unsupported_identity( + overrides: dict[str, str], +) -> None: + with pytest.raises(ValueError): + _order(**overrides) diff --git a/tests/bt_api_contract/test_ctp_production_execution_approval.py b/tests/bt_api_contract/test_ctp_production_execution_approval.py new file mode 100644 index 00000000..868509f6 --- /dev/null +++ b/tests/bt_api_contract/test_ctp_production_execution_approval.py @@ -0,0 +1,449 @@ +"""Contract tests for production-only, non-authorizing CTP write approval evidence.""" + +from __future__ import annotations + +import base64 +import json +from datetime import UTC, datetime, timedelta +from typing import Any + +import pytest + +from bt_api_py._contracts.errors import NormalizedApiError + +SCHEMA = "ctp-production-managed-write-approval-v1" +ROOT_SCHEMA = "ctp-production-write-trust-root-v1" +PURPOSE = "ctp_production_managed_write" +ISSUER_KEY_ID = "prod-issuer-1" +ISSUER_ROLE = "independent_production_approver" + + +def _b64(value: bytes) -> str: + return base64.urlsafe_b64encode(value).decode("ascii").rstrip("=") + + +def _iso(value: datetime) -> str: + return value.astimezone(UTC).isoformat(timespec="microseconds").replace("+00:00", "Z") + + +def _artifact(schema: str, payload: dict[str, Any], signer) -> bytes: + payload_bytes = json.dumps( + payload, + ensure_ascii=False, + sort_keys=True, + separators=(",", ":"), + allow_nan=False, + ).encode("utf-8") + return json.dumps( + { + "schema_version": schema, + "algorithm": "Ed25519", + "payload": payload, + "signature": _b64(signer.sign(payload_bytes)), + }, + ensure_ascii=False, + sort_keys=True, + separators=(",", ":"), + ).encode("utf-8") + + +@pytest.fixture() +def crypto(): + ed25519 = pytest.importorskip("cryptography.hazmat.primitives.asymmetric.ed25519") + now = datetime.now(UTC) + root_signer = ed25519.Ed25519PrivateKey.generate() + issuer_signer = ed25519.Ed25519PrivateKey.generate() + root_payload = { + "schema_version": ROOT_SCHEMA, + "root_id": "production-root-1", + "issued_at": _iso(now - timedelta(minutes=1)), + "expires_at": _iso(now + timedelta(days=30)), + "keys": { + ISSUER_KEY_ID: { + "public_key": _b64(issuer_signer.public_key().public_bytes_raw()), + "role": ISSUER_ROLE, + "purposes": [PURPOSE], + "not_before": _iso(now - timedelta(minutes=1)), + "expires_at": _iso(now + timedelta(days=1)), + } + }, + "revocation_snapshot": { + "version": 7, + "issued_at": _iso(now - timedelta(minutes=1)), + "expires_at": _iso(now + timedelta(hours=12)), + "revoked_approval_ids": [], + "revoked_nonces": [], + }, + } + root_artifact = _artifact(ROOT_SCHEMA, root_payload, root_signer) + anchor = root_signer.public_key().public_bytes_raw() + return now, issuer_signer, root_signer, root_payload, root_artifact, anchor + + +def _context_values() -> dict[str, Any]: + return { + "environment": "production", + "broker_id": "9999", + "account_fingerprint": "a" * 64, + "md_front": "tcp://md.production.invalid:41213", + "td_front": "tcp://td.production.invalid:41205", + "trading_day": "20260924", + "connection_generation": 41, + "strategy_id": "strategy-prod-1", + "runtime_id": "runtime-prod-1", + "artifact_sha256": "b" * 64, + "config_sha256": "c" * 64, + } + + +def _payload(now: datetime, **changes) -> dict[str, Any]: + values = _context_values() + values.update( + { + "schema_version": SCHEMA, + "approval_id": "approval-prod-1", + "nonce": "nonce-prod-1", + "issuer_key_id": ISSUER_KEY_ID, + "issuer_role": ISSUER_ROLE, + "purpose": PURPOSE, + "issued_at": _iso(now - timedelta(seconds=1)), + "not_before": _iso(now - timedelta(seconds=1)), + "expires_at": _iso(now + timedelta(minutes=10)), + "revocation_snapshot_version": 7, + "orders": [ + { + "intent_id": "intent-entry-1", + "instrument_id": "rb2701", + "exchange_id": "SHFE", + "side": "buy", + "offset": "open", + "hedge_flag": "1", + "volume": 2, + "limit_price": "3250.5", + } + ], + "cancellations": [ + { + "cancel_id": "cancel-entry-1", + "target_order_ref": "000041", + "instrument_id": "rb2701", + "exchange_id": "SHFE", + } + ], + } + ) + values.update(changes) + return values + + +def _verify(crypto, payload: dict[str, Any], *, root=None, anchor=None, context_values=None): + from bt_api_py._ctp_production_execution_approval import ( + _new_runtime_context, + verify_ctp_production_managed_write_approval, + ) + + now, issuer, _root_signer, _root_payload, root_artifact, trust_anchor = crypto + owner = object() + context = _new_runtime_context(context_values or _context_values(), owner=owner) + return verify_ctp_production_managed_write_approval( + _artifact(SCHEMA, payload, issuer), + trust_root_artifact=root if root is not None else root_artifact, + trust_anchor_public_key=anchor if anchor is not None else trust_anchor, + context=context, + owner=owner, + minimum_revocation_snapshot_version=7, + _now=now, + ) + + +def test_production_evidence_verifies_exact_runtime_and_scope(crypto): + from bt_api_py._ctp_production_execution_approval import ( + PRODUCTION_APPROVAL_SCHEMA_VERSION, + CtpProductionWriteApprovalEvidence, + ) + + now = crypto[0] + result = _verify(crypto, _payload(now)) + assert type(result) is CtpProductionWriteApprovalEvidence + assert result.approval_id == "approval-prod-1" + assert result.environment == "production" + assert result.payload["schema_version"] == PRODUCTION_APPROVAL_SCHEMA_VERSION + assert result.payload["orders"][0]["volume"] == 2 + assert result.payload["cancellations"][0]["target_order_ref"] == "000041" + assert result.as_dict()["authorizes_write"] is False + assert not hasattr(result, "arm") + assert not hasattr(result, "submit_order") + assert not hasattr(result, "cancel_order") + with pytest.raises(TypeError): + result.payload["orders"][0]["volume"] = 99 + + +@pytest.mark.parametrize( + ("field", "value", "code"), + [ + ("environment", "simulation", "ctp_production_approval_environment_mismatch"), + ("md_front", "tcp://other.invalid:41213", "ctp_production_approval_context_mismatch"), + ("td_front", "tcp://other.invalid:41205", "ctp_production_approval_context_mismatch"), + ("broker_id", "9998", "ctp_production_approval_context_mismatch"), + ("account_fingerprint", "d" * 64, "ctp_production_approval_context_mismatch"), + ("trading_day", "20260925", "ctp_production_approval_context_mismatch"), + ("connection_generation", 42, "ctp_production_approval_context_mismatch"), + ("strategy_id", "strategy-prod-2", "ctp_production_approval_context_mismatch"), + ("runtime_id", "runtime-prod-2", "ctp_production_approval_context_mismatch"), + ("artifact_sha256", "e" * 64, "ctp_production_approval_context_mismatch"), + ("config_sha256", "f" * 64, "ctp_production_approval_context_mismatch"), + ], +) +def test_signed_context_mismatch_is_rejected(crypto, field, value, code): + now = crypto[0] + payload = _payload(now, **{field: value}) + with pytest.raises(NormalizedApiError) as raised: + _verify(crypto, payload) + assert raised.value.code == code + + +@pytest.mark.parametrize( + ("field", "value", "code"), + [ + ("purpose", "ctp_execution_approval", "ctp_production_approval_purpose_mismatch"), + ( + "schema_version", + "ctp-execution-approval-v2-simnow-binding", + "ctp_production_approval_unknown_schema", + ), + ], +) +def test_simnow_and_legacy_approval_contracts_are_not_accepted(crypto, field, value, code): + now, issuer, _root_signer, _root_payload, root_artifact, anchor = crypto + payload = _payload(now, **{field: value}) + from bt_api_py._ctp_production_execution_approval import ( + _new_runtime_context, + verify_ctp_production_managed_write_approval, + ) + + owner = object() + with pytest.raises(NormalizedApiError) as raised: + verify_ctp_production_managed_write_approval( + _artifact(SCHEMA, payload, issuer), + trust_root_artifact=root_artifact, + trust_anchor_public_key=anchor, + context=_new_runtime_context(_context_values(), owner=owner), + owner=owner, + minimum_revocation_snapshot_version=7, + _now=now, + ) + assert raised.value.code == code + + +def test_unsealed_mapping_context_and_wrong_owner_are_rejected(crypto): + now, issuer, _root_signer, _root_payload, root_artifact, anchor = crypto + from bt_api_py._ctp_production_execution_approval import ( + _new_runtime_context, + verify_ctp_production_managed_write_approval, + ) + + with pytest.raises(NormalizedApiError) as raised: + verify_ctp_production_managed_write_approval( + _artifact(SCHEMA, _payload(now), issuer), + trust_root_artifact=root_artifact, + trust_anchor_public_key=anchor, + context=_context_values(), # type: ignore[arg-type] + owner=object(), + minimum_revocation_snapshot_version=7, + _now=now, + ) + assert raised.value.code == "ctp_production_approval_context_untrusted" + owner = object() + context = _new_runtime_context(_context_values(), owner=owner) + with pytest.raises(NormalizedApiError) as raised: + verify_ctp_production_managed_write_approval( + _artifact(SCHEMA, _payload(now), issuer), + trust_root_artifact=root_artifact, + trust_anchor_public_key=anchor, + context=context, + owner=object(), + minimum_revocation_snapshot_version=7, + _now=now, + ) + assert raised.value.code == "ctp_production_approval_context_owner_mismatch" + + +def test_independent_trust_anchor_and_root_signature_are_required(crypto): + import base64 + + ed25519 = pytest.importorskip("cryptography.hazmat.primitives.asymmetric.ed25519") + now, _issuer, _root_signer, _root_payload, root_artifact, anchor = crypto + payload = _payload(now) + wrong_anchor = ed25519.Ed25519PrivateKey.generate().public_key().public_bytes_raw() + with pytest.raises(NormalizedApiError) as raised: + _verify(crypto, payload, anchor=wrong_anchor) + assert raised.value.code == "ctp_production_approval_trust_root_signature_invalid" + + root_value = json.loads(root_artifact) + root_value["signature"] = base64.urlsafe_b64encode(b"x" * 64).decode("ascii").rstrip("=") + with pytest.raises(NormalizedApiError) as raised: + _verify(crypto, payload, root=json.dumps(root_value).encode()) + assert raised.value.code == "ctp_production_approval_trust_root_signature_invalid" + + +def test_issuer_signing_key_must_be_separate_from_trust_anchor(crypto): + now, _issuer, root_signer, root_payload, _root_artifact, anchor = crypto + root_payload["keys"][ISSUER_KEY_ID]["public_key"] = _b64(anchor) + reused_key_root = _artifact(ROOT_SCHEMA, root_payload, root_signer) + with pytest.raises(NormalizedApiError) as raised: + _verify(crypto, _payload(now), root=reused_key_root) + assert raised.value.code == "ctp_production_approval_issuer_key_must_differ_from_trust_anchor" + + +def test_approval_signature_revocation_and_expiry_are_enforced(crypto): + now, issuer, root_signer, root_payload, root_artifact, anchor = crypto + from bt_api_py._ctp_production_execution_approval import ( + _new_runtime_context, + verify_ctp_production_managed_write_approval, + ) + + payload = _payload(now) + # A changed payload with the old signature fails even when its shape is valid. + artifact_value = json.loads(_artifact(SCHEMA, payload, issuer)) + artifact_value["payload"]["orders"][0]["volume"] = 3 + artifact = json.dumps(artifact_value).encode() + owner = object() + context = _new_runtime_context(_context_values(), owner=owner) + with pytest.raises(NormalizedApiError) as raised: + verify_ctp_production_managed_write_approval( + artifact, + trust_root_artifact=root_artifact, + trust_anchor_public_key=anchor, + context=context, + owner=owner, + minimum_revocation_snapshot_version=7, + _now=now, + ) + assert raised.value.code in { + "ctp_production_approval_invalid_order_scope", + "ctp_production_approval_signature_invalid", + } + + expired = _payload( + now, + issued_at=_iso(now - timedelta(seconds=3)), + not_before=_iso(now - timedelta(seconds=2)), + expires_at=_iso(now - timedelta(seconds=1)), + ) + with pytest.raises(NormalizedApiError) as raised: + _verify(crypto, expired) + assert raised.value.code == "ctp_production_approval_expired" + + root_payload["revocation_snapshot"]["revoked_approval_ids"] = ["approval-prod-1"] + revoked_root = _artifact(ROOT_SCHEMA, root_payload, root_signer) + with pytest.raises(NormalizedApiError) as raised: + _verify(crypto, _payload(now), root=revoked_root) + assert raised.value.code == "ctp_production_approval_revoked" + + +def test_revocation_snapshot_rollback_floor_is_enforced(crypto): + now, _issuer, root_signer, root_payload, _root_artifact, _anchor = crypto + root_payload["revocation_snapshot"]["version"] = 6 + older_root = _artifact(ROOT_SCHEMA, root_payload, root_signer) + with pytest.raises(NormalizedApiError) as raised: + _verify(crypto, _payload(now), root=older_root) + assert raised.value.code == "ctp_production_approval_revocation_version_rollback" + + +@pytest.mark.parametrize( + ("changes", "expected"), + [ + ({"orders": []}, "ctp_production_approval_order_scope_out_of_bounds"), + ( + { + "orders": [ + { + "intent_id": "too-large", + "instrument_id": "rb2701", + "exchange_id": "SHFE", + "side": "buy", + "offset": "open", + "hedge_flag": "1", + "volume": 1001, + "limit_price": "1", + } + ] + }, + "ctp_production_approval_order_volume_out_of_bounds", + ), + ( + { + "orders": [ + { + "intent_id": "bad-price", + "instrument_id": "rb2701", + "exchange_id": "SHFE", + "side": "buy", + "offset": "open", + "hedge_flag": "1", + "volume": 1, + "limit_price": "NaN", + } + ] + }, + "ctp_production_approval_invalid_order_scope", + ), + ( + { + "cancellations": [ + { + "cancel_id": "bad-cancel", + "target_order_ref": "x", + "instrument_id": "rb2701", + "exchange_id": "SHFE", + "extra": True, + } + ] + }, + "ctp_production_approval_invalid_cancel_scope", + ), + ], +) +def test_bounded_exact_order_and_cancel_schema_rejects_unsafe_scope(crypto, changes, expected): + now = crypto[0] + payload = _payload(now, **changes) + with pytest.raises(NormalizedApiError) as raised: + _verify(crypto, payload) + assert raised.value.code == expected + + +def test_invalid_front_duplicate_json_and_direct_evidence_construction_fail(crypto): + now = crypto[0] + with pytest.raises(NormalizedApiError) as raised: + _verify(crypto, _payload(now, td_front="tcp://user:pass@td.invalid:41205")) + assert raised.value.code == "ctp_production_approval_invalid_front" + + from bt_api_py._ctp_production_execution_approval import ( + CtpProductionWriteApprovalEvidence, + _new_runtime_context, + verify_ctp_production_managed_write_approval, + ) + + owner = object() + context = _new_runtime_context(_context_values(), owner=owner) + duplicate = b'{"schema_version":"wrong","schema_version":"wrong"}' + with pytest.raises(NormalizedApiError) as raised: + verify_ctp_production_managed_write_approval( + duplicate, + trust_root_artifact=crypto[4], + trust_anchor_public_key=crypto[5], + context=context, + owner=owner, + minimum_revocation_snapshot_version=7, + _now=now, + ) + assert raised.value.code == "ctp_production_approval_duplicate_json_key" + with pytest.raises(TypeError): + CtpProductionWriteApprovalEvidence( + payload={}, + payload_sha256="0" * 64, + trust_root_sha256="0" * 64, + trust_anchor_key_sha256="0" * 64, + revocation_snapshot={}, + _seal=None, + ) diff --git a/tests/bt_api_contract/test_ctp_simnow_public_adapter.py b/tests/bt_api_contract/test_ctp_simnow_public_adapter.py new file mode 100644 index 00000000..101abf39 --- /dev/null +++ b/tests/bt_api_contract/test_ctp_simnow_public_adapter.py @@ -0,0 +1,465 @@ +"""Offline-only contracts for the SDK-owned SimNow set1 adapter.""" + +from __future__ import annotations + +import queue +from datetime import UTC, datetime +from decimal import Decimal +from types import SimpleNamespace + +import pytest + +from bt_api_py import ( + CancelOrderRequest, + CtpSimNowExecutionAdapter, + CtpSimNowExecutionError, + CtpSimNowOrderIdentity, + CtpSimNowOrderRequest, + build_ctp_simnow_cancel_request, + map_ctp_simnow_cancel_result, + map_ctp_simnow_order_result, +) +from bt_api_py._contracts.models import Side +from bt_api_py._execution_session import _ExecutionSession +from bt_api_py._normalization import normalize_result +from bt_api_py._venue_mappers.ctp import map_order_request + +ACCOUNT = "acct_0123456789abcdef" +TRADING_DAY = "20260923" +INSTRUMENT = "rb2701" +EXCHANGE_ID = "SHFE" +CLIENT_ORDER_ID = "000000001234" + + +class FakeBtApi: + def __init__(self, *, profile="set1_group1", environment="demo"): + self.profile = profile + self.environment = environment + self.calls = [] + self.query_result = {"status": "accepted", "client_order_id": CLIENT_ORDER_ID} + self.orders_result = SimpleNamespace( + request_type="orders", + account_fingerprint=ACCOUNT[5:], + connection_generation=8, + complete=True, + records=( + { + "InstrumentID": INSTRUMENT, + "ExchangeID": EXCHANGE_ID, + "OrderRef": CLIENT_ORDER_ID, + "OrderSysID": "sys-88", + "FrontID": 17, + "SessionID": 19, + "TradingDay": TRADING_DAY, + "OrderStatus": "a", + "VolumeTotalOriginal": 2, + "VolumeTraded": 0, + }, + ), + ) + + def get_environment_info(self, _exchange_name): + return { + "verified": True, + "environment": self.environment, + "transport_mode": "direct", + } + + def get_ctp_session_state(self, _exchange_name): + return { + "environment_profile": self.profile, + "read_only_ready": True, + "auto_settlement_confirm": False, + "account_fingerprint": ACCOUNT, + "trading_day": TRADING_DAY, + "connection_generation": 8, + } + + def get_execution_identity(self, _exchange_name): + return { + "mode": "direct", + "account_fingerprint": ACCOUNT, + "account_id": "ctp-account-alias", + } + + def query_order(self, _exchange_name, request, *, normalized=False): + self.calls.append(("query_order", request, normalized)) + return self.query_result + + def query_ctp_result(self, _exchange_name, query_type): + self.calls.append(("query_ctp_result", query_type)) + return self.orders_result + + def redeem_ctp_execution_approval(self, *_args, **_kwargs): + self.calls.append(("redeem",)) + raise AssertionError("adapter must not issue approval without private binding") + + def arm_execution_from_approval(self, *_args, **_kwargs): + self.calls.append(("arm",)) + raise AssertionError("adapter must not arm without private binding") + + def make_order(self, *_args, **_kwargs): + self.calls.append(("make_order",)) + raise AssertionError("adapter must not send a native order") + + def cancel_order(self, *_args, **_kwargs): + self.calls.append(("cancel_order",)) + raise AssertionError("adapter must not send a native cancel") + + +def _adapter(api=None, *, profile="set1_group1"): + return CtpSimNowExecutionAdapter(api or FakeBtApi(), selected_profile=profile) + + +def _order_identity(runtime_order_id="runtime-order-42"): + return CtpSimNowOrderIdentity( + instrument_id=INSTRUMENT, + exchange_id=EXCHANGE_ID, + client_order_id=CLIENT_ORDER_ID, + order_ref=CLIENT_ORDER_ID, + order_sys_id="sys-88", + front_id=17, + session_id=19, + trading_day=TRADING_DAY, + runtime_order_id=runtime_order_id, + ) + + +@pytest.mark.parametrize("profile", ["set1_group1", "set1_group2"]) +def test_only_exact_official_set1_profiles_are_accepted(profile): + adapter = _adapter(FakeBtApi(profile=profile), profile=profile) + assert adapter.get_execution_identity().profile == profile + assert "acct_" not in repr(adapter.get_execution_identity()) + + +@pytest.mark.parametrize( + "profile", + ["set2_7x24", "set2_7x24_4000x", "set1", "set1_group1_vpn", "custom"], +) +def test_set2_aliases_and_nonofficial_profiles_are_rejected_before_api_calls(profile): + api = FakeBtApi(profile=profile) + with pytest.raises(CtpSimNowExecutionError, match="ctp_simnow_set1_profile_required"): + CtpSimNowExecutionAdapter(api, selected_profile=profile) + assert api.calls == [] + + +def test_selected_profile_and_verified_demo_scope_are_rechecked(): + with pytest.raises(CtpSimNowExecutionError, match="ctp_simnow_selected_profile_mismatch"): + _adapter(FakeBtApi(profile="set1_group2"), profile="set1_group1") + with pytest.raises(CtpSimNowExecutionError, match="ctp_simnow_official_demo_required"): + _adapter(FakeBtApi(environment="production")) + + +def test_adapter_is_fixed_to_ctp_future(): + api = FakeBtApi() + with pytest.raises(CtpSimNowExecutionError, match="ctp_simnow_exchange_scope_invalid"): + CtpSimNowExecutionAdapter(api, selected_profile="set1_group1", exchange_name="CTP___OPTION") + assert api.calls == [] + + +@pytest.mark.parametrize("client_order_id", ["1234567890123", "12345678901é", "12\x0034"]) +def test_native_order_ref_candidates_over_12_ascii_bytes_are_rejected(client_order_id): + with pytest.raises(CtpSimNowExecutionError, match="ctp_native_order_ref_mapping_unavailable"): + CtpSimNowOrderRequest( + client_order_id=client_order_id, + instrument_id=INSTRUMENT, + exchange_id=EXCHANGE_ID, + side="buy", + quantity=Decimal("1"), + limit_price=Decimal("100"), + ) + + +def test_order_request_preserves_set1_hedge_flag_and_native_identity_mapping(): + adapter = _adapter() + request = CtpSimNowOrderRequest( + client_order_id=CLIENT_ORDER_ID, + instrument_id=INSTRUMENT, + exchange_id=EXCHANGE_ID, + side="buy", + quantity=Decimal("2"), + limit_price=Decimal("100"), + hedge_flag="2", + ) + typed = adapter.build_order_request(request) + native = map_order_request(typed) + assert typed.side is Side.BUY + assert typed.quantity_unit == "lots" + assert typed.hedge_flag == native["hedge_flag"] == "2" + mapped = map_ctp_simnow_order_result( + { + "client_order_id": CLIENT_ORDER_ID, + "order_ref": CLIENT_ORDER_ID, + "order_id": "sys-88", + "front_id": 17, + "session_id": 19, + "instrument_id": INSTRUMENT, + "exchange_id": EXCHANGE_ID, + "trading_day": TRADING_DAY, + "status": "accepted", + }, + request, + adapter.get_execution_identity(), + ) + assert mapped.identity.client_order_id == mapped.identity.order_ref == CLIENT_ORDER_ID + assert mapped.identity.order_sys_id == "sys-88" + assert (mapped.identity.front_id, mapped.identity.session_id) == (17, 19) + assert mapped.status == "ACCEPTED" and not mapped.execution_unknown + + +def test_query_order_accepts_typed_normalized_result(): + adapter = _adapter() + adapter._api.query_result = SimpleNamespace( + client_order_id=CLIENT_ORDER_ID, + order_ref=CLIENT_ORDER_ID, + order_id="sys-typed-89", + front_id=17, + session_id=19, + instrument_id=INSTRUMENT, + exchange_id=EXCHANGE_ID, + trading_day=TRADING_DAY, + status="accepted", + ) + + result = adapter.query_order(_order_identity()) + + assert result.status == "ACCEPTED" + assert not result.execution_unknown + assert result.identity.order_sys_id == "sys-typed-89" + + +def test_native_insert_field_receives_selected_set1_hedge_flag(monkeypatch): + from bt_api_ctp.feeds.live_ctp_feed import CtpRequestDataFuture + + class FakeTrader: + _req_id = 20 + _front_id = 17 + _session_id = 19 + + def __init__(self): + self.field = None + + def next_order_ref(self): + return CLIENT_ORDER_ID + + def _next_request_id(self): + self._req_id += 1 + return self._req_id + + def submit_order_insert(self, field, _request_id, *, execution_capability): + assert execution_capability is None + self.field = field + return 0 + + feed = CtpRequestDataFuture( + queue.Queue(), broker_id="9999", user_id="sim-account", td_front="tcp://offline" + ) + trader = FakeTrader() + feed._trader = trader + monkeypatch.setattr(feed, "_ensure_execution_permitted", lambda *_args, **_kwargs: None) + monkeypatch.setattr(feed, "_ensure_trading_ready", lambda: None) + + feed.make_order( + INSTRUMENT, + volume=1, + price="100", + order_type="buy-limit", + offset="open", + exchange_id=EXCHANGE_ID, + client_order_id=CLIENT_ORDER_ID, + hedge_flag="2", + ) + + assert trader.field is not None + assert trader.field.CombHedgeFlag == "2" + + +def test_adapter_write_calls_fail_closed_without_issuing_approval_or_native_calls(): + api = FakeBtApi() + adapter = _adapter(api) + request = CtpSimNowOrderRequest( + client_order_id=CLIENT_ORDER_ID, + instrument_id=INSTRUMENT, + exchange_id=EXCHANGE_ID, + side="buy", + quantity=Decimal("1"), + limit_price=Decimal("100"), + ) + assert adapter.write_admitted is False + assert adapter.write_blockers == ( + "ctp_execution_credential_binding_unavailable", + "ctp_native_order_ref_mapping_unavailable", + ) + with pytest.raises( + CtpSimNowExecutionError, match="ctp_execution_credential_binding_unavailable" + ): + adapter.arm_from_approval(object()) + with pytest.raises(CtpSimNowExecutionError, match="ctp_native_order_ref_mapping_unavailable"): + adapter.submit_order_insert(request) + with pytest.raises( + CtpSimNowExecutionError, match="ctp_execution_credential_binding_unavailable" + ): + adapter.submit_order_action(_order_identity(), "cancel-1") + assert api.calls == [] + + +def test_cancel_request_maps_native_order_ids_and_sdk_action_id(): + request = build_ctp_simnow_cancel_request( + _order_identity(), + account_id="ctp-account-alias", + action_id="cancel-42", + idempotency_key="sdk-idempotency-42", + ) + assert request.order_id == "sys-88" + assert request.client_order_id == CLIENT_ORDER_ID + assert request.order_ref == CLIENT_ORDER_ID + assert (request.front_id, request.session_id) == (17, 19) + assert request.runtime_order_id == "runtime-order-42" + assert request.runtime_action_id == "cancel-42" + assert request.idempotency_key == "sdk-idempotency-42" + + +def test_typed_action_ack_is_never_promoted_to_cancelled_and_redacted_dict_is_unknown(): + from bt_api_ctp.order_action import CtpOrderActionEvidence + + now = datetime.now(UTC) + evidence = CtpOrderActionEvidence( + request_id=42, + order_action_ref="42", + status="accepted", + account_fingerprint=ACCOUNT, + trading_day=TRADING_DAY, + connection_generation=8, + order_ref=CLIENT_ORDER_ID, + order_sys_id="sys-88", + front_id=17, + session_id=19, + instrument_id=INSTRUMENT, + exchange_id=EXCHANGE_ID, + action_flag="0", + evidence_source="OnRspOrderAction", + callback_received=True, + evidence_received=True, + error_code=0, + error_message="", + reason="", + submitted_at_utc=now, + observed_at_utc=now, + submit_code=0, + ) + result = map_ctp_simnow_cancel_result( + "cancel-42", + _order_identity(), + evidence, + _adapter().get_execution_identity(), + request_id=42, + order_action_ref="42", + ) + assert result.status == "ACCEPTED" + assert result.execution_unknown is True + assert result.request_id == 42 and result.order_action_ref == 42 + + redacted = evidence.as_dict() + assert redacted["account_fingerprint"] == "" + redacted_result = map_ctp_simnow_cancel_result( + "cancel-42", + _order_identity(), + redacted, + _adapter().get_execution_identity(), + request_id=42, + order_action_ref="42", + ) + assert redacted_result.status == "UNKNOWN" and redacted_result.execution_unknown + + forged_terminal = map_ctp_simnow_cancel_result( + "cancel-42", + _order_identity(), + {**redacted, "status": "cancelled", "account_fingerprint": ACCOUNT}, + _adapter().get_execution_identity(), + request_id=42, + order_action_ref="42", + ) + assert forged_terminal.status == "UNKNOWN" and forged_terminal.execution_unknown + + +def test_cancel_normalization_and_single_execution_update_keep_unknown(): + raw = SimpleNamespace( + get_input_data=lambda: {}, + get_data=lambda: [{"OrderRef": CLIENT_ORDER_ID, "TradingDay": TRADING_DAY}], + get_extra_data=lambda: { + "ctp_cancel": { + "request_id": 42, + "order_action_ref": "42", + "evidence": { + "request_id": 42, + "order_action_ref": "42", + "status": "accepted", + "account_fingerprint": "", + "trading_day": TRADING_DAY, + "connection_generation": 8, + "order_ref": CLIENT_ORDER_ID, + "order_sys_id": "sys-88", + "front_id": 17, + "session_id": 19, + "instrument_id": INSTRUMENT, + "exchange_id": EXCHANGE_ID, + "action_flag": "0", + "callback_received": True, + "evidence_received": True, + }, + } + }, + ) + normalized = normalize_result( + "cancel_order", + raw, + "CTP___FUTURE", + INSTRUMENT, + CancelOrderRequest( + symbol=INSTRUMENT, + account_id="ctp-account-alias", + idempotency_key="cancel-42", + client_order_id=CLIENT_ORDER_ID, + order_id="sys-88", + order_ref=CLIENT_ORDER_ID, + exchange_id=EXCHANGE_ID, + front_id=17, + session_id=19, + ), + ) + assert normalized["cancel_status"] == "unknown" + assert normalized["status"] == "submitted" and normalized["execution_unknown"] + + session = object.__new__(_ExecutionSession) + session.accounts = {} + session.config = {"account_currencies": {}, "account_currency": "CNY"} + state = { + "symbol": INSTRUMENT, + "exchange_name": "CTP___FUTURE", + "account_id": "ctp-account-alias", + "client_order_id": CLIENT_ORDER_ID, + "order_id": "sys-88", + "side": "buy", + "size": Decimal("1"), + "order_ref": CLIENT_ORDER_ID, + "exchange_id": EXCHANGE_ID, + "trading_day": TRADING_DAY, + "terminal": False, + "last_update": {}, + "_explicit_identity_fields": set(), + } + update = session._order_update(state, normalized) + assert update["execution_unknown"] is True and update["terminal_confirmed"] is False + assert update["cancel_action_id"] == "cancel-42" + assert update["native_request_id"] == 42 + assert update["order_action_ref"] == "42" + assert update["cancel_evidence"]["status"] == "unknown" + + +def test_query_order_snapshot_never_claims_account_wide_completeness(): + adapter = _adapter() + result = adapter.query_account_open_orders() + assert result.complete is False + assert len(result.records) == 1 + assert result.records[0].order_ref == CLIENT_ORDER_ID + assert result.records[0].order_sys_id == "sys-88" diff --git a/tests/bt_api_contract/test_ctp_simnow_read_observation.py b/tests/bt_api_contract/test_ctp_simnow_read_observation.py new file mode 100644 index 00000000..2e530ec5 --- /dev/null +++ b/tests/bt_api_contract/test_ctp_simnow_read_observation.py @@ -0,0 +1,253 @@ +"""Offline contracts for the public CTP read-observation bridge.""" + +from __future__ import annotations + +from types import SimpleNamespace + +import pytest +from bt_api_ctp.ctp.client import TraderClient + +from bt_api_py import CtpSimNowExecutionAdapter, CtpSimNowExecutionError + +BROKER_ID = "9999" +INVESTOR_ID = "sim-account" +INSTRUMENT_ID = "rb2701" +EXCHANGE_ID = "SHFE" +TRADING_DAY = "20260923" +CONNECTION_GENERATION = 8 +HEDGE_FLAG = "1" + + +class _NativeQueryApi: + def __init__(self, client: TraderClient, *, late_account_callback: bool = False): + self.client = client + self.late_account_callback = late_account_callback + self.request_types = { + "ReqQryTradingAccount": "account", + "ReqQryInvestorPosition": "positions", + "ReqQryOrder": "orders", + "ReqQryTrade": "trades", + "ReqQryInstrument": "instruments", + "ReqQryInstrumentMarginRate": "margin_rate", + "ReqQryInstrumentCommissionRate": "commission_rate", + } + + def __getattr__(self, method_name): + request_type = self.request_types.get(method_name) + if request_type is None: + raise AttributeError(method_name) + + def submit(_field, request_id): + rows = self._rows(request_type) + for index, row in enumerate(rows): + self.client._handle_query_callback( + request_type, + row, + None, + request_id, + index == len(rows) - 1, + ) + if not rows: + self.client._handle_query_callback(request_type, None, None, request_id, True) + if request_type == "account" and self.late_account_callback: + self.client._handle_query_callback(request_type, None, None, request_id, False) + return 0 + + return submit + + def _rows(self, request_type: str): + account_scope = {"BrokerID": BROKER_ID, "InvestorID": INVESTOR_ID} + if request_type == "account": + return ({"BrokerID": BROKER_ID, "AccountID": INVESTOR_ID},) + if request_type == "positions": + return () + if request_type == "orders": + return ( + { + **account_scope, + "TradingDay": TRADING_DAY, + "InstrumentID": INSTRUMENT_ID, + "ExchangeID": EXCHANGE_ID, + "OrderRef": "000000000041", + "OrderStatus": "a", + "VolumeTotalOriginal": 2, + "VolumeTraded": 0, + }, + ) + if request_type == "trades": + return () + if request_type == "instruments": + return ({"InstrumentID": INSTRUMENT_ID, "ExchangeID": EXCHANGE_ID},) + if request_type == "margin_rate": + return ( + { + **account_scope, + "TradingDay": TRADING_DAY, + "InstrumentID": INSTRUMENT_ID, + "ExchangeID": EXCHANGE_ID, + "HedgeFlag": HEDGE_FLAG, + "LongMarginRatioByMoney": 0.12, + }, + ) + if request_type == "commission_rate": + return ( + { + **account_scope, + "TradingDay": TRADING_DAY, + "InstrumentID": INSTRUMENT_ID, + "ExchangeID": EXCHANGE_ID, + "OpenRatioByMoney": 0.0001, + }, + ) + raise AssertionError(request_type) + + +class _PublicBtApi: + def __init__(self, client: TraderClient): + self.profile = "set1_group1" + self.client = client + self.exchange_feeds = { + "CTP___FUTURE": SimpleNamespace(trader_client=client), + } + self.calls = [] + + def get_environment_info(self, _exchange_name): + return {"verified": True, "environment": "demo", "transport_mode": "direct"} + + def get_ctp_session_state(self, _exchange_name): + return { + **self.client.get_session_state(), + "environment_profile": self.profile, + } + + def get_execution_identity(self, _exchange_name): + return { + "mode": "direct", + "account_fingerprint": f"acct_{self.client._account_fingerprint}", + "account_id": "offline-ctp-account", + } + + def query_ctp_result(self, _exchange_name, query_type, **kwargs): + self.calls.append((query_type, kwargs)) + routes = { + "account": self.client.query_account_result, + "positions": self.client.query_positions_result, + "orders": self.client.query_orders_result, + "trades": self.client.query_trades_result, + "instruments": self.client.query_instruments_result, + "margin_rate": self.client.query_instrument_margin_rate_result, + "commission_rate": self.client.query_instrument_commission_rate_result, + } + return routes[query_type](**kwargs) + + +def _adapter(*, late_account_callback=False): + client = TraderClient( + "tcp://offline.invalid:0", + BROKER_ID, + INVESTOR_ID, + "", + auto_settlement_confirm=False, + ) + client._connected = True + client._authentication_state = "authenticated" + client._login_state = "logged_in" + client._trading_day = TRADING_DAY + client._connection_generation = CONNECTION_GENERATION + client._req_id = 40 + client._query_interval = 0.0 + client._api = _NativeQueryApi(client, late_account_callback=late_account_callback) + api = _PublicBtApi(client) + return CtpSimNowExecutionAdapter(api, selected_profile="set1_group1"), api + + +def test_public_read_observation_binds_native_certificate_without_authority(): + adapter, api = _adapter() + + observation = adapter.query_native_read_observation( + instrument_id=INSTRUMENT_ID, + exchange_id=EXCHANGE_ID, + hedge_flag=HEDGE_FLAG, + ) + + public = observation.as_public_dict() + certificate = public["native_query_certificate"] + assert [query_type for query_type, _kwargs in api.calls] == [ + "account", + "positions", + "orders", + "trades", + "instruments", + "margin_rate", + "commission_rate", + ] + assert api.calls[5][1]["hedge_flag"] == HEDGE_FLAG + assert certificate["complete"] is True + assert len(certificate["queries"]) == 7 + request_ids = [query["request_id"] for query in certificate["queries"]] + assert all(type(request_id) is int and request_id > 0 for request_id in request_ids) + assert len(set(request_ids)) == len(request_ids) + assert certificate["scope"]["connection_generation"] == CONNECTION_GENERATION + assert certificate["scope"]["trading_day"] == TRADING_DAY + assert certificate["scope"]["account_fingerprint_sha256"] + for raw_identifier in (BROKER_ID, INVESTOR_ID, INSTRUMENT_ID, EXCHANGE_ID): + assert raw_identifier not in str(public) + margin_query = next( + query for query in certificate["queries"] if query["request_type"] == "margin_rate" + ) + assert margin_query["request_filter_names"] == [ + "BrokerID", + "ExchangeID", + "HedgeFlag", + "InstrumentID", + "InvestorID", + ] + assert margin_query["explicit_request_filter_names"] == ["HedgeFlag"] + assert margin_query["source_provenance_validated"] is True + assert margin_query["complete"] is True + assert margin_query["is_last_seen"] is True + assert margin_query["timed_out"] is False + assert margin_query["unsupported"] is False + assert margin_query["error_code"] in (None, 0) + assert margin_query["error_message_present"] is False + assert margin_query["submit_code"] in (None, 0) + assert margin_query["late_callback_count"] == 0 + assert observation.authority_status == "NON_AUTHORIZING" + assert observation.execution_authorized is False + assert observation.account_open_orders_complete is False + assert observation.account_open_orders_status == "UNPROVEN" + assert public["account_open_orders"] == {"complete": False, "status": "UNPROVEN"} + assert adapter.write_admitted is False + + +def test_late_native_callback_prevents_public_observation(): + adapter, api = _adapter(late_account_callback=True) + + with pytest.raises( + CtpSimNowExecutionError, + match="ctp_native_query_certificate_query_late_callback", + ): + adapter.query_native_read_observation( + instrument_id=INSTRUMENT_ID, + exchange_id=EXCHANGE_ID, + hedge_flag=HEDGE_FLAG, + ) + + assert [query_type for query_type, _kwargs in api.calls] == ["account"] + + +def test_public_read_observation_requires_the_native_client_used_by_query_route(): + adapter, api = _adapter() + api.exchange_feeds = {} + + with pytest.raises( + CtpSimNowExecutionError, + match="ctp_native_query_client_unavailable", + ): + adapter.query_native_read_observation( + instrument_id=INSTRUMENT_ID, + exchange_id=EXCHANGE_ID, + hedge_flag=HEDGE_FLAG, + ) + + assert api.calls == [] diff --git a/tests/bt_api_contract/test_direct_backend_runtime_contract.py b/tests/bt_api_contract/test_direct_backend_runtime_contract.py index 6b106ed4..ec4b8eb4 100644 --- a/tests/bt_api_contract/test_direct_backend_runtime_contract.py +++ b/tests/bt_api_contract/test_direct_backend_runtime_contract.py @@ -10,7 +10,7 @@ from bt_api_base.feeds.capability import Capability from bt_api_py._contracts.errors import CapabilityNotSupportedError -from bt_api_py._contracts.models import OrderRequest, OrderType, Side +from bt_api_py._contracts.models import CancelOrderRequest, OrderRequest, OrderType, Side from bt_api_py.bt_api import BtApi from bt_api_py.exceptions import InvalidOrderError @@ -161,6 +161,48 @@ def test_direct_backend_preserves_all_public_v1_operations() -> None: api.get_command_status("COVERAGE___SPOT", "command-1") +def test_ctp_direct_adapter_forwards_managed_submit_and_cancel_identity() -> None: + api, feed = _api_with_direct_feed() + api.exchange_feeds["CTP___FUTURE"] = feed + api.data_queues["CTP___FUTURE"] = queue.Queue() + order = OrderRequest( + symbol="SA609", + side=Side.BUY, + order_type=OrderType.LIMIT, + quantity=Decimal("1"), + price=Decimal("1500"), + account_id="acct-1", + client_order_id="000000000042", + exchange_id="CZCE", + offset="open", + managed_intent_id="managed-intent-42", + runtime_order_id="runtime-order-42", + hedge_flag="2", + ) + api.make_order("CTP___FUTURE", order) + submit_kwargs = feed.calls[-1][2] + assert submit_kwargs["managed_intent_id"] == "managed-intent-42" + assert submit_kwargs["runtime_order_id"] == "runtime-order-42" + assert submit_kwargs["hedge_flag"] == "2" + + cancel = CancelOrderRequest( + symbol="SA609", + account_id="acct-1", + client_order_id="000000000042", + order_ref="000000000042", + runtime_order_id="runtime-order-42", + runtime_action_id="sdk-action-1", + managed_cancel_intent_id="cancel.managed-intent-42", + ) + api.cancel_order("CTP___FUTURE", cancel) + cancel_args, cancel_kwargs = feed.calls[-1][1], feed.calls[-1][2] + assert cancel_args == ("SA609", None) + assert cancel_kwargs["order_ref"] == "000000000042" + assert cancel_kwargs["runtime_order_id"] == "runtime-order-42" + assert cancel_kwargs["runtime_action_id"] == "sdk-action-1" + assert cancel_kwargs["managed_cancel_intent_id"] == "cancel.managed-intent-42" + + def test_direct_capability_sets_and_mappings_normalize_to_plain_sorted_flags() -> None: api, feed = _api_with_direct_feed() diff --git a/tests/bt_api_contract/test_execution_arming.py b/tests/bt_api_contract/test_execution_arming.py index ceabb782..13593cac 100644 --- a/tests/bt_api_contract/test_execution_arming.py +++ b/tests/bt_api_contract/test_execution_arming.py @@ -4,6 +4,7 @@ import hashlib import json +import os import queue import sys import threading @@ -11,7 +12,7 @@ from decimal import Decimal from types import SimpleNamespace from typing import Any -from unittest.mock import Mock +from unittest.mock import Mock, patch import pytest @@ -81,14 +82,18 @@ def _context(proof=None, **changes): return result -def _session(tmp_path, *, risk=False, journal=True, require_journal=True): +def _session(tmp_path, *, risk=False, journal=True, require_journal=True, provisioned=False): + journal_path = tmp_path / f"orders-{time.time_ns()}.jsonl" if journal else None + if journal_path is not None and provisioned: + # Windows CTP tests exercise the provisioned-file path. This fixture is + # structural only; it does not establish target-FS power-loss durability. + journal_path.touch() return _ExecutionSession( { "market_data_only": True, "require_order_journal": require_journal, - "order_journal": ( - str(tmp_path / f"orders-{time.time_ns()}.jsonl") if journal else None - ), + "order_journal": str(journal_path) if journal_path is not None else None, + "windows_ctp_journal_preprovisioned": provisioned, "account_ids": {}, "required_environments": {VENUE: "demo"}, "strategy_id": "iter22-midfreq", @@ -179,6 +184,40 @@ def _reserve_budget(session, proof_value, *, mode="ordinary"): return session.reserve_ctp_execution_budget(_budget_evidence(session, proof_value), mode=mode) +def _bound_order( + session, + proof_value, + symbol, + *, + order_ref_number, + exchange_id="CZCE", + cycle="cycle-1", +): + runtime_order_id = f"test-runtime-order-{order_ref_number}" + budget = _reserve_budget(session, proof_value) + now_ns = time.time_ns() + deterministic_ns = ((now_ns // 10**12) + 1) * 10**12 + order_ref_number + with patch("bt_api_py._execution_session.time.time_ns", return_value=deterministic_ns): + binding = session.new_runtime_order_binding( + VENUE, + symbol=symbol, + account_id=ACCOUNT_FINGERPRINT, + runtime_order_id=runtime_order_id, + budget_capability=budget, + ) + return ( + _order( + symbol, + client_order_id=binding["client_order_id"], + runtime_order_id=runtime_order_id, + exchange_id=exchange_id, + cycle=cycle, + ), + budget, + binding, + ) + + def _ready_state(**changes): state = { "connected": True, @@ -640,22 +679,25 @@ def test_public_arm_verifies_exact_v2_bundle_scope(monkeypatch, tmp_path): def test_v2_bundle_allows_only_exact_czce_contract_legs_before_journal(tmp_path): - session = _session(tmp_path) + session = _session(tmp_path, provisioned=True) transport = Mock(return_value={"status": "accepted", "order_id": "SYS1"}) try: bundle = _bundle_proof() _arm_direct(session, proof=bundle, context=_context(bundle)) for index, symbol in enumerate(BUNDLE_INSTRUMENTS, start=1): + request, budget, _binding = _bound_order( + session, + bundle, + symbol.split(".", 1)[1], + order_ref_number=index, + ) session.invoke( "make_order", VENUE, - _order( - symbol.split(".", 1)[1], - client_order_id=f"00000000000{index}", - ), + request, transport, - budget_capability=_reserve_budget(session, bundle), + budget_capability=budget, ) submit_calls = session.submit_calls with pytest.raises(NormalizedApiError) as raised: @@ -682,16 +724,23 @@ def test_v2_bundle_accepts_native_dce_option_spelling_and_rejects_case_changes( scope_version=BUNDLE_SCOPE_VERSION, authorized_instruments=instruments, ) - session = _session(tmp_path) + session = _session(tmp_path, provisioned=True) transport = Mock(return_value={"status": "accepted", "order_id": "SYS1"}) try: _arm_direct(session, proof=proof, context=_context(proof)) + request, budget, _binding = _bound_order( + session, + proof, + "m2701-C-3400", + order_ref_number=1, + exchange_id="DCE", + ) session.invoke( "make_order", VENUE, - _order("m2701-C-3400", exchange_id="DCE"), + request, transport, - budget_capability=_reserve_budget(session, proof), + budget_capability=budget, ) with pytest.raises(NormalizedApiError) as raised: session.invoke( @@ -1136,6 +1185,7 @@ def test_armed_placement_still_runs_account_risk_guards( def _order( symbol, client_order_id="000000000001", + runtime_order_id=None, exchange_id="CZCE", cycle="cycle-1", ): @@ -1154,6 +1204,7 @@ def _order( execution_cycle_id=cycle, execution_role="entry", strategy_identity_sha256=STRATEGY_IDENTITY, + runtime_order_id=runtime_order_id, ) @@ -1264,18 +1315,24 @@ def stop(self): def test_armed_same_instrument_order_uses_normal_journal_and_transport_path(tmp_path): - session = _session(tmp_path) + session = _session(tmp_path, provisioned=True) transport = Mock(return_value=_order_update()) try: proof = _proof() _arm_direct(session, proof=proof) + request, budget, _binding = _bound_order( + session, + proof, + "SA609.CZCE", + order_ref_number=1, + ) result = session.invoke( "make_order", VENUE, - _order("SA609.CZCE"), + request, transport, - budget_capability=_reserve_budget(session, proof), + budget_capability=budget, ) assert result["status"] == "accepted" @@ -1286,6 +1343,7 @@ def test_armed_same_instrument_order_uses_normal_journal_and_transport_path(tmp_ assert [row["event"] for row in rows] == [ "ctp_budget_reservation_started", "ctp_budget_reservation_committed", + "client_id_reservation", "intent", "ctp_budget_action_started", "order_update", @@ -1294,26 +1352,74 @@ def test_armed_same_instrument_order_uses_normal_journal_and_transport_path(tmp_ session.close() +@pytest.mark.skipif(os.name != "nt", reason="Windows file identity guard") +def test_windows_ctp_journal_replacement_blocks_transport_dispatch(tmp_path): + session = _session(tmp_path, provisioned=True) + transport = Mock(return_value=_order_update()) + try: + proof = _proof() + _arm_direct(session, proof=proof) + request, budget, _binding = _bound_order( + session, + proof, + "SA609.CZCE", + order_ref_number=1, + ) + + replacement = session.path.with_suffix(".replacement") + replacement.write_text("", encoding="utf-8") + session.path.unlink() + replacement.replace(session.path) + + with pytest.raises(NormalizedApiError) as raised: + session.invoke( + "make_order", + VENUE, + request, + transport, + budget_capability=budget, + ) + + assert raised.value.code == "persistence_failed" + assert session.submit_calls == 0 + transport.assert_not_called() + finally: + session.close() + + def test_armed_tracked_same_instrument_cancel_remains_available(tmp_path): - session = _session(tmp_path) + session = _session(tmp_path, provisioned=True) place = Mock(return_value=_order_update()) cancel = Mock(return_value=_order_update(status="canceled", terminal=True)) try: proof = _proof() _arm_direct(session, proof=proof) + order_request, order_budget, binding = _bound_order( + session, + proof, + "SA609.CZCE", + order_ref_number=1, + ) session.invoke( "make_order", VENUE, - _order("SA609.CZCE"), + order_request, place, - budget_capability=_reserve_budget(session, proof), + budget_capability=order_budget, ) request = CancelOrderRequest( symbol="SA609.CZCE", account_id=ACCOUNT_FINGERPRINT, - client_order_id="000000000001", + client_order_id=binding["client_order_id"], order_id="SYS1", exchange_id="CZCE", + order_ref=binding["ctp_order_ref"], + runtime_order_id=binding["runtime_order_id"], + runtime_action_id=session.next_runtime_action_id( + VENUE, + account_id=ACCOUNT_FINGERPRINT, + runtime_order_id=binding["runtime_order_id"], + ), ) result = session.invoke( @@ -1332,6 +1438,7 @@ def test_armed_tracked_same_instrument_cancel_remains_available(tmp_path): assert [row["event"] for row in rows] == [ "ctp_budget_reservation_started", "ctp_budget_reservation_committed", + "client_id_reservation", "intent", "ctp_budget_action_started", "order_update", diff --git a/tests/bt_api_contract/test_execution_recovery.py b/tests/bt_api_contract/test_execution_recovery.py index 29d001e8..47fda987 100644 --- a/tests/bt_api_contract/test_execution_recovery.py +++ b/tests/bt_api_contract/test_execution_recovery.py @@ -5,13 +5,16 @@ import asyncio import hashlib import json +import os import queue import threading +import time from collections import Counter, deque +from dataclasses import replace from decimal import Decimal from types import SimpleNamespace from typing import Any -from unittest.mock import AsyncMock, Mock +from unittest.mock import AsyncMock, Mock, patch import pytest @@ -56,7 +59,9 @@ def __init__(self, generation=4): "settlement_readback_verified": True, "auto_settlement_confirm": False, "connection_generation": generation, - "account_fingerprint": ACCOUNT.removeprefix("acct_"), + "account_fingerprint": ( + ACCOUNT[len("acct_") :] if ACCOUNT.startswith("acct_") else ACCOUNT + ), "trading_day": TRADING_DAY, "environment_profile": "simnow_demo", } @@ -194,12 +199,17 @@ def context(value): } -def make_session(path, *, strategy_identity=STRATEGY_IDENTITY): +def make_session(path, *, strategy_identity=STRATEGY_IDENTITY, market_data_only=True): + provisioned = os.name == "nt" + if provisioned: + path.parent.mkdir(parents=True, exist_ok=True) + path.touch(exist_ok=True) return _ExecutionSession( { - "market_data_only": True, + "market_data_only": market_data_only, "require_order_journal": True, "order_journal": str(path), + "windows_ctp_journal_preprovisioned": provisioned, "account_ids": {}, "required_environments": {VENUE: "demo"}, "strategy_id": "iter22-midfreq", @@ -285,6 +295,59 @@ def _reserve_budget(session, proof_value, *, mode="ordinary"): return session.reserve_ctp_execution_budget(_budget_evidence(session, proof_value), mode=mode) +def reserve_runtime_order( + session, + proof_value, + *, + runtime_order_id, + managed_intent_id=None, + order_ref_number, + symbol="SA609.CZCE", + recovery_action=False, +): + """Use the production durable binding protocol with a deterministic test OrderRef.""" + mode = "recovery" if recovery_action else "ordinary" + budget = _reserve_budget(session, proof_value, mode=mode) + now_ns = time.time_ns() + deterministic_ns = ((now_ns // 10**12) + 1) * 10**12 + order_ref_number + with patch("bt_api_py._execution_session.time.time_ns", return_value=deterministic_ns): + binding = session.new_runtime_order_binding( + VENUE, + symbol=symbol, + account_id=ACCOUNT, + managed_intent_id=managed_intent_id, + runtime_order_id=runtime_order_id, + budget_capability=budget, + recovery_action=recovery_action, + ) + return binding, budget + + +def bound_order_request( + session, + proof_value, + *, + runtime_order_id, + order_ref_number, + recovery_action=False, + **request_kwargs, +): + binding, budget = reserve_runtime_order( + session, + proof_value, + runtime_order_id=runtime_order_id, + managed_intent_id=request_kwargs.get("managed_intent_id"), + order_ref_number=order_ref_number, + recovery_action=recovery_action, + ) + request = order_request( + client_order_id=binding["client_order_id"], + runtime_order_id=runtime_order_id, + **request_kwargs, + ) + return request, budget, binding + + def order_request( *, side=Side.BUY, @@ -294,6 +357,9 @@ def order_request( role="entry", client_order_id="000000000001", cycle=CYCLE, + runtime_order_id=None, + managed_intent_id=None, + hedge_flag=None, ): return OrderRequest( symbol="SA609.CZCE", @@ -311,10 +377,13 @@ def order_request( execution_cycle_id=cycle, execution_role=role, strategy_identity_sha256=STRATEGY_IDENTITY, + managed_intent_id=managed_intent_id, + hedge_flag=hedge_flag, + runtime_order_id=runtime_order_id, ) -def bundle_order_request(instrument, *, client_order_id, cycle=CYCLE): +def bundle_order_request(instrument, *, client_order_id, runtime_order_id, cycle=CYCLE): exchange_id, symbol = instrument.split(".", 1) return OrderRequest( symbol=symbol, @@ -332,6 +401,7 @@ def bundle_order_request(instrument, *, client_order_id, cycle=CYCLE): execution_cycle_id=cycle, execution_role="entry", strategy_identity_sha256=STRATEGY_IDENTITY, + runtime_order_id=runtime_order_id, ) @@ -704,15 +774,24 @@ def write_crashed_journal(path, *, exposure=None, active=False, uncertain=False) session.arm_from_preflight(old_proof, lambda: context(old_proof)) side = "sell" if exposure == "short" else "buy" position_side = "short" if exposure == "short" else "long" + runtime_order_id = "crashed-journal-order" + binding, budget = reserve_runtime_order( + session, + old_proof, + runtime_order_id=runtime_order_id, + order_ref_number=1, + ) request = order_request( side=Side.SELL if side == "sell" else Side.BUY, position_side=position_side, + client_order_id=binding["client_order_id"], + runtime_order_id=runtime_order_id, ) def submit(): if uncertain: raise TimeoutError("transport outcome unknown") - return order_update(side=side) + return order_update(side=side, client_order_id=binding["client_order_id"]) try: session.invoke( @@ -720,7 +799,7 @@ def submit(): VENUE, request, submit, - budget_capability=_reserve_budget(session, old_proof), + budget_capability=budget, ) except NormalizedApiError: if not uncertain: @@ -728,16 +807,30 @@ def submit(): if exposure is not None: session.event( VENUE, - trade_update(side=side, position_side=position_side), + trade_update( + side=side, + position_side=position_side, + client_order_id=binding["client_order_id"], + ), ) session.event( VENUE, - order_update(side=side, status="completed", terminal=True), + order_update( + side=side, + status="completed", + terminal=True, + client_order_id=binding["client_order_id"], + ), ) elif not active and not uncertain: session.event( VENUE, - order_update(side=side, status="canceled", terminal=True), + order_update( + side=side, + status="canceled", + terminal=True, + client_order_id=binding["client_order_id"], + ), ) session.close() @@ -759,12 +852,21 @@ def write_bundle_crashed_journal(path, *, instruments=BUNDLE_INSTRUMENTS[:2]): session.arm_from_preflight(old_proof, lambda: context(old_proof)) try: for index, instrument in enumerate(instruments, start=1): - client_order_id = f"0000000000{index:02d}" + runtime_order_id = f"bundle-fixture-order-{index}" order_id = f"SYS{index}" trade_id = f"TRADE{index}" + binding, budget = reserve_runtime_order( + session, + old_proof, + runtime_order_id=runtime_order_id, + order_ref_number=index, + symbol=instrument.split(".", 1)[1], + ) + client_order_id = binding["client_order_id"] request = bundle_order_request( instrument, client_order_id=client_order_id, + runtime_order_id=runtime_order_id, ) session.invoke( "make_order", @@ -777,7 +879,7 @@ def write_bundle_crashed_journal(path, *, instruments=BUNDLE_INSTRUMENTS[:2]): order_id=order_id, ) ), - budget_capability=_reserve_budget(session, old_proof), + budget_capability=budget, ) session.event( VENUE, @@ -1270,13 +1372,17 @@ def test_recovery_close_blocks_open_reverse_wrong_cycle_and_oversize(tmp_path): assert session.submit_calls == 0 transport.assert_not_called() - allowed = order_request( + allowed, recovery_budget, allowed_binding = bound_order_request( + session, + current_proof, + runtime_order_id="recovery-close-order", + order_ref_number=2, + recovery_action=True, side=Side.SELL, quantity="2", offset="close", position_side="long", role="recovery_exit", - client_order_id="000000000002", ) result = session.invoke( "make_order", @@ -1286,12 +1392,13 @@ def test_recovery_close_blocks_open_reverse_wrong_cycle_and_oversize(tmp_path): return_value=order_update( side="sell", quantity="2", - client_order_id="000000000002", + client_order_id=allowed_binding["client_order_id"], order_id="SYS2", offset="close", position_side="long", ) ), + budget_capability=recovery_budget, ) assert result["status"] == "accepted" with pytest.raises(NormalizedApiError): @@ -1303,11 +1410,24 @@ def test_recovery_close_blocks_open_reverse_wrong_cycle_and_oversize(tmp_path): def test_public_sync_recovery_exit_bypasses_entry_loss_latch_only(tmp_path): session = breached_recovery_session(tmp_path / "sync-orders.jsonl") + current_proof = session._arm_proof + close, recovery_budget, close_binding = bound_order_request( + session, + current_proof, + runtime_order_id="sync-recovery-exit", + order_ref_number=2, + recovery_action=True, + side=Side.SELL, + quantity="1", + offset="close", + position_side="long", + role="recovery_exit", + ) submit = Mock( return_value=order_update( side="sell", quantity="1", - client_order_id="000000000002", + client_order_id=close_binding["client_order_id"], order_id="SYS2", offset="close", position_side="long", @@ -1315,21 +1435,21 @@ def test_public_sync_recovery_exit_bypasses_entry_loss_latch_only(tmp_path): ) api = public_order_api(session, SimpleNamespace(make_order=submit)) entry = order_request(client_order_id="000000000003") - close = order_request( - side=Side.SELL, - quantity="1", - offset="close", - position_side="long", - role="recovery_exit", - client_order_id="000000000002", - ) try: with pytest.raises(NormalizedApiError) as raised: api.make_order(VENUE, entry, normalized=True) assert raised.value.code == "execution_recovery_open_forbidden" submit.assert_not_called() - assert api.make_order(VENUE, close, normalized=True)["status"] == "accepted" + assert ( + api.make_order( + VENUE, + close, + normalized=True, + budget_capability=recovery_budget, + )["status"] + == "accepted" + ) submit.assert_called_once() finally: session.close() @@ -1337,11 +1457,24 @@ def test_public_sync_recovery_exit_bypasses_entry_loss_latch_only(tmp_path): def test_public_async_recovery_exit_bypasses_entry_loss_latch_only(tmp_path): session = breached_recovery_session(tmp_path / "async-orders.jsonl") + current_proof = session._arm_proof + close, recovery_budget, close_binding = bound_order_request( + session, + current_proof, + runtime_order_id="async-recovery-exit", + order_ref_number=2, + recovery_action=True, + side=Side.SELL, + quantity="1", + offset="close", + position_side="long", + role="recovery_exit", + ) submit = AsyncMock( return_value=order_update( side="sell", quantity="1", - client_order_id="000000000002", + client_order_id=close_binding["client_order_id"], order_id="SYS2", offset="close", position_side="long", @@ -1349,14 +1482,6 @@ def test_public_async_recovery_exit_bypasses_entry_loss_latch_only(tmp_path): ) api = public_order_api(session, SimpleNamespace(async_make_order=submit)) entry = order_request(client_order_id="000000000003") - close = order_request( - side=Side.SELL, - quantity="1", - offset="close", - position_side="long", - role="recovery_exit", - client_order_id="000000000002", - ) async def run(): with pytest.raises(NormalizedApiError) as raised: @@ -1364,7 +1489,12 @@ async def run(): assert raised.value.code == "execution_recovery_open_forbidden" submit.assert_not_awaited() - result = await api.async_make_order(VENUE, close, normalized=True) + result = await api.async_make_order( + VENUE, + close, + normalized=True, + budget_capability=recovery_budget, + ) assert result["status"] == "accepted" submit.assert_awaited_once() @@ -1379,6 +1509,7 @@ def test_cancel_allowance_is_atomic_one_shot_and_refresh_rotates_token(tmp_path) write_crashed_journal(path, active=True) session, current_proof = prepare_from_journal(path) current = snapshot(orders=[active_order()]) + runtime_order_id = "crashed-journal-order" cancel = CancelOrderRequest( symbol="SA609.CZCE", account_id=ACCOUNT, @@ -1388,6 +1519,7 @@ def test_cancel_allowance_is_atomic_one_shot_and_refresh_rotates_token(tmp_path) front_id=11, session_id=22, order_ref="000000000001", + runtime_order_id=runtime_order_id, ) transport = Mock(return_value=order_update(status="canceled", terminal=True)) try: @@ -1400,7 +1532,23 @@ def test_cancel_allowance_is_atomic_one_shot_and_refresh_rotates_token(tmp_path) lambda: context(current_proof), budget_capability=_reserve_budget(session, current_proof, mode="recovery"), ) + cancel = replace( + cancel, + runtime_action_id=session.next_runtime_action_id( + VENUE, + account_id=ACCOUNT, + runtime_order_id=runtime_order_id, + ), + ) session.invoke("cancel_order", VENUE, cancel, transport) + cancel = replace( + cancel, + runtime_action_id=session.next_runtime_action_id( + VENUE, + account_id=ACCOUNT, + runtime_order_id=runtime_order_id, + ), + ) with pytest.raises(NormalizedApiError) as raised: session.invoke("cancel_order", VENUE, cancel, transport) assert raised.value.code == "execution_recovery_foreign_cancel" @@ -1431,6 +1579,8 @@ def test_failed_recovery_transport_consumes_action_and_pauses_lease(tmp_path): client_order_id="000000000001", order_id="SYS1", exchange_id="CZCE", + order_ref="000000000001", + runtime_order_id="crashed-journal-order", ) failed = Mock(side_effect=TimeoutError("unknown cancel result")) retry = Mock() @@ -1442,6 +1592,14 @@ def test_failed_recovery_transport_consumes_action_and_pauses_lease(tmp_path): lambda: context(current_proof), budget_capability=_reserve_budget(session, current_proof, mode="recovery"), ) + cancel = replace( + cancel, + runtime_action_id=session.next_runtime_action_id( + VENUE, + account_id=ACCOUNT, + runtime_order_id=cancel.runtime_order_id, + ), + ) result = session.invoke("cancel_order", VENUE, cancel, failed) assert result["execution_unknown"] is True assert session.config["market_data_only"] is True @@ -1456,16 +1614,21 @@ def test_failed_recovery_transport_consumes_action_and_pauses_lease(tmp_path): def test_sync_recovery_dispatch_blocks_sync_and_async_contenders(tmp_path): session = breached_recovery_session(tmp_path / "orders.jsonl") + current_proof = session._arm_proof entered = threading.Event() release = threading.Event() owner_result: queue.Queue[Any] = queue.Queue() - owner = order_request( + owner, owner_budget, owner_binding = bound_order_request( + session, + current_proof, + runtime_order_id="sync-recovery-dispatch-owner", + order_ref_number=2, + recovery_action=True, side=Side.SELL, quantity="1", offset="close", position_side="long", role="recovery_exit", - client_order_id="000000000002", ) contender = order_request( side=Side.SELL, @@ -1482,7 +1645,7 @@ def transport(): return order_update( side="sell", quantity="1", - client_order_id="000000000002", + client_order_id=owner_binding["client_order_id"], order_id="SYS2", offset="close", position_side="long", @@ -1490,7 +1653,15 @@ def transport(): def run_owner(): try: - owner_result.put(session.invoke("make_order", VENUE, owner, transport)) + owner_result.put( + session.invoke( + "make_order", + VENUE, + owner, + transport, + budget_capability=owner_budget, + ) + ) except Exception as exc: # pragma: no cover - assertion reports it owner_result.put(exc) @@ -1523,14 +1694,19 @@ def run_owner(): def test_async_recovery_cancel_releases_claim_and_disarms_native_gate(tmp_path): session = breached_recovery_session(tmp_path / "orders.jsonl") + current_proof = session._arm_proof entered = asyncio.Event() - owner = order_request( + owner, owner_budget, _owner_binding = bound_order_request( + session, + current_proof, + runtime_order_id="async-recovery-dispatch-owner", + order_ref_number=2, + recovery_action=True, side=Side.SELL, quantity="1", offset="close", position_side="long", role="recovery_exit", - client_order_id="000000000002", ) contender = order_request( side=Side.SELL, @@ -1552,7 +1728,14 @@ async def blocked_transport(*_args): ) async def run(): - task = asyncio.create_task(api.async_make_order(VENUE, owner, normalized=True)) + task = asyncio.create_task( + api.async_make_order( + VENUE, + owner, + normalized=True, + budget_capability=owner_budget, + ) + ) await asyncio.wait_for(entered.wait(), timeout=5) assert session._recovery_dispatch_in_progress is True @@ -1580,7 +1763,7 @@ async def run(): side="sell", status="canceled", terminal=True, - client_order_id="000000000002", + client_order_id=owner.client_order_id, order_id="SYS2", offset="close", position_side="long", @@ -1593,21 +1776,31 @@ async def run(): def test_failed_public_recovery_transport_disarms_native_gate_before_return(tmp_path): session = breached_recovery_session(tmp_path / "orders.jsonl") - failed = Mock(side_effect=TimeoutError("unknown recovery result")) - api, feed, stream = public_recovery_order_api( + current_proof = session._arm_proof + close, recovery_budget, close_binding = bound_order_request( session, - SimpleNamespace(make_order=failed), - ) - close = order_request( + current_proof, + runtime_order_id="failed-public-recovery-order", + order_ref_number=2, + recovery_action=True, side=Side.SELL, quantity="2", offset="close", position_side="long", role="recovery_exit", - client_order_id="000000000002", + ) + failed = Mock(side_effect=TimeoutError("unknown recovery result")) + api, feed, stream = public_recovery_order_api( + session, + SimpleNamespace(make_order=failed), ) try: - result = api.make_order(VENUE, close, normalized=True) + result = api.make_order( + VENUE, + close, + normalized=True, + budget_capability=recovery_budget, + ) assert result["execution_unknown"] is True assert session._recovery_dispatch_in_progress is False assert session.config["market_data_only"] is True @@ -1623,7 +1816,7 @@ def test_failed_public_recovery_transport_disarms_native_gate_before_return(tmp_ side="sell", status="canceled", terminal=True, - client_order_id="000000000002", + client_order_id=close_binding["client_order_id"], order_id="SYS2", offset="close", position_side="long", @@ -1636,6 +1829,19 @@ def test_failed_public_recovery_transport_disarms_native_gate_before_return(tmp_ def test_pending_private_ingress_blocks_write_before_transport(tmp_path): session = breached_recovery_session(tmp_path / "orders.jsonl") + current_proof = session._arm_proof + close, recovery_budget, _close_binding = bound_order_request( + session, + current_proof, + runtime_order_id="pending-private-recovery-order", + order_ref_number=2, + recovery_action=True, + side=Side.SELL, + quantity="1", + offset="close", + position_side="long", + role="recovery_exit", + ) transport = Mock() api, feed, stream = public_recovery_order_api( session, @@ -1666,16 +1872,13 @@ def blocked_note(venue, ordered_after_write=False): producer.start() try: assert note_entered.wait(timeout=5) - close = order_request( - side=Side.SELL, - quantity="1", - offset="close", - position_side="long", - role="recovery_exit", - client_order_id="000000000002", - ) with pytest.raises(NormalizedApiError) as raised: - api.make_order(VENUE, close, normalized=True) + api.make_order( + VENUE, + close, + normalized=True, + budget_capability=recovery_budget, + ) assert raised.value.code == "execution_private_event_pending" transport.assert_not_called() finally: @@ -1724,24 +1927,40 @@ def test_ordinary_arm_allows_durable_cycle_only_after_entry_and_exit_net_flat( old_proof = proof(3) writer = make_session(path) writer.arm_from_preflight(old_proof, lambda: context(old_proof)) + entry_request, entry_budget, entry_binding = bound_order_request( + writer, + old_proof, + runtime_order_id="ordinary-cycle-entry", + order_ref_number=1, + ) writer.invoke( "make_order", VENUE, - order_request(), - lambda: order_update(), - budget_capability=_reserve_budget(writer, old_proof), + entry_request, + lambda: order_update(client_order_id=entry_binding["client_order_id"]), + budget_capability=entry_budget, ) - writer.event(VENUE, trade_update()) writer.event( VENUE, - order_update(status="completed", terminal=True), + trade_update(client_order_id=entry_binding["client_order_id"]), ) - close_request = order_request( + writer.event( + VENUE, + order_update( + status="completed", + terminal=True, + client_order_id=entry_binding["client_order_id"], + ), + ) + close_request, close_budget, close_binding = bound_order_request( + writer, + old_proof, + runtime_order_id="ordinary-cycle-exit", + order_ref_number=2, side=Side.SELL, offset="close", position_side="long", role="exit", - client_order_id="000000000002", ) writer.invoke( "make_order", @@ -1749,19 +1968,19 @@ def test_ordinary_arm_allows_durable_cycle_only_after_entry_and_exit_net_flat( close_request, lambda: order_update( side="sell", - client_order_id="000000000002", + client_order_id=close_binding["client_order_id"], order_id="SYS2", offset="close", position_side="long", ), - budget_capability=_reserve_budget(writer, old_proof), + budget_capability=close_budget, ) writer.event( VENUE, trade_update( side="sell", position_side="long", - client_order_id="000000000002", + client_order_id=close_binding["client_order_id"], order_id="SYS2", offset="close", trade_id="TRADE2", @@ -1773,7 +1992,7 @@ def test_ordinary_arm_allows_durable_cycle_only_after_entry_and_exit_net_flat( side="sell", status="completed", terminal=True, - client_order_id="000000000002", + client_order_id=close_binding["client_order_id"], order_id="SYS2", offset="close", position_side="long", @@ -1883,16 +2102,26 @@ def test_ordinary_arm_rejects_terminal_fill_without_durable_trade(tmp_path): old_proof = proof(3) writer = make_session(path) writer.arm_from_preflight(old_proof, lambda: context(old_proof)) + request, budget, binding = bound_order_request( + writer, + old_proof, + runtime_order_id="terminal-fill-order", + order_ref_number=1, + ) writer.invoke( "make_order", VENUE, - order_request(), - lambda: order_update(), - budget_capability=_reserve_budget(writer, old_proof), + request, + lambda: order_update(client_order_id=binding["client_order_id"]), + budget_capability=budget, ) writer.event( VENUE, - order_update(status="completed", terminal=True), + order_update( + status="completed", + terminal=True, + client_order_id=binding["client_order_id"], + ), ) writer.close() @@ -2344,3 +2573,624 @@ def test_prepare_authorization_fences_old_generation_but_allows_new_proof(tmp_pa assert session.arm_from_preflight(new_proof, lambda: context(new_proof))["armed"] is True finally: session.close() + + +def test_ctp_runtime_orderref_binding_is_fsynced_and_orphan_retry_freezes(tmp_path): + path = tmp_path / "orders.jsonl" + current_proof = proof(4) + session = make_session(path) + runtime_order_id = "framework-order-0042" + try: + session.arm_from_preflight(current_proof, lambda: context(current_proof)) + budget = _reserve_budget(session, current_proof) + binding = session.new_runtime_order_binding( + VENUE, + symbol="SA609.CZCE", + account_id=ACCOUNT, + runtime_order_id=runtime_order_id, + budget_capability=budget, + ) + assert binding["runtime_order_id"] == runtime_order_id + assert binding["client_order_id"] == binding["ctp_order_ref"] + assert len(binding["ctp_order_ref"]) == 12 + assert binding["ctp_order_ref"].isascii() and binding["ctp_order_ref"].isdigit() + reservation_rows = [ + json.loads(line) + for line in path.read_text().splitlines() + if json.loads(line).get("event") == "client_id_reservation" + ] + assert len(reservation_rows) == 1 + assert reservation_rows[0]["runtime_order_id"] == runtime_order_id + assert reservation_rows[0]["client_order_id"] == binding["ctp_order_ref"] + assert reservation_rows[0]["connection_generation"] == 4 + finally: + session.close() + + recovered = make_session(path) + try: + recovered.arm_from_preflight(current_proof, lambda: context(current_proof)) + rows = recovered.get_runtime_order_bindings(VENUE, unresolved_only=False) + assert rows == [ + { + "runtime_order_id": runtime_order_id, + "client_order_id": binding["client_order_id"], + "ctp_order_ref": binding["ctp_order_ref"], + "symbol": "SA609.CZCE", + "connection_generation": 4, + "trading_day": TRADING_DAY, + "status": "reservation_only", + "safe_burn": True, + "recovery_required": True, + } + ] + assert recovered._unknown_ids() == set() + with pytest.raises(NormalizedApiError) as raised: + recovered.new_runtime_order_binding( + VENUE, + symbol="SA609.CZCE", + account_id=ACCOUNT, + runtime_order_id="a-different-order-after-crash", + budget_capability=_reserve_budget(recovered, current_proof), + ) + assert raised.value.code == "runtime_order_binding_recovery_required" + finally: + recovered.close() + + +def test_managed_intent_binding_is_persisted_and_cannot_be_reused(tmp_path): + path = tmp_path / "managed-intent-binding.jsonl" + current_proof = proof(4) + runtime_order_id = "runtime-scope-intent-42" + managed_intent_id = "managed-intent-42" + session = make_session(path) + try: + session.arm_from_preflight(current_proof, lambda: context(current_proof)) + binding, budget = reserve_runtime_order( + session, + current_proof, + runtime_order_id=runtime_order_id, + managed_intent_id=managed_intent_id, + order_ref_number=42, + ) + request = order_request( + client_order_id=binding["client_order_id"], + runtime_order_id=runtime_order_id, + managed_intent_id="managed-intent-other", + hedge_flag="2", + ) + transport = Mock(return_value=order_update(client_order_id=binding["client_order_id"])) + api = public_order_api(session, SimpleNamespace(make_order=transport)) + with pytest.raises(NormalizedApiError) as raised: + api.make_order( + VENUE, + replace(request, managed_intent_id=None), + normalized=True, + budget_capability=budget, + ) + assert raised.value.code == "ctp_managed_order_identity_mismatch" + transport.assert_not_called() + with pytest.raises(NormalizedApiError) as raised: + api.make_order( + VENUE, + order_request( + client_order_id="000000000099", + runtime_order_id=runtime_order_id, + managed_intent_id=managed_intent_id, + hedge_flag="2", + ), + normalized=True, + budget_capability=budget, + ) + assert raised.value.code == "ctp_runtime_order_binding_conflict" + transport.assert_not_called() + with pytest.raises(NormalizedApiError) as raised: + session.invoke( + "make_order", + VENUE, + request, + transport, + budget_capability=budget, + ) + assert raised.value.code == "ctp_runtime_order_binding_conflict" + transport.assert_not_called() + with pytest.raises(NormalizedApiError) as raised: + session.new_runtime_order_binding( + VENUE, + symbol="SA609.CZCE", + account_id=ACCOUNT, + managed_intent_id="managed-intent-other", + runtime_order_id=runtime_order_id, + budget_capability=_reserve_budget(session, current_proof), + ) + assert raised.value.code == "runtime_order_id_collision_or_recovery_required" + with pytest.raises(NormalizedApiError) as raised: + session.new_runtime_order_binding( + VENUE, + symbol="SA609.CZCE", + account_id=ACCOUNT, + managed_intent_id=managed_intent_id, + runtime_order_id="different-runtime-for-same-intent", + budget_capability=_reserve_budget(session, current_proof), + ) + assert raised.value.code == "managed_intent_runtime_order_conflict" + finally: + session.close() + + recovered = make_session(path) + try: + recovered.arm_from_preflight(current_proof, lambda: context(current_proof)) + rows = recovered.get_runtime_order_bindings( + VENUE, unresolved_only=False, runtime_order_id=runtime_order_id + ) + assert len(rows) == 1 + assert rows[0]["managed_intent_id"] == managed_intent_id + with pytest.raises(NormalizedApiError) as raised: + recovered.new_runtime_order_binding( + VENUE, + symbol="SA609.CZCE", + account_id=ACCOUNT, + managed_intent_id=managed_intent_id, + runtime_order_id=runtime_order_id, + budget_capability=_reserve_budget(recovered, current_proof), + ) + assert raised.value.code == "runtime_order_binding_recovery_required" + finally: + recovered.close() + + +def test_managed_cancel_resolves_only_durable_orderref_and_sdk_action_id(tmp_path): + path = tmp_path / "managed-cancel-identity.jsonl" + current_proof = proof(4) + session = make_session(path) + session.arm_from_preflight(current_proof, lambda: context(current_proof)) + order, order_budget, binding = bound_order_request( + session, + current_proof, + runtime_order_id="managed-cancel-target", + managed_intent_id="managed-cancel-intent", + order_ref_number=43, + hedge_flag="1", + ) + session.invoke( + "make_order", + VENUE, + order, + lambda: order_update(client_order_id=binding["client_order_id"]), + budget_capability=order_budget, + ) + native_cancel = Mock( + return_value=order_update( + status="canceled", + terminal=True, + client_order_id=binding["client_order_id"], + ) + ) + api = public_order_api(session, SimpleNamespace(cancel_order=native_cancel)) + # This API shell isolates request binding; native-gate lifecycle is covered + # by its dedicated contract tests. + api._finalize_ctp_execution_dispatch = lambda *_args: None + api._sync_ctp_gate_after_session_invoke = lambda *_args: None + cancel = CancelOrderRequest( + symbol=order.symbol, + account_id=order.account_id, + runtime_order_id=binding["runtime_order_id"], + managed_cancel_intent_id="cancel.managed-cancel-intent", + ) + try: + with pytest.raises(NormalizedApiError) as raised: + api.cancel_order( + VENUE, + replace(cancel, managed_cancel_intent_id=None), + normalized=True, + budget_capability=_reserve_budget(session, current_proof), + ) + assert raised.value.code == "ctp_managed_cancel_identity_required" + with pytest.raises(NormalizedApiError) as raised: + api.cancel_order( + VENUE, + replace(cancel, order_ref="caller-order-ref"), + normalized=True, + budget_capability=_reserve_budget(session, current_proof), + ) + assert raised.value.code == "ctp_runtime_cancel_reference_mismatch" + with pytest.raises(NormalizedApiError) as raised: + api.cancel_order( + VENUE, + replace(cancel, runtime_action_id="caller-action-id"), + normalized=True, + budget_capability=_reserve_budget(session, current_proof), + ) + assert raised.value.code == "ctp_runtime_cancel_action_id_mismatch" + native_cancel.assert_not_called() + api.cancel_order( + VENUE, + cancel, + normalized=True, + budget_capability=_reserve_budget(session, current_proof), + ) + dispatched_request = native_cancel.call_args.args[1] + assert dispatched_request.client_order_id == binding["client_order_id"] + assert dispatched_request.order_ref == binding["ctp_order_ref"] + assert dispatched_request.order_id is None + assert dispatched_request.runtime_order_id == binding["runtime_order_id"] + assert dispatched_request.runtime_action_id + assert dispatched_request.managed_cancel_intent_id == cancel.managed_cancel_intent_id + assert native_cancel.call_count == 1 + finally: + session.close() + + +@pytest.mark.parametrize("damage", ["duplicate_order", "truncated_tail"]) +def test_ctp_runtime_orderref_journal_collision_or_truncation_fails_closed(tmp_path, damage): + path = tmp_path / "orders.jsonl" + current_proof = proof(4) + session = make_session(path) + try: + session.arm_from_preflight(current_proof, lambda: context(current_proof)) + session.new_runtime_order_binding( + VENUE, + symbol="SA609.CZCE", + account_id=ACCOUNT, + runtime_order_id="collision-probe", + budget_capability=_reserve_budget(session, current_proof), + ) + finally: + session.close() + + if damage == "duplicate_order": + row = next( + json.loads(line) + for line in path.read_text().splitlines() + if json.loads(line).get("event") == "client_id_reservation" + ) + row["client_order_id"] = f"{(int(row['client_order_id']) + 1) % 10**12:012d}" + with path.open("a", encoding="utf-8") as stream: + stream.write(json.dumps(row) + "\n") + else: + with path.open("a", encoding="utf-8") as stream: + stream.write('{"event":"client_id_reservation"') + + with pytest.raises(NormalizedApiError) as raised: + make_session(path, market_data_only=False) + assert raised.value.code == "unreadable_journal" + + +def test_ctp_runtime_orderref_reverse_reservation_collision_fails_closed(tmp_path): + path = tmp_path / "orders.jsonl" + current_proof = proof(4) + session = make_session(path) + try: + session.arm_from_preflight(current_proof, lambda: context(current_proof)) + reserve_runtime_order( + session, + current_proof, + runtime_order_id="first-runtime-order", + order_ref_number=41, + ) + finally: + session.close() + + rows = [json.loads(line) for line in path.read_text().splitlines()] + reservation = next(row for row in rows if row.get("event") == "client_id_reservation") + collision = dict(reservation, runtime_order_id="different-runtime-order") + with path.open("a", encoding="utf-8") as stream: + stream.write(json.dumps(collision) + "\n") + + with pytest.raises(NormalizedApiError) as raised: + make_session(path, market_data_only=False) + assert raised.value.code == "unreadable_journal" + + +@pytest.mark.parametrize("async_call", [False, True], ids=["sync", "async"]) +@pytest.mark.parametrize("reservation_kind", ["legacy", "runtime"], ids=["legacy_id", "bound_ref"]) +def test_managed_ctp_order_cannot_dispatch_without_runtime_binding( + tmp_path, async_call, reservation_kind +): + path = tmp_path / f"missing-runtime-{async_call}-{reservation_kind}.jsonl" + session = make_session(path) + current_proof = proof(4) + session.arm_from_preflight(current_proof, lambda: context(current_proof)) + if reservation_kind == "legacy": + budget = _reserve_budget(session, current_proof) + client_order_id = session.new_client_order_id(VENUE, account_id=ACCOUNT) + else: + binding, budget = reserve_runtime_order( + session, + current_proof, + runtime_order_id="reserved-but-unbound-order", + order_ref_number=42, + ) + client_order_id = binding["client_order_id"] + request = order_request(client_order_id=client_order_id) + sync_native = Mock(return_value=order_update(client_order_id=client_order_id)) + async_native = AsyncMock(return_value=order_update(client_order_id=client_order_id)) + api = public_order_api( + session, + SimpleNamespace(make_order=sync_native, async_make_order=async_native), + ) + try: + if async_call: + + async def run(): + with pytest.raises(NormalizedApiError) as raised: + await api.async_make_order( + VENUE, + request, + normalized=True, + budget_capability=budget, + ) + return raised.value + + error = asyncio.run(run()) + async_native.assert_not_awaited() + else: + with pytest.raises(NormalizedApiError) as raised: + api.make_order( + VENUE, + request, + normalized=True, + budget_capability=budget, + ) + error = raised.value + sync_native.assert_not_called() + assert error.code == "ctp_runtime_order_identity_required" + assert session.submit_calls == 0 + assert not any( + json.loads(line).get("event") == "intent" for line in path.read_text().splitlines() + ) + finally: + session.close() + + +@pytest.mark.parametrize("async_call", [False, True], ids=["sync", "async"]) +def test_managed_ctp_cancel_requires_runtime_action_identity_before_native_dispatch( + tmp_path, async_call +): + path = tmp_path / f"missing-cancel-runtime-{async_call}.jsonl" + session = make_session(path) + current_proof = proof(4) + session.arm_from_preflight(current_proof, lambda: context(current_proof)) + order, budget, binding = bound_order_request( + session, + current_proof, + runtime_order_id="cancel-target-order", + order_ref_number=43, + ) + try: + session.invoke( + "make_order", + VENUE, + order, + lambda: order_update(client_order_id=binding["client_order_id"]), + budget_capability=budget, + ) + cancel = CancelOrderRequest( + symbol=order.symbol, + account_id=order.account_id, + client_order_id=binding["client_order_id"], + order_id="SYS1", + exchange_id="CZCE", + order_ref=binding["ctp_order_ref"], + ) + sync_native = Mock(return_value=order_update(status="canceled", terminal=True)) + async_native = AsyncMock(return_value=order_update(status="canceled", terminal=True)) + api = public_order_api( + session, + SimpleNamespace( + cancel_order=sync_native, + async_cancel_order=async_native, + ), + ) + if async_call: + + async def run(): + with pytest.raises(NormalizedApiError) as raised: + await api.async_cancel_order( + VENUE, + cancel, + normalized=True, + ) + return raised.value + + error = asyncio.run(run()) + async_native.assert_not_awaited() + else: + with pytest.raises(NormalizedApiError) as raised: + api.cancel_order(VENUE, cancel, normalized=True) + error = raised.value + sync_native.assert_not_called() + assert error.code == "ctp_runtime_cancel_identity_required" + assert session.cancel_calls == 0 + assert not any( + json.loads(line).get("event") == "cancel_intent" + for line in path.read_text().splitlines() + ) + finally: + session.close() + + +def test_session_async_ctp_cancel_journals_runtime_action_id_for_local_transport(tmp_path): + path = tmp_path / "async-cancel-action-id.jsonl" + session = make_session(path) + current_proof = proof(4) + session.arm_from_preflight(current_proof, lambda: context(current_proof)) + order, order_budget, binding = bound_order_request( + session, + current_proof, + runtime_order_id="async-cancel-target-order", + order_ref_number=44, + ) + session.invoke( + "make_order", + VENUE, + order, + lambda: order_update(client_order_id=binding["client_order_id"]), + budget_capability=order_budget, + ) + cancel = CancelOrderRequest( + symbol=order.symbol, + account_id=order.account_id, + client_order_id=binding["client_order_id"], + order_id="SYS1", + exchange_id="CZCE", + order_ref=binding["ctp_order_ref"], + runtime_order_id=binding["runtime_order_id"], + ) + + observed_requests = [] + observed_journal = [] + + async def local_transport(): + cancel_row = next( + json.loads(line) + for line in path.read_text().splitlines() + if json.loads(line).get("event") == "cancel_intent" + ) + observed_requests.append(cancel) + observed_journal.append(cancel_row) + return order_update( + status="canceled", + terminal=True, + client_order_id=binding["client_order_id"], + ) + + runtime_action_id = session.next_runtime_action_id( + VENUE, + account_id=cancel.account_id, + runtime_order_id=cancel.runtime_order_id, + ) + cancel = replace(cancel, runtime_action_id=runtime_action_id) + try: + result = asyncio.run( + session.async_invoke( + "cancel_order", + VENUE, + cancel, + local_transport, + budget_capability=_reserve_budget(session, current_proof), + ) + ) + assert result is not None + assert observed_requests == [cancel] + assert cancel.runtime_action_id == observed_journal[0]["runtime_action_id"] + assert observed_journal[0]["runtime_order_id"] == binding["runtime_order_id"] + assert session.get_runtime_action_bindings( + VENUE, runtime_order_id=binding["runtime_order_id"] + ) == [ + { + "runtime_action_id": cancel.runtime_action_id, + "runtime_order_id": binding["runtime_order_id"], + "client_order_id": binding["client_order_id"], + "connection_generation": 4, + } + ] + finally: + session.close() + + +def test_ctp_cancel_attempt_identity_is_journaled_and_rebuilt_after_restart(tmp_path): + path = tmp_path / "orders.jsonl" + current_proof = proof(4) + runtime_order_id = "framework-order-cancel-1" + session = make_session(path) + try: + session.arm_from_preflight(current_proof, lambda: context(current_proof)) + budget = _reserve_budget(session, current_proof) + binding = session.new_runtime_order_binding( + VENUE, + symbol="SA609.CZCE", + account_id=ACCOUNT, + runtime_order_id=runtime_order_id, + budget_capability=budget, + ) + request = order_request( + client_order_id=binding["client_order_id"], + runtime_order_id=runtime_order_id, + ) + session.invoke( + "make_order", + VENUE, + request, + lambda: order_update(client_order_id=binding["client_order_id"]), + budget_capability=budget, + ) + first_action_id = session.next_runtime_action_id( + VENUE, + account_id=ACCOUNT, + runtime_order_id=runtime_order_id, + ) + cancel = CancelOrderRequest( + symbol=request.symbol, + account_id=ACCOUNT, + client_order_id=binding["client_order_id"], + order_id="SYS1", + exchange_id="CZCE", + order_ref=binding["ctp_order_ref"], + runtime_order_id=runtime_order_id, + runtime_action_id=first_action_id, + ) + result = session.invoke( + "cancel_order", + VENUE, + cancel, + lambda: order_update( + status="canceled", + terminal=True, + client_order_id=binding["client_order_id"], + ), + budget_capability=budget, + ) + assert result["terminal_confirmed"] is True + journal_cancel = next( + json.loads(line) + for line in path.read_text().splitlines() + if json.loads(line).get("event") == "cancel_intent" + ) + assert journal_cancel["runtime_action_id"] == first_action_id + assert journal_cancel["runtime_order_id"] == runtime_order_id + next_action_id = session.next_runtime_action_id( + VENUE, + account_id=ACCOUNT, + runtime_order_id=runtime_order_id, + ) + assert next_action_id != first_action_id + finally: + session.close() + + recovered = make_session(path) + try: + recovered.arm_from_preflight(current_proof, lambda: context(current_proof)) + assert ( + recovered._runtime_action_attempts[ + recovered._runtime_order_key( + VENUE, + {"account_id": ACCOUNT, "strategy_id": "iter22-midfreq"}, + runtime_order_id, + ) + ] + == 1 + ) + assert ( + recovered.next_runtime_action_id( + VENUE, + account_id=ACCOUNT, + runtime_order_id=runtime_order_id, + ) + == next_action_id + ) + assert recovered.get_runtime_action_bindings(VENUE, runtime_order_id=runtime_order_id) == [ + { + "runtime_action_id": first_action_id, + "runtime_order_id": runtime_order_id, + "client_order_id": binding["client_order_id"], + "connection_generation": 4, + } + ] + finally: + recovered.close() + + with path.open("a", encoding="utf-8") as stream: + stream.write(json.dumps(journal_cancel) + "\n") + with pytest.raises(NormalizedApiError) as raised: + make_session(path, market_data_only=False) + assert raised.value.code == "unreadable_journal" diff --git a/tests/bt_api_contract/test_execution_session.py b/tests/bt_api_contract/test_execution_session.py index 507821fd..a3cbfe46 100644 --- a/tests/bt_api_contract/test_execution_session.py +++ b/tests/bt_api_contract/test_execution_session.py @@ -893,6 +893,7 @@ def run(): [ {"market_data_only": 1}, {"require_order_journal": "true"}, + {"windows_ctp_journal_preprovisioned": "true"}, {"account_currency": ""}, {"account_currency": 1}, {"account_currencies": {VENUE: ""}}, @@ -1363,6 +1364,167 @@ def reconcile(**manifest): ) +@pytest.mark.parametrize( + ("record", "claim"), + [ + ( + { + "event": "intent", + "exchange_name": "CTP___SIMNOW", + "client_order_id": "ctp-order", + }, + { + "provider": "CTP", + "environment": "demo", + "account_id": "ctp-account", + "strategy_id": "ctp-strategy", + }, + ), + ( + { + "event": "ctp_execution_approval_consumed", + "client_order_id": "approval-record", + }, + { + "provider": "OKX", + "environment": "demo", + "account_id": "demo-a", + "strategy_id": "spread-a", + "credential_fingerprint": MIGRATION_FINGERPRINT, + }, + ), + ], +) +def test_windows_ctp_migration_refuses_before_remote_reconcile_or_publish( + monkeypatch, tmp_path, record, claim +): + monkeypatch.setattr(execution_session_module, "_migration_platform_name", lambda: "nt") + source = tmp_path / "legacy-ctp.jsonl" + destination = tmp_path / "migrated-ctp.jsonl" + source_bytes = (json.dumps(record) + "\n").encode() + source.write_bytes(source_bytes) + remote_reconcile = Mock() + + with pytest.raises( + NormalizedApiError, + match="windows_ctp_migration_durability_unavailable", + ): + migrate_execution_journal( + source, + destination, + {record["client_order_id"]: claim}, + remote_reconcile=remote_reconcile, + ) + + assert remote_reconcile.call_count == 0 + assert source.read_bytes() == source_bytes + assert not destination.exists() + assert not Path(str(source) + ".freeze").exists() + assert not Path(str(destination) + ".cutover.transaction.json").exists() + + +@pytest.mark.parametrize("transaction_provider", ["CTP", "OKX"]) +def test_windows_partial_ctp_cutover_recovery_refuses_without_touching_transaction( + monkeypatch, tmp_path, transaction_provider +): + monkeypatch.setattr(execution_session_module, "_migration_platform_name", lambda: "nt") + source = tmp_path / "partial-ctp.jsonl" + destination = tmp_path / "published-ctp.jsonl" + source_bytes = ( + json.dumps( + { + "event": "intent", + "exchange_name": "CTP___SIMNOW", + "client_order_id": "partial-ctp-order", + } + ) + + "\n" + ).encode() + source.write_bytes(source_bytes) + transaction_path = Path(str(destination) + ".cutover.transaction.json") + transaction = { + "schema_version": 2, + "status": "PREPARED", + "migration_id": "partial-ctp", + "source": str(source.resolve()), + "destination": str(destination.resolve()), + "staging": str(tmp_path / ".published-ctp.validated"), + "sealed_source": str(tmp_path / "partial-ctp.jsonl.partial-ctp.sealed"), + "ledger_identity": { + "provider": transaction_provider, + "environment": "demo", + "account_id": "ctp-account" if transaction_provider == "CTP" else "demo-a", + **( + {"account_fingerprint": "a" * 64} + if transaction_provider == "CTP" + else {"credential_fingerprint": MIGRATION_FINGERPRINT} + ), + }, + } + transaction_bytes = json.dumps(transaction).encode() + transaction_path.write_bytes(transaction_bytes) + monkeypatch.setattr( + execution_session_module, + "_lock_existing_journal", + lambda *_args: pytest.fail("recovery acquired a source lease before rejecting CTP"), + ) + + with pytest.raises( + NormalizedApiError, + match="windows_ctp_migration_durability_unavailable", + ): + migrate_execution_journal(source, destination, {}) + + assert source.read_bytes() == source_bytes + assert transaction_path.read_bytes() == transaction_bytes + assert not destination.exists() + assert not Path(transaction["staging"]).exists() + assert not Path(transaction["sealed_source"]).exists() + + +def test_windows_non_ctp_journal_migration_keeps_existing_cutover_behavior(monkeypatch, tmp_path): + monkeypatch.setattr(execution_session_module, "_migration_platform_name", lambda: "nt") + monkeypatch.setattr( + execution_session_module, + "_ledger_registry_root", + lambda: tmp_path / "execution-ledgers", + ) + source = tmp_path / "legacy-okx-windows.jsonl" + destination = tmp_path / "migrated-okx-windows.jsonl" + source.write_text( + json.dumps( + { + "event": "intent", + "exchange_name": VENUE, + "client_order_id": "okx-windows-migration", + } + ) + + "\n" + ) + claims = { + "okx-windows-migration": { + "provider": "OKX", + "environment": "demo", + "account_id": "demo-a", + "strategy_id": "spread-a", + "credential_fingerprint": MIGRATION_FINGERPRINT, + } + } + + def reconcile(**manifest): + return {"verified": True, "unknown_ids": [], **manifest} + + report = migrate_execution_journal( + source, + destination, + claims, + remote_reconcile=reconcile, + ) + + assert report["status"] == "COMPLETE" + assert destination.is_file() + + def test_migration_detects_raw_concurrent_append_after_reconcile(monkeypatch, tmp_path): monkeypatch.setattr( execution_session_module, @@ -1961,6 +2123,72 @@ def fsync(fd): assert api.get_execution_summary()["trading_blocked"] +def test_windows_ctp_preprovision_gate_requires_operator_attestation_and_existing_file( + tmp_path, +): + path = tmp_path / "provisioned-ctp.jsonl" + path.touch() + config = execution_session_module.session_config({"windows_ctp_journal_preprovisioned": False}) + + with pytest.raises(NormalizedApiError, match="windows_ctp_journal_provisioning_required"): + execution_session_module._require_windows_ctp_journal_preprovision( + path, + config, + journal_identity_at_open=execution_session_module._journal_file_identity(path), + platform_name="nt", + ) + + config["windows_ctp_journal_preprovisioned"] = True + with pytest.raises(NormalizedApiError, match="windows_ctp_journal_must_be_preprovisioned"): + execution_session_module._require_windows_ctp_journal_preprovision( + path, + config, + journal_identity_at_open=None, + platform_name="nt", + ) + + assert ( + execution_session_module._require_windows_ctp_journal_preprovision( + path, + config, + journal_identity_at_open=execution_session_module._journal_file_identity(path), + platform_name="nt", + ) + is None + ) + + +def test_windows_ctp_journal_does_not_create_first_file(tmp_path, monkeypatch): + path = tmp_path / "first-ctp.jsonl" + session = execution_session_module._ExecutionSession( + { + "market_data_only": True, + "order_journal": str(path), + "windows_ctp_journal_preprovisioned": True, + }, + exchange_names=("CTP___FUTURE",), + ) + original = execution_session_module._require_windows_ctp_journal_preprovision + + def require_windows_preprovision(*args, **kwargs): + return original(*args, **kwargs, platform_name="nt") + + monkeypatch.setattr( + execution_session_module, + "_require_windows_ctp_journal_preprovision", + require_windows_preprovision, + ) + with pytest.raises(NormalizedApiError, match="persistence_failed"): + session._journal( + "intent", + {"exchange_name": "CTP___FUTURE"}, + allow_read_only=True, + ) + + assert not path.exists() + session.close() + + @pytest.mark.parametrize( "error", [TimeoutError("signed URL secret"), RuntimeError("signed URL secret")] ) @@ -2490,7 +2718,7 @@ def failure(): def test_ctp_trade_authority_and_native_cancel_locator_are_preserved(factory): - api = factory() + api = factory(legacy=True) venue = "CTP___FUTURE" api.data_queues[venue] = Queue() req = request( @@ -2618,11 +2846,19 @@ def test_restart_restores_later_native_order_references_and_trade_ids(factory, t "exchange_name": venue, "symbol": "IF2609", "client_order_id": "100000000001", + "account_id": venue, + "runtime_order_id": "recovered-ctp-order", + "strategy_id": "default", + "connection_generation": 1, } path.write_text( "\n".join( json.dumps(row) for row in [ + { + "event": "client_id_reservation", + **base, + }, { "event": "intent", **base, diff --git a/tests/runtime_plugins/test_cancellation_control.py b/tests/runtime_plugins/test_cancellation_control.py new file mode 100644 index 00000000..2b899843 --- /dev/null +++ b/tests/runtime_plugins/test_cancellation_control.py @@ -0,0 +1,538 @@ +"""Local acceptance for audited release of an unknown cancellation freeze. + +These fixtures use only SQLite journals and fake callbacks. They intentionally +prove no provider dispatch and no automatic release of an ambiguous +cancellation outcome. +""" + +from __future__ import annotations + +import importlib +from decimal import Decimal +from pathlib import Path + +import pytest + +from bt_api_py.runtime_plugins import ( + CAPABILITY_EXECUTION, + CAPABILITY_MONITOR, + CAPABILITY_RISK, + AuthorizationDecision, + CancellationReconciliationEvidence, + CapabilityCatalog, + CapabilityPin, + ControlCommandStatus, + ManagedCancellationReconciliationControlPort, + ReleaseCancellationFreezeCommand, + RuntimeCapabilityContract, + RuntimePluginError, + SealedNormalizedInstrumentMetadataSnapshot, + compose_managed_execution, +) + +_REPO_ROOT = Path(__file__).resolve().parents[2] +_CAPABILITY_SOURCES = ( + _REPO_ROOT / "bt_api" / "bt_api_base" / "src", + _REPO_ROOT / "bt_api" / "bt_api_execution" / "src", + _REPO_ROOT / "bt_api" / "bt_api_risk" / "src", + _REPO_ROOT / "bt_api" / "bt_api_monitor" / "src", +) +_NOW = 1_700_000_000.0 + + +def _contract() -> RuntimeCapabilityContract: + return RuntimeCapabilityContract( + strategy_id="example.014_1.ctp_options_lowfreq", + mode="live", + preset="managed_live_direct", + environment="production", + order_route="managed_execution", + required_capabilities=(CAPABILITY_EXECUTION, CAPABILITY_RISK, CAPABILITY_MONITOR), + effective_digest="a" * 64, + ) + + +def _catalog(monkeypatch: pytest.MonkeyPatch) -> CapabilityCatalog: + for source in _CAPABILITY_SOURCES: + monkeypatch.syspath_prepend(str(source)) + return CapabilityCatalog( + ( + CapabilityPin(CAPABILITY_EXECUTION, "bt_api_execution", "bt_api_execution", "0.1.0"), + CapabilityPin(CAPABILITY_RISK, "bt_api_risk", "bt_api_risk", "0.1.0"), + CapabilityPin(CAPABILITY_MONITOR, "bt_api_monitor", "bt_api_monitor", "0.1.0"), + ), + importer=importlib.import_module, + version_getter=lambda _distribution: "0.1.0", + ) + + +def _snapshot() -> SealedNormalizedInstrumentMetadataSnapshot: + return SealedNormalizedInstrumentMetadataSnapshot.from_normalized_payload( + { + "provider": "fixture_provider", + "environment": "production", + "account_ref": "fixture_account", + "trading_day": "20260922", + "metadata_version": "fixture-normalized-v1", + "as_of_ns": 1_000, + "expires_at_ns": 2_000, + "account_currency": "USD", + "instruments": [ + { + "instrument": "fixture/contract", + "tick_size": "0.1", + "lot_size": "1", + "contract_multiplier": "1", + "max_gross_notional_account": "1000", + "quote_currency": "USD", + "fee_currency": "USD", + "quote_to_account_fx": "1", + "fee_to_account_fx": "1", + "taker_fee_bps": "0", + "fixed_fee": "0", + "max_slippage_bps": "0", + "quantity_unit": "contracts", + } + ], + } + ) + + +def _runtime(monkeypatch: pytest.MonkeyPatch, state_directory: Path): + loaded = _catalog(monkeypatch).load(_contract()) + snapshot = _snapshot() + return loaded, compose_managed_execution( + loaded, + state_directory=state_directory, + provider="fixture_provider", + environment="production", + account_ref="fixture_account", + strategy_id="example.014_1.ctp_options_lowfreq", + writer_id="fixture_writer", + policy_id="fixture_policy", + max_increase_notional=Decimal("100"), + max_increase_count=3, + trading_day=snapshot.trading_day, + instrument_metadata_snapshot=snapshot, + instrument_clock_ns=lambda: 1_500, + ) + + +class _CancelGate: + def reserve(self, intent): + return {"permit_id": "cancel-permit-" + intent.cancel_id} + + def validate(self, permit_reference, intent): + assert permit_reference == "cancel-permit-" + intent.cancel_id + + def settle(self, permit_reference): + assert permit_reference.startswith("cancel-permit-") + + def release(self, permit_reference, reason): + assert permit_reference.startswith("cancel-permit-") + assert reason + + +def _order_intent(runtime, execution): + snapshot = runtime.instrument_metadata_snapshot + assert snapshot is not None + return execution.OrderIntent.limit( + intent_id="intent.cancel.unknown", + scope=runtime.scope, + signal_id="signal.cancel.unknown", + instrument="fixture/contract", + side=execution.Side.BUY, + quantity=Decimal("1"), + price=Decimal("10"), + metadata_version=snapshot.metadata_version, + tags={ + "instrument_metadata_digest": snapshot.instrument_digest("fixture/contract"), + "quantity_unit": snapshot.instrument_metadata("fixture/contract").quantity_unit, + }, + ) + + +def _unknown_cancel(runtime, execution): + order_intent = _order_intent(runtime, execution) + runtime.submit( + order_intent, + lambda intent: execution.ProviderObservation.accepted(intent.intent_id, "provider.order.1"), + ) + facade = execution.ManagedCancellationFacade( + runtime.execution_store, + runtime.scope, + acquire_writer_lease=runtime.facade.acquire_writer_lease, + admission_gate=_CancelGate(), + ) + cancel_intent = execution.CancelIntent( + cancel_id="cancel.unknown.1", + scope=runtime.scope, + target_intent_id=order_intent.intent_id, + provider_order_id="provider.order.1", + metadata_version="metadata.1", + ) + + def timeout_provider(_intent): + raise TimeoutError("fixture cancellation timeout") + + record = facade.cancel(cancel_intent, timeout_provider) + assert record.state is execution.ExecutionState.UNKNOWN + cause_id = "cancel-outcome-unknown:" + runtime.scope.key + ":" + cancel_intent.cancel_id + runtime.risk_gate.freeze(runtime.risk_scope, cause_id, cause_id) + assert cause_id in runtime.risk_gate.active_freeze_reasons(runtime.risk_scope) + return facade, cancel_intent, cause_id + + +def _evidence(cancel_intent, execution) -> CancellationReconciliationEvidence: + return CancellationReconciliationEvidence( + evidence_id="cancel-evidence.1", + cancel_id=cancel_intent.cancel_id, + target_intent_id=cancel_intent.target_intent_id, + provider_order_id=cancel_intent.provider_order_id, + observation=execution.CancelObservation.cancelled( + cancel_intent.cancel_id, + cancel_intent.target_intent_id, + cancel_intent.provider_order_id, + ), + source_receipt_digest="b" * 64, + observed_at=_NOW, + ) + + +def _command( + runtime, evidence: CancellationReconciliationEvidence +) -> ReleaseCancellationFreezeCommand: + return ReleaseCancellationFreezeCommand( + command_id="release-cancel.1", + scope=runtime.scope.key, + cancel_id=evidence.cancel_id, + evidence_id=evidence.evidence_id, + evidence_fingerprint=evidence.fingerprint, + issuer_id="operator.alice", + reason_code="dual_review_complete", + issued_at=_NOW + 1.0, + expires_at=_NOW + 60.0, + ) + + +def _approve(request) -> AuthorizationDecision: + assert request.command.issuer_id == "operator.alice" + assert request.evidence.cancel_id == request.command.cancel_id + return AuthorizationDecision( + approved=True, + subject_id="operator.alice", + receipt_digest="c" * 64, + reason_code="dual_review_complete", + ) + + +def _control(runtime, facade, state_directory: Path, authorize=_approve): + return ManagedCancellationReconciliationControlPort( + runtime, + facade, + state_directory=state_directory, + authorize=authorize, + clock=lambda: _NOW + 2.0, + ) + + +def _event_types(runtime) -> list[str]: + return [ + item.event.event_type + for item in runtime.outbox.read_pending("cancel-control-test", runtime.scope.key, limit=100) + ] + + +def test_terminal_cancel_reconcile_then_authorized_release_is_audited( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + loaded, runtime = _runtime(monkeypatch, tmp_path) + execution = loaded.require(CAPABILITY_EXECUTION) + facade, cancel_intent, cause_id = _unknown_cancel(runtime, execution) + control = _control(runtime, facade, tmp_path) + try: + evidence = _evidence(cancel_intent, execution) + + # A direct journal reconciliation makes the cancellation known but is + # never itself an authorization to lift the account freeze. + direct = facade.reconcile(evidence.observation) + assert direct.state is execution.ExecutionState.CANCELLED + assert cause_id in runtime.risk_gate.active_freeze_reasons(runtime.risk_scope) + + reconciled = control.reconcile(evidence) + assert reconciled.record.state is execution.ExecutionState.CANCELLED + assert reconciled.audit.monitor_published is True + assert cause_id in runtime.risk_gate.active_freeze_reasons(runtime.risk_scope) + + command = _command(runtime, evidence) + released = control.release_cancel_freeze(command) + assert released.released is True + assert released.idempotent is False + assert cause_id not in runtime.risk_gate.active_freeze_reasons(runtime.risk_scope) + audit = control.audit.get_command(command.command_id) + assert audit is not None + assert audit.status is ControlCommandStatus.RELEASED + assert audit.authorization_subject_id == command.issuer_id + assert _event_types(runtime).count("cancellation_reconciled") == 1 + assert _event_types(runtime).count("cancellation_freeze_release_authorized") == 1 + assert _event_types(runtime).count("cancellation_freeze_released") == 1 + + replay = control.release_cancel_freeze(command) + assert replay.idempotent is True + assert _event_types(runtime).count("cancellation_freeze_released") == 1 + finally: + control.close() + runtime.close() + + +def test_acknowledged_cancel_evidence_cannot_release_unknown_freeze( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + loaded, runtime = _runtime(monkeypatch, tmp_path) + execution = loaded.require(CAPABILITY_EXECUTION) + facade, cancel_intent, cause_id = _unknown_cancel(runtime, execution) + control = _control(runtime, facade, tmp_path) + try: + evidence = CancellationReconciliationEvidence( + evidence_id="cancel-evidence.pending", + cancel_id=cancel_intent.cancel_id, + target_intent_id=cancel_intent.target_intent_id, + provider_order_id=cancel_intent.provider_order_id, + observation=execution.CancelObservation.accepted( + cancel_intent.cancel_id, + cancel_intent.target_intent_id, + cancel_intent.provider_order_id, + ), + source_receipt_digest="b" * 64, + observed_at=_NOW, + ) + with pytest.raises(RuntimePluginError) as caught: + control.reconcile(evidence) + assert caught.value.code == "CANCELLATION_RECONCILIATION_INCOMPLETE" + assert cause_id in runtime.risk_gate.active_freeze_reasons(runtime.risk_scope) + assert facade.get(cancel_intent.cancel_id).state is execution.ExecutionState.UNKNOWN + finally: + control.close() + runtime.close() + + +def test_denied_or_identity_mismatched_authorization_keeps_cancel_freeze( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + loaded, runtime = _runtime(monkeypatch, tmp_path) + execution = loaded.require(CAPABILITY_EXECUTION) + facade, cancel_intent, cause_id = _unknown_cancel(runtime, execution) + + def forged_identity(_request) -> AuthorizationDecision: + return AuthorizationDecision( + approved=True, + subject_id="operator.mallory", + receipt_digest="d" * 64, + reason_code="dual_review_complete", + ) + + control = _control(runtime, facade, tmp_path, forged_identity) + try: + evidence = _evidence(cancel_intent, execution) + control.reconcile(evidence) + with pytest.raises(RuntimePluginError) as caught: + control.release_cancel_freeze(_command(runtime, evidence)) + assert caught.value.code == "CANCELLATION_CONTROL_AUTHORIZATION_IDENTITY_MISMATCH" + assert cause_id in runtime.risk_gate.active_freeze_reasons(runtime.risk_scope) + audit = control.audit.get_command("release-cancel.1") + assert audit is not None + assert audit.status is ControlCommandStatus.PENDING + finally: + control.close() + runtime.close() + + +def test_release_outbox_failure_reasserts_cancel_freeze_before_retry( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + loaded, runtime = _runtime(monkeypatch, tmp_path) + execution = loaded.require(CAPABILITY_EXECUTION) + facade, cancel_intent, cause_id = _unknown_cancel(runtime, execution) + control = _control(runtime, facade, tmp_path) + original_append = runtime.outbox.append + + def fail_release_event(event): + if event.event_type == "cancellation_freeze_released": + raise OSError("monitor unavailable") + return original_append(event) + + try: + evidence = _evidence(cancel_intent, execution) + control.reconcile(evidence) + command = _command(runtime, evidence) + monkeypatch.setattr(runtime.outbox, "append", fail_release_event) + with pytest.raises(RuntimePluginError) as caught: + control.release_cancel_freeze(command) + assert caught.value.code == "CANCELLATION_CONTROL_MONITOR_OUTBOX_UNCONFIRMED" + assert cause_id in runtime.risk_gate.active_freeze_reasons(runtime.risk_scope) + audit = control.audit.get_command(command.command_id) + assert audit is not None + assert audit.status is ControlCommandStatus.PENDING + + monkeypatch.setattr(runtime.outbox, "append", original_append) + retry = control.release_cancel_freeze(command) + assert retry.released is True + assert cause_id not in runtime.risk_gate.active_freeze_reasons(runtime.risk_scope) + finally: + control.close() + runtime.close() + + +def test_cancel_control_survives_restart_without_duplicate_monitor_facts( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + loaded, first = _runtime(monkeypatch, tmp_path) + execution = loaded.require(CAPABILITY_EXECUTION) + facade, cancel_intent, cause_id = _unknown_cancel(first, execution) + first_control = _control(first, facade, tmp_path) + evidence = _evidence(cancel_intent, execution) + command = _command(first, evidence) + try: + first_control.reconcile(evidence) + assert cause_id in first.risk_gate.active_freeze_reasons(first.risk_scope) + finally: + first_control.close() + first.close() + + loaded, second = _runtime(monkeypatch, tmp_path) + execution = loaded.require(CAPABILITY_EXECUTION) + second_facade = execution.ManagedCancellationFacade( + second.execution_store, + second.scope, + acquire_writer_lease=second.facade.acquire_writer_lease, + admission_gate=_CancelGate(), + ) + second_control = _control(second, second_facade, tmp_path) + try: + replayed = second_control.reconcile(evidence) + assert replayed.audit.monitor_published is True + assert _event_types(second).count("cancellation_reconciled") == 1 + released = second_control.release_cancel_freeze(command) + assert released.released is True + assert cause_id not in second.risk_gate.active_freeze_reasons(second.risk_scope) + finally: + second_control.close() + second.close() + + +@pytest.mark.parametrize("release_was_called", [False, True]) +def test_cancel_restart_reasserts_prepared_release_before_or_after_risk_mutation( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path, release_was_called: bool +) -> None: + """Prepared cancel-release audit state never implies a successful release.""" + + loaded, first = _runtime(monkeypatch, tmp_path) + execution = loaded.require(CAPABILITY_EXECUTION) + facade, cancel_intent, cause_id = _unknown_cancel(first, execution) + first_control = _control(first, facade, tmp_path) + evidence = _evidence(cancel_intent, execution) + command = _command(first, evidence) + try: + first_control.reconcile(evidence) + first_control.audit.record_command(command) + first_control.audit.record_authorization( + command.command_id, + AuthorizationDecision( + approved=True, + subject_id=command.issuer_id, + receipt_digest="c" * 64, + reason_code="dual_review_complete", + ), + ) + prepared = first_control.audit.record_release_applied(command.command_id) + assert prepared.release_applied_at is not None + if release_was_called: + first.risk_gate.resolve_freeze(first.risk_scope, cause_id) + assert cause_id not in first.risk_gate.active_freeze_reasons(first.risk_scope) + finally: + first_control.close() + first.close() + + loaded, restarted = _runtime(monkeypatch, tmp_path) + execution = loaded.require(CAPABILITY_EXECUTION) + restarted_facade = execution.ManagedCancellationFacade( + restarted.execution_store, + restarted.scope, + acquire_writer_lease=restarted.facade.acquire_writer_lease, + admission_gate=_CancelGate(), + ) + restarted_control = _control(restarted, restarted_facade, tmp_path) + try: + assert cause_id in restarted.risk_gate.active_freeze_reasons(restarted.risk_scope) + audit = restarted_control.audit.get_command(command.command_id) + assert audit is not None + assert audit.status is ControlCommandStatus.PENDING + assert audit.release_applied_at == prepared.release_applied_at + assert restarted_control.release_cancel_freeze(command).released is True + assert cause_id not in restarted.risk_gate.active_freeze_reasons(restarted.risk_scope) + finally: + restarted_control.close() + restarted.close() + + +@pytest.mark.parametrize("failure_stage", ["outbox", "final_audit"]) +def test_cancel_release_failure_then_restart_reasserts_and_recovers( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path, failure_stage: str +) -> None: + """Cancellation release remains frozen through outbox/audit recovery gaps.""" + + loaded, first = _runtime(monkeypatch, tmp_path) + execution = loaded.require(CAPABILITY_EXECUTION) + facade, cancel_intent, cause_id = _unknown_cancel(first, execution) + first_control = _control(first, facade, tmp_path) + evidence = _evidence(cancel_intent, execution) + command = _command(first, evidence) + try: + first_control.reconcile(evidence) + if failure_stage == "outbox": + original_append = first.outbox.append + + def fail_release_event(event): + if event.event_type == "cancellation_freeze_released": + raise OSError("monitor unavailable") + return original_append(event) + + monkeypatch.setattr(first.outbox, "append", fail_release_event) + expected_code = "CANCELLATION_CONTROL_MONITOR_OUTBOX_UNCONFIRMED" + else: + monkeypatch.setattr( + first_control.audit, + "mark_command_released", + lambda _command_id: (_ for _ in ()).throw(OSError("audit unavailable")), + ) + expected_code = "CANCELLATION_CONTROL_AUDIT_UNCONFIRMED" + + with pytest.raises(RuntimePluginError) as caught: + first_control.release_cancel_freeze(command) + assert caught.value.code == expected_code + assert cause_id in first.risk_gate.active_freeze_reasons(first.risk_scope) + audit = first_control.audit.get_command(command.command_id) + assert audit is not None + assert audit.status is ControlCommandStatus.PENDING + assert audit.release_applied_at is not None + finally: + first_control.close() + first.close() + + loaded, restarted = _runtime(monkeypatch, tmp_path) + execution = loaded.require(CAPABILITY_EXECUTION) + restarted_facade = execution.ManagedCancellationFacade( + restarted.execution_store, + restarted.scope, + acquire_writer_lease=restarted.facade.acquire_writer_lease, + admission_gate=_CancelGate(), + ) + restarted_control = _control(restarted, restarted_facade, tmp_path) + try: + assert cause_id in restarted.risk_gate.active_freeze_reasons(restarted.risk_scope) + assert restarted_control.release_cancel_freeze(command).released is True + assert cause_id not in restarted.risk_gate.active_freeze_reasons(restarted.risk_scope) + assert _event_types(restarted).count("cancellation_freeze_released") == 1 + finally: + restarted_control.close() + restarted.close() diff --git a/tests/runtime_plugins/test_capability_composition.py b/tests/runtime_plugins/test_capability_composition.py new file mode 100644 index 00000000..efb12e48 --- /dev/null +++ b/tests/runtime_plugins/test_capability_composition.py @@ -0,0 +1,838 @@ +"""Tests for the Iteration 41 sealed managed-runtime composition root.""" + +from __future__ import annotations + +import importlib +import time +from decimal import Decimal +from pathlib import Path +from types import SimpleNamespace + +import pytest + +from bt_api_py.runtime_plugins import ( + CAPABILITY_EXECUTION, + CAPABILITY_GATEWAY, + CAPABILITY_MONITOR, + CAPABILITY_RISK, + CAPABILITY_TRANSPORT_ZMQ, + CapabilityCatalog, + CapabilityPin, + ManagedExecutionRuntime, + RuntimeCapabilityContract, + RuntimePluginError, + SealedNormalizedInstrumentMetadataSnapshot, + compose_managed_execution, +) + +_REPO_ROOT = Path(__file__).resolve().parents[2] +_CAPABILITY_SOURCES = ( + _REPO_ROOT / "bt_api" / "bt_api_base" / "src", + _REPO_ROOT / "bt_api" / "bt_api_execution" / "src", + _REPO_ROOT / "bt_api" / "bt_api_risk" / "src", + _REPO_ROOT / "bt_api" / "bt_api_monitor" / "src", +) + + +def _digest() -> str: + return "a" * 64 + + +def _contract(*, preset: str = "managed_live_direct") -> RuntimeCapabilityContract: + shapes = { + "local_backtest": ("backtest", "local", None, ()), + "replay": ("simulation", "offline", None, ()), + "shadow": ("simulation", "public_read", "read_only", ()), + "paper": ("simulation", "public_read", "local_simulation", ()), + "sandbox": ("simulation", "sandbox", None, ()), + "managed_live_direct": ( + "live", + "production", + "managed_execution", + (CAPABILITY_EXECUTION, CAPABILITY_RISK, CAPABILITY_MONITOR), + ), + "managed_live_gateway": ( + "live", + "production", + "managed_execution", + ( + CAPABILITY_EXECUTION, + CAPABILITY_RISK, + CAPABILITY_MONITOR, + CAPABILITY_GATEWAY, + CAPABILITY_TRANSPORT_ZMQ, + ), + ), + } + mode, environment, route, capabilities = shapes[preset] + return RuntimeCapabilityContract( + strategy_id="example.014_1.ctp_options_lowfreq", + mode=mode, + preset=preset, + environment=environment, + order_route=route, + required_capabilities=capabilities, + effective_digest=_digest(), + ) + + +@pytest.mark.parametrize( + "preset", ("local_backtest", "replay", "shadow", "paper", "sandbox", "managed_live_direct") +) +def test_contract_accepts_each_sealed_runtime_shape(preset: str) -> None: + contract = _contract(preset=preset) + + assert contract.is_managed_execution is (preset == "managed_live_direct") + assert len(contract.fingerprint()) == 64 + + +def test_contract_allows_only_registered_sandbox_managed_shape() -> None: + contract = RuntimeCapabilityContract( + strategy_id="example.014_1.ctp_options_lowfreq", + mode="simulation", + preset="sandbox", + environment="sandbox", + order_route="managed_execution", + required_capabilities=(CAPABILITY_EXECUTION, CAPABILITY_RISK, CAPABILITY_MONITOR), + effective_digest=_digest(), + ) + + assert contract.is_managed_execution is True + + with pytest.raises(ValueError, match="sandbox"): + RuntimeCapabilityContract( + strategy_id="example.014_1.ctp_options_lowfreq", + mode="simulation", + preset="sandbox", + environment="sandbox", + order_route="read_only", + required_capabilities=(), + effective_digest=_digest(), + ) + + +def test_contract_allows_only_sealed_offline_managed_replay_shape() -> None: + contract = RuntimeCapabilityContract( + strategy_id="example.013_3.sa_midfreq_simnow", + mode="simulation", + preset="replay", + environment="offline", + order_route="managed_execution", + required_capabilities=(CAPABILITY_EXECUTION, CAPABILITY_RISK, CAPABILITY_MONITOR), + effective_digest=_digest(), + ) + + assert contract.is_managed_execution is True + assert contract.is_managed_live is False + + with pytest.raises(ValueError, match="replay"): + RuntimeCapabilityContract( + strategy_id="example.013_3.sa_midfreq_simnow", + mode="live", + preset="replay", + environment="production", + order_route="managed_execution", + required_capabilities=(CAPABILITY_EXECUTION, CAPABILITY_RISK, CAPABILITY_MONITOR), + effective_digest=_digest(), + ) + + with pytest.raises(ValueError, match="replay"): + RuntimeCapabilityContract( + strategy_id="example.013_3.sa_midfreq_simnow", + mode="simulation", + preset="replay", + environment="offline", + order_route="managed_execution", + required_capabilities=(CAPABILITY_EXECUTION, CAPABILITY_RISK), + effective_digest=_digest(), + ) + + +def test_effective_public_projection_keeps_safe_read_only_routes() -> None: + contract = RuntimeCapabilityContract.from_effective_public_dict( + { + "strategy_id": "example.014_1.ctp_options_lowfreq", + "mode": "simulation", + "preset": "paper", + "environment": "public_read", + "order_route": "local_simulation", + "required_capabilities": (), + "effective_digest": _digest(), + "display_only": "permitted", + } + ) + + assert contract.order_route == "local_simulation" + assert contract.is_managed_execution is False + + +def test_catalog_fails_before_import_when_an_exact_pin_does_not_match() -> None: + imported: list[str] = [] + catalog = CapabilityCatalog( + (CapabilityPin(CAPABILITY_EXECUTION, "bt_api_execution", "sealed.execution", "0.1.0"),), + importer=lambda name: imported.append(name), + version_getter=lambda distribution: "0.1.1", + ) + + with pytest.raises(RuntimePluginError, match="version") as caught: + catalog.load(_contract()) + + assert caught.value.code == "CAPABILITY_VERSION_MISMATCH" + assert imported == [] + + +def test_non_managed_contract_imports_no_optional_capability() -> None: + imported: list[str] = [] + checked: list[str] = [] + catalog = CapabilityCatalog( + (CapabilityPin(CAPABILITY_EXECUTION, "bt_api_execution", "sealed.execution", "0.1.0"),), + importer=lambda name: imported.append(name), + version_getter=lambda distribution: checked.append(distribution) or "0.1.0", + ) + + loaded = catalog.load(_contract(preset="paper")) + + assert dict(loaded.modules) == {} + assert imported == [] + assert checked == [] + + +def test_managed_runtime_close_releases_each_closable_component() -> None: + closed: list[str] = [] + + class Closeable: + def __init__(self, name: str) -> None: + self.name = name + + def close(self) -> None: + closed.append(self.name) + + runtime = ManagedExecutionRuntime( + contract=object(), + facade=Closeable("facade"), + execution_store=Closeable("execution_store"), + outbox=Closeable("outbox"), + risk_gate=Closeable("risk_gate"), + risk_scope=object(), + scope=object(), + execution=object(), + outbox_event_type=object(), + ) + + runtime.close() + + assert closed == ["facade", "outbox", "execution_store", "risk_gate"] + + +def test_managed_runtime_closes_registered_framework_receipt_before_execution_store() -> None: + closed: list[str] = [] + + class Closeable: + def __init__(self, name: str) -> None: + self.name = name + + def close(self) -> None: + closed.append(self.name) + + receipt = Closeable("framework_receipt") + runtime = ManagedExecutionRuntime( + contract=object(), + facade=Closeable("facade"), + execution_store=Closeable("execution_store"), + outbox=Closeable("outbox"), + risk_gate=Closeable("risk_gate"), + risk_scope=object(), + scope=object(), + execution=object(), + outbox_event_type=object(), + ) + + runtime.register_framework_projection_closeable(receipt) + runtime.register_framework_projection_closeable(receipt) + runtime.close() + + assert closed == ["framework_receipt", "facade", "outbox", "execution_store", "risk_gate"] + + +def _source_catalog(monkeypatch: pytest.MonkeyPatch) -> CapabilityCatalog: + for source in _CAPABILITY_SOURCES: + monkeypatch.syspath_prepend(str(source)) + return CapabilityCatalog( + ( + CapabilityPin(CAPABILITY_EXECUTION, "bt_api_execution", "bt_api_execution", "0.1.0"), + CapabilityPin(CAPABILITY_RISK, "bt_api_risk", "bt_api_risk", "0.1.0"), + CapabilityPin(CAPABILITY_MONITOR, "bt_api_monitor", "bt_api_monitor", "0.1.0"), + ), + importer=importlib.import_module, + version_getter=lambda distribution: "0.1.0", + ) + + +def _snapshot() -> SealedNormalizedInstrumentMetadataSnapshot: + return SealedNormalizedInstrumentMetadataSnapshot.from_normalized_payload( + { + "provider": "fixture_provider", + "environment": "production", + "account_ref": "fixture_account", + "trading_day": "20260922", + "metadata_version": "fixture-normalized-v1", + "as_of_ns": 1_000, + "expires_at_ns": 2_000, + "account_currency": "USD", + "instruments": [ + { + "instrument": "fixture/contract", + "tick_size": "0.1", + "lot_size": "1", + "contract_multiplier": "1", + "max_gross_notional_account": "1000", + "quote_currency": "USD", + "fee_currency": "USD", + "quote_to_account_fx": "1", + "fee_to_account_fx": "1", + "taker_fee_bps": "0", + "fixed_fee": "0", + "max_slippage_bps": "0", + "quantity_unit": "contracts", + } + ], + } + ) + + +def _managed_runtime( + loaded: object, + state_directory: Path, + *, + max_increase_notional: Decimal = Decimal("100"), + max_increase_count: int = 3, + permit_ttl_seconds: float = 30.0, +): + snapshot = _snapshot() + return compose_managed_execution( + loaded, + state_directory=state_directory, + provider="fixture_provider", + environment="production", + account_ref="fixture_account", + strategy_id="example.014_1.ctp_options_lowfreq", + writer_id="fixture_writer", + policy_id="fixture_policy", + max_increase_notional=max_increase_notional, + max_increase_count=max_increase_count, + permit_ttl_seconds=permit_ttl_seconds, + trading_day=snapshot.trading_day, + instrument_metadata_snapshot=snapshot, + instrument_clock_ns=lambda: 1_500, + ) + + +def _bound_metadata(runtime: object) -> dict[str, object]: + snapshot = runtime.instrument_metadata_snapshot + assert snapshot is not None + return { + "metadata_version": snapshot.metadata_version, + "tags": { + "instrument_metadata_digest": snapshot.instrument_digest("fixture/contract"), + "quantity_unit": snapshot.instrument_metadata("fixture/contract").quantity_unit, + }, + } + + +def test_real_capability_stack_persists_admission_before_one_fake_dispatch( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + loaded = _source_catalog(monkeypatch).load(_contract()) + runtime = _managed_runtime( + loaded, + tmp_path, + max_increase_notional=Decimal("100"), + max_increase_count=1, + ) + execution = loaded.require(CAPABILITY_EXECUTION) + intent = execution.OrderIntent.limit( + intent_id="intent.one", + scope=runtime.scope, + signal_id="signal.one", + instrument="fixture/contract", + side=execution.Side.BUY, + quantity=Decimal("1"), + price=Decimal("10"), + **_bound_metadata(runtime), + ) + dispatched: list[str] = [] + + def fake_provider(order: object) -> object: + dispatched.append(order.intent_id) + return execution.ProviderObservation.accepted(order.intent_id, "provider.order.one") + + record = runtime.submit(intent, fake_provider) + repeated = runtime.submit(intent, fake_provider) + + assert record.state is execution.ExecutionState.ACKED + assert repeated.state is execution.ExecutionState.ACKED + assert dispatched == [intent.intent_id] + pending = runtime.outbox.read_pending("fixture_monitor", runtime.scope.key) + assert len(pending) == 1 + assert pending[0].event.data == { + "intent_id": intent.intent_id, + "scope_digest": ( + runtime.scope.key[6:] + if runtime.scope.key.startswith("scope:") + else runtime.scope.key + ), + "state": "ACKED", + } + runtime.close() + + +def test_direct_confirmation_resolves_freeze_after_monitor_fact_and_allows_next_opening( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + loaded = _source_catalog(monkeypatch).load(_contract()) + runtime = _managed_runtime( + loaded, + tmp_path, + max_increase_notional=Decimal("100"), + max_increase_count=3, + ) + execution = loaded.require(CAPABILITY_EXECUTION) + first_intent = execution.OrderIntent.limit( + intent_id="intent.confirmed", + scope=runtime.scope, + signal_id="signal.confirmed", + instrument="fixture/contract", + side=execution.Side.BUY, + quantity=Decimal("1"), + price=Decimal("10"), + **_bound_metadata(runtime), + ) + first_cause_id = "dispatch-inflight:" + first_intent.intent_id + dispatched: list[str] = [] + + def confirmed_provider(order: object) -> object: + assert "dispatch-inflight:" + order.intent_id in runtime.risk_gate.active_freeze_reasons( + runtime.risk_scope + ) + dispatched.append(order.intent_id) + return execution.ProviderObservation.accepted(order.intent_id, "provider.order.1") + + try: + first = runtime.submit(first_intent, confirmed_provider) + + second_intent = execution.OrderIntent.limit( + intent_id="intent.confirmed.next", + scope=runtime.scope, + signal_id="signal.confirmed.next", + instrument="fixture/contract", + side=execution.Side.BUY, + quantity=Decimal("1"), + price=Decimal("10"), + **_bound_metadata(runtime), + ) + second = runtime.submit(second_intent, confirmed_provider) + + assert runtime.contract is loaded.contract + assert runtime.risk_scope.account_id == "fixture_account" + assert first.state is execution.ExecutionState.ACKED + assert second.state is execution.ExecutionState.ACKED + assert dispatched == [first_intent.intent_id, second_intent.intent_id] + assert first_cause_id not in runtime.risk_gate.active_freeze_reasons(runtime.risk_scope) + assert ( + "dispatch-inflight:" + second_intent.intent_id + not in runtime.risk_gate.active_freeze_reasons(runtime.risk_scope) + ) + finally: + runtime.close() + + +def test_unknown_dispatch_freeze_survives_permit_expiry_and_reopen( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + permit_ttl_seconds = 0.1 + loaded = _source_catalog(monkeypatch).load(_contract()) + runtime = _managed_runtime( + loaded, + tmp_path, + max_increase_notional=Decimal("100"), + max_increase_count=3, + permit_ttl_seconds=permit_ttl_seconds, + ) + execution = loaded.require(CAPABILITY_EXECUTION) + intent = execution.OrderIntent.limit( + intent_id="intent.unknown", + scope=runtime.scope, + signal_id="signal.unknown", + instrument="fixture/contract", + side=execution.Side.BUY, + quantity=Decimal("1"), + price=Decimal("10"), + **_bound_metadata(runtime), + ) + scope = runtime.risk_scope + cause_id = "dispatch-inflight:" + intent.intent_id + + def failing_provider(order: object) -> object: + raise TimeoutError(order.intent_id) + + try: + record = runtime.facade.submit(intent, failing_provider) + + assert record.state is execution.ExecutionState.UNKNOWN + assert cause_id in runtime.risk_gate.active_freeze_reasons(scope) + with pytest.raises(RuntimePluginError) as caught: + runtime.resolve_confirmed_dispatch_freeze(intent.intent_id) + assert caught.value.code == "CONTROLLED_FREEZE_RELEASE_REQUIRED" + assert cause_id in runtime.risk_gate.active_freeze_reasons(scope) + finally: + runtime.close() + + time.sleep(0.2) + risk = loaded.require(CAPABILITY_RISK) + reopened = risk.DurableRiskGate( + tmp_path / "risk.sqlite3", + risk.RiskPolicy( + policy_id="fixture_policy", + max_increase_notional=Decimal("100"), + max_increase_count=3, + permit_ttl_seconds=permit_ttl_seconds, + ), + ) + try: + assert cause_id in reopened.active_freeze_reasons(scope) + # A dispatch claim proves possible market exposure. Permit TTL must + # not erase it from risk accounting while reconciliation is pending. + assert reopened.snapshot(scope)["increase_count"] == 1 + with pytest.raises(risk.RiskDeniedError) as caught: + reopened.reserve( + risk.RiskIntent( + intent_id="after-reopen-increase", + scope=scope, + action=risk.IntentAction.INCREASE, + notional=Decimal("1"), + payload_fingerprint=_digest(), + ) + ) + assert caught.value.code == "FROZEN" + for action in (risk.IntentAction.REDUCE, risk.IntentAction.CANCEL): + permit = reopened.reserve( + risk.RiskIntent( + intent_id="after-reopen-" + action.value, + scope=scope, + action=action, + payload_fingerprint=_digest(), + ) + ) + assert permit.action is action + finally: + reopened.close() + + +def test_reconciled_evidence_never_automatically_releases_dispatch_freeze( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + loaded = _source_catalog(monkeypatch).load(_contract()) + runtime = _managed_runtime( + loaded, + tmp_path, + max_increase_notional=Decimal("100"), + max_increase_count=3, + ) + execution = loaded.require(CAPABILITY_EXECUTION) + intent = execution.OrderIntent.limit( + intent_id="intent.reconciled", + scope=runtime.scope, + signal_id="signal.reconciled", + instrument="fixture/contract", + side=execution.Side.BUY, + quantity=Decimal("1"), + price=Decimal("10"), + **_bound_metadata(runtime), + ) + cause_id = "dispatch-inflight:" + intent.intent_id + + def failing_provider(order: object) -> object: + raise TimeoutError(order.intent_id) + + try: + assert ( + runtime.facade.submit(intent, failing_provider).state + is execution.ExecutionState.UNKNOWN + ) + reconciled = runtime.facade.reconcile( + execution.ProviderObservation.accepted(intent.intent_id, "provider.order.reconciled") + ) + + assert reconciled.state is execution.ExecutionState.ACKED + assert reconciled.review_required is True + assert cause_id in runtime.risk_gate.active_freeze_reasons(runtime.risk_scope) + with pytest.raises(RuntimePluginError) as caught: + runtime.resolve_confirmed_dispatch_freeze(intent.intent_id) + assert caught.value.code == "CONTROLLED_FREEZE_RELEASE_REQUIRED" + finally: + runtime.close() + + +def test_monitor_outbox_failure_keeps_known_dispatch_freeze( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + loaded = _source_catalog(monkeypatch).load(_contract()) + runtime = _managed_runtime( + loaded, + tmp_path, + max_increase_notional=Decimal("100"), + max_increase_count=3, + ) + execution = loaded.require(CAPABILITY_EXECUTION) + intent = execution.OrderIntent.limit( + intent_id="intent.monitor-failure", + scope=runtime.scope, + signal_id="signal.monitor-failure", + instrument="fixture/contract", + side=execution.Side.BUY, + quantity=Decimal("1"), + price=Decimal("10"), + **_bound_metadata(runtime), + ) + cause_id = "dispatch-inflight:" + intent.intent_id + + def failing_append(event: object) -> object: + raise OSError(event.event_id) + + monkeypatch.setattr(runtime.outbox, "append", failing_append) + try: + with pytest.raises(RuntimePluginError) as caught: + runtime.submit( + intent, + lambda order: execution.ProviderObservation.accepted( + order.intent_id, "provider.order.1" + ), + ) + + assert caught.value.code == "MONITOR_OUTBOX_UNCONFIRMED" + assert runtime.facade.get(intent.intent_id).state is execution.ExecutionState.ACKED + assert cause_id in runtime.risk_gate.active_freeze_reasons(runtime.risk_scope) + finally: + runtime.close() + + +def test_auto_freeze_resolution_failure_is_explicit_and_keeps_freeze( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + loaded = _source_catalog(monkeypatch).load(_contract()) + runtime = _managed_runtime( + loaded, + tmp_path, + max_increase_notional=Decimal("100"), + max_increase_count=3, + ) + execution = loaded.require(CAPABILITY_EXECUTION) + intent = execution.OrderIntent.limit( + intent_id="intent.resolve-failure", + scope=runtime.scope, + signal_id="signal.resolve-failure", + instrument="fixture/contract", + side=execution.Side.BUY, + quantity=Decimal("1"), + price=Decimal("10"), + **_bound_metadata(runtime), + ) + cause_id = "dispatch-inflight:" + intent.intent_id + + def failing_resolve(scope: object, cause: str) -> None: + raise OSError(cause) + + monkeypatch.setattr(runtime.risk_gate, "resolve_freeze", failing_resolve) + try: + with pytest.raises(RuntimePluginError) as caught: + runtime.submit( + intent, + lambda order: execution.ProviderObservation.accepted( + order.intent_id, "provider.order.1" + ), + ) + + assert caught.value.code == "DISPATCH_FREEZE_RESOLUTION_FAILED" + assert runtime.facade.get(intent.intent_id).state is execution.ExecutionState.ACKED + assert cause_id in runtime.risk_gate.active_freeze_reasons(runtime.risk_scope) + assert len(runtime.outbox.read_pending("fixture_monitor", runtime.scope.key)) == 1 + finally: + runtime.close() + + +def test_unproven_market_opening_blocks_before_fake_provider( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + loaded = _source_catalog(monkeypatch).load(_contract()) + runtime = _managed_runtime( + loaded, + tmp_path, + max_increase_notional=Decimal("100"), + max_increase_count=1, + ) + execution = loaded.require(CAPABILITY_EXECUTION) + intent = execution.OrderIntent( + intent_id="intent.market", + scope=runtime.scope, + signal_id="signal.market", + instrument="fixture/contract", + side=execution.Side.BUY, + position_effect=execution.PositionEffect.OPEN, + order_type=execution.OrderType.MARKET, + quantity=Decimal("1"), + **_bound_metadata(runtime), + ) + dispatched: list[object] = [] + + record = runtime.submit(intent, lambda order: dispatched.append(order)) + + assert record.state is execution.ExecutionState.BLOCKED + assert dispatched == [] + + +def test_gateway_managed_contract_fails_before_capability_loading(tmp_path: Path) -> None: + required: list[str] = [] + contract = _contract(preset="managed_live_gateway") + loaded = SimpleNamespace( + contract=contract, + require=lambda capability: required.append(capability) or object(), + ) + + with pytest.raises(RuntimePluginError) as caught: + compose_managed_execution( + loaded, + state_directory=tmp_path, + provider="fixture_provider", + environment="production", + account_ref="fixture_account", + strategy_id=contract.strategy_id, + writer_id="fixture_writer", + policy_id="fixture_policy", + max_increase_notional=Decimal("1"), + max_increase_count=1, + ) + + assert caught.value.code == "GATEWAY_DISPATCH_UNSUPPORTED" + assert required == [] + + +def test_managed_live_requires_a_sealed_metadata_snapshot_before_capability_construction( + tmp_path: Path, +) -> None: + contract = _contract() + required: list[str] = [] + loaded = SimpleNamespace( + contract=contract, + require=lambda capability: required.append(capability) or object(), + ) + + with pytest.raises(RuntimePluginError) as caught: + compose_managed_execution( + loaded, + state_directory=tmp_path, + provider="fixture_provider", + environment="production", + account_ref="fixture_account", + strategy_id=contract.strategy_id, + writer_id="fixture_writer", + policy_id="fixture_policy", + max_increase_notional=Decimal("1"), + max_increase_count=1, + ) + + assert caught.value.code == "INSTRUMENT_SNAPSHOT_REQUIRED" + assert required == [] + + +def test_non_offline_managed_sandbox_requires_sealed_metadata_before_capability_construction( + tmp_path: Path, +) -> None: + """A future sandbox provider route cannot fall back to quantity * price.""" + + contract = RuntimeCapabilityContract( + strategy_id="example.014_1.ctp_options_lowfreq", + mode="simulation", + preset="sandbox", + environment="sandbox", + order_route="managed_execution", + required_capabilities=(CAPABILITY_EXECUTION, CAPABILITY_RISK, CAPABILITY_MONITOR), + effective_digest=_digest(), + ) + required: list[str] = [] + loaded = SimpleNamespace( + contract=contract, + require=lambda capability: required.append(capability) or object(), + ) + + with pytest.raises(RuntimePluginError) as caught: + compose_managed_execution( + loaded, + state_directory=tmp_path, + provider="fixture_provider", + environment="sandbox", + account_ref="fixture_account", + strategy_id=contract.strategy_id, + writer_id="fixture_writer", + policy_id="fixture_policy", + max_increase_notional=Decimal("1"), + max_increase_count=1, + ) + + assert caught.value.code == "INSTRUMENT_SNAPSHOT_REQUIRED" + assert required == [] + + +def test_offline_managed_replay_is_not_promoted_to_live_snapshot_requirements( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + contract = RuntimeCapabilityContract( + strategy_id="example.013_3.sa_midfreq_simnow", + mode="simulation", + preset="replay", + environment="offline", + order_route="managed_execution", + required_capabilities=(CAPABILITY_EXECUTION, CAPABILITY_RISK, CAPABILITY_MONITOR), + effective_digest=_digest(), + ) + loaded = _source_catalog(monkeypatch).load(contract) + + runtime = compose_managed_execution( + loaded, + state_directory=tmp_path, + provider="fixture_provider", + environment="offline", + account_ref="fixture_account", + strategy_id=contract.strategy_id, + writer_id="fixture_writer", + policy_id="fixture-policy", + max_increase_notional=Decimal("10"), + max_increase_count=1, + ) + try: + assert runtime.instrument_metadata_snapshot is None + assert runtime.instrument_admission is None + finally: + runtime.close() + + +def test_composition_rejects_scope_downgrade_before_any_provider_is_available( + tmp_path: Path, +) -> None: + contract = _contract() + loaded = SimpleNamespace(contract=contract, require=lambda capability: object()) + + with pytest.raises(RuntimePluginError, match="environment") as caught: + compose_managed_execution( + loaded, + state_directory=tmp_path, + provider="fixture_provider", + environment="sandbox", + account_ref="fixture_account", + strategy_id=contract.strategy_id, + writer_id="fixture_writer", + policy_id="fixture_policy", + max_increase_notional=Decimal("1"), + max_increase_count=1, + ) + + assert caught.value.code == "ENVIRONMENT_SCOPE_MISMATCH" diff --git a/tests/runtime_plugins/test_gateway_managed_dispatch.py b/tests/runtime_plugins/test_gateway_managed_dispatch.py new file mode 100644 index 00000000..77596644 --- /dev/null +++ b/tests/runtime_plugins/test_gateway_managed_dispatch.py @@ -0,0 +1,772 @@ +"""Local-only acceptance for the Iteration 41 gateway managed dispatch adapter.""" + +from __future__ import annotations + +import importlib +import threading +import uuid +from decimal import Decimal +from pathlib import Path +from types import SimpleNamespace +from typing import Any + +import pytest +import zmq + +from bt_api_py.runtime_plugins import ( + CAPABILITY_EXECUTION, + CAPABILITY_GATEWAY, + CAPABILITY_MONITOR, + CAPABILITY_RISK, + CAPABILITY_TRANSPORT_ZMQ, + CapabilityCatalog, + CapabilityPin, + RuntimeCapabilityContract, + RuntimePluginError, + SealedNormalizedInstrumentMetadataSnapshot, + compose_gateway_execution_authority, + compose_gateway_managed_client, +) + +_REPO_ROOT = Path(__file__).resolve().parents[2] +_CAPABILITY_SOURCES = ( + _REPO_ROOT / "bt_api" / "bt_api_base" / "src", + _REPO_ROOT / "bt_api" / "bt_api_execution" / "src", + _REPO_ROOT / "bt_api" / "bt_api_risk" / "src", + _REPO_ROOT / "bt_api" / "bt_api_monitor" / "src", + _REPO_ROOT / "bt_api" / "bt_api_gateway" / "src", + _REPO_ROOT / "bt_api" / "bt_api_transport_zmq" / "src", +) + + +def _contract() -> RuntimeCapabilityContract: + return RuntimeCapabilityContract( + strategy_id="example.014_1.ctp_options_lowfreq", + mode="live", + preset="managed_live_gateway", + environment="production", + order_route="managed_execution", + required_capabilities=( + CAPABILITY_EXECUTION, + CAPABILITY_RISK, + CAPABILITY_MONITOR, + CAPABILITY_GATEWAY, + CAPABILITY_TRANSPORT_ZMQ, + ), + effective_digest="a" * 64, + ) + + +def _catalog(monkeypatch: pytest.MonkeyPatch) -> CapabilityCatalog: + for source in _CAPABILITY_SOURCES: + monkeypatch.syspath_prepend(str(source)) + return CapabilityCatalog( + ( + CapabilityPin(CAPABILITY_EXECUTION, "bt_api_execution", "bt_api_execution", "0.1.0"), + CapabilityPin(CAPABILITY_RISK, "bt_api_risk", "bt_api_risk", "0.1.0"), + CapabilityPin(CAPABILITY_MONITOR, "bt_api_monitor", "bt_api_monitor", "0.1.0"), + CapabilityPin(CAPABILITY_GATEWAY, "bt_api_gateway", "bt_api_gateway", "0.1.0"), + CapabilityPin( + CAPABILITY_TRANSPORT_ZMQ, + "bt_api_transport_zmq", + "bt_api_transport_zmq", + "0.1.0", + ), + ), + importer=importlib.import_module, + version_getter=lambda distribution: "0.1.0", + ) + + +def _snapshot() -> SealedNormalizedInstrumentMetadataSnapshot: + return SealedNormalizedInstrumentMetadataSnapshot.from_normalized_payload( + { + "provider": "fixture_provider", + "environment": "production", + "account_ref": "fixture_account", + "trading_day": "20260922", + "metadata_version": "fixture.gateway.metadata.v1", + "as_of_ns": 1_000, + "expires_at_ns": 2_000, + "account_currency": "USD", + "instruments": [ + { + "instrument": "fixture/contract", + "tick_size": "0.1", + "lot_size": "1", + "contract_multiplier": "1", + "max_gross_notional_account": "1000", + "quote_currency": "USD", + "fee_currency": "USD", + "quote_to_account_fx": "1", + "fee_to_account_fx": "1", + "taker_fee_bps": "0", + "fixed_fee": "0", + "max_slippage_bps": "0", + "quantity_unit": "contracts", + } + ], + } + ) + + +def _metadata_kwargs() -> dict[str, Any]: + return { + "trading_day": "20260922", + "instrument_metadata_snapshot": _snapshot(), + "instrument_clock_ns": lambda: 1_500, + } + + +def _intent(runtime: Any, intent_id: str = "intent.gateway.1") -> Any: + snapshot = runtime.instrument_metadata_snapshot + return runtime.execution.OrderIntent.limit( + intent_id=intent_id, + scope=runtime.scope, + signal_id="signal.gateway.1", + instrument="fixture/contract", + side=runtime.execution.Side.BUY, + quantity=Decimal("1"), + price=Decimal("10"), + metadata_version=snapshot.metadata_version, + tags={ + "instrument_metadata_digest": snapshot.instrument_digest("fixture/contract"), + "quantity_unit": snapshot.instrument_metadata("fixture/contract").quantity_unit, + }, + ) + + +def _serve_one(server: Any) -> threading.Thread: + worker = threading.Thread(target=lambda: server.serve_once(timeout_ms=2_000)) + worker.start() + return worker + + +def _join(worker: threading.Thread) -> None: + worker.join(timeout=5) + assert not worker.is_alive() + + +def test_gateway_client_dispatches_only_through_authenticated_server_authority( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + """The local client journal has no provider/risk route and never calls Store direct.""" + + loaded = _catalog(monkeypatch).load(_contract()) + execution = loaded.require(CAPABILITY_EXECUTION) + gateway = loaded.require(CAPABILITY_GATEWAY) + transport = loaded.require(CAPABILITY_TRANSPORT_ZMQ) + provider_calls: list[str] = [] + + def provider_dispatch(intent: Any) -> Any: + provider_calls.append(intent.intent_id) + return execution.ProviderObservation.accepted(intent.intent_id, "provider.gateway.1") + + server_state = tmp_path / "server" + server_state.mkdir(parents=True) + writer_authority = gateway.GatewayAccountWriterAuthority( + server_state / "gateway_router.sqlite3" + ) + authority = compose_gateway_execution_authority( + loaded, + state_directory=server_state, + provider="fixture_provider", + environment="production", + account_ref="fixture_account", + strategy_id="example.014_1.ctp_options_lowfreq", + writer_id="gateway_server_writer", + policy_id="gateway_policy", + max_increase_notional=Decimal("100"), + max_increase_count=1, + provider_dispatch=provider_dispatch, + server_admission=lambda _principal, _command: True, + writer_authority=writer_authority, + **_metadata_kwargs(), + ) + writer_authority.acquire_writer( + authority.scope.account_key, "gateway_server_writer", lease_seconds=60 + ) + assert authority.server_runtime.recovery_coordinator is not None + principal = gateway.GatewayPrincipal( + principal_id="server-derived-fixture-principal", + account_scopes=frozenset({authority.scope.account_key}), + strategy_scopes=frozenset({authority.scope.key}), + allowed_kinds=frozenset({gateway.GatewayCommandKind.SUBMIT}), + ) + context = zmq.Context() + endpoint = "inproc://iteration41-gateway-" + uuid.uuid4().hex + server = authority.create_zmq_server( + endpoint, + lambda peer_identity, message: principal, + context=context, + ) + client = transport.ZmqCommandClient(endpoint, context=context) + runtime = compose_gateway_managed_client( + loaded, + state_directory=tmp_path / "client", + provider="fixture_provider", + environment="production", + account_ref="fixture_account", + strategy_id="example.014_1.ctp_options_lowfreq", + writer_id="gateway_client_writer", + client=client, + **_metadata_kwargs(), + ) + direct_calls: list[Any] = [] + intent = _intent(runtime) + command = runtime.dispatcher.command_for_intent(intent) + assert not hasattr(runtime, "risk_gate") + assert not hasattr(runtime, "provider_dispatch") + worker = _serve_one(server) + try: + record = runtime.submit( + intent, + lambda order: ( + direct_calls.append(order) + or (_ for _ in ()).throw(AssertionError("Store direct dispatch must never run")) + ), + ) + _join(worker) + + assert record.state is execution.ExecutionState.UNKNOWN + assert record.review_required is True + assert provider_calls == [intent.intent_id] + assert direct_calls == [] + gateway_result = authority.router.get(command.command_id) + assert gateway_result.status is gateway.GatewayCommandStatus.RETURNED_UNVERIFIED + # The fixture provider says ACKED, but the gateway has no independent + # terminal-query evidence and must keep the command unverified. + assert gateway_result.outcome["state"] == "ACKED" + assert runtime.dispatcher.command_for_intent(intent).fingerprint == command.fingerprint + + # A replay stays UNKNOWN and cannot automatically repeat the provider + # side effect while terminal-query evidence is absent. + repeated = runtime.submit(intent, lambda order: direct_calls.append(order)) + assert repeated.state is execution.ExecutionState.UNKNOWN + assert repeated.review_required is True + assert provider_calls == [intent.intent_id] + assert direct_calls == [] + finally: + runtime.close() + client.close() + server.close() + authority.close() + context.term() + + +def test_server_provider_timeout_latches_server_risk_and_client_unknown( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + """A server-side uncertain provider effect cannot free account capacity.""" + + loaded = _catalog(monkeypatch).load(_contract()) + gateway = loaded.require(CAPABILITY_GATEWAY) + transport = loaded.require(CAPABILITY_TRANSPORT_ZMQ) + provider_calls: list[str] = [] + + def uncertain_provider(intent: Any) -> Any: + provider_calls.append(intent.intent_id) + raise TimeoutError("fixture provider outcome is unknown") + + server_state = tmp_path / "server" + server_state.mkdir(parents=True) + writer_authority = gateway.GatewayAccountWriterAuthority( + server_state / "gateway_router.sqlite3" + ) + authority = compose_gateway_execution_authority( + loaded, + state_directory=server_state, + provider="fixture_provider", + environment="production", + account_ref="fixture_account", + strategy_id="example.014_1.ctp_options_lowfreq", + writer_id="gateway_server_writer", + policy_id="gateway_policy", + max_increase_notional=Decimal("100"), + max_increase_count=1, + provider_dispatch=uncertain_provider, + server_admission=lambda _principal, _command: True, + writer_authority=writer_authority, + **_metadata_kwargs(), + ) + writer_authority.acquire_writer( + authority.scope.account_key, "gateway_server_writer", lease_seconds=60 + ) + principal = gateway.GatewayPrincipal( + principal_id="server-derived-fixture-principal", + account_scopes=frozenset({authority.scope.account_key}), + strategy_scopes=frozenset({authority.scope.key}), + allowed_kinds=frozenset({gateway.GatewayCommandKind.SUBMIT}), + ) + context = zmq.Context() + endpoint = "inproc://iteration41-server-timeout-" + uuid.uuid4().hex + server = authority.create_zmq_server( + endpoint, + lambda peer_identity, message: principal, + context=context, + ) + client = transport.ZmqCommandClient(endpoint, context=context) + runtime = compose_gateway_managed_client( + loaded, + state_directory=tmp_path / "client", + provider="fixture_provider", + environment="production", + account_ref="fixture_account", + strategy_id="example.014_1.ctp_options_lowfreq", + writer_id="gateway_client_writer", + client=client, + **_metadata_kwargs(), + ) + direct_calls: list[Any] = [] + intent = _intent(runtime, "intent.gateway.server-timeout") + worker = _serve_one(server) + try: + record = runtime.submit(intent, lambda order: direct_calls.append(order)) + _join(worker) + + assert record.state is runtime.execution.ExecutionState.UNKNOWN + assert record.review_required is True + assert provider_calls == [intent.intent_id] + assert direct_calls == [] + assert authority.server_runtime.risk_gate.active_freeze_reasons( + authority.server_runtime.risk_scope + ) == ["dispatch-inflight:" + intent.intent_id] + finally: + runtime.close() + client.close() + server.close() + authority.close() + context.term() + + +def test_missing_server_admission_rejects_write_before_provider_dispatch( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + """A server authority without its own write admission stays fail-closed.""" + + loaded = _catalog(monkeypatch).load(_contract()) + execution = loaded.require(CAPABILITY_EXECUTION) + gateway = loaded.require(CAPABILITY_GATEWAY) + transport = loaded.require(CAPABILITY_TRANSPORT_ZMQ) + provider_calls: list[str] = [] + authority = compose_gateway_execution_authority( + loaded, + state_directory=tmp_path / "server", + provider="fixture_provider", + environment="production", + account_ref="fixture_account", + strategy_id="example.014_1.ctp_options_lowfreq", + writer_id="gateway_server_writer", + policy_id="gateway_policy", + max_increase_notional=Decimal("100"), + max_increase_count=1, + provider_dispatch=lambda intent: ( + provider_calls.append(intent.intent_id) + or execution.ProviderObservation.accepted(intent.intent_id, "provider.gateway.denied") + ), + **_metadata_kwargs(), + ) + principal = gateway.GatewayPrincipal( + principal_id="server-derived-fixture-principal", + account_scopes=frozenset({authority.scope.account_key}), + strategy_scopes=frozenset({authority.scope.key}), + allowed_kinds=frozenset({gateway.GatewayCommandKind.SUBMIT}), + ) + context = zmq.Context() + endpoint = "inproc://iteration41-no-server-admission-" + uuid.uuid4().hex + server = authority.create_zmq_server( + endpoint, + lambda _peer_identity, _message: principal, + context=context, + ) + client = transport.ZmqCommandClient(endpoint, context=context) + runtime = compose_gateway_managed_client( + loaded, + state_directory=tmp_path / "client", + provider="fixture_provider", + environment="production", + account_ref="fixture_account", + strategy_id="example.014_1.ctp_options_lowfreq", + writer_id="gateway_client_writer", + client=client, + **_metadata_kwargs(), + ) + intent = _intent(runtime, "intent.gateway.no-server-admission") + command = runtime.dispatcher.command_for_intent(intent) + worker = _serve_one(server) + try: + record = runtime.submit( + intent, lambda _order: pytest.fail("legacy dispatch must stay closed") + ) + _join(worker) + + assert record.state is execution.ExecutionState.UNKNOWN + assert provider_calls == [] + rejected = authority.router.get(command.command_id) + assert rejected is not None + assert rejected.status is gateway.GatewayCommandStatus.REJECTED + finally: + runtime.close() + client.close() + server.close() + authority.close() + context.term() + + +def test_gateway_rejects_client_claimed_principal_before_provider_execution( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + """Authorization comes only from the server authenticator, never wire JSON.""" + + loaded = _catalog(monkeypatch).load(_contract()) + execution = loaded.require(CAPABILITY_EXECUTION) + gateway = loaded.require(CAPABILITY_GATEWAY) + transport = loaded.require(CAPABILITY_TRANSPORT_ZMQ) + provider_calls: list[str] = [] + authority = compose_gateway_execution_authority( + loaded, + state_directory=tmp_path / "server", + provider="fixture_provider", + environment="production", + account_ref="fixture_account", + strategy_id="example.014_1.ctp_options_lowfreq", + writer_id="gateway_server_writer", + policy_id="gateway_policy", + max_increase_notional=Decimal("100"), + max_increase_count=1, + provider_dispatch=lambda intent: ( + provider_calls.append(intent.intent_id) + or execution.ProviderObservation.accepted(intent.intent_id, "provider.gateway.1") + ), + **_metadata_kwargs(), + ) + principal = gateway.GatewayPrincipal( + principal_id="trusted-server-principal", + account_scopes=frozenset({authority.scope.account_key}), + strategy_scopes=frozenset({authority.scope.key}), + allowed_kinds=frozenset({gateway.GatewayCommandKind.SUBMIT}), + ) + context = zmq.Context() + endpoint = "inproc://iteration41-principal-" + uuid.uuid4().hex + server = authority.create_zmq_server( + endpoint, + lambda peer_identity, message: principal, + context=context, + ) + client = transport.ZmqCommandClient(endpoint, context=context) + runtime = compose_gateway_managed_client( + loaded, + state_directory=tmp_path / "client", + provider="fixture_provider", + environment="production", + account_ref="fixture_account", + strategy_id="example.014_1.ctp_options_lowfreq", + writer_id="gateway_client_writer", + client=client, + **_metadata_kwargs(), + ) + intent = _intent(runtime) + command = runtime.dispatcher.command_for_intent(intent) + payload = gateway.gateway_command_to_wire_payload(command) + payload["principal"] = "client-supplied-admin" + message = transport.WireMessage( + message_id=command.command_id, + channel=transport.WireChannel.COMMAND, + scope=command.strategy_scope, + sequence=int(command.fingerprint[:16], 16), + sent_at=1.0, + payload=payload, + ) + worker = _serve_one(server) + try: + response = client.request(message, timeout_ms=2_000) + _join(worker) + assert response.payload["accepted"] is False + assert provider_calls == [] + assert authority.router.get(command.command_id) is None + finally: + runtime.close() + client.close() + server.close() + authority.close() + context.term() + + +def test_client_timeout_is_durable_unknown_and_never_uses_legacy_dispatch( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + """A lost ZMQ outcome has one local UNKNOWN record and no resend/fallback.""" + + loaded = _catalog(monkeypatch).load(_contract()) + transport = loaded.require(CAPABILITY_TRANSPORT_ZMQ) + attempted_messages: list[Any] = [] + + class TimeoutClient: + def request(self, message: Any, timeout_ms: int) -> Any: + assert timeout_ms == 5_000 + attempted_messages.append(message) + raise transport.CommandOutcomeUnknown(message.message_id) + + runtime = compose_gateway_managed_client( + loaded, + state_directory=tmp_path / "client", + provider="fixture_provider", + environment="production", + account_ref="fixture_account", + strategy_id="example.014_1.ctp_options_lowfreq", + writer_id="gateway_client_writer", + client=TimeoutClient(), + clock=lambda: 1_700_000_000.0, + **_metadata_kwargs(), + ) + direct_calls: list[Any] = [] + intent = _intent(runtime, "intent.gateway.timeout") + command = runtime.dispatcher.command_for_intent(intent) + try: + first = runtime.submit( + intent, + lambda order: ( + direct_calls.append(order) + or (_ for _ in ()).throw(AssertionError("legacy direct dispatch must not run")) + ), + ) + repeated = runtime.submit(intent, lambda order: direct_calls.append(order)) + + assert first.state is runtime.execution.ExecutionState.UNKNOWN + assert repeated.state is runtime.execution.ExecutionState.UNKNOWN + assert first.review_required is True + assert len(attempted_messages) == 1 + assert direct_calls == [] + finally: + runtime.close() + + # The persisted command has the same command ID, fingerprint, and original + # deadline after a client process restart. It is not regenerated as a new + # dispatch opportunity. + reopened = compose_gateway_managed_client( + loaded, + state_directory=tmp_path / "client", + provider="fixture_provider", + environment="production", + account_ref="fixture_account", + strategy_id="example.014_1.ctp_options_lowfreq", + writer_id="gateway_client_writer_reopened", + client=TimeoutClient(), + clock=lambda: 1_700_000_100.0, + **_metadata_kwargs(), + ) + try: + restored = reopened.dispatcher.command_for_intent(intent) + restored_record = reopened.execution_store.get(intent.intent_id, scope=reopened.scope) + assert restored.command_id == command.command_id + assert restored.fingerprint == command.fingerprint + assert restored.issued_at == command.issued_at + assert restored.expires_at == command.expires_at + assert restored_record is not None + assert restored_record.state is reopened.execution.ExecutionState.UNKNOWN + finally: + reopened.close() + + +def test_gateway_live_compositions_require_snapshot_before_loading_capabilities( + tmp_path: Path, +) -> None: + """Neither client nor authority can construct a live gateway path without metadata.""" + + required: list[str] = [] + loaded = SimpleNamespace( + contract=_contract(), + require=lambda capability: required.append(capability) or object(), + ) + common = { + "state_directory": tmp_path, + "provider": "fixture_provider", + "environment": "production", + "account_ref": "fixture_account", + "strategy_id": "example.014_1.ctp_options_lowfreq", + "writer_id": "gateway_writer", + } + + with pytest.raises(RuntimePluginError) as client_error: + compose_gateway_managed_client(loaded, client=object(), **common) + assert client_error.value.code == "INSTRUMENT_SNAPSHOT_REQUIRED" + assert required == [] + + with pytest.raises(RuntimePluginError) as authority_error: + compose_gateway_execution_authority( + loaded, + policy_id="gateway_policy", + max_increase_notional=Decimal("100"), + max_increase_count=1, + provider_dispatch=lambda _intent: None, + **common, + ) + assert authority_error.value.code == "INSTRUMENT_SNAPSHOT_REQUIRED" + assert required == [] + + +def test_gateway_snapshot_day_and_intent_digest_mismatch_fail_before_transport_or_provider( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + """Gateway client and server independently enforce the sealed snapshot binding.""" + + loaded = _catalog(monkeypatch).load(_contract()) + no_transport_calls: list[Any] = [] + + class NeverTransport: + def request(self, message: Any, timeout_ms: int) -> Any: + no_transport_calls.append((message, timeout_ms)) + raise AssertionError("metadata failure must precede transport") + + snapshot = _snapshot() + with pytest.raises(RuntimePluginError) as day_error: + compose_gateway_managed_client( + loaded, + state_directory=tmp_path / "wrong-day", + provider="fixture_provider", + environment="production", + account_ref="fixture_account", + strategy_id="example.014_1.ctp_options_lowfreq", + writer_id="gateway_client_writer", + client=NeverTransport(), + trading_day="20260923", + instrument_metadata_snapshot=snapshot, + instrument_clock_ns=lambda: 1_500, + ) + assert day_error.value.code == "INSTRUMENT_SNAPSHOT_TRADING_DAY_MISMATCH" + assert no_transport_calls == [] + + runtime = compose_gateway_managed_client( + loaded, + state_directory=tmp_path / "client", + provider="fixture_provider", + environment="production", + account_ref="fixture_account", + strategy_id="example.014_1.ctp_options_lowfreq", + writer_id="gateway_client_writer", + client=NeverTransport(), + **_metadata_kwargs(), + ) + try: + bad = runtime.execution.OrderIntent.limit( + intent_id="intent.gateway.bad-digest", + scope=runtime.scope, + signal_id="signal.gateway.bad-digest", + instrument="fixture/contract", + side=runtime.execution.Side.BUY, + quantity=Decimal("1"), + price=Decimal("10"), + metadata_version=runtime.instrument_metadata_snapshot.metadata_version, + tags={"instrument_metadata_digest": "0" * 64, "quantity_unit": "contracts"}, + ) + with pytest.raises(RuntimePluginError) as digest_error: + runtime.dispatcher.command_for_intent(bad) + assert digest_error.value.code == "INSTRUMENT_SNAPSHOT_METADATA_DIGEST_MISMATCH" + assert no_transport_calls == [] + + bad_unit = runtime.execution.OrderIntent.limit( + intent_id="intent.gateway.bad-unit", + scope=runtime.scope, + signal_id="signal.gateway.bad-unit", + instrument="fixture/contract", + side=runtime.execution.Side.BUY, + quantity=Decimal("1"), + price=Decimal("10"), + metadata_version=runtime.instrument_metadata_snapshot.metadata_version, + tags={ + "instrument_metadata_digest": runtime.instrument_metadata_snapshot.instrument_digest( + "fixture/contract" + ), + "quantity_unit": "base", + }, + ) + with pytest.raises(RuntimePluginError) as unit_error: + runtime.dispatcher.command_for_intent(bad_unit) + assert unit_error.value.code == "INSTRUMENT_SNAPSHOT_QUANTITY_UNIT_MISMATCH" + assert no_transport_calls == [] + finally: + runtime.close() + + provider_calls: list[str] = [] + authority = compose_gateway_execution_authority( + loaded, + state_directory=tmp_path / "server", + provider="fixture_provider", + environment="production", + account_ref="fixture_account", + strategy_id="example.014_1.ctp_options_lowfreq", + writer_id="gateway_server_writer", + policy_id="gateway_policy", + max_increase_notional=Decimal("100"), + max_increase_count=1, + provider_dispatch=lambda intent: provider_calls.append(intent.intent_id), + **_metadata_kwargs(), + ) + try: + server_bad = authority.execution.OrderIntent.limit( + intent_id="intent.gateway.server-bad-digest", + scope=authority.scope, + signal_id="signal.gateway.server-bad-digest", + instrument="fixture/contract", + side=authority.execution.Side.BUY, + quantity=Decimal("1"), + price=Decimal("10"), + metadata_version=authority.server_runtime.instrument_metadata_snapshot.metadata_version, + tags={"instrument_metadata_digest": "0" * 64, "quantity_unit": "contracts"}, + ) + record = authority.server_runtime.submit(server_bad, authority._provider_dispatch) + assert record.state is authority.execution.ExecutionState.BLOCKED + assert provider_calls == [] + finally: + authority.close() + + +def test_gateway_client_pre_authority_crash_recovers_dispatching_as_unknown_without_transport_io( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + """A crash before command creation has no resend path after client restart recovery.""" + + loaded = _catalog(monkeypatch).load(_contract()) + transport_calls: list[Any] = [] + + class NeverTransport: + def request(self, message: Any, timeout_ms: int) -> Any: + transport_calls.append((message, timeout_ms)) + raise AssertionError("client recovery must not call the gateway") + + runtime = compose_gateway_managed_client( + loaded, + state_directory=tmp_path / "client", + provider="fixture_provider", + environment="production", + account_ref="fixture_account", + strategy_id="example.014_1.ctp_options_lowfreq", + writer_id="gateway_client_writer", + client=NeverTransport(), + **_metadata_kwargs(), + ) + intent = _intent(runtime, "intent.gateway.pre-authority-gap") + + def crash_before_authority(_intent: Any) -> Any: + raise SystemExit("simulated gateway client crash before authority") + + monkeypatch.setattr(runtime.dispatcher, "submit", crash_before_authority) + try: + with pytest.raises(SystemExit, match="before authority"): + runtime._facade.submit(intent, runtime.dispatcher) + assert runtime.execution_store.get(intent.intent_id, scope=runtime.scope).state is ( + runtime.execution.ExecutionState.DISPATCHING + ) + + assert runtime.recover() == (intent.intent_id,) + recovered = runtime.execution_store.get(intent.intent_id, scope=runtime.scope) + assert recovered.state is runtime.execution.ExecutionState.UNKNOWN + assert recovered.review_required is True + assert transport_calls == [] + assert ( + runtime.submit(intent, lambda _intent: None).state + is runtime.execution.ExecutionState.UNKNOWN + ) + assert transport_calls == [] + finally: + runtime.close() diff --git a/tests/runtime_plugins/test_gateway_transport_server.py b/tests/runtime_plugins/test_gateway_transport_server.py new file mode 100644 index 00000000..74a4765b --- /dev/null +++ b/tests/runtime_plugins/test_gateway_transport_server.py @@ -0,0 +1,272 @@ +"""Local Curve/ZAP acceptance of the strict transport-to-gateway bridge.""" + +from __future__ import annotations + +import importlib +import socket +import threading +from pathlib import Path +from typing import Any + +import pytest +import zmq + +from bt_api_py.runtime_plugins.gateway_transport_server import ( + GatewayTransportCommandHandler, + GatewayTransportCompositionError, + create_gateway_zmq_server, + gateway_principal_from_remote_grant, +) + +_REPO_ROOT = Path(__file__).resolve().parents[2] +_CAPABILITY_SOURCES = ( + _REPO_ROOT / "bt_api" / "bt_api_gateway" / "src", + _REPO_ROOT / "bt_api" / "bt_api_transport_zmq" / "src", +) +_ACCOUNT_SCOPE = "fixture-account-scope" +_STRATEGY_SCOPE = "fixture-strategy-scope" +_NOW = 1_800_000_000.0 + + +class _RouterAuthority: + """Tiny authority shell that exercises the real gateway router contract.""" + + def __init__(self, gateway: Any, transport: Any, router: Any) -> None: + self.gateway = gateway + self.transport = transport + self.router = router + self.handle_calls = 0 + self.principals: list[Any] = [] + + def handle(self, principal: Any, message: Any) -> dict[str, Any]: + self.handle_calls += 1 + self.principals.append(principal) + command = self.gateway.gateway_command_from_wire_payload(message.payload) + result = self.router.dispatch(principal, command) + return { + "command_fingerprint": command.fingerprint, + "command_id": command.command_id, + "status": result.status.value, + } + + +def _modules(monkeypatch: pytest.MonkeyPatch) -> tuple[Any, Any]: + for source in _CAPABILITY_SOURCES: + monkeypatch.syspath_prepend(str(source)) + return ( + importlib.import_module("bt_api_gateway"), + importlib.import_module("bt_api_transport_zmq"), + ) + + +def _grant(transport: Any, kinds: frozenset[str] = frozenset({"read", "submit"})) -> Any: + return transport.RemotePrincipalGrant( + principal_id="fixture-principal", + account_id="fixture-account", + strategy_id="fixture-strategy", + account_scope=_ACCOUNT_SCOPE, + strategy_scope=_STRATEGY_SCOPE, + allowed_kinds=kinds, + ) + + +def _command(gateway: Any, command_id: str, *, kind: str = "read", payload: Any = None) -> Any: + return gateway.GatewayCommand( + command_id=command_id, + account_scope=_ACCOUNT_SCOPE, + strategy_scope=_STRATEGY_SCOPE, + kind=gateway.GatewayCommandKind(kind), + payload={} if payload is None else payload, + receipt_digest="a" * 64, + issued_at=_NOW - 5, + expires_at=_NOW + 30, + ) + + +def _wire_message(transport: Any, gateway: Any, command: Any, *, sequence: int) -> Any: + return transport.WireMessage( + message_id=command.command_id, + channel=transport.WireChannel.COMMAND, + scope=_STRATEGY_SCOPE, + sequence=sequence, + sent_at=_NOW, + payload=gateway.gateway_command_to_wire_payload(command), + ) + + +def _tcp_pair( + gateway: Any, + transport: Any, + authority: _RouterAuthority, + *, + context: Any, +) -> tuple[Any, Any, Any]: + server_public, server_secret = zmq.curve_keypair() + client_public, client_secret = zmq.curve_keypair() + with socket.socket() as listener: + listener.bind(("127.0.0.1", 0)) + port = listener.getsockname()[1] + endpoint = f"tcp://0.0.0.0:{port}" + server = create_gateway_zmq_server( + gateway=gateway, + transport=transport, + authority=authority, + endpoint=endpoint, + authenticator=lambda *_: pytest.fail("remote path must use the ZAP-derived grant"), + context=context, + curve_credentials=transport.CurveServerCredentials(server_public, server_secret), + allow_remote=True, + remote_principals={client_public: _grant(transport)}, + ) + client = transport.ZmqCommandClient( + f"tcp://127.0.0.1:{port}", + context=context, + curve_credentials=transport.CurveClientCredentials( + server_public, client_public, client_secret + ), + allow_remote=True, + ) + return server, client, context + + +def _serve_requests(server: Any, count: int) -> threading.Thread: + worker = threading.Thread( + target=lambda: [server.serve_once(timeout_ms=3_000) for _ in range(count)] + ) + worker.start() + return worker + + +def test_remote_curve_zap_request_is_decoded_and_dispatched_by_gateway_router( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + gateway, transport = _modules(monkeypatch) + calls: list[str] = [] + database = tmp_path / "gateway.sqlite" + writer_authority = gateway.GatewayAccountWriterAuthority(database, clock=lambda: _NOW) + writer_authority.acquire_writer(_ACCOUNT_SCOPE, "fixture-writer", lease_seconds=40) + router = gateway.GatewayCommandRouter( + database, + lambda command: calls.append(command.command_id) or {"fixture": "accepted"}, + clock=lambda: _NOW, + admission=lambda _principal, _command: True, + writer_authority=writer_authority, + ) + authority = _RouterAuthority(gateway, transport, router) + context = zmq.Context() + server, client, _ = _tcp_pair(gateway, transport, authority, context=context) + commands = [ + _command(gateway, "valid-submit", kind="submit"), + _command(gateway, "wrong-schema", kind="submit"), + _command(gateway, "wrong-fingerprint", kind="submit"), + _command(gateway, "forged-account", kind="submit", payload={"account_id": "other-account"}), + _command( + gateway, "forged-strategy", kind="submit", payload={"strategy_id": "other-strategy"} + ), + ] + messages = [ + _wire_message(transport, gateway, command, sequence=index) + for index, command in enumerate(commands) + ] + messages[1].payload["schema"] = "gateway-command-v1" + messages[2].payload["command_fingerprint"] = "0" * 64 + worker = _serve_requests(server, len(messages)) + responses = [] + try: + responses.extend(client.request(message, timeout_ms=3_000) for message in messages) + finally: + worker.join(timeout=5) + client.close() + server.close() + context.term() + + assert not worker.is_alive() + assert responses[0].payload["accepted"] is True + assert responses[0].payload["outcome"]["status"] == "returned_unverified" + assert [response.payload["accepted"] for response in responses[1:]] == [False] * 4 + assert calls == ["valid-submit"] + # The two schema/fingerprint failures reach the adapter but not authority; + # forged identity claims are rejected by the transport ACL before handler. + assert authority.handle_calls == 1 + assert authority.principals[0] == gateway.GatewayPrincipal( + principal_id="fixture-principal", + account_scopes=frozenset({_ACCOUNT_SCOPE}), + strategy_scopes=frozenset({_STRATEGY_SCOPE}), + allowed_kinds=frozenset( + {gateway.GatewayCommandKind.READ, gateway.GatewayCommandKind.SUBMIT} + ), + ) + + +@pytest.mark.parametrize( + ("admission", "writer"), + [ + (None, False), # no server permission and no writer authority + (True, False), # explicit permission but no writer authority + (True, True), # writer authority exists but no active lease + ], + ids=("no-permission", "no-writer-authority", "no-writer-lease"), +) +def test_remote_submit_fails_closed_without_permission_or_active_writer( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + admission: bool | None, + writer: bool, +) -> None: + gateway, transport = _modules(monkeypatch) + database = tmp_path / "gateway.sqlite" + provider_calls: list[str] = [] + writer_authority = ( + gateway.GatewayAccountWriterAuthority(database, clock=lambda: _NOW) if writer else None + ) + router = gateway.GatewayCommandRouter( + database, + lambda command: provider_calls.append(command.command_id) or {"provider": "called"}, + clock=lambda: _NOW, + admission=(lambda _principal, _command: admission is True) + if admission is not None + else None, + writer_authority=writer_authority, + ) + authority = _RouterAuthority(gateway, transport, router) + context = zmq.Context() + server, client, _ = _tcp_pair(gateway, transport, authority, context=context) + command = _command(gateway, "submit-without-authority", kind="submit") + worker = _serve_requests(server, 1) + try: + response = client.request( + _wire_message(transport, gateway, command, sequence=1), timeout_ms=3_000 + ) + finally: + worker.join(timeout=5) + client.close() + server.close() + context.term() + + assert not worker.is_alive() + assert response.payload["accepted"] is False + assert provider_calls == [] + assert authority.handle_calls == 1 + + +def test_bridge_rejects_missing_gateway_authority_and_untrusted_local_principal( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + gateway, transport = _modules(monkeypatch) + with pytest.raises(GatewayTransportCompositionError, match="gateway and transport"): + gateway_principal_from_remote_grant(None, transport, object()) + + router = gateway.GatewayCommandRouter(tmp_path / "gateway.sqlite", lambda _command: {}) + authority = _RouterAuthority(gateway, transport, router) + with pytest.raises(GatewayTransportCompositionError, match="gateway"): + GatewayTransportCommandHandler(gateway=None, transport=transport, authority=authority) + with pytest.raises(GatewayTransportCompositionError, match="authority"): + GatewayTransportCommandHandler(gateway=gateway, transport=transport, authority=None) + + handler = GatewayTransportCommandHandler( + gateway=gateway, transport=transport, authority=authority + ) + message = _wire_message(transport, gateway, _command(gateway, "local-untrusted"), sequence=0) + with pytest.raises(GatewayTransportCompositionError, match="ACL grant"): + handler(object(), message) + assert authority.handle_calls == 0 diff --git a/tests/runtime_plugins/test_instrument_risk_admission.py b/tests/runtime_plugins/test_instrument_risk_admission.py new file mode 100644 index 00000000..8e9b6e0a --- /dev/null +++ b/tests/runtime_plugins/test_instrument_risk_admission.py @@ -0,0 +1,599 @@ +"""Integration tests for the optional instrument-aware managed admission helper.""" + +from __future__ import annotations + +import importlib +from decimal import Decimal +from pathlib import Path + +import pytest + +from bt_api_py.runtime_plugins import ( + CAPABILITY_EXECUTION, + CAPABILITY_MONITOR, + CAPABILITY_RISK, + CapabilityCatalog, + CapabilityPin, + RuntimeCapabilityContract, + RuntimePluginError, + SealedNormalizedInstrumentMetadataSnapshot, + compose_instrument_risk_admission, + compose_managed_execution, +) + +_REPO_ROOT = Path(__file__).resolve().parents[2] +_CAPABILITY_SOURCES = ( + _REPO_ROOT / "bt_api" / "bt_api_execution" / "src", + _REPO_ROOT / "bt_api" / "bt_api_risk" / "src", + _REPO_ROOT / "bt_api" / "bt_api_monitor" / "src", +) + + +def _contract(*, managed: bool = True) -> RuntimeCapabilityContract: + return RuntimeCapabilityContract( + strategy_id="example.014_1.ctp_options_lowfreq", + mode="live" if managed else "simulation", + preset="managed_live_direct" if managed else "replay", + environment="production" if managed else "offline", + order_route="managed_execution" if managed else None, + required_capabilities=( + CAPABILITY_EXECUTION, + CAPABILITY_RISK, + CAPABILITY_MONITOR, + ) + if managed + else (), + effective_digest="a" * 64, + ) + + +def _catalog(monkeypatch: pytest.MonkeyPatch) -> CapabilityCatalog: + for source in _CAPABILITY_SOURCES: + monkeypatch.syspath_prepend(str(source)) + return CapabilityCatalog( + ( + CapabilityPin( + CAPABILITY_EXECUTION, "bt_api_execution", "bt_api_execution", "0.1.0" + ), + CapabilityPin(CAPABILITY_RISK, "bt_api_risk", "bt_api_risk", "0.1.0"), + CapabilityPin( + CAPABILITY_MONITOR, "bt_api_monitor", "bt_api_monitor", "0.1.0" + ), + ), + importer=importlib.import_module, + version_getter=lambda distribution: "0.1.0", + ) + + +def _metadata(risk: object) -> object: + return risk.InstrumentRiskMetadata( + instrument="fixture/contract", + metadata_version="instrument-v1", + as_of_ns=1_000, + expires_at_ns=2_000, + tick_size=Decimal("0.1"), + quantity_step=Decimal("0.25"), + contract_multiplier=Decimal("3"), + max_gross_notional=Decimal("1_000"), + taker_fee_bps=Decimal("10"), + fixed_fee=Decimal("0.2"), + max_slippage_bps=Decimal("50"), + ) + + +def _normalized_snapshot_payload( + *, + expires_at_ns: int = 2_000, + trading_day: str = "20260922", + instrument_overrides: dict[str, object] | None = None, +) -> dict[str, object]: + instrument: dict[str, object] = { + "instrument": "fixture/contract", + "tick_size": "0.1", + "lot_size": "1", + "contract_multiplier": "3", + "max_gross_notional_account": "1000", + "quote_currency": "USDT", + "fee_currency": "USDT", + "quote_to_account_fx": "1.25", + "fee_to_account_fx": "1.5", + "taker_fee_bps": "10", + "fixed_fee": "0.2", + "max_slippage_bps": "50", + "quantity_unit": "contracts", + "min_quantity": "1", + "max_quantity": "10", + } + if instrument_overrides: + instrument.update(instrument_overrides) + return { + "provider": "fixture_provider", + "environment": "production", + "account_ref": "fixture_account", + "trading_day": trading_day, + "metadata_version": "normalized-v1", + "as_of_ns": 1_000, + "expires_at_ns": expires_at_ns, + "account_currency": "USD", + "instruments": [instrument], + } + + +def _sealed_runtime( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + *, + snapshot: SealedNormalizedInstrumentMetadataSnapshot, + clock_ns: int = 1_500, +): + loaded = _catalog(monkeypatch).load(_contract()) + return loaded, compose_managed_execution( + loaded, + state_directory=tmp_path / "managed-state", + provider="fixture_provider", + environment="production", + account_ref="fixture_account", + strategy_id="example.014_1.ctp_options_lowfreq", + writer_id="fixture.writer", + policy_id="normalized-instrument-policy", + max_increase_notional=Decimal("2_000"), + max_increase_count=2, + trading_day=snapshot.trading_day, + instrument_metadata_snapshot=snapshot, + instrument_clock_ns=lambda: clock_ns, + ) + + +def _sealed_intent( + execution: object, runtime: object, snapshot: object, **overrides: object +): + values: dict[str, object] = { + "intent_id": "intent.normalized", + "scope": runtime.scope, + "signal_id": "signal.normalized", + "instrument": "fixture/contract", + "side": execution.Side.BUY, + "quantity": Decimal("2"), + "price": Decimal("100"), + "metadata_version": snapshot.metadata_version, + "tags": { + "instrument_metadata_digest": snapshot.instrument_digest("fixture/contract"), + "quantity_unit": snapshot.instrument_metadata("fixture/contract").quantity_unit, + }, + } + values.update(overrides) + return execution.OrderIntent.limit(**values) + + +def test_helper_installs_exact_metadata_bound_admission_before_provider_dispatch( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + loaded = _catalog(monkeypatch).load(_contract()) + execution = loaded.require(CAPABILITY_EXECUTION) + risk = loaded.require(CAPABILITY_RISK) + execution_scope = execution.ExecutionScope( + provider="fixture_provider", + environment="production", + account_ref="fixture_account", + strategy_id="example.014_1.ctp_options_lowfreq", + ) + risk_scope = risk.AccountScope("fixture_provider", "fixture_account", "production") + risk_gate = risk.DurableRiskGate( + tmp_path / "risk.sqlite3", + risk.RiskPolicy("instrument-policy", Decimal("2_000"), 2), + ) + metadata = _metadata(risk) + admission = compose_instrument_risk_admission( + loaded, + risk_gate=risk_gate, + risk_scope=risk_scope, + metadata=(metadata,), + clock_ns=lambda: 1_500, + ) + facade = execution.ManagedExecutionFacade( + execution.SqliteExecutionStore(tmp_path / "execution.sqlite3"), + execution_scope, + writer_id="fixture_writer", + admission_gate=admission.admission_gate, + ) + intent = execution.OrderIntent.limit( + intent_id="intent.instrument", + scope=execution_scope, + signal_id="signal.instrument", + instrument=metadata.instrument, + side=execution.Side.BUY, + quantity=Decimal("2"), + price=Decimal("100"), + metadata_version=metadata.metadata_version, + tags={risk.INSTRUMENT_METADATA_DIGEST_TAG: metadata.digest}, + ) + provider_calls: list[str] = [] + try: + record = facade.submit( + intent, + lambda order: ( + provider_calls.append(order.intent_id) + or execution.ProviderObservation.accepted( + order.intent_id, "provider.order.1" + ) + ), + ) + + assert record.state is execution.ExecutionState.ACKED + assert provider_calls == [intent.intent_id] + assert admission.map_execution_intent(intent).notional == Decimal("603.8030") + finally: + facade.close() + risk_gate.close() + + +def test_missing_metadata_digest_blocks_facade_before_provider_dispatch( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + loaded = _catalog(monkeypatch).load(_contract()) + execution = loaded.require(CAPABILITY_EXECUTION) + risk = loaded.require(CAPABILITY_RISK) + execution_scope = execution.ExecutionScope( + provider="fixture_provider", + environment="production", + account_ref="fixture_account", + strategy_id="example.014_1.ctp_options_lowfreq", + ) + risk_scope = risk.AccountScope("fixture_provider", "fixture_account", "production") + risk_gate = risk.DurableRiskGate( + tmp_path / "risk.sqlite3", + risk.RiskPolicy("instrument-policy", Decimal("2_000"), 2), + ) + metadata = _metadata(risk) + admission = compose_instrument_risk_admission( + loaded, + risk_gate=risk_gate, + risk_scope=risk_scope, + metadata=(metadata,), + clock_ns=lambda: 1_500, + ) + facade = execution.ManagedExecutionFacade( + execution.SqliteExecutionStore(tmp_path / "execution.sqlite3"), + execution_scope, + writer_id="fixture_writer", + admission_gate=admission.admission_gate, + ) + intent = execution.OrderIntent.limit( + intent_id="intent.no-digest", + scope=execution_scope, + signal_id="signal.no-digest", + instrument=metadata.instrument, + side=execution.Side.BUY, + quantity=Decimal("2"), + price=Decimal("100"), + metadata_version=metadata.metadata_version, + ) + provider_calls: list[str] = [] + try: + record = facade.submit( + intent, + lambda order: ( + provider_calls.append(order.intent_id) + or execution.ProviderObservation.accepted( + order.intent_id, "provider.order.1" + ) + ), + ) + + assert record.state is execution.ExecutionState.BLOCKED + assert provider_calls == [] + finally: + facade.close() + risk_gate.close() + + +def test_helper_rejects_non_managed_contract_without_loading_optional_capability( + monkeypatch: pytest.MonkeyPatch, +) -> None: + loaded = _catalog(monkeypatch).load(_contract(managed=False)) + + with pytest.raises(RuntimePluginError) as caught: + compose_instrument_risk_admission( + loaded, + risk_gate=object(), + risk_scope=object(), + metadata=(), + ) + + assert caught.value.code == "MANAGED_CONTRACT_REQUIRED" + + +def test_helper_is_a_declared_runtime_plugin_public_export() -> None: + import bt_api_py.runtime_plugins as runtime_plugins + + assert "InstrumentRiskAdmission" in runtime_plugins.__all__ + assert "NormalizedInstrumentMetadata" in runtime_plugins.__all__ + assert "SealedNormalizedInstrumentMetadataSnapshot" in runtime_plugins.__all__ + assert "compose_instrument_risk_admission" in runtime_plugins.__all__ + assert ( + runtime_plugins.compose_instrument_risk_admission + is compose_instrument_risk_admission + ) + + +def test_sealed_normalized_snapshot_binds_lot_fee_fx_and_trading_day_before_dispatch( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + snapshot = SealedNormalizedInstrumentMetadataSnapshot.from_normalized_payload( + _normalized_snapshot_payload() + ) + loaded, runtime = _sealed_runtime(monkeypatch, tmp_path, snapshot=snapshot) + execution = loaded.require(CAPABILITY_EXECUTION) + provider_calls: list[str] = [] + try: + assert runtime.instrument_metadata_snapshot is snapshot + assert runtime.instrument_admission.snapshot is snapshot + intent = _sealed_intent(execution, runtime, snapshot) + mapped = runtime.instrument_admission.map_execution_intent(intent) + + # 2 * ceil(100 * 1.005, 0.1) * 3 * 1.25 + 10 bps fee + # plus 0.2 USDT * 1.5 USD/USDT fixed fee. + assert mapped.notional == Decimal("754.80375") + record = runtime.facade.submit( + intent, + lambda order: ( + provider_calls.append(order.intent_id) + or execution.ProviderObservation.accepted( + order.intent_id, "provider.order.1" + ) + ), + ) + + assert record.state is execution.ExecutionState.ACKED + assert provider_calls == [intent.intent_id] + assert runtime.instrument_admission.metadata_digest_for( + intent.instrument + ) == snapshot.instrument_digest(intent.instrument) + finally: + runtime.close() + + +def test_missing_normalized_metadata_digest_blocks_before_fake_provider_dispatch( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + snapshot = SealedNormalizedInstrumentMetadataSnapshot.from_normalized_payload( + _normalized_snapshot_payload() + ) + loaded, runtime = _sealed_runtime(monkeypatch, tmp_path, snapshot=snapshot) + execution = loaded.require(CAPABILITY_EXECUTION) + intent = _sealed_intent(execution, runtime, snapshot, tags={}) + provider_calls: list[str] = [] + try: + with pytest.raises(RuntimePluginError) as caught: + runtime.instrument_admission.map_execution_intent(intent) + assert caught.value.code == "INSTRUMENT_SNAPSHOT_METADATA_DIGEST_REQUIRED" + + record = runtime.facade.submit( + intent, + lambda order: ( + provider_calls.append(order.intent_id) + or execution.ProviderObservation.accepted( + order.intent_id, "provider.order.1" + ) + ), + ) + + assert record.state is execution.ExecutionState.BLOCKED + assert record.dispatch_attempts == 0 + assert provider_calls == [] + finally: + runtime.close() + + +def test_live_snapshot_requires_quantity_unit_and_rejects_mutated_intent_unit( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + """Quantity semantics are sealed facts, never mutable bridge metadata.""" + + incomplete = _normalized_snapshot_payload() + incomplete["instruments"][0].pop("quantity_unit") + snapshot_without_unit = SealedNormalizedInstrumentMetadataSnapshot.from_normalized_payload( + incomplete + ) + with pytest.raises(RuntimePluginError) as missing_unit: + _sealed_runtime(monkeypatch, tmp_path / "missing-unit", snapshot=snapshot_without_unit) + assert missing_unit.value.code == "INSTRUMENT_SNAPSHOT_QUANTITY_UNIT_REQUIRED" + + snapshot = SealedNormalizedInstrumentMetadataSnapshot.from_normalized_payload( + _normalized_snapshot_payload() + ) + loaded, runtime = _sealed_runtime(monkeypatch, tmp_path / "mismatch", snapshot=snapshot) + execution = loaded.require(CAPABILITY_EXECUTION) + provider_calls: list[str] = [] + intent = _sealed_intent( + execution, + runtime, + snapshot, + tags={ + "instrument_metadata_digest": snapshot.instrument_digest("fixture/contract"), + "quantity_unit": "base", + }, + ) + try: + with pytest.raises(RuntimePluginError) as caught: + runtime.instrument_admission.map_execution_intent(intent) + assert caught.value.code == "INSTRUMENT_SNAPSHOT_QUANTITY_UNIT_MISMATCH" + + record = runtime.facade.submit( + intent, + lambda order: ( + provider_calls.append(order.intent_id) + or execution.ProviderObservation.accepted( + order.intent_id, "provider.order.quantity-unit" + ) + ), + ) + assert record.state is execution.ExecutionState.BLOCKED + assert record.dispatch_attempts == 0 + assert provider_calls == [] + finally: + runtime.close() + + +def test_stale_normalized_metadata_blocks_before_fake_provider_dispatch( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + snapshot = SealedNormalizedInstrumentMetadataSnapshot.from_normalized_payload( + _normalized_snapshot_payload(expires_at_ns=1_500) + ) + loaded, runtime = _sealed_runtime( + monkeypatch, tmp_path, snapshot=snapshot, clock_ns=1_500 + ) + execution = loaded.require(CAPABILITY_EXECUTION) + intent = _sealed_intent(execution, runtime, snapshot) + provider_calls: list[str] = [] + try: + with pytest.raises(RuntimePluginError) as caught: + runtime.instrument_admission.map_execution_intent(intent) + assert caught.value.code == "INSTRUMENT_SNAPSHOT_METADATA_STALE" + + record = runtime.facade.submit( + intent, + lambda order: ( + provider_calls.append(order.intent_id) + or execution.ProviderObservation.accepted( + order.intent_id, "provider.order.1" + ) + ), + ) + + assert record.state is execution.ExecutionState.BLOCKED + assert record.dispatch_attempts == 0 + assert provider_calls == [] + finally: + runtime.close() + + +@pytest.mark.parametrize( + "mutator", + ( + lambda payload: payload.pop("trading_day"), + lambda payload: payload["instruments"][0].pop("quote_to_account_fx"), + lambda payload: payload["instruments"][0].pop("fee_to_account_fx"), + ), +) +def test_incomplete_normalized_provider_payload_never_creates_a_dispatchable_snapshot( + mutator: object, +) -> None: + payload = _normalized_snapshot_payload() + mutator(payload) + + with pytest.raises(ValueError): + SealedNormalizedInstrumentMetadataSnapshot.from_normalized_payload(payload) + + +def test_normalized_provider_snapshot_rejects_noncanonical_trading_day() -> None: + with pytest.raises(ValueError): + SealedNormalizedInstrumentMetadataSnapshot.from_normalized_payload( + _normalized_snapshot_payload(trading_day="untrusted-day") + ) + + +def test_serialized_normalized_snapshot_round_trips_with_schema_and_account_currency() -> None: + snapshot = SealedNormalizedInstrumentMetadataSnapshot.from_normalized_payload( + _normalized_snapshot_payload() + ) + + reloaded = SealedNormalizedInstrumentMetadataSnapshot.from_normalized_payload( + snapshot.to_payload() + ) + + assert reloaded == snapshot + assert reloaded.digest == snapshot.digest + + +def test_serialized_normalized_snapshot_rejects_unknown_schema_or_record_account_currency() -> None: + snapshot = SealedNormalizedInstrumentMetadataSnapshot.from_normalized_payload( + _normalized_snapshot_payload() + ) + wrong_schema = snapshot.to_payload() + wrong_schema["schema"] = "untrusted.snapshot.v9" + + with pytest.raises(ValueError, match="schema"): + SealedNormalizedInstrumentMetadataSnapshot.from_normalized_payload(wrong_schema) + + wrong_currency = snapshot.to_payload() + wrong_currency["instruments"][0]["account_currency"] = "EUR" + with pytest.raises(ValueError, match="account currency"): + SealedNormalizedInstrumentMetadataSnapshot.from_normalized_payload(wrong_currency) + + +@pytest.mark.parametrize( + ("trading_day", "instrument_overrides"), + ( + ("20260923", {}), + ("20260922", {"lot_size": "2"}), + ("20260922", {"contract_multiplier": "4"}), + ("20260922", {"taker_fee_bps": "11"}), + ("20260922", {"quote_to_account_fx": "1.30"}), + ("20260922", {"fee_to_account_fx": "1.60"}), + ("20260922", {"quantity_unit": "base"}), + ), +) +def test_snapshot_digest_changes_when_bound_provider_facts_change( + trading_day: str, instrument_overrides: dict[str, object] +) -> None: + baseline = SealedNormalizedInstrumentMetadataSnapshot.from_normalized_payload( + _normalized_snapshot_payload() + ) + changed = SealedNormalizedInstrumentMetadataSnapshot.from_normalized_payload( + _normalized_snapshot_payload( + trading_day=trading_day, + instrument_overrides=instrument_overrides, + ) + ) + + assert changed.digest != baseline.digest + assert changed.instrument_digest("fixture/contract") != baseline.instrument_digest( + "fixture/contract" + ) + + +def test_old_snapshot_digest_blocks_after_fx_or_trading_day_change_before_dispatch( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + prior = SealedNormalizedInstrumentMetadataSnapshot.from_normalized_payload( + _normalized_snapshot_payload() + ) + current = SealedNormalizedInstrumentMetadataSnapshot.from_normalized_payload( + _normalized_snapshot_payload( + trading_day="20260923", instrument_overrides={"quote_to_account_fx": "1.30"} + ) + ) + loaded, runtime = _sealed_runtime(monkeypatch, tmp_path, snapshot=current) + execution = loaded.require(CAPABILITY_EXECUTION) + intent = _sealed_intent( + execution, + runtime, + current, + tags={ + "instrument_metadata_digest": prior.instrument_digest("fixture/contract"), + "quantity_unit": prior.instrument_metadata("fixture/contract").quantity_unit, + }, + ) + provider_calls: list[str] = [] + try: + with pytest.raises(RuntimePluginError) as caught: + runtime.instrument_admission.map_execution_intent(intent) + assert caught.value.code == "INSTRUMENT_SNAPSHOT_METADATA_DIGEST_MISMATCH" + + record = runtime.facade.submit( + intent, + lambda order: ( + provider_calls.append(order.intent_id) + or execution.ProviderObservation.accepted( + order.intent_id, "provider.order.1" + ) + ), + ) + + assert record.state is execution.ExecutionState.BLOCKED + assert record.dispatch_attempts == 0 + assert provider_calls == [] + finally: + runtime.close() diff --git a/tests/runtime_plugins/test_managed_recovery_coordinator.py b/tests/runtime_plugins/test_managed_recovery_coordinator.py new file mode 100644 index 00000000..fc1f981c --- /dev/null +++ b/tests/runtime_plugins/test_managed_recovery_coordinator.py @@ -0,0 +1,600 @@ +"""Fault/restart coverage for the Iteration 41 local recovery authority. + +These tests use only the provider-neutral fake dispatch port. They prove local +SQLite recovery semantics; they are not evidence of provider-side atomicity or +remote reconciliation. +""" + +from __future__ import annotations + +import importlib +import sqlite3 +import threading +import time +from decimal import Decimal +from pathlib import Path +from typing import Any + +import pytest + +from bt_api_py.runtime_plugins import ( + CAPABILITY_EXECUTION, + CAPABILITY_MONITOR, + CAPABILITY_RISK, + CapabilityCatalog, + CapabilityPin, + RuntimeCapabilityContract, + RuntimePluginError, + SealedNormalizedInstrumentMetadataSnapshot, + compose_managed_execution, +) + +_REPO_ROOT = Path(__file__).resolve().parents[2] +_CAPABILITY_SOURCES = ( + _REPO_ROOT / "bt_api" / "bt_api_base" / "src", + _REPO_ROOT / "bt_api" / "bt_api_execution" / "src", + _REPO_ROOT / "bt_api" / "bt_api_risk" / "src", + _REPO_ROOT / "bt_api" / "bt_api_monitor" / "src", +) + + +def _contract() -> RuntimeCapabilityContract: + return RuntimeCapabilityContract( + strategy_id="example.014_1.ctp_options_lowfreq", + mode="live", + preset="managed_live_direct", + environment="production", + order_route="managed_execution", + required_capabilities=( + CAPABILITY_EXECUTION, + CAPABILITY_RISK, + CAPABILITY_MONITOR, + ), + effective_digest="a" * 64, + ) + + +def _catalog(monkeypatch: pytest.MonkeyPatch) -> CapabilityCatalog: + for source in _CAPABILITY_SOURCES: + monkeypatch.syspath_prepend(str(source)) + return CapabilityCatalog( + ( + CapabilityPin( + CAPABILITY_EXECUTION, "bt_api_execution", "bt_api_execution", "0.1.0" + ), + CapabilityPin(CAPABILITY_RISK, "bt_api_risk", "bt_api_risk", "0.1.0"), + CapabilityPin( + CAPABILITY_MONITOR, "bt_api_monitor", "bt_api_monitor", "0.1.0" + ), + ), + importer=importlib.import_module, + version_getter=lambda _: "0.1.0", + ) + + +def _snapshot() -> SealedNormalizedInstrumentMetadataSnapshot: + """Return a code-owned fake-provider snapshot for managed-live fault tests.""" + + return SealedNormalizedInstrumentMetadataSnapshot.from_normalized_payload( + { + "provider": "fixture_provider", + "environment": "production", + "account_ref": "fixture_account", + "trading_day": "20260922", + "metadata_version": "fixture-normalized-v1", + "as_of_ns": 1_000, + "expires_at_ns": 2_000, + "account_currency": "USD", + "instruments": [ + { + "instrument": "fixture/contract", + "tick_size": "0.1", + "lot_size": "1", + "contract_multiplier": "1", + "max_gross_notional_account": "1000", + "quote_currency": "USD", + "fee_currency": "USD", + "quote_to_account_fx": "1", + "fee_to_account_fx": "1", + "taker_fee_bps": "0", + "fixed_fee": "0", + "max_slippage_bps": "0", + "quantity_unit": "contracts", + } + ], + } + ) + + +def _runtime( + monkeypatch: pytest.MonkeyPatch, + state_directory: Path, + *, + writer_id: str, +) -> tuple[Any, Any]: + loaded = _catalog(monkeypatch).load(_contract()) + snapshot = _snapshot() + runtime = compose_managed_execution( + loaded, + state_directory=state_directory, + provider="fixture_provider", + environment="production", + account_ref="fixture_account", + strategy_id=_contract().strategy_id, + writer_id=writer_id, + policy_id="recovery-policy", + max_increase_notional=Decimal("100"), + max_increase_count=3, + trading_day=snapshot.trading_day, + instrument_metadata_snapshot=snapshot, + instrument_clock_ns=lambda: 1_500, + ) + return loaded, runtime + + +def _intent(execution: Any, runtime: Any, intent_id: str) -> Any: + snapshot = runtime.instrument_metadata_snapshot + assert snapshot is not None + return execution.OrderIntent.limit( + intent_id=intent_id, + scope=runtime.scope, + signal_id="signal." + intent_id, + instrument="fixture/contract", + side=execution.Side.BUY, + quantity=Decimal("1"), + price=Decimal("10"), + metadata_version=snapshot.metadata_version, + tags={ + "instrument_metadata_digest": snapshot.instrument_digest("fixture/contract"), + "quantity_unit": snapshot.instrument_metadata("fixture/contract").quantity_unit, + }, + ) + + +def test_crash_after_durable_prepare_marks_unknown_on_reopen_and_never_redispatches( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + """A fatal interruption after provider entry is recovery-only, never a retry.""" + + loaded, runtime = _runtime(monkeypatch, tmp_path, writer_id="writer.initial") + execution = loaded.require(CAPABILITY_EXECUTION) + intent = _intent(execution, runtime, "intent.crash-before-result") + dispatched: list[str] = [] + + def fatal_provider(order: Any) -> Any: + dispatched.append(order.intent_id) + raise SystemExit("simulated process death after provider entry") + + try: + with pytest.raises(SystemExit, match="simulated process death"): + runtime.submit(intent, fatal_provider) + work = runtime.recovery_coordinator.work_for( + runtime.scope.key, intent.intent_id + ) + assert work is not None + assert work.phase == "DISPATCH_PREPARED" + assert work.scope_key == runtime.scope.key + assert work.payload_sha256 == intent.fingerprint + assert ( + runtime.facade.get(intent.intent_id).state + is execution.ExecutionState.DISPATCHING + ) + finally: + runtime.close() + + _, reopened = _runtime(monkeypatch, tmp_path, writer_id="writer.reopened") + redispatches: list[str] = [] + try: + report = reopened.recover() + record = reopened.facade.get(intent.intent_id) + cause_id = "dispatch-inflight:" + intent.intent_id + + assert report.recovered_unknown_intent_ids == (intent.intent_id,) + assert report.reconciliation_required_intent_ids == (intent.intent_id,) + assert record.state is execution.ExecutionState.UNKNOWN + assert ( + reopened.recovery_coordinator.work_for( + reopened.scope.key, intent.intent_id + ).recovery_reason + == "coordinator_recovery_unknown" + ) + assert cause_id in reopened.risk_gate.active_freeze_reasons(reopened.risk_scope) + blocked_open = reopened.submit( + _intent(execution, reopened, "intent.blocked-by-recovery"), + lambda order: redispatches.append(order.intent_id), + ) + assert blocked_open.state is execution.ExecutionState.BLOCKED + repeated = reopened.submit( + intent, lambda order: redispatches.append(order.intent_id) + ) + assert repeated.state is execution.ExecutionState.UNKNOWN + assert dispatched == [intent.intent_id] + assert redispatches == [] + finally: + reopened.close() + + +def test_restart_rebuilds_monitor_fact_after_provider_result_before_monitor_commit( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + """A known result survives the gap before the coordinator records its outbox fact.""" + + loaded, runtime = _runtime(monkeypatch, tmp_path, writer_id="writer.initial") + execution = loaded.require(CAPABILITY_EXECUTION) + intent = _intent(execution, runtime, "intent.crash-after-result") + dispatched: list[str] = [] + + def fatal_after_result(*_: Any, **__: Any) -> Any: + raise SystemExit("simulated process death before monitor journal") + + monkeypatch.setattr( + runtime.recovery_coordinator, "record_result", fatal_after_result + ) + try: + with pytest.raises(SystemExit, match="simulated process death"): + runtime.submit( + intent, + lambda order: ( + dispatched.append(order.intent_id) + or execution.ProviderObservation.accepted( + order.intent_id, "provider.order.1" + ) + ), + ) + assert ( + runtime.facade.get(intent.intent_id).state is execution.ExecutionState.ACKED + ) + finally: + runtime.close() + + _, reopened = _runtime(monkeypatch, tmp_path, writer_id="writer.reopened") + redispatches: list[str] = [] + try: + first = reopened.recover() + pending = reopened.outbox.read_pending("recovery-monitor", reopened.scope.key) + event_id = reopened.recovery_coordinator.event_id( + reopened.scope.key, intent.intent_id, "ACKED" + ) + cause_id = "dispatch-inflight:" + intent.intent_id + + assert first.emitted_event_ids == (event_id,) + assert first.resolved_freeze_intent_ids == (intent.intent_id,) + assert [item.event.event_id for item in pending] == [event_id] + assert cause_id not in reopened.risk_gate.active_freeze_reasons( + reopened.risk_scope + ) + assert reopened.recover().emitted_event_ids == () + assert reopened.submit( + intent, lambda order: redispatches.append(order.intent_id) + ).state is (execution.ExecutionState.ACKED) + assert dispatched == [intent.intent_id] + assert redispatches == [] + assert ( + len(reopened.outbox.read_pending("another-monitor", reopened.scope.key)) + == 1 + ) + finally: + reopened.close() + + +def test_monitor_append_failure_is_replayed_idempotently_after_reopen( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + """The local event journal retains a confirmed result when monitor SQLite is unavailable.""" + + loaded, runtime = _runtime(monkeypatch, tmp_path, writer_id="writer.initial") + execution = loaded.require(CAPABILITY_EXECUTION) + intent = _intent(execution, runtime, "intent.monitor-replay") + cause_id = "dispatch-inflight:" + intent.intent_id + + def reject_append(_: Any) -> Any: + raise OSError("simulated monitor journal outage") + + monkeypatch.setattr(runtime.outbox, "append", reject_append) + try: + with pytest.raises(RuntimePluginError) as caught: + runtime.submit( + intent, + lambda order: execution.ProviderObservation.accepted( + order.intent_id, "provider.order.monitor" + ), + ) + assert caught.value.code == "MONITOR_OUTBOX_UNCONFIRMED" + assert ( + runtime.facade.get(intent.intent_id).state is execution.ExecutionState.ACKED + ) + assert cause_id in runtime.risk_gate.active_freeze_reasons(runtime.risk_scope) + assert runtime.recovery_coordinator.pending_events(runtime.scope.key) + finally: + runtime.close() + + _, reopened = _runtime(monkeypatch, tmp_path, writer_id="writer.reopened") + try: + report = reopened.recover() + assert len(report.emitted_event_ids) == 1 + assert report.resolved_freeze_intent_ids == (intent.intent_id,) + assert cause_id not in reopened.risk_gate.active_freeze_reasons( + reopened.risk_scope + ) + assert len(reopened.outbox.read_pending("monitor", reopened.scope.key)) == 1 + assert reopened.recovery_coordinator.pending_events(reopened.scope.key) == () + finally: + reopened.close() + + +def test_independent_runtime_writer_is_fenced_while_provider_dispatch_is_inflight( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + """The facade writer lease fences two local runtimes sharing one coordinator journal.""" + + loaded, first = _runtime(monkeypatch, tmp_path, writer_id="writer.one") + _, contender = _runtime(monkeypatch, tmp_path, writer_id="writer.two") + execution = loaded.require(CAPABILITY_EXECUTION) + first_intent = _intent(execution, first, "intent.writer-one") + contender_intent = _intent(execution, contender, "intent.writer-two") + entered = threading.Event() + release = threading.Event() + first_result: list[Any] = [] + first_errors: list[BaseException] = [] + contender_dispatches: list[str] = [] + + def blocking_provider(order: Any) -> Any: + entered.set() + assert release.wait(timeout=5) + return execution.ProviderObservation.accepted( + order.intent_id, "provider.order.one" + ) + + def submit_first() -> None: + try: + first_result.append(first.submit(first_intent, blocking_provider)) + except BaseException as error: # pragma: no cover - asserted below + first_errors.append(error) + + thread = threading.Thread(target=submit_first) + thread.start() + assert entered.wait(timeout=5) + try: + with pytest.raises(execution.WriterLeaseUnavailable): + contender.submit( + contender_intent, + lambda order: contender_dispatches.append(order.intent_id), + ) + finally: + release.set() + thread.join(timeout=5) + first.close() + contender.close() + + assert not thread.is_alive() + assert first_errors == [] + assert first_result[0].state is execution.ExecutionState.ACKED + assert contender_dispatches == [] + + +def test_crash_after_monitor_append_replays_same_event_id_without_duplicate( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + """An append/checkpoint crash is at-least-once with one immutable event identity.""" + + loaded, runtime = _runtime(monkeypatch, tmp_path, writer_id="writer.initial") + execution = loaded.require(CAPABILITY_EXECUTION) + intent = _intent(execution, runtime, "intent.crash-after-monitor-append") + dispatched: list[str] = [] + + def crash_before_coordinator_confirmation(_: str) -> None: + raise SystemExit("simulated death after monitor outbox append") + + monkeypatch.setattr( + runtime.recovery_coordinator, + "_mark_event_emitted", + crash_before_coordinator_confirmation, + ) + try: + with pytest.raises(SystemExit, match="simulated death after monitor"): + runtime.submit( + intent, + lambda order: ( + dispatched.append(order.intent_id) + or execution.ProviderObservation.accepted( + order.intent_id, "provider.order.append" + ) + ), + ) + assert ( + len(runtime.outbox.read_pending("before-restart", runtime.scope.key)) == 1 + ) + finally: + runtime.close() + + _, reopened = _runtime(monkeypatch, tmp_path, writer_id="writer.reopened") + redispatches: list[str] = [] + try: + report = reopened.recover() + event_id = reopened.recovery_coordinator.event_id( + reopened.scope.key, intent.intent_id, "ACKED" + ) + + assert report.emitted_event_ids == (event_id,) + assert ( + len(reopened.outbox.read_pending("after-restart", reopened.scope.key)) == 1 + ) + assert reopened.submit( + intent, lambda order: redispatches.append(order.intent_id) + ).state is (execution.ExecutionState.ACKED) + assert dispatched == [intent.intent_id] + assert redispatches == [] + finally: + reopened.close() + + +def test_known_result_crash_before_risk_settlement_retains_exposure_until_recovery_proves_it( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + """A claimed permit cannot expire out of risk accounting after a known result.""" + + loaded, runtime = _runtime(monkeypatch, tmp_path, writer_id="writer.initial") + execution = loaded.require(CAPABILITY_EXECUTION) + intent = _intent(execution, runtime, "intent.known-before-risk-settlement") + cause_id = "dispatch-inflight:" + intent.intent_id + + # Simulate death after the execution record and coordinator fact commit, + # but before the facade's normal risk settlement / runtime finish path. + monkeypatch.setattr( + runtime.facade, + "_settle_after_evidenced_outcome", + lambda record, **_kwargs: record, + ) + def crash_after_result(*_args: Any, **_kwargs: Any) -> tuple[str, ...]: + if runtime.recovery_coordinator.pending_events(runtime.scope.key): + raise SystemExit("simulated death before risk settlement") + return () + + monkeypatch.setattr( + runtime.recovery_coordinator, "append_pending_monitor_events", crash_after_result + ) + try: + with pytest.raises(SystemExit, match="before risk settlement"): + runtime.submit( + intent, + lambda order: execution.ProviderObservation.accepted( + order.intent_id, "provider.order.known" + ), + ) + record = runtime.facade.get(intent.intent_id) + work = runtime.recovery_coordinator.work_for(runtime.scope.key, intent.intent_id) + assert record.state is execution.ExecutionState.ACKED + assert work.risk_settlement_status == "PENDING" + assert cause_id in runtime.risk_gate.active_freeze_reasons(runtime.risk_scope) + + # Force the permit's deadline into the past. Calling snapshot performs + # the TTL sweep, which must retain a dispatch-claimed reservation. + with sqlite3.connect(tmp_path / "risk.sqlite3") as connection: + connection.execute("UPDATE risk_reservations SET expires_at = 0") + assert runtime.risk_gate.snapshot(runtime.risk_scope)["increase_count"] == 1 + assert cause_id in runtime.risk_gate.active_freeze_reasons(runtime.risk_scope) + finally: + runtime.close() + + _, reopened = _runtime(monkeypatch, tmp_path, writer_id="writer.reopened") + try: + report = reopened.recover() + work = reopened.recovery_coordinator.work_for(reopened.scope.key, intent.intent_id) + + assert report.resolved_freeze_intent_ids == (intent.intent_id,) + assert work.risk_settlement_status == "SETTLED" + assert reopened.risk_gate.snapshot(reopened.risk_scope)["increase_count"] == 1 + assert cause_id not in reopened.risk_gate.active_freeze_reasons(reopened.risk_scope) + finally: + reopened.close() + + +def test_recovery_discovers_dispatching_record_left_before_prepare_hook_without_provider_io( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + """A pre-hook crash becomes UNKNOWN/reconcile instead of stranded DISPATCHING.""" + + loaded, runtime = _runtime(monkeypatch, tmp_path, writer_id="writer.initial") + execution = loaded.require(CAPABILITY_EXECUTION) + intent = _intent(execution, runtime, "intent.prepare-hook-gap") + provider_calls: list[str] = [] + + monkeypatch.setattr( + runtime.recovery_coordinator, + "prepare_dispatch", + lambda _intent: (_ for _ in ()).throw(SystemExit("simulated pre-hook crash")), + ) + try: + with pytest.raises(SystemExit, match="pre-hook crash"): + runtime.submit( + intent, + lambda order: provider_calls.append(order.intent_id), + ) + assert runtime.facade.get(intent.intent_id).state is execution.ExecutionState.DISPATCHING + assert runtime.recovery_coordinator.work_for(runtime.scope.key, intent.intent_id) is None + assert provider_calls == [] + finally: + runtime.close() + + _, reopened = _runtime(monkeypatch, tmp_path, writer_id="writer.reopened") + retry_calls: list[str] = [] + try: + report = reopened.recover() + cause_id = "dispatch-inflight:" + intent.intent_id + + assert report.recovered_unknown_intent_ids == (intent.intent_id,) + assert report.reconciliation_required_intent_ids == (intent.intent_id,) + assert reopened.facade.get(intent.intent_id).state is execution.ExecutionState.UNKNOWN + assert cause_id in reopened.risk_gate.active_freeze_reasons(reopened.risk_scope) + assert reopened.submit(intent, lambda order: retry_calls.append(order.intent_id)).state is ( + execution.ExecutionState.UNKNOWN + ) + assert retry_calls == [] + finally: + reopened.close() + + +def test_expired_writer_generation_cannot_project_provider_result_after_new_owner_fences_it( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + """A stale provider thread cannot append ACKED after its lease is replaced.""" + + loaded, runtime = _runtime(monkeypatch, tmp_path, writer_id="writer.initial") + execution = loaded.require(CAPABILITY_EXECUTION) + intent = _intent(execution, runtime, "intent.writer-fenced-after-provider") + runtime.facade._lease_ttl_ns = 100_000_000 # 100ms; expiry occurs inside provider I/O + entered = threading.Event() + release = threading.Event() + errors: list[BaseException] = [] + provider_calls: list[str] = [] + + def blocking_provider(order: Any) -> Any: + provider_calls.append(order.intent_id) + entered.set() + assert release.wait(timeout=5) + return execution.ProviderObservation.accepted(order.intent_id, "provider.order.fenced") + + def submit() -> None: + try: + runtime.submit(intent, blocking_provider) + except BaseException as error: # asserted after the provider is released + errors.append(error) + + thread = threading.Thread(target=submit) + thread.start() + assert entered.wait(timeout=5) + try: + time.sleep(0.15) + contender = runtime.execution_store.acquire_or_renew_lease( + runtime.scope, + "writer.contender", + ttl_ns=1_000_000_000, + ) + assert contender.fencing_token > runtime.facade._last_writer_lease.fencing_token + release.set() + thread.join(timeout=5) + + assert not thread.is_alive() + assert len(errors) == 1 + assert isinstance(errors[0], execution.WriterLeaseUnavailable) + assert runtime.facade.get(intent.intent_id).state is execution.ExecutionState.DISPATCHING + # The stale facade close must not delete the contender's generation. + assert runtime.facade.close() is False + assert provider_calls == [intent.intent_id] + assert runtime.execution_store.release_lease( + runtime.scope, + "writer.contender", + fencing_token=contender.fencing_token, + ) + finally: + release.set() + thread.join(timeout=5) + runtime.close() + + _, reopened = _runtime(monkeypatch, tmp_path, writer_id="writer.reopened") + try: + report = reopened.recover() + assert report.recovered_unknown_intent_ids == (intent.intent_id,) + assert reopened.facade.get(intent.intent_id).state is execution.ExecutionState.UNKNOWN + finally: + reopened.close() diff --git a/tests/runtime_plugins/test_reconcile_control.py b/tests/runtime_plugins/test_reconcile_control.py new file mode 100644 index 00000000..0e20f4b5 --- /dev/null +++ b/tests/runtime_plugins/test_reconcile_control.py @@ -0,0 +1,595 @@ +"""Acceptance tests for reviewed reconciliation and per-intent freeze release.""" + +from __future__ import annotations + +import importlib +from decimal import Decimal +from pathlib import Path + +import pytest + +from bt_api_py.runtime_plugins import ( + CAPABILITY_EXECUTION, + CAPABILITY_MONITOR, + CAPABILITY_RISK, + AuthorizationDecision, + CapabilityCatalog, + CapabilityPin, + ControlCommandStatus, + ManagedReconciliationControlPort, + ReconciliationEvidence, + ReleaseIntentFreezeCommand, + RuntimeCapabilityContract, + RuntimePluginError, + SealedNormalizedInstrumentMetadataSnapshot, + compose_managed_execution, +) + +_REPO_ROOT = Path(__file__).resolve().parents[2] +_CAPABILITY_SOURCES = ( + _REPO_ROOT / "bt_api" / "bt_api_base" / "src", + _REPO_ROOT / "bt_api" / "bt_api_execution" / "src", + _REPO_ROOT / "bt_api" / "bt_api_risk" / "src", + _REPO_ROOT / "bt_api" / "bt_api_monitor" / "src", +) +_NOW = 1_700_000_000.0 + + +def _contract() -> RuntimeCapabilityContract: + return RuntimeCapabilityContract( + strategy_id="example.014_1.ctp_options_lowfreq", + mode="live", + preset="managed_live_direct", + environment="production", + order_route="managed_execution", + required_capabilities=( + CAPABILITY_EXECUTION, + CAPABILITY_RISK, + CAPABILITY_MONITOR, + ), + effective_digest="a" * 64, + ) + + +def _catalog(monkeypatch: pytest.MonkeyPatch) -> CapabilityCatalog: + for source in _CAPABILITY_SOURCES: + monkeypatch.syspath_prepend(str(source)) + return CapabilityCatalog( + ( + CapabilityPin( + CAPABILITY_EXECUTION, "bt_api_execution", "bt_api_execution", "0.1.0" + ), + CapabilityPin(CAPABILITY_RISK, "bt_api_risk", "bt_api_risk", "0.1.0"), + CapabilityPin( + CAPABILITY_MONITOR, "bt_api_monitor", "bt_api_monitor", "0.1.0" + ), + ), + importer=importlib.import_module, + version_getter=lambda distribution: "0.1.0", + ) + + +def _snapshot() -> SealedNormalizedInstrumentMetadataSnapshot: + return SealedNormalizedInstrumentMetadataSnapshot.from_normalized_payload( + { + "provider": "fixture_provider", + "environment": "production", + "account_ref": "fixture_account", + "trading_day": "20260922", + "metadata_version": "fixture-normalized-v1", + "as_of_ns": 1_000, + "expires_at_ns": 2_000, + "account_currency": "USD", + "instruments": [ + { + "instrument": "fixture/contract", + "tick_size": "0.1", + "lot_size": "1", + "contract_multiplier": "1", + "max_gross_notional_account": "1000", + "quote_currency": "USD", + "fee_currency": "USD", + "quote_to_account_fx": "1", + "fee_to_account_fx": "1", + "taker_fee_bps": "0", + "fixed_fee": "0", + "max_slippage_bps": "0", + "quantity_unit": "contracts", + } + ], + } + ) + + +def _runtime(monkeypatch: pytest.MonkeyPatch, state_directory: Path): + loaded = _catalog(monkeypatch).load(_contract()) + snapshot = _snapshot() + return loaded, compose_managed_execution( + loaded, + state_directory=state_directory, + provider="fixture_provider", + environment="production", + account_ref="fixture_account", + strategy_id="example.014_1.ctp_options_lowfreq", + writer_id="fixture_writer", + policy_id="fixture_policy", + max_increase_notional=Decimal("100"), + max_increase_count=3, + trading_day=snapshot.trading_day, + instrument_metadata_snapshot=snapshot, + instrument_clock_ns=lambda: 1_500, + ) + + +def _unknown_intent(runtime, execution): + snapshot = runtime.instrument_metadata_snapshot + assert snapshot is not None + return execution.OrderIntent.limit( + intent_id="intent.unknown", + scope=runtime.scope, + signal_id="signal.unknown", + instrument="fixture/contract", + side=execution.Side.BUY, + quantity=Decimal("1"), + price=Decimal("10"), + metadata_version=snapshot.metadata_version, + tags={ + "instrument_metadata_digest": snapshot.instrument_digest("fixture/contract"), + "quantity_unit": snapshot.instrument_metadata("fixture/contract").quantity_unit, + }, + ) + + +def _make_unknown(runtime, execution): + intent = _unknown_intent(runtime, execution) + + def timeout_provider(_intent: object) -> object: + raise TimeoutError("fixture timeout") + + record = runtime.submit(intent, timeout_provider) + assert record.state is execution.ExecutionState.UNKNOWN + cause_id = "dispatch-inflight:" + intent.intent_id + assert cause_id in runtime.risk_gate.active_freeze_reasons(runtime.risk_scope) + return intent, cause_id + + +def _evidence(intent, execution) -> ReconciliationEvidence: + return ReconciliationEvidence( + evidence_id="evidence.unknown.1", + intent_id=intent.intent_id, + observation=execution.ProviderObservation.accepted( + intent.intent_id, "provider.order.unknown" + ), + source_receipt_digest="b" * 64, + observed_at=_NOW, + ) + + +def _command(runtime, evidence: ReconciliationEvidence) -> ReleaseIntentFreezeCommand: + return ReleaseIntentFreezeCommand( + command_id="release.unknown.1", + scope=runtime.scope.key, + intent_id=evidence.intent_id, + evidence_id=evidence.evidence_id, + evidence_fingerprint=evidence.fingerprint, + issuer_id="operator.alice", + reason_code="dual_review_complete", + issued_at=_NOW + 1.0, + expires_at=_NOW + 60.0, + ) + + +def _approve(request) -> AuthorizationDecision: + assert request.command.issuer_id == "operator.alice" + assert request.evidence.intent_id == request.command.intent_id + return AuthorizationDecision( + approved=True, + subject_id="operator.alice", + receipt_digest="c" * 64, + reason_code="dual_review_complete", + ) + + +def _control( + runtime, state_directory: Path, authorize=_approve +) -> ManagedReconciliationControlPort: + assert runtime.state_directory == state_directory.resolve(strict=False) + return runtime.create_reconciliation_control( + authorize=authorize, + clock=lambda: _NOW + 2.0, + ) + + +def _event_types(runtime) -> list[str]: + return [ + event.event.event_type + for event in runtime.outbox.read_pending( + "reconcile-test", runtime.scope.key, limit=100 + ) + ] + + +def test_unknown_reconcile_then_authorized_release_requires_audited_identity( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + loaded, runtime = _runtime(monkeypatch, tmp_path) + execution = loaded.require(CAPABILITY_EXECUTION) + control = _control(runtime, tmp_path) + try: + intent, cause_id = _make_unknown(runtime, execution) + evidence = _evidence(intent, execution) + + # The facade can record evidence, but it remains ledger-only and leaves + # the runtime freeze in place until the control port has an auditable + # reconciliation and an explicitly authorized release command. + direct = runtime.facade.reconcile(evidence.observation) + assert direct.state is execution.ExecutionState.ACKED + assert cause_id in runtime.risk_gate.active_freeze_reasons(runtime.risk_scope) + assert "execution_reconciled" not in _event_types(runtime) + + reconciled = control.reconcile(evidence) + assert reconciled.record.state is execution.ExecutionState.ACKED + assert reconciled.audit.monitor_published is True + assert cause_id in runtime.risk_gate.active_freeze_reasons(runtime.risk_scope) + + command = _command(runtime, evidence) + released = control.release_intent_freeze(command) + + assert released.released is True + assert released.idempotent is False + assert cause_id not in runtime.risk_gate.active_freeze_reasons( + runtime.risk_scope + ) + audit = control.audit.get_command(command.command_id) + assert audit is not None + assert audit.status is ControlCommandStatus.RELEASED + assert audit.authorization_subject_id == command.issuer_id + assert audit.authorization_receipt_digest == "c" * 64 + assert _event_types(runtime).count("execution_reconciled") == 1 + assert _event_types(runtime).count("execution_freeze_release_authorized") == 1 + assert _event_types(runtime).count("execution_freeze_released") == 1 + + # The generic restart recovery pass may publish its own deterministic + # execution fact, but it must not reassert a latch after this separate + # audited control port has released it. + runtime.recover() + assert cause_id not in runtime.risk_gate.active_freeze_reasons( + runtime.risk_scope + ) + + replay = control.release_intent_freeze(command) + assert replay.idempotent is True + assert _event_types(runtime).count("execution_freeze_released") == 1 + finally: + control.close() + runtime.close() + + +def test_unauthorized_release_is_durably_refused_and_keeps_freeze( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + loaded, runtime = _runtime(monkeypatch, tmp_path) + execution = loaded.require(CAPABILITY_EXECUTION) + authorizer_calls: list[str] = [] + + def deny(request) -> AuthorizationDecision: + authorizer_calls.append(request.command.command_id) + return AuthorizationDecision( + approved=False, + subject_id=request.command.issuer_id, + receipt_digest="d" * 64, + reason_code="operator_not_authorized", + ) + + control = _control(runtime, tmp_path, deny) + try: + intent, cause_id = _make_unknown(runtime, execution) + evidence = _evidence(intent, execution) + control.reconcile(evidence) + command = _command(runtime, evidence) + + with pytest.raises(RuntimePluginError) as caught: + control.release_intent_freeze(command) + + assert caught.value.code == "CONTROL_AUTHORIZATION_DENIED" + assert cause_id in runtime.risk_gate.active_freeze_reasons(runtime.risk_scope) + audit = control.audit.get_command(command.command_id) + assert audit is not None + assert audit.status is ControlCommandStatus.DENIED + assert audit.authorization_subject_id == command.issuer_id + assert authorizer_calls == [command.command_id] + + with pytest.raises(RuntimePluginError) as repeated: + control.release_intent_freeze(command) + assert repeated.value.code == "CONTROL_AUTHORIZATION_DENIED" + assert authorizer_calls == [command.command_id] + finally: + control.close() + runtime.close() + + +def test_reconciliation_monitor_outbox_failure_keeps_unknown_freeze( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + loaded, runtime = _runtime(monkeypatch, tmp_path) + execution = loaded.require(CAPABILITY_EXECUTION) + control = _control(runtime, tmp_path) + original_append = runtime.outbox.append + + def fail_reconciliation(event: object) -> object: + if event.event_type == "execution_reconciled": + raise OSError("monitor unavailable") + return original_append(event) + + try: + intent, cause_id = _make_unknown(runtime, execution) + evidence = _evidence(intent, execution) + monkeypatch.setattr(runtime.outbox, "append", fail_reconciliation) + + with pytest.raises(RuntimePluginError) as caught: + control.reconcile(evidence) + + assert caught.value.code == "RECONCILIATION_MONITOR_OUTBOX_UNCONFIRMED" + assert cause_id in runtime.risk_gate.active_freeze_reasons(runtime.risk_scope) + audit = control.audit.get_reconciliation(evidence.evidence_id) + assert audit is not None + assert audit.monitor_published is False + + monkeypatch.setattr(runtime.outbox, "append", original_append) + recovered = control.reconcile(evidence) + assert recovered.audit.monitor_published is True + assert cause_id in runtime.risk_gate.active_freeze_reasons(runtime.risk_scope) + finally: + control.close() + runtime.close() + + +def test_release_monitor_outbox_failure_reasserts_freeze_before_retry( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + loaded, runtime = _runtime(monkeypatch, tmp_path) + execution = loaded.require(CAPABILITY_EXECUTION) + control = _control(runtime, tmp_path) + original_append = runtime.outbox.append + + def fail_authorization_event(event: object) -> object: + if event.event_type == "execution_freeze_release_authorized": + raise OSError("monitor unavailable") + return original_append(event) + + try: + intent, cause_id = _make_unknown(runtime, execution) + evidence = _evidence(intent, execution) + control.reconcile(evidence) + command = _command(runtime, evidence) + monkeypatch.setattr(runtime.outbox, "append", fail_authorization_event) + + with pytest.raises(RuntimePluginError) as caught: + control.release_intent_freeze(command) + + assert caught.value.code == "CONTROL_MONITOR_OUTBOX_UNCONFIRMED" + assert cause_id in runtime.risk_gate.active_freeze_reasons(runtime.risk_scope) + audit = control.audit.get_command(command.command_id) + assert audit is not None + assert audit.status is ControlCommandStatus.AUTHORIZED + + monkeypatch.setattr(runtime.outbox, "append", original_append) + retried = control.release_intent_freeze(command) + assert retried.released is True + assert cause_id not in runtime.risk_gate.active_freeze_reasons( + runtime.risk_scope + ) + finally: + control.close() + runtime.close() + + +def test_final_release_monitor_outbox_failure_reasserts_freeze( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + loaded, runtime = _runtime(monkeypatch, tmp_path) + execution = loaded.require(CAPABILITY_EXECUTION) + control = _control(runtime, tmp_path) + original_append = runtime.outbox.append + + def fail_release_event(event: object) -> object: + if event.event_type == "execution_freeze_released": + raise OSError("monitor unavailable") + return original_append(event) + + try: + intent, cause_id = _make_unknown(runtime, execution) + evidence = _evidence(intent, execution) + control.reconcile(evidence) + command = _command(runtime, evidence) + monkeypatch.setattr(runtime.outbox, "append", fail_release_event) + + with pytest.raises(RuntimePluginError) as caught: + control.release_intent_freeze(command) + + assert caught.value.code == "CONTROL_MONITOR_OUTBOX_UNCONFIRMED" + assert cause_id in runtime.risk_gate.active_freeze_reasons(runtime.risk_scope) + audit = control.audit.get_command(command.command_id) + assert audit is not None + assert audit.status is ControlCommandStatus.PENDING + + monkeypatch.setattr(runtime.outbox, "append", original_append) + retried = control.release_intent_freeze(command) + assert retried.released is True + assert cause_id not in runtime.risk_gate.active_freeze_reasons( + runtime.risk_scope + ) + finally: + control.close() + runtime.close() + + +def test_reconcile_and_release_are_idempotent_across_runtime_restart( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + loaded, first = _runtime(monkeypatch, tmp_path) + execution = loaded.require(CAPABILITY_EXECUTION) + first_control = _control(first, tmp_path) + intent, cause_id = _make_unknown(first, execution) + evidence = _evidence(intent, execution) + command = _command(first, evidence) + try: + first_control.reconcile(evidence) + first_control.reconcile(evidence) + assert _event_types(first).count("execution_reconciled") == 1 + assert cause_id in first.risk_gate.active_freeze_reasons(first.risk_scope) + finally: + first_control.close() + first.close() + + _, second = _runtime(monkeypatch, tmp_path) + second_control = _control(second, tmp_path) + try: + replayed = second_control.reconcile(evidence) + assert replayed.audit.monitor_published is True + assert _event_types(second).count("execution_reconciled") == 1 + assert cause_id in second.risk_gate.active_freeze_reasons(second.risk_scope) + + released = second_control.release_intent_freeze(command) + assert released.released is True + assert _event_types(second).count("execution_freeze_released") == 1 + finally: + second_control.close() + second.close() + + _, third = _runtime(monkeypatch, tmp_path) + third_control = _control(third, tmp_path) + try: + replay = third_control.release_intent_freeze(command) + assert replay.released is True + assert replay.idempotent is True + assert cause_id not in third.risk_gate.active_freeze_reasons(third.risk_scope) + assert _event_types(third).count("execution_freeze_released") == 1 + finally: + third_control.close() + third.close() + + +@pytest.mark.parametrize("release_was_called", [False, True]) +def test_restart_reasserts_prepared_release_before_or_after_risk_mutation( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path, release_was_called: bool +) -> None: + """A prepared audit row is never treated as successful release evidence. + + This simulates a process death on either side of the separate risk SQLite + mutation. The next control-port construction must restore the intent latch + before another reviewed retry can be considered. + """ + + loaded, first = _runtime(monkeypatch, tmp_path) + execution = loaded.require(CAPABILITY_EXECUTION) + first_control = _control(first, tmp_path) + intent, cause_id = _make_unknown(first, execution) + evidence = _evidence(intent, execution) + command = _command(first, evidence) + try: + first_control.reconcile(evidence) + first_control.audit.record_command(command) + first_control.audit.record_authorization( + command.command_id, + AuthorizationDecision( + approved=True, + subject_id=command.issuer_id, + receipt_digest="c" * 64, + reason_code="dual_review_complete", + ), + ) + prepared = first_control.audit.record_release_applied(command.command_id) + assert prepared.release_applied_at is not None + if release_was_called: + first.risk_gate.resolve_freeze(first.risk_scope, cause_id) + assert cause_id not in first.risk_gate.active_freeze_reasons( + first.risk_scope + ) + finally: + first_control.close() + first.close() + + _, restarted = _runtime(monkeypatch, tmp_path) + restarted_control = _control(restarted, tmp_path) + try: + assert cause_id in restarted.risk_gate.active_freeze_reasons( + restarted.risk_scope + ) + audit = restarted_control.audit.get_command(command.command_id) + assert audit is not None + assert audit.status is ControlCommandStatus.PENDING + # The prepare timestamp survives reassertion so an eventual retry has a + # stable monitor event identity instead of conflicting with an already + # persisted delivery fact. + assert audit.release_applied_at == prepared.release_applied_at + released = restarted_control.release_intent_freeze(command) + assert released.released is True + assert cause_id not in restarted.risk_gate.active_freeze_reasons( + restarted.risk_scope + ) + finally: + restarted_control.close() + restarted.close() + + +@pytest.mark.parametrize("failure_stage", ["outbox", "final_audit"]) +def test_release_failure_then_restart_keeps_freeze_and_reuses_stable_event( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path, failure_stage: str +) -> None: + """Outbox/audit failures after risk mutation remain fail-closed across restart.""" + + loaded, first = _runtime(monkeypatch, tmp_path) + execution = loaded.require(CAPABILITY_EXECUTION) + first_control = _control(first, tmp_path) + intent, cause_id = _make_unknown(first, execution) + evidence = _evidence(intent, execution) + command = _command(first, evidence) + try: + first_control.reconcile(evidence) + if failure_stage == "outbox": + original_append = first.outbox.append + + def fail_release_event(event): + if event.event_type == "execution_freeze_released": + raise OSError("monitor unavailable") + return original_append(event) + + monkeypatch.setattr(first.outbox, "append", fail_release_event) + expected_code = "CONTROL_MONITOR_OUTBOX_UNCONFIRMED" + else: + monkeypatch.setattr( + first_control.audit, + "mark_command_released", + lambda _command_id: (_ for _ in ()).throw(OSError("audit unavailable")), + ) + expected_code = "CONTROL_AUDIT_UNCONFIRMED" + + with pytest.raises(RuntimePluginError) as caught: + first_control.release_intent_freeze(command) + assert caught.value.code == expected_code + assert cause_id in first.risk_gate.active_freeze_reasons(first.risk_scope) + audit = first_control.audit.get_command(command.command_id) + assert audit is not None + assert audit.status is ControlCommandStatus.PENDING + assert audit.release_applied_at is not None + finally: + first_control.close() + first.close() + + _, restarted = _runtime(monkeypatch, tmp_path) + restarted_control = _control(restarted, tmp_path) + try: + assert cause_id in restarted.risk_gate.active_freeze_reasons( + restarted.risk_scope + ) + assert restarted_control.release_intent_freeze(command).released is True + assert cause_id not in restarted.risk_gate.active_freeze_reasons( + restarted.risk_scope + ) + # A final-audit failure may already have made the stable release event + # durable. A retry must use that same id/payload rather than conflict. + assert _event_types(restarted).count("execution_freeze_released") == 1 + finally: + restarted_control.close() + restarted.close() diff --git a/tests/test_iteration41_capability_bundle.py b/tests/test_iteration41_capability_bundle.py new file mode 100644 index 00000000..916f7b05 --- /dev/null +++ b/tests/test_iteration41_capability_bundle.py @@ -0,0 +1,806 @@ +"""Acceptance checks for the local-only Iteration 41 wheel consumer matrix.""" + +from __future__ import annotations + +import base64 +import hashlib +import json +import os +import sys +import zipfile +from importlib import metadata +from pathlib import Path + +import pytest + +from scripts.ci import verify_iteration41_capability_bundle as verifier +from scripts.ci.verify_iteration41_capability_bundle import ( + _PYTHON_SOCKET_GUARD_DESCRIPTION, + _REDACTION_SENTINEL_ENV, + FAILED_RESULT, + LOCAL_ONLY_RESULT, + BundleVerificationError, + ProjectSpec, + _capture_source_snapshot, + _installed_record_rows, + _offline_environment, + _parse_consumer_probe_payload, + _recheck_source_snapshot, + _record_contract, + _repackage_distribution, + _run_logged, + _stage_snapshot, + _validate_consumer_probe_payload, + _write_receipt_atomically, + main, + verify, +) + +SDK_ROOT = Path(__file__).resolve().parents[1] + + +def _backtrader_root() -> Path: + """Locate the optional sibling checkout without hard-coding a drive path.""" + + candidates = [] + configured = os.environ.get("BACKTRADER_ROOT") + if configured: + candidates.append(Path(configured)) + candidates.extend( + ( + SDK_ROOT.parent / "source_code" / "backtrader", + SDK_ROOT.parent / "backtrader", + ) + ) + for candidate in candidates: + if (candidate / "setup.py").is_file() and (candidate / "backtrader").is_dir(): + return candidate.resolve() + pytest.skip( + "set BACKTRADER_ROOT to run the full local capability-bundle integration test" + ) + + +def _record_hash(payload: bytes) -> str: + return "sha256=" + base64.urlsafe_b64encode( + hashlib.sha256(payload).digest() + ).decode().rstrip("=") + + +def _write_controller_distribution( + site_root: Path, + *, + name: str, + version: str, + payloads: dict[str, bytes], +) -> metadata.Distribution: + """Create a minimal controller install with a valid RECORD for unit tests.""" + + metadata_name = f"{name.replace('-', '_')}-{version}.dist-info" + metadata_payload = ( + f"Metadata-Version: 2.1\nName: {name}\nVersion: {version}\n" + ).encode() + members = {**payloads, f"{metadata_name}/METADATA": metadata_payload} + for relative, payload in members.items(): + target = site_root / relative + target.parent.mkdir(parents=True, exist_ok=True) + target.write_bytes(payload) + rows = [ + f"{relative},{_record_hash(payload)},{len(payload)}" + for relative, payload in sorted(members.items()) + ] + rows.append(f"{metadata_name}/RECORD,,") + metadata_root = site_root / metadata_name + (metadata_root / "RECORD").write_text("\n".join(rows) + "\n", encoding="utf-8") + return metadata.PathDistribution(metadata_root) + + +@pytest.mark.integration +def test_local_bundle_builds_reproducible_wheels_and_uses_only_installed_artifacts( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A full matrix proves only local mechanics, never release eligibility.""" + + monkeypatch.setenv(_REDACTION_SENTINEL_ENV, "controller-secret-must-not-leak") + receipt = verify( + artifacts_dir=tmp_path / "iteration41-bundle", + backtrader_root=_backtrader_root(), + sdk_root=SDK_ROOT, + ) + + assert receipt["result"] == LOCAL_ONLY_RESULT + assert receipt["local_validation"] == "PASSED" + assert receipt["release_status"] == "NOT_RELEASE_ELIGIBLE" + assert receipt["consumer"]["local_wheelhouse_only"] is True + assert receipt["consumer"]["probe_payload"]["provider_evidence"] == "fixture_only" + assert receipt["consumer"]["probe_payload"]["fake_provider_calls"] == 1 + assert receipt["consumer"]["probe_payload"]["network_guard_attempts"] == [] + assert receipt["consumer"]["probe_payload"]["local_backtest_report"]["status"] == ( + verifier._LOCAL_BACKTEST_EXPECTED_STATUS + ) + assert all( + receipt["consumer"]["probe_payload"]["local_backtest_report"][field] == 0 + for field in ( + "external_network_requests", + "external_write_requests", + "actual_fills", + "provider_submissions", + ) + ) + assert { + runtime_id: report["status"] + for runtime_id, report in receipt["consumer"]["probe_payload"]["l2_fixture_reports"].items() + } == verifier._L2_FIXTURE_EXPECTED_STATUSES + assert all( + report["external_network_requests"] == 0 + and report["external_write_requests"] == 0 + and report["actual_fills"] == 0 + for report in receipt["consumer"]["probe_payload"]["l2_fixture_reports"].values() + ) + assert ( + receipt["consumer"]["probe_payload"]["controller_environment_sentinel_absent"] + is True + ) + assert "bt_api_base" in receipt["consumer"]["expected_local_projects"] + assert set(receipt["consumer"]["expected_local_projects"]) == set( + receipt["local_wheels"] + ) + assert all( + "site-packages" in path.replace("\\", "/").lower() + for path in receipt["consumer"]["probe_payload"]["module_paths"].values() + ) + assert all(item["record_validated"] for item in receipt["local_wheels"].values()) + assert all(item["reproducible_build"] for item in receipt["local_wheels"].values()) + assert all( + item["captured_from_bytes"] for item in receipt["source_snapshots"].values() + ) + assert receipt["wheelhouse_manifest"]["wheel_count"] >= len(receipt["local_wheels"]) + assert len(receipt["wheelhouse_manifest"]["sha256"]) == 64 + assert (tmp_path / "iteration41-bundle" / "receipt.json").is_file() + + +def test_source_snapshot_freezes_bytes_and_detects_original_mutation( + tmp_path: Path, +) -> None: + source_root = tmp_path / "source" + package = source_root / "fixture_package" + package.mkdir(parents=True) + (source_root / "setup.py").write_text( + "from setuptools import setup\nsetup(name='fixture')\n" + ) + tracked = package / "__init__.py" + tracked.write_text("VALUE = 'before'\n", encoding="utf-8") + project = ProjectSpec( + key="fixture", + distribution="fixture", + module="fixture_package", + source_root=source_root, + includes=("setup.py", "fixture_package"), + ) + + snapshot = _capture_source_snapshot(project, tmp_path / "frozen") + tracked.write_text("VALUE = 'after'\n", encoding="utf-8") + + build_source = tmp_path / "build-source" + _stage_snapshot(snapshot, build_source) + assert (build_source / "fixture_package" / "__init__.py").read_text( + encoding="utf-8" + ) == ("VALUE = 'before'\n") + with pytest.raises(BundleVerificationError, match="source content changed"): + _recheck_source_snapshot(snapshot) + + +def test_repackaging_rejects_poisoned_editable_startup_hook( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + site_root = tmp_path / "site-packages" + site_root.mkdir() + poison = tmp_path / "poisoned.txt" + distribution = _write_controller_distribution( + site_root, + name="evil-editable", + version="1.0", + payloads={ + "__editable__.evil_editable-1.0.pth": ( + "import pathlib; pathlib.Path(" + + repr(str(poison)) + + ").write_text('executed')\n" + ).encode("utf-8"), + }, + ) + monkeypatch.setattr(verifier, "_site_roots", lambda: (site_root.resolve(),)) + + with pytest.raises(BundleVerificationError, match="startup hook"): + _repackage_distribution(distribution, tmp_path / "wheelhouse") + + assert not poison.exists() + assert not list((tmp_path / "wheelhouse").glob("*.whl")) + + +def test_repackaging_rejects_external_startup_hook_before_skipping_it( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + site_root = tmp_path / "site-packages" + site_root.mkdir() + distribution = _write_controller_distribution( + site_root, + name="external-hook", + version="1.0", + payloads={"external_hook.py": b"VALUE = 'safe'\n"}, + ) + metadata_root = site_root / "external_hook-1.0.dist-info" + with (metadata_root / "RECORD").open("a", encoding="utf-8") as handle: + handle.write("../../Scripts/external_hook.pth,,\n") + distribution = metadata.PathDistribution(metadata_root) + monkeypatch.setattr(verifier, "_site_roots", lambda: (site_root.resolve(),)) + + with pytest.raises(BundleVerificationError, match="startup hook"): + _repackage_distribution(distribution, tmp_path / "wheelhouse") + + assert not list((tmp_path / "wheelhouse").glob("*.whl")) + + +def test_repackaging_skips_missing_nonruntime_record_member_and_marks_origin_untrusted( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A stale controller doc/data entry never becomes an external wheel copy.""" + + site_root = tmp_path / "site-packages" + site_root.mkdir() + distribution = _write_controller_distribution( + site_root, + name="stale-data-record", + version="1.0", + payloads={"stale_data_record.py": b"VALUE = 'installed'\n"}, + ) + metadata_root = site_root / "stale_data_record-1.0.dist-info" + # ``fonttools``-style wheel records can retain a static `.data/data` man + # page even though that payload was not installed into site-packages. It + # must not cause the repackage step to read from any outside controller + # path. + with (metadata_root / "RECORD").open("a", encoding="utf-8") as handle: + handle.write("stale_data_record-1.0.data/data/share/man/man1/ttx.1,,\n") + distribution = metadata.PathDistribution(metadata_root) + monkeypatch.setattr(verifier, "_site_roots", lambda: (site_root.resolve(),)) + + wheel, origin = _repackage_distribution(distribution, tmp_path / "wheelhouse") + + assert origin["controller_record_status"] == "UNTRUSTED_CONTROLLER_REPACK" + assert ( + origin["controller_record_reason"] == "UNREPACKAGED_CONTROLLER_RECORD_MEMBERS" + ) + assert origin["skipped_controller_member_categories"] == { + "missing_or_non_regular_member": 1 + } + with zipfile.ZipFile(wheel) as archive: + assert "stale_data_record.py" in archive.namelist() + assert not any(name.endswith("ttx.1") for name in archive.namelist()) + + +def test_repackaging_omits_existing_external_runtime_member_and_import_fails( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """An external `.py` never enters a repackaged wheel or fake consumer PASS.""" + + site_root = tmp_path / "site-packages" + site_root.mkdir() + distribution = _write_controller_distribution( + site_root, + name="external-runtime", + version="1.0", + payloads={"external_runtime.py": b"VALUE = 'safe'\n"}, + ) + metadata_root = site_root / "external_runtime-1.0.dist-info" + member = "external_runtime.py" + outside = tmp_path / "outside" / member + outside.parent.mkdir() + outside.write_text("outside-controller-bytes", encoding="utf-8") + + class ExternalStaticPathDistribution(metadata.PathDistribution): + def locate_file(self, path: object) -> Path: + if str(path) == member: + return outside + return super().locate_file(path) + + distribution = ExternalStaticPathDistribution(metadata_root) + monkeypatch.setattr(verifier, "_site_roots", lambda: (site_root.resolve(),)) + + wheel, origin = _repackage_distribution(distribution, tmp_path / "wheelhouse") + + assert origin["controller_record_status"] == "UNTRUSTED_CONTROLLER_REPACK" + assert origin["skipped_controller_member_categories"] == {"external_member": 1} + with zipfile.ZipFile(wheel) as archive: + assert member not in archive.namelist() + assert "outside-controller-bytes" not in archive.read( + "external_runtime-1.0.dist-info/METADATA" + ).decode("utf-8") + + venv_dir = tmp_path / "venv" + create = _run_logged( + [sys.executable, "-m", "venv", str(venv_dir)], + cwd=tmp_path, + environment=_offline_environment(), + logs_dir=tmp_path / "logs", + name="external-member-venv", + ) + assert create["exit_code"] == 0 + python = venv_dir / ("Scripts/python.exe" if os.name == "nt" else "bin/python") + install = _run_logged( + [ + str(python), + "-m", + "pip", + "install", + "--no-index", + "--no-deps", + "--force-reinstall", + str(wheel), + ], + cwd=tmp_path, + environment=_offline_environment(), + logs_dir=tmp_path / "logs", + name="external-member-install", + ) + assert install["exit_code"] == 0 + probe = _run_logged( + [str(python), "-I", "-c", "import external_runtime"], + cwd=tmp_path, + environment=_offline_environment(), + logs_dir=tmp_path / "logs", + name="external-member-probe", + ) + assert probe["exit_code"] != 0 + assert "ModuleNotFoundError" in probe["stderr"] + + +def test_repackaging_omits_nonconsole_unsafe_record_path( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A path escape is never copied, even though the offline wheel can be built.""" + + site_root = tmp_path / "site-packages" + site_root.mkdir() + distribution = _write_controller_distribution( + site_root, + name="unsafe-record", + version="1.0", + payloads={"unsafe_record.py": b"VALUE = 'safe'\n"}, + ) + metadata_root = site_root / "unsafe_record-1.0.dist-info" + with (metadata_root / "RECORD").open("a", encoding="utf-8") as handle: + handle.write("../../outside.txt,,\n") + distribution = metadata.PathDistribution(metadata_root) + monkeypatch.setattr(verifier, "_site_roots", lambda: (site_root.resolve(),)) + + wheel, origin = _repackage_distribution(distribution, tmp_path / "wheelhouse") + + assert origin["controller_record_status"] == "UNTRUSTED_CONTROLLER_REPACK" + assert origin["skipped_controller_member_categories"] == {"unsafe_relative_path": 1} + with zipfile.ZipFile(wheel) as archive: + assert "unsafe_record.py" in archive.namelist() + + +@pytest.mark.parametrize( + "member", + ( + "static_hook-1.0.data/data/share/evil.pth ", + "static_hook-1.0.data/data/share/sitecustomize.py.", + ), +) +def test_repackaging_rejects_startup_hook_inside_static_data_exception( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + member: str, +) -> None: + """Omitting an unsafe member cannot suppress startup-hook rejection.""" + + site_root = tmp_path / "site-packages" + site_root.mkdir() + distribution = _write_controller_distribution( + site_root, + name="static-hook", + version="1.0", + payloads={"static_hook.py": b"VALUE = 'safe'\n"}, + ) + metadata_root = site_root / "static_hook-1.0.dist-info" + with (metadata_root / "RECORD").open("a", encoding="utf-8") as handle: + handle.write(f"{member},,\n") + distribution = metadata.PathDistribution(metadata_root) + monkeypatch.setattr(verifier, "_site_roots", lambda: (site_root.resolve(),)) + + with pytest.raises(BundleVerificationError, match="startup hook"): + _repackage_distribution(distribution, tmp_path / "wheelhouse") + + assert not list((tmp_path / "wheelhouse").glob("*.whl")) + + +@pytest.mark.parametrize("member", ("evil.pth ", "sitecustomize.py.")) +def test_wheel_contract_rejects_windows_normalized_startup_hook( + tmp_path: Path, member: str +) -> None: + wheel = tmp_path / "hostile-1.0-py3-none-any.whl" + metadata_name = "hostile-1.0.dist-info" + with zipfile.ZipFile(wheel, "w") as archive: + archive.writestr( + f"{metadata_name}/METADATA", + "Metadata-Version: 2.1\nName: hostile\nVersion: 1.0\n", + ) + archive.writestr(member, "import os\n") + archive.writestr(f"{metadata_name}/RECORD", "") + + with pytest.raises(BundleVerificationError, match="startup hook"): + _record_contract(wheel, "hostile") + + +def test_wheel_contract_rejects_windows_device_member(tmp_path: Path) -> None: + wheel = tmp_path / "hostile-device-1.0-py3-none-any.whl" + metadata_name = "hostile_device-1.0.dist-info" + with zipfile.ZipFile(wheel, "w") as archive: + archive.writestr( + f"{metadata_name}/METADATA", + "Metadata-Version: 2.1\nName: hostile-device\nVersion: 1.0\n", + ) + archive.writestr("NUL.txt", "payload") + archive.writestr(f"{metadata_name}/RECORD", "") + + with pytest.raises(BundleVerificationError, match="unsafe archive member"): + _record_contract(wheel, "hostile-device") + + +def test_repackaging_strips_controller_metadata_and_keeps_record_evidence( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + site_root = tmp_path / "site-packages" + site_root.mkdir() + distribution = _write_controller_distribution( + site_root, + name="benign-controller-package", + version="1.0", + payloads={ + "benign_controller_package.py": b"VALUE = 'safe'\n", + "benign_controller_package-1.0.dist-info/direct_url.json": b'{"url":"file:///tmp"}', + "benign_controller_package-1.0.dist-info/INSTALLER": b"pip\n", + }, + ) + monkeypatch.setattr(verifier, "_site_roots", lambda: (site_root.resolve(),)) + + wheel, origin = _repackage_distribution(distribution, tmp_path / "wheelhouse") + + assert origin["origin"] == "controller_site_packages_repack" + assert origin["controller_record_status"] == "VALIDATED_CONTROLLER_RECORD" + with zipfile.ZipFile(wheel) as archive: + names = archive.namelist() + assert not any( + name.casefold().endswith(("direct_url.json", "installer", ".pth", ".egg-link")) + for name in names + ) + + +def test_editable_direct_url_is_rejected_even_without_a_pth( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + site_root = tmp_path / "site-packages" + site_root.mkdir() + distribution = _write_controller_distribution( + site_root, + name="editable-metadata-only", + version="1.0", + payloads={ + "editable_metadata_only.py": b"VALUE = 'not-used'\n", + "editable_metadata_only-1.0.dist-info/direct_url.json": ( + b'{"url":"file:///tmp/source","dir_info":{"editable":true}}' + ), + }, + ) + monkeypatch.setattr(verifier, "_site_roots", lambda: (site_root.resolve(),)) + + with pytest.raises(BundleVerificationError, match="refusing editable controller"): + _repackage_distribution(distribution, tmp_path / "wheelhouse") + + +def test_installed_record_allows_pip_wheel_url_and_console_script_only( + tmp_path: Path, +) -> None: + site_root = tmp_path / "venv" / "Lib" / "site-packages" + metadata_root = site_root / "fixture-1.0.dist-info" + metadata_root.mkdir(parents=True) + (metadata_root / "METADATA").write_text( + "Metadata-Version: 2.1\nName: fixture\nVersion: 1.0\n", + encoding="utf-8", + ) + (metadata_root / "direct_url.json").write_text( + '{"url":"file:///tmp/fixture-1.0.whl"}', encoding="utf-8" + ) + (metadata_root / "RECORD").write_text( + "fixture-1.0.dist-info/METADATA,,\n" + "fixture-1.0.dist-info/direct_url.json,,\n" + "fixture-1.0.dist-info/RECORD,,\n" + "../../Scripts/fixture.exe,,\n", + encoding="utf-8", + ) + + rows = _installed_record_rows(metadata_root, roots=(site_root,)) + + assert "fixture-1.0.dist-info/direct_url.json" in rows + assert "../../Scripts/fixture.exe" not in rows + with (metadata_root / "RECORD").open("a", encoding="utf-8") as handle: + handle.write("../../Scripts/fixture.pth , ,\n") + with pytest.raises(BundleVerificationError, match="startup hook"): + _installed_record_rows(metadata_root, roots=(site_root,)) + + +def test_minimal_environment_redacts_controller_sentinel( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setenv(_REDACTION_SENTINEL_ENV, "secret") + monkeypatch.setenv("PYTHONPATH", r"C:\ambient-source") + + environment = _offline_environment() + + assert _REDACTION_SENTINEL_ENV not in environment + assert "PYTHONPATH" not in environment + assert environment["PIP_NO_INDEX"] == "1" + if os.name == "nt": + platform_probe = _run_logged( + [ + str(Path(environment["SYSTEMROOT"]) / "System32" / "cmd.exe"), + "/c", + "ver", + ], + cwd=tmp_path, + environment=environment, + logs_dir=tmp_path / "logs", + name="windows-platform-probe", + ) + assert platform_probe["exit_code"] == 0 + + +def test_probe_parser_and_log_capture_fail_closed_or_decode_robustly( + tmp_path: Path, +) -> None: + with pytest.raises(BundleVerificationError, match="did not emit JSON"): + _parse_consumer_probe_payload("not json") + with pytest.raises(BundleVerificationError, match="must be an object"): + _parse_consumer_probe_payload("[]") + + run = _run_logged( + [sys.executable, "-c", "import sys; sys.stdout.buffer.write(b'\\xff')"], + cwd=tmp_path, + environment=_offline_environment(), + logs_dir=tmp_path / "logs", + name="non-utf8-output", + ) + assert run["exit_code"] == 0 + assert "\ufffd" in run["stdout"] + + +def test_logs_and_failure_receipts_redact_secret_values(tmp_path: Path) -> None: + secret = "top-secret-value" + run = _run_logged( + [ + sys.executable, + "-c", + f"print('api_key={secret}'); print('Authorization: Bearer {secret}')", + ], + cwd=tmp_path, + environment=_offline_environment(), + logs_dir=tmp_path / "logs", + name="secret-output", + ) + assert secret not in run["stdout"] + assert "***REDACTED***" in run["stdout"] + assert secret not in (tmp_path / "logs" / "secret-output.stdout.log").read_text( + encoding="utf-8" + ) + + artifacts_dir = tmp_path / "failure-receipt" + exit_code = main( + [ + "--artifacts-dir", + str(artifacts_dir), + "--backtrader-root", + str(tmp_path / f"api_key={secret}"), + "--sdk-root", + str(SDK_ROOT), + ] + ) + receipt_text = (artifacts_dir / "receipt.json").read_text(encoding="utf-8") + assert exit_code == 1 + assert secret not in receipt_text + assert "***REDACTED***" in receipt_text + + +def test_consumer_payload_validation_rejects_empty_and_forged_results( + tmp_path: Path, +) -> None: + site_root = tmp_path / "venv" / "Lib" / "site-packages" + site_root.mkdir(parents=True) + module = site_root / "fixture.py" + module.write_text("VALUE = 'installed'\n", encoding="utf-8") + metadata_root = site_root / "fixture-1.0.dist-info" + metadata_root.mkdir() + (metadata_root / "METADATA").write_text( + "Metadata-Version: 2.1\nName: fixture\nVersion: 1.0\n", + encoding="utf-8", + ) + expected_projects = { + "fixture": { + "distribution": "fixture", + "version": "1.0", + "wheel_sha256": "a" * 64, + } + } + expected_modules = {"fixture": ("fixture", "fixture.py")} + bindings = { + "fixture": { + "metadata_path": str(metadata_root), + "payload_paths": {"fixture.py": str(module)}, + } + } + payload = { + "module_paths": {"fixture": str(module)}, + "package_versions": {"fixture": "1.0"}, + "installed_local_projects": { + "fixture": { + "distribution": "fixture", + "version": "1.0", + "wheel_sha256": "a" * 64, + "metadata_path": str(metadata_root), + } + }, + "fake_provider_calls": 1, + "execution_state": "ACKED", + "local_backtest_report": { + "status": verifier._LOCAL_BACKTEST_EXPECTED_STATUS, + "external_network_requests": 0, + "external_write_requests": 0, + "actual_fills": 0, + "provider_submissions": 0, + "actual_pnl": "NOT_APPLICABLE", + "pnl_source": "local_backtest_no_orders", + "data_bars": 4, + "network_guard_attempts": [], + "runtime_config": { + "strategy_id": verifier._LOCAL_BACKTEST_EXPECTED_RUNTIME_ID, + "mode": "backtest", + "preset": "local_backtest", + "environment": "local", + "allows_network": False, + "allows_external_writes": False, + "allows_production_writes": False, + }, + }, + "l2_fixture_reports": { + runtime_id: { + "status": status, + "external_network_requests": 0, + "external_write_requests": 0, + "actual_fills": 0, + "provider_submissions": 1, + } + for runtime_id, status in verifier._L2_FIXTURE_EXPECTED_STATUSES.items() + }, + "network_guard": _PYTHON_SOCKET_GUARD_DESCRIPTION, + "network_guard_attempts": [], + "controller_environment_sentinel_absent": True, + "provider_evidence": "fixture_only", + } + + with pytest.raises(BundleVerificationError, match="unexpected result schema"): + _validate_consumer_probe_payload( + {}, + expected_projects=expected_projects, + expected_modules=expected_modules, + installation_bindings=bindings, + site_roots=(site_root,), + ) + forged = json.loads(json.dumps(payload)) + forged["installed_local_projects"]["fixture"]["wheel_sha256"] = "b" * 64 + with pytest.raises(BundleVerificationError, match="wheel binding mismatch"): + _validate_consumer_probe_payload( + forged, + expected_projects=expected_projects, + expected_modules=expected_modules, + installation_bindings=bindings, + site_roots=(site_root,), + ) + forged_module = json.loads(json.dumps(payload)) + forged_module["module_paths"]["fixture"] = str(metadata_root / "METADATA") + with pytest.raises(BundleVerificationError, match="module binding mismatch"): + _validate_consumer_probe_payload( + forged_module, + expected_projects=expected_projects, + expected_modules=expected_modules, + installation_bindings=bindings, + site_roots=(site_root,), + ) + assert ( + _validate_consumer_probe_payload( + payload, + expected_projects=expected_projects, + expected_modules=expected_modules, + installation_bindings=bindings, + site_roots=(site_root,), + )["execution_state"] + == "ACKED" + ) + + +def test_missing_backtrader_source_writes_a_non_pass_receipt(tmp_path: Path) -> None: + artifacts_dir = tmp_path / "missing-source-receipt" + + exit_code = main( + [ + "--artifacts-dir", + str(artifacts_dir), + "--backtrader-root", + str(tmp_path / "not-present"), + "--sdk-root", + str(SDK_ROOT), + ] + ) + + receipt = json.loads((artifacts_dir / "receipt.json").read_text(encoding="utf-8")) + assert exit_code == 1 + assert receipt["result"] == FAILED_RESULT + assert receipt["local_validation"] == "FAILED" + assert receipt["release_status"] == "NOT_RELEASE_ELIGIBLE" + + +def test_nonempty_artifacts_are_preserved_and_failure_receipt_is_sidecar( + tmp_path: Path, +) -> None: + artifacts_dir = tmp_path / "existing-artifacts" + artifacts_dir.mkdir() + preserved = artifacts_dir / "preserved-evidence.txt" + preserved.write_text("do-not-overwrite", encoding="utf-8") + original_receipt = artifacts_dir / "receipt.json" + original_receipt.write_text("old receipt", encoding="utf-8") + + exit_codes = [ + main( + [ + "--artifacts-dir", + str(artifacts_dir), + "--backtrader-root", + str(tmp_path / "not-present"), + "--sdk-root", + str(SDK_ROOT), + ] + ), + main( + [ + "--artifacts-dir", + str(artifacts_dir), + "--backtrader-root", + str(tmp_path / "not-present"), + "--sdk-root", + str(SDK_ROOT), + ] + ), + ] + + sidecars = sorted(tmp_path.glob("existing-artifacts.failure-receipt*.json")) + receipts = [json.loads(path.read_text(encoding="utf-8")) for path in sidecars] + assert exit_codes == [1, 1] + assert preserved.read_text(encoding="utf-8") == "do-not-overwrite" + assert original_receipt.read_text(encoding="utf-8") == "old receipt" + assert len(sidecars) == 2 + assert all(receipt["result"] == FAILED_RESULT for receipt in receipts) + + +def test_atomic_receipt_writer_does_not_clobber_existing_file(tmp_path: Path) -> None: + receipt_path = tmp_path / "receipt.json" + _write_receipt_atomically(receipt_path, {"result": "first"}) + + with pytest.raises(FileExistsError): + _write_receipt_atomically(receipt_path, {"result": "second"}) + + assert json.loads(receipt_path.read_text(encoding="utf-8")) == {"result": "first"}