diff --git a/backtrader_runtime/_local_fake_account_actor_candidate/LOCAL_FAKE_ONLY.md b/backtrader_runtime/_local_fake_account_actor_candidate/LOCAL_FAKE_ONLY.md new file mode 100644 index 00000000..cdf52717 --- /dev/null +++ b/backtrader_runtime/_local_fake_account_actor_candidate/LOCAL_FAKE_ONLY.md @@ -0,0 +1,21 @@ +# Local fake-only account actor protocol candidate + +This package is an isolated integration candidate. The service child owns a +SQLite ledger and in-memory fake sink; the caller sends typed intents over +local stdio JSON-lines and receives an unauthenticated local receipt. The +runtime registry, CLI, default runtime imports, and `BtApiStore` do not import +or construct this candidate. + +Response lines and aggregate queued output are bounded. Outgoing requests are +capped at 64 KiB and use a write deadline. If shutdown requires forced child +termination, the client closes its streams and reports +`actor_service_cleanup_uncertain`: no OS Job Object or process-group supervisor +verifies descendant cleanup. + +The child and caller share one OS principal; the SQLite file is locally +writable. Snapshot proof, account/session values, epochs, and fake sends are +synthetic. This package contains no provider, credential, network, native SDK, +external actor identity, cross-host writer fence, or real account snapshot. +`StoreBoundaryHarness` is a toy ordering model; it does not import or modify +`BtApiStore`. The candidate is not registered, not a Store route, and not write +authority. G6-P/F14 remain unresolved; `NO_WRITE / LIVE_NO_GO` is unchanged. diff --git a/backtrader_runtime/_local_fake_account_actor_candidate/__init__.py b/backtrader_runtime/_local_fake_account_actor_candidate/__init__.py new file mode 100644 index 00000000..358224c0 --- /dev/null +++ b/backtrader_runtime/_local_fake_account_actor_candidate/__init__.py @@ -0,0 +1,5 @@ +"""Unregistered local fake-only account-actor protocol candidate. + +Nothing is imported here. The runtime registry, CLI, and Store do not import +this package, and importing :mod:`backtrader_runtime` never loads it. +""" diff --git a/backtrader_runtime/_local_fake_account_actor_candidate/account_actor_port.py b/backtrader_runtime/_local_fake_account_actor_candidate/account_actor_port.py new file mode 100644 index 00000000..53ae9ead --- /dev/null +++ b/backtrader_runtime/_local_fake_account_actor_candidate/account_actor_port.py @@ -0,0 +1,682 @@ +"""Offline-only typed account actor port and preconstruction CTP route gate. + +This module defines a capability boundary for a future external actor client. +It contains no transport, credential resolver, SDK import, native API, or actor +implementation. A typed fake port proves wiring only; it is not authority. +""" +from __future__ import annotations + +import hashlib +import json +import threading +from abc import ABC, abstractmethod +from collections.abc import Mapping +from dataclasses import dataclass, field +from decimal import Decimal +from enum import Enum +from typing import Any, Optional + + +class AccountActorGateError(RuntimeError): + """Fail-closed error before any local CTP client is connected or created.""" + + def __init__(self, code: str) -> None: + self.code = code + super().__init__(code.replace("_", " ")) + + +class RouteKind(str, Enum): + CTP = "ctp" + NON_CTP = "non_ctp" + AMBIGUOUS = "ambiguous" + UNSUPPORTED = "unsupported" + + +class ActorCommandState(str, Enum): + QUEUED = "queued" + UNKNOWN = "unknown" + REJECTED = "rejected" + + +@dataclass(frozen=True) +class ActorCommandContextV1: + """Caller-supplied local binding context; not authenticated actor authority.""" + + account_ref: str + runtime_id: str + mode: str + config_digest: str + session_id: str + front_id: int + native_session_id: int + session_generation: int + actor_epoch: int + + def __post_init__(self) -> None: + for name in ("account_ref", "runtime_id", "session_id"): + _required_text(getattr(self, name), name) + if type(self.mode) is not str or self.mode not in {"simulation", "live"}: + raise ValueError("mode must be simulation or live") + if type(self.config_digest) is not str or len(self.config_digest) != 64: + raise ValueError("config_digest must be a lowercase SHA-256 digest") + if any(char not in "0123456789abcdef" for char in self.config_digest): + raise ValueError("config_digest must be a lowercase SHA-256 digest") + if type(self.session_generation) is not int or self.session_generation <= 0: + raise ValueError("session_generation must be a positive exact integer") + if type(self.front_id) is not int or self.front_id <= 0: + raise ValueError("front_id must be a positive exact integer") + if type(self.native_session_id) is not int or self.native_session_id <= 0: + raise ValueError("native_session_id must be a positive exact integer") + if type(self.actor_epoch) is not int or self.actor_epoch <= 0: + raise ValueError("actor_epoch must be a positive exact integer") + + def to_payload(self) -> dict[str, Any]: + return { + "account_ref": self.account_ref, + "runtime_id": self.runtime_id, + "mode": self.mode, + "config_digest": self.config_digest, + "session_id": self.session_id, + "front_id": self.front_id, + "native_session_id": self.native_session_id, + "session_generation": self.session_generation, + "actor_epoch": self.actor_epoch, + } + + +@dataclass(frozen=True) +class CtpSubmitIntentV2: + """Context-bound logical order intent; no native identity is caller input.""" + + intent_id: str + instrument_id: str + exchange_id: str + side: str + offset: str + hedge_flag: str + quantity: int + limit_price: Decimal + context: ActorCommandContextV1 + + def __post_init__(self) -> None: + _required_text(self.intent_id, "intent_id") + _required_text(self.instrument_id, "instrument_id") + _required_text(self.exchange_id, "exchange_id") + _required_text(self.side, "side") + _required_text(self.offset, "offset") + _required_text(self.hedge_flag, "hedge_flag") + if self.side not in {"BUY", "SELL"}: + raise ValueError("side must be BUY or SELL") + if self.offset not in {"OPEN", "CLOSE", "CLOSE_TODAY", "CLOSE_YESTERDAY"}: + raise ValueError("unsupported offset") + if self.hedge_flag not in {"SPECULATION", "ARBITRAGE", "HEDGE", "MARKET_MAKER"}: + raise ValueError("unsupported hedge_flag") + if type(self.quantity) is not int or self.quantity <= 0: + raise ValueError("quantity must be a positive exact integer") + if type(self.limit_price) is not 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 type(self.context) is not ActorCommandContextV1: + raise ValueError("context must be ActorCommandContextV1") + + @property + def command_id(self) -> str: + return self.intent_id + + @property + def operation(self) -> str: + return "SUBMIT" + + @property + def command_digest(self) -> str: + return _command_digest( + self.operation, + self.command_id, + self.context, + { + "instrument_id": self.instrument_id, + "exchange_id": self.exchange_id, + "side": self.side, + "offset": self.offset, + "hedge_flag": self.hedge_flag, + "quantity": self.quantity, + "limit_price": format(self.limit_price, "f"), + }, + ) + + +@dataclass(frozen=True) +class CtpCancelIntentV2: + """Context-bound logical cancel intent; native refs are never caller fields.""" + + cancel_intent_id: str + runtime_order_id: str + order_ref: str + front_id: int + session_id: int + exchange_id: str + order_sys_id: str + context: ActorCommandContextV1 + + def __post_init__(self) -> None: + for name in ( + "cancel_intent_id", + "runtime_order_id", + "order_ref", + "exchange_id", + "order_sys_id", + ): + _required_text(getattr(self, name), name) + if type(self.front_id) is not int or self.front_id <= 0: + raise ValueError("front_id must be a positive exact integer") + if type(self.session_id) is not int or self.session_id <= 0: + raise ValueError("session_id must be a positive exact integer") + if type(self.context) is not ActorCommandContextV1: + raise ValueError("context must be ActorCommandContextV1") + if self.front_id != self.context.front_id or self.session_id != self.context.native_session_id: + raise ValueError("cancel target must match the expected native front/session") + + @property + def command_id(self) -> str: + return self.cancel_intent_id + + @property + def operation(self) -> str: + return "CANCEL" + + @property + def command_digest(self) -> str: + return _command_digest( + self.operation, + self.command_id, + self.context, + { + "runtime_order_id": self.runtime_order_id, + "order_ref": self.order_ref, + "front_id": self.front_id, + "session_id": self.session_id, + "exchange_id": self.exchange_id, + "order_sys_id": self.order_sys_id, + }, + ) + + +@dataclass(frozen=True) +class ActorCommandReceiptV2: + """Locally comparable actor receipt; never a provider acknowledgement.""" + + operation: str + command_id: str + state: ActorCommandState + context: ActorCommandContextV1 + command_digest: str + + def __post_init__(self) -> None: + if type(self.operation) is not str or self.operation not in {"SUBMIT", "CANCEL"}: + raise ValueError("operation must be SUBMIT or CANCEL") + _required_text(self.command_id, "command_id") + if type(self.state) is not ActorCommandState: + raise ValueError("state must be ActorCommandState") + if type(self.context) is not ActorCommandContextV1: + raise ValueError("context must be ActorCommandContextV1") + if type(self.command_digest) is not str or len(self.command_digest) != 64: + raise ValueError("command_digest must be a lowercase SHA-256 digest") + if any(char not in "0123456789abcdef" for char in self.command_digest): + raise ValueError("command_digest must be a lowercase SHA-256 digest") + + +@dataclass(frozen=True) +class ActorCommandExpectationV2: + """Exact locally expected receipt binding derived from one typed intent.""" + + operation: str + command_id: str + context: ActorCommandContextV1 + command_digest: str + + @classmethod + def from_intent(cls, intent: Any) -> ActorCommandExpectationV2: + if type(intent) not in {CtpSubmitIntentV2, CtpCancelIntentV2}: + raise AccountActorGateError("typed_actor_intent_required") + try: + intent.context.__post_init__() + intent.__post_init__() + except (AttributeError, TypeError, ValueError): + raise AccountActorGateError("actor_intent_invalid") from None + return cls( + operation=intent.operation, + command_id=intent.command_id, + context=intent.context, + command_digest=intent.command_digest, + ) + + def __post_init__(self) -> None: + if type(self.operation) is not str or self.operation not in {"SUBMIT", "CANCEL"}: + raise ValueError("operation must be SUBMIT or CANCEL") + _required_text(self.command_id, "command_id") + if type(self.context) is not ActorCommandContextV1: + raise ValueError("context must be ActorCommandContextV1") + if type(self.command_digest) is not str or len(self.command_digest) != 64: + raise ValueError("command_digest must be a lowercase SHA-256 digest") + if any(char not in "0123456789abcdef" for char in self.command_digest): + raise ValueError("command_digest must be a lowercase SHA-256 digest") + + +class FakeLocalActorReplayLedger: + """In-memory replay guard for offline tests only; not durable or authoritative.""" + + def __init__(self) -> None: + self._seen: dict[str, tuple[str, ActorCommandContextV1]] = {} + self._lock = threading.Lock() + + def claim_once(self, intent: Any) -> None: + if type(intent) not in {CtpSubmitIntentV2, CtpCancelIntentV2}: + raise AccountActorGateError("typed_actor_intent_required") + try: + intent.context.__post_init__() + intent.__post_init__() + except (AttributeError, TypeError, ValueError): + raise AccountActorGateError("actor_intent_invalid") from None + with self._lock: + if intent.command_id in self._seen: + raise AccountActorGateError("actor_intent_replay") + self._seen[intent.command_id] = (intent.command_digest, intent.context) + + +class CtpAccountActorPort(ABC): + """Typed remote-owner client seam; methods never accept local native callbacks.""" + + @abstractmethod + def submit_order(self, intent: CtpSubmitIntentV2) -> ActorCommandReceiptV2: + """Submit one logical intent to the external account actor.""" + + @abstractmethod + def cancel_order(self, intent: CtpCancelIntentV2) -> ActorCommandReceiptV2: + """Submit one logical cancel intent to the external account actor.""" + + +class UnavailableCtpAccountActorPort(CtpAccountActorPort): + """Default port. No external account actor is installed in this candidate.""" + + def submit_order(self, intent: CtpSubmitIntentV2) -> ActorCommandReceiptV2: + del intent + raise AccountActorGateError("external_account_actor_unavailable") + + def cancel_order(self, intent: CtpCancelIntentV2) -> ActorCommandReceiptV2: + del intent + raise AccountActorGateError("external_account_actor_unavailable") + + +@dataclass(frozen=True) +class StoreRouteDescriptor: + """Only non-secret routing facts used before Store SDK/client construction.""" + + provider: str + backend: Optional[str] = None # noqa: UP045 - retain Python 3.8 compatibility + config: Mapping[str, Any] = field(default_factory=dict) + api_kwargs: Mapping[str, Any] = field(default_factory=dict) + api: Any = None + api_cls: Any = None + environment_provider: Optional[str] = None # noqa: UP045 - Python 3.8 compatibility + environment_exchange_type: Optional[str] = None # noqa: UP045 - Python 3.8 compatibility + + +# This is deliberately a small, code-owned registry. It is not inferred from +# arbitrary BtApiStore provider strings or a caller-supplied client object. +_DIRECT_NON_CTP_PROVIDERS = frozenset({"okx", "binance"}) +_GATEWAY_PROVIDER_EXCHANGE = { + "ib_web_gateway": "IB_WEB", + "mt5_gateway": "MT5", +} +_GENERIC_GATEWAY_PROVIDERS = frozenset({"gateway"}) +_CTP_PROVIDERS = frozenset({"ctp", "ctp_gateway"}) +_UNSUPPORTED_PROVIDERS = frozenset({"futu", "oanda", "vc"}) +_SUPPORTED_NON_CTP_VENUES = frozenset({"OKX", "BINANCE", "MT5", "IB_WEB"}) +_ALL_KNOWN_VENUES = _SUPPORTED_NON_CTP_VENUES | frozenset({"CTP"}) + + +def classify_store_route(route: StoreRouteDescriptor) -> RouteKind: + """Classify only exact, code-owned selectors; never inspect injected clients. + + This is an offline candidate contract, not a complete BtApiStore provider + registry. Unknown/custom providers, opaque clients/classes and malformed + routes are ambiguous. Explicit CTP evidence wins over every non-CTP label, + including environment overrides and nested symbol routing. + """ + + if type(route) is not StoreRouteDescriptor: + return RouteKind.AMBIGUOUS + + provider = _selector(route.provider) + backend = _selector(route.backend) + env_provider = _selector(route.environment_provider) + + if ( + (route.backend is not None and type(route.backend) is not str) + or (route.environment_provider is not None and type(route.environment_provider) is not str) + ): + return RouteKind.AMBIGUOUS + + # Check direct CTP selectors before considering any environment or route + # aliases. A gateway environment override cannot downgrade a CTP request. + if provider in _CTP_PROVIDERS or env_provider in _CTP_PROVIDERS: + return RouteKind.CTP + + config = _exact_dict(route.config) + api_kwargs = _exact_dict(route.api_kwargs) + if config is None or api_kwargs is None: + return RouteKind.AMBIGUOUS + + venues: set[str] = set() + unknown = False + has_selector = False + for values in (config, api_kwargs): + found, invalid, present = _collect_config_routes(values) + venues.update(found) + unknown = unknown or invalid + has_selector = has_selector or present + + env_exchange = _venue(route.environment_exchange_type) + if route.environment_exchange_type is not None: + if not env_exchange: + unknown = True + else: + venues.add(env_exchange) + has_selector = True + + # Route maps can reveal CTP even if the top-level selector appears safe. + if "CTP" in venues: + return RouteKind.CTP + + # Do not touch attributes on route.api or route.api_cls. Even a property + # read can execute caller code before the missing-actor gate. + if route.api is not None or route.api_cls is not None: + return RouteKind.AMBIGUOUS + + if unknown or not venues.issubset(_ALL_KNOWN_VENUES): + return RouteKind.AMBIGUOUS + if any(venue not in _SUPPORTED_NON_CTP_VENUES for venue in venues): + return RouteKind.AMBIGUOUS + + # An environment-selected provider that changes a non-CTP selector is an + # ambiguous route, not authority to silently choose another local client. + if env_provider and env_provider != provider: + return RouteKind.AMBIGUOUS + + if provider in _UNSUPPORTED_PROVIDERS: + return RouteKind.UNSUPPORTED + + if provider in _DIRECT_NON_CTP_PROVIDERS: + expected = provider.upper() + if backend not in {"", "direct"}: + return RouteKind.AMBIGUOUS + if venues and venues != {expected}: + return RouteKind.AMBIGUOUS + return RouteKind.NON_CTP + + if provider == "btapi": + if backend not in {"", "direct"}: + return RouteKind.AMBIGUOUS + # Generic multi-venue routing is safe only with a complete explicit + # route map. No raw API/class injection is accepted above. + return RouteKind.NON_CTP if has_selector and venues else RouteKind.AMBIGUOUS + + if provider in _GENERIC_GATEWAY_PROVIDERS: + if backend not in {"", "gateway"}: + return RouteKind.AMBIGUOUS + if not has_selector: + # BtApiStore's generic gateway wrapper defaults to CTP. + return RouteKind.CTP + return RouteKind.NON_CTP if venues and venues <= {"IB_WEB", "MT5"} else RouteKind.AMBIGUOUS + + expected_gateway = _GATEWAY_PROVIDER_EXCHANGE.get(provider) + if expected_gateway: + if backend not in {"", "gateway"}: + return RouteKind.AMBIGUOUS + # The alias itself is not sufficient: the configured route must match + # it. With no explicit selector, the native gateway defaults to CTP. + if not has_selector: + return RouteKind.CTP + return RouteKind.NON_CTP if venues == {expected_gateway} else RouteKind.AMBIGUOUS + + # Do not infer safety from arbitrary names, aliases, or a `_gateway` suffix. + return RouteKind.AMBIGUOUS + + +def require_account_actor_before_local_client( + route: StoreRouteDescriptor, + actor_port: Optional[CtpAccountActorPort], # noqa: UP045 - Python 3.8 compatibility +) -> CtpAccountActorPort: + """Reject CTP/ambiguous routes before any local API, SDK or gateway creation.""" + + kind = classify_store_route(route) + if kind is RouteKind.NON_CTP: + return actor_port if actor_port is not None else UnavailableCtpAccountActorPort() + + if kind is RouteKind.UNSUPPORTED: + raise AccountActorGateError("store_provider_unsupported") + if kind is RouteKind.AMBIGUOUS: + raise AccountActorGateError("store_route_ambiguous") + + if actor_port is None or isinstance(actor_port, UnavailableCtpAccountActorPort): + raise AccountActorGateError("external_account_actor_unavailable") + if not isinstance(actor_port, CtpAccountActorPort): + raise AccountActorGateError("typed_account_actor_port_required") + if route.api is not None or route.api_cls is not None: + raise AccountActorGateError("local_ctp_client_injection_forbidden") + return actor_port + + +def validate_actor_receipt( + receipt: object, *, expected: ActorCommandExpectationV2 +) -> ActorCommandReceiptV2: + """Compare every local binding field; this still proves no external authority.""" + + if type(expected) is not ActorCommandExpectationV2: + raise AccountActorGateError("actor_expectation_type_invalid") + if type(receipt) is not ActorCommandReceiptV2: + raise AccountActorGateError("actor_receipt_type_invalid") + try: + receipt.context.__post_init__() + receipt.__post_init__() + expected.context.__post_init__() + expected.__post_init__() + except (AttributeError, TypeError, ValueError): + raise AccountActorGateError("actor_receipt_binding_invalid") from None + if receipt.operation != expected.operation: + raise AccountActorGateError("actor_receipt_operation_mismatch") + if receipt.command_id != expected.command_id: + raise AccountActorGateError("actor_receipt_command_mismatch") + if type(receipt.context) is not ActorCommandContextV1: + raise AccountActorGateError("actor_receipt_context_invalid") + if receipt.context != expected.context: + raise AccountActorGateError("actor_receipt_context_mismatch") + if receipt.command_digest != expected.command_digest: + raise AccountActorGateError("actor_receipt_digest_mismatch") + return receipt + + +def reject_ctp_legacy_dispatch(route_kind: RouteKind) -> None: + """Defense in depth for old private Store dispatchers.""" + + if route_kind is not RouteKind.NON_CTP: + raise AccountActorGateError("ctp_legacy_dispatch_forbidden") + + +def _collect_config_routes(values: dict[str, Any]) -> tuple[set[str], bool, bool]: + venues: set[str] = set() + unknown = False + present = False + for key in ("exchange_type", "exchange"): + if key in values: + present = True + venue = _venue(values[key]) + if venue: + venues.add(venue) + else: + unknown = True + + if "exchange_kwargs" in values: + present = True + raw = _exact_dict(values["exchange_kwargs"]) + if raw is None or not raw: + unknown = True + else: + for key, options in raw.items(): + venue = _venue(key) + if not venue or type(options) is not dict or venue not in _ALL_KNOWN_VENUES: + unknown = True + else: + venues.add(venue) + nested, invalid = _collect_nested_selector_fields(options) + venues.update(nested) + unknown = unknown or invalid + + if "symbol_routes" in values: + present = True + found, invalid = _collect_symbol_routes(values["symbol_routes"]) + venues.update(found) + unknown = unknown or invalid + return venues, unknown, present + + +def _collect_symbol_routes(value: Any) -> tuple[set[str], bool]: + if type(value) is not dict or not value: + return set(), True + venues: set[str] = set() + unknown = False + for route_value in value.values(): + if type(route_value) is str: + venue = _venue(route_value) + if venue: + venues.add(venue) + else: + unknown = True + elif type(route_value) is dict: + found, invalid, present = _collect_nested_route(route_value) + venues.update(found) + unknown = unknown or invalid or not present + else: + unknown = True + return venues, unknown + + +def _collect_nested_route(value: dict[str, Any]) -> tuple[set[str], bool, bool]: + venues: set[str] = set() + unknown = False + present = False + selector_keys = {"exchange_type", "exchange", "provider", "route"} + for key, nested in value.items(): + if key in selector_keys: + present = True + venue = _venue(nested) + if venue: + venues.add(venue) + else: + unknown = True + elif type(nested) is dict: + found, invalid, child_present = _collect_nested_route(nested) + venues.update(found) + unknown = unknown or invalid + present = present or child_present + elif type(nested) is str: + # Nested maps commonly use a symbol/route key and a venue value. + present = True + venue = _venue(nested) + if venue: + venues.add(venue) + else: + unknown = True + else: + unknown = True + return venues, unknown, present + + +def _collect_nested_selector_fields(value: dict[str, Any]) -> tuple[set[str], bool]: + """Find only route selector fields below venue options, ignoring secrets.""" + + venues: set[str] = set() + unknown = False + selector_keys = {"exchange_type", "exchange", "provider", "route"} + for key, nested in value.items(): + if key in selector_keys: + venue = _venue(nested) + if venue: + venues.add(venue) + else: + unknown = True + elif key == "symbol_routes": + found, invalid = _collect_symbol_routes(nested) + venues.update(found) + unknown = unknown or invalid + elif key == "exchange_kwargs": + found, invalid, _present = _collect_config_routes({"exchange_kwargs": nested}) + venues.update(found) + unknown = unknown or invalid + elif type(nested) is dict: + found, invalid = _collect_nested_selector_fields(nested) + venues.update(found) + unknown = unknown or invalid + return venues, unknown + + +def _venue(value: Any) -> str: + if type(value) is not str: + return "" + candidate = value.strip().partition("___")[0].upper() + return candidate if candidate in _ALL_KNOWN_VENUES else "" + + +def _selector(value: Any) -> str: + return value.strip().lower() if type(value) is str else "" + + +def _exact_dict(value: Any) -> Optional[dict[str, Any]]: # noqa: UP045 - Python 3.8 compatibility + return value if type(value) is dict else None + + +def _required_text(value: Any, name: str) -> None: + if type(value) is not str or not value.strip(): + raise ValueError(f"{name} is required") + + +def _command_digest( + operation: str, + command_id: str, + context: ActorCommandContextV1, + intent_payload: dict[str, Any], +) -> str: + canonical = json.dumps( + { + "schema": "ctp-account-actor-command.v2", + "operation": operation, + "command_id": command_id, + "context": context.to_payload(), + "intent": intent_payload, + }, + sort_keys=True, + separators=(",", ":"), + ensure_ascii=True, + ).encode("ascii") + return hashlib.sha256(canonical).hexdigest() + + +__all__ = [ + "AccountActorGateError", + "ActorCommandContextV1", + "ActorCommandExpectationV2", + "ActorCommandReceiptV2", + "ActorCommandState", + "CtpAccountActorPort", + "CtpCancelIntentV2", + "CtpSubmitIntentV2", + "FakeLocalActorReplayLedger", + "RouteKind", + "StoreRouteDescriptor", + "UnavailableCtpAccountActorPort", + "classify_store_route", + "reject_ctp_legacy_dispatch", + "require_account_actor_before_local_client", + "validate_actor_receipt", +] diff --git a/backtrader_runtime/_local_fake_account_actor_candidate/account_actor_server_core.py b/backtrader_runtime/_local_fake_account_actor_candidate/account_actor_server_core.py new file mode 100644 index 00000000..232b7316 --- /dev/null +++ b/backtrader_runtime/_local_fake_account_actor_candidate/account_actor_server_core.py @@ -0,0 +1,1545 @@ +"""Fake-only durable core for an external CTP account-actor contract. + +The module records writer epochs, verified common snapshots, idempotent logical +intents, and transactional dispatch authorizations in SQLite. It performs no +provider or SDK calls. A future service must replace the fake snapshot verifier +with its authenticated source boundary and separately implement dispatch. +""" + +from __future__ import annotations + +import hashlib +import hmac +import json +import os +import re +import secrets +import sqlite3 +import threading +from contextlib import closing +from dataclasses import dataclass, field +from types import TracebackType +from typing import Any, Optional + +if __package__: + from .account_actor_port import ActorCommandContextV1 +else: # Imported by the fake service when it is run directly as a script. + from account_actor_port import ActorCommandContextV1 + +_ACCOUNT_REF = re.compile(r"^ctp-account-ref\.v1:[0-9a-f]{64}$") +_DOMAINS = frozenset({"funds", "orders", "trades", "positions"}) +_OPERATIONS = frozenset({"SUBMIT", "CANCEL"}) +_SCHEMA_VERSION = 2 +_SCHEMA_COLUMNS = { + "actor_account_writers": ( + "account_ref", + "epoch", + "owner_id", + "token_sha256", + "state", + "context_json", + ), + "actor_snapshots": ( + "account_ref", + "snapshot_version", + "source_id", + "authority_id", + "snapshot_digest", + "proof_hex", + ), + "actor_snapshot_domains": ( + "account_ref", + "snapshot_version", + "domain", + "source_id", + "payload_json", + ), + "actor_current_snapshots": ("account_ref", "snapshot_version"), + "actor_commands": ( + "account_ref", + "operation", + "intent_id", + "command_digest", + "context_json", + "payload_json", + "expected_snapshot_version", + "writer_epoch", + "state", + ), + "actor_dispatch_outbox": ( + "dispatch_id", + "account_ref", + "operation", + "intent_id", + "command_digest", + "writer_epoch", + "snapshot_version", + ), +} +_SCHEMA_V1_COLUMNS = dict(_SCHEMA_COLUMNS) +_SCHEMA_DDL = { + "actor_account_writers": ( + "CREATE TABLE actor_account_writers(" + "account_ref TEXT PRIMARY KEY, epoch INTEGER NOT NULL CHECK(epoch > 0)," + "owner_id TEXT NOT NULL, token_sha256 TEXT NOT NULL," + "state TEXT NOT NULL CHECK(state IN ('ACTIVE','REVOKED')), context_json TEXT)" + ), + "actor_snapshots": ( + "CREATE TABLE actor_snapshots(" + "account_ref TEXT NOT NULL, snapshot_version INTEGER NOT NULL CHECK(snapshot_version > 0)," + "source_id TEXT NOT NULL, authority_id TEXT NOT NULL, snapshot_digest TEXT NOT NULL," + "proof_hex TEXT NOT NULL, PRIMARY KEY(account_ref,snapshot_version))" + ), + "actor_snapshot_domains": ( + "CREATE TABLE actor_snapshot_domains(" + "account_ref TEXT NOT NULL, snapshot_version INTEGER NOT NULL, domain TEXT NOT NULL," + "source_id TEXT NOT NULL, payload_json TEXT NOT NULL," + "PRIMARY KEY(account_ref,snapshot_version,domain)," + "FOREIGN KEY(account_ref,snapshot_version)" + " REFERENCES actor_snapshots(account_ref,snapshot_version))" + ), + "actor_current_snapshots": ( + "CREATE TABLE actor_current_snapshots(" + "account_ref TEXT PRIMARY KEY, snapshot_version INTEGER NOT NULL," + "FOREIGN KEY(account_ref,snapshot_version)" + " REFERENCES actor_snapshots(account_ref,snapshot_version))" + ), + "actor_commands": ( + "CREATE TABLE actor_commands(" + "account_ref TEXT NOT NULL, operation TEXT NOT NULL CHECK(operation IN ('SUBMIT','CANCEL'))," + "intent_id TEXT NOT NULL, command_digest TEXT NOT NULL, context_json TEXT NOT NULL," + "payload_json TEXT NOT NULL," + "expected_snapshot_version INTEGER NOT NULL, writer_epoch INTEGER NOT NULL," + "state TEXT NOT NULL CHECK(state IN ('RESERVED','AUTHORIZED','BLOCKED'))," + "PRIMARY KEY(account_ref,operation,intent_id))" + ), + "actor_dispatch_outbox": ( + "CREATE TABLE actor_dispatch_outbox(" + "dispatch_id INTEGER PRIMARY KEY AUTOINCREMENT, account_ref TEXT NOT NULL," + "operation TEXT NOT NULL, intent_id TEXT NOT NULL, command_digest TEXT NOT NULL," + "writer_epoch INTEGER NOT NULL, snapshot_version INTEGER NOT NULL," + "UNIQUE(account_ref,operation,intent_id)," + "FOREIGN KEY(account_ref,operation,intent_id)" + " REFERENCES actor_commands(account_ref,operation,intent_id))" + ), +} +_SCHEMA_V1_DDL = dict(_SCHEMA_DDL) +_SCHEMA_COLUMNS["actor_dispatch_lifecycle"] = ("dispatch_id", "state") +_SCHEMA_DDL["actor_dispatch_lifecycle"] = ( + "CREATE TABLE actor_dispatch_lifecycle(" + "dispatch_id INTEGER PRIMARY KEY," + "state TEXT NOT NULL CHECK(state IN ('AVAILABLE','REVOKED','CLAIMED'))," + "FOREIGN KEY(dispatch_id) REFERENCES actor_dispatch_outbox(dispatch_id))" +) + + +class ActorServerError(RuntimeError): + """Fixed-code, fail-closed error from the fake durable service core.""" + + def __init__(self, code: str) -> None: + self.code = code + super().__init__(code.replace("_", " ")) + + +@dataclass(frozen=True) +class WriterEpochV1: + """Opaque local capability returned only after a durable writer claim.""" + + account_ref: str + owner_id: str + epoch: int + token: str = field(repr=False) + + def __post_init__(self) -> None: + _require_account_ref(self.account_ref) + _require_text(self.owner_id, "owner_id") + if type(self.epoch) is not int or self.epoch <= 0: + raise ValueError("epoch must be a positive exact integer") + if type(self.token) is not str or len(self.token) < 32: + raise ValueError("token must be nonempty opaque text") + + +@dataclass(frozen=True) +class SnapshotDomainFactV1: + """One canonical domain payload from a shared account snapshot.""" + + account_ref: str + snapshot_version: int + source_id: str + domain: str + payload_json: str + + def __post_init__(self) -> None: + _require_account_ref(self.account_ref) + if type(self.snapshot_version) is not int or self.snapshot_version <= 0: + raise ValueError("snapshot_version must be a positive exact integer") + _require_text(self.source_id, "source_id") + if type(self.domain) is not str or self.domain not in _DOMAINS: + raise ValueError("unsupported snapshot domain") + _require_canonical_object_json(self.payload_json) + + @classmethod + def from_payload( + cls, + *, + account_ref: str, + snapshot_version: int, + source_id: str, + domain: str, + payload: dict[str, Any], + ) -> SnapshotDomainFactV1: + return cls( + account_ref=account_ref, + snapshot_version=snapshot_version, + source_id=source_id, + domain=domain, + payload_json=_canonical_object(payload), + ) + + +@dataclass(frozen=True) +class AccountSnapshotBundleV1: + """Exactly four domain facts sharing one account/version/source tuple.""" + + account_ref: str + snapshot_version: int + source_id: str + facts: tuple[SnapshotDomainFactV1, ...] + + def __post_init__(self) -> None: + _require_account_ref(self.account_ref) + if type(self.snapshot_version) is not int or self.snapshot_version <= 0: + raise ValueError("snapshot_version must be a positive exact integer") + _require_text(self.source_id, "source_id") + if type(self.facts) is not tuple or len(self.facts) != len(_DOMAINS): + raise ValueError("snapshot must contain exactly four domain facts") + seen: set[str] = set() + for fact in self.facts: + if type(fact) is not SnapshotDomainFactV1: + raise ValueError("snapshot facts must be exact typed domain facts") + fact.__post_init__() + if fact.account_ref != self.account_ref: + raise ValueError("snapshot domain account mismatch") + if fact.snapshot_version != self.snapshot_version: + raise ValueError("snapshot domain version mismatch") + if fact.source_id != self.source_id: + raise ValueError("snapshot domain source mismatch") + if fact.domain in seen: + raise ValueError("duplicate snapshot domain") + seen.add(fact.domain) + if seen != _DOMAINS: + raise ValueError("snapshot domain set incomplete") + + @property + def digest(self) -> str: + self.__post_init__() + body = { + "account_ref": self.account_ref, + "snapshot_version": self.snapshot_version, + "source_id": self.source_id, + "facts": [ + { + "domain": fact.domain, + "payload": json.loads(fact.payload_json), + } + for fact in sorted(self.facts, key=lambda item: item.domain) + ], + } + return _sha256(_canonical_json(body)) + + +@dataclass(frozen=True) +class SnapshotAuthorityProofV1: + """Proof shape returned by the injected snapshot authority port.""" + + authority_id: str + account_ref: str + snapshot_version: int + source_id: str + snapshot_digest: str + proof_hex: str + + def __post_init__(self) -> None: + _require_text(self.authority_id, "authority_id") + _require_account_ref(self.account_ref) + if type(self.snapshot_version) is not int or self.snapshot_version <= 0: + raise ValueError("snapshot_version must be a positive exact integer") + _require_text(self.source_id, "source_id") + _require_digest(self.snapshot_digest, "snapshot_digest") + _require_digest(self.proof_hex, "proof_hex") + + +class FakeSnapshotAuthorityV1: + """Test-only HMAC verifier; it is not a production trust root or key source.""" + + def __init__(self, *, authority_id: str, source_id: str, key: bytes) -> None: + _require_text(authority_id, "authority_id") + _require_text(source_id, "source_id") + if type(key) is not bytes or len(key) < 32: + raise ValueError("fake test key must contain at least 32 bytes") + self.authority_id = authority_id + self.source_id = source_id + self._key = bytes(key) + + def attest(self, bundle: AccountSnapshotBundleV1) -> SnapshotAuthorityProofV1: + if type(bundle) is not AccountSnapshotBundleV1: + raise ActorServerError("snapshot_bundle_type_invalid") + bundle.__post_init__() + if bundle.source_id != self.source_id: + raise ActorServerError("snapshot_source_untrusted") + digest = bundle.digest + unsigned = { + "authority_id": self.authority_id, + "account_ref": bundle.account_ref, + "snapshot_version": bundle.snapshot_version, + "source_id": bundle.source_id, + "snapshot_digest": digest, + } + proof_hex = hmac.new( + self._key, + b"fake-account-snapshot-v1\x00" + _canonical_json(unsigned).encode("utf-8"), + hashlib.sha256, + ).hexdigest() + return SnapshotAuthorityProofV1( + authority_id=self.authority_id, + account_ref=bundle.account_ref, + snapshot_version=bundle.snapshot_version, + source_id=bundle.source_id, + snapshot_digest=digest, + proof_hex=proof_hex, + ) + + def verify( + self, bundle: AccountSnapshotBundleV1, proof: SnapshotAuthorityProofV1 + ) -> bool: + if ( + type(bundle) is not AccountSnapshotBundleV1 + or type(proof) is not SnapshotAuthorityProofV1 + ): + return False + try: + bundle.__post_init__() + proof.__post_init__() + except (TypeError, ValueError): + return False + if ( + proof.authority_id != self.authority_id + or proof.account_ref != bundle.account_ref + or proof.snapshot_version != bundle.snapshot_version + or proof.source_id != bundle.source_id + or proof.snapshot_digest != bundle.digest + or bundle.source_id != self.source_id + ): + return False + expected = self.attest(bundle) + return hmac.compare_digest(proof.proof_hex, expected.proof_hex) + + +@dataclass(frozen=True) +class AccountActorIntentV1: + """Logical command; no provider/native identity is accepted here.""" + + operation: str + intent_id: str + context: ActorCommandContextV1 + payload_json: str + + def __post_init__(self) -> None: + if type(self.operation) is not str or self.operation not in _OPERATIONS: + raise ValueError("operation must be SUBMIT or CANCEL") + _require_text(self.intent_id, "intent_id") + if type(self.context) is not ActorCommandContextV1: + raise ValueError("context must be ActorCommandContextV1") + self.context.__post_init__() + _require_account_ref(self.context.account_ref) + _require_canonical_object_json(self.payload_json) + + @classmethod + def from_payload( + cls, + *, + operation: str, + intent_id: str, + context: ActorCommandContextV1, + payload: dict[str, Any], + ) -> AccountActorIntentV1: + return cls( + operation=operation, + intent_id=intent_id, + context=context, + payload_json=_canonical_object(payload), + ) + + @property + def command_digest(self) -> str: + self.__post_init__() + return _command_digest( + self.operation, + self.intent_id, + _canonical_json(self.context.to_payload()), + self.payload_json, + ) + + @property + def context_json(self) -> str: + self.__post_init__() + return _canonical_json(self.context.to_payload()) + + +@dataclass(frozen=True) +class DurableCommandV1: + account_ref: str + operation: str + intent_id: str + command_digest: str + expected_snapshot_version: int + writer_epoch: int + state: str + + +@dataclass(frozen=True) +class DispatchAuthorizationV1: + """Local outbox row identity; not a dispatch credential or provider receipt. + + A consumer must pass this value to ``claim_for_dispatch``. The core only + returns a claim while the exact writer, session, and current snapshot remain + valid, and a claim freezes local snapshot/writer changes until process-level + recovery is designed. + """ + + account_ref: str + operation: str + intent_id: str + command_digest: str + writer_epoch: int + snapshot_version: int + dispatch_id: int + state: str = "AUTHORIZED_LOCAL_OUTBOX" + + def __post_init__(self) -> None: + _require_account_ref(self.account_ref) + if self.operation not in _OPERATIONS or type(self.operation) is not str: + raise ValueError("operation is invalid") + _require_text(self.intent_id, "intent_id") + _require_digest(self.command_digest, "command_digest") + if type(self.writer_epoch) is not int or self.writer_epoch <= 0: + raise ValueError("writer_epoch must be a positive exact integer") + if type(self.snapshot_version) is not int or self.snapshot_version <= 0: + raise ValueError("snapshot_version must be a positive exact integer") + if type(self.dispatch_id) is not int or self.dispatch_id <= 0: + raise ValueError("dispatch_id must be a positive exact integer") + if self.state != "AUTHORIZED_LOCAL_OUTBOX": + raise ValueError("authorization state is invalid") + + +@dataclass(frozen=True) +class DispatchClaimV1: + """One-shot local final-gate result; no provider call is performed.""" + + account_ref: str + operation: str + intent_id: str + command_digest: str + writer_epoch: int + snapshot_version: int + dispatch_id: int + state: str = "CLAIMED_LOCAL_ONLY" + + def __post_init__(self) -> None: + _require_account_ref(self.account_ref) + if type(self.operation) is not str or self.operation not in _OPERATIONS: + raise ValueError("operation is invalid") + _require_text(self.intent_id, "intent_id") + _require_digest(self.command_digest, "command_digest") + for name in ("writer_epoch", "snapshot_version", "dispatch_id"): + value = getattr(self, name) + if type(value) is not int or value <= 0: + raise ValueError(f"{name} must be a positive exact integer") + if self.state != "CLAIMED_LOCAL_ONLY": + raise ValueError("claim state is invalid") + + +class AccountActorServerCoreV1: + """SQLite-backed fake service core with no provider/native dispatcher.""" + + def __init__( + self, + database_path: str, + *, + snapshot_authority: Optional[FakeSnapshotAuthorityV1] = None, # noqa: UP045 - Python 3.8 + ) -> None: + if type(database_path) is not str or not database_path.strip(): + raise ValueError("database_path must be a nonempty filesystem path") + if database_path == ":memory:" or database_path.startswith("file:"): + raise ValueError( + "durable fake service requires a plain filesystem database" + ) + self.database_path = os.path.abspath(database_path) + if ( + snapshot_authority is not None + and type(snapshot_authority) is not FakeSnapshotAuthorityV1 + ): + raise ValueError("snapshot_authority must be exact fake authority type") + self._snapshot_authority = snapshot_authority + self._local_lock = threading.RLock() + self._initialize_schema() + + def close(self) -> None: + """No retained connection; process exit leaves durable claims unchanged.""" + + def claim_writer(self, account_ref: str, owner_id: str) -> WriterEpochV1: + _require_account_ref(account_ref) + _require_text(owner_id, "owner_id") + token = secrets.token_urlsafe(40) + token_hash = _sha256(token) + with self._local_lock, self._transaction() as cursor: + if self._has_claimed_dispatch_cursor(cursor, account_ref): + raise ActorServerError("dispatch_claim_in_flight") + row = cursor.execute( + "SELECT epoch, state FROM actor_account_writers WHERE account_ref=?", + (account_ref,), + ).fetchone() + if row is not None and row[1] == "ACTIVE": + raise ActorServerError("account_writer_already_claimed") + epoch = 1 if row is None else _exact_db_int(row[0], "writer epoch") + 1 + if row is None: + cursor.execute( + "INSERT INTO actor_account_writers" + "(account_ref, epoch, owner_id, token_sha256, state, context_json)" + " VALUES (?, ?, ?, ?, 'ACTIVE', NULL)", + (account_ref, epoch, owner_id, token_hash), + ) + else: + cursor.execute( + "UPDATE actor_account_writers SET epoch=?, owner_id=?, token_sha256=?," + " state='ACTIVE', context_json=NULL WHERE account_ref=? AND state='REVOKED'", + (epoch, owner_id, token_hash, account_ref), + ) + if cursor.rowcount != 1: + raise ActorServerError("account_writer_claim_conflict") + return WriterEpochV1(account_ref, owner_id, epoch, token) + + def bind_session( + self, writer: WriterEpochV1, context: ActorCommandContextV1 + ) -> None: + """Bind one exact local session context to this epoch.""" + + if type(context) is not ActorCommandContextV1: + raise ActorServerError("session_context_type_invalid") + try: + context.__post_init__() + _require_account_ref(context.account_ref) + except (TypeError, ValueError): + raise ActorServerError("session_context_invalid") from None + if ( + context.account_ref != writer.account_ref + or context.actor_epoch != writer.epoch + ): + raise ActorServerError("session_context_writer_mismatch") + context_json = _canonical_json(context.to_payload()) + with self._local_lock, self._transaction() as cursor: + self._require_writer_cursor(cursor, writer) + current = self._session_context_json_cursor(cursor, writer.account_ref) + if current is not None: + if current != context_json: + raise ActorServerError("session_context_already_bound") + return + cursor.execute( + "UPDATE actor_account_writers SET context_json=?" + " WHERE account_ref=? AND epoch=? AND state='ACTIVE' AND context_json IS NULL", + (context_json, writer.account_ref, writer.epoch), + ) + if cursor.rowcount != 1: + raise ActorServerError("session_context_bind_conflict") + + def revoke_writer(self, writer: WriterEpochV1) -> None: + with self._local_lock, self._transaction() as cursor: + self._require_writer_cursor(cursor, writer) + if self._has_claimed_dispatch_cursor(cursor, writer.account_ref): + raise ActorServerError("dispatch_claim_in_flight") + cursor.execute( + "UPDATE actor_account_writers SET state='REVOKED'" + " WHERE account_ref=? AND epoch=? AND owner_id=? AND state='ACTIVE'", + (writer.account_ref, writer.epoch, writer.owner_id), + ) + if cursor.rowcount != 1: + raise ActorServerError("writer_epoch_revocation_conflict") + + def publish_snapshot( + self, writer: WriterEpochV1, bundle: AccountSnapshotBundleV1 + ) -> SnapshotAuthorityProofV1: + authority = self._require_snapshot_authority() + _validate_bundle_type(bundle) + if bundle.account_ref != writer.account_ref: + raise ActorServerError("snapshot_account_mismatch") + proof = authority.attest(bundle) + if type(proof) is not SnapshotAuthorityProofV1 or not authority.verify( + bundle, proof + ): + raise ActorServerError("snapshot_authority_proof_invalid") + with self._local_lock, self._transaction() as cursor: + self._require_writer_cursor(cursor, writer) + existing = cursor.execute( + "SELECT source_id, authority_id, snapshot_digest, proof_hex" + " FROM actor_snapshots WHERE account_ref=? AND snapshot_version=?", + (bundle.account_ref, bundle.snapshot_version), + ).fetchone() + if existing is not None: + if tuple(existing) == ( + bundle.source_id, + proof.authority_id, + proof.snapshot_digest, + proof.proof_hex, + ) and self._snapshot_matches_cursor( + cursor, bundle.account_ref, bundle.snapshot_version + ): + return proof + raise ActorServerError("snapshot_version_conflict") + current = cursor.execute( + "SELECT snapshot_version FROM actor_current_snapshots WHERE account_ref=?", + (bundle.account_ref,), + ).fetchone() + if current is not None and bundle.snapshot_version <= current[0]: + raise ActorServerError("snapshot_version_not_increasing") + if self._has_claimed_dispatch_cursor(cursor, writer.account_ref): + raise ActorServerError("dispatch_claim_in_flight") + cursor.execute( + "UPDATE actor_dispatch_lifecycle SET state='REVOKED'" + " WHERE state='AVAILABLE' AND dispatch_id IN (" + " SELECT dispatch_id FROM actor_dispatch_outbox WHERE account_ref=?)", + (writer.account_ref,), + ) + cursor.execute( + "UPDATE actor_commands SET state='BLOCKED' WHERE account_ref=?" + " AND state='AUTHORIZED' AND EXISTS (" + " SELECT 1 FROM actor_dispatch_outbox o" + " JOIN actor_dispatch_lifecycle l USING(dispatch_id)" + " WHERE o.account_ref=actor_commands.account_ref" + " AND o.operation=actor_commands.operation" + " AND o.intent_id=actor_commands.intent_id AND l.state='REVOKED')", + (writer.account_ref,), + ) + cursor.execute( + "INSERT INTO actor_snapshots" + "(account_ref, snapshot_version, source_id, authority_id, snapshot_digest, proof_hex)" + " VALUES (?, ?, ?, ?, ?, ?)", + ( + bundle.account_ref, + bundle.snapshot_version, + bundle.source_id, + proof.authority_id, + proof.snapshot_digest, + proof.proof_hex, + ), + ) + cursor.executemany( + "INSERT INTO actor_snapshot_domains" + "(account_ref, snapshot_version, domain, source_id, payload_json)" + " VALUES (?, ?, ?, ?, ?)", + [ + ( + bundle.account_ref, + bundle.snapshot_version, + fact.domain, + fact.source_id, + fact.payload_json, + ) + for fact in bundle.facts + ], + ) + cursor.execute( + "INSERT INTO actor_current_snapshots(account_ref, snapshot_version) VALUES (?, ?)" + " ON CONFLICT(account_ref) DO UPDATE SET snapshot_version=excluded.snapshot_version", + (bundle.account_ref, bundle.snapshot_version), + ) + return proof + + def reserve_intent( + self, + writer: WriterEpochV1, + intent: AccountActorIntentV1, + *, + expected_snapshot_version: int, + ) -> DurableCommandV1: + _validate_intent_type(intent) + if type(expected_snapshot_version) is not int or expected_snapshot_version <= 0: + raise ActorServerError("snapshot_version_invalid") + self._require_context_for_writer(writer, intent) + with self._local_lock, self._transaction() as cursor: + self._require_writer_cursor(cursor, writer) + self._require_session_context_cursor(cursor, writer, intent.context) + self._require_current_verified_snapshot_cursor( + cursor, intent.context.account_ref, expected_snapshot_version + ) + row = cursor.execute( + "SELECT command_digest, context_json, payload_json, expected_snapshot_version," + " writer_epoch, state" + " FROM actor_commands WHERE account_ref=? AND operation=? AND intent_id=?", + (intent.context.account_ref, intent.operation, intent.intent_id), + ).fetchone() + if row is not None: + if ( + row[0] != intent.command_digest + or row[3] != expected_snapshot_version + or not _stored_command_is_valid( + account_ref=intent.context.account_ref, + operation=intent.operation, + intent_id=intent.intent_id, + command_digest=row[0], + context_json=row[1], + payload_json=row[2], + expected_epoch=writer.epoch, + expected_context_json=self._session_context_json_cursor( + cursor, writer.account_ref + ), + ) + ): + raise ActorServerError("intent_replay_conflict") + return DurableCommandV1( + intent.context.account_ref, + intent.operation, + intent.intent_id, + row[0], + row[3], + row[4], + row[5], + ) + cursor.execute( + "INSERT INTO actor_commands" + "(account_ref, operation, intent_id, command_digest, context_json, payload_json," + " expected_snapshot_version, writer_epoch, state)" + " VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'RESERVED')", + ( + intent.context.account_ref, + intent.operation, + intent.intent_id, + intent.command_digest, + intent.context_json, + intent.payload_json, + expected_snapshot_version, + writer.epoch, + ), + ) + return DurableCommandV1( + intent.context.account_ref, + intent.operation, + intent.intent_id, + intent.command_digest, + expected_snapshot_version, + writer.epoch, + "RESERVED", + ) + + def authorize_dispatch( + self, + writer: WriterEpochV1, + *, + operation: str, + intent_id: str, + ) -> DispatchAuthorizationV1: + """Append or replay a local authorization row after a current check. + + The returned value identifies an outbox row; it is not a dispatch + credential. A consumer must pass it to ``claim_for_dispatch`` for the + one-shot final check. This method never calls a provider or SDK. + """ + + if type(operation) is not str or operation not in _OPERATIONS: + raise ActorServerError("operation_invalid") + _require_text(intent_id, "intent_id") + authority = self._require_snapshot_authority() + failure_code = None + authorization = None + with self._local_lock, self._transaction() as cursor: + self._require_writer_cursor(cursor, writer) + expected_context_json = self._session_context_json_cursor( + cursor, writer.account_ref + ) + if expected_context_json is None: + raise ActorServerError("writer_session_unbound") + row = cursor.execute( + "SELECT command_digest, context_json, payload_json, expected_snapshot_version," + " writer_epoch, state" + " FROM actor_commands WHERE account_ref=? AND operation=? AND intent_id=?", + (writer.account_ref, operation, intent_id), + ).fetchone() + if row is None: + raise ActorServerError("intent_not_reserved") + ( + command_digest, + context_json, + payload_json, + snapshot_version, + command_epoch, + state, + ) = row + if command_epoch != writer.epoch: + raise ActorServerError("command_writer_epoch_mismatch") + if not _stored_command_is_valid( + account_ref=writer.account_ref, + operation=operation, + intent_id=intent_id, + command_digest=command_digest, + context_json=context_json, + payload_json=payload_json, + expected_epoch=writer.epoch, + expected_context_json=expected_context_json, + ): + raise ActorServerError("command_binding_readback_mismatch") + if state == "AUTHORIZED": + outbox = cursor.execute( + "SELECT dispatch_id, command_digest, writer_epoch, snapshot_version" + " FROM actor_dispatch_outbox WHERE account_ref=? AND operation=? AND intent_id=?", + (writer.account_ref, operation, intent_id), + ).fetchone() + if outbox is None or tuple(outbox[1:]) != ( + command_digest, + writer.epoch, + snapshot_version, + ): + raise ActorServerError("dispatch_outbox_readback_mismatch") + dispatch_id = _exact_db_int(outbox[0], "dispatch_id") + lifecycle = cursor.execute( + "SELECT state FROM actor_dispatch_lifecycle WHERE dispatch_id=?", + (dispatch_id,), + ).fetchone() + if lifecycle is None: + raise ActorServerError("dispatch_lifecycle_readback_mismatch") + try: + self._require_current_verified_snapshot_cursor( + cursor, + writer.account_ref, + snapshot_version, + authority=authority, + ) + except ActorServerError: + if lifecycle[0] == "AVAILABLE": + cursor.execute( + "UPDATE actor_dispatch_lifecycle SET state='REVOKED'" + " WHERE dispatch_id=? AND state='AVAILABLE'", + (dispatch_id,), + ) + cursor.execute( + "UPDATE actor_commands SET state='BLOCKED'" + " WHERE account_ref=? AND operation=? AND intent_id=?" + " AND state='AUTHORIZED'", + (writer.account_ref, operation, intent_id), + ) + failure_code = "dispatch_authorization_stale" + else: + if lifecycle[0] != "AVAILABLE": + failure_code = ( + "dispatch_authorization_stale" + if lifecycle[0] == "REVOKED" + else "dispatch_authorization_consumed" + ) + else: + authorization = DispatchAuthorizationV1( + writer.account_ref, + operation, + intent_id, + command_digest, + writer.epoch, + snapshot_version, + dispatch_id, + ) + elif state == "BLOCKED": + lifecycle = cursor.execute( + "SELECT l.state FROM actor_dispatch_outbox o" + " JOIN actor_dispatch_lifecycle l USING(dispatch_id)" + " WHERE o.account_ref=? AND o.operation=? AND o.intent_id=?", + (writer.account_ref, operation, intent_id), + ).fetchone() + if lifecycle is not None and lifecycle[0] == "REVOKED": + failure_code = "dispatch_authorization_stale" + else: + failure_code = "intent_state_not_dispatchable" + elif state == "RESERVED": + self._require_current_verified_snapshot_cursor( + cursor, + writer.account_ref, + snapshot_version, + authority=authority, + ) + cursor.execute( + "INSERT INTO actor_dispatch_outbox" + "(account_ref, operation, intent_id, command_digest, writer_epoch, snapshot_version)" + " VALUES (?, ?, ?, ?, ?, ?)", + ( + writer.account_ref, + operation, + intent_id, + command_digest, + writer.epoch, + snapshot_version, + ), + ) + dispatch_id = _exact_db_int(cursor.lastrowid, "dispatch_id") + cursor.execute( + "INSERT INTO actor_dispatch_lifecycle(dispatch_id,state)" + " VALUES (?, 'AVAILABLE')", + (dispatch_id,), + ) + cursor.execute( + "UPDATE actor_commands SET state='AUTHORIZED'" + " WHERE account_ref=? AND operation=? AND intent_id=? AND state='RESERVED'", + (writer.account_ref, operation, intent_id), + ) + if cursor.rowcount != 1: + raise ActorServerError("intent_authorization_conflict") + authorization = DispatchAuthorizationV1( + writer.account_ref, + operation, + intent_id, + command_digest, + writer.epoch, + snapshot_version, + dispatch_id, + ) + else: + raise ActorServerError("intent_state_not_dispatchable") + if failure_code is not None: + raise ActorServerError(failure_code) + if authorization is None: + raise ActorServerError("dispatch_authorization_readback_mismatch") + return authorization + + def claim_for_dispatch( + self, + writer: WriterEpochV1, + authorization: DispatchAuthorizationV1, + ) -> DispatchClaimV1: + """Consume an outbox authorization at the final local dispatch gate. + + This method never calls a provider. The one-shot claim pins the exact + writer epoch and snapshot: publishing a newer snapshot or revoking or + reclaiming the writer is rejected while it remains CLAIMED. There is + deliberately no release or completion API in this fake candidate, so + process death leaves the account frozen instead of risking replay. + """ + + if type(authorization) is not DispatchAuthorizationV1: + raise ActorServerError("dispatch_authorization_type_invalid") + try: + authorization.__post_init__() + except (TypeError, ValueError): + raise ActorServerError("dispatch_authorization_invalid") from None + if type(writer) is not WriterEpochV1: + raise ActorServerError("writer_epoch_type_invalid") + authority = self._require_snapshot_authority() + failure_code = None + claim = None + with self._local_lock, self._transaction() as cursor: + self._require_writer_cursor(cursor, writer) + expected_context_json = self._session_context_json_cursor( + cursor, writer.account_ref + ) + if expected_context_json is None: + raise ActorServerError("writer_session_unbound") + row = cursor.execute( + "SELECT c.command_digest, c.context_json, c.payload_json," + " c.expected_snapshot_version, c.writer_epoch, c.state," + " o.dispatch_id, o.command_digest, o.writer_epoch, o.snapshot_version," + " l.state FROM actor_commands c" + " JOIN actor_dispatch_outbox o USING(account_ref,operation,intent_id)" + " JOIN actor_dispatch_lifecycle l USING(dispatch_id)" + " WHERE c.account_ref=? AND c.operation=? AND c.intent_id=?", + (writer.account_ref, authorization.operation, authorization.intent_id), + ).fetchone() + if row is None: + raise ActorServerError("dispatch_authorization_not_found") + ( + command_digest, + context_json, + payload_json, + command_snapshot_version, + command_epoch, + command_state, + dispatch_id, + outbox_digest, + outbox_epoch, + outbox_snapshot_version, + lifecycle_state, + ) = row + if not _stored_command_is_valid( + account_ref=writer.account_ref, + operation=authorization.operation, + intent_id=authorization.intent_id, + command_digest=command_digest, + context_json=context_json, + payload_json=payload_json, + expected_epoch=writer.epoch, + expected_context_json=expected_context_json, + ): + raise ActorServerError("command_binding_readback_mismatch") + if ( + command_state != "AUTHORIZED" + or command_epoch != writer.epoch + or command_snapshot_version != authorization.snapshot_version + or outbox_digest != command_digest + or outbox_epoch != writer.epoch + or outbox_snapshot_version != command_snapshot_version + or _exact_db_int(dispatch_id, "dispatch_id") != authorization.dispatch_id + or authorization.account_ref != writer.account_ref + or authorization.command_digest != command_digest + or authorization.writer_epoch != writer.epoch + or lifecycle_state != "AVAILABLE" + ): + raise ActorServerError("dispatch_authorization_not_available") + try: + self._require_current_verified_snapshot_cursor( + cursor, + writer.account_ref, + command_snapshot_version, + authority=authority, + ) + except ActorServerError: + cursor.execute( + "UPDATE actor_dispatch_lifecycle SET state='REVOKED'" + " WHERE dispatch_id=? AND state='AVAILABLE'", + (authorization.dispatch_id,), + ) + cursor.execute( + "UPDATE actor_commands SET state='BLOCKED'" + " WHERE account_ref=? AND operation=? AND intent_id=?" + " AND state='AUTHORIZED'", + (writer.account_ref, authorization.operation, authorization.intent_id), + ) + failure_code = "dispatch_authorization_stale" + else: + cursor.execute( + "UPDATE actor_dispatch_lifecycle SET state='CLAIMED'" + " WHERE dispatch_id=? AND state='AVAILABLE'", + (authorization.dispatch_id,), + ) + if cursor.rowcount != 1: + raise ActorServerError("dispatch_claim_conflict") + claim = DispatchClaimV1( + writer.account_ref, + authorization.operation, + authorization.intent_id, + command_digest, + writer.epoch, + command_snapshot_version, + authorization.dispatch_id, + ) + if failure_code is not None: + raise ActorServerError(failure_code) + if claim is None: + raise ActorServerError("dispatch_claim_readback_mismatch") + return claim + + def read_dispatch_state( + self, account_ref: str, operation: str, intent_id: str + ) -> str: + """Read the durable final-gate lifecycle state for a local outbox row.""" + + _require_account_ref(account_ref) + if type(operation) is not str or operation not in _OPERATIONS: + raise ActorServerError("operation_invalid") + _require_text(intent_id, "intent_id") + with closing(self._connect()) as connection: + row = connection.execute( + "SELECT l.state FROM actor_dispatch_outbox o" + " JOIN actor_dispatch_lifecycle l USING(dispatch_id)" + " WHERE o.account_ref=? AND o.operation=? AND o.intent_id=?", + (account_ref, operation, intent_id), + ).fetchone() + if row is None: + raise ActorServerError("dispatch_authorization_not_found") + return row[0] + + def count_dispatch_rows(self, account_ref: str) -> int: + _require_account_ref(account_ref) + with closing(self._connect()) as connection: + row = connection.execute( + "SELECT COUNT(*) FROM actor_dispatch_outbox WHERE account_ref=?", + (account_ref,), + ).fetchone() + return _exact_db_int(row[0], "outbox count") + + def read_writer_epoch(self, account_ref: str) -> tuple[int, str, str]: + _require_account_ref(account_ref) + with closing(self._connect()) as connection: + row = connection.execute( + "SELECT epoch, owner_id, state FROM actor_account_writers WHERE account_ref=?", + (account_ref,), + ).fetchone() + if row is None: + raise ActorServerError("writer_not_found") + return _exact_db_int(row[0], "writer epoch"), row[1], row[2] + + def _initialize_schema(self) -> None: + connection = self._connect() + try: + connection.execute("PRAGMA foreign_keys=ON") + version = connection.execute("PRAGMA user_version").fetchone()[0] + user_tables = { + row[0] + for row in connection.execute( + "SELECT name FROM sqlite_master WHERE name NOT LIKE 'sqlite_%'" + ) + } + if version == 0: + if user_tables: + raise ActorServerError("database_schema_unsupported") + elif version == 1: + self._validate_schema( + connection, + user_tables, + schema_ddl=_SCHEMA_V1_DDL, + schema_columns=_SCHEMA_V1_COLUMNS, + ) + self._migrate_v1_to_v2(connection) + migrated_tables = { + row[0] + for row in connection.execute( + "SELECT name FROM sqlite_master WHERE name NOT LIKE 'sqlite_%'" + ) + } + self._validate_schema(connection, migrated_tables) + return + elif version == _SCHEMA_VERSION: + self._validate_schema(connection, user_tables) + return + else: + raise ActorServerError("database_schema_unsupported") + connection.executescript( + "BEGIN IMMEDIATE;" + + ";".join(_SCHEMA_DDL.values()) + + f";PRAGMA user_version={_SCHEMA_VERSION};COMMIT;" + ) + finally: + connection.close() + + @staticmethod + def _validate_schema( + connection: sqlite3.Connection, + user_tables: set[str], + *, + schema_ddl=None, + schema_columns=None, + ) -> None: + schema_ddl = _SCHEMA_DDL if schema_ddl is None else schema_ddl + schema_columns = _SCHEMA_COLUMNS if schema_columns is None else schema_columns + if user_tables != set(schema_columns): + raise ActorServerError("database_schema_shape_invalid") + objects = connection.execute( + "SELECT type, name, sql FROM sqlite_master WHERE name NOT LIKE 'sqlite_%'" + ).fetchall() + if len(objects) != len(schema_ddl): + raise ActorServerError("database_schema_shape_invalid") + for object_type, name, sql in objects: + expected = schema_ddl.get(name) + if ( + object_type != "table" + or expected is None + or _normalize_sql(sql) != _normalize_sql(expected) + ): + raise ActorServerError("database_schema_shape_invalid") + for table, expected_columns in schema_columns.items(): + actual_columns = tuple( + row[1] for row in connection.execute(f"PRAGMA table_info({table})") + ) + if actual_columns != expected_columns: + raise ActorServerError("database_schema_shape_invalid") + + @staticmethod + def _migrate_v1_to_v2(connection: sqlite3.Connection) -> None: + """Preserve v1 outbox rows as revoked audit history. + + A v1 outbox may already have escaped to an unknown consumer, so the + migration deliberately never upgrades it into an available claim. + """ + + connection.execute("BEGIN IMMEDIATE") + try: + version = connection.execute("PRAGMA user_version").fetchone()[0] + if version == _SCHEMA_VERSION: + tables = { + row[0] + for row in connection.execute( + "SELECT name FROM sqlite_master WHERE name NOT LIKE 'sqlite_%'" + ) + } + AccountActorServerCoreV1._validate_schema(connection, tables) + connection.commit() + return + if version != 1: + raise ActorServerError("database_schema_unsupported") + tables = { + row[0] + for row in connection.execute( + "SELECT name FROM sqlite_master WHERE name NOT LIKE 'sqlite_%'" + ) + } + AccountActorServerCoreV1._validate_schema( + connection, + tables, + schema_ddl=_SCHEMA_V1_DDL, + schema_columns=_SCHEMA_V1_COLUMNS, + ) + if connection.execute("PRAGMA foreign_key_check").fetchone() is not None: + raise ActorServerError("database_v1_integrity_invalid") + + outbox_rows = connection.execute( + "SELECT dispatch_id, account_ref, operation, intent_id, command_digest," + " writer_epoch, snapshot_version FROM actor_dispatch_outbox" + ).fetchall() + authorized_commands = connection.execute( + "SELECT account_ref, operation, intent_id, command_digest," + " writer_epoch, expected_snapshot_version FROM actor_commands" + " WHERE state='AUTHORIZED'" + ).fetchall() + by_key = { + (row[1], row[2], row[3]): row + for row in outbox_rows + } + if len(by_key) != len(outbox_rows) or len(authorized_commands) != len(outbox_rows): + raise ActorServerError("database_v1_authorization_history_invalid") + for command in authorized_commands: + outbox = by_key.get((command[0], command[1], command[2])) + if outbox is None or tuple(command[3:]) != tuple(outbox[4:7]): + raise ActorServerError("database_v1_authorization_history_invalid") + + connection.execute(_SCHEMA_DDL["actor_dispatch_lifecycle"]) + connection.executemany( + "INSERT INTO actor_dispatch_lifecycle(dispatch_id,state) VALUES (?, 'REVOKED')", + [(_exact_db_int(row[0], "dispatch_id"),) for row in outbox_rows], + ) + connection.execute( + "UPDATE actor_commands SET state='BLOCKED' WHERE state='AUTHORIZED'" + ) + connection.execute(f"PRAGMA user_version={_SCHEMA_VERSION}") + connection.commit() + except Exception: + connection.rollback() + raise + + def _connect(self) -> sqlite3.Connection: + connection = sqlite3.connect( + self.database_path, timeout=10.0, isolation_level=None + ) + connection.execute("PRAGMA busy_timeout=10000") + connection.execute("PRAGMA foreign_keys=ON") + return connection + + class _Transaction: + def __init__(self, core: AccountActorServerCoreV1) -> None: + self.core = core + self.connection: sqlite3.Connection | None = None + + def __enter__(self) -> sqlite3.Cursor: + self.connection = self.core._connect() + try: + self.connection.execute("BEGIN IMMEDIATE") + except Exception: + self.connection.close() + self.connection = None + raise + return self.connection.cursor() + + def __exit__( + self, + exc_type: Optional[type[BaseException]], # noqa: UP045 - Python 3.8 + exc: Optional[BaseException], # noqa: UP045 - Python 3.8 + tb: Optional[TracebackType], # noqa: UP045 - Python 3.8 + ) -> bool: + assert self.connection is not None + try: + if exc_type is None: + self.connection.commit() + else: + self.connection.rollback() + finally: + self.connection.close() + return False + + def _transaction(self) -> AccountActorServerCoreV1._Transaction: + return self._Transaction(self) + + @staticmethod + def _has_claimed_dispatch_cursor( + cursor: sqlite3.Cursor, account_ref: str + ) -> bool: + row = cursor.execute( + "SELECT 1 FROM actor_dispatch_outbox o" + " JOIN actor_dispatch_lifecycle l USING(dispatch_id)" + " WHERE o.account_ref=? AND l.state='CLAIMED' LIMIT 1", + (account_ref,), + ).fetchone() + return row is not None + + def _require_writer_cursor( + self, cursor: sqlite3.Cursor, writer: WriterEpochV1 + ) -> None: + if type(writer) is not WriterEpochV1: + raise ActorServerError("writer_epoch_type_invalid") + try: + writer.__post_init__() + except (TypeError, ValueError): + raise ActorServerError("writer_epoch_invalid") from None + row = cursor.execute( + "SELECT epoch, owner_id, token_sha256, state FROM actor_account_writers WHERE account_ref=?", + (writer.account_ref,), + ).fetchone() + if row is None or row[3] != "ACTIVE": + raise ActorServerError("writer_epoch_inactive") + if ( + row[0] != writer.epoch + or row[1] != writer.owner_id + or not hmac.compare_digest(row[2], _sha256(writer.token)) + ): + raise ActorServerError("writer_epoch_mismatch") + + def _require_context_for_writer( + self, writer: WriterEpochV1, intent: AccountActorIntentV1 + ) -> None: + if type(writer) is not WriterEpochV1: + raise ActorServerError("writer_epoch_type_invalid") + if intent.context.account_ref != writer.account_ref: + raise ActorServerError("intent_account_mismatch") + if intent.context.actor_epoch != writer.epoch: + raise ActorServerError("intent_actor_epoch_mismatch") + + def _require_session_context_cursor( + self, + cursor: sqlite3.Cursor, + writer: WriterEpochV1, + context: ActorCommandContextV1, + ) -> None: + expected = self._session_context_json_cursor(cursor, writer.account_ref) + if expected is None: + raise ActorServerError("writer_session_unbound") + if _canonical_json(context.to_payload()) != expected: + raise ActorServerError("intent_session_binding_mismatch") + + @staticmethod + def _session_context_json_cursor( + cursor: sqlite3.Cursor, account_ref: str + ) -> Optional[str]: # noqa: UP045 - Python 3.8 + row = cursor.execute( + "SELECT context_json FROM actor_account_writers WHERE account_ref=?", + (account_ref,), + ).fetchone() + if row is None: + raise ActorServerError("writer_not_found") + if row[0] is None: + return None + try: + _require_canonical_object_json(row[0]) + decoded = json.loads(row[0]) + context = ActorCommandContextV1(**decoded) + context.__post_init__() + _require_account_ref(context.account_ref) + except (TypeError, ValueError): + raise ActorServerError("writer_session_binding_corrupt") from None + return row[0] + + def _require_snapshot_authority(self) -> FakeSnapshotAuthorityV1: + authority = self._snapshot_authority + if type(authority) is not FakeSnapshotAuthorityV1: + raise ActorServerError("snapshot_source_authority_unavailable") + return authority + + def _require_current_verified_snapshot_cursor( + self, + cursor: sqlite3.Cursor, + account_ref: str, + snapshot_version: int, + *, + authority: Optional[FakeSnapshotAuthorityV1] = None, # noqa: UP045 - Python 3.8 + ) -> None: + authority = authority or self._require_snapshot_authority() + current = cursor.execute( + "SELECT snapshot_version FROM actor_current_snapshots WHERE account_ref=?", + (account_ref,), + ).fetchone() + if current is None or current[0] != snapshot_version: + raise ActorServerError("snapshot_not_current") + bundle, proof = self._read_snapshot_cursor( + cursor, account_ref, snapshot_version + ) + if not authority.verify(bundle, proof): + raise ActorServerError("snapshot_source_authority_invalid") + + def _snapshot_matches_cursor( + self, cursor: sqlite3.Cursor, account_ref: str, snapshot_version: int + ) -> bool: + authority = self._snapshot_authority + if type(authority) is not FakeSnapshotAuthorityV1: + return False + try: + bundle, proof = self._read_snapshot_cursor( + cursor, account_ref, snapshot_version + ) + except (ActorServerError, TypeError, ValueError): + return False + return authority.verify(bundle, proof) + + def _read_snapshot_cursor( + self, cursor: sqlite3.Cursor, account_ref: str, snapshot_version: int + ) -> tuple[AccountSnapshotBundleV1, SnapshotAuthorityProofV1]: + header = cursor.execute( + "SELECT source_id, authority_id, snapshot_digest, proof_hex FROM actor_snapshots" + " WHERE account_ref=? AND snapshot_version=?", + (account_ref, snapshot_version), + ).fetchone() + if header is None: + raise ActorServerError("snapshot_not_found") + rows = cursor.execute( + "SELECT domain, source_id, payload_json FROM actor_snapshot_domains" + " WHERE account_ref=? AND snapshot_version=? ORDER BY domain", + (account_ref, snapshot_version), + ).fetchall() + if len(rows) != 4 or {row[0] for row in rows} != _DOMAINS: + raise ActorServerError("snapshot_domain_set_incomplete") + facts = tuple( + SnapshotDomainFactV1( + account_ref=account_ref, + snapshot_version=snapshot_version, + source_id=row[1], + domain=row[0], + payload_json=row[2], + ) + for row in rows + ) + bundle = AccountSnapshotBundleV1( + account_ref=account_ref, + snapshot_version=snapshot_version, + source_id=header[0], + facts=facts, + ) + proof = SnapshotAuthorityProofV1( + authority_id=header[1], + account_ref=account_ref, + snapshot_version=snapshot_version, + source_id=header[0], + snapshot_digest=header[2], + proof_hex=header[3], + ) + if proof.snapshot_digest != bundle.digest: + raise ActorServerError("snapshot_digest_readback_mismatch") + return bundle, proof + + +def _validate_bundle_type(bundle: AccountSnapshotBundleV1) -> None: + if type(bundle) is not AccountSnapshotBundleV1: + raise ActorServerError("snapshot_bundle_type_invalid") + try: + bundle.__post_init__() + except (TypeError, ValueError): + raise ActorServerError("snapshot_bundle_invalid") from None + + +def _validate_intent_type(intent: AccountActorIntentV1) -> None: + if type(intent) is not AccountActorIntentV1: + raise ActorServerError("intent_type_invalid") + try: + intent.__post_init__() + except (TypeError, ValueError): + raise ActorServerError("intent_invalid") from None + + +def _require_account_ref(value: str) -> None: + if type(value) is not str or _ACCOUNT_REF.fullmatch(value) is None: + raise ValueError("account_ref must be canonical ctp-account-ref.v1 SHA-256") + + +def _require_text(value: str, name: str) -> None: + if type(value) is not str or not value.strip(): + raise ValueError(f"{name} must be nonempty exact text") + + +def _require_digest(value: str, name: str) -> None: + if type(value) is not str or re.fullmatch(r"[0-9a-f]{64}", value) is None: + raise ValueError(f"{name} must be lowercase SHA-256") + + +def _canonical_object(value: dict[str, Any]) -> str: + if type(value) is not dict: + raise ValueError("payload must be an exact dict") + return _canonical_json(value) + + +def _require_canonical_object_json(value: str) -> None: + if type(value) is not str: + raise ValueError("canonical payload must be exact text") + try: + decoded = json.loads(value) + if type(decoded) is not dict or _canonical_json(decoded) != value: + raise ValueError + except (TypeError, ValueError, json.JSONDecodeError): + raise ValueError("payload must be canonical JSON object") from None + + +def _canonical_json(value: Any) -> str: + return json.dumps( + value, + ensure_ascii=False, + sort_keys=True, + separators=(",", ":"), + allow_nan=False, + ) + + +def _sha256(text: str) -> str: + return hashlib.sha256(text.encode("utf-8")).hexdigest() + + +def _command_digest( + operation: str, intent_id: str, context_json: str, payload_json: str +) -> str: + _require_canonical_object_json(context_json) + _require_canonical_object_json(payload_json) + return _sha256( + _canonical_json( + { + "operation": operation, + "intent_id": intent_id, + "context": json.loads(context_json), + "payload": json.loads(payload_json), + } + ) + ) + + +def _stored_command_is_valid( + *, + account_ref: str, + operation: str, + intent_id: str, + command_digest: str, + context_json: str, + payload_json: str, + expected_epoch: int, + expected_context_json: Optional[str], # noqa: UP045 - Python 3.8 +) -> bool: + try: + if expected_context_json is None or context_json != expected_context_json: + return False + _require_account_ref(account_ref) + _require_canonical_object_json(context_json) + _require_canonical_object_json(payload_json) + context = ActorCommandContextV1(**json.loads(context_json)) + context.__post_init__() + if context.account_ref != account_ref or context.actor_epoch != expected_epoch: + return False + return hmac.compare_digest( + command_digest, + _command_digest(operation, intent_id, context_json, payload_json), + ) + except (TypeError, ValueError, ActorServerError): + return False + + +def _normalize_sql(value: str) -> str: + if type(value) is not str: + return "" + return re.sub(r"\s+", "", value).lower() + + +def _exact_db_int(value: Any, name: str) -> int: + if type(value) is not int: + raise ActorServerError(f"{name.replace(' ', '_')}_invalid") + return value diff --git a/backtrader_runtime/_local_fake_account_actor_candidate/fake_actor_client.py b/backtrader_runtime/_local_fake_account_actor_candidate/fake_actor_client.py new file mode 100644 index 00000000..76281bfc --- /dev/null +++ b/backtrader_runtime/_local_fake_account_actor_candidate/fake_actor_client.py @@ -0,0 +1,542 @@ +from __future__ import annotations + +import json +import math +import queue +import subprocess +import sys +import threading +import time +from collections import deque +from dataclasses import dataclass +from pathlib import Path +from typing import Optional + +from .account_actor_port import ( + AccountActorGateError, + ActorCommandContextV1, + ActorCommandExpectationV2, + ActorCommandReceiptV2, + ActorCommandState, + CtpAccountActorPort, + CtpCancelIntentV2, + CtpSubmitIntentV2, + validate_actor_receipt, +) + +_SCHEMA = "account-actor-wire.v1" +_MAX_RESPONSE_LINE_BYTES = 64 * 1024 +_MAX_REQUEST_BYTES = 64 * 1024 +_MAX_RESPONSE_QUEUE_LINES = 16 +_MAX_RESPONSE_QUEUE_BYTES = 512 * 1024 +_STDERR_TAIL_BYTES = 8 * 1024 + + +class _BoundedResponseMailbox: + """Bound both queued line count and aggregate payload bytes.""" + + def __init__(self, max_lines: int, max_bytes: int) -> None: + self._max_lines = max_lines + self._max_bytes = max_bytes + self._condition = threading.Condition() + self._lines = deque() + self._queued_bytes = 0 + self._terminal = None + + def publish_line(self, line: bytes) -> bool: + with self._condition: + if self._terminal is not None: + return False + if ( + len(self._lines) >= self._max_lines + or self._queued_bytes + len(line) > self._max_bytes + ): + self._lines.clear() + self._queued_bytes = 0 + self._terminal = "overflow" + self._condition.notify_all() + return False + self._lines.append(line) + self._queued_bytes += len(line) + self._condition.notify() + return True + + def finish(self, kind: str) -> None: + with self._condition: + if self._terminal is None: + self._terminal = kind + self._condition.notify_all() + + def get(self, timeout: float): + deadline = time.monotonic() + timeout + with self._condition: + while True: + if self._terminal in ("overflow", "oversize", "read_error"): + return self._terminal, None + if self._lines: + line = self._lines.popleft() + self._queued_bytes -= len(line) + return "line", line + if self._terminal is not None: + return self._terminal, None + remaining = deadline - time.monotonic() + if remaining <= 0: + raise queue.Empty + self._condition.wait(remaining) + + +@dataclass(frozen=True) +class FakeWireReceiptDetails: + """Extra local test metadata; not part of the production port receipt.""" + + writer_epoch: int + snapshot_version: int + dispatch_id: int + durable_command_digest: str + fake_send_id: str + provider_acknowledged: bool + + +class SubprocessFakeActorPort(CtpAccountActorPort): + """stdio client to the child-process fake actor; deliberately no local fallback.""" + + def __init__( + self, + database_path: str, + response_timeout_seconds: float = 2.0, + request_timeout_seconds: float = 2.0, + response_queue_max_lines: int = _MAX_RESPONSE_QUEUE_LINES, + response_queue_max_bytes: int = _MAX_RESPONSE_QUEUE_BYTES, + ) -> None: + if ( + type(response_timeout_seconds) not in (int, float) + or not math.isfinite(response_timeout_seconds) + or response_timeout_seconds <= 0 + ): + raise ValueError("positive finite response timeout required") + if ( + type(request_timeout_seconds) not in (int, float) + or not math.isfinite(request_timeout_seconds) + or request_timeout_seconds <= 0 + ): + raise ValueError("positive finite request timeout required") + if type(response_queue_max_lines) is not int or not 1 <= response_queue_max_lines <= 128: + raise ValueError("response queue line bound out of range") + if ( + type(response_queue_max_bytes) is not int + or not 1 <= response_queue_max_bytes <= 4 * 1024 * 1024 + ): + raise ValueError("response queue byte bound out of range") + self._response_timeout_seconds = float(response_timeout_seconds) + self._request_timeout_seconds = float(request_timeout_seconds) + self._response_lines = _BoundedResponseMailbox( + response_queue_max_lines, + response_queue_max_bytes, + ) + self._exchange_lock = threading.Lock() + self._active_writer = None + self._active_writer_done = None + self._cleanup_uncertain = False + self._stderr_lock = threading.Lock() + self._stderr_bytes_drained = 0 + self._stderr_tail = bytearray() + self._closed = False + self._stdout_reader = None + self._stderr_reader = None + service_path = Path(__file__).resolve().with_name("fake_actor_service.py") + self._process = subprocess.Popen( + [sys.executable, "-B", str(service_path), database_path], + cwd=str(service_path.parent), + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=False, + bufsize=0, + ) + self._stdout_reader = threading.Thread( + target=self._drain_stdout, + name="fake-actor-stdout-reader", + daemon=True, + ) + self._stderr_reader = threading.Thread( + target=self._drain_stderr, + name="fake-actor-stderr-reader", + daemon=True, + ) + self._stdout_reader.start() + self._stderr_reader.start() + self._last_wire_receipt: Optional[FakeWireReceiptDetails] = None # noqa: UP045 - retain Python 3.8 compatibility + try: + ready = self._read_response() + if ready.get("kind") != "ready": + raise AccountActorGateError("fake_actor_service_not_ready") + self._stale_context = ActorCommandContextV1(**ready["stale_context"]) + self._current_context = ActorCommandContextV1(**ready["current_context"]) + self._snapshot_version = ready["snapshot_version"] + if ( + type(self._snapshot_version) is not int + or self._snapshot_version <= 0 + or self._current_context.actor_epoch <= self._stale_context.actor_epoch + ): + raise AccountActorGateError("fake_actor_service_context_invalid") + except Exception: + self.close() + raise + + @property + def test_stale_context(self) -> ActorCommandContextV1: + return self._stale_context + + @property + def test_current_context(self) -> ActorCommandContextV1: + return self._current_context + + @property + def test_snapshot_version(self) -> int: + return self._snapshot_version + + @property + def test_last_wire_receipt(self) -> Optional[FakeWireReceiptDetails]: # noqa: UP045 - retain Python 3.8 compatibility + return self._last_wire_receipt + + @property + def test_stderr_bytes_drained(self) -> int: + with self._stderr_lock: + return self._stderr_bytes_drained + + @property + def test_stderr_tail(self) -> bytes: + with self._stderr_lock: + return bytes(self._stderr_tail) + + def submit_order(self, intent: CtpSubmitIntentV2) -> ActorCommandReceiptV2: + if type(intent) is not CtpSubmitIntentV2: + raise AccountActorGateError("typed_actor_intent_required") + try: + intent.__post_init__() + except (TypeError, ValueError): + raise AccountActorGateError("actor_intent_invalid") from None + wire_intent = { + "intent_id": intent.intent_id, + "instrument_id": intent.instrument_id, + "exchange_id": intent.exchange_id, + "side": intent.side, + "offset": intent.offset, + "hedge_flag": intent.hedge_flag, + "quantity": intent.quantity, + "limit_price": format(intent.limit_price, "f"), + "context": intent.context.to_payload(), + "command_digest": intent.command_digest, + } + response = self._exchange({"schema": _SCHEMA, "kind": "submit", "intent": wire_intent}) + if response.get("kind") == "rejected": + reason = response.get("reason") + mapped = { + "intent_actor_epoch_mismatch": "actor_service_stale_epoch", + "intent_session_binding_mismatch": "actor_service_stale_session", + }.get(reason, "actor_service_rejected") + raise AccountActorGateError(mapped) + if response.get("kind") != "receipt": + raise AccountActorGateError("actor_service_receipt_invalid") + receipt_data = response.get("receipt") + if type(receipt_data) is not dict: + raise AccountActorGateError("actor_service_receipt_invalid") + try: + context = ActorCommandContextV1(**receipt_data["context"]) + receipt = ActorCommandReceiptV2( + operation=receipt_data["operation"], + command_id=receipt_data["command_id"], + state=ActorCommandState[receipt_data["state"]], + context=context, + command_digest=receipt_data["command_digest"], + ) + validated = validate_actor_receipt( + receipt, + expected=ActorCommandExpectationV2.from_intent(intent), + ) + details = FakeWireReceiptDetails( + writer_epoch=receipt_data["writer_epoch"], + snapshot_version=receipt_data["snapshot_version"], + dispatch_id=receipt_data["dispatch_id"], + durable_command_digest=receipt_data["durable_command_digest"], + fake_send_id=receipt_data["fake_send_id"], + provider_acknowledged=receipt_data["provider_acknowledged"], + ) + if ( + type(details.writer_epoch) is not int + or details.writer_epoch != intent.context.actor_epoch + or type(details.snapshot_version) is not int + or details.snapshot_version != self._snapshot_version + or type(details.dispatch_id) is not int + or details.dispatch_id <= 0 + or type(details.durable_command_digest) is not str + or len(details.durable_command_digest) != 64 + or type(details.fake_send_id) is not str + or not details.fake_send_id + or details.provider_acknowledged is not False + ): + raise ValueError("fake receipt details invalid") + except (KeyError, TypeError, ValueError): + raise AccountActorGateError("actor_service_receipt_binding_invalid") from None + self._last_wire_receipt = details + return validated + + def cancel_order(self, intent: CtpCancelIntentV2) -> ActorCommandReceiptV2: + del intent + raise AccountActorGateError("fake_wire_cancel_not_implemented") + + def test_inspect_fake_sink(self) -> dict: + return self._exchange({"schema": _SCHEMA, "kind": "test_inspect_fake_sink"}) + + def test_inspect_import_boundary(self) -> dict: + return self._exchange({"schema": _SCHEMA, "kind": "test_import_boundary"}) + + def test_crash_service(self) -> dict: + return self._exchange({"schema": _SCHEMA, "kind": "test_abrupt_exit"}) + + def test_stall_output(self) -> dict: + return self._exchange({"schema": _SCHEMA, "kind": "test_stall_output"}) + + def test_stderr_burst(self, byte_count: int) -> dict: + if type(byte_count) is not int or not 65536 <= byte_count <= 4 * 1024 * 1024: + raise ValueError("stderr burst byte count out of range") + return self._exchange( + {"schema": _SCHEMA, "kind": "test_stderr_burst", "byte_count": byte_count} + ) + + def test_response_queue_payload(self, byte_count: int) -> dict: + if type(byte_count) is not int or not 1 <= byte_count <= 48 * 1024: + raise ValueError("queue payload byte count out of range") + return self._exchange( + {"schema": _SCHEMA, "kind": "test_response_queue_payload", "byte_count": byte_count} + ) + + def _exchange(self, request: dict) -> dict: + if not self._exchange_lock.acquire( + timeout=self._request_timeout_seconds + self._response_timeout_seconds + ): + raise AccountActorGateError("actor_service_request_busy") + try: + return self._exchange_locked(request) + finally: + self._exchange_lock.release() + + def _exchange_locked(self, request: dict) -> dict: + process = self._process + if self._closed or process.poll() is not None or process.stdin is None: + raise AccountActorGateError("actor_service_unavailable") + try: + request_bytes = ( + json.dumps( + request, + sort_keys=True, + separators=(",", ":"), + ensure_ascii=True, + ) + + "\n" + ).encode("utf-8") + except (TypeError, ValueError, OverflowError): + raise AccountActorGateError("actor_service_request_invalid") from None + if len(request_bytes) > _MAX_REQUEST_BYTES: + raise AccountActorGateError("actor_service_request_too_large") + + write_done = threading.Event() + write_error = [] + + def write_request() -> None: + try: + stream = process.stdin + if stream is None: + raise BrokenPipeError + remaining = memoryview(request_bytes) + while remaining: + written = stream.write(remaining) + if written is None or written <= 0: + raise BrokenPipeError + remaining = remaining[written:] + stream.flush() + except (BrokenPipeError, OSError, ValueError) as exc: + write_error.append(exc) + finally: + write_done.set() + + writer = threading.Thread( + target=write_request, + name="fake-actor-request-writer", + daemon=True, + ) + self._active_writer = writer + self._active_writer_done = write_done + writer.start() + if not write_done.wait(self._request_timeout_seconds): + self.close() + raise AccountActorGateError("actor_service_request_timeout") + writer.join(timeout=0.05) + self._active_writer = None + self._active_writer_done = None + if writer.is_alive(): + self.close() + raise AccountActorGateError("actor_service_cleanup_uncertain") + if write_error: + self.close() + raise AccountActorGateError("actor_service_unavailable") from None + return self._read_response() + + def _read_response(self) -> dict: + if self._closed: + raise AccountActorGateError("actor_service_unavailable") + try: + kind, line = self._response_lines.get(timeout=self._response_timeout_seconds) + except queue.Empty: + self.close() + raise AccountActorGateError("actor_service_response_timeout") from None + if kind == "eof": + self.close() + raise AccountActorGateError("actor_service_unavailable") + if kind == "overflow": + self.close() + raise AccountActorGateError("actor_service_response_queue_overflow") + if kind == "oversize": + self.close() + raise AccountActorGateError("actor_service_wire_invalid") + if kind == "read_error": + self.close() + raise AccountActorGateError("actor_service_unavailable") + if kind != "line" or line is None: + self.close() + raise AccountActorGateError("actor_service_wire_invalid") + try: + response = json.loads(line.decode("utf-8")) + except (UnicodeDecodeError, TypeError, ValueError): + self.close() + raise AccountActorGateError("actor_service_wire_invalid") from None + if type(response) is not dict or response.get("schema") != _SCHEMA: + self.close() + raise AccountActorGateError("actor_service_wire_invalid") + return response + + def _drain_stdout(self) -> None: + stream = self._process.stdout + if stream is None: + self._response_lines.finish("eof") + return + pending = bytearray() + try: + while True: + chunk = stream.read(4096) + if not chunk: + if pending and not self._response_lines.publish_line(bytes(pending)): + return + self._response_lines.finish("eof") + return + pending.extend(chunk) + while True: + newline_at = pending.find(b"\n") + if newline_at < 0: + break + line_size = newline_at + 1 + if line_size > _MAX_RESPONSE_LINE_BYTES: + self._response_lines.finish("oversize") + return + line = bytes(pending[:line_size]) + del pending[:line_size] + if not self._response_lines.publish_line(line): + return + if len(pending) > _MAX_RESPONSE_LINE_BYTES: + self._response_lines.finish("oversize") + return + except (OSError, ValueError): + self._response_lines.finish("read_error") + + def _drain_stderr(self) -> None: + stream = self._process.stderr + if stream is None: + return + try: + while True: + chunk = stream.read(4096) + if not chunk: + return + with self._stderr_lock: + self._stderr_bytes_drained += len(chunk) + self._stderr_tail.extend(chunk) + if len(self._stderr_tail) > _STDERR_TAIL_BYTES: + del self._stderr_tail[:-_STDERR_TAIL_BYTES] + except (OSError, ValueError): + return + + def close(self) -> None: + process = getattr(self, "_process", None) + if process is None: + return + if self._closed: + if self._cleanup_uncertain: + raise AccountActorGateError("actor_service_cleanup_uncertain") + return + self._closed = True + streams = (process.stdin, process.stdout, process.stderr) + writer = self._active_writer + writer_done = self._active_writer_done + write_in_progress = ( + writer is not None + and writer.is_alive() + and writer_done is not None + and not writer_done.is_set() + ) + forced_termination = False + cleanup_uncertain = False + try: + if process.stdin is not None and not write_in_progress: + try: + process.stdin.close() + except (OSError, ValueError): + pass + if process.poll() is None: + try: + process.wait(timeout=0.5) + except subprocess.TimeoutExpired: + forced_termination = True + try: + process.terminate() + except OSError: + cleanup_uncertain = True + try: + process.wait(timeout=0.5) + except subprocess.TimeoutExpired: + try: + process.kill() + except OSError: + cleanup_uncertain = True + try: + process.wait(timeout=0.5) + except subprocess.TimeoutExpired: + cleanup_uncertain = True + if process.poll() is None: + cleanup_uncertain = True + # This fake client has no OS job/process-group supervisor. A forced + # kill confirms neither descendant cleanup nor process-tree closure. + if forced_termination: + cleanup_uncertain = True + except (OSError, subprocess.SubprocessError, ValueError, RuntimeError): + cleanup_uncertain = True + finally: + # Close every pipe even when poll() reported an already-exited child + # or a shutdown operation raised unexpectedly. + for stream in streams: + if stream is not None: + try: + stream.close() + except (OSError, ValueError): + cleanup_uncertain = True + for reader in (writer, self._stdout_reader, self._stderr_reader): + if reader is not None and reader is not threading.current_thread(): + try: + reader.join(timeout=0.5) + except RuntimeError: + cleanup_uncertain = True + else: + if reader.is_alive(): + cleanup_uncertain = True + self._cleanup_uncertain = cleanup_uncertain + if cleanup_uncertain: + raise AccountActorGateError("actor_service_cleanup_uncertain") diff --git a/backtrader_runtime/_local_fake_account_actor_candidate/fake_actor_service.py b/backtrader_runtime/_local_fake_account_actor_candidate/fake_actor_service.py new file mode 100644 index 00000000..e3fc98f8 --- /dev/null +++ b/backtrader_runtime/_local_fake_account_actor_candidate/fake_actor_service.py @@ -0,0 +1,303 @@ +from __future__ import annotations + +import hashlib +import json +import os +import sys +import time +from decimal import Decimal +from pathlib import Path + +if __package__: + from .account_actor_port import ActorCommandContextV1, CtpSubmitIntentV2 + from .account_actor_server_core import ( + AccountActorIntentV1, + AccountActorServerCoreV1, + AccountSnapshotBundleV1, + ActorServerError, + FakeSnapshotAuthorityV1, + SnapshotDomainFactV1, + ) +else: # Imported by the fake service when it is run directly as a script. + from account_actor_port import ActorCommandContextV1, CtpSubmitIntentV2 + from account_actor_server_core import ( + AccountActorIntentV1, + AccountActorServerCoreV1, + AccountSnapshotBundleV1, + ActorServerError, + FakeSnapshotAuthorityV1, + SnapshotDomainFactV1, + ) + +_SCHEMA = "account-actor-wire.v1" +_ACCOUNT_REF = "ctp-account-ref.v1:" + hashlib.sha256(b"wire-r0-fake-account").hexdigest() +_AUTHORITY = FakeSnapshotAuthorityV1( + authority_id="wire-r0-test-only", + source_id="wire-r0-test-source", + key=b"wire-r0-test-only-key-not-a-credential-0123456789", +) + + +def _canonical(value): + return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=True) + + +def _context(epoch, session_id): + return ActorCommandContextV1( + account_ref=_ACCOUNT_REF, + runtime_id="wire-r0-runtime", + mode="simulation", + config_digest=hashlib.sha256(b"wire-r0-config").hexdigest(), + session_id=session_id, + front_id=10 + epoch, + native_session_id=20 + epoch, + session_generation=epoch, + actor_epoch=epoch, + ) + + +def _snapshot(version): + payloads = { + "funds": {"available": "100000.00", "currency": "CNY"}, + "orders": {"open_order_count": 0}, + "trades": {"trade_count": 0}, + "positions": {"position_count": 0}, + } + facts = tuple( + SnapshotDomainFactV1.from_payload( + account_ref=_ACCOUNT_REF, + snapshot_version=version, + source_id="wire-r0-test-source", + domain=domain, + payload=payloads[domain], + ) + for domain in ("funds", "orders", "trades", "positions") + ) + return AccountSnapshotBundleV1(_ACCOUNT_REF, version, "wire-r0-test-source", facts) + + +def _read_request(line): + def no_duplicate_pairs(pairs): + result = {} + for key, value in pairs: + if key in result: + raise ValueError("duplicate JSON field") + result[key] = value + return result + + value = json.loads(line, object_pairs_hook=no_duplicate_pairs) + if type(value) is not dict or value.get("schema") != _SCHEMA: + raise ValueError("wire schema invalid") + return value + + +def _submit(core, writer, request, snapshot_version, fake_sink): + wire_intent = request.get("intent") + if type(wire_intent) is not dict: + raise ValueError("typed intent payload required") + expected_keys = { + "intent_id", + "instrument_id", + "exchange_id", + "side", + "offset", + "hedge_flag", + "quantity", + "limit_price", + "context", + "command_digest", + } + if set(wire_intent) != expected_keys: + raise ValueError("intent fields invalid") + context_data = wire_intent["context"] + if type(context_data) is not dict: + raise ValueError("context payload invalid") + context = ActorCommandContextV1(**context_data) + intent = CtpSubmitIntentV2( + intent_id=wire_intent["intent_id"], + instrument_id=wire_intent["instrument_id"], + exchange_id=wire_intent["exchange_id"], + side=wire_intent["side"], + offset=wire_intent["offset"], + hedge_flag=wire_intent["hedge_flag"], + quantity=wire_intent["quantity"], + limit_price=Decimal(wire_intent["limit_price"]), + context=context, + ) + intent.__post_init__() + if wire_intent["command_digest"] != intent.command_digest: + raise ValueError("intent digest mismatch") + + payload = { + "instrument_id": intent.instrument_id, + "exchange_id": intent.exchange_id, + "side": intent.side, + "offset": intent.offset, + "hedge_flag": intent.hedge_flag, + "quantity": intent.quantity, + "limit_price": format(intent.limit_price, "f"), + } + durable_intent = AccountActorIntentV1.from_payload( + operation="SUBMIT", + intent_id=intent.intent_id, + context=intent.context, + payload=payload, + ) + core.reserve_intent( + writer, + durable_intent, + expected_snapshot_version=snapshot_version, + ) + authorization = core.authorize_dispatch( + writer, + operation="SUBMIT", + intent_id=intent.intent_id, + ) + claim = core.claim_for_dispatch(writer, authorization) + + # This sink exists only inside the child process and stands in for a native send. + fake_send_id = f"fake-send-{len(fake_sink) + 1}" + fake_sink.append( + { + "fake_send_id": fake_send_id, + "intent_id": intent.intent_id, + "client_command_digest": intent.command_digest, + "durable_command_digest": claim.command_digest, + "dispatch_id": claim.dispatch_id, + "payload": payload, + } + ) + return { + "schema": _SCHEMA, + "kind": "receipt", + "receipt": { + "operation": "SUBMIT", + "command_id": intent.intent_id, + "state": "QUEUED", + "context": context.to_payload(), + "command_digest": intent.command_digest, + "writer_epoch": claim.writer_epoch, + "snapshot_version": claim.snapshot_version, + "dispatch_id": claim.dispatch_id, + "durable_command_digest": claim.command_digest, + "fake_send_id": fake_send_id, + "provider_acknowledged": False, + }, + } + + +def main(): + database_path = str(Path(sys.argv[1]).resolve()) + core = AccountActorServerCoreV1(database_path, snapshot_authority=_AUTHORITY) + + # Simulate a service-owned revoke and takeover before exposing its current session. + stale_writer = core.claim_writer(_ACCOUNT_REF, "wire-r0-owner-old") + stale_context = _context(stale_writer.epoch, "wire-r0-session-old") + core.bind_session(stale_writer, stale_context) + core.revoke_writer(stale_writer) + + writer = core.claim_writer(_ACCOUNT_REF, "wire-r0-owner-current") + current_context = _context(writer.epoch, "wire-r0-session-current") + core.bind_session(writer, current_context) + snapshot_version = 2 + core.publish_snapshot(writer, _snapshot(snapshot_version)) + fake_sink = [] + + ready = { + "schema": _SCHEMA, + "kind": "ready", + "stale_context": stale_context.to_payload(), + "current_context": current_context.to_payload(), + "snapshot_version": snapshot_version, + } + sys.stdout.write(_canonical(ready) + "\n") + sys.stdout.flush() + + for line in sys.stdin: + try: + request = _read_request(line) + if request.get("kind") == "submit": + response = _submit(core, writer, request, snapshot_version, fake_sink) + elif request.get("kind") == "test_inspect_fake_sink": + response = { + "schema": _SCHEMA, + "kind": "test_inspection", + "send_count": len(fake_sink), + "sends": fake_sink, + } + elif request.get("kind") == "test_import_boundary": + response = { + "schema": _SCHEMA, + "kind": "test_import_boundary", + "runtime_root_loaded": "backtrader_runtime" in sys.modules, + "provider_loaded": any( + name.startswith(("bt_api_", "_ctp.")) or name == "_ctp" + for name in sys.modules + ), + } + elif request.get("kind") == "test_abrupt_exit": + os._exit(7) + elif request.get("kind") == "test_stall_output": + time.sleep(10) + continue + elif request.get("kind") == "test_stderr_burst": + byte_count = request.get("byte_count") + if type(byte_count) is not int or not 65536 <= byte_count <= 4 * 1024 * 1024: + response = { + "schema": _SCHEMA, + "kind": "rejected", + "reason": "test_byte_count_invalid", + } + else: + block = b"x" * 65536 + for _ in range(byte_count // len(block)): + sys.stderr.buffer.write(block) + remainder = byte_count % len(block) + if remainder: + sys.stderr.buffer.write(b"x" * remainder) + sys.stderr.flush() + response = { + "schema": _SCHEMA, + "kind": "test_stderr_burst_complete", + "byte_count": byte_count, + } + elif request.get("kind") == "test_response_queue_payload": + byte_count = request.get("byte_count") + if type(byte_count) is not int or not 1 <= byte_count <= 48 * 1024: + response = { + "schema": _SCHEMA, + "kind": "rejected", + "reason": "test_byte_count_invalid", + } + else: + response = { + "schema": _SCHEMA, + "kind": "test_response_queue_payload", + "padding": "x" * byte_count, + } + else: + response = { + "schema": _SCHEMA, + "kind": "rejected", + "reason": "wire_operation_unsupported", + } + except ActorServerError as exc: + response = { + "schema": _SCHEMA, + "kind": "rejected", + "reason": exc.code, + } + except (KeyError, TypeError, ValueError, ArithmeticError): + response = { + "schema": _SCHEMA, + "kind": "rejected", + "reason": "wire_intent_invalid", + } + sys.stdout.write(_canonical(response) + "\n") + sys.stdout.flush() + + core.close() + + +if __name__ == "__main__": + main() diff --git a/backtrader_runtime/_local_fake_account_actor_candidate/store_boundary_harness.py b/backtrader_runtime/_local_fake_account_actor_candidate/store_boundary_harness.py new file mode 100644 index 00000000..4651f35b --- /dev/null +++ b/backtrader_runtime/_local_fake_account_actor_candidate/store_boundary_harness.py @@ -0,0 +1,111 @@ +"""Tiny fake Store boundary. It models ordering only and never imports Backtrader.""" +from __future__ import annotations + +from collections.abc import Callable +from typing import Any, Optional + +from .account_actor_port import ( + AccountActorGateError, + ActorCommandContextV1, + ActorCommandExpectationV2, + CtpAccountActorPort, + CtpCancelIntentV2, + CtpSubmitIntentV2, + FakeLocalActorReplayLedger, + RouteKind, + StoreRouteDescriptor, + classify_store_route, + reject_ctp_legacy_dispatch, + require_account_actor_before_local_client, + validate_actor_receipt, +) + + +class StoreBoundaryHarness: + def __init__( + self, + route: StoreRouteDescriptor, + *, + actor_port: Optional[CtpAccountActorPort] = None, # noqa: UP045 - Python 3.8 + actor_context: Optional[ActorCommandContextV1] = None, # noqa: UP045 - Python 3.8 + receipt_ledger: Optional[FakeLocalActorReplayLedger] = None, # noqa: UP045 - Python 3.8 + credential_resolver: Optional[Callable[[], Any]] = None, # noqa: UP045 - Python 3.8 + api_factory: Optional[Callable[[], Any]] = None, # noqa: UP045 - Python 3.8 + gateway_factory: Optional[Callable[[], Any]] = None, # noqa: UP045 - Python 3.8 + ) -> None: + # This is the candidate integration order: gate before retaining or + # invoking any local API/API class/gateway factory. + self.route_kind = classify_store_route(route) + self.actor_port = require_account_actor_before_local_client(route, actor_port) + if self.route_kind is RouteKind.CTP: + if type(actor_context) is not ActorCommandContextV1: + raise AccountActorGateError("actor_command_context_required") + if type(receipt_ledger) is FakeLocalActorReplayLedger: + self.receipt_ledger = receipt_ledger + else: + raise AccountActorGateError("fake_replay_ledger_required") + self.actor_context = actor_context + else: + self.receipt_ledger = receipt_ledger + self.actor_context = actor_context + self.api = None + self.legacy_calls = 0 + # This ordering models the production boundary: classification and the + # unavailable-actor rejection precede credential resolution too. + if self.route_kind is RouteKind.NON_CTP and credential_resolver is not None: + credential_resolver() + if self.route_kind is RouteKind.NON_CTP: + if route.api is not None: + self.api = route.api + elif route.backend == "gateway": + self.api = gateway_factory() if gateway_factory is not None else None + elif route.api_cls is not None: + self.api = api_factory() if api_factory is not None else route.api_cls() + elif route.api is not None or route.api_cls is not None: + # The gate above rejects these for an actor-owned CTP route. + raise AccountActorGateError("local_ctp_client_injection_forbidden") + + def submit_order(self, intent: object) -> Any: + if self.route_kind is RouteKind.CTP: + if type(intent) is not CtpSubmitIntentV2: + raise AccountActorGateError("typed_submit_intent_required") + self._require_current_actor_context(intent) + expected = ActorCommandExpectationV2.from_intent(intent) + self.receipt_ledger.claim_once(intent) + receipt = self.actor_port.submit_order(intent) + return validate_actor_receipt(receipt, expected=expected) + return self._submit_order_legacy(intent) + + def cancel_order(self, intent: object) -> Any: + if self.route_kind is RouteKind.CTP: + if type(intent) is not CtpCancelIntentV2: + raise AccountActorGateError("typed_cancel_intent_required") + self._require_current_actor_context(intent) + expected = ActorCommandExpectationV2.from_intent(intent) + self.receipt_ledger.claim_once(intent) + receipt = self.actor_port.cancel_order(intent) + return validate_actor_receipt(receipt, expected=expected) + return self._cancel_order_legacy(intent) + + def _require_current_actor_context(self, intent: Any) -> None: + if type(intent.context) is not ActorCommandContextV1: + raise AccountActorGateError("actor_intent_context_invalid") + try: + intent.context.__post_init__() + intent.__post_init__() + except (AttributeError, TypeError, ValueError): + raise AccountActorGateError("actor_intent_invalid") from None + if intent.context != self.actor_context: + raise AccountActorGateError("actor_intent_context_mismatch") + + def _submit_order_legacy(self, value: object) -> str: + del value + reject_ctp_legacy_dispatch(self.route_kind) + self.legacy_calls += 1 + return "legacy_non_ctp" + + def _cancel_order_legacy(self, value: object) -> str: + del value + reject_ctp_legacy_dispatch(self.route_kind) + self.legacy_calls += 1 + return "legacy_non_ctp" diff --git a/backtrader_runtime/_local_fake_actionref_checkpoint_candidate.py b/backtrader_runtime/_local_fake_actionref_checkpoint_candidate.py new file mode 100644 index 00000000..44ed7238 --- /dev/null +++ b/backtrader_runtime/_local_fake_actionref_checkpoint_candidate.py @@ -0,0 +1,234 @@ +"""Local-only durable checkpoint candidate for ActionRef ledger snapshots. + +This wrapper detects accidental snapshot rollback across processes when the +same SQLite file is retained. The file is locally writable, source digests are +not authenticated, and this module is not registered or suitable as an +account-wide authority or native-floor producer. +""" + +from __future__ import annotations + +import json +import sqlite3 +from pathlib import Path +from typing import Any, Optional + +from .ctp_managed_actionref_floor import ( + CtpManagedActionRefAllocationV1, + CtpManagedActionRefLedgerSnapshotV1, +) + + +class CtpLocalActionRefCheckpointError(RuntimeError): + """Redacted fail-closed result from the local fake checkpoint candidate.""" + + def __init__(self, code: str) -> None: + self.code = code + super().__init__("local ActionRef snapshot checkpoint is unavailable") + + +def _reject(code: str) -> None: + raise CtpLocalActionRefCheckpointError(code) + + +class CtpLocalActionRefCheckpointSource: + """Persist account-keyed snapshot high-water state for local diagnostics.""" + + def __init__(self, *, source: Any, database_path: str | Path) -> None: + if not callable(getattr(source, "read_action_ref_ledger", None)): + _reject("source_unavailable") + path = Path(database_path) + if not path.is_absolute() or str(path) == ":memory:": + _reject("absolute_persistent_path_required") + if not path.parent.is_dir(): + _reject("checkpoint_parent_unavailable") + self._source = source + self._database_path = path + + def read_action_ref_ledger( + self, account_key: str + ) -> Optional[CtpManagedActionRefLedgerSnapshotV1]: + """Read and durably checkpoint one exact account snapshot or fail closed.""" + + try: + snapshot = self._source.read_action_ref_ledger(account_key) + except Exception: + _reject("source_read_failed") + if ( + type(snapshot) is not CtpManagedActionRefLedgerSnapshotV1 + or snapshot.account_key != account_key + ): + _reject("snapshot_unavailable_or_account_mismatch") + self._checkpoint(snapshot) + return snapshot + + def _checkpoint(self, snapshot: CtpManagedActionRefLedgerSnapshotV1) -> None: + document = _encode_snapshot(snapshot) + try: + connection = sqlite3.connect( + str(self._database_path), timeout=5.0, isolation_level=None + ) + except sqlite3.Error: + _reject("checkpoint_open_failed") + try: + connection.execute("PRAGMA busy_timeout = 5000") + connection.execute("PRAGMA synchronous = FULL") + connection.execute("BEGIN IMMEDIATE") + version = connection.execute("PRAGMA user_version").fetchone()[0] + if version not in (0, 1): + _reject("checkpoint_schema_unsupported") + connection.execute( + "CREATE TABLE IF NOT EXISTS actionref_snapshot_checkpoint (" + "account_key TEXT PRIMARY KEY, ledger_epoch INTEGER NOT NULL, " + "snapshot_digest TEXT NOT NULL, snapshot_json TEXT NOT NULL)" + ) + if version == 0: + connection.execute("PRAGMA user_version = 1") + row = connection.execute( + "SELECT ledger_epoch, snapshot_digest, snapshot_json " + "FROM actionref_snapshot_checkpoint WHERE account_key = ?", + (snapshot.account_key,), + ).fetchone() + if row is not None: + prior = _decode_snapshot(row[2]) + if row[0] != prior.ledger_epoch or row[1] != prior.digest: + _reject("checkpoint_row_binding_mismatch") + _require_monotonic(prior, snapshot) + if row is None or row[1] != snapshot.digest: + connection.execute( + "INSERT INTO actionref_snapshot_checkpoint " + "(account_key, ledger_epoch, snapshot_digest, snapshot_json) " + "VALUES (?, ?, ?, ?) ON CONFLICT(account_key) DO UPDATE SET " + "ledger_epoch=excluded.ledger_epoch, " + "snapshot_digest=excluded.snapshot_digest, " + "snapshot_json=excluded.snapshot_json", + ( + snapshot.account_key, + snapshot.ledger_epoch, + snapshot.digest, + document, + ), + ) + connection.execute("COMMIT") + except CtpLocalActionRefCheckpointError: + try: + connection.execute("ROLLBACK") + except sqlite3.Error: + pass + raise + except Exception: + try: + connection.execute("ROLLBACK") + except sqlite3.Error: + pass + _reject("checkpoint_transaction_failed") + finally: + connection.close() + + +def _encode_snapshot(snapshot: CtpManagedActionRefLedgerSnapshotV1) -> str: + document = { + "snapshot_payload": snapshot.to_payload(), + "allocations": [row.to_payload() for row in snapshot.allocations], + "digest": snapshot.digest, + } + try: + return json.dumps( + document, + sort_keys=True, + separators=(",", ":"), + ensure_ascii=True, + allow_nan=False, + ) + except (TypeError, ValueError): + _reject("snapshot_encoding_failed") + + +def _decode_snapshot(encoded: str) -> CtpManagedActionRefLedgerSnapshotV1: + try: + document = json.loads(encoded) + if type(document) is not dict or set(document) != { + "snapshot_payload", + "allocations", + "digest", + }: + _reject("checkpoint_snapshot_corrupt") + payload = document["snapshot_payload"] + if ( + type(payload) is not dict + or payload.get("schema") != "ctp.managed.actionref.account-ledger.v1" + ): + _reject("checkpoint_snapshot_corrupt") + raw_allocations = document["allocations"] + if type(raw_allocations) is not list: + _reject("checkpoint_snapshot_corrupt") + allocations = tuple(CtpManagedActionRefAllocationV1(**row) for row in raw_allocations) + if payload.get("allocation_count") != len(allocations): + _reject("checkpoint_snapshot_corrupt") + snapshot = CtpManagedActionRefLedgerSnapshotV1( + account_key=payload["account_key"], + cutover_id=payload["cutover_id"], + cutover_floor=payload["cutover_floor"], + observed_native_high_water=payload["observed_native_high_water"], + counter_high_water=payload["counter_high_water"], + ledger_epoch=payload["ledger_epoch"], + ledger_sources=tuple(payload["ledger_sources"]), + allocations=allocations, + unresolved_unknown_count=payload["unresolved_unknown_count"], + observed_at_ns=payload["observed_at_ns"], + valid_until_ns=payload["valid_until_ns"], + native_floor_source_digest_sha256=payload["native_floor_source_digest_sha256"], + merged_ledger_source_digest_sha256=payload["merged_ledger_source_digest_sha256"], + mapping_sha256=payload["mapping_sha256"], + ) + if document["digest"] != snapshot.digest: + _reject("checkpoint_snapshot_digest_mismatch") + return snapshot + except CtpLocalActionRefCheckpointError: + raise + except Exception: + _reject("checkpoint_snapshot_corrupt") + + +def _require_monotonic( + previous: CtpManagedActionRefLedgerSnapshotV1, + current: CtpManagedActionRefLedgerSnapshotV1, +) -> None: + if current.ledger_epoch < previous.ledger_epoch: + _reject("snapshot_epoch_regressed") + if current.ledger_epoch == previous.ledger_epoch: + if current.digest != previous.digest: + _reject("snapshot_same_epoch_changed") + return + if current.cutover_id != previous.cutover_id or current.cutover_floor != previous.cutover_floor: + _reject("snapshot_cutover_changed_without_transition_proof") + if ( + current.observed_native_high_water < previous.observed_native_high_water + or current.counter_high_water < previous.counter_high_water + ): + _reject("snapshot_high_water_regressed") + + previous_by_ref = {row.native_action_ref: row for row in previous.allocations} + current_by_ref = {row.native_action_ref: row for row in current.allocations} + status_rank = {"READY": 0, "CLAIMED": 1, "COMPLETED": 2} + for action_ref, old_row in previous_by_ref.items(): + new_row = current_by_ref.get(action_ref) + if new_row is None or ( + new_row.account_key, + new_row.command_id, + new_row.scope_key, + new_row.managed_action_id, + ) != ( + old_row.account_key, + old_row.command_id, + old_row.scope_key, + old_row.managed_action_id, + ): + _reject("snapshot_allocation_identity_changed") + if status_rank.get(new_row.status, -1) < status_rank.get(old_row.status, -1): + _reject("snapshot_allocation_status_regressed") + if any( + action_ref not in previous_by_ref and action_ref <= previous.counter_high_water + for action_ref in current_by_ref + ): + _reject("snapshot_allocation_reused_below_counter") diff --git a/backtrader_runtime/ctp_managed_actionref_floor.py b/backtrader_runtime/ctp_managed_actionref_floor.py new file mode 100644 index 00000000..01fd84c4 --- /dev/null +++ b/backtrader_runtime/ctp_managed_actionref_floor.py @@ -0,0 +1,306 @@ +"""Account-wide ActionRef snapshot contract for managed CTP cancellation. + +This module defines a fail-closed consumer contract only. The repository does +not provide a trusted producer for native ActionRef floors or the merged G5 / +V21 history. Source digest fields are shape-checked only; they are not signed or +authenticated here. An absent producer cannot authorize a cancellation. +""" + +from __future__ import annotations + +import hashlib +import json +import re +from dataclasses import dataclass +from typing import Any, Optional, Protocol, Tuple + + +_MAX_NATIVE_ACTION_REF = 2_147_483_647 +_MAX_SNAPSHOT_AGE_NS = 250_000_000 +_MAX_ALLOCATIONS = 100_000 +_ACCOUNT_KEY = re.compile(r"^account:[0-9a-f]{64}$", re.ASCII) +_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$", re.ASCII) +_HEX_64 = re.compile(r"^[0-9a-f]{64}$", re.ASCII) +_LEDGER_SOURCES = ("g5", "v21") +_KNOWN_STATES = frozenset({"READY", "CLAIMED", "COMPLETED", "UNKNOWN"}) + + +class CtpManagedActionRefLedgerError(RuntimeError): + """Redacted fail-closed result from ActionRef snapshot validation.""" + + def __init__(self, code: str) -> None: + self.code = code + super().__init__("managed CTP ActionRef ledger snapshot is unavailable") + + +def _reject(code: str) -> None: + raise CtpManagedActionRefLedgerError(code) + + +def _digest(value: Any) -> str: + try: + payload = json.dumps( + value, + sort_keys=True, + separators=(",", ":"), + ensure_ascii=True, + allow_nan=False, + ).encode("ascii") + except (TypeError, ValueError, UnicodeEncodeError): + _reject("snapshot_payload_invalid") + return hashlib.sha256(payload).hexdigest() + + +def ctp_managed_action_ref_mapping_sha256( + allocations: Tuple["CtpManagedActionRefAllocationV1", ...], +) -> str: + """Hash a canonical, sorted full-account mapping without one giant JSON blob.""" + + if ( + type(allocations) is not tuple + or len(allocations) > _MAX_ALLOCATIONS + or any(type(row) is not CtpManagedActionRefAllocationV1 for row in allocations) + ): + _reject("snapshot_allocations_invalid") + digest = hashlib.sha256(b"ctp.managed.actionref.mapping.v1\0") + for row in sorted( + allocations, key=lambda item: (item.native_action_ref, item.command_id, item.scope_key) + ): + encoded = json.dumps( + row.to_payload(), + sort_keys=True, + separators=(",", ":"), + ensure_ascii=True, + allow_nan=False, + ).encode("ascii") + digest.update(len(encoded).to_bytes(8, "big")) + digest.update(encoded) + return digest.hexdigest() + + +def _require_id(value: Any, code: str) -> str: + if type(value) is not str or _ID.fullmatch(value) is None: + _reject(code) + return value + + +@dataclass(frozen=True) +class CtpManagedActionRefAllocationV1: + """One account-scoped durable native ActionRef mapping.""" + + account_key: str + native_action_ref: int + command_id: str + scope_key: str + managed_action_id: str + status: str + + def __post_init__(self) -> None: + if type(self.account_key) is not str or _ACCOUNT_KEY.fullmatch(self.account_key) is None: + _reject("allocation_account_invalid") + if ( + type(self.native_action_ref) is not int + or not 1 <= self.native_action_ref <= _MAX_NATIVE_ACTION_REF + ): + _reject("allocation_action_ref_invalid") + _require_id(self.command_id, "allocation_command_invalid") + _require_id(self.scope_key, "allocation_scope_invalid") + _require_id(self.managed_action_id, "allocation_identity_invalid") + if type(self.status) is not str or self.status not in _KNOWN_STATES: + _reject("allocation_state_invalid") + + def to_payload(self) -> dict[str, Any]: + return { + "account_key": self.account_key, + "native_action_ref": self.native_action_ref, + "command_id": self.command_id, + "scope_key": self.scope_key, + "managed_action_id": self.managed_action_id, + "status": self.status, + } + + +@dataclass(frozen=True) +class CtpManagedActionRefLedgerSnapshotV1: + """Short-lived account-wide cutover, native-floor, and allocation claim. + + ``ledger_sources`` must identify both the historical G5 allocation history + and the V21 Store ledger. ``cutover_floor`` claims the native + MaxOrderActionRef observed at the quiesced cutover. ``observed_native_high_water`` + claims the freshest native high-water observation available to the source. + This DTO checks internal consistency but does not authenticate either + claim, the source digests, or the account-wide cutover. + """ + + account_key: str + cutover_id: str + cutover_floor: int + observed_native_high_water: int + counter_high_water: int + ledger_epoch: int + ledger_sources: Tuple[str, ...] + allocations: Tuple[CtpManagedActionRefAllocationV1, ...] + unresolved_unknown_count: int + observed_at_ns: int + valid_until_ns: int + native_floor_source_digest_sha256: str + merged_ledger_source_digest_sha256: str + mapping_sha256: str + + def __post_init__(self) -> None: + if type(self.account_key) is not str or _ACCOUNT_KEY.fullmatch(self.account_key) is None: + _reject("snapshot_account_invalid") + _require_id(self.cutover_id, "snapshot_cutover_invalid") + for value in ( + self.cutover_floor, + self.observed_native_high_water, + self.counter_high_water, + ): + if type(value) is not int or not 0 <= value <= _MAX_NATIVE_ACTION_REF: + _reject("snapshot_high_water_invalid") + if ( + self.observed_native_high_water < self.cutover_floor + or self.counter_high_water < self.cutover_floor + ): + _reject("snapshot_floor_regressed") + if type(self.ledger_epoch) is not int or self.ledger_epoch <= 0: + _reject("snapshot_epoch_invalid") + if type(self.ledger_sources) is not tuple or self.ledger_sources != _LEDGER_SOURCES: + _reject("snapshot_sources_incomplete") + if ( + type(self.allocations) is not tuple + or len(self.allocations) > _MAX_ALLOCATIONS + or any(type(row) is not CtpManagedActionRefAllocationV1 for row in self.allocations) + ): + _reject("snapshot_allocations_invalid") + if type(self.unresolved_unknown_count) is not int or self.unresolved_unknown_count < 0: + _reject("snapshot_unknown_count_invalid") + for value in (self.observed_at_ns, self.valid_until_ns): + if type(value) is not int or value <= 0: + _reject("snapshot_time_invalid") + if self.valid_until_ns <= self.observed_at_ns: + _reject("snapshot_lifetime_invalid") + for digest in ( + self.native_floor_source_digest_sha256, + self.merged_ledger_source_digest_sha256, + self.mapping_sha256, + ): + if type(digest) is not str or _HEX_64.fullmatch(digest) is None: + _reject("snapshot_digest_invalid") + if self.mapping_sha256 != ctp_managed_action_ref_mapping_sha256(self.allocations): + _reject("snapshot_mapping_digest_mismatch") + + def to_payload(self) -> dict[str, Any]: + return { + "schema": "ctp.managed.actionref.account-ledger.v1", + "account_key": self.account_key, + "cutover_id": self.cutover_id, + "cutover_floor": self.cutover_floor, + "observed_native_high_water": self.observed_native_high_water, + "counter_high_water": self.counter_high_water, + "ledger_epoch": self.ledger_epoch, + "ledger_sources": list(self.ledger_sources), + "allocation_count": len(self.allocations), + "unresolved_unknown_count": self.unresolved_unknown_count, + "observed_at_ns": self.observed_at_ns, + "valid_until_ns": self.valid_until_ns, + "native_floor_source_digest_sha256": self.native_floor_source_digest_sha256, + "merged_ledger_source_digest_sha256": self.merged_ledger_source_digest_sha256, + "mapping_sha256": self.mapping_sha256, + } + + @property + def digest(self) -> str: + return _digest(self.to_payload()) + + @property + def fresh_until_ns(self) -> int: + return min(self.valid_until_ns, self.observed_at_ns + _MAX_SNAPSHOT_AGE_NS) + + def require_current_cancel( + self, + *, + now_ns: int, + account_key: str, + command_id: str, + scope_key: str, + managed_action_id: str, + native_action_ref: int, + expected_status: str, + ) -> None: + """Require a fresh, internally consistent current CANCEL allocation.""" + + if ( + type(now_ns) is not int + or now_ns <= 0 + or self.observed_at_ns > now_ns + or now_ns - self.observed_at_ns > _MAX_SNAPSHOT_AGE_NS + or self.valid_until_ns <= now_ns + ): + _reject("snapshot_stale") + if type(expected_status) is not str or expected_status not in {"READY", "CLAIMED"}: + _reject("current_action_status_invalid") + if type(account_key) is not str or self.account_key != account_key: + _reject("snapshot_account_mismatch") + if self.unresolved_unknown_count != 0: + _reject("account_unknown_actions_present") + if self.counter_high_water < self.observed_native_high_water: + _reject("counter_below_native_floor") + + refs: set[int] = set() + commands: set[str] = set() + action_ids: set[tuple[str, str]] = set() + matched: list[CtpManagedActionRefAllocationV1] = [] + max_allocated = self.cutover_floor + for row in self.allocations: + if row.account_key != self.account_key: + _reject("allocation_account_mismatch") + if row.native_action_ref <= self.cutover_floor: + _reject("allocation_below_cutover_floor") + if ( + row.native_action_ref in refs + or row.command_id in commands + or (row.scope_key, row.managed_action_id) in action_ids + ): + _reject("account_action_mapping_conflict") + refs.add(row.native_action_ref) + commands.add(row.command_id) + action_ids.add((row.scope_key, row.managed_action_id)) + max_allocated = max(max_allocated, row.native_action_ref) + if row.status == "UNKNOWN": + _reject("account_unknown_actions_present") + if row.status == "CLAIMED" and row.command_id != command_id: + _reject("account_claim_inflight") + if row.command_id == command_id: + matched.append(row) + if self.counter_high_water != max_allocated: + _reject("account_counter_mapping_mismatch") + if len(matched) != 1: + _reject("current_action_mapping_missing_or_duplicate") + current = matched[0] + if ( + current.scope_key != scope_key + or current.managed_action_id != managed_action_id + or current.native_action_ref != native_action_ref + or current.status != expected_status + ): + _reject("current_action_mapping_mismatch") + if ( + type(native_action_ref) is not int + or native_action_ref <= self.observed_native_high_water + ): + _reject("current_action_ref_not_above_native_floor") + + +class CtpManagedActionRefLedgerSource(Protocol): + """Expected deployment port for an account-wide ActionRef snapshot. + + The consumer does not authenticate an implementation of this protocol. + Only a separately reviewed, authenticated producer can satisfy the trust + assumptions; this repository currently provides none. + """ + + def read_action_ref_ledger( + self, account_key: str + ) -> Optional[CtpManagedActionRefLedgerSnapshotV1]: + """Return fresh claimed floor/history data, or no snapshot.""" diff --git a/tests/unit/runtime/test_ctp_managed_actionref_floor.py b/tests/unit/runtime/test_ctp_managed_actionref_floor.py new file mode 100644 index 00000000..a9369830 --- /dev/null +++ b/tests/unit/runtime/test_ctp_managed_actionref_floor.py @@ -0,0 +1,172 @@ +"""Offline contracts for the account-wide CTP ActionRef admission snapshot.""" + +from __future__ import annotations + +from dataclasses import replace + +import pytest + +from backtrader_runtime import ctp_managed_actionref_floor as actionref_floor + + +NOW = 1_800_000_000_000_000_000 +ACCOUNT = "account:" + "a" * 64 +OTHER_ACCOUNT = "account:" + "b" * 64 +SCOPE = "scope:" + "c" * 64 +ACTION = "action.cancel.1" +COMMAND = "command.cancel.1" + + +def _row( + *, + action_ref: int = 17, + command_id: str = COMMAND, + scope_key: str = SCOPE, + managed_action_id: str = ACTION, + status: str = "READY", + account_key: str = ACCOUNT, +) -> actionref_floor.CtpManagedActionRefAllocationV1: + return actionref_floor.CtpManagedActionRefAllocationV1( + account_key=account_key, + native_action_ref=action_ref, + command_id=command_id, + scope_key=scope_key, + managed_action_id=managed_action_id, + status=status, + ) + + +def _snapshot( + rows: tuple, + *, + counter: int | None = None, + observed_native_high_water: int = 16, + unresolved_unknown_count: int = 0, + observed_at_ns: int = NOW - 1_000_000, + sources: tuple = ("g5", "v21"), +) -> actionref_floor.CtpManagedActionRefLedgerSnapshotV1: + return actionref_floor.CtpManagedActionRefLedgerSnapshotV1( + account_key=ACCOUNT, + cutover_id="cutover.account.20260928", + cutover_floor=12, + observed_native_high_water=observed_native_high_water, + counter_high_water=max((row.native_action_ref for row in rows), default=12) + if counter is None + else counter, + ledger_epoch=8, + ledger_sources=sources, + allocations=rows, + unresolved_unknown_count=unresolved_unknown_count, + observed_at_ns=observed_at_ns, + valid_until_ns=NOW + 1_000_000_000, + native_floor_source_digest_sha256="d" * 64, + merged_ledger_source_digest_sha256="e" * 64, + mapping_sha256=actionref_floor.ctp_managed_action_ref_mapping_sha256(rows), + ) + + +def _require(snapshot, *, action_ref: int = 17, command_id: str = COMMAND) -> None: + snapshot.require_current_cancel( + now_ns=NOW, + account_key=ACCOUNT, + command_id=command_id, + scope_key=SCOPE, + managed_action_id=ACTION, + native_action_ref=action_ref, + expected_status="READY", + ) + + +def test_account_wide_snapshot_checks_merged_map_counter_floor_and_current_cancel(): + prior = _row( + action_ref=16, + command_id="command.cancel.prior", + managed_action_id="action.cancel.prior", + status="COMPLETED", + ) + current = _row(action_ref=17) + snapshot = _snapshot((current, prior), counter=17) + + _require(snapshot) + + assert snapshot.ledger_sources == ("g5", "v21") + assert snapshot.fresh_until_ns == NOW + 249_000_000 + assert len(snapshot.digest) == 64 + + +@pytest.mark.parametrize( + ("rows", "counter", "native_high_water", "unknown_count", "code"), + [ + ( + (_row(action_ref=17), _row(action_ref=17, command_id="command.other")), + 17, + 16, + 0, + "account_action_mapping_conflict", + ), + ((_row(action_ref=17, status="UNKNOWN"),), 17, 16, 1, "account_unknown_actions_present"), + ((_row(action_ref=17),), 18, 16, 0, "account_counter_mapping_mismatch"), + ((_row(action_ref=17),), 17, 18, 0, "counter_below_native_floor"), + ], +) +def test_account_conflict_unknown_or_floor_mismatch_fails_closed( + rows, counter, native_high_water, unknown_count, code +): + snapshot = _snapshot( + rows, + counter=counter, + observed_native_high_water=native_high_water, + unresolved_unknown_count=unknown_count, + ) + + with pytest.raises(actionref_floor.CtpManagedActionRefLedgerError) as error: + _require(snapshot) + + assert error.value.code == code + + +def test_stale_or_expired_snapshot_does_not_authorize_cancel(): + stale = _snapshot((_row(),), observed_at_ns=NOW - 251_000_000) + expired = replace(stale, observed_at_ns=NOW - 10, valid_until_ns=NOW) + + for snapshot in (stale, expired): + with pytest.raises(actionref_floor.CtpManagedActionRefLedgerError) as error: + _require(snapshot) + assert error.value.code == "snapshot_stale" + + +def test_action_mapping_must_be_account_local_and_match_exact_current_target(): + wrong_account = _snapshot((_row(account_key=OTHER_ACCOUNT),)) + with pytest.raises(actionref_floor.CtpManagedActionRefLedgerError) as error: + _require(wrong_account) + assert error.value.code == "allocation_account_mismatch" + + missing = _snapshot((_row(command_id="command.other"),)) + with pytest.raises(actionref_floor.CtpManagedActionRefLedgerError) as error: + _require(missing) + assert error.value.code == "current_action_mapping_missing_or_duplicate" + + wrong_target = _snapshot((_row(managed_action_id="action.other"),)) + with pytest.raises(actionref_floor.CtpManagedActionRefLedgerError) as error: + _require(wrong_target) + assert error.value.code == "current_action_mapping_mismatch" + + +def test_current_ref_must_be_above_trusted_native_high_water(): + snapshot = _snapshot((_row(action_ref=17),), observed_native_high_water=17) + + with pytest.raises(actionref_floor.CtpManagedActionRefLedgerError) as error: + _require(snapshot) + + assert error.value.code == "current_action_ref_not_above_native_floor" + + +def test_incomplete_ledger_sources_and_tampered_mapping_digest_are_rejected(): + with pytest.raises(actionref_floor.CtpManagedActionRefLedgerError) as error: + _snapshot((_row(),), sources=("v21",)) + assert error.value.code == "snapshot_sources_incomplete" + + snapshot = _snapshot((_row(),)) + with pytest.raises(actionref_floor.CtpManagedActionRefLedgerError) as error: + replace(snapshot, mapping_sha256="f" * 64) + assert error.value.code == "snapshot_mapping_digest_mismatch" diff --git a/tests/unit/runtime/test_g6p_local_actor_candidate_isolation.py b/tests/unit/runtime/test_g6p_local_actor_candidate_isolation.py new file mode 100644 index 00000000..ca46c457 --- /dev/null +++ b/tests/unit/runtime/test_g6p_local_actor_candidate_isolation.py @@ -0,0 +1,41 @@ +"""Keep the local fake AccountActor candidate outside production imports. + +This is a source-level non-registration guard only. It does not establish +writer exclusion, provider authority, account-state completeness, or G6-P. +""" + +from pathlib import Path + + +_FORBIDDEN_REFERENCES = ( + "_local_fake_account_actor_candidate", + "fake_actor_service", +) + + +def test_production_sources_do_not_reference_the_local_fake_actor_candidate(): + repository_root = Path(__file__).resolve().parents[3] + source_roots = ( + repository_root / "backtrader_runtime", + repository_root / "backtrader", + ) + candidate_directory = "_local_fake_account_actor_candidate" + + references = [] + for source_root in source_roots: + for source_path in source_root.rglob("*.py"): + relative_parts = source_path.relative_to(source_root).parts + if candidate_directory in relative_parts or "__pycache__" in relative_parts: + continue + try: + source = source_path.read_text(encoding="utf-8") + except (OSError, UnicodeError) as error: + references.append(f"{source_path}: source unreadable ({type(error).__name__})") + continue + for forbidden in _FORBIDDEN_REFERENCES: + if forbidden in source: + references.append(f"{source_path}: contains {forbidden!r}") + + assert not references, "local fake Actor referenced by production source:\n" + "\n".join( + references + ) diff --git a/tests/unit/runtime/test_local_fake_account_actor_candidate.py b/tests/unit/runtime/test_local_fake_account_actor_candidate.py new file mode 100644 index 00000000..9c482fa1 --- /dev/null +++ b/tests/unit/runtime/test_local_fake_account_actor_candidate.py @@ -0,0 +1,277 @@ +from __future__ import annotations + +import json +import os +import subprocess +import sys +import tempfile +import threading +import time +import unittest +from decimal import Decimal +from pathlib import Path + +from backtrader_runtime._local_fake_account_actor_candidate.account_actor_port import ( + AccountActorGateError, + ActorCommandState, + CtpSubmitIntentV2, +) +from backtrader_runtime._local_fake_account_actor_candidate.fake_actor_client import ( + SubprocessFakeActorPort, + _BoundedResponseMailbox, +) + + +class SubprocessActorWireContractTests(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory(prefix="actor-wire-r1-") + self.port = self._new_port("service.sqlite3") + + def tearDown(self): + try: + self.port.close() + except AccountActorGateError as exc: + if exc.code != "actor_service_cleanup_uncertain": + raise + self.temp.cleanup() + + def _new_port( + self, + db_name, + response_timeout_seconds=2.0, + request_timeout_seconds=2.0, + response_queue_max_lines=16, + response_queue_max_bytes=512 * 1024, + ): + return SubprocessFakeActorPort( + str(Path(self.temp.name) / db_name), + response_timeout_seconds=response_timeout_seconds, + request_timeout_seconds=request_timeout_seconds, + response_queue_max_lines=response_queue_max_lines, + response_queue_max_bytes=response_queue_max_bytes, + ) + + def _intent(self, context, intent_id): + return CtpSubmitIntentV2( + intent_id=intent_id, + instrument_id="IF2612", + exchange_id="CFFEX", + side="BUY", + offset="OPEN", + hedge_flag="SPECULATION", + quantity=1, + limit_price=Decimal("3500.0"), + context=context, + ) + + def _assert_all_pipe_handles_closed(self, port): + process = port._process + self.assertIsNotNone(process.stdin) + self.assertIsNotNone(process.stdout) + self.assertIsNotNone(process.stderr) + self.assertTrue(process.stdin.closed) + self.assertTrue(process.stdout.closed) + self.assertTrue(process.stderr.closed) + self.assertFalse(port._stdout_reader.is_alive()) + self.assertFalse(port._stderr_reader.is_alive()) + + def test_stale_epoch_rejects_before_fake_send_then_exact_current_receipt(self): + self.assertNotEqual(self.port._process.pid, os.getpid()) + boundary = self.port.test_inspect_import_boundary() + self.assertFalse(boundary["runtime_root_loaded"]) + self.assertFalse(boundary["provider_loaded"]) + + stale = self._intent(self.port.test_stale_context, "stale-intent-1") + with self.assertRaises(AccountActorGateError) as caught: + self.port.submit_order(stale) + self.assertEqual(caught.exception.code, "actor_service_stale_epoch") + + inspection = self.port.test_inspect_fake_sink() + self.assertEqual(inspection["kind"], "test_inspection") + self.assertEqual(inspection["send_count"], 0) + self.assertEqual(inspection["sends"], []) + + current = self._intent(self.port.test_current_context, "current-intent-1") + receipt = self.port.submit_order(current) + self.assertEqual(receipt.operation, "SUBMIT") + self.assertEqual(receipt.command_id, current.intent_id) + self.assertEqual(receipt.context, current.context) + self.assertEqual(receipt.command_digest, current.command_digest) + self.assertIs(receipt.state, ActorCommandState.QUEUED) + + details = self.port.test_last_wire_receipt + self.assertIsNotNone(details) + self.assertEqual(details.writer_epoch, current.context.actor_epoch) + self.assertEqual(details.snapshot_version, self.port.test_snapshot_version) + self.assertEqual(details.dispatch_id, 1) + self.assertEqual(details.fake_send_id, "fake-send-1") + self.assertIs(details.provider_acknowledged, False) + self.assertRegex(details.durable_command_digest, r"^[0-9a-f]{64}$") + + inspection = self.port.test_inspect_fake_sink() + self.assertEqual(inspection["send_count"], 1) + self.assertEqual(len(inspection["sends"]), 1) + sent = inspection["sends"][0] + self.assertEqual(sent["fake_send_id"], details.fake_send_id) + self.assertEqual(sent["intent_id"], current.intent_id) + self.assertEqual(sent["client_command_digest"], current.command_digest) + self.assertEqual(sent["durable_command_digest"], details.durable_command_digest) + self.assertEqual(sent["dispatch_id"], details.dispatch_id) + self.assertEqual(sent["payload"]["instrument_id"], current.instrument_id) + self.assertEqual(sent["payload"]["limit_price"], "3500.0") + + def test_abrupt_child_exit_reports_unavailable_and_closes_all_pipes(self): + with self.assertRaises(AccountActorGateError) as caught: + self.port.test_crash_service() + self.assertEqual(caught.exception.code, "actor_service_unavailable") + self.assertEqual(self.port._process.poll(), 7) + self._assert_all_pipe_handles_closed(self.port) + + def test_stalled_response_times_out_and_terminates_child(self): + # Keep startup on the normal bound, then shorten only the test response + # wait so READY initialization stays independent of host load. + self.port._response_timeout_seconds = 0.2 + + started = time.monotonic() + with self.assertRaises(AccountActorGateError) as caught: + self.port.test_stall_output() + elapsed = time.monotonic() - started + + self.assertEqual(caught.exception.code, "actor_service_cleanup_uncertain") + self.assertLess(elapsed, 2.0) + self.assertIsNotNone(self.port._process.poll()) + self._assert_all_pipe_handles_closed(self.port) + + + def test_large_stderr_burst_is_drained_while_response_is_read(self): + byte_count = 1024 * 1024 + response = self.port.test_stderr_burst(byte_count) + + self.assertEqual(response["kind"], "test_stderr_burst_complete") + self.assertEqual(response["byte_count"], byte_count) + deadline = time.monotonic() + 1.0 + while self.port.test_stderr_bytes_drained < byte_count and time.monotonic() < deadline: + time.sleep(0.01) + self.assertEqual(self.port.test_stderr_bytes_drained, byte_count) + self.assertEqual(len(self.port.test_stderr_tail), 8 * 1024) + + def test_response_mailbox_enforces_line_and_byte_bounds(self): + line_bounded = _BoundedResponseMailbox(max_lines=2, max_bytes=100) + self.assertTrue(line_bounded.publish_line(b"a")) + self.assertTrue(line_bounded.publish_line(b"b")) + self.assertFalse(line_bounded.publish_line(b"c")) + self.assertLessEqual(len(line_bounded._lines), 2) + self.assertLessEqual(line_bounded._queued_bytes, 100) + self.assertEqual(line_bounded.get(0.1), ("overflow", None)) + + byte_bounded = _BoundedResponseMailbox(max_lines=8, max_bytes=3) + self.assertTrue(byte_bounded.publish_line(b"ab")) + self.assertFalse(byte_bounded.publish_line(b"cd")) + self.assertLessEqual(len(byte_bounded._lines), 8) + self.assertLessEqual(byte_bounded._queued_bytes, 3) + self.assertEqual(byte_bounded.get(0.1), ("overflow", None)) + + def test_response_byte_limit_fails_closed_on_child_payload(self): + self.port.close() + self.port = self._new_port( + "response-queue.sqlite3", + response_queue_max_bytes=4096, + ) + + with self.assertRaises(AccountActorGateError) as caught: + self.port.test_response_queue_payload(8192) + + self.assertEqual(caught.exception.code, "actor_service_response_queue_overflow") + self._assert_all_pipe_handles_closed(self.port) + + def test_oversized_request_rejects_before_pipe_write(self): + with self.assertRaises(AccountActorGateError) as caught: + self.port._exchange( + { + "schema": "account-actor-wire.v1", + "kind": "test_oversized_request", + "padding": "x" * (64 * 1024), + } + ) + + self.assertEqual(caught.exception.code, "actor_service_request_too_large") + self.assertEqual(self.port.test_inspect_fake_sink()["send_count"], 0) + + def test_blocked_request_write_times_out_and_reports_cleanup_uncertain(self): + class BlockingStdin: + def __init__(self, wrapped): + self._wrapped = wrapped + self._release = threading.Event() + + @property + def closed(self): + return self._wrapped.closed + + def write(self, _data): + self._release.wait() + raise BrokenPipeError + + def flush(self): + return None + + def close(self): + self._release.set() + self._wrapped.close() + + self.port._request_timeout_seconds = 0.2 + self.port._process.stdin = BlockingStdin(self.port._process.stdin) + started = time.monotonic() + + with self.assertRaises(AccountActorGateError) as caught: + self.port.test_inspect_fake_sink() + + elapsed = time.monotonic() - started + self.assertEqual(caught.exception.code, "actor_service_cleanup_uncertain") + self.assertLess(elapsed, 2.0) + self.assertIsNotNone(self.port._process.poll()) + self._assert_all_pipe_handles_closed(self.port) + + +class DefaultRuntimeIsolationTests(unittest.TestCase): + def test_package_import_and_default_registry_do_not_load_candidate(self): + root = Path(__file__).resolve().parents[3] + candidate_name = "_local_fake_account_actor_candidate" + guarded = ( + root / "backtrader_runtime" / "__init__.py", + root / "backtrader_runtime" / "inventory.py", + root / "backtrader_runtime" / "registry.py", + root / "backtrader_runtime" / "cli.py", + root / "backtrader" / "stores" / "btapistore.py", + ) + for path in guarded: + self.assertNotIn(candidate_name, path.read_text(encoding="utf-8")) + + script = ( + "import json, sys; " + "import backtrader_runtime; " + "from backtrader_runtime.inventory import iteration41_runtime_registry; " + "registry = iteration41_runtime_registry(); " + "print(json.dumps({'ids': [r.runtime_id for r in registry.registrations], " + "'loaded': any(name.startswith('backtrader_runtime." + "_local_fake_account_actor_candidate') for name in sys.modules)}))" + ) + env = os.environ.copy() + env.pop("PYTHONPATH", None) + result = subprocess.run( + [sys.executable, "-B", "-c", script], + cwd=str(root), + env=env, + capture_output=True, + text=True, + timeout=15, + check=True, + ) + observation = json.loads(result.stdout.strip()) + self.assertFalse(observation["loaded"]) + self.assertFalse( + any("local_fake_account_actor" in runtime_id for runtime_id in observation["ids"]) + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/runtime/test_local_fake_account_actor_server_core_candidate.py b/tests/unit/runtime/test_local_fake_account_actor_server_core_candidate.py new file mode 100644 index 00000000..ea3d4dfc --- /dev/null +++ b/tests/unit/runtime/test_local_fake_account_actor_server_core_candidate.py @@ -0,0 +1,705 @@ +from __future__ import annotations + +import hashlib +import multiprocessing +import os +import sqlite3 +import tempfile +import unittest +from contextlib import closing +from dataclasses import replace +from pathlib import Path + +from backtrader_runtime._local_fake_account_actor_candidate.account_actor_port import ( + ActorCommandContextV1, +) +from backtrader_runtime._local_fake_account_actor_candidate.account_actor_server_core import ( + AccountActorIntentV1, + AccountActorServerCoreV1, + AccountSnapshotBundleV1, + ActorServerError, + FakeSnapshotAuthorityV1, + SnapshotDomainFactV1, +) + +ACCOUNT_A = "ctp-account-ref.v1:" + hashlib.sha256(b"account-a-test-only").hexdigest() +ACCOUNT_B = "ctp-account-ref.v1:" + hashlib.sha256(b"account-b-test-only").hexdigest() +SOURCE_ID = "fake-source-1" +AUTHORITY_ID = "fake-authority-1" +TEST_KEY = b"test-only-not-a-deployed-secret-key-0123456789" + + +def _context(account_ref=ACCOUNT_A, *, epoch=1, session_id="session-1"): + return ActorCommandContextV1( + account_ref=account_ref, + runtime_id="runtime-test", + mode="simulation", + config_digest=hashlib.sha256(b"test-config").hexdigest(), + session_id=session_id, + front_id=11, + native_session_id=22, + session_generation=3, + actor_epoch=epoch, + ) + + +def _authority(): + return FakeSnapshotAuthorityV1( + authority_id=AUTHORITY_ID, + source_id=SOURCE_ID, + key=TEST_KEY, + ) + + +def _bundle(account_ref=ACCOUNT_A, *, version=1, source_id=SOURCE_ID): + payloads = { + "funds": {"available": "100000.00", "currency": "CNY"}, + "orders": {"open_order_count": 0}, + "trades": {"trade_count": 0}, + "positions": {"position_count": 0}, + } + facts = tuple( + SnapshotDomainFactV1.from_payload( + account_ref=account_ref, + snapshot_version=version, + source_id=source_id, + domain=domain, + payload=payloads[domain], + ) + for domain in ("funds", "orders", "trades", "positions") + ) + return AccountSnapshotBundleV1(account_ref, version, source_id, facts) + + +def _intent( + *, + account_ref=ACCOUNT_A, + epoch=1, + intent_id="intent-1", + quantity=2, + session_id="session-1", +): + return AccountActorIntentV1.from_payload( + operation="SUBMIT", + intent_id=intent_id, + context=_context(account_ref, epoch=epoch, session_id=session_id), + payload={ + "instrument": "IF2612", + "side": "BUY", + "offset": "OPEN", + "quantity": quantity, + "limit_price": "3500.0", + }, + ) + + +def _claim_process(database_path, account_ref, barrier, connection, owner_id): + core = AccountActorServerCoreV1(database_path) + barrier.wait(timeout=10) + try: + handle = core.claim_writer(account_ref, owner_id) + connection.send(("claimed", handle.epoch)) + except ActorServerError as exc: + connection.send(("rejected", exc.code)) + finally: + core.close() + connection.close() + + +def _crash_after_claim_process(database_path, connection): + core = AccountActorServerCoreV1(database_path) + handle = core.claim_writer(ACCOUNT_A, "crashed-owner") + connection.send(("claimed", handle.epoch)) + connection.close() + os._exit(0) + + +def _reserve_process(database_path, writer, barrier, connection): + core = AccountActorServerCoreV1(database_path, snapshot_authority=_authority()) + barrier.wait(timeout=10) + try: + command = core.reserve_intent(writer, _intent(), expected_snapshot_version=1) + connection.send(("reserved", command.state, command.command_digest)) + except ActorServerError as exc: + connection.send(("rejected", exc.code)) + finally: + core.close() + connection.close() + + +def _claim_dispatch_process(database_path, writer, authorization, connection): + core = AccountActorServerCoreV1( + database_path, + snapshot_authority=_authority(), + ) + try: + core.claim_for_dispatch(writer, authorization) + connection.send(("claimed", core.read_dispatch_state( + ACCOUNT_A, authorization.operation, authorization.intent_id + ))) + except ActorServerError as exc: + connection.send(("rejected", exc.code, core.count_dispatch_rows(ACCOUNT_A), + core.read_dispatch_state( + ACCOUNT_A, authorization.operation, authorization.intent_id + ))) + finally: + core.close() + connection.close() + + +def _claim_and_crash_process(database_path, writer, authorization, connection): + core = AccountActorServerCoreV1( + database_path, + snapshot_authority=_authority(), + ) + claim = core.claim_for_dispatch(writer, authorization) + connection.send(("claimed", claim.state)) + connection.close() + os._exit(0) + + +class AccountActorServerCoreTests(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory(prefix="g6-actor-core-") + self.database_path = str(Path(self.temp.name) / "actor.sqlite3") + self.core = AccountActorServerCoreV1( + self.database_path, + snapshot_authority=_authority(), + ) + self.writer = self.core.claim_writer(ACCOUNT_A, "writer-a") + self.core.bind_session(self.writer, _context(epoch=self.writer.epoch)) + self.core.publish_snapshot(self.writer, _bundle()) + + def tearDown(self): + self.core.close() + self.temp.cleanup() + + def test_snapshot_requires_exact_four_same_account_version_source_domains(self): + incomplete = AccountSnapshotBundleV1.__new__(AccountSnapshotBundleV1) + object.__setattr__(incomplete, "account_ref", ACCOUNT_A) + object.__setattr__(incomplete, "snapshot_version", 2) + object.__setattr__(incomplete, "source_id", SOURCE_ID) + object.__setattr__(incomplete, "facts", _bundle(version=2).facts[:3]) + with self.assertRaisesRegex(ActorServerError, "snapshot bundle invalid"): + self.core.publish_snapshot(self.writer, incomplete) + self.assertEqual(self.core.count_dispatch_rows(ACCOUNT_A), 0) + with self.assertRaisesRegex(ValueError, "version mismatch"): + wrong_version_fact = replace( + _bundle(version=2).facts[0], snapshot_version=3 + ) + AccountSnapshotBundleV1( + ACCOUNT_A, + 2, + SOURCE_ID, + (wrong_version_fact,) + _bundle(version=2).facts[1:], + ) + with self.assertRaisesRegex(ValueError, "source mismatch"): + wrong_source_fact = replace( + _bundle(version=2).facts[0], source_id="other-source" + ) + AccountSnapshotBundleV1( + ACCOUNT_A, + 2, + SOURCE_ID, + (wrong_source_fact,) + _bundle(version=2).facts[1:], + ) + + def test_missing_source_authority_blocks_snapshot_reservation_and_dispatch(self): + core = AccountActorServerCoreV1( + str(Path(self.temp.name) / "no-authority.sqlite3") + ) + writer = core.claim_writer(ACCOUNT_A, "writer-no-source") + with self.assertRaisesRegex( + ActorServerError, "snapshot source authority unavailable" + ): + core.publish_snapshot(writer, _bundle()) + with self.assertRaisesRegex( + ActorServerError, "snapshot source authority unavailable" + ): + core.authorize_dispatch(writer, operation="SUBMIT", intent_id="absent") + self.assertEqual(core.count_dispatch_rows(ACCOUNT_A), 0) + core.close() + + def test_missing_authority_at_final_gate_emits_no_outbox_row(self): + self.core.reserve_intent(self.writer, _intent(), expected_snapshot_version=1) + reopened_without_authority = AccountActorServerCoreV1(self.database_path) + with self.assertRaisesRegex( + ActorServerError, "snapshot source authority unavailable" + ): + reopened_without_authority.authorize_dispatch( + self.writer, + operation="SUBMIT", + intent_id="intent-1", + ) + self.assertEqual(reopened_without_authority.count_dispatch_rows(ACCOUNT_A), 0) + reopened_without_authority.close() + + def test_wrong_source_authority_and_wrong_account_cannot_publish(self): + foreign_authority = FakeSnapshotAuthorityV1( + authority_id=AUTHORITY_ID, + source_id="different-source", + key=TEST_KEY, + ) + foreign_core = AccountActorServerCoreV1( + str(Path(self.temp.name) / "wrong-source.sqlite3"), + snapshot_authority=foreign_authority, + ) + writer = foreign_core.claim_writer(ACCOUNT_A, "writer-a") + with self.assertRaisesRegex(ActorServerError, "snapshot source untrusted"): + foreign_core.publish_snapshot(writer, _bundle()) + with self.assertRaisesRegex(ActorServerError, "snapshot account mismatch"): + self.core.publish_snapshot(self.writer, _bundle(account_ref=ACCOUNT_B)) + self.assertEqual(foreign_core.count_dispatch_rows(ACCOUNT_A), 0) + self.assertEqual(self.core.count_dispatch_rows(ACCOUNT_A), 0) + foreign_core.close() + + def test_wrong_account_and_wrong_epoch_intents_are_rejected_before_reservation( + self, + ): + with self.assertRaisesRegex(ActorServerError, "intent account mismatch"): + self.core.reserve_intent( + self.writer, + _intent(account_ref=ACCOUNT_B), + expected_snapshot_version=1, + ) + with self.assertRaisesRegex(ActorServerError, "intent actor epoch mismatch"): + self.core.reserve_intent( + self.writer, + _intent(epoch=self.writer.epoch + 1), + expected_snapshot_version=1, + ) + self.assertEqual(self.core.count_dispatch_rows(ACCOUNT_A), 0) + + def test_session_binding_is_exact_and_immutable_within_writer_epoch(self): + expected = _context(epoch=self.writer.epoch) + self.core.bind_session(self.writer, expected) + with self.assertRaisesRegex(ActorServerError, "session context already bound"): + self.core.bind_session( + self.writer, + _context(epoch=self.writer.epoch, session_id="replacement-session"), + ) + self.core.reserve_intent(self.writer, _intent(), expected_snapshot_version=1) + authorized = self.core.authorize_dispatch( + self.writer, + operation="SUBMIT", + intent_id="intent-1", + ) + self.assertEqual(authorized.state, "AUTHORIZED_LOCAL_OUTBOX") + self.assertEqual(self.core.count_dispatch_rows(ACCOUNT_A), 1) + + def test_durable_intent_deduplication_survives_reopen_and_body_conflict_rejects( + self, + ): + intent = _intent() + first = self.core.reserve_intent( + self.writer, intent, expected_snapshot_version=1 + ) + self.core.close() + reopened = AccountActorServerCoreV1( + self.database_path, + snapshot_authority=_authority(), + ) + duplicate = reopened.reserve_intent( + self.writer, intent, expected_snapshot_version=1 + ) + self.assertEqual(duplicate, first) + with self.assertRaisesRegex(ActorServerError, "intent replay conflict"): + reopened.reserve_intent( + self.writer, + _intent(quantity=3), + expected_snapshot_version=1, + ) + self.assertEqual(reopened.count_dispatch_rows(ACCOUNT_A), 0) + reopened.close() + + def test_durable_deduplication_key_includes_operation(self): + submit = _intent(intent_id="shared-id") + cancel = replace(submit, operation="CANCEL") + self.core.reserve_intent(self.writer, submit, expected_snapshot_version=1) + self.core.reserve_intent(self.writer, cancel, expected_snapshot_version=1) + submit_receipt = self.core.authorize_dispatch( + self.writer, + operation="SUBMIT", + intent_id="shared-id", + ) + cancel_receipt = self.core.authorize_dispatch( + self.writer, + operation="CANCEL", + intent_id="shared-id", + ) + self.assertNotEqual(submit_receipt.dispatch_id, cancel_receipt.dispatch_id) + self.assertEqual(self.core.count_dispatch_rows(ACCOUNT_A), 2) + + def test_finalize_after_reopen_is_idempotent_and_writes_one_outbox_row(self): + self.core.reserve_intent(self.writer, _intent(), expected_snapshot_version=1) + self.core.close() + reopened = AccountActorServerCoreV1( + self.database_path, + snapshot_authority=_authority(), + ) + first = reopened.authorize_dispatch( + self.writer, + operation="SUBMIT", + intent_id="intent-1", + ) + second = reopened.authorize_dispatch( + self.writer, + operation="SUBMIT", + intent_id="intent-1", + ) + self.assertEqual(first, second) + self.assertEqual(reopened.count_dispatch_rows(ACCOUNT_A), 1) + reopened.close() + + def test_new_snapshot_revokes_old_authorization_and_final_gate_rejects_it(self): + self.core.reserve_intent(self.writer, _intent(), expected_snapshot_version=1) + old_authorization = self.core.authorize_dispatch( + self.writer, operation="SUBMIT", intent_id="intent-1" + ) + self.assertEqual(self.core.read_dispatch_state(ACCOUNT_A, "SUBMIT", "intent-1"), + "AVAILABLE") + + self.core.publish_snapshot(self.writer, _bundle(version=2)) + self.assertEqual(self.core.read_dispatch_state(ACCOUNT_A, "SUBMIT", "intent-1"), + "REVOKED") + with self.assertRaisesRegex(ActorServerError, "dispatch authorization stale"): + self.core.authorize_dispatch( + self.writer, operation="SUBMIT", intent_id="intent-1" + ) + with self.assertRaisesRegex(ActorServerError, "dispatch authorization not available"): + self.core.claim_for_dispatch(self.writer, old_authorization) + + self.assertEqual(self.core.count_dispatch_rows(ACCOUNT_A), 1) + with closing(sqlite3.connect(self.database_path)) as connection: + row = connection.execute( + "SELECT state,expected_snapshot_version FROM actor_commands" + " WHERE account_ref=? AND operation='SUBMIT' AND intent_id='intent-1'", + (ACCOUNT_A,), + ).fetchone() + self.assertEqual(row, ("BLOCKED", 1)) + + def test_authorized_replay_rechecks_current_snapshot_even_without_revocation_marker( + self, + ): + self.core.reserve_intent(self.writer, _intent(), expected_snapshot_version=1) + self.core.authorize_dispatch( + self.writer, operation="SUBMIT", intent_id="intent-1" + ) + self.core.publish_snapshot(self.writer, _bundle(version=2)) + # Simulate a stale lifecycle marker left by an old/partial consumer. + # The final authorization path must independently verify the pointer. + with closing(sqlite3.connect(self.database_path)) as connection: + connection.execute( + "UPDATE actor_dispatch_lifecycle SET state='AVAILABLE'" + " WHERE dispatch_id=1" + ) + connection.execute( + "UPDATE actor_commands SET state='AUTHORIZED'" + " WHERE account_ref=? AND operation='SUBMIT' AND intent_id='intent-1'", + (ACCOUNT_A,), + ) + connection.commit() + + with self.assertRaisesRegex(ActorServerError, "dispatch authorization stale"): + self.core.authorize_dispatch( + self.writer, operation="SUBMIT", intent_id="intent-1" + ) + self.assertEqual(self.core.read_dispatch_state(ACCOUNT_A, "SUBMIT", "intent-1"), + "REVOKED") + with closing(sqlite3.connect(self.database_path)) as connection: + state = connection.execute( + "SELECT state FROM actor_commands WHERE account_ref=? AND operation='SUBMIT'" + " AND intent_id='intent-1'", + (ACCOUNT_A,), + ).fetchone()[0] + self.assertEqual(state, "BLOCKED") + self.assertEqual(self.core.count_dispatch_rows(ACCOUNT_A), 1) + + def test_stale_authorization_is_rejected_after_cross_process_reopen(self): + self.core.reserve_intent(self.writer, _intent(), expected_snapshot_version=1) + old_authorization = self.core.authorize_dispatch( + self.writer, operation="SUBMIT", intent_id="intent-1" + ) + self.core.publish_snapshot(self.writer, _bundle(version=2)) + self.core.close() + + context = multiprocessing.get_context("spawn") + parent, child = context.Pipe(duplex=False) + process = context.Process( + target=_claim_dispatch_process, + args=(self.database_path, self.writer, old_authorization, child), + ) + process.start() + child.close() + self.assertTrue(parent.poll(15)) + result = parent.recv() + process.join(15) + self.assertEqual(process.exitcode, 0) + self.assertEqual( + result, + ("rejected", "dispatch_authorization_not_available", 1, "REVOKED"), + ) + parent.close() + + reopened = AccountActorServerCoreV1( + self.database_path, snapshot_authority=_authority() + ) + with self.assertRaisesRegex(ActorServerError, "dispatch authorization stale"): + reopened.authorize_dispatch( + self.writer, operation="SUBMIT", intent_id="intent-1" + ) + with self.assertRaisesRegex(ActorServerError, "intent replay conflict"): + reopened.reserve_intent( + self.writer, _intent(), expected_snapshot_version=2 + ) + self.assertEqual(reopened.count_dispatch_rows(ACCOUNT_A), 1) + self.assertEqual( + reopened.read_dispatch_state(ACCOUNT_A, "SUBMIT", "intent-1"), + "REVOKED", + ) + reopened.close() + + def test_final_claim_is_single_use_and_freezes_snapshot_and_writer(self): + self.core.reserve_intent(self.writer, _intent(), expected_snapshot_version=1) + authorization = self.core.authorize_dispatch( + self.writer, operation="SUBMIT", intent_id="intent-1" + ) + claim = self.core.claim_for_dispatch(self.writer, authorization) + self.assertEqual(claim.state, "CLAIMED_LOCAL_ONLY") + self.assertEqual(self.core.read_dispatch_state(ACCOUNT_A, "SUBMIT", "intent-1"), + "CLAIMED") + with self.assertRaisesRegex(ActorServerError, "dispatch authorization not available"): + self.core.claim_for_dispatch(self.writer, authorization) + with self.assertRaisesRegex(ActorServerError, "dispatch claim in flight"): + self.core.publish_snapshot(self.writer, _bundle(version=2)) + with self.assertRaisesRegex(ActorServerError, "dispatch claim in flight"): + self.core.revoke_writer(self.writer) + self.assertEqual(self.core.count_dispatch_rows(ACCOUNT_A), 1) + self.assertEqual(self.core.read_dispatch_state(ACCOUNT_A, "SUBMIT", "intent-1"), + "CLAIMED") + + def test_claimed_dispatch_remains_fenced_after_process_crash_and_reopen(self): + self.core.reserve_intent(self.writer, _intent(), expected_snapshot_version=1) + authorization = self.core.authorize_dispatch( + self.writer, operation="SUBMIT", intent_id="intent-1" + ) + self.core.close() + + context = multiprocessing.get_context("spawn") + parent, child = context.Pipe(duplex=False) + process = context.Process( + target=_claim_and_crash_process, + args=(self.database_path, self.writer, authorization, child), + ) + process.start() + child.close() + self.assertTrue(parent.poll(15)) + self.assertEqual(parent.recv(), ("claimed", "CLAIMED_LOCAL_ONLY")) + process.join(15) + self.assertEqual(process.exitcode, 0) + parent.close() + + reopened = AccountActorServerCoreV1( + self.database_path, snapshot_authority=_authority() + ) + self.assertEqual( + reopened.read_dispatch_state(ACCOUNT_A, "SUBMIT", "intent-1"), + "CLAIMED", + ) + with self.assertRaisesRegex(ActorServerError, "dispatch authorization not available"): + reopened.claim_for_dispatch(self.writer, authorization) + with self.assertRaisesRegex(ActorServerError, "dispatch claim in flight"): + reopened.publish_snapshot(self.writer, _bundle(version=2)) + with self.assertRaisesRegex(ActorServerError, "dispatch claim in flight"): + reopened.revoke_writer(self.writer) + self.assertEqual(reopened.count_dispatch_rows(ACCOUNT_A), 1) + reopened.close() + + def test_v1_authorized_outbox_migrates_to_revoked_audit_only(self): + self.core.reserve_intent(self.writer, _intent(), expected_snapshot_version=1) + old_authorization = self.core.authorize_dispatch( + self.writer, operation="SUBMIT", intent_id="intent-1" + ) + self.core.close() + with closing(sqlite3.connect(self.database_path)) as connection: + connection.execute("DROP TABLE actor_dispatch_lifecycle") + connection.execute("PRAGMA user_version=1") + connection.commit() + + reopened = AccountActorServerCoreV1( + self.database_path, snapshot_authority=_authority() + ) + with closing(sqlite3.connect(self.database_path)) as connection: + schema_version = connection.execute("PRAGMA user_version").fetchone()[0] + command_state = connection.execute( + "SELECT state FROM actor_commands WHERE account_ref=? AND operation='SUBMIT'" + " AND intent_id='intent-1'", + (ACCOUNT_A,), + ).fetchone()[0] + self.assertEqual(schema_version, 2) + self.assertEqual(command_state, "BLOCKED") + self.assertEqual( + reopened.read_dispatch_state(ACCOUNT_A, "SUBMIT", "intent-1"), + "REVOKED", + ) + with self.assertRaisesRegex(ActorServerError, "dispatch authorization not available"): + reopened.claim_for_dispatch(self.writer, old_authorization) + with self.assertRaisesRegex(ActorServerError, "dispatch authorization stale"): + reopened.authorize_dispatch( + self.writer, operation="SUBMIT", intent_id="intent-1" + ) + self.assertEqual(reopened.count_dispatch_rows(ACCOUNT_A), 1) + reopened.close() + + def test_new_snapshot_between_reservation_and_final_gate_blocks_dispatch(self): + self.core.reserve_intent(self.writer, _intent(), expected_snapshot_version=1) + self.core.publish_snapshot(self.writer, _bundle(version=2)) + with self.assertRaisesRegex(ActorServerError, "snapshot not current"): + self.core.authorize_dispatch( + self.writer, + operation="SUBMIT", + intent_id="intent-1", + ) + self.assertEqual(self.core.count_dispatch_rows(ACCOUNT_A), 0) + + def test_changed_domain_bytes_fail_source_proof_at_final_dispatch_gate(self): + self.core.reserve_intent(self.writer, _intent(), expected_snapshot_version=1) + with closing(sqlite3.connect(self.database_path)) as connection: + connection.execute( + "UPDATE actor_snapshot_domains SET payload_json=?" + " WHERE account_ref=? AND snapshot_version=1 AND domain='funds'", + ('{"available":"999999.00","currency":"CNY"}', ACCOUNT_A), + ) + connection.commit() + with self.assertRaisesRegex( + ActorServerError, "snapshot digest readback mismatch" + ): + self.core.authorize_dispatch( + self.writer, + operation="SUBMIT", + intent_id="intent-1", + ) + self.assertEqual(self.core.count_dispatch_rows(ACCOUNT_A), 0) + + def test_malformed_existing_schema_rejects_without_repair(self): + other_path = str(Path(self.temp.name) / "schema-shape.sqlite3") + fresh = AccountActorServerCoreV1(other_path) + fresh.close() + with closing(sqlite3.connect(other_path)) as connection: + connection.execute("DROP TABLE actor_account_writers") + connection.execute( + "CREATE TABLE actor_account_writers(" + "account_ref TEXT, epoch INTEGER, owner_id TEXT, token_sha256 TEXT, state TEXT)" + ) + connection.commit() + with self.assertRaisesRegex(ActorServerError, "database schema shape invalid"): + AccountActorServerCoreV1(other_path) + with closing(sqlite3.connect(other_path)) as connection: + row = connection.execute("PRAGMA user_version").fetchone() + sql = connection.execute( + "SELECT sql FROM sqlite_master WHERE name='actor_account_writers'" + ).fetchone()[0] + self.assertEqual(row[0], 2) + self.assertIn("epoch INTEGER", sql) + self.assertNotIn("PRIMARY KEY", sql) + + def test_revoked_epoch_cannot_dispatch_and_reclaim_increments_generation(self): + self.core.reserve_intent(self.writer, _intent(), expected_snapshot_version=1) + self.core.revoke_writer(self.writer) + new_writer = self.core.claim_writer(ACCOUNT_A, "writer-b") + self.assertEqual(new_writer.epoch, self.writer.epoch + 1) + self.core.bind_session( + new_writer, _context(epoch=new_writer.epoch, session_id="session-2") + ) + with self.assertRaisesRegex( + ActorServerError, "writer epoch inactive|writer epoch mismatch" + ): + self.core.authorize_dispatch( + self.writer, + operation="SUBMIT", + intent_id="intent-1", + ) + self.assertEqual(self.core.count_dispatch_rows(ACCOUNT_A), 0) + with self.assertRaisesRegex(ActorServerError, "command writer epoch mismatch"): + self.core.authorize_dispatch( + new_writer, + operation="SUBMIT", + intent_id="intent-1", + ) + self.assertEqual(self.core.count_dispatch_rows(ACCOUNT_A), 0) + + def test_snapshot_context_session_and_generation_are_digest_bound(self): + wrong_session = _intent(session_id="different-session") + self.core.reserve_intent(self.writer, _intent(), expected_snapshot_version=1) + with self.assertRaisesRegex( + ActorServerError, "intent session binding mismatch" + ): + self.core.reserve_intent( + self.writer, wrong_session, expected_snapshot_version=1 + ) + self.assertEqual(self.core.count_dispatch_rows(ACCOUNT_A), 0) + + def test_two_processes_racing_for_one_account_epoch_have_one_winner(self): + race_path = str(Path(self.temp.name) / "claim-race.sqlite3") + empty_core = AccountActorServerCoreV1(race_path) + empty_core.close() + context = multiprocessing.get_context("spawn") + barrier = context.Barrier(2) + endpoints = [context.Pipe(duplex=False) for _ in range(2)] + processes = [ + context.Process( + target=_claim_process, + args=( + race_path, + ACCOUNT_B, + barrier, + endpoints[index][1], + f"writer-{index}", + ), + ) + for index in range(2) + ] + for process in processes: + process.start() + for _, child in endpoints: + child.close() + results = [parent.recv() for parent, _ in endpoints] + for process in processes: + process.join(15) + self.assertEqual(process.exitcode, 0) + self.assertEqual(sum(result[0] == "claimed" for result in results), 1) + self.assertEqual(sum(result[0] == "rejected" for result in results), 1) + winner = next(result for result in results if result[0] == "claimed") + race_core = AccountActorServerCoreV1(race_path) + self.assertEqual(race_core.read_writer_epoch(ACCOUNT_B)[0], winner[1]) + race_core.close() + + def test_process_death_leaves_writer_claim_durable_and_unreclaimable(self): + other_path = str(Path(self.temp.name) / "crash.sqlite3") + core = AccountActorServerCoreV1(other_path) + core.close() + context = multiprocessing.get_context("spawn") + parent, child = context.Pipe(duplex=False) + process = context.Process( + target=_crash_after_claim_process, + args=(other_path, child), + ) + process.start() + child.close() + result = parent.recv() + process.join(15) + parent.close() + self.assertEqual(process.exitcode, 0) + self.assertEqual(result, ("claimed", 1)) + reopened = AccountActorServerCoreV1(other_path) + self.assertEqual( + reopened.read_writer_epoch(ACCOUNT_A), (1, "crashed-owner", "ACTIVE") + ) + with self.assertRaisesRegex(ActorServerError, "account writer already claimed"): + reopened.claim_writer(ACCOUNT_A, "new-process") + self.assertEqual(reopened.count_dispatch_rows(ACCOUNT_A), 0) + reopened.close() + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/runtime/test_local_fake_account_actor_store_boundary_candidate.py b/tests/unit/runtime/test_local_fake_account_actor_store_boundary_candidate.py new file mode 100644 index 00000000..1b397077 --- /dev/null +++ b/tests/unit/runtime/test_local_fake_account_actor_store_boundary_candidate.py @@ -0,0 +1,721 @@ +import unittest +from dataclasses import replace +from decimal import Decimal + +from backtrader_runtime._local_fake_account_actor_candidate.account_actor_port import ( + AccountActorGateError, + ActorCommandContextV1, + ActorCommandReceiptV2, + ActorCommandState, + CtpAccountActorPort, + CtpCancelIntentV2, + CtpSubmitIntentV2, + FakeLocalActorReplayLedger, + RouteKind, + StoreRouteDescriptor, + UnavailableCtpAccountActorPort, + classify_store_route, +) +from backtrader_runtime._local_fake_account_actor_candidate.store_boundary_harness import ( + StoreBoundaryHarness, +) + + +class Counters: + def __init__(self): + self.constructed = 0 + self.connected = 0 + self.native_calls = 0 + self.gateway_created = 0 + + +class FakeNativeClient: + def __init__(self, counters): + counters.constructed += 1 + self.counters = counters + + def connect(self): + self.counters.connected += 1 + + def ReqOrderInsert(self, *_args): + self.counters.native_calls += 1 + return 0 + + def ReqOrderAction(self, *_args): + self.counters.native_calls += 1 + return 0 + + +class FakeActorPort(CtpAccountActorPort): + def __init__(self): + self.submits = [] + self.cancels = [] + self.receipt_mutator = None + + def _receipt(self, intent): + receipt = ActorCommandReceiptV2( + operation=intent.operation, + command_id=intent.command_id, + state=ActorCommandState.QUEUED, + context=intent.context, + command_digest=intent.command_digest, + ) + return self.receipt_mutator(receipt) if self.receipt_mutator else receipt + + def submit_order(self, intent): + self.submits.append(intent) + return self._receipt(intent) + + def cancel_order(self, intent): + self.cancels.append(intent) + return self._receipt(intent) + + +def actor_context(**overrides): + values = { + "account_ref": "ctp-account-ref.v1:account-test", + "runtime_id": "runtime-test", + "mode": "simulation", + "config_digest": "a" * 64, + "session_id": "session-test", + "front_id": 7, + "native_session_id": 11, + "session_generation": 4, + "actor_epoch": 3, + } + values.update(overrides) + return ActorCommandContextV1(**values) + + +def submit_intent(*, context=None, intent_id="submit-1"): + return CtpSubmitIntentV2( + intent_id=intent_id, + instrument_id="IF2601", + exchange_id="CFFEX", + side="BUY", + offset="OPEN", + hedge_flag="SPECULATION", + quantity=1, + limit_price=Decimal("3000.5"), + context=context or actor_context(), + ) + + +def cancel_intent(*, context=None, cancel_intent_id="cancel-1", front_id=None, session_id=None): + current = context or actor_context() + return CtpCancelIntentV2( + cancel_intent_id=cancel_intent_id, + runtime_order_id="runtime-order-1", + order_ref="123", + front_id=current.front_id if front_id is None else front_id, + session_id=current.native_session_id if session_id is None else session_id, + exchange_id="CFFEX", + order_sys_id="system-order-1", + context=current, + ) + + +def actor_store(actor, *, context=None, route=None, **kwargs): + kwargs.setdefault("receipt_ledger", FakeLocalActorReplayLedger()) + return StoreBoundaryHarness( + route or StoreRouteDescriptor(provider="ctp", backend="direct"), + actor_port=actor, + actor_context=context or actor_context(), + **kwargs, + ) + + +class ActorPortGateTests(unittest.TestCase): + def test_ctp_direct_legacy_store_rejects_before_api_class_factory(self): + counters = Counters() + + def factory(): + return FakeNativeClient(counters) + + route = StoreRouteDescriptor( + provider="ctp", + backend="direct", + api_cls=FakeNativeClient, + ) + with self.assertRaisesRegex(AccountActorGateError, "external account actor unavailable"): + StoreBoundaryHarness( + route, + credential_resolver=lambda: self.fail("credentials must not be resolved"), + api_factory=factory, + ) + self.assertEqual(counters.constructed, 0) + self.assertEqual(counters.connected, 0) + self.assertEqual(counters.native_calls, 0) + + def test_ctp_gateway_rejects_before_legacy_gateway_import_or_factory(self): + counters = Counters() + + def gateway_factory(): + counters.gateway_created += 1 + return FakeNativeClient(counters) + + route = StoreRouteDescriptor( + provider="gateway", + backend="gateway", + config={"exchange_type": "CTP"}, + ) + with self.assertRaisesRegex(AccountActorGateError, "external account actor unavailable"): + StoreBoundaryHarness( + route, + credential_resolver=lambda: self.fail("credentials must not be resolved"), + gateway_factory=gateway_factory, + ) + self.assertEqual(counters.gateway_created, 0) + self.assertEqual(counters.constructed, 0) + self.assertEqual(counters.native_calls, 0) + + def test_ctp_gateway_default_exchange_is_fail_closed(self): + route = StoreRouteDescriptor(provider="ctp_gateway", backend="gateway") + self.assertIs(classify_store_route(route), RouteKind.CTP) + with self.assertRaisesRegex(AccountActorGateError, "external account actor unavailable"): + StoreBoundaryHarness( + route, + credential_resolver=lambda: self.fail("credentials must not be resolved"), + ) + self.assertIs(classify_store_route(StoreRouteDescriptor(provider="ctp")), RouteKind.CTP) + + def test_gateway_primary_exchange_cannot_mask_ctp_symbol_route(self): + counters = Counters() + route = StoreRouteDescriptor( + provider="gateway", + backend="gateway", + config={ + "exchange_type": "OKX", + "symbol_routes": {"IF2601": "CTP"}, + }, + ) + self.assertIs(classify_store_route(route), RouteKind.CTP) + + def gateway_factory(): + counters.gateway_created += 1 + return FakeNativeClient(counters) + + with self.assertRaisesRegex(AccountActorGateError, "external account actor unavailable"): + StoreBoundaryHarness( + route, + credential_resolver=lambda: self.fail("credentials must not be resolved"), + gateway_factory=gateway_factory, + ) + self.assertEqual(counters.gateway_created, 0) + self.assertEqual(counters.constructed, 0) + self.assertEqual(counters.native_calls, 0) + + def test_injected_api_is_rejected_before_store_connect_or_native_call(self): + counters = Counters() + prebuilt = FakeNativeClient(counters) + route = StoreRouteDescriptor(provider="ctp", backend="direct", api=prebuilt) + with self.assertRaisesRegex(AccountActorGateError, "external account actor unavailable"): + StoreBoundaryHarness( + route, + credential_resolver=lambda: self.fail("credentials must not be resolved"), + ) + self.assertEqual(counters.constructed, 1) # caller built it before Store entry + self.assertEqual(counters.connected, 0) + self.assertEqual(counters.native_calls, 0) + + def test_actor_route_rejects_api_and_api_cls_injection_even_with_actor(self): + counters = Counters() + actor = FakeActorPort() + prebuilt = FakeNativeClient(counters) + route = StoreRouteDescriptor(provider="ctp", api=prebuilt) + with self.assertRaisesRegex(AccountActorGateError, "local ctp client injection forbidden"): + StoreBoundaryHarness(route, actor_port=actor) + route = StoreRouteDescriptor(provider="ctp", api_cls=FakeNativeClient) + with self.assertRaisesRegex(AccountActorGateError, "local ctp client injection forbidden"): + StoreBoundaryHarness(route, actor_port=actor, api_factory=lambda counters=counters: FakeNativeClient(counters)) + self.assertEqual(counters.constructed, 1) + self.assertEqual(counters.native_calls, 0) + self.assertEqual(actor.submits, []) + + def test_btapi_ctp_exchange_configuration_rejects_before_custom_class(self): + counters = Counters() + route = StoreRouteDescriptor( + provider="btapi", + api_kwargs={"exchange_kwargs": {"CTP": {"broker_id": "fake"}}}, + api_cls=FakeNativeClient, + ) + self.assertIs(classify_store_route(route), RouteKind.CTP) + with self.assertRaisesRegex(AccountActorGateError, "external account actor unavailable"): + StoreBoundaryHarness(route, api_factory=lambda counters=counters: FakeNativeClient(counters)) + self.assertEqual(counters.constructed, 0) + self.assertEqual(counters.native_calls, 0) + + def test_ambiguous_btapi_custom_client_fails_closed_before_factory(self): + counters = Counters() + route = StoreRouteDescriptor(provider="btapi", api_cls=FakeNativeClient) + self.assertIs(classify_store_route(route), RouteKind.AMBIGUOUS) + with self.assertRaisesRegex(AccountActorGateError, "route ambiguous"): + StoreBoundaryHarness( + route, + credential_resolver=lambda: self.fail("credentials must not be resolved"), + api_factory=lambda counters=counters: FakeNativeClient(counters), + ) + self.assertEqual(counters.constructed, 0) + self.assertEqual(counters.native_calls, 0) + + def test_unknown_provider_and_unknown_gateway_suffix_never_fall_through(self): + for route in ( + StoreRouteDescriptor(provider="custom_exchange", api_cls=FakeNativeClient), + StoreRouteDescriptor(provider="custom_exchange"), + StoreRouteDescriptor(provider="custom_gateway", backend="gateway"), + ): + with self.subTest(provider=route.provider): + self.assertIs(classify_store_route(route), RouteKind.AMBIGUOUS) + with self.assertRaisesRegex(AccountActorGateError, "route ambiguous"): + StoreBoundaryHarness(route, actor_port=FakeActorPort()) + + def test_known_placeholder_provider_is_unsupported(self): + route = StoreRouteDescriptor(provider="futu") + self.assertIs(classify_store_route(route), RouteKind.UNSUPPORTED) + with self.assertRaisesRegex(AccountActorGateError, "provider unsupported"): + StoreBoundaryHarness(route, actor_port=FakeActorPort()) + + def test_malformed_backend_and_environment_types_are_ambiguous(self): + for route in ( + StoreRouteDescriptor(provider="okx", backend=object()), + StoreRouteDescriptor(provider="okx", environment_provider=object()), + StoreRouteDescriptor(provider="okx", environment_exchange_type=object()), + ): + with self.subTest(provider=route.provider): + self.assertIs(classify_store_route(route), RouteKind.AMBIGUOUS) + + def test_untrusted_api_properties_are_never_read_by_classifier_or_gate(self): + class TrapClient: + @property + def exchange_kwargs(self): + raise AssertionError("untrusted API property must not be read") + + route = StoreRouteDescriptor(provider="btapi", api=TrapClient()) + self.assertIs(classify_store_route(route), RouteKind.AMBIGUOUS) + with self.assertRaisesRegex(AccountActorGateError, "route ambiguous"): + StoreBoundaryHarness(route, actor_port=FakeActorPort()) + + route = StoreRouteDescriptor(provider="gateway", backend="gateway", api_cls=TrapClient) + self.assertIs(classify_store_route(route), RouteKind.AMBIGUOUS) + with self.assertRaisesRegex(AccountActorGateError, "route ambiguous"): + StoreBoundaryHarness(route, actor_port=FakeActorPort()) + + def test_exact_code_owned_non_ctp_routes_remain_classifiable(self): + safe_routes = ( + StoreRouteDescriptor(provider="okx"), + StoreRouteDescriptor(provider="binance", backend="direct"), + StoreRouteDescriptor( + provider="btapi", + config={ + "exchange_kwargs": {"OKX": {}, "BINANCE": {}}, + "symbol_routes": {"BTC-USDT": "OKX___SWAP", "ETH-USDT": "BINANCE___SPOT"}, + }, + ), + StoreRouteDescriptor( + provider="gateway", + backend="gateway", + config={"exchange_type": "IB_WEB"}, + ), + StoreRouteDescriptor( + provider="mt5_gateway", + backend="gateway", + config={"exchange_type": "MT5"}, + ), + StoreRouteDescriptor( + provider="ib_web_gateway", + backend="gateway", + environment_exchange_type="IB_WEB", + ), + ) + for route in safe_routes: + with self.subTest(provider=route.provider, config=route.config): + self.assertIs(classify_store_route(route), RouteKind.NON_CTP) + + def test_raw_api_and_api_cls_injection_is_ambiguous_without_introspection(self): + class TrapClient: + @property + def exchange_kwargs(self): + raise AssertionError("API must not be introspected") + + for route in ( + StoreRouteDescriptor(provider="okx", api=TrapClient()), + StoreRouteDescriptor(provider="okx", api_cls=TrapClient), + StoreRouteDescriptor( + provider="gateway", + backend="gateway", + config={"exchange_type": "IB_WEB"}, + api=TrapClient(), + ), + StoreRouteDescriptor( + provider="btapi", + config={"symbol_routes": {"BTC-USDT": "OKX"}}, + api_cls=TrapClient, + ), + ): + with self.subTest(provider=route.provider, has_api=route.api is not None): + self.assertIs(classify_store_route(route), RouteKind.AMBIGUOUS) + with self.assertRaisesRegex(AccountActorGateError, "route ambiguous"): + StoreBoundaryHarness(route, actor_port=FakeActorPort()) + + def test_environment_provider_cannot_downgrade_ctp(self): + routes = ( + StoreRouteDescriptor( + provider="ctp", + backend="direct", + environment_provider="ib_web_gateway", + environment_exchange_type="IB_WEB", + ), + StoreRouteDescriptor( + provider="ctp", + config={"exchange_type": "CTP"}, + environment_provider="gateway", + environment_exchange_type="MT5", + ), + StoreRouteDescriptor( + provider="ctp_gateway", + backend="gateway", + environment_provider="okx", + environment_exchange_type="OKX", + ), + ) + for route in routes: + with self.subTest(environment_provider=route.environment_provider): + self.assertIs(classify_store_route(route), RouteKind.CTP) + counters = Counters() + with self.assertRaisesRegex(AccountActorGateError, "external account actor unavailable"): + StoreBoundaryHarness( + route, + credential_resolver=lambda: self.fail("credentials must not be resolved"), + api_factory=lambda counters=counters: FakeNativeClient(counters), + gateway_factory=lambda counters=counters: FakeNativeClient(counters), + ) + self.assertEqual(counters.constructed, 0) + self.assertEqual(counters.native_calls, 0) + + def test_nested_symbol_routes_detect_ctp_and_reject_unknown(self): + ctp_route = StoreRouteDescriptor( + provider="btapi", + config={ + "exchange_kwargs": {"OKX": {}}, + "symbol_routes": { + "by_market": {"IF2601": {"exchange_type": "CTP"}}, + "BTC-USDT": "OKX___SWAP", + }, + }, + ) + self.assertIs(classify_store_route(ctp_route), RouteKind.CTP) + with self.assertRaisesRegex(AccountActorGateError, "external account actor unavailable"): + StoreBoundaryHarness(ctp_route, credential_resolver=lambda: self.fail("credentials")) + + unknown_route = StoreRouteDescriptor( + provider="btapi", + config={"symbol_routes": {"BTC-USDT": {"venue": "UNKNOWN_VENUE"}}}, + ) + self.assertIs(classify_store_route(unknown_route), RouteKind.AMBIGUOUS) + with self.assertRaisesRegex(AccountActorGateError, "route ambiguous"): + StoreBoundaryHarness(unknown_route, actor_port=FakeActorPort()) + + hidden_exchange_ctp = StoreRouteDescriptor( + provider="btapi", + config={"exchange_kwargs": {"OKX": {"routing": {"exchange_type": "CTP"}}}}, + ) + self.assertIs(classify_store_route(hidden_exchange_ctp), RouteKind.CTP) + + def test_environment_reselection_of_non_ctp_provider_is_ambiguous(self): + route = StoreRouteDescriptor( + provider="okx", + environment_provider="binance", + ) + self.assertIs(classify_store_route(route), RouteKind.AMBIGUOUS) + with self.assertRaisesRegex(AccountActorGateError, "route ambiguous"): + StoreBoundaryHarness(route, actor_port=FakeActorPort()) + + def test_actor_only_typed_submit_and_cancel_have_no_local_native_client(self): + actor = FakeActorPort() + route = StoreRouteDescriptor(provider="ctp", backend="direct") + store = actor_store(actor, route=route) + submit_result = store.submit_order(submit_intent()) + cancel_result = store.cancel_order(cancel_intent()) + self.assertEqual(submit_result.state, ActorCommandState.QUEUED) + self.assertEqual(cancel_result.state, ActorCommandState.QUEUED) + self.assertEqual(len(actor.submits), 1) + self.assertEqual(len(actor.cancels), 1) + self.assertIsNone(store.api) + self.assertEqual(store.legacy_calls, 0) + + def test_actor_route_rejects_legacy_private_fallback(self): + actor = FakeActorPort() + store = actor_store(actor, route=StoreRouteDescriptor(provider="ctp", backend="direct")) + with self.assertRaisesRegex(AccountActorGateError, "ctp legacy dispatch forbidden"): + store._submit_order_legacy(submit_intent()) + with self.assertRaisesRegex(AccountActorGateError, "ctp legacy dispatch forbidden"): + store._cancel_order_legacy(cancel_intent()) + self.assertEqual(store.legacy_calls, 0) + + def test_actor_route_requires_exact_typed_intents_and_no_dict_fallback(self): + actor = FakeActorPort() + store = actor_store(actor, route=StoreRouteDescriptor(provider="ctp")) + with self.assertRaisesRegex(AccountActorGateError, "typed submit intent required"): + store.submit_order({"instrument_id": "IF2601"}) + with self.assertRaisesRegex(AccountActorGateError, "typed cancel intent required"): + store.cancel_order({"order_ref": "123"}) + self.assertEqual(actor.submits, []) + self.assertEqual(actor.cancels, []) + + def test_bad_actor_receipt_never_falls_back_to_native(self): + class BadActor(FakeActorPort): + def submit_order(self, intent): + self.submits.append(intent) + return True + + actor = BadActor() + store = actor_store(actor, route=StoreRouteDescriptor(provider="ctp")) + with self.assertRaisesRegex(AccountActorGateError, "actor receipt type invalid"): + store.submit_order(submit_intent()) + self.assertIsNone(store.api) + self.assertEqual(store.legacy_calls, 0) + + def test_ctp_actor_requires_explicit_fake_replay_ledger(self): + actor = FakeActorPort() + with self.assertRaisesRegex(AccountActorGateError, "fake replay ledger required"): + StoreBoundaryHarness( + StoreRouteDescriptor(provider="ctp"), + actor_port=actor, + actor_context=actor_context(), + credential_resolver=lambda: self.fail("credentials must not be resolved"), + ) + self.assertEqual(actor.submits, []) + + +class TypedIntentValidationTests(unittest.TestCase): + def test_bool_quantity_is_not_native_integer_quantity(self): + with self.assertRaisesRegex(ValueError, "positive exact integer"): + CtpSubmitIntentV2( + intent_id="i", + instrument_id="x", + exchange_id="e", + side="BUY", + offset="OPEN", + hedge_flag="SPECULATION", + quantity=True, + limit_price=Decimal(1), + context=actor_context(), + ) + + def test_bool_front_id_is_rejected(self): + with self.assertRaisesRegex(ValueError, "positive exact integer"): + CtpCancelIntentV2( + cancel_intent_id="c", + runtime_order_id="r", + order_ref="o", + front_id=True, + session_id=1, + exchange_id="e", + order_sys_id="s", + context=actor_context(), + ) + + def test_unavailable_default_does_not_claim_authority(self): + unavailable = UnavailableCtpAccountActorPort() + with self.assertRaisesRegex(AccountActorGateError, "external account actor unavailable"): + unavailable.submit_order(submit_intent()) + + +class ActorReceiptBindingTests(unittest.TestCase): + def test_intent_digest_binds_context_and_all_logical_fields(self): + baseline = submit_intent() + self.assertEqual(len(baseline.command_digest), 64) + self.assertNotEqual( + baseline.command_digest, + submit_intent(context=actor_context(actor_epoch=baseline.context.actor_epoch + 1)).command_digest, + ) + self.assertNotEqual( + baseline.command_digest, + submit_intent(intent_id="submit-other").command_digest, + ) + + def test_stale_epoch_wrong_account_or_wrong_session_intent_reject_before_actor(self): + expected = actor_context() + adversarial_contexts = ( + actor_context(actor_epoch=expected.actor_epoch + 1), + actor_context(account_ref="ctp-account-ref.v1:other"), + actor_context(session_id="different-session"), + actor_context(front_id=expected.front_id + 1), + actor_context(native_session_id=expected.native_session_id + 1), + actor_context(session_generation=expected.session_generation + 1), + actor_context(runtime_id="other-runtime"), + actor_context(mode="live"), + actor_context(config_digest="b" * 64), + ) + for wrong_context in adversarial_contexts: + with self.subTest(context=wrong_context): + actor = FakeActorPort() + counters = Counters() + store = actor_store( + actor, + context=expected, + api_factory=lambda counters=counters: FakeNativeClient(counters), + gateway_factory=lambda counters=counters: FakeNativeClient(counters), + ) + with self.assertRaisesRegex(AccountActorGateError, "intent context mismatch"): + store.submit_order(submit_intent(context=wrong_context)) + self.assertEqual(actor.submits, []) + self.assertEqual(actor.cancels, []) + self.assertIsNone(store.api) + self.assertEqual(store.legacy_calls, 0) + self.assertEqual(counters.constructed, 0) + self.assertEqual(counters.native_calls, 0) + + for wrong_context in ( + actor_context(front_id=expected.front_id + 1), + actor_context(native_session_id=expected.native_session_id + 1), + ): + actor = FakeActorPort() + counters = Counters() + store = actor_store( + actor, + context=expected, + api_factory=lambda counters=counters: FakeNativeClient(counters), + gateway_factory=lambda counters=counters: FakeNativeClient(counters), + ) + with self.assertRaisesRegex(AccountActorGateError, "intent context mismatch"): + store.cancel_order(cancel_intent(context=wrong_context)) + self.assertEqual(actor.cancels, []) + self.assertEqual(store.legacy_calls, 0) + self.assertEqual(counters.constructed, 0) + self.assertEqual(counters.native_calls, 0) + + def test_cancel_native_front_and_session_must_match_context(self): + with self.assertRaisesRegex(ValueError, "match the expected native front/session"): + cancel_intent(session_id=999) + with self.assertRaisesRegex(ValueError, "match the expected native front/session"): + cancel_intent(front_id=999) + + def test_duplicate_intent_is_claimed_once_before_actor_call(self): + actor = FakeActorPort() + ledger = FakeLocalActorReplayLedger() + counters = Counters() + first = actor_store( + actor, + receipt_ledger=ledger, + api_factory=lambda counters=counters: FakeNativeClient(counters), + ) + second = actor_store( + actor, + receipt_ledger=ledger, + api_factory=lambda counters=counters: FakeNativeClient(counters), + ) + first.submit_order(submit_intent()) + with self.assertRaisesRegex(AccountActorGateError, "actor intent replay"): + second.submit_order(replace(submit_intent(), quantity=2)) + self.assertEqual(len(actor.submits), 1) + self.assertEqual(counters.constructed, 0) + self.assertEqual(counters.native_calls, 0) + self.assertEqual(first.legacy_calls + second.legacy_calls, 0) + + def test_receipt_wrong_command_is_rejected_without_local_fallback(self): + actor = FakeActorPort() + actor.receipt_mutator = lambda receipt: replace(receipt, command_id="forged-command") + counters = Counters() + store = actor_store( + actor, + api_factory=lambda counters=counters: FakeNativeClient(counters), + gateway_factory=lambda counters=counters: FakeNativeClient(counters), + ) + with self.assertRaisesRegex(AccountActorGateError, "receipt command mismatch"): + store.submit_order(submit_intent()) + self.assertEqual(len(actor.submits), 1) + self.assertEqual(counters.constructed, 0) + self.assertEqual(counters.native_calls, 0) + self.assertEqual(store.legacy_calls, 0) + + def test_receipt_wrong_operation_is_rejected_without_local_fallback(self): + actor = FakeActorPort() + actor.receipt_mutator = lambda receipt: replace(receipt, operation="CANCEL") + counters = Counters() + store = actor_store( + actor, + api_factory=lambda counters=counters: FakeNativeClient(counters), + gateway_factory=lambda counters=counters: FakeNativeClient(counters), + ) + with self.assertRaisesRegex(AccountActorGateError, "receipt operation mismatch"): + store.submit_order(submit_intent()) + self.assertEqual(len(actor.submits), 1) + self.assertEqual(counters.constructed, 0) + self.assertEqual(counters.native_calls, 0) + self.assertEqual(store.legacy_calls, 0) + + def test_receipt_rejects_stale_epoch_wrong_account_and_wrong_session(self): + context = actor_context() + mutations = ( + {"actor_epoch": context.actor_epoch + 1}, + {"account_ref": "ctp-account-ref.v1:other"}, + {"session_id": "other-session"}, + {"front_id": context.front_id + 1}, + {"native_session_id": context.native_session_id + 1}, + {"session_generation": context.session_generation + 1}, + {"runtime_id": "other-runtime"}, + {"mode": "live"}, + {"config_digest": "b" * 64}, + ) + for mutation in mutations: + actor = FakeActorPort() + actor.receipt_mutator = lambda receipt, mutation=mutation: replace( + receipt, context=actor_context(**mutation) + ) + counters = Counters() + store = actor_store( + actor, + context=context, + api_factory=lambda counters=counters: FakeNativeClient(counters), + gateway_factory=lambda counters=counters: FakeNativeClient(counters), + ) + with self.subTest(mutation=mutation): + with self.assertRaisesRegex(AccountActorGateError, "receipt context mismatch"): + store.submit_order(submit_intent(context=context)) + self.assertEqual(len(actor.submits), 1) + self.assertIsNone(store.api) + self.assertEqual(store.legacy_calls, 0) + self.assertEqual(counters.constructed, 0) + self.assertEqual(counters.native_calls, 0) + + def test_receipt_wrong_digest_and_untyped_receipt_are_rejected(self): + for mutator, expected_error in ( + ( + lambda receipt: replace(receipt, command_digest="f" * 64), + "receipt digest mismatch", + ), + (lambda _receipt: True, "receipt type invalid"), + ): + actor = FakeActorPort() + actor.receipt_mutator = mutator + counters = Counters() + store = actor_store( + actor, + api_factory=lambda counters=counters: FakeNativeClient(counters), + gateway_factory=lambda counters=counters: FakeNativeClient(counters), + ) + with self.subTest(expected_error=expected_error): + with self.assertRaisesRegex(AccountActorGateError, expected_error): + store.submit_order(submit_intent()) + self.assertEqual(len(actor.submits), 1) + self.assertEqual(counters.constructed, 0) + self.assertEqual(counters.native_calls, 0) + self.assertEqual(store.legacy_calls, 0) + + def test_receipt_failure_consumes_fake_local_replay_slot(self): + actor = FakeActorPort() + actor.receipt_mutator = lambda receipt: replace(receipt, command_id="wrong") + ledger = FakeLocalActorReplayLedger() + store = actor_store(actor, receipt_ledger=ledger) + with self.assertRaisesRegex(AccountActorGateError, "receipt command mismatch"): + store.submit_order(submit_intent()) + actor.receipt_mutator = None + with self.assertRaisesRegex(AccountActorGateError, "actor intent replay"): + store.submit_order(submit_intent()) + self.assertEqual(len(actor.submits), 1) + self.assertEqual(store.legacy_calls, 0) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/runtime/test_local_fake_actionref_checkpoint_candidate.py b/tests/unit/runtime/test_local_fake_actionref_checkpoint_candidate.py new file mode 100644 index 00000000..8a2a377b --- /dev/null +++ b/tests/unit/runtime/test_local_fake_actionref_checkpoint_candidate.py @@ -0,0 +1,160 @@ +"""Local fake-only tests for restart-persistent ActionRef snapshot checkpoints.""" + +from dataclasses import replace +from pathlib import Path +import subprocess +import sys + +import pytest + +from backtrader_runtime.ctp_managed_actionref_floor import ( + CtpManagedActionRefAllocationV1, + CtpManagedActionRefLedgerSnapshotV1, + ctp_managed_action_ref_mapping_sha256, +) +from backtrader_runtime._local_fake_actionref_checkpoint_candidate import ( + CtpLocalActionRefCheckpointError, + CtpLocalActionRefCheckpointSource, +) + + +ACCOUNT = "account:" + "a" * 64 +SCOPE = "scope:" + "b" * 64 +NOW = 1_800_000_000_000_000_000 + + +class _FakeSnapshotSource: + def __init__(self, snapshot): + self.snapshot = snapshot + + def read_action_ref_ledger(self, _account_key): + return self.snapshot + + +def _snapshot(*, epoch=8, cutover_id="cutover.20260928", observed_at_ns=NOW - 1_000_000): + allocations = ( + CtpManagedActionRefAllocationV1( + account_key=ACCOUNT, + native_action_ref=17, + command_id="command.cancel.17", + scope_key=SCOPE, + managed_action_id="action.cancel.17", + status="READY", + ), + ) + return CtpManagedActionRefLedgerSnapshotV1( + account_key=ACCOUNT, + cutover_id=cutover_id, + cutover_floor=12, + observed_native_high_water=16, + counter_high_water=17, + ledger_epoch=epoch, + ledger_sources=("g5", "v21"), + allocations=allocations, + unresolved_unknown_count=0, + observed_at_ns=observed_at_ns, + valid_until_ns=NOW + 1_000_000_000, + native_floor_source_digest_sha256="c" * 64, + merged_ledger_source_digest_sha256="d" * 64, + mapping_sha256=ctp_managed_action_ref_mapping_sha256(allocations), + ) + + +def test_checkpoint_rejects_epoch_rollback_after_process_restart(tmp_path): + checkpoint = tmp_path / "actionref-checkpoint.sqlite3" + seed_script = r""" +import sys +from backtrader_runtime.ctp_managed_actionref_floor import ( + CtpManagedActionRefAllocationV1, + CtpManagedActionRefLedgerSnapshotV1, + ctp_managed_action_ref_mapping_sha256, +) +from backtrader_runtime._local_fake_actionref_checkpoint_candidate import ( + CtpLocalActionRefCheckpointSource, +) + +account = "account:" + "a" * 64 +rows = (CtpManagedActionRefAllocationV1( + account_key=account, + native_action_ref=17, + command_id="command.cancel.17", + scope_key="scope:" + "b" * 64, + managed_action_id="action.cancel.17", + status="READY", +),) +snapshot = CtpManagedActionRefLedgerSnapshotV1( + account_key=account, + cutover_id="cutover.20260928", + cutover_floor=12, + observed_native_high_water=16, + counter_high_water=17, + ledger_epoch=8, + ledger_sources=("g5", "v21"), + allocations=rows, + unresolved_unknown_count=0, + observed_at_ns=1_799_999_999_999_000_000, + valid_until_ns=1_800_000_001_000_000_000, + native_floor_source_digest_sha256="c" * 64, + merged_ledger_source_digest_sha256="d" * 64, + mapping_sha256=ctp_managed_action_ref_mapping_sha256(rows), +) +class Source: + def read_action_ref_ledger(self, _account_key): + return snapshot +CtpLocalActionRefCheckpointSource( + source=Source(), database_path=sys.argv[1] +).read_action_ref_ledger(account) +""" + subprocess.run( + [sys.executable, "-c", seed_script, str(checkpoint)], + cwd=Path(__file__).resolve().parents[3], + check=True, + capture_output=True, + text=True, + ) + + restarted_source = _FakeSnapshotSource(_snapshot(epoch=7)) + restarted_consumer = CtpLocalActionRefCheckpointSource( + source=restarted_source, database_path=checkpoint + ) + with pytest.raises(CtpLocalActionRefCheckpointError) as error: + restarted_consumer.read_action_ref_ledger(ACCOUNT) + + assert error.value.code == "snapshot_epoch_regressed" + + +def test_checkpoint_rejects_same_epoch_rewrite_and_cutover_replacement(tmp_path): + checkpoint = tmp_path / "actionref-checkpoint.sqlite3" + source = _FakeSnapshotSource(_snapshot(epoch=8)) + consumer = CtpLocalActionRefCheckpointSource(source=source, database_path=checkpoint) + consumer.read_action_ref_ledger(ACCOUNT) + + source.snapshot = _snapshot(epoch=8, observed_at_ns=NOW - 2_000_000) + with pytest.raises(CtpLocalActionRefCheckpointError) as error: + consumer.read_action_ref_ledger(ACCOUNT) + assert error.value.code == "snapshot_same_epoch_changed" + + source.snapshot = _snapshot(epoch=9, cutover_id="replacement-cutover") + with pytest.raises(CtpLocalActionRefCheckpointError) as error: + consumer.read_action_ref_ledger(ACCOUNT) + assert error.value.code == "snapshot_cutover_changed_without_transition_proof" + + +def test_checkpoint_keeps_other_account_state_independent(tmp_path): + checkpoint = tmp_path / "actionref-checkpoint.sqlite3" + account_a = _FakeSnapshotSource(_snapshot(epoch=8)) + consumer_a = CtpLocalActionRefCheckpointSource(source=account_a, database_path=checkpoint) + assert consumer_a.read_action_ref_ledger(ACCOUNT).ledger_epoch == 8 + + other_account = "account:" + "e" * 64 + other_row = replace(_snapshot().allocations[0], account_key=other_account) + other_snapshot = replace( + _snapshot(), + account_key=other_account, + allocations=(other_row,), + mapping_sha256=ctp_managed_action_ref_mapping_sha256((other_row,)), + ) + consumer_b = CtpLocalActionRefCheckpointSource( + source=_FakeSnapshotSource(other_snapshot), database_path=checkpoint + ) + assert consumer_b.read_action_ref_ledger(other_account).ledger_epoch == 8