From d69557074017ab7d4d7c0e424820fdb400b445c4 Mon Sep 17 00:00:00 2001 From: cjharriskc-ai Date: Mon, 27 Jul 2026 01:29:37 -0500 Subject: [PATCH 1/3] Integrate DumbMoney Robinhood execution cell --- .github/ci/fast_subset.txt | 8 + .github/workflows/tests.yml | 1 + PROTECTED_MANIFEST.json | 23 +- .../dopey-robinhood-runner.v1.template.json | 44 + .../dopey-robinhood-runner.v1.schema.json | 183 ++ docs/DUMBMONEY_ROBINHOOD_CELL.md | 437 ++++ dopey_live/__init__.py | 30 +- dopey_live/dumbmoney_authority.py | 1172 +++++++++++ dopey_live/dumbmoney_command_feed.py | 1596 +++++++++++++++ dopey_live/dumbmoney_contract_feed.py | 846 ++++++++ dopey_live/dumbmoney_journal_anchor.py | 595 ++++++ dopey_live/dumbmoney_promotion.py | 585 ++++++ dopey_live/dumbmoney_windows_service.py | 1790 +++++++++++++++++ dopey_live/ledger.py | 117 ++ dopey_live/models.py | 29 +- dopey_live/robinhood_gateway.py | 1386 +++++++++++-- dopey_live/robinhood_mcp_direct.py | 512 +++++ dopey_live/service.py | 1280 +++++++++++- dopey_live/signals.py | 119 +- .../install_live_branch_schedule.ps1 | 10 +- operator_tools/live_branch.cmd | 6 +- operator_tools/operator_live_branch.py | 131 +- tests/_dummy_v2_manifest.txt | 1 + tests/dummy_blunder.py | 1 + ...apital-envelope-cap-order-negative.v1.json | 23 + .../dumbmoney/signed-capital-envelope.v1.json | 50 + tests/test_dumbmoney_command_feed.py | 1119 +++++++++++ tests/test_dumbmoney_contract_feed.py | 592 ++++++ tests/test_dumbmoney_direct_mcp.py | 887 ++++++++ tests/test_dumbmoney_journal_anchor.py | 236 +++ tests/test_dumbmoney_live_authority.py | 1126 +++++++++++ tests/test_dumbmoney_windows_service.py | 770 +++++++ tests/test_no_live_artifact_bindings.py | 2 +- uv.lock | 1309 ++++++++++++ verifier/protected_manifest.py | 7 + 35 files changed, 16793 insertions(+), 230 deletions(-) create mode 100644 configs/dumbmoney/dopey-robinhood-runner.v1.template.json create mode 100644 configs/dumbmoney/schemas/dopey-robinhood-runner.v1.schema.json create mode 100644 docs/DUMBMONEY_ROBINHOOD_CELL.md create mode 100644 dopey_live/dumbmoney_authority.py create mode 100644 dopey_live/dumbmoney_command_feed.py create mode 100644 dopey_live/dumbmoney_contract_feed.py create mode 100644 dopey_live/dumbmoney_journal_anchor.py create mode 100644 dopey_live/dumbmoney_promotion.py create mode 100644 dopey_live/dumbmoney_windows_service.py create mode 100644 dopey_live/robinhood_mcp_direct.py create mode 100644 tests/_dummy_v2_manifest.txt create mode 100644 tests/dummy_blunder.py create mode 100644 tests/fixtures/dumbmoney/capital-envelope-cap-order-negative.v1.json create mode 100644 tests/fixtures/dumbmoney/signed-capital-envelope.v1.json create mode 100644 tests/test_dumbmoney_command_feed.py create mode 100644 tests/test_dumbmoney_contract_feed.py create mode 100644 tests/test_dumbmoney_direct_mcp.py create mode 100644 tests/test_dumbmoney_journal_anchor.py create mode 100644 tests/test_dumbmoney_live_authority.py create mode 100644 tests/test_dumbmoney_windows_service.py create mode 100644 uv.lock diff --git a/.github/ci/fast_subset.txt b/.github/ci/fast_subset.txt index 19c6674..8599a88 100644 --- a/.github/ci/fast_subset.txt +++ b/.github/ci/fast_subset.txt @@ -89,3 +89,11 @@ tests/test_ci_and_epistemic_ledger_stability.py tests/test_epistemic_adversarial_supervision.py tests/test_epistemic_runtime_integration.py tests/test_squeeze_bubble_epistemic_hardening.py +tests/test_dumbmoney_live_authority.py +tests/test_dumbmoney_command_feed.py +tests/test_dumbmoney_contract_feed.py +tests/test_dumbmoney_direct_mcp.py +tests/test_dumbmoney_journal_anchor.py +tests/test_dumbmoney_windows_service.py +tests/test_live_cash_settlement_and_auth.py +tests/test_live_owner_liquidation_and_legacy_twin.py diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 770cfff..84b7618 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -159,6 +159,7 @@ jobs: ruff check . --select E9,F63,F7,F82 ruff check \ dopey \ + dopey_live \ dopey_acceleration \ dopey_execution \ dopey_governor \ diff --git a/PROTECTED_MANIFEST.json b/PROTECTED_MANIFEST.json index 1826b9e..c88fa5c 100644 --- a/PROTECTED_MANIFEST.json +++ b/PROTECTED_MANIFEST.json @@ -67,14 +67,21 @@ "dopey_governor/live_order_gate.py": "b3a06c62d3218a54ba28f84cdb5eaee4e345986690e9482eef19e760adfcaa28", "dopey_governor/promotion_policy.py": "a2d650285c2cde579378226758732e82be94f8396b8491f7bf0a09199eef8489", "dopey_governor/trade_permission_kernel.py": "8be9e4167151da04b0b01d229aa76d23b9eae3454c0452184368ff7f8047ac6f", - "dopey_live/__init__.py": "72a5cd8e2f1b1b38661c4af97a9101fa522a896b26de61982d1ee3676efe0d40", + "dopey_live/__init__.py": "3cc132b1af4c6e67627d8e3390830ea64c3c11f58b38dd17cd69c89f14d967ba", "dopey_live/control.py": "047b57171821bd0dedb26024b21a8a9c1365e398881a2182c6d87830184b0286", - "dopey_live/ledger.py": "228eaff3d398e0578a44a300223141835726033fc975955f90b689a68429681b", - "dopey_live/models.py": "938a660732c48e7198fb8b89ece16ba4392b5264083ad2088ce52ff576bc1bdc", - "dopey_live/robinhood_gateway.py": "6239e7b351623d5eb41a74caf73fcaa8dd3edd11fb3496982306b5b6970874b1", - "dopey_live/service.py": "fad0bccd3539d6d7968491245efd46857f31dbbbdddbe5019db2dcbc80c3f956", + "dopey_live/dumbmoney_authority.py": "214c3cd32e6e3ddca6f188d8301251433494ff0cad61cacedf4d8adb941ca554", + "dopey_live/dumbmoney_command_feed.py": "63b5ae71fdea758004f303521ee4c3c1b499e98ae4d8010ad3e9ea80ea122e74", + "dopey_live/dumbmoney_contract_feed.py": "e7a00f12afbfff4de1dead1e4685c6421f4d1e932371bb7a6de4a034a1c8b927", + "dopey_live/dumbmoney_journal_anchor.py": "af45c1f00075deb1e6c03b17318228375a72dfd2c2550d8376bd5f023b4aebf5", + "dopey_live/dumbmoney_promotion.py": "b919df0ce3870f8c6490a8e24c8c52a64aa6f598979c469ecdc59760b0fef57f", + "dopey_live/dumbmoney_windows_service.py": "4cba589b6e0a05f69916fdd8ca45321898d8c8fc07a44c6f05a5385010bc0a47", + "dopey_live/ledger.py": "934d5a03e5a378400ec5fd787e270a948c57e0235615ca0b57ab78e6c1b993b7", + "dopey_live/models.py": "e1f78db857fae5c72ca368724b78096c5d3ba7b3497c5a52c29d9ddeca0f05a9", + "dopey_live/robinhood_gateway.py": "e6c3a07b2013d5a26bddb5f9d665d3cd137ccee2b5581f1e22de75d2d0492fb3", + "dopey_live/robinhood_mcp_direct.py": "a181bebbb99e094440a2ae0353ad100a1883ce0c57bdef16c0c94ef6b69d6c7a", + "dopey_live/service.py": "899611c7d0dcd1240ef3467ec39f24defdc1b6cb754c64ad049407116e3f8b46", "dopey_live/settlement.py": "336892885775e3476010e5a6b319c9f4299c92758c0c7fc4f997787f049d8dd9", - "dopey_live/signals.py": "9bd1beb39b7769918f3e82c7b6492a804a95aea79c4457a85ce04a4ab1b6d778", + "dopey_live/signals.py": "b45298ef50ae9d77766cb33575e71095870e4fddbfe12f9be662bbd63b4452d4", "environments/environment_v1.json": "811d348d94e151087cd77d496bd1f5135b24dd40ba107b5d860cde34b8475652", "pyproject.toml": "07939e32fda357c13d347aecc5f95139d9d4aa236e29b7722ebb2fd378da1f27", "sealed_evaluator_client/__init__.py": "f97f1169fdf35b1331d93cb1147510d35e0ed7df89121ae162705bb645eff0aa", @@ -87,8 +94,8 @@ "verifier/environment_freeze.py": "e6310223af95c1fb4ba319400aaa335de2e23fe612d2186dcd9531778b39a177", "verifier/external_root.py": "e5eafa732082ca1a3e50456f42b0c249fc84979a15c04056439dd35594821945", "verifier/path_policy.py": "740d9947b7b12cec4db86f3aae9c9f6c26c029a7243efb047d1fe57ee39c3ea5", - "verifier/protected_manifest.py": "f0f5d1aa53739236cdedde3d3a3d57428bf1f6c5caaf6fab01f8f9e5979f4982" + "verifier/protected_manifest.py": "2dd350cbe84c3638c78d164a85acb7207a472d5398c593792aa913d18e2a6343" }, - "manifest_hash": "11da8aec9fedb8b8cea4dc9a698fdf68fb2798d23dac69dae28177392240f2d1", + "manifest_hash": "894c621aab645f30eef84802ed987138da4fa35236a083fa996bccaebbb56470", "schema_version": "dopey-protected-manifest-v1" } diff --git a/configs/dumbmoney/dopey-robinhood-runner.v1.template.json b/configs/dumbmoney/dopey-robinhood-runner.v1.template.json new file mode 100644 index 0000000..4165a95 --- /dev/null +++ b/configs/dumbmoney/dopey-robinhood-runner.v1.template.json @@ -0,0 +1,44 @@ +{ + "schema": "dopey.dumbmoney-robinhood-runner-config.v1", + "service_name": "DumbMoneyDopeyRobinhood", + "release_id": "TO_BE_RESOLVED", + "core_endpoint_ref": "endpoint-ref:DumbMoneyCore", + "core_readiness_path": "C:\\ProgramData\\DumbMoney\\readiness\\DumbMoneyCore.json", + "core_cell_token_target": "credential-target:DumbMoney/DopeyCellToken", + "robinhood_profile_target": "credential-target:DumbMoney/RobinhoodProfile", + "start_mode": "RECONCILIATION_ONLY", + "data_root": "C:\\ProgramData\\DumbMoney\\dopey-robinhood", + "readiness_ref": "readiness-ref:DumbMoneyDopeyRobinhood", + "readiness_path": "C:\\ProgramData\\DumbMoney\\readiness\\DumbMoneyDopeyRobinhood.json", + "core_public_key_base64url": "TO_BE_RESOLVED", + "operator_public_keys_base64url": { + "TO_BE_RESOLVED": "TO_BE_RESOLVED" + }, + "promoter_public_keys_base64url": { + "TO_BE_RESOLVED": "TO_BE_RESOLVED" + }, + "research_public_keys_base64url": { + "TO_BE_RESOLVED": "TO_BE_RESOLVED" + }, + "evaluator_public_keys_base64url": { + "TO_BE_RESOLVED": "TO_BE_RESOLVED" + }, + "expected_account_hash": "TO_BE_RESOLVED", + "fund_lock_sha256": "TO_BE_RESOLVED", + "service_manifest_sha256": "TO_BE_RESOLVED", + "role_public_keys_sha256": "TO_BE_RESOLVED", + "core_runner_config_sha256": "TO_BE_RESOLVED", + "risk_policy_sha256": "TO_BE_RESOLVED", + "readiness_signer_public_key_sha256": "TO_BE_RESOLVED", + "codex_executable_path": "TO_BE_RESOLVED", + "codex_executable_sha256": "TO_BE_RESOLVED", + "codex_local_provider": "ollama", + "codex_local_model": "TO_BE_RESOLVED", + "codex_local_model_digest": "TO_BE_RESOLVED", + "ollama_executable_path": "TO_BE_RESOLVED", + "ollama_executable_sha256": "TO_BE_RESOLVED", + "ollama_process_identity_sid": "TO_BE_RESOLVED", + "ollama_runtime_evidence_sha256": "TO_BE_RESOLVED", + "poll_interval_seconds": 15, + "readiness_ttl_seconds": 90 +} diff --git a/configs/dumbmoney/schemas/dopey-robinhood-runner.v1.schema.json b/configs/dumbmoney/schemas/dopey-robinhood-runner.v1.schema.json new file mode 100644 index 0000000..6e403e0 --- /dev/null +++ b/configs/dumbmoney/schemas/dopey-robinhood-runner.v1.schema.json @@ -0,0 +1,183 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://obtuse.ai/schemas/dumbmoney/dopey-robinhood-runner.v1.schema.json", + "title": "DumbMoney Dopey Robinhood Windows runner public config", + "type": "object", + "additionalProperties": false, + "required": [ + "schema", + "service_name", + "release_id", + "core_endpoint_ref", + "core_readiness_path", + "core_cell_token_target", + "robinhood_profile_target", + "start_mode", + "data_root", + "readiness_ref", + "readiness_path", + "core_public_key_base64url", + "operator_public_keys_base64url", + "promoter_public_keys_base64url", + "research_public_keys_base64url", + "evaluator_public_keys_base64url", + "expected_account_hash", + "fund_lock_sha256", + "service_manifest_sha256", + "role_public_keys_sha256", + "core_runner_config_sha256", + "risk_policy_sha256", + "readiness_signer_public_key_sha256", + "codex_executable_path", + "codex_executable_sha256", + "codex_local_provider", + "codex_local_model", + "codex_local_model_digest", + "ollama_executable_path", + "ollama_executable_sha256", + "ollama_process_identity_sid", + "ollama_runtime_evidence_sha256", + "poll_interval_seconds", + "readiness_ttl_seconds" + ], + "properties": { + "schema": { + "const": "dopey.dumbmoney-robinhood-runner-config.v1" + }, + "service_name": { + "const": "DumbMoneyDopeyRobinhood" + }, + "release_id": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "core_endpoint_ref": { + "const": "endpoint-ref:DumbMoneyCore" + }, + "core_readiness_path": { + "const": "C:\\ProgramData\\DumbMoney\\readiness\\DumbMoneyCore.json" + }, + "core_cell_token_target": { + "const": "credential-target:DumbMoney/DopeyCellToken" + }, + "robinhood_profile_target": { + "const": "credential-target:DumbMoney/RobinhoodProfile" + }, + "start_mode": { + "const": "RECONCILIATION_ONLY" + }, + "data_root": { + "const": "C:\\ProgramData\\DumbMoney\\dopey-robinhood" + }, + "readiness_ref": { + "const": "readiness-ref:DumbMoneyDopeyRobinhood" + }, + "readiness_path": { + "const": "C:\\ProgramData\\DumbMoney\\readiness\\DumbMoneyDopeyRobinhood.json" + }, + "core_public_key_base64url": { + "$ref": "#/$defs/public_key" + }, + "operator_public_keys_base64url": { + "$ref": "#/$defs/key_map" + }, + "promoter_public_keys_base64url": { + "$ref": "#/$defs/key_map" + }, + "research_public_keys_base64url": { + "$ref": "#/$defs/key_map" + }, + "evaluator_public_keys_base64url": { + "$ref": "#/$defs/key_map" + }, + "expected_account_hash": { + "$ref": "#/$defs/sha256" + }, + "fund_lock_sha256": { + "$ref": "#/$defs/sha256" + }, + "service_manifest_sha256": { + "$ref": "#/$defs/sha256" + }, + "role_public_keys_sha256": { + "$ref": "#/$defs/sha256" + }, + "core_runner_config_sha256": { + "$ref": "#/$defs/sha256" + }, + "risk_policy_sha256": { + "$ref": "#/$defs/sha256" + }, + "readiness_signer_public_key_sha256": { + "$ref": "#/$defs/sha256" + }, + "codex_executable_path": { + "type": "string", + "minLength": 3, + "maxLength": 1024 + }, + "codex_executable_sha256": { + "$ref": "#/$defs/sha256" + }, + "codex_local_provider": { + "const": "ollama" + }, + "codex_local_model": { + "type": "string", + "pattern": "^[a-z0-9][a-z0-9._-]*(/[a-z0-9][a-z0-9._-]*)*:[a-z0-9][a-z0-9._-]{0,63}$", + "not": { + "pattern": ":(latest|cloud)$" + } + }, + "codex_local_model_digest": { + "$ref": "#/$defs/sha256" + }, + "ollama_executable_path": { + "type": "string", + "minLength": 3, + "maxLength": 1024 + }, + "ollama_executable_sha256": { + "$ref": "#/$defs/sha256" + }, + "ollama_process_identity_sid": { + "type": "string", + "pattern": "^S-1-5-([0-9]+-){1,14}[0-9]+$" + }, + "ollama_runtime_evidence_sha256": { + "$ref": "#/$defs/sha256" + }, + "poll_interval_seconds": { + "type": "integer", + "minimum": 5, + "maximum": 300 + }, + "readiness_ttl_seconds": { + "type": "integer", + "minimum": 10, + "maximum": 120 + } + }, + "$defs": { + "sha256": { + "type": "string", + "pattern": "^[0-9a-f]{64}$" + }, + "public_key": { + "type": "string", + "pattern": "^[A-Za-z0-9_-]{43}$" + }, + "key_map": { + "type": "object", + "minProperties": 1, + "maxProperties": 16, + "additionalProperties": false, + "patternProperties": { + "^[0-9a-f]{64}$": { + "$ref": "#/$defs/public_key" + } + } + } + } +} diff --git a/docs/DUMBMONEY_ROBINHOOD_CELL.md b/docs/DUMBMONEY_ROBINHOOD_CELL.md new file mode 100644 index 0000000..264d777 --- /dev/null +++ b/docs/DUMBMONEY_ROBINHOOD_CELL.md @@ -0,0 +1,437 @@ +# DumbMoney Robinhood venue cell + +`dopey_live` is the sovereign Robinhood execution cell for DumbMoney. The +older `dopey_execution` Robinhood adapter remains a discovery-only denial stub +and is not imported or routed by this integration. DumbMoney Core can narrow +DOPEY authority; it cannot enlarge the owner control, broker account scope, +settled cash, local risk policy, research gates, or broker capabilities. + +## Release status + +The private-local Windows service entrypoint is +`dopey_live.dumbmoney_windows_service:main`, packaged as +`DumbMoneyDopeyRobinhood.exe`. It is separate from the inherited operator CLI; +that CLI cannot satisfy the service credential, discovery, or freshness +contract and remains fail-closed in strict mode. + +Importing any DumbMoney module starts no listener, timer, thread, network +request, or broker call. Service startup validates configuration and +credentials, acquires its ProgramData lease, starts a loopback-only health +endpoint, and writes a signed `RECONCILIATION_ONLY` readiness descriptor. It +waits one poll interval before its first read-only reconciliation. New exposure +can be considered only on a later cycle. + +The legacy standalone operator remains available outside strict release mode. +A manually supplied signed capital envelope can be used for preflight: + +```powershell +py -3.12 -m operator_tools.operator_live_branch ` + --dumbmoney-envelope C:\secure\capital-envelope.json ` + --dumbmoney-public-key C:\secure\dumbmoney-core.pub ` + --dumbmoney-account-hash +``` + +The legacy CLI is read-only preflight: every `--execute` invocation is rejected +before gateway or model construction, including programmatic calls through its +crash-tombstone wrapper. Production execution exists only in the supervised +Windows service. A static envelope is not the production Core command channel. +The manually supplied envelope file must be exact canonical UTF-8 JSON: +duplicate keys, non-finite numbers, floats, alternate key order/whitespace, a +byte-order mark, and trailing bytes are rejected. + +## Authenticated pull-only command feed + +Core exposes the local-only endpoint: + +```text +GET http://127.0.0.1:/v1/cells/dopey_robinhood/commands + ?after=&cursor=&limit= + &request_nonce=<64-lowercase-hex> +``` + +Use `DumbMoneyCommandFeedClient` to bind deployment-owned dependencies. The +bearer provider must read the per-cell secret from an OS-backed secret store; +the bearer is never accepted from a CLI flag, environment variable, persisted +state, log, or artifact. The HTTP transport is also injected, so library import +cannot contact Core. + +```python +from datetime import datetime +from pathlib import Path + +from dopey_live import ( + DumbMoneyCommandFeedClient, + DumbMoneyContractResolverClient, +) +from dopey_live.dumbmoney_command_feed import command_feed_state_path +from operator_tools.operator_live_branch import run_supervised_dumbmoney_cycle + +repository_root = Path(r"C:\path\to\dopey") +client = DumbMoneyCommandFeedClient( + base_url="http://127.0.0.1:8765", + cell_bearer_provider=os_secret_provider, + trusted_public_key=trusted_core_public_key, + trusted_operator_public_keys=sealed_operator_public_key_map, + expected_account_hash=pinned_agentic_account_hash, + state_path=command_feed_state_path(repository_root), + transport=loopback_http_transport, +) +resolver = DumbMoneyContractResolverClient( + base_url="http://127.0.0.1:8765", + cell_bearer_provider=os_secret_provider, + trusted_core_public_key=trusted_core_public_key, + trusted_promoter_public_keys=sealed_promoter_public_key_map, + trusted_research_public_keys=sealed_research_public_key_map, + trusted_evaluator_public_keys=sealed_evaluator_public_key_map, + transport=loopback_http_transport, +) + +# The helper polls under the Live Branch process lock, then passes the same +# callable to the broker-sink guard for immediate pre-spawn and post-return polls. +result = run_supervised_dumbmoney_cycle( + client.poll_once, + resolver, + execute_orders=False, +) +``` + +`os_secret_provider`, the Core/operator/promoter/research/evaluator public +keys, the account hash, and `loopback_http_transport` are deployment +dependencies, not repository defaults. The production Windows runner supplies +these bindings from one exact-SHA public config and two Credential Manager +targets. + +The consumer: + +- restricts Core to an exact `http://127.0.0.1:` origin; +- sends the injected bearer only in the request authorization header; +- generates a fresh cryptographically random 32-byte request nonce for every + poll and rejects any top-level or signed-checkpoint echo mismatch; +- verifies the page checkpoint with the pinned Core key; +- recomputes every full ledger-event proof and binds its event identity, + source sequence, signer, body digest, timestamps, causality, and hash-chain + material to the original signed envelope; +- accepts capital envelopes only from the Core key and kill/desired-mode + envelopes only from the disjoint sealed operator key set; +- enforces per-control monotonic generation/revision continuity; +- advances a digest cursor and atomically replaces its local state file; +- re-verifies persisted commands after restart; +- replays expired signed history to recover sequence state, while never + granting stale/future LIVE mode or capital; +- preserves an expired active kill as active until a newer signed generation + clears it; +- maps authentication, transport, state, schema, signature, gap, cursor, + pagination, omission, and unknown failures to `kill_active=True` or + paused/unknown, never to positive authority. + +The Core-signed checkpoint covers the request cursor, ordered command +projections and complete ledger proofs, next cursor, ledger head, observation +time, required action, and per-request nonce. Page fields must exactly match +that checkpoint. This prevents an untrusted transport from replaying, omitting, +or rewriting a command. Core and its signing key remain the root control-plane +trust boundary, so keep them inside the protected host and prove kill latency +during the attended canary. + +## Core-backed local rollback anchors + +Before any broker reconciliation and again after command-state and cycle +mutations, the Windows runner publishes integrity-checked heads for the command +cursor, live-branch ledger, and paper-twin ledger to Core's authenticated +`/v1/cells/dopey_robinhood/journal-heads:anchor` route. Each request binds the +pinned account, deterministic journal stream, local sequence, local head, and +fresh nonce. Dopey accepts the result only after verifying the Core-signed +anchor envelope, request-bound checkpoint, and recomputed global-ledger proof. + +Core rejects a lower sequence or a different head at the same sequence. Dopey +therefore detects restored, truncated, or forked local state even if the local +files remain internally hash-consistent. A rejected pre-cycle anchor returns +`blocked_rollback_anchor` before any Robinhood contact. A failed post-cycle +anchor makes readiness `BLOCKED`, disables the following execution cycle, and +preserves the cycle result for reconciliation. `/health/ready` requires current +anchors, complete reconciliation, and `execution_enabled=true`; mere process +liveness or read-only reconciliation is not execution readiness. + +## Capital and promotion authority + +`CapitalEnvelopeV1` is Ed25519-signed, account-bound, and valid for at most 60 +seconds. The cell verifies it at cycle allocation, at broker-sink preparation, +and again through the refreshed command feed immediately before the external +write boundary. + +The contract requires: + +- exactly one `strategy_hash`, one `passport_hash`, and one `promotion_hash`, + forming a single non-mixable tuple per capital lease; +- sorted, nonempty, exact Robinhood sink identities: + `equity:` or + `option::`; +- `max_order_risk_cents <= max_correlated_risk_cents <= + max_open_risk_cents`; +- positive open-order, daily-loss, generation, and policy-epoch limits. + +Wildcards, symbol prefixes, instrument-class-only grants, source-hash aliases, +and multi-candidate capital sets are rejected. Local signal builders leave +`passport_hash` and `promotion_hash` empty. The resolver derives both values; +caller-supplied values are rejected. + +For each buy, Dopey calls the authenticated Core route: + +```text +GET /v1/cells/dopey_robinhood/contracts/ + ?request_nonce=<64-lowercase-hex> + &capital_envelope_digest= +``` + +It verifies the nonce-bound Core checkpoint, complete recomputed ledger proof, +active capital digest and fencing generation, original signed envelope, and +currentness projection. Promotion certificates must come from the disjoint +sealed promoter key set; passports must come from the disjoint sealed research +key set. Each resolution must also contain the Core-signed +`dumbmoney.cell-authority-state.v1` projection for the same ledger state. Dopey +requires current LIVE mode, clear kill state, the exact mandate, policy epoch, +capital fence, strategy/passport/promotion tuple, at least two unique current +PASS verdict facts from distinct courts and sealed evaluator signer IDs, and a +ledger head continuous with the requested contract proof. The promotion and +passport reads must resolve against one identical authority state. Venue, +exact instrument, policy epoch, strategy, passport link, and maximum loss must +agree with the active singleton capital tuple. The full resolution occurs at +preflight, broker-sink preparation, and inside each final broker-boundary +guard. A changed event, signer, lineage, verdict, ledger head, capital fence, +or expiry fails closed. + +The canonical cross-repository positive fixture is +`tests/fixtures/dumbmoney/signed-capital-envelope.v1.json`, whose file SHA-256 +is +`b0fe2fa0261b2dc36e4043e8c33b91b1016676e04a25e4b4d5bd66eea0c9079a`. +Local negative conformance cases cover both invalid cap-order directions. + +A persisted fence binds the complete authority identity: + +```text +(envelope_id, event_id, body_digest, fencing_generation, policy_epoch) +``` + +Reusing one generation with any different identity is rejected, and a stale +generation cannot regain authority after process-lock reacquisition. + +## Deterministic broker-write checkpoint and revocation window + +The generative subprocess receives neither the Robinhood bearer nor the +Robinhood MCP endpoint. Broker reads and writes occur only in the parent +process through `DeterministicRobinhoodMcpClient`; a configured parent +transport cannot fall back to a model-selected tool call. + +[Robinhood's official Agentic overview](https://robinhood.com/us/en/support/articles/agentic-trading-overview/) +says an authenticated agent may read all Robinhood accounts but can place +trades only in its Agentic account. Dopey therefore calls `get_accounts` inside +every write session, requires exactly one explicitly labeled Agentic account, +and binds its SHA-256 to the account hash sealed in both runner config and +capital authority. The current equity write schemas expose no account +selector; adding one is schema drift and blocks the call. This same-session +proof plus Robinhood's server-side Agentic-only write contract prevents silent +default-account selection. + +The phase-one write path is deliberately equity-only: + +1. persist `DISPATCHING` with zero placement retries; +2. refresh kill, mode, capital, exact authority identity, fence, promotion, + passport, local kill, and current account evidence; +3. require more than the configured 30-second write timeout plus a 5-second + safety margin on the authority lease; +4. construct one exact quantity-mode equity argument object locally; +5. open a one-shot official Streamable HTTP MCP session, verify the live + `review_equity_order` and `get_accounts` schemas, prove the pinned Agentic + account, and call review exactly once; +6. bind the typed review response to the exact local arguments and require + explicit approval; +7. refresh the complete signed authority again after review and immediately + before placement; +8. open a second one-shot MCP session, verify the live place/account schemas, + prove the same pinned Agentic account, refresh authority after schema + verification, and call `place_equity_order` exactly once with the identical + argument object; +9. never retry an attempted placement; an incomplete, conflicting, timed-out, + or non-attested response is ambiguous and enters reconcile/cancel; +10. refresh controls again after return. + +The direct client disables proxy inheritance and redirects, pins the MCP +protocol version, checks request IDs and typed JSON/SSE responses, closes +initialized sessions in a `finally` path, rejects tool schema drift before the +write call, and exposes only sealed read tools plus the three equity +review/place/cancel names to parent code. Parent reads preserve broker evidence +without model normalization: negative/overflow balances, missing or +conflicting position economics, incomplete history, bad timestamps, and +missing/extra quote symbols fail closed. Option writes remain blocked until +their exact current schemas, contract identity, quotes, and result semantics +receive the same deterministic adapter. + +A kill that arrives after step 7 can still race the single outbound placement +request. The system does not claim instantaneous revocation. The bounded +timeout, at-most-once dispatch, ambiguous state, post-return poll, and +immediate cancel/reconcile remain the truthful in-flight guarantees. + +## Risk and sell-side behavior + +New exposure intersects the signed grant with settled cash, complete broker +positions, all active orders, worst-case intent loss, and witnessed daily loss. +Worst-case buy risk must fit the remaining daily-loss capacity; equality at +the boundary is allowed. Missing broker fee evidence or missing unrealized PnL +blocks the calculation rather than being represented as zero. + +Until a signed cross-account risk snapshot carries independently witnessed +cluster attribution, DOPEY conservatively applies the correlated-risk ceiling +to total observed open risk. It does not claim a reconciliation receipt digest, +high-water drawdown, or true cluster-level attribution it cannot witness. + +Sells are risk-reducing only. A sell requires exactly one fresh matching long +broker position, a positive finite quantity no greater than the witnessed +holding, and no active sell for the same stock or exact option contract. Naked +sells, oversells, and duplicate exits fail closed. + +## Kill and degraded operation + +Either a signed feed kill or the local file +`artifacts/dopey/live_branch/DUMBMONEY_KILL.v1` blocks new exposure. An active +kill: + +1. attempts one deterministic official-MCP cancellation for each active, + DOPEY-attributed equity order; +2. polls complete order history at most three times; +3. reports `CANCEL_ONLY_DEGRADED` until history is terminal and reconciled. + +Production cancellation uses only the sealed `cancel_equity_order` parent-side +adapter. Option cancellation remains blocked until it receives the same +deterministic schema and result attestation. The cancel path never reviews, +replaces, or places an order. Unattributed orders, unidentified ambiguous +submissions, option orders, and residual positions remain attended blockers; +they are not silently represented as canceled or flat. + +Ambiguous placement is never retried. The cell resolves it automatically only +when bounded, complete read-only broker history supplies one exact match for +account, instrument, contract, side, quantity/notional, limit, time-in-force, +market date, and the submission window. Every other case remains +operator-visible and cancel-only. + +## Laptop migration and attended canary + +Move non-secret DOPEY data from the laptop as a content-hashed, staged import +under `C:\ProgramData\DumbMoney\dopey-robinhood`; do not copy OAuth cookies, +browser profiles, bearer tokens, or plaintext credentials. + +## Private-local Windows runner + +The runner loads only: + +- the exact raw bytes of + `C:\ProgramData\DumbMoney\config\dopey-robinhood-runner.v1.json`, whose + lowercase SHA-256 must equal `--config-sha256`; +- the cell bearer from Windows Credential Manager target + `DumbMoney/DopeyCellToken`; +- the official Robinhood MCP bearer profile from Windows Credential Manager + target `DumbMoney/RobinhoodProfile`. + +There is no environment-variable, plaintext-file, hosted coordinator, +telemetry, or public-repository secret fallback. +Core/operator/promoter/research/evaluator public keys, the account hash, Core +readiness pins, release identity, and fixed ProgramData paths are public +config. `role_public_keys_sha256` is the canonical SHA-256 of Core's complete +`role_public_keys_base64url` object. The release must derive the Dopey +readiness signer from the cell credential and require its public-key ID to +equal the sole `dopey_venue` role key; an ambiguous role set fails release +assembly. The absolute Codex child executable and its SHA-256 are also pinned +there; the runner re-hashes it at construction and immediately before every +child spawn, so the credential-bearing process is never selected through +`PATH`. The sealed release installs that dependency at +`C:\Program Files\DumbMoney\releases\\tools\codex\codex.exe`; +the runner does not download or fabricate it. + +Any local generative Codex execution is forced to `--oss --local-provider +ollama` at the literal `http://127.0.0.1:11434/v1` endpoint, without Robinhood +MCP configuration or broker credentials. It never inherits `HOME`, +`USERPROFILE`, `CODEX_HOME`, `OPENAI_API_KEY`, or a hosted-provider credential. +The public config pins one explicit, non-`latest`, non-`cloud` Ollama model tag +and its 64-character manifest digest, plus the absolute Ollama server +executable and its SHA-256. Immediately before every Codex spawn, +the runner uses the Windows TCP owner table to require exactly one +`127.0.0.1:11434` listener, resolves its process image, requires the pinned +absolute path and binary hash, reads the loopback-only `/api/tags` inventory, +requires exactly one matching tag/model/digest tuple, then re-checks that the +same PID, process SID, image, and hash still own the listener. Missing, +ambiguous, changed, wildcard-bound, wrong-owner, or path-hijacked local model +state blocks the broker operation before the subprocess. + +The config also pins the raw SHA-256 of the static installation attestation at +`C:\ProgramData\DumbMoney\dopey-robinhood\evidence\ollama-runtime-evidence.v1.json`. +The release artifact binds the dedicated non-service local process identity, +loopback-only/no-egress policy, executable, ACL-protected model-store owner and +SDDL digest, model-store manifest, and model tag/digest. Dopey hashes that +artifact at use time and then independently revalidates the live listener PID, +owner image, executable hash, and model inventory; the static artifact contains +no restart-volatile PID. +OpenRouter is therefore outside this privileged execution transport; it may be +budgeted for research and planning without becoming Robinhood order authority. + +The config schema and release template are +`configs/dumbmoney/schemas/dopey-robinhood-runner.v1.schema.json` and +`configs/dumbmoney/dopey-robinhood-runner.v1.template.json`. + +Core discovery reads only the current Core-signed descriptor at +`C:\ProgramData\DumbMoney\readiness\DumbMoneyCore.json`. Dopey verifies its +Core signature, release and artifact pins, short lifetime, broker-none +authority, and literal loopback endpoint before calling `/health/live`, +`/v1/cells/dopey_robinhood/commands`, or the contract resolver. It never +receives the operator token and does not treat the unsigned Core health route +as authority; the nonce-bound Core command checkpoint is required. + +The service writes a short-lived Ed25519-signed descriptor to +`C:\ProgramData\DumbMoney\readiness\DumbMoneyDopeyRobinhood.json`. Its signing +key is domain-separated from the Credential Manager cell bearer, and the +derived public-key SHA-256 is pinned in the public config. Readiness reports +`execution_enabled=false` and mode `RECONCILIATION_ONLY` until a complete +broker reconciliation, all current signed controls, and all three Core-backed +local journal anchors agree. + +One Windows byte-range lease on the exact ProgramData root excludes concurrent +service writers; the OS releases it on process death, and a restart must +reacquire it before any cycle. Intent reservation and fence activation are +read-check-append transactions under the cross-process ledger lock and are +flushed to disk. An interrupted reservation stays unresolved and blocks a new +dispatch until broker reconciliation. The final guard re-reads the current +fence under that lock, polls fresh Core authority, checks the local kill file, +and runs both before review and again immediately before the one placement +call. Cross-process regression tests prove a second writer is denied while the +lease is held and can acquire only after the first process exits. + +The runner gives the Robinhood profile only to the deterministic parent-side +MCP client. No generative process receives the bearer, the MCP endpoint, an +account identifier, or a broker tool. The bearer is never placed in argv, +prompt, ledger, readiness, logs, artifacts, or subprocess environment. +Parent-side MCP evidence remains in memory and only typed redacted snapshots +enter Dopey's ledger. + +Before the first live canary: + +- install the exact-SHA public config with disjoint Core/operator/promoter/ + research/evaluator public-key roles, the exact canonical Core role-bundle + digest, the sole matching Dopey venue readiness key, and the dedicated + Agentic account hash; +- provision the per-cell bearer in the OS secret provider and test 401/403, + timeout, restart, stale-state, journal rollback/fork, cursor-gap, kill, and + kill-clear behavior; +- provision the Robinhood profile only in Windows Credential Manager and + verify it is not inherited from environment or plaintext files; +- install the sealed `DumbMoneyDopeyRobinhood.exe` service runner with the + exact config SHA argument; +- install the release-selected tool-capable Ollama model locally, populate its + explicit tag, `/api/tags` digest, absolute server executable, and executable + SHA-256 in the public config, and deny Ollama cloud models and non-loopback + listeners; +- prove broker history exposes explicit per-fill fees, or add an attested + realized-PnL source; missing fee evidence blocks new exposure after a fill; +- add transcript-attested `get_option_quotes` evidence before enabling options; +- perform attended place, cancel, history-convergence, restart, pre-spawn kill, + and in-flight kill drills at minimum capital; +- keep external confirmation that no unattributed or ambiguous order remains. + +These are activation requirements, not profitability or broker-readiness +claims. diff --git a/dopey_live/__init__.py b/dopey_live/__init__.py index d607e67..16c772f 100644 --- a/dopey_live/__init__.py +++ b/dopey_live/__init__.py @@ -1,6 +1,34 @@ """DOPEY Live Branch: official Robinhood Agentic execution with a paper twin.""" from .control import LiveBranchControl, load_live_branch_control +from .dumbmoney_authority import ( + CAPITAL_ENVELOPE_SCHEMA, + DESIRED_MODE_SCHEMA, + KILL_STATE_SCHEMA, + SIGNED_ENVELOPE_SCHEMA, + DumbMoneyAuthorityContext, +) +from .dumbmoney_command_feed import ( + DumbMoneyCommandDispatch, + DumbMoneyCommandFeedClient, + poll_once, +) +from .dumbmoney_contract_feed import DumbMoneyContractResolverClient +from .dumbmoney_journal_anchor import CoreJournalAnchorClient from .service import run_live_branch_cycle -__all__ = ["LiveBranchControl", "load_live_branch_control", "run_live_branch_cycle"] +__all__ = [ + "CAPITAL_ENVELOPE_SCHEMA", + "DESIRED_MODE_SCHEMA", + "KILL_STATE_SCHEMA", + "SIGNED_ENVELOPE_SCHEMA", + "CoreJournalAnchorClient", + "DumbMoneyAuthorityContext", + "DumbMoneyCommandDispatch", + "DumbMoneyCommandFeedClient", + "DumbMoneyContractResolverClient", + "LiveBranchControl", + "load_live_branch_control", + "poll_once", + "run_live_branch_cycle", +] diff --git a/dopey_live/dumbmoney_authority.py b/dopey_live/dumbmoney_authority.py new file mode 100644 index 0000000..9e85fce --- /dev/null +++ b/dopey_live/dumbmoney_authority.py @@ -0,0 +1,1172 @@ +"""DumbMoney capital authority for the sovereign Robinhood venue cell. + +The fund control plane can grant *less* authority than DOPEY's local controls, +never more. The signed envelope is verified at allocation time and again at +the broker sink so an expired or superseded lease cannot be used after a slow +research or quote step. +""" + +from __future__ import annotations + +import base64 +import hashlib +import json +import math +import re +from dataclasses import asdict, dataclass +from datetime import datetime, timedelta, timezone +from pathlib import Path +from typing import Any, Final + +from cryptography.exceptions import InvalidSignature +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey + +from .models import AccountSnapshot, LiveExecutionIntent + + +SIGNED_ENVELOPE_SCHEMA: Final[str] = "dumbmoney.signed-envelope.v1" +CAPITAL_ENVELOPE_SCHEMA: Final[str] = "dumbmoney.capital-envelope.v1" +DESIRED_MODE_SCHEMA: Final[str] = "dumbmoney.desired-mode.v1" +KILL_STATE_SCHEMA: Final[str] = "dumbmoney.kill-state.v1" +DOPEY_VENUE: Final[str] = "dopey_robinhood" +SIGNATURE_ALGORITHM: Final[str] = "Ed25519" +MAXIMUM_CAPITAL_ENVELOPE_TTL: Final[timedelta] = timedelta(seconds=60) +SHA256_HEX_LENGTH: Final[int] = 64 +IDENTIFIER_PATTERN: Final[re.Pattern[str]] = re.compile( + r"^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$" +) +UTC_TIMESTAMP_PATTERN: Final[re.Pattern[str]] = re.compile( + r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z$" +) +EQUITY_INSTRUMENT_ID_PATTERN: Final[re.Pattern[str]] = re.compile( + r"^equity:[A-Z0-9][A-Z0-9.-]{0,31}$" +) +OPTION_INSTRUMENT_ID_PATTERN: Final[re.Pattern[str]] = re.compile( + r"^option:[A-Z0-9][A-Z0-9.-]{0,31}:" + r"[A-Za-z0-9][A-Za-z0-9._/-]{0,95}$" +) +SIGNED_ENVELOPE_FIELDS: Final[frozenset[str]] = frozenset( + { + "schema", + "source_id", + "source_sequence", + "event_id", + "correlation_id", + "causation_id", + "nonce", + "not_before", + "expires_at", + "body_schema", + "body_digest", + "body", + "signature_algorithm", + "signer_key_id", + "signature", + } +) +CAPITAL_ENVELOPE_FIELDS: Final[frozenset[str]] = frozenset( + { + "schema", + "envelope_id", + "mandate_id", + "venue", + "account_hash", + "strategy_hashes", + "passport_hashes", + "promotion_hashes", + "authorized_instruments", + "authorized_mode", + "max_order_risk_cents", + "max_open_risk_cents", + "max_correlated_risk_cents", + "max_daily_loss_cents", + "max_open_orders", + "fencing_generation", + "policy_epoch", + "not_before", + "expires_at", + } +) +DESIRED_MODE_FIELDS: Final[frozenset[str]] = frozenset( + { + "schema", + "venue", + "mode", + "revision", + "reason", + "policy_epoch", + "not_before", + "expires_at", + } +) +KILL_STATE_FIELDS: Final[frozenset[str]] = frozenset( + { + "schema", + "active", + "generation", + "reason", + "changed_by", + "policy_epoch", + "changed_at", + } +) +DESIRED_MODES: Final[frozenset[str]] = frozenset( + {"READ_ONLY", "PAPER", "LIVE", "PAUSED"} +) + + +class DumbMoneyAuthorityError(RuntimeError): + """Raised when global capital authority is absent, invalid, or exhausted.""" + + +@dataclass(frozen=True) +class CapitalEnvelopeV1: + schema: str + envelope_id: str + mandate_id: str + venue: str + account_hash: str + strategy_hashes: tuple[str, ...] + passport_hashes: tuple[str, ...] + promotion_hashes: tuple[str, ...] + authorized_instruments: tuple[str, ...] + authorized_mode: str + max_order_risk_cents: int + max_open_risk_cents: int + max_correlated_risk_cents: int + max_daily_loss_cents: int + max_open_orders: int + fencing_generation: int + policy_epoch: int + not_before: str + expires_at: str + + def to_dict(self) -> dict[str, Any]: + payload = asdict(self) + payload["strategy_hashes"] = list(self.strategy_hashes) + payload["passport_hashes"] = list(self.passport_hashes) + payload["promotion_hashes"] = list(self.promotion_hashes) + payload["authorized_instruments"] = list(self.authorized_instruments) + return payload + + +@dataclass(frozen=True) +class DesiredModeV1: + schema: str + venue: str + mode: str + revision: int + reason: str + policy_epoch: int + not_before: str + expires_at: str + + +@dataclass(frozen=True) +class KillStateV1: + schema: str + active: bool + generation: int + reason: str + changed_by: str + policy_epoch: int + changed_at: str + + +@dataclass(frozen=True) +class VerifiedCapitalAuthority: + envelope: CapitalEnvelopeV1 + event_id: str + source_id: str + source_sequence: int + signer_key_id: str + body_digest: str + + def evidence(self) -> dict[str, Any]: + return { + "envelope_id": self.envelope.envelope_id, + "mandate_id": self.envelope.mandate_id, + "event_id": self.event_id, + "source_id": self.source_id, + "source_sequence": self.source_sequence, + "signer_key_id": self.signer_key_id, + "body_digest": self.body_digest, + "fencing_generation": self.envelope.fencing_generation, + "policy_epoch": self.envelope.policy_epoch, + "expires_at": self.envelope.expires_at, + } + + +@dataclass(frozen=True) +class VerifiedDesiredMode: + desired_mode: DesiredModeV1 + event_id: str + source_id: str + source_sequence: int + signer_key_id: str + body_digest: str + not_before: str + expires_at: str + + +@dataclass(frozen=True) +class VerifiedKillState: + kill_state: KillStateV1 + event_id: str + source_id: str + source_sequence: int + signer_key_id: str + body_digest: str + not_before: str + expires_at: str + + +VerifiedControlEnvelope = ( + VerifiedCapitalAuthority | VerifiedDesiredMode | VerifiedKillState +) + + +@dataclass(frozen=True) +class DumbMoneyAuthorityContext: + """Explicit opt-in authority inputs; constructing this does not arm DOPEY.""" + + signed_envelope: dict[str, Any] + trusted_public_key: bytes + expected_account_hash: str + + @classmethod + def from_files( + cls, + envelope_path: Path, + public_key_path: Path, + expected_account_hash: str, + ) -> "DumbMoneyAuthorityContext": + try: + envelope_bytes = envelope_path.read_bytes() + except OSError as error: + raise DumbMoneyAuthorityError( + f"Unable to read DumbMoney envelope: path={envelope_path}, error={error}" + ) from error + payload = _loads_strict_signed_json( + envelope_bytes, + "DumbMoney signed envelope", + require_canonical_bytes=True, + ) + try: + encoded_key = public_key_path.read_bytes() + except OSError as error: + raise DumbMoneyAuthorityError( + f"Unable to read DumbMoney public key: path={public_key_path}, error={error}" + ) from error + return cls( + signed_envelope=dict(payload), + trusted_public_key=_decode_public_key(encoded_key), + expected_account_hash=_sha256_text( + expected_account_hash, + "expected_account_hash", + ), + ) + + def verify( + self, + now_utc: datetime, + observed_account_hash: str, + ) -> VerifiedCapitalAuthority: + if observed_account_hash != self.expected_account_hash: + raise DumbMoneyAuthorityError( + "Robinhood Agentic account hash does not match the pinned DumbMoney account" + ) + return verify_signed_capital_envelope( + self.signed_envelope, + self.trusted_public_key, + now_utc, + self.expected_account_hash, + ) + + +def canonical_json_bytes(payload: dict[str, Any]) -> bytes: + try: + return json.dumps( + payload, + sort_keys=True, + separators=(",", ":"), + ensure_ascii=False, + allow_nan=False, + ).encode("utf-8") + except (TypeError, ValueError) as error: + raise DumbMoneyAuthorityError( + f"DumbMoney payload is not canonical-JSON encodable: {error}" + ) from error + + +def _loads_strict_signed_json( + raw: bytes, + label: str, + *, + require_canonical_bytes: bool, +) -> dict[str, Any]: + def reject_duplicate_keys(pairs: list[tuple[str, Any]]) -> dict[str, Any]: + result: dict[str, Any] = {} + for key, value in pairs: + if key in result: + raise DumbMoneyAuthorityError( + f"{label} contains a duplicate JSON key: {key}" + ) + result[key] = value + return result + + def reject_constant(value: str) -> None: + raise DumbMoneyAuthorityError( + f"{label} contains a non-finite JSON number: {value}" + ) + + try: + text = raw.decode("utf-8") + payload = json.loads( + text, + object_pairs_hook=reject_duplicate_keys, + parse_constant=reject_constant, + ) + except (UnicodeDecodeError, json.JSONDecodeError) as error: + raise DumbMoneyAuthorityError(f"{label} must be valid UTF-8 JSON") from error + if not isinstance(payload, dict) or not all( + isinstance(key, str) for key in payload + ): + raise DumbMoneyAuthorityError(f"{label} must be a JSON object") + _validate_signed_json_domain(payload, label) + if require_canonical_bytes and raw != canonical_json_bytes(payload): + raise DumbMoneyAuthorityError(f"{label} must use exact canonical JSON bytes") + return payload + + +def _validate_signed_json_domain(value: Any, label: str, path: str = "$") -> None: + if value is None or isinstance(value, (str, bool, int)): + return + if isinstance(value, float): + if not math.isfinite(value): + raise DumbMoneyAuthorityError( + f"{label} {path} contains a non-finite JSON number" + ) + raise DumbMoneyAuthorityError( + f"{label} {path} contains a float outside the signed integer domain" + ) + if isinstance(value, list): + for index, item in enumerate(value): + _validate_signed_json_domain(item, label, f"{path}[{index}]") + return + if isinstance(value, dict): + if not all(isinstance(key, str) for key in value): + raise DumbMoneyAuthorityError( + f"{label} {path} contains a non-string object key" + ) + for key, item in value.items(): + _validate_signed_json_domain(item, label, f"{path}.{key}") + return + raise DumbMoneyAuthorityError( + f"{label} {path} contains unsupported JSON type {type(value).__name__}" + ) + + +def _decode_public_key(encoded: bytes) -> bytes: + if len(encoded) == 32: + return encoded + stripped = encoded.strip() + try: + text = stripped.decode("ascii") + except UnicodeDecodeError as error: + raise DumbMoneyAuthorityError( + "DumbMoney Ed25519 public key must be raw bytes, hex, or base64url" + ) from error + if len(text) == 64: + try: + decoded = bytes.fromhex(text) + except ValueError: + decoded = b"" + if len(decoded) == 32: + return decoded + try: + decoded = _urlsafe_b64decode(text) + except ValueError as error: + raise DumbMoneyAuthorityError( + "DumbMoney Ed25519 public key is not valid base64url" + ) from error + if len(decoded) != 32: + raise DumbMoneyAuthorityError( + f"DumbMoney Ed25519 public key must be 32 bytes: actual={len(decoded)}" + ) + return decoded + + +def _urlsafe_b64decode(value: str) -> bytes: + if ( + not value + or "=" in value + or not re.fullmatch(r"[A-Za-z0-9_-]+", value) + or len(value) % 4 == 1 + ): + raise ValueError("base64url must be unpadded") + try: + decoded = base64.urlsafe_b64decode(value + "=" * (-len(value) % 4)) + except (ValueError, TypeError) as error: + raise ValueError("invalid base64url") from error + canonical = base64.urlsafe_b64encode(decoded).decode("ascii").rstrip("=") + if canonical != value: + raise ValueError("base64url is not canonical") + return decoded + + +def _sha256_text(value: Any, label: str) -> str: + normalized = str(value).strip().lower() + if len(normalized) != SHA256_HEX_LENGTH: + raise DumbMoneyAuthorityError(f"{label} must be lowercase sha256 hex") + try: + bytes.fromhex(normalized) + except ValueError as error: + raise DumbMoneyAuthorityError( + f"{label} must be lowercase sha256 hex" + ) from error + if normalized != str(value): + raise DumbMoneyAuthorityError(f"{label} must be canonical lowercase sha256 hex") + return normalized + + +def account_hash(account_id: str) -> str: + if not account_id: + raise DumbMoneyAuthorityError("Robinhood Agentic account ID is missing") + return hashlib.sha256(account_id.encode("utf-8")).hexdigest() + + +def _utc_timestamp(value: Any, label: str) -> datetime: + text = str(value) + if not UTC_TIMESTAMP_PATTERN.fullmatch(text): + raise DumbMoneyAuthorityError( + f"{label} must be canonical RFC3339 UTC ending in Z" + ) + try: + parsed = datetime.fromisoformat(text[:-1] + "+00:00") + except ValueError as error: + raise DumbMoneyAuthorityError( + f"{label} is not a valid RFC3339 timestamp" + ) from error + if parsed.tzinfo is None or parsed.utcoffset() != timezone.utc.utcoffset(parsed): + raise DumbMoneyAuthorityError(f"{label} must use UTC") + canonical = parsed.isoformat( + timespec="microseconds" if parsed.microsecond else "seconds" + ).replace("+00:00", "Z") + if canonical != text: + raise DumbMoneyAuthorityError( + f"{label} must use canonical RFC3339 UTC precision" + ) + return parsed + + +def _exact_fields( + payload: dict[str, Any], expected: frozenset[str], label: str +) -> None: + missing = sorted(expected - set(payload)) + unknown = sorted(set(payload) - expected) + if missing or unknown: + raise DumbMoneyAuthorityError( + f"{label} schema mismatch: missing={missing}, unknown={unknown}" + ) + + +def _positive_int(payload: dict[str, Any], field: str) -> int: + value = payload.get(field) + if isinstance(value, bool) or not isinstance(value, int) or value <= 0: + raise DumbMoneyAuthorityError(f"{field} must be a positive integer") + return value + + +def _nonnegative_int(payload: dict[str, Any], field: str) -> int: + value = payload.get(field) + if isinstance(value, bool) or not isinstance(value, int) or value < 0: + raise DumbMoneyAuthorityError(f"{field} must be a nonnegative integer") + return value + + +def _hash_array(payload: dict[str, Any], field: str) -> tuple[str, ...]: + values = payload.get(field) + if not isinstance(values, list): + raise DumbMoneyAuthorityError(f"{field} must be an array") + normalized = tuple(_sha256_text(value, field) for value in values) + if not normalized: + raise DumbMoneyAuthorityError(f"{field} must be nonempty") + if normalized != tuple(sorted(set(normalized))): + raise DumbMoneyAuthorityError(f"{field} must be sorted and duplicate-free") + return normalized + + +def _identifier_array(payload: dict[str, Any], field: str) -> tuple[str, ...]: + values = payload.get(field) + if not isinstance(values, list): + raise DumbMoneyAuthorityError(f"{field} must be an array") + normalized = tuple(str(value) for value in values) + if ( + not normalized + or any(not IDENTIFIER_PATTERN.fullmatch(value) for value in normalized) + or normalized != tuple(sorted(set(normalized))) + ): + raise DumbMoneyAuthorityError( + f"{field} must be nonempty, canonical, sorted, and duplicate-free" + ) + return normalized + + +def _capital_envelope( + payload: dict[str, Any], + expected_account_hash: str, +) -> CapitalEnvelopeV1: + _exact_fields(payload, CAPITAL_ENVELOPE_FIELDS, "CapitalEnvelopeV1") + if payload.get("schema") != CAPITAL_ENVELOPE_SCHEMA: + raise DumbMoneyAuthorityError("CapitalEnvelopeV1 schema is unsupported") + if payload.get("venue") != DOPEY_VENUE: + raise DumbMoneyAuthorityError("CapitalEnvelopeV1 is not for dopey_robinhood") + if payload.get("authorized_mode") != "LIVE": + raise DumbMoneyAuthorityError("CapitalEnvelopeV1 does not authorize LIVE mode") + envelope_id = _sha256_text(payload.get("envelope_id"), "envelope_id") + mandate_id = _sha256_text(payload.get("mandate_id"), "mandate_id") + account_digest = _sha256_text(payload.get("account_hash"), "account_hash") + if account_digest != expected_account_hash: + raise DumbMoneyAuthorityError("CapitalEnvelopeV1 targets a different account") + max_order = _positive_int(payload, "max_order_risk_cents") + max_open = _positive_int(payload, "max_open_risk_cents") + max_correlated = _positive_int(payload, "max_correlated_risk_cents") + max_daily_loss = _positive_int(payload, "max_daily_loss_cents") + if not max_order <= max_correlated <= max_open: + raise DumbMoneyAuthorityError( + "CapitalEnvelopeV1 requires max_order_risk_cents <= " + "max_correlated_risk_cents <= max_open_risk_cents" + ) + not_before = str(payload.get("not_before", "")) + expires_at = str(payload.get("expires_at", "")) + if _utc_timestamp(expires_at, "body.expires_at") <= _utc_timestamp( + not_before, "body.not_before" + ): + raise DumbMoneyAuthorityError("CapitalEnvelopeV1 validity interval is empty") + authorized_instruments = _identifier_array( + payload, + "authorized_instruments", + ) + unsupported_instruments = sorted( + instrument_id + for instrument_id in authorized_instruments + if not ( + EQUITY_INSTRUMENT_ID_PATTERN.fullmatch(instrument_id) + or OPTION_INSTRUMENT_ID_PATTERN.fullmatch(instrument_id) + ) + ) + if unsupported_instruments: + raise DumbMoneyAuthorityError( + "CapitalEnvelopeV1 contains noncanonical exact Dopey instrument IDs: " + f"{unsupported_instruments}" + ) + strategy_hashes = _hash_array(payload, "strategy_hashes") + passport_hashes = _hash_array(payload, "passport_hashes") + promotion_hashes = _hash_array(payload, "promotion_hashes") + if ( + len(strategy_hashes) != 1 + or len(passport_hashes) != 1 + or len(promotion_hashes) != 1 + ): + raise DumbMoneyAuthorityError( + "CapitalEnvelopeV1 must authorize exactly one bound " + "strategy/passport/promotion tuple" + ) + return CapitalEnvelopeV1( + schema=CAPITAL_ENVELOPE_SCHEMA, + envelope_id=envelope_id, + mandate_id=mandate_id, + venue=DOPEY_VENUE, + account_hash=account_digest, + strategy_hashes=strategy_hashes, + passport_hashes=passport_hashes, + promotion_hashes=promotion_hashes, + authorized_instruments=authorized_instruments, + authorized_mode="LIVE", + max_order_risk_cents=max_order, + max_open_risk_cents=max_open, + max_correlated_risk_cents=max_correlated, + max_daily_loss_cents=max_daily_loss, + max_open_orders=_positive_int(payload, "max_open_orders"), + fencing_generation=_positive_int(payload, "fencing_generation"), + policy_epoch=_positive_int(payload, "policy_epoch"), + not_before=not_before, + expires_at=expires_at, + ) + + +@dataclass(frozen=True) +class _VerifiedSignedWrapper: + body_schema: str + body: dict[str, Any] + event_id: str + source_id: str + source_sequence: int + signer_key_id: str + body_digest: str + not_before: str + expires_at: str + not_before_utc: datetime + expires_at_utc: datetime + + +def _verify_signed_wrapper( + payload: dict[str, Any], + trusted_public_key: bytes, + now_utc: datetime, + *, + enforce_current_window: bool, +) -> _VerifiedSignedWrapper: + if now_utc.tzinfo is None: + raise DumbMoneyAuthorityError("Envelope verification requires aware now_utc") + now = now_utc.astimezone(timezone.utc) + _exact_fields(payload, SIGNED_ENVELOPE_FIELDS, "SignedEnvelopeV1") + if payload.get("schema") != SIGNED_ENVELOPE_SCHEMA: + raise DumbMoneyAuthorityError("SignedEnvelopeV1 schema is unsupported") + if payload.get("signature_algorithm") != SIGNATURE_ALGORITHM: + raise DumbMoneyAuthorityError( + "SignedEnvelopeV1 signature algorithm is unsupported" + ) + source_sequence = _positive_int(payload, "source_sequence") + for field in ("source_id", "correlation_id", "nonce"): + if not IDENTIFIER_PATTERN.fullmatch(str(payload.get(field, ""))): + raise DumbMoneyAuthorityError(f"{field} is not a canonical identifier") + causation_id = payload.get("causation_id") + if causation_id is not None: + _sha256_text(causation_id, "causation_id") + body = payload.get("body") + if not isinstance(body, dict) or not all(isinstance(key, str) for key in body): + raise DumbMoneyAuthorityError("SignedEnvelopeV1 body must be an object") + body_schema = str(payload.get("body_schema", "")) + if not IDENTIFIER_PATTERN.fullmatch(body_schema): + raise DumbMoneyAuthorityError( + "SignedEnvelopeV1 body_schema is not a canonical identifier" + ) + if body.get("schema") != body_schema: + raise DumbMoneyAuthorityError( + "SignedEnvelopeV1 body_schema does not match embedded body" + ) + body_digest = hashlib.sha256(canonical_json_bytes(body)).hexdigest() + if payload.get("body_digest") != body_digest: + raise DumbMoneyAuthorityError("SignedEnvelopeV1 body digest mismatch") + unsigned_event = dict(payload) + unsigned_event.pop("signature", None) + unsigned_event.pop("event_id", None) + event_id = hashlib.sha256(canonical_json_bytes(unsigned_event)).hexdigest() + if payload.get("event_id") != event_id: + raise DumbMoneyAuthorityError("SignedEnvelopeV1 event_id mismatch") + public_key = _decode_public_key(trusted_public_key) + signer_key_id = hashlib.sha256(public_key).hexdigest() + if payload.get("signer_key_id") != signer_key_id: + raise DumbMoneyAuthorityError("SignedEnvelopeV1 signer_key_id mismatch") + try: + signature = _urlsafe_b64decode(str(payload.get("signature", ""))) + if len(signature) != 64: + raise ValueError("Ed25519 signature must be 64 bytes") + Ed25519PublicKey.from_public_bytes(public_key).verify( + signature, + canonical_json_bytes( + {key: value for key, value in payload.items() if key != "signature"} + ), + ) + except (InvalidSignature, ValueError) as error: + raise DumbMoneyAuthorityError( + "SignedEnvelopeV1 signature is invalid" + ) from error + not_before = str(payload.get("not_before", "")) + expires_at = str(payload.get("expires_at", "")) + wrapper_not_before = _utc_timestamp(not_before, "not_before") + wrapper_expires = _utc_timestamp(expires_at, "expires_at") + if wrapper_expires <= wrapper_not_before: + raise DumbMoneyAuthorityError("SignedEnvelopeV1 validity interval is empty") + if enforce_current_window and not wrapper_not_before <= now < wrapper_expires: + raise DumbMoneyAuthorityError("SignedEnvelopeV1 is not currently valid") + return _VerifiedSignedWrapper( + body_schema=body_schema, + body=dict(body), + event_id=event_id, + source_id=str(payload["source_id"]), + source_sequence=source_sequence, + signer_key_id=signer_key_id, + body_digest=body_digest, + not_before=not_before, + expires_at=expires_at, + not_before_utc=wrapper_not_before, + expires_at_utc=wrapper_expires, + ) + + +def _verified_capital_from_wrapper( + wrapper: _VerifiedSignedWrapper, + now_utc: datetime, + expected_account_hash: str, + *, + enforce_current_window: bool, +) -> VerifiedCapitalAuthority: + if wrapper.body_schema != CAPITAL_ENVELOPE_SCHEMA: + raise DumbMoneyAuthorityError( + "SignedEnvelopeV1 body_schema is not capital authority" + ) + now = now_utc.astimezone(timezone.utc) + envelope = _capital_envelope(wrapper.body, expected_account_hash) + body_not_before = _utc_timestamp(envelope.not_before, "body.not_before") + body_expires = _utc_timestamp(envelope.expires_at, "body.expires_at") + if enforce_current_window and not body_not_before <= now < body_expires: + raise DumbMoneyAuthorityError("CapitalEnvelopeV1 is not currently valid") + if ( + wrapper.not_before_utc != body_not_before + or wrapper.expires_at_utc != body_expires + ): + raise DumbMoneyAuthorityError( + "SignedEnvelopeV1 and CapitalEnvelopeV1 validity windows must match exactly" + ) + if wrapper.expires_at_utc - wrapper.not_before_utc > MAXIMUM_CAPITAL_ENVELOPE_TTL: + raise DumbMoneyAuthorityError( + "Capital authority validity exceeds the local 60-second maximum" + ) + return VerifiedCapitalAuthority( + envelope=envelope, + event_id=wrapper.event_id, + source_id=wrapper.source_id, + source_sequence=wrapper.source_sequence, + signer_key_id=wrapper.signer_key_id, + body_digest=wrapper.body_digest, + ) + + +def verify_signed_capital_envelope( + payload: dict[str, Any], + trusted_public_key: bytes, + now_utc: datetime, + expected_account_hash: str, +) -> VerifiedCapitalAuthority: + wrapper = _verify_signed_wrapper( + payload, + trusted_public_key, + now_utc, + enforce_current_window=True, + ) + return _verified_capital_from_wrapper( + wrapper, + now_utc, + expected_account_hash, + enforce_current_window=True, + ) + + +def _desired_mode( + payload: dict[str, Any], + now_utc: datetime, + *, + enforce_current_window: bool, +) -> DesiredModeV1: + _exact_fields(payload, DESIRED_MODE_FIELDS, "DesiredModeV1") + if payload.get("schema") != DESIRED_MODE_SCHEMA: + raise DumbMoneyAuthorityError("DesiredModeV1 schema is unsupported") + if payload.get("venue") != DOPEY_VENUE: + raise DumbMoneyAuthorityError("DesiredModeV1 is not for dopey_robinhood") + mode = str(payload.get("mode", "")) + if mode not in DESIRED_MODES: + raise DumbMoneyAuthorityError("DesiredModeV1 mode is unsupported") + reason = payload.get("reason") + if not isinstance(reason, str) or not reason.strip(): + raise DumbMoneyAuthorityError("DesiredModeV1 reason must be nonempty") + not_before = str(payload.get("not_before", "")) + expires_at = str(payload.get("expires_at", "")) + body_not_before = _utc_timestamp(not_before, "body.not_before") + body_expires = _utc_timestamp(expires_at, "body.expires_at") + if body_expires <= body_not_before: + raise DumbMoneyAuthorityError("DesiredModeV1 validity interval is empty") + now = now_utc.astimezone(timezone.utc) + if enforce_current_window and not body_not_before <= now < body_expires: + raise DumbMoneyAuthorityError("DesiredModeV1 is not currently valid") + return DesiredModeV1( + schema=DESIRED_MODE_SCHEMA, + venue=DOPEY_VENUE, + mode=mode, + revision=_positive_int(payload, "revision"), + reason=reason, + policy_epoch=_positive_int(payload, "policy_epoch"), + not_before=not_before, + expires_at=expires_at, + ) + + +def _kill_state( + payload: dict[str, Any], + now_utc: datetime, + *, + enforce_current_window: bool, +) -> KillStateV1: + _exact_fields(payload, KILL_STATE_FIELDS, "KillStateV1") + if payload.get("schema") != KILL_STATE_SCHEMA: + raise DumbMoneyAuthorityError("KillStateV1 schema is unsupported") + active = payload.get("active") + if not isinstance(active, bool): + raise DumbMoneyAuthorityError("KillStateV1 active must be a boolean") + reason = payload.get("reason") + if not isinstance(reason, str) or not reason.strip(): + raise DumbMoneyAuthorityError("KillStateV1 reason must be nonempty") + changed_by = str(payload.get("changed_by", "")) + if not IDENTIFIER_PATTERN.fullmatch(changed_by): + raise DumbMoneyAuthorityError( + "KillStateV1 changed_by is not a canonical identifier" + ) + changed_at = str(payload.get("changed_at", "")) + changed_at_utc = _utc_timestamp(changed_at, "body.changed_at") + if enforce_current_window and changed_at_utc > now_utc.astimezone(timezone.utc): + raise DumbMoneyAuthorityError("KillStateV1 changed_at is future-dated") + return KillStateV1( + schema=KILL_STATE_SCHEMA, + active=active, + generation=_positive_int(payload, "generation"), + reason=reason, + changed_by=changed_by, + policy_epoch=_positive_int(payload, "policy_epoch"), + changed_at=changed_at, + ) + + +def verify_signed_control_envelope( + payload: dict[str, Any], + trusted_public_key: bytes, + now_utc: datetime, + expected_account_hash: str, + *, + enforce_current_window: bool = True, +) -> VerifiedControlEnvelope: + """Verify one Core command. + + ``enforce_current_window=False`` exists only so the command-feed recovery + path can authenticate previously persisted generations. It must never be + used to grant positive capital authority. + """ + + wrapper = _verify_signed_wrapper( + payload, + trusted_public_key, + now_utc, + enforce_current_window=enforce_current_window, + ) + if wrapper.body_schema == CAPITAL_ENVELOPE_SCHEMA: + return _verified_capital_from_wrapper( + wrapper, + now_utc, + expected_account_hash, + enforce_current_window=enforce_current_window, + ) + if wrapper.body_schema == DESIRED_MODE_SCHEMA: + desired = _desired_mode( + wrapper.body, + now_utc, + enforce_current_window=enforce_current_window, + ) + return VerifiedDesiredMode( + desired_mode=desired, + event_id=wrapper.event_id, + source_id=wrapper.source_id, + source_sequence=wrapper.source_sequence, + signer_key_id=wrapper.signer_key_id, + body_digest=wrapper.body_digest, + not_before=wrapper.not_before, + expires_at=wrapper.expires_at, + ) + if wrapper.body_schema == KILL_STATE_SCHEMA: + kill = _kill_state( + wrapper.body, + now_utc, + enforce_current_window=enforce_current_window, + ) + return VerifiedKillState( + kill_state=kill, + event_id=wrapper.event_id, + source_id=wrapper.source_id, + source_sequence=wrapper.source_sequence, + signer_key_id=wrapper.signer_key_id, + body_digest=wrapper.body_digest, + not_before=wrapper.not_before, + expires_at=wrapper.expires_at, + ) + raise DumbMoneyAuthorityError( + f"SignedEnvelopeV1 body_schema is unsupported: {wrapper.body_schema}" + ) + + +def account_open_risk_cents(account: AccountSnapshot) -> int: + total_usd = 0.0 + for multiplier, positions in ( + (1.0, account.equity_positions), + (100.0, account.option_positions), + ): + for position in positions: + required = {"quantity", "average_cost", "market_value"} + if not required <= set(position): + raise DumbMoneyAuthorityError( + "Nonzero broker position lacks complete finite risk evidence" + ) + try: + market_value = abs(float(position.get("market_value", 0.0))) + quantity = abs(float(position.get("quantity", 0.0))) + average_cost = abs(float(position.get("average_cost", 0.0))) + except (TypeError, ValueError) as error: + raise DumbMoneyAuthorityError( + "Broker position risk fields are not numeric" + ) from error + cost_basis = quantity * average_cost * multiplier + if not math.isfinite(market_value) or not math.isfinite(cost_basis): + raise DumbMoneyAuthorityError("Broker position cost basis is invalid") + position_risk = max(market_value, cost_basis) + if quantity > 0.0 and position_risk <= 0.0: + raise DumbMoneyAuthorityError( + "Nonzero broker position lacks positive risk evidence" + ) + total_usd += position_risk + if not math.isfinite(total_usd): + raise DumbMoneyAuthorityError("Broker open risk is not finite") + return math.ceil(total_usd * 100.0 - 1e-9) + + +def active_order_count(account: AccountSnapshot) -> int: + active = {"queued", "pending", "open", "confirmed", "partially_filled"} + return sum( + str(order.get("status", "")).strip().lower() in active + for order in (*account.recent_equity_orders, *account.recent_option_orders) + ) + + +def active_entry_order_present(account: AccountSnapshot) -> bool: + active = {"queued", "pending", "open", "confirmed", "partially_filled"} + return any( + str(order.get("status", "")).strip().lower() in active + and str(order.get("side", "")).strip().lower() == "buy" + for order in (*account.recent_equity_orders, *account.recent_option_orders) + ) + + +def _validated_daily_loss( + authority: VerifiedCapitalAuthority, + observed_daily_loss_cents: int | None, +) -> int: + if ( + isinstance(observed_daily_loss_cents, bool) + or not isinstance(observed_daily_loss_cents, int) + or observed_daily_loss_cents < 0 + ): + raise DumbMoneyAuthorityError( + "Broker/ledger-witnessed daily loss is required for new exposure" + ) + if observed_daily_loss_cents >= authority.envelope.max_daily_loss_cents: + raise DumbMoneyAuthorityError( + "CapitalEnvelopeV1 max_daily_loss_cents is exhausted" + ) + return observed_daily_loss_cents + + +def authorized_settled_cash_usd( + authority: VerifiedCapitalAuthority, + account: AccountSnapshot, + spendable_settled_usd: float, + observed_daily_loss_cents: int | None, +) -> float: + if not math.isfinite(spendable_settled_usd) or spendable_settled_usd < 0.0: + raise DumbMoneyAuthorityError("Settled cash must be finite and nonnegative") + _validated_daily_loss(authority, observed_daily_loss_cents) + open_risk = account_open_risk_cents(account) + ceiling = min( + authority.envelope.max_open_risk_cents, + authority.envelope.max_correlated_risk_cents, + ) + remaining_cents = max(0, ceiling - open_risk) + return min(spendable_settled_usd, remaining_cents / 100.0) + + +def intent_incremental_risk_cents(intent: LiveExecutionIntent) -> int: + if intent.side == "sell": + return 0 + if intent.instrument == "option": + contract = intent.option_contract + multiplier = contract.get("multiplier", 100) + try: + maximum_loss = float(contract.get("maximum_loss_usd", 0.0)) + computed_debit = ( + float(intent.limit_price) * float(intent.quantity) * float(multiplier) + ) + except (TypeError, ValueError) as error: + raise DumbMoneyAuthorityError( + "Option debit fields are not numeric" + ) from error + risk_usd = max(maximum_loss, computed_debit, intent.notional_usd) + else: + risk_usd = max(intent.notional_usd, intent.quantity * intent.limit_price) + if not math.isfinite(risk_usd) or risk_usd <= 0.0: + raise DumbMoneyAuthorityError("Entry intent risk must be finite and positive") + return math.ceil(risk_usd * 100.0 - 1e-9) + + +def intent_authorized_instrument_id(intent: LiveExecutionIntent) -> str: + """Return the exact broker-sink identity used by CapitalEnvelopeV1.""" + + symbol = intent.symbol.strip().upper() + if intent.instrument == "equity": + instrument_id = f"equity:{symbol}" + if not EQUITY_INSTRUMENT_ID_PATTERN.fullmatch(instrument_id): + raise DumbMoneyAuthorityError( + "Equity intent cannot form a canonical authorized instrument ID" + ) + return instrument_id + if intent.instrument == "option": + contract_id = str(intent.option_contract.get("contract_id", "")).strip() + instrument_id = f"option:{symbol}:{contract_id}" + if not OPTION_INSTRUMENT_ID_PATTERN.fullmatch(instrument_id): + raise DumbMoneyAuthorityError( + "Option intent cannot form a canonical authorized instrument ID" + ) + return instrument_id + raise DumbMoneyAuthorityError( + f"Unsupported Dopey execution instrument: {intent.instrument}" + ) + + +def authorize_intent( + authority: VerifiedCapitalAuthority, + account: AccountSnapshot, + intent: LiveExecutionIntent, + observed_daily_loss_cents: int | None, +) -> int: + if intent.side == "sell": + return _authorize_reducing_exit(account, intent) + witnessed_daily_loss = _validated_daily_loss( + authority, + observed_daily_loss_cents, + ) + if active_entry_order_present(account): + raise DumbMoneyAuthorityError( + "Existing active entry order blocks additional new exposure" + ) + instrument_id = intent_authorized_instrument_id(intent) + if instrument_id not in authority.envelope.authorized_instruments: + raise DumbMoneyAuthorityError( + "Execution intent exact broker instrument is absent from " + "CapitalEnvelopeV1 authorized_instruments" + ) + source_hash = _sha256_text(intent.source_hash, "intent.source_hash") + if source_hash != authority.envelope.strategy_hashes[0]: + raise DumbMoneyAuthorityError( + "Execution intent strategy hash does not match the exact " + "CapitalEnvelopeV1 strategy" + ) + passport_hash = _sha256_text( + intent.passport_hash, + "intent.passport_hash", + ) + if passport_hash != authority.envelope.passport_hashes[0]: + raise DumbMoneyAuthorityError( + "Execution intent passport hash does not match the exact " + "CapitalEnvelopeV1 passport" + ) + promotion_hash = _sha256_text( + intent.promotion_hash, + "intent.promotion_hash", + ) + if promotion_hash != authority.envelope.promotion_hashes[0]: + raise DumbMoneyAuthorityError( + "Execution intent promotion hash does not match the exact " + "CapitalEnvelopeV1 promotion" + ) + incremental = intent_incremental_risk_cents(intent) + if incremental > authority.envelope.max_order_risk_cents: + raise DumbMoneyAuthorityError("Execution intent exceeds max_order_risk_cents") + remaining_daily_loss_capacity = ( + authority.envelope.max_daily_loss_cents - witnessed_daily_loss + ) + if incremental > remaining_daily_loss_capacity: + raise DumbMoneyAuthorityError( + "Execution intent worst-case risk exceeds remaining daily-loss capacity" + ) + if active_order_count(account) >= authority.envelope.max_open_orders: + raise DumbMoneyAuthorityError("CapitalEnvelopeV1 max_open_orders is exhausted") + projected = account_open_risk_cents(account) + incremental + if projected > authority.envelope.max_open_risk_cents: + raise DumbMoneyAuthorityError("Execution intent exceeds max_open_risk_cents") + if projected > authority.envelope.max_correlated_risk_cents: + raise DumbMoneyAuthorityError( + "Execution intent exceeds max_correlated_risk_cents" + ) + return incremental + + +def _authorize_reducing_exit( + account: AccountSnapshot, + intent: LiveExecutionIntent, +) -> int: + try: + requested_quantity = float(intent.quantity) + except (TypeError, ValueError) as error: + raise DumbMoneyAuthorityError("Exit intent quantity is not numeric") from error + if not math.isfinite(requested_quantity) or requested_quantity <= 0.0: + raise DumbMoneyAuthorityError( + "Exit intent quantity must be finite and positive" + ) + if intent.instrument == "equity": + positions = tuple( + position + for position in account.equity_positions + if str(position.get("symbol", "")).strip().upper() + == intent.symbol.strip().upper() + ) + matching_active_sells = tuple( + order + for order in account.recent_equity_orders + if str(order.get("symbol", "")).strip().upper() + == intent.symbol.strip().upper() + and str(order.get("side", "")).strip().lower() == "sell" + and str(order.get("status", "")).strip().lower() + in {"queued", "pending", "open", "confirmed", "partially_filled"} + ) + elif intent.instrument == "option": + contract_id = str(intent.option_contract.get("contract_id", "")).strip() + if not contract_id: + raise DumbMoneyAuthorityError( + "Option exit intent lacks an exact contract_id" + ) + positions = tuple( + position + for position in account.option_positions + if str(position.get("contract_id", "")).strip() == contract_id + ) + matching_active_sells = tuple( + order + for order in account.recent_option_orders + if str(order.get("contract_id", "")).strip() == contract_id + and str(order.get("side", "")).strip().lower() == "sell" + and str(order.get("status", "")).strip().lower() + in {"queued", "pending", "open", "confirmed", "partially_filled"} + ) + else: + raise DumbMoneyAuthorityError( + f"Unsupported exit instrument: {intent.instrument}" + ) + if len(positions) != 1: + raise DumbMoneyAuthorityError( + "Exit intent requires exactly one fresh matching long broker position" + ) + if matching_active_sells: + raise DumbMoneyAuthorityError( + "Existing active sell order blocks another exit for the same holding" + ) + try: + held_quantity = float(positions[0]["quantity"]) + except (KeyError, TypeError, ValueError) as error: + raise DumbMoneyAuthorityError( + "Matching broker position lacks numeric quantity evidence" + ) from error + if not math.isfinite(held_quantity) or held_quantity <= 0.0: + raise DumbMoneyAuthorityError( + "Matching broker position quantity must be finite and positive" + ) + if requested_quantity > held_quantity + 1e-9: + raise DumbMoneyAuthorityError( + "Exit intent would oversell the fresh witnessed long position" + ) + return 0 diff --git a/dopey_live/dumbmoney_command_feed.py b/dopey_live/dumbmoney_command_feed.py new file mode 100644 index 0000000..e51fb01 --- /dev/null +++ b/dopey_live/dumbmoney_command_feed.py @@ -0,0 +1,1596 @@ +"""Authenticated, pull-only DumbMoney Core command delivery for DOPEY. + +Importing this module starts no thread, listener, timer, or network request. +The supervising process must inject both the cell bearer-token provider and a +single-request transport into :func:`poll_once`. +""" + +from __future__ import annotations + +import hashlib +import json +import math +import os +import secrets +import tempfile +from contextlib import contextmanager +from dataclasses import dataclass +from datetime import datetime, timedelta, timezone +from pathlib import Path +from typing import Any, Callable, Final, Iterator, Mapping, Protocol +from urllib.parse import urlencode, urlsplit + +from cryptography.exceptions import InvalidSignature +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey + +from .dumbmoney_authority import ( + CAPITAL_ENVELOPE_SCHEMA, + DESIRED_MODE_SCHEMA, + DOPEY_VENUE, + KILL_STATE_SCHEMA, + DumbMoneyAuthorityContext, + DumbMoneyAuthorityError, + VerifiedCapitalAuthority, + VerifiedDesiredMode, + VerifiedKillState, + _decode_public_key, + _sha256_text, + _urlsafe_b64decode, + _utc_timestamp, + canonical_json_bytes, + verify_signed_control_envelope, +) +from .ledger import artifact_root + +COMMAND_PAGE_SCHEMA: Final[str] = "dumbmoney.cell-command-page.v1" +COMMAND_CHECKPOINT_SCHEMA: Final[str] = "dumbmoney.cell-command-checkpoint.v1" +LEDGER_EVENT_PROOF_SCHEMA: Final[str] = "dumbmoney.ledger-event-proof.v1" +COMMAND_STATE_SCHEMA: Final[str] = "dopey.dumbmoney-command-feed-state.v1" +GENESIS_DIGEST: Final[str] = "0" * 64 +DEFAULT_PAGE_LIMIT: Final[int] = 250 +MAXIMUM_PAGE_LIMIT: Final[int] = 1000 +MAXIMUM_RESPONSE_BYTES: Final[int] = 1_048_576 +MAXIMUM_PAGE_CLOCK_SKEW: Final[timedelta] = timedelta(seconds=60) +MAXIMUM_KILL_CHANGED_AT_FUTURE_SKEW: Final[timedelta] = timedelta(seconds=5) +VALID_REQUIRED_ACTIONS: Final[frozenset[str]] = frozenset( + {"APPLY_SIGNED_CONTROLS", "PAUSE_NEW_RISK", "CANCEL_AND_RECONCILE"} +) +COMMAND_PAGE_FIELDS: Final[frozenset[str]] = frozenset( + { + "schema", + "cell_id", + "request_nonce", + "observed_at", + "after_sequence", + "after_digest", + "next_sequence", + "next_digest", + "ledger_head_sequence", + "ledger_head_digest", + "has_more", + "required_action", + "commands", + "checkpoint", + "checkpoint_signature", + } +) +COMMAND_FIELDS: Final[frozenset[str]] = frozenset( + { + "global_sequence", + "event_id", + "event_digest", + "body_schema", + "valid_now", + "transport_window_current", + "authority_effect", + "ledger_proof", + "envelope", + } +) +ORDERED_COMMAND_FIELDS: Final[frozenset[str]] = COMMAND_FIELDS - {"envelope"} +COMMAND_CHECKPOINT_FIELDS: Final[frozenset[str]] = frozenset( + { + "schema", + "cell_id", + "request_nonce", + "after_sequence", + "after_digest", + "ordered_commands", + "next_sequence", + "next_digest", + "ledger_head_sequence", + "ledger_head_digest", + "observed_at", + "required_action", + } +) +CHECKPOINT_SIGNATURE_FIELDS: Final[frozenset[str]] = frozenset( + {"algorithm", "signer_key_id", "signature"} +) +LEDGER_EVENT_PROOF_FIELDS: Final[frozenset[str]] = frozenset( + { + "schema", + "global_sequence", + "event_id", + "source_id", + "source_sequence", + "signer_key_id", + "nonce", + "event_schema", + "observed_at", + "received_at", + "correlation_id", + "causation_id", + "payload_digest", + "previous_source_digest", + "previous_global_digest", + "event_digest", + } +) +VALID_AUTHORITY_EFFECTS: Final[frozenset[str]] = frozenset( + {"APPLY_FAIL_CLOSED", "APPLY_POSITIVE", "HISTORICAL_ONLY"} +) +COMMAND_STATE_FIELDS: Final[frozenset[str]] = frozenset( + { + "schema", + "cell_id", + "next_sequence", + "next_digest", + "kill_envelope", + "desired_mode_envelope", + "capital_envelope", + "updated_at", + } +) + + +class DumbMoneyCommandFeedError(RuntimeError): + """Raised internally when the command feed cannot advance safely.""" + + +@dataclass(frozen=True) +class CommandFeedHttpRequest: + method: str + url: str + headers: Mapping[str, str] + timeout_seconds: float + body: bytes | None = None + + +@dataclass(frozen=True) +class CommandFeedHttpResponse: + status: int + headers: Mapping[str, str] + body: bytes + + +class CommandFeedTransport(Protocol): + def __call__( + self, + request: CommandFeedHttpRequest, + ) -> CommandFeedHttpResponse: ... + + +@dataclass(frozen=True) +class DumbMoneyCommandFeedClient: + """Injected, side-effect-free-until-called command-feed binding.""" + + base_url: str + cell_bearer_provider: Callable[[], str] + trusted_public_key: bytes + trusted_operator_public_keys: Mapping[str, bytes] + expected_account_hash: str + state_path: Path + transport: CommandFeedTransport + limit: int = DEFAULT_PAGE_LIMIT + timeout_seconds: float = 5.0 + + def poll_once(self, now_utc: datetime) -> DumbMoneyCommandDispatch: + return poll_once( + base_url=self.base_url, + cell_bearer_provider=self.cell_bearer_provider, + trusted_public_key=self.trusted_public_key, + trusted_operator_public_keys=self.trusted_operator_public_keys, + expected_account_hash=self.expected_account_hash, + state_path=self.state_path, + transport=self.transport, + now_utc=now_utc, + limit=self.limit, + timeout_seconds=self.timeout_seconds, + ) + + +@dataclass(frozen=True) +class DumbMoneyCommandDispatch: + """Controls safe to hand to one Live Branch cycle.""" + + desired_mode: str + kill_active: bool + allow_new_risk: bool + capital_authority: DumbMoneyAuthorityContext | None + fail_closed_reason: str + required_action: str + caught_up: bool + next_sequence: int + next_digest: str + applied_event_ids: tuple[str, ...] + + def evidence(self) -> dict[str, Any]: + return { + "desired_mode": self.desired_mode, + "kill_active": self.kill_active, + "allow_new_risk": self.allow_new_risk, + "capital_authority_present": self.capital_authority is not None, + "fail_closed_reason": self.fail_closed_reason, + "required_action": self.required_action, + "caught_up": self.caught_up, + "next_sequence": self.next_sequence, + "next_digest": self.next_digest, + "applied_event_ids": list(self.applied_event_ids), + } + + +@dataclass(frozen=True) +class _CommandState: + next_sequence: int = 0 + next_digest: str = GENESIS_DIGEST + kill_envelope: dict[str, Any] | None = None + desired_mode_envelope: dict[str, Any] | None = None + capital_envelope: dict[str, Any] | None = None + updated_at: str = "1970-01-01T00:00:00Z" + + def to_dict(self) -> dict[str, Any]: + return { + "schema": COMMAND_STATE_SCHEMA, + "cell_id": DOPEY_VENUE, + "next_sequence": self.next_sequence, + "next_digest": self.next_digest, + "kill_envelope": self.kill_envelope, + "desired_mode_envelope": self.desired_mode_envelope, + "capital_envelope": self.capital_envelope, + "updated_at": self.updated_at, + } + + +def command_feed_state_path(repository_root: Path) -> Path: + return artifact_root(repository_root) / "dumbmoney_command_feed_state.json" + + +def command_feed_head(state_path: Path) -> tuple[int, str]: + """Return one integrity-checked persisted cursor for Core anchoring.""" + + state = _read_state(state_path.resolve()) + return state.next_sequence, state.next_digest + + +def _format_utc(value: datetime) -> str: + utc = value.astimezone(timezone.utc) + rendered = utc.isoformat(timespec="microseconds" if utc.microsecond else "seconds") + return rendered.replace("+00:00", "Z") + + +def _positive_int(value: Any, label: str) -> int: + if isinstance(value, bool) or not isinstance(value, int) or value <= 0: + raise DumbMoneyCommandFeedError(f"{label} must be a positive integer") + return value + + +def _nonnegative_int(value: Any, label: str) -> int: + if isinstance(value, bool) or not isinstance(value, int) or value < 0: + raise DumbMoneyCommandFeedError(f"{label} must be a nonnegative integer") + return value + + +def _digest(value: Any, label: str) -> str: + try: + return _sha256_text(value, label) + except DumbMoneyAuthorityError as error: + raise DumbMoneyCommandFeedError(str(error)) from error + + +def _trusted_keyring( + trusted_public_key: bytes, + trusted_operator_public_keys: Mapping[str, bytes], +) -> tuple[bytes, dict[str, bytes]]: + try: + core_key = _decode_public_key(trusted_public_key) + except DumbMoneyAuthorityError as error: + raise DumbMoneyCommandFeedError("pinned Core public key is invalid") from error + core_key_id = hashlib.sha256(core_key).hexdigest() + if ( + not isinstance(trusted_operator_public_keys, Mapping) + or not 1 <= len(trusted_operator_public_keys) <= 16 + ): + raise DumbMoneyCommandFeedError( + "trusted operator public-key set must contain 1 to 16 keys" + ) + operator_keys: dict[str, bytes] = {} + for raw_key_id, raw_key in trusted_operator_public_keys.items(): + key_id = _digest(raw_key_id, "trusted operator key ID") + try: + public_key = _decode_public_key(raw_key) + except DumbMoneyAuthorityError as error: + raise DumbMoneyCommandFeedError( + f"trusted operator public key is invalid: key_id={key_id}" + ) from error + if hashlib.sha256(public_key).hexdigest() != key_id: + raise DumbMoneyCommandFeedError( + f"trusted operator key ID does not bind its key: key_id={key_id}" + ) + if key_id == core_key_id: + raise DumbMoneyCommandFeedError( + "Core checkpoint key and operator command keys must be disjoint" + ) + operator_keys[key_id] = public_key + return core_key, operator_keys + + +def _command_public_key( + envelope: Mapping[str, Any], + body_schema: str, + core_public_key: bytes, + operator_public_keys: Mapping[str, bytes], +) -> bytes: + signer_key_id = _digest( + envelope.get("signer_key_id"), + "command signer_key_id", + ) + core_key_id = hashlib.sha256(core_public_key).hexdigest() + if body_schema == CAPITAL_ENVELOPE_SCHEMA: + if signer_key_id != core_key_id: + raise DumbMoneyCommandFeedError( + "CapitalEnvelopeV1 command is not signed by pinned Core" + ) + return core_public_key + if body_schema in {DESIRED_MODE_SCHEMA, KILL_STATE_SCHEMA}: + public_key = operator_public_keys.get(signer_key_id) + if public_key is None: + raise DumbMoneyCommandFeedError( + "operator control command signer is outside the sealed operator key set" + ) + return public_key + raise DumbMoneyCommandFeedError( + f"command body schema is unsupported: {body_schema}" + ) + + +def _verify_checkpoint_signature( + checkpoint: Mapping[str, Any], + signature_payload: Mapping[str, Any], + core_public_key: bytes, +) -> None: + _exact_fields( + checkpoint, + COMMAND_CHECKPOINT_FIELDS, + "command checkpoint", + ) + _exact_fields( + signature_payload, + CHECKPOINT_SIGNATURE_FIELDS, + "command checkpoint signature", + ) + if checkpoint.get("schema") != COMMAND_CHECKPOINT_SCHEMA: + raise DumbMoneyCommandFeedError("command checkpoint schema is unsupported") + if signature_payload.get("algorithm") != "Ed25519": + raise DumbMoneyCommandFeedError( + "command checkpoint signature algorithm is unsupported" + ) + expected_key_id = hashlib.sha256(core_public_key).hexdigest() + if signature_payload.get("signer_key_id") != expected_key_id: + raise DumbMoneyCommandFeedError("command checkpoint signer is not pinned Core") + try: + signature = _urlsafe_b64decode(str(signature_payload.get("signature", ""))) + if len(signature) != 64: + raise ValueError("Ed25519 signature must be 64 bytes") + Ed25519PublicKey.from_public_bytes(core_public_key).verify( + signature, + canonical_json_bytes(dict(checkpoint)), + ) + except (InvalidSignature, ValueError) as error: + raise DumbMoneyCommandFeedError( + "command checkpoint signature is invalid" + ) from error + + +def _exact_fields( + payload: Mapping[str, Any], + expected: frozenset[str], + label: str, +) -> None: + missing = sorted(expected - set(payload)) + unknown = sorted(set(payload) - expected) + if missing or unknown: + raise DumbMoneyCommandFeedError( + f"{label} schema mismatch: missing={missing}, unknown={unknown}" + ) + + +def _reject_duplicate_keys(pairs: list[tuple[str, Any]]) -> dict[str, Any]: + result: dict[str, Any] = {} + for key, value in pairs: + if key in result: + raise DumbMoneyCommandFeedError(f"duplicate JSON key is prohibited: {key}") + result[key] = value + return result + + +def _reject_json_constant(value: str) -> None: + raise DumbMoneyCommandFeedError(f"non-finite JSON value is prohibited: {value}") + + +def _validate_json_domain(value: Any, path: str = "$") -> None: + if value is None or isinstance(value, (str, bool, int)): + return + if isinstance(value, float): + if not math.isfinite(value): + raise DumbMoneyCommandFeedError(f"{path} contains a non-finite number") + raise DumbMoneyCommandFeedError( + f"{path} contains a float outside the signed integer domain" + ) + if isinstance(value, list): + for index, item in enumerate(value): + _validate_json_domain(item, f"{path}[{index}]") + return + if isinstance(value, dict): + if not all(isinstance(key, str) for key in value): + raise DumbMoneyCommandFeedError(f"{path} contains a non-string object key") + for key, item in value.items(): + _validate_json_domain(item, f"{path}.{key}") + return + raise DumbMoneyCommandFeedError( + f"{path} contains unsupported JSON type {type(value).__name__}" + ) + + +def _loads_strict_json(raw: bytes, label: str) -> dict[str, Any]: + try: + value = json.loads( + raw, + object_pairs_hook=_reject_duplicate_keys, + parse_constant=_reject_json_constant, + ) + except (UnicodeDecodeError, json.JSONDecodeError) as error: + raise DumbMoneyCommandFeedError(f"{label} is not valid JSON") from error + if not isinstance(value, dict): + raise DumbMoneyCommandFeedError(f"{label} root must be an object") + _validate_json_domain(value) + return value + + +def _validated_ledger_proof( + raw_proof: Any, + command: Mapping[str, Any], + envelope: Mapping[str, Any], +) -> dict[str, Any]: + if not isinstance(raw_proof, dict): + raise DumbMoneyCommandFeedError("command ledger_proof must be an object") + _exact_fields(raw_proof, LEDGER_EVENT_PROOF_FIELDS, "ledger event proof") + if raw_proof.get("schema") != LEDGER_EVENT_PROOF_SCHEMA: + raise DumbMoneyCommandFeedError("ledger event proof schema is unsupported") + global_sequence = _positive_int( + raw_proof.get("global_sequence"), + "ledger proof global_sequence", + ) + source_sequence = _positive_int( + raw_proof.get("source_sequence"), + "ledger proof source_sequence", + ) + event_id = _digest(raw_proof.get("event_id"), "ledger proof event_id") + signer_key_id = _digest( + raw_proof.get("signer_key_id"), + "ledger proof signer_key_id", + ) + payload_digest = _digest( + raw_proof.get("payload_digest"), + "ledger proof payload_digest", + ) + _digest( + raw_proof.get("previous_source_digest"), + "ledger proof previous_source_digest", + ) + _digest( + raw_proof.get("previous_global_digest"), + "ledger proof previous_global_digest", + ) + event_digest = _digest( + raw_proof.get("event_digest"), + "ledger proof event_digest", + ) + for field in ("source_id", "nonce", "event_schema"): + value = raw_proof.get(field) + if not isinstance(value, str) or not value: + raise DumbMoneyCommandFeedError(f"ledger proof {field} must be nonempty") + correlation_id = raw_proof.get("correlation_id") + if not isinstance(correlation_id, str) or not correlation_id: + raise DumbMoneyCommandFeedError("ledger proof correlation_id must be nonempty") + causation_id = raw_proof.get("causation_id") + if causation_id is not None: + _digest(causation_id, "ledger proof causation_id") + try: + observed_at = _utc_timestamp( + raw_proof.get("observed_at"), + "ledger proof observed_at", + ) + received_at = _utc_timestamp( + raw_proof.get("received_at"), + "ledger proof received_at", + ) + envelope_not_before = _utc_timestamp( + envelope.get("not_before"), + "ledger proof envelope not_before", + ) + except DumbMoneyAuthorityError as error: + raise DumbMoneyCommandFeedError(str(error)) from error + if observed_at != max(received_at, envelope_not_before): + raise DumbMoneyCommandFeedError( + "ledger proof observed_at does not match ledger receipt semantics" + ) + if (source_sequence == 1) is not ( + raw_proof["previous_source_digest"] == GENESIS_DIGEST + ): + raise DumbMoneyCommandFeedError( + "ledger proof source-chain genesis semantics are invalid" + ) + if (global_sequence == 1) is not ( + raw_proof["previous_global_digest"] == GENESIS_DIGEST + ): + raise DumbMoneyCommandFeedError( + "ledger proof global-chain genesis semantics are invalid" + ) + event_core = { + key: value + for key, value in raw_proof.items() + if key not in {"schema", "event_digest"} + } + recomputed = hashlib.sha256(canonical_json_bytes(event_core)).hexdigest() + if recomputed != event_digest: + raise DumbMoneyCommandFeedError("ledger proof event_digest does not recompute") + duplicate_bindings = ( + (global_sequence, command.get("global_sequence"), "global_sequence"), + (event_id, command.get("event_id"), "event_id"), + (event_digest, command.get("event_digest"), "event_digest"), + (raw_proof["event_schema"], command.get("body_schema"), "body_schema"), + (event_id, envelope.get("event_id"), "envelope event_id"), + (raw_proof["source_id"], envelope.get("source_id"), "source_id"), + ( + source_sequence, + envelope.get("source_sequence"), + "source_sequence", + ), + ( + signer_key_id, + envelope.get("signer_key_id"), + "signer_key_id", + ), + (raw_proof["nonce"], envelope.get("nonce"), "nonce"), + ( + raw_proof["event_schema"], + envelope.get("body_schema"), + "event_schema", + ), + ( + correlation_id, + envelope.get("correlation_id"), + "correlation_id", + ), + (causation_id, envelope.get("causation_id"), "causation_id"), + ( + payload_digest, + envelope.get("body_digest"), + "payload_digest", + ), + ) + for observed, expected, label in duplicate_bindings: + if observed != expected: + raise DumbMoneyCommandFeedError(f"ledger proof {label} binding mismatch") + return dict(raw_proof) + + +def _validate_command_projection( + command: Mapping[str, Any], + verified: VerifiedCapitalAuthority | VerifiedDesiredMode | VerifiedKillState, + now_utc: datetime, +) -> None: + transport_current = False + envelope = command["envelope"] + try: + wrapper_not_before = _utc_timestamp( + envelope.get("not_before"), + "command envelope not_before", + ) + wrapper_expires = _utc_timestamp( + envelope.get("expires_at"), + "command envelope expires_at", + ) + except DumbMoneyAuthorityError as error: + raise DumbMoneyCommandFeedError(str(error)) from error + now = now_utc.astimezone(timezone.utc) + transport_current = wrapper_not_before <= now < wrapper_expires + if isinstance(verified, VerifiedCapitalAuthority): + body_not_before = _utc_timestamp( + verified.envelope.not_before, + "capital body not_before", + ) + body_expires = _utc_timestamp( + verified.envelope.expires_at, + "capital body expires_at", + ) + body_current = body_not_before <= now < body_expires + positive_grant = True + elif isinstance(verified, VerifiedDesiredMode): + body_not_before = _utc_timestamp( + verified.desired_mode.not_before, + "desired mode body not_before", + ) + body_expires = _utc_timestamp( + verified.desired_mode.expires_at, + "desired mode body expires_at", + ) + body_current = body_not_before <= now < body_expires + positive_grant = verified.desired_mode.mode == "LIVE" + else: + changed_at = _utc_timestamp( + verified.kill_state.changed_at, + "kill state changed_at", + ) + body_current = changed_at <= now + MAXIMUM_KILL_CHANGED_AT_FUTURE_SKEW + positive_grant = not verified.kill_state.active + valid_now = transport_current and body_current + expected_effect = ( + "APPLY_FAIL_CLOSED" + if not positive_grant + else "APPLY_POSITIVE" + if valid_now + else "HISTORICAL_ONLY" + ) + if command.get("transport_window_current") is not transport_current: + raise DumbMoneyCommandFeedError( + "command transport_window_current projection mismatch" + ) + if command.get("valid_now") is not valid_now: + raise DumbMoneyCommandFeedError("command valid_now projection mismatch") + if command.get("authority_effect") != expected_effect: + raise DumbMoneyCommandFeedError("command authority_effect projection mismatch") + + +def _validated_loopback_origin(base_url: str) -> str: + try: + parsed = urlsplit(base_url) + port = parsed.port + except ValueError as error: + raise DumbMoneyCommandFeedError( + "DumbMoney Core URL has an invalid port" + ) from error + if ( + parsed.scheme != "http" + or parsed.hostname != "127.0.0.1" + or port is None + or not 1 <= port <= 65_535 + or parsed.username is not None + or parsed.password is not None + or parsed.path not in {"", "/"} + or parsed.query + or parsed.fragment + ): + raise DumbMoneyCommandFeedError( + "DumbMoney Core command feed must use an exact " + "http://127.0.0.1: origin" + ) + return f"http://127.0.0.1:{port}" + + +def _cell_bearer(provider: Callable[[], str]) -> str: + try: + value = provider() + except Exception as error: + raise DumbMoneyCommandFeedError( + f"cell bearer provider failed: {type(error).__name__}" + ) from error + if ( + not isinstance(value, str) + or not 32 <= len(value) <= 4096 + or any(character.isspace() or ord(character) < 33 for character in value) + ): + raise DumbMoneyCommandFeedError( + "cell bearer must be an injected high-entropy non-whitespace string" + ) + return value + + +def _state_from_payload(payload: dict[str, Any]) -> _CommandState: + _exact_fields(payload, COMMAND_STATE_FIELDS, "command state") + if payload.get("schema") != COMMAND_STATE_SCHEMA: + raise DumbMoneyCommandFeedError("command state schema is unsupported") + if payload.get("cell_id") != DOPEY_VENUE: + raise DumbMoneyCommandFeedError("command state targets another cell") + next_sequence = _nonnegative_int( + payload.get("next_sequence"), + "command state next_sequence", + ) + next_digest = _digest( + payload.get("next_digest"), + "command state next_digest", + ) + if next_sequence == 0 and next_digest != GENESIS_DIGEST: + raise DumbMoneyCommandFeedError( + "bootstrap command cursor must use the zero digest" + ) + for field in ( + "kill_envelope", + "desired_mode_envelope", + "capital_envelope", + ): + value = payload.get(field) + if value is not None and ( + not isinstance(value, dict) + or not all(isinstance(key, str) for key in value) + ): + raise DumbMoneyCommandFeedError( + f"command state {field} must be an object or null" + ) + updated_at = str(payload.get("updated_at", "")) + try: + _utc_timestamp(updated_at, "command state updated_at") + except DumbMoneyAuthorityError as error: + raise DumbMoneyCommandFeedError(str(error)) from error + return _CommandState( + next_sequence=next_sequence, + next_digest=next_digest, + kill_envelope=payload.get("kill_envelope"), + desired_mode_envelope=payload.get("desired_mode_envelope"), + capital_envelope=payload.get("capital_envelope"), + updated_at=updated_at, + ) + + +def _read_state(path: Path) -> _CommandState: + if not path.exists(): + return _CommandState() + if not path.is_file(): + raise DumbMoneyCommandFeedError("command state path is not a file") + try: + raw = path.read_bytes() + except OSError as error: + raise DumbMoneyCommandFeedError("command state is unreadable") from error + if len(raw) > MAXIMUM_RESPONSE_BYTES: + raise DumbMoneyCommandFeedError("command state exceeds its size ceiling") + return _state_from_payload(_loads_strict_json(raw, "command state")) + + +def _write_state(path: Path, state: _CommandState) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + encoded = ( + json.dumps( + state.to_dict(), + sort_keys=True, + separators=(",", ":"), + ensure_ascii=False, + allow_nan=False, + ) + + "\n" + ).encode("utf-8") + temporary_path: Path | None = None + try: + with tempfile.NamedTemporaryFile( + mode="wb", + dir=path.parent, + prefix=f".{path.name}.", + suffix=".tmp", + delete=False, + ) as handle: + handle.write(encoded) + handle.flush() + os.fsync(handle.fileno()) + temporary_path = Path(handle.name) + os.replace(temporary_path, path) + temporary_path = None + finally: + if temporary_path is not None: + try: + temporary_path.unlink() + except FileNotFoundError: + pass + + +@contextmanager +def _exclusive_state_lock(path: Path) -> Iterator[None]: + lock_path = path.with_suffix(f"{path.suffix}.lock") + lock_path.parent.mkdir(parents=True, exist_ok=True) + try: + descriptor = os.open(lock_path, os.O_CREAT | os.O_EXCL | os.O_WRONLY) + except FileExistsError as error: + raise DumbMoneyCommandFeedError( + "command feed state is locked by another poller" + ) from error + try: + os.write(descriptor, str(os.getpid()).encode("ascii")) + os.fsync(descriptor) + yield + finally: + os.close(descriptor) + try: + lock_path.unlink() + except FileNotFoundError: + pass + + +def _response_content_type(headers: Mapping[str, str]) -> str: + return next( + ( + str(value).lower() + for name, value in headers.items() + if str(name).lower() == "content-type" + ), + "", + ) + + +def _current_persisted_control( + envelope: dict[str, Any] | None, + core_public_key: bytes, + operator_public_keys: Mapping[str, bytes], + expected_account_hash: str, + now_utc: datetime, +) -> VerifiedCapitalAuthority | VerifiedDesiredMode | VerifiedKillState | None: + if envelope is None: + return None + body_schema = str(envelope.get("body_schema", "")) + public_key = _command_public_key( + envelope, + body_schema, + core_public_key, + operator_public_keys, + ) + return verify_signed_control_envelope( + envelope, + public_key, + now_utc, + expected_account_hash, + enforce_current_window=True, + ) + + +def _historical_persisted_control( + envelope: dict[str, Any] | None, + core_public_key: bytes, + operator_public_keys: Mapping[str, bytes], + expected_account_hash: str, + now_utc: datetime, +) -> VerifiedCapitalAuthority | VerifiedDesiredMode | VerifiedKillState | None: + if envelope is None: + return None + body_schema = str(envelope.get("body_schema", "")) + public_key = _command_public_key( + envelope, + body_schema, + core_public_key, + operator_public_keys, + ) + return verify_signed_control_envelope( + envelope, + public_key, + now_utc, + expected_account_hash, + enforce_current_window=False, + ) + + +def _fail_closed( + state: _CommandState, + reason: str, + *, + kill_active: bool = True, + required_action: str = "FAIL_CLOSED", + caught_up: bool = False, + applied_event_ids: tuple[str, ...] = (), +) -> DumbMoneyCommandDispatch: + return DumbMoneyCommandDispatch( + desired_mode="PAUSED", + kill_active=kill_active, + allow_new_risk=False, + capital_authority=None, + fail_closed_reason=reason, + required_action=required_action, + caught_up=caught_up, + next_sequence=state.next_sequence, + next_digest=state.next_digest, + applied_event_ids=applied_event_ids, + ) + + +def _validated_page( + payload: dict[str, Any], + state: _CommandState, + now_utc: datetime, + limit: int, + core_public_key: bytes, + expected_request_nonce: str, +) -> tuple[ + int, + str, + bool, + str, + tuple[dict[str, Any], ...], +]: + _exact_fields(payload, COMMAND_PAGE_FIELDS, "command page") + if payload.get("schema") != COMMAND_PAGE_SCHEMA: + raise DumbMoneyCommandFeedError("command page schema is unsupported") + raw_checkpoint = payload.get("checkpoint") + raw_checkpoint_signature = payload.get("checkpoint_signature") + if not isinstance(raw_checkpoint, dict) or not isinstance( + raw_checkpoint_signature, + dict, + ): + raise DumbMoneyCommandFeedError( + "command page checkpoint and signature must be objects" + ) + _verify_checkpoint_signature( + raw_checkpoint, + raw_checkpoint_signature, + core_public_key, + ) + checkpoint_bindings = { + "cell_id": payload.get("cell_id"), + "request_nonce": payload.get("request_nonce"), + "observed_at": payload.get("observed_at"), + "after_sequence": payload.get("after_sequence"), + "after_digest": payload.get("after_digest"), + "next_sequence": payload.get("next_sequence"), + "next_digest": payload.get("next_digest"), + "ledger_head_sequence": payload.get("ledger_head_sequence"), + "ledger_head_digest": payload.get("ledger_head_digest"), + "required_action": payload.get("required_action"), + } + if any( + raw_checkpoint.get(field) != value + for field, value in checkpoint_bindings.items() + ): + raise DumbMoneyCommandFeedError( + "signed checkpoint does not bind command page metadata" + ) + if payload.get("cell_id") != DOPEY_VENUE: + raise DumbMoneyCommandFeedError("command page targets another cell") + request_nonce = _digest( + payload.get("request_nonce"), + "command page request_nonce", + ) + if request_nonce != expected_request_nonce: + raise DumbMoneyCommandFeedError( + "command page request_nonce does not match this poll" + ) + if payload.get("after_sequence") != state.next_sequence: + raise DumbMoneyCommandFeedError("command page sequence gap or rollback") + if payload.get("after_digest") != state.next_digest: + raise DumbMoneyCommandFeedError("command page starting digest mismatch") + next_sequence = _nonnegative_int( + payload.get("next_sequence"), + "command page next_sequence", + ) + if ( + next_sequence < state.next_sequence + or next_sequence - state.next_sequence > limit + ): + raise DumbMoneyCommandFeedError("command page sequence advance is invalid") + next_digest = _digest( + payload.get("next_digest"), + "command page next_digest", + ) + ledger_head = _nonnegative_int( + payload.get("ledger_head_sequence"), + "command page ledger_head_sequence", + ) + ledger_head_digest = _digest( + payload.get("ledger_head_digest"), + "command page ledger_head_digest", + ) + if next_sequence > ledger_head: + raise DumbMoneyCommandFeedError( + "command page cursor is ahead of the ledger head" + ) + if next_sequence == ledger_head and next_digest != ledger_head_digest: + raise DumbMoneyCommandFeedError( + "caught-up page cursor does not bind the ledger head digest" + ) + has_more = payload.get("has_more") + if not isinstance(has_more, bool) or has_more is not (next_sequence < ledger_head): + raise DumbMoneyCommandFeedError("command page has_more is inconsistent") + if next_sequence == state.next_sequence and next_digest != state.next_digest: + raise DumbMoneyCommandFeedError("stationary command cursor changed its digest") + required_action = str(payload.get("required_action", "")) + if required_action not in VALID_REQUIRED_ACTIONS: + raise DumbMoneyCommandFeedError("command page required_action is unsupported") + try: + observed_at = _utc_timestamp( + payload.get("observed_at"), + "command page observed_at", + ) + except DumbMoneyAuthorityError as error: + raise DumbMoneyCommandFeedError(str(error)) from error + now = now_utc.astimezone(timezone.utc) + if abs(now - observed_at) > MAXIMUM_PAGE_CLOCK_SKEW: + raise DumbMoneyCommandFeedError( + "command page observed_at is stale or future-dated" + ) + raw_commands = payload.get("commands") + if not isinstance(raw_commands, list) or len(raw_commands) > limit: + raise DumbMoneyCommandFeedError("command page commands must be a bounded array") + ordered_commands = raw_checkpoint.get("ordered_commands") + if not isinstance(ordered_commands, list) or len(ordered_commands) != len( + raw_commands + ): + raise DumbMoneyCommandFeedError( + "signed checkpoint ordered_commands does not match the page" + ) + commands: list[dict[str, Any]] = [] + previous_sequence = state.next_sequence + previous_event_digest = state.next_digest + event_ids: set[str] = set() + event_digests: set[str] = set() + for index, raw_command in enumerate(raw_commands): + if not isinstance(raw_command, dict): + raise DumbMoneyCommandFeedError( + f"command page command {index} must be an object" + ) + _exact_fields(raw_command, COMMAND_FIELDS, f"command {index}") + global_sequence = _positive_int( + raw_command.get("global_sequence"), + f"command {index} global_sequence", + ) + prior_sequence = previous_sequence + if not previous_sequence < global_sequence <= next_sequence: + raise DumbMoneyCommandFeedError( + "command global sequences are duplicated, reordered, " + "or outside the page" + ) + event_id = _digest(raw_command.get("event_id"), f"command {index} event_id") + event_digest = _digest( + raw_command.get("event_digest"), + f"command {index} event_digest", + ) + if event_id in event_ids or event_digest in event_digests: + raise DumbMoneyCommandFeedError( + "command page repeats an event identity or digest" + ) + event_ids.add(event_id) + event_digests.add(event_digest) + if not isinstance(raw_command.get("valid_now"), bool): + raise DumbMoneyCommandFeedError( + "command valid_now projection must be a boolean" + ) + if not isinstance( + raw_command.get("transport_window_current"), + bool, + ): + raise DumbMoneyCommandFeedError( + "command transport_window_current projection must be a boolean" + ) + if raw_command.get("authority_effect") not in VALID_AUTHORITY_EFFECTS: + raise DumbMoneyCommandFeedError( + "command authority_effect projection is unsupported" + ) + envelope = raw_command.get("envelope") + if not isinstance(envelope, dict): + raise DumbMoneyCommandFeedError( + f"command {index} envelope must be an object" + ) + if envelope.get("event_id") != event_id: + raise DumbMoneyCommandFeedError( + f"command {index} event_id does not bind its envelope" + ) + if envelope.get("body_schema") != raw_command.get("body_schema"): + raise DumbMoneyCommandFeedError( + f"command {index} body_schema does not bind its envelope" + ) + proof = _validated_ledger_proof( + raw_command.get("ledger_proof"), + raw_command, + envelope, + ) + if ( + global_sequence == state.next_sequence + 1 + and proof["previous_global_digest"] != state.next_digest + ): + raise DumbMoneyCommandFeedError( + "first contiguous ledger proof does not bind the request cursor" + ) + if ( + commands + and global_sequence == prior_sequence + 1 + and proof["previous_global_digest"] != previous_event_digest + ): + raise DumbMoneyCommandFeedError( + "contiguous ledger proofs do not form a global hash chain" + ) + ordered_command = ordered_commands[index] + if not isinstance(ordered_command, dict): + raise DumbMoneyCommandFeedError( + "signed checkpoint ordered command must be an object" + ) + _exact_fields( + ordered_command, + ORDERED_COMMAND_FIELDS, + f"checkpoint ordered command {index}", + ) + projected = { + key: value for key, value in raw_command.items() if key != "envelope" + } + if ordered_command != projected: + raise DumbMoneyCommandFeedError( + "signed checkpoint ordered command projection mismatch" + ) + commands.append(dict(raw_command)) + previous_sequence = global_sequence + previous_event_digest = event_digest + if commands and commands[-1]["global_sequence"] == next_sequence: + if commands[-1]["event_digest"] != next_digest: + raise DumbMoneyCommandFeedError( + "command page terminal event digest does not bind the next cursor" + ) + return next_sequence, next_digest, has_more, required_action, tuple(commands) + + +def _apply_commands( + state: _CommandState, + commands: tuple[dict[str, Any], ...], + core_public_key: bytes, + operator_public_keys: Mapping[str, bytes], + expected_account_hash: str, + now_utc: datetime, +) -> tuple[ + dict[str, Any] | None, + dict[str, Any] | None, + dict[str, Any] | None, + tuple[str, ...], +]: + historical_kill = _historical_persisted_control( + state.kill_envelope, + core_public_key, + operator_public_keys, + expected_account_hash, + now_utc, + ) + historical_mode = _historical_persisted_control( + state.desired_mode_envelope, + core_public_key, + operator_public_keys, + expected_account_hash, + now_utc, + ) + historical_capital = _historical_persisted_control( + state.capital_envelope, + core_public_key, + operator_public_keys, + expected_account_hash, + now_utc, + ) + if historical_kill is not None and not isinstance( + historical_kill, + VerifiedKillState, + ): + raise DumbMoneyCommandFeedError("persisted kill control has the wrong schema") + if historical_mode is not None and not isinstance( + historical_mode, + VerifiedDesiredMode, + ): + raise DumbMoneyCommandFeedError("persisted mode control has the wrong schema") + if historical_capital is not None and not isinstance( + historical_capital, + VerifiedCapitalAuthority, + ): + raise DumbMoneyCommandFeedError( + "persisted capital control has the wrong schema" + ) + kill_envelope = state.kill_envelope + desired_envelope = state.desired_mode_envelope + capital_envelope = state.capital_envelope + kill_generation = ( + historical_kill.kill_state.generation if historical_kill is not None else 0 + ) + mode_revision = ( + historical_mode.desired_mode.revision if historical_mode is not None else 0 + ) + capital_generation = ( + historical_capital.envelope.fencing_generation + if historical_capital is not None + else 0 + ) + applied_event_ids: list[str] = [] + for command in commands: + envelope = dict(command["envelope"]) + body_schema = str(command["body_schema"]) + try: + public_key = _command_public_key( + envelope, + body_schema, + core_public_key, + operator_public_keys, + ) + verified = verify_signed_control_envelope( + envelope, + public_key, + now_utc, + expected_account_hash, + enforce_current_window=False, + ) + except DumbMoneyAuthorityError as error: + raise DumbMoneyCommandFeedError( + f"signed command verification failed: {error}" + ) from error + _validate_command_projection(command, verified, now_utc) + if isinstance(verified, VerifiedKillState): + if body_schema != KILL_STATE_SCHEMA: + raise DumbMoneyCommandFeedError("verified kill schema mismatch") + if verified.kill_state.generation != kill_generation + 1: + raise DumbMoneyCommandFeedError("KillStateV1 generation gap or replay") + kill_generation = verified.kill_state.generation + kill_envelope = envelope + elif isinstance(verified, VerifiedDesiredMode): + if body_schema != DESIRED_MODE_SCHEMA: + raise DumbMoneyCommandFeedError("verified desired-mode schema mismatch") + if verified.desired_mode.revision != mode_revision + 1: + raise DumbMoneyCommandFeedError("DesiredModeV1 revision gap or replay") + mode_revision = verified.desired_mode.revision + desired_envelope = envelope + elif isinstance(verified, VerifiedCapitalAuthority): + if body_schema != CAPITAL_ENVELOPE_SCHEMA: + raise DumbMoneyCommandFeedError("verified capital schema mismatch") + if verified.envelope.fencing_generation != capital_generation + 1: + raise DumbMoneyCommandFeedError( + "CapitalEnvelopeV1 fencing generation gap or replay" + ) + capital_generation = verified.envelope.fencing_generation + capital_envelope = envelope + else: # pragma: no cover - closed by the verifier union + raise DumbMoneyCommandFeedError("unsupported verified command") + applied_event_ids.append(verified.event_id) + return ( + kill_envelope, + desired_envelope, + capital_envelope, + tuple(applied_event_ids), + ) + + +def _dispatch_from_state( + state: _CommandState, + core_public_key: bytes, + operator_public_keys: Mapping[str, bytes], + expected_account_hash: str, + now_utc: datetime, + *, + required_action: str, + caught_up: bool, + applied_event_ids: tuple[str, ...], +) -> DumbMoneyCommandDispatch: + try: + kill = _current_persisted_control( + state.kill_envelope, + core_public_key, + operator_public_keys, + expected_account_hash, + now_utc, + ) + except DumbMoneyAuthorityError: + _historical_persisted_control( + state.kill_envelope, + core_public_key, + operator_public_keys, + expected_account_hash, + now_utc, + ) + return _fail_closed( + state, + "kill_control_stale_or_future", + # A previously authenticated active kill is durable. An expired + # inactive projection is no longer positive evidence that kill is + # clear, so unknown also resolves to the risk-reducing state. + kill_active=True, + required_action=required_action, + caught_up=caught_up, + applied_event_ids=applied_event_ids, + ) + if kill is None or not isinstance(kill, VerifiedKillState): + return _fail_closed( + state, + "verified_kill_state_missing", + required_action=required_action, + caught_up=caught_up, + applied_event_ids=applied_event_ids, + ) + if kill.kill_state.active: + return _fail_closed( + state, + "signed_global_kill_active", + kill_active=True, + required_action=required_action, + caught_up=caught_up, + applied_event_ids=applied_event_ids, + ) + try: + desired = _current_persisted_control( + state.desired_mode_envelope, + core_public_key, + operator_public_keys, + expected_account_hash, + now_utc, + ) + capital = _current_persisted_control( + state.capital_envelope, + core_public_key, + operator_public_keys, + expected_account_hash, + now_utc, + ) + except DumbMoneyAuthorityError: + return _fail_closed( + state, + "mode_or_capital_control_stale_future_or_invalid", + kill_active=False, + required_action=required_action, + caught_up=caught_up, + applied_event_ids=applied_event_ids, + ) + if desired is None or not isinstance(desired, VerifiedDesiredMode): + return _fail_closed( + state, + "verified_desired_mode_missing", + kill_active=False, + required_action=required_action, + caught_up=caught_up, + applied_event_ids=applied_event_ids, + ) + if desired.desired_mode.mode != "LIVE": + dispatch = _fail_closed( + state, + f"desired_mode_{desired.desired_mode.mode.lower()}", + kill_active=False, + required_action=required_action, + caught_up=caught_up, + applied_event_ids=applied_event_ids, + ) + return DumbMoneyCommandDispatch( + **{ + **dispatch.__dict__, + "desired_mode": desired.desired_mode.mode, + } + ) + if capital is None or not isinstance(capital, VerifiedCapitalAuthority): + return _fail_closed( + state, + "verified_capital_authority_missing", + kill_active=False, + required_action=required_action, + caught_up=caught_up, + applied_event_ids=applied_event_ids, + ) + policy_epochs = { + kill.kill_state.policy_epoch, + desired.desired_mode.policy_epoch, + capital.envelope.policy_epoch, + } + if len(policy_epochs) != 1: + return _fail_closed( + state, + "control_policy_epoch_mismatch", + kill_active=False, + required_action=required_action, + caught_up=caught_up, + applied_event_ids=applied_event_ids, + ) + if not caught_up: + return _fail_closed( + state, + "command_feed_not_caught_up", + kill_active=False, + required_action=required_action, + caught_up=False, + applied_event_ids=applied_event_ids, + ) + if required_action != "APPLY_SIGNED_CONTROLS": + return _fail_closed( + state, + f"core_required_action_{required_action.lower()}", + kill_active=False, + required_action=required_action, + caught_up=caught_up, + applied_event_ids=applied_event_ids, + ) + authority = DumbMoneyAuthorityContext( + signed_envelope=dict(state.capital_envelope or {}), + trusted_public_key=core_public_key, + expected_account_hash=expected_account_hash, + ) + return DumbMoneyCommandDispatch( + desired_mode="LIVE", + kill_active=False, + allow_new_risk=True, + capital_authority=authority, + fail_closed_reason="", + required_action=required_action, + caught_up=True, + next_sequence=state.next_sequence, + next_digest=state.next_digest, + applied_event_ids=applied_event_ids, + ) + + +def poll_once( + *, + base_url: str, + cell_bearer_provider: Callable[[], str], + trusted_public_key: bytes, + trusted_operator_public_keys: Mapping[str, bytes], + expected_account_hash: str, + state_path: Path, + transport: CommandFeedTransport, + now_utc: datetime, + limit: int = DEFAULT_PAGE_LIMIT, + timeout_seconds: float = 5.0, +) -> DumbMoneyCommandDispatch: + """Fetch, verify, persist, and dispatch at most one bounded command page.""" + + if now_utc.tzinfo is None: + return _fail_closed(_CommandState(), "poll_clock_is_timezone_naive") + try: + origin = _validated_loopback_origin(base_url) + core_public_key, operator_public_keys = _trusted_keyring( + trusted_public_key, + trusted_operator_public_keys, + ) + account_hash = _sha256_text( + expected_account_hash, + "expected_account_hash", + ) + if ( + isinstance(limit, bool) + or not isinstance(limit, int) + or not 1 <= limit <= MAXIMUM_PAGE_LIMIT + ): + raise DumbMoneyCommandFeedError( + "command page limit must be between 1 and 1000" + ) + if ( + isinstance(timeout_seconds, bool) + or not isinstance(timeout_seconds, (int, float)) + or not math.isfinite(float(timeout_seconds)) + or not 0.1 <= float(timeout_seconds) <= 30.0 + ): + raise DumbMoneyCommandFeedError( + "command transport timeout must be between 0.1 and 30 seconds" + ) + resolved_state_path = state_path.resolve() + except (DumbMoneyAuthorityError, DumbMoneyCommandFeedError, OSError) as error: + return _fail_closed( + _CommandState(), + f"feed_configuration_invalid:{type(error).__name__}", + ) + try: + with _exclusive_state_lock(resolved_state_path): + state = _read_state(resolved_state_path) + try: + token = _cell_bearer(cell_bearer_provider) + try: + raw_request_nonce = secrets.token_hex(32) + except Exception as error: + raise DumbMoneyCommandFeedError( + "cryptographic request nonce generation failed" + ) from error + request_nonce = _digest( + raw_request_nonce, + "command request_nonce", + ) + query = urlencode( + { + "after": state.next_sequence, + "cursor": state.next_digest, + "limit": limit, + "request_nonce": request_nonce, + } + ) + request = CommandFeedHttpRequest( + method="GET", + url=(f"{origin}/v1/cells/{DOPEY_VENUE}/commands?{query}"), + headers={ + "Accept": "application/json", + "Authorization": f"Bearer {token}", + }, + timeout_seconds=float(timeout_seconds), + ) + try: + response = transport(request) + except Exception as error: + return _fail_closed( + state, + f"command_transport_failure:{type(error).__name__}", + ) + if not isinstance(response, CommandFeedHttpResponse): + return _fail_closed( + state, + "command_transport_response_invalid", + ) + if isinstance(response.status, bool) or not isinstance( + response.status, int + ): + return _fail_closed(state, "command_transport_status_invalid") + if response.status in {401, 403}: + return _fail_closed(state, "cell_bearer_authentication_failed") + if response.status == 409: + return _fail_closed(state, "command_cursor_rejected") + if response.status != 200: + return _fail_closed( + state, + f"command_http_status_{response.status}", + ) + if not isinstance(response.body, bytes): + return _fail_closed(state, "command_response_body_not_bytes") + if len(response.body) > MAXIMUM_RESPONSE_BYTES: + return _fail_closed(state, "command_response_too_large") + if not _response_content_type(response.headers).startswith( + "application/json" + ): + return _fail_closed( + state, + "command_response_content_type_invalid", + ) + page = _loads_strict_json(response.body, "command page") + ( + next_sequence, + next_digest, + has_more, + required_action, + commands, + ) = _validated_page( + page, + state, + now_utc, + limit, + core_public_key, + request_nonce, + ) + ( + kill_envelope, + desired_envelope, + capital_envelope, + applied_event_ids, + ) = _apply_commands( + state, + commands, + core_public_key, + operator_public_keys, + account_hash, + now_utc, + ) + advanced = _CommandState( + next_sequence=next_sequence, + next_digest=next_digest, + kill_envelope=kill_envelope, + desired_mode_envelope=desired_envelope, + capital_envelope=capital_envelope, + updated_at=_format_utc(now_utc), + ) + _write_state(resolved_state_path, advanced) + return _dispatch_from_state( + advanced, + core_public_key, + operator_public_keys, + account_hash, + now_utc, + required_action=required_action, + caught_up=not has_more, + applied_event_ids=applied_event_ids, + ) + except ( + DumbMoneyAuthorityError, + DumbMoneyCommandFeedError, + OSError, + ) as error: + return _fail_closed( + state, + f"command_page_rejected:{error}", + kill_active=True, + ) + except (DumbMoneyCommandFeedError, OSError) as error: + return _fail_closed( + _CommandState(), + f"command_state_unavailable:{error}", + ) diff --git a/dopey_live/dumbmoney_contract_feed.py b/dopey_live/dumbmoney_contract_feed.py new file mode 100644 index 0000000..b6d90f7 --- /dev/null +++ b/dopey_live/dumbmoney_contract_feed.py @@ -0,0 +1,846 @@ +"""Nonce-bound Core resolver for Dopey promotion and passport contracts.""" + +from __future__ import annotations + +import hashlib +import math +import secrets +from dataclasses import dataclass +from datetime import datetime, timedelta, timezone +from typing import Any, Callable, Final, Mapping +from urllib.parse import urlencode + +from cryptography.exceptions import InvalidSignature +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey + +from .dumbmoney_authority import ( + DOPEY_VENUE, + DumbMoneyAuthorityError, + VerifiedCapitalAuthority, + _decode_public_key, + _sha256_text, + _utc_timestamp, + _urlsafe_b64decode, + canonical_json_bytes, +) +from .dumbmoney_command_feed import ( + CHECKPOINT_SIGNATURE_FIELDS, + MAXIMUM_RESPONSE_BYTES, + CommandFeedHttpRequest, + CommandFeedHttpResponse, + CommandFeedTransport, + DumbMoneyCommandFeedError, + _cell_bearer, + _exact_fields, + _loads_strict_json, + _response_content_type, + _validated_ledger_proof, + _validated_loopback_origin, +) +from .dumbmoney_promotion import ( + ALPHA_PASSPORT_SCHEMA, + LIVE_PROMOTION_STAGES, + PROMOTION_CERTIFICATE_SCHEMA, + DumbMoneyPromotionError, + PromotionAuthorityResolver, + VerifiedAlphaPassport, + VerifiedPromotionBinding, + VerifiedPromotionCertificate, + bind_verified_promotion, + validated_role_keyrings, + verify_alpha_passport_envelope, + verify_promotion_certificate_envelope, +) +from .models import LiveExecutionIntent + + +CONTRACT_RESOLUTION_SCHEMA: Final[str] = "dumbmoney.cell-contract-resolution.v1" +CONTRACT_RESOLUTION_CHECKPOINT_SCHEMA: Final[str] = ( + "dumbmoney.cell-contract-resolution-checkpoint.v1" +) +CONTRACT_RESOLUTION_FIELDS: Final[frozenset[str]] = frozenset( + { + "schema", + "cell_id", + "request_nonce", + "requested_body_digest", + "capital_envelope_digest", + "fencing_generation", + "observed_at", + "contract_schema", + "transport_window_current", + "body_window_current", + "eligible_live_input", + "authority_state", + "ledger_proof", + "envelope", + "checkpoint", + "checkpoint_signature", + } +) +CONTRACT_RESOLUTION_CHECKPOINT_FIELDS: Final[frozenset[str]] = frozenset( + { + "schema", + "cell_id", + "request_nonce", + "requested_body_digest", + "capital_envelope_digest", + "fencing_generation", + "observed_at", + "contract_schema", + "transport_window_current", + "body_window_current", + "eligible_live_input", + "authority_state", + "ledger_proof", + "envelope", + } +) +CONTRACT_SCHEMAS: Final[frozenset[str]] = frozenset( + {ALPHA_PASSPORT_SCHEMA, PROMOTION_CERTIFICATE_SCHEMA} +) +MAXIMUM_RESOLUTION_CLOCK_SKEW: Final[timedelta] = timedelta(seconds=60) +AUTHORITY_STATE_SCHEMA: Final[str] = "dumbmoney.cell-authority-state.v1" +AUTHORITY_STATE_FIELDS: Final[frozenset[str]] = frozenset( + { + "schema", + "evaluated_at", + "authority_valid_until", + "policy_epoch", + "mandate_id", + "mandate_event_digest", + "kill_clear", + "kill_generation", + "kill_event_digest", + "desired_mode", + "desired_mode_revision", + "desired_mode_event_digest", + "capital_envelope_digest", + "capital_event_digest", + "fencing_generation", + "strategy_hash", + "passport_digest", + "passport_event_digest", + "promotion_digest", + "promotion_event_digest", + "verdicts", + "ledger_head_sequence", + "ledger_head_digest", + } +) +AUTHORITY_VERDICT_FIELDS: Final[frozenset[str]] = frozenset( + { + "verdict_digest", + "verdict_event_digest", + "verdict_id", + "court", + "decision", + "signer_key_id", + "evaluated_at", + "expires_at", + "transport_expires_at", + } +) +VERDICT_COURTS: Final[frozenset[str]] = frozenset( + {"integrity", "statistics", "economics", "adversarial_operations"} +) + + +@dataclass(frozen=True) +class _ResolvedContract: + contract_schema: str + body_digest: str + envelope: dict[str, Any] + verified: VerifiedAlphaPassport | VerifiedPromotionCertificate + authority_state: dict[str, Any] + + +def _validated_evaluator_keyring( + values: Mapping[str, bytes], + *, + disjoint_key_ids: frozenset[str], +) -> dict[str, bytes]: + if not isinstance(values, Mapping) or not 1 <= len(values) <= 32: + raise DumbMoneyPromotionError( + "sealed evaluator keyring must contain 1 to 32 keys" + ) + resolved: dict[str, bytes] = {} + for raw_key_id, raw_key in values.items(): + key_id = _sha256_text(raw_key_id, "evaluator signer key ID") + try: + public_key = _decode_public_key(raw_key) + except DumbMoneyAuthorityError as error: + raise DumbMoneyPromotionError( + "sealed evaluator public key is invalid" + ) from error + if hashlib.sha256(public_key).hexdigest() != key_id: + raise DumbMoneyPromotionError( + "evaluator signer key ID does not bind its public key" + ) + if key_id in disjoint_key_ids: + raise DumbMoneyPromotionError( + "evaluator signer role overlaps a control or research role" + ) + resolved[key_id] = public_key + return resolved + + +@dataclass(frozen=True) +class DumbMoneyContractResolverClient(PromotionAuthorityResolver): + """Fetch and authenticate the exact singleton lineage named by capital.""" + + base_url: str + cell_bearer_provider: Callable[[], str] + trusted_core_public_key: bytes + trusted_promoter_public_keys: Mapping[str, bytes] + trusted_research_public_keys: Mapping[str, bytes] + trusted_evaluator_public_keys: Mapping[str, bytes] + transport: CommandFeedTransport + timeout_seconds: float = 5.0 + + def bind_intent( + self, + authority: VerifiedCapitalAuthority, + intent: LiveExecutionIntent, + now_utc: datetime, + *, + expected_binding: VerifiedPromotionBinding | None = None, + ) -> tuple[LiveExecutionIntent, VerifiedPromotionBinding]: + if now_utc.tzinfo is None: + raise DumbMoneyPromotionError( + "contract resolution requires an aware UTC clock" + ) + if ( + len(authority.envelope.passport_hashes) != 1 + or len(authority.envelope.promotion_hashes) != 1 + ): + raise DumbMoneyPromotionError( + "contract resolver requires singleton capital lineage" + ) + try: + core_key = _decode_public_key(self.trusted_core_public_key) + except DumbMoneyAuthorityError as error: + raise DumbMoneyPromotionError( + "pinned Core public key is invalid" + ) from error + promoter_keys, research_keys = validated_role_keyrings( + self.trusted_promoter_public_keys, + self.trusted_research_public_keys, + core_public_key=core_key, + ) + evaluator_keys = _validated_evaluator_keyring( + self.trusted_evaluator_public_keys, + disjoint_key_ids=frozenset( + { + hashlib.sha256(core_key).hexdigest(), + *promoter_keys, + *research_keys, + } + ), + ) + promotion_resolution = self._resolve( + authority.envelope.promotion_hashes[0], + PROMOTION_CERTIFICATE_SCHEMA, + authority.body_digest, + authority.envelope.fencing_generation, + now_utc, + core_key, + promoter_keys, + research_keys, + evaluator_keys, + authority, + ) + if not isinstance( + promotion_resolution.verified, + VerifiedPromotionCertificate, + ): + raise DumbMoneyPromotionError( + "Core promotion resolution returned the wrong contract type" + ) + passport_digest = promotion_resolution.verified.certificate.passport_digest + if passport_digest != authority.envelope.passport_hashes[0]: + raise DumbMoneyPromotionError( + "resolved promotion points outside the singleton capital passport" + ) + passport_resolution = self._resolve( + passport_digest, + ALPHA_PASSPORT_SCHEMA, + authority.body_digest, + authority.envelope.fencing_generation, + now_utc, + core_key, + promoter_keys, + research_keys, + evaluator_keys, + authority, + ) + if not isinstance( + passport_resolution.verified, + VerifiedAlphaPassport, + ): + raise DumbMoneyPromotionError( + "Core passport resolution returned the wrong contract type" + ) + promotion_state = { + key: value + for key, value in promotion_resolution.authority_state.items() + if key != "evaluated_at" + } + passport_state = { + key: value + for key, value in passport_resolution.authority_state.items() + if key != "evaluated_at" + } + if promotion_state != passport_state: + raise DumbMoneyPromotionError( + "promotion and passport were not resolved from one authority state" + ) + return bind_verified_promotion( + authority, + intent, + passport_resolution.verified, + promotion_resolution.verified, + now_utc, + expected_binding=expected_binding, + ) + + def _resolve( + self, + body_digest: str, + expected_schema: str, + capital_envelope_digest: str, + fencing_generation: int, + now_utc: datetime, + core_public_key: bytes, + promoter_public_keys: Mapping[str, bytes], + research_public_keys: Mapping[str, bytes], + evaluator_public_keys: Mapping[str, bytes], + authority: VerifiedCapitalAuthority, + ) -> _ResolvedContract: + try: + origin = _validated_loopback_origin(self.base_url) + digest = _sha256_text(body_digest, "requested contract digest") + capital_digest = _sha256_text( + capital_envelope_digest, + "active capital envelope digest", + ) + if ( + isinstance(fencing_generation, bool) + or not isinstance(fencing_generation, int) + or fencing_generation <= 0 + ): + raise DumbMoneyPromotionError( + "active capital fencing generation must be positive" + ) + if expected_schema not in CONTRACT_SCHEMAS: + raise DumbMoneyPromotionError( + "requested contract schema is unsupported" + ) + if ( + isinstance(self.timeout_seconds, bool) + or not isinstance(self.timeout_seconds, (int, float)) + or not math.isfinite(float(self.timeout_seconds)) + or not 0.1 <= float(self.timeout_seconds) <= 30.0 + ): + raise DumbMoneyPromotionError( + "contract transport timeout must be between 0.1 and 30 seconds" + ) + token = _cell_bearer(self.cell_bearer_provider) + try: + request_nonce = _sha256_text( + secrets.token_hex(32), + "contract request_nonce", + ) + except Exception as error: + raise DumbMoneyPromotionError( + "cryptographic contract nonce generation failed" + ) from error + except (DumbMoneyAuthorityError, DumbMoneyCommandFeedError) as error: + raise DumbMoneyPromotionError( + f"contract resolver configuration is invalid: {error}" + ) from error + query = urlencode( + { + "request_nonce": request_nonce, + "capital_envelope_digest": capital_digest, + } + ) + request = CommandFeedHttpRequest( + method="GET", + url=(f"{origin}/v1/cells/{DOPEY_VENUE}/contracts/{digest}?{query}"), + headers={ + "Accept": "application/json", + "Authorization": f"Bearer {token}", + }, + timeout_seconds=float(self.timeout_seconds), + ) + try: + response = self.transport(request) + except Exception as error: + raise DumbMoneyPromotionError( + f"contract transport failed closed: {type(error).__name__}" + ) from error + if not isinstance(response, CommandFeedHttpResponse): + raise DumbMoneyPromotionError( + "contract transport returned an unsupported response" + ) + if isinstance(response.status, bool) or not isinstance(response.status, int): + raise DumbMoneyPromotionError("contract HTTP status is invalid") + if response.status in {401, 403}: + raise DumbMoneyPromotionError( + "contract resolution authentication or scope was denied" + ) + if response.status != 200: + raise DumbMoneyPromotionError( + f"contract resolver returned HTTP {response.status}" + ) + if not isinstance(response.body, bytes): + raise DumbMoneyPromotionError("contract response body is not bytes") + if len(response.body) > MAXIMUM_RESPONSE_BYTES: + raise DumbMoneyPromotionError("contract response exceeds size limit") + if not _response_content_type(response.headers).startswith("application/json"): + raise DumbMoneyPromotionError("contract response content type is invalid") + try: + payload = _loads_strict_json(response.body, "contract resolution") + return _validated_resolution( + payload, + digest, + expected_schema, + capital_digest, + fencing_generation, + request_nonce, + now_utc, + core_public_key, + promoter_public_keys, + research_public_keys, + evaluator_public_keys, + authority, + ) + except ( + DumbMoneyAuthorityError, + DumbMoneyCommandFeedError, + DumbMoneyPromotionError, + ) as error: + raise DumbMoneyPromotionError( + f"contract resolution was rejected: {error}" + ) from error + + +def _verify_resolution_checkpoint( + checkpoint: Mapping[str, Any], + signature_payload: Mapping[str, Any], + core_public_key: bytes, +) -> None: + _exact_fields( + checkpoint, + CONTRACT_RESOLUTION_CHECKPOINT_FIELDS, + "contract resolution checkpoint", + ) + _exact_fields( + signature_payload, + CHECKPOINT_SIGNATURE_FIELDS, + "contract resolution checkpoint signature", + ) + if checkpoint.get("schema") != CONTRACT_RESOLUTION_CHECKPOINT_SCHEMA: + raise DumbMoneyPromotionError( + "contract resolution checkpoint schema is unsupported" + ) + if signature_payload.get("algorithm") != "Ed25519": + raise DumbMoneyPromotionError( + "contract resolution checkpoint algorithm is unsupported" + ) + expected_key_id = hashlib.sha256(core_public_key).hexdigest() + if signature_payload.get("signer_key_id") != expected_key_id: + raise DumbMoneyPromotionError( + "contract resolution checkpoint signer is not pinned Core" + ) + try: + signature = _urlsafe_b64decode(str(signature_payload.get("signature", ""))) + if len(signature) != 64: + raise ValueError("Ed25519 signature must be 64 bytes") + Ed25519PublicKey.from_public_bytes(core_public_key).verify( + signature, + canonical_json_bytes(dict(checkpoint)), + ) + except (InvalidSignature, ValueError) as error: + raise DumbMoneyPromotionError( + "contract resolution checkpoint signature is invalid" + ) from error + + +def _positive_sequence(value: Any, label: str) -> int: + if isinstance(value, bool) or not isinstance(value, int) or value <= 0: + raise DumbMoneyPromotionError(f"{label} must be a positive integer") + return value + + +def _validated_authority_state( + raw_state: Any, + *, + authority: VerifiedCapitalAuthority, + now_utc: datetime, + evaluator_public_keys: Mapping[str, bytes], +) -> dict[str, Any]: + if not isinstance(raw_state, dict): + raise DumbMoneyPromotionError("contract authority_state must be an object") + _exact_fields(raw_state, AUTHORITY_STATE_FIELDS, "contract authority_state") + if raw_state.get("schema") != AUTHORITY_STATE_SCHEMA: + raise DumbMoneyPromotionError("contract authority_state schema is unsupported") + now = now_utc.astimezone(timezone.utc) + evaluated_at = _utc_timestamp( + raw_state.get("evaluated_at"), + "authority_state.evaluated_at", + ) + valid_until = _utc_timestamp( + raw_state.get("authority_valid_until"), + "authority_state.authority_valid_until", + ) + if ( + abs(now - evaluated_at) > MAXIMUM_RESOLUTION_CLOCK_SKEW + or not evaluated_at < valid_until + or not now < valid_until + or valid_until + > _utc_timestamp( + authority.envelope.expires_at, + "active capital envelope expires_at", + ) + ): + raise DumbMoneyPromotionError( + "contract authority_state is stale, future-dated, or expired" + ) + expected_values = { + "policy_epoch": authority.envelope.policy_epoch, + "mandate_id": authority.envelope.mandate_id, + "kill_clear": True, + "desired_mode": "LIVE", + "capital_envelope_digest": authority.body_digest, + "fencing_generation": authority.envelope.fencing_generation, + "strategy_hash": authority.envelope.strategy_hashes[0], + "passport_digest": authority.envelope.passport_hashes[0], + "promotion_digest": authority.envelope.promotion_hashes[0], + } + for field, expected in expected_values.items(): + if raw_state.get(field) != expected: + raise DumbMoneyPromotionError( + f"contract authority_state {field} does not match active authority" + ) + for field in ( + "kill_generation", + "desired_mode_revision", + "ledger_head_sequence", + ): + _positive_sequence(raw_state.get(field), f"authority_state.{field}") + for field in ( + "mandate_event_digest", + "kill_event_digest", + "desired_mode_event_digest", + "capital_event_digest", + "passport_event_digest", + "promotion_event_digest", + "ledger_head_digest", + ): + _sha256_text(raw_state.get(field), f"authority_state.{field}") + verdicts = raw_state.get("verdicts") + if not isinstance(verdicts, list) or len(verdicts) < 2: + raise DumbMoneyPromotionError( + "contract authority_state requires at least two PASS verdicts" + ) + verdict_facts: set[tuple[str, str, str, str]] = set() + courts: set[str] = set() + for index, verdict in enumerate(verdicts): + if not isinstance(verdict, dict): + raise DumbMoneyPromotionError( + f"authority_state.verdicts[{index}] must be an object" + ) + _exact_fields( + verdict, + AUTHORITY_VERDICT_FIELDS, + f"authority_state.verdicts[{index}]", + ) + digest = _sha256_text( + verdict.get("verdict_digest"), + f"authority_state.verdicts[{index}].verdict_digest", + ) + event_digest = _sha256_text( + verdict.get("verdict_event_digest"), + f"authority_state.verdicts[{index}].verdict_event_digest", + ) + verdict_id = str(verdict.get("verdict_id", "")) + if not verdict_id or len(verdict_id) > 128: + raise DumbMoneyPromotionError("authority verdict ID is invalid") + court = str(verdict.get("court", "")) + if court not in VERDICT_COURTS or verdict.get("decision") != "PASS": + raise DumbMoneyPromotionError( + "contract authority verdict is not a recognized PASS" + ) + signer_key_id = _sha256_text( + verdict.get("signer_key_id"), + f"authority_state.verdicts[{index}].signer_key_id", + ) + if signer_key_id not in evaluator_public_keys: + raise DumbMoneyPromotionError( + "contract authority verdict signer is not sealed" + ) + verdict_evaluated_at = _utc_timestamp( + verdict.get("evaluated_at"), + f"authority_state.verdicts[{index}].evaluated_at", + ) + verdict_expires_at = _utc_timestamp( + verdict.get("expires_at"), + f"authority_state.verdicts[{index}].expires_at", + ) + transport_expires_at = _utc_timestamp( + verdict.get("transport_expires_at"), + f"authority_state.verdicts[{index}].transport_expires_at", + ) + if ( + verdict_evaluated_at > now + MAXIMUM_RESOLUTION_CLOCK_SKEW + or not verdict_evaluated_at < verdict_expires_at + or not now < verdict_expires_at + or not now < transport_expires_at + or valid_until > min(verdict_expires_at, transport_expires_at) + ): + raise DumbMoneyPromotionError( + "contract authority verdict is stale, future-dated, or expired" + ) + verdict_facts.add((digest, event_digest, verdict_id, court)) + courts.add(court) + if len(verdict_facts) != len(verdicts) or len(courts) < 2: + raise DumbMoneyPromotionError( + "contract authority verdict facts or courts are not unique" + ) + return dict(raw_state) + + +def _validated_resolution( + payload: dict[str, Any], + expected_digest: str, + expected_schema: str, + expected_capital_digest: str, + expected_fencing_generation: int, + expected_nonce: str, + now_utc: datetime, + core_public_key: bytes, + promoter_public_keys: Mapping[str, bytes], + research_public_keys: Mapping[str, bytes], + evaluator_public_keys: Mapping[str, bytes], + authority: VerifiedCapitalAuthority, +) -> _ResolvedContract: + _exact_fields(payload, CONTRACT_RESOLUTION_FIELDS, "contract resolution") + if payload.get("schema") != CONTRACT_RESOLUTION_SCHEMA: + raise DumbMoneyPromotionError("contract resolution schema is unsupported") + checkpoint = payload.get("checkpoint") + checkpoint_signature = payload.get("checkpoint_signature") + if not isinstance(checkpoint, dict) or not isinstance( + checkpoint_signature, + dict, + ): + raise DumbMoneyPromotionError( + "contract resolution checkpoint and signature must be objects" + ) + _verify_resolution_checkpoint( + checkpoint, + checkpoint_signature, + core_public_key, + ) + expected_checkpoint = { + key: value + for key, value in payload.items() + if key not in {"schema", "checkpoint", "checkpoint_signature"} + } + expected_checkpoint["schema"] = CONTRACT_RESOLUTION_CHECKPOINT_SCHEMA + if checkpoint != expected_checkpoint: + raise DumbMoneyPromotionError( + "signed checkpoint does not bind the full contract resolution" + ) + if payload.get("cell_id") != DOPEY_VENUE: + raise DumbMoneyPromotionError("contract resolution targets another venue cell") + request_nonce = _sha256_text( + payload.get("request_nonce"), + "contract response request_nonce", + ) + if request_nonce != expected_nonce: + raise DumbMoneyPromotionError( + "contract response nonce does not match this request" + ) + requested_digest = _sha256_text( + payload.get("requested_body_digest"), + "contract response requested_body_digest", + ) + if requested_digest != expected_digest: + raise DumbMoneyPromotionError( + "contract response digest does not match this request" + ) + capital_digest = _sha256_text( + payload.get("capital_envelope_digest"), + "contract response capital_envelope_digest", + ) + if capital_digest != expected_capital_digest: + raise DumbMoneyPromotionError( + "contract response is not bound to the active capital envelope" + ) + fencing_generation = payload.get("fencing_generation") + if ( + isinstance(fencing_generation, bool) + or not isinstance(fencing_generation, int) + or fencing_generation != expected_fencing_generation + ): + raise DumbMoneyPromotionError( + "contract response is not bound to the active capital fence" + ) + contract_schema = str(payload.get("contract_schema", "")) + if contract_schema != expected_schema: + raise DumbMoneyPromotionError( + "contract response schema does not match this request" + ) + for field in ( + "transport_window_current", + "body_window_current", + "eligible_live_input", + ): + if not isinstance(payload.get(field), bool): + raise DumbMoneyPromotionError( + f"contract response {field} must be a boolean" + ) + observed_at = _utc_timestamp( + payload.get("observed_at"), + "contract response observed_at", + ) + now = now_utc.astimezone(timezone.utc) + if abs(now - observed_at) > MAXIMUM_RESOLUTION_CLOCK_SKEW: + raise DumbMoneyPromotionError( + "contract response observed_at is stale or future-dated" + ) + envelope = payload.get("envelope") + if not isinstance(envelope, dict): + raise DumbMoneyPromotionError("contract response envelope must be an object") + if envelope.get("body_digest") != expected_digest: + raise DumbMoneyPromotionError( + "contract envelope body digest does not match the requested digest" + ) + if envelope.get("body_schema") != expected_schema: + raise DumbMoneyPromotionError( + "contract envelope schema does not match the requested schema" + ) + authority_state = _validated_authority_state( + payload.get("authority_state"), + authority=authority, + now_utc=now_utc, + evaluator_public_keys=evaluator_public_keys, + ) + if ( + _utc_timestamp( + authority_state["evaluated_at"], + "authority_state.evaluated_at", + ) + != observed_at + ): + raise DumbMoneyPromotionError( + "contract authority_state evaluated_at does not mirror observed_at" + ) + raw_proof = payload.get("ledger_proof") + if not isinstance(raw_proof, dict): + raise DumbMoneyPromotionError("contract ledger proof must be an object") + proof_command = { + "global_sequence": raw_proof.get("global_sequence"), + "event_id": raw_proof.get("event_id"), + "event_digest": raw_proof.get("event_digest"), + "body_schema": expected_schema, + } + proof = _validated_ledger_proof( + raw_proof, + proof_command, + envelope, + ) + if ( + proof["payload_digest"] != expected_digest + or proof["event_schema"] != expected_schema + ): + raise DumbMoneyPromotionError( + "contract ledger proof does not bind the requested contract" + ) + expected_authority_event_digest = authority_state[ + ( + "promotion_event_digest" + if expected_schema == PROMOTION_CERTIFICATE_SCHEMA + else "passport_event_digest" + ) + ] + if proof["event_digest"] != expected_authority_event_digest: + raise DumbMoneyPromotionError( + "contract proof event digest does not match current authority_state" + ) + ledger_head_sequence = int(authority_state["ledger_head_sequence"]) + proof_sequence = int(proof["global_sequence"]) + if proof_sequence > ledger_head_sequence or ( + proof_sequence == ledger_head_sequence + and proof["event_digest"] != authority_state["ledger_head_digest"] + ): + raise DumbMoneyPromotionError( + "contract proof is not continuous with the authority ledger head" + ) + if expected_schema == PROMOTION_CERTIFICATE_SCHEMA: + promotion_verified = verify_promotion_certificate_envelope( + envelope, + promoter_public_keys, + now_utc, + ) + verified: VerifiedAlphaPassport | VerifiedPromotionCertificate = ( + promotion_verified + ) + body_not_before = _utc_timestamp( + promotion_verified.certificate.not_before, + "promotion.not_before", + ) + body_expires = _utc_timestamp( + promotion_verified.certificate.expires_at, + "promotion.expires_at", + ) + eligible = ( + body_not_before <= observed_at < body_expires + and promotion_verified.certificate.stage in LIVE_PROMOTION_STAGES + ) + else: + passport_verified = verify_alpha_passport_envelope( + envelope, + research_public_keys, + now_utc, + ) + verified = passport_verified + body_not_before = _utc_timestamp( + passport_verified.passport.created_at, + "passport.created_at", + ) + body_expires = _utc_timestamp( + passport_verified.passport.expires_at, + "passport.expires_at", + ) + eligible = body_not_before <= observed_at < body_expires + wrapper_not_before = _utc_timestamp( + envelope.get("not_before"), + "contract envelope not_before", + ) + wrapper_expires = _utc_timestamp( + envelope.get("expires_at"), + "contract envelope expires_at", + ) + transport_current = wrapper_not_before <= observed_at < wrapper_expires + body_current = body_not_before <= observed_at < body_expires + eligible = transport_current and eligible + if payload["transport_window_current"] is not transport_current: + raise DumbMoneyPromotionError("contract transport-window projection mismatch") + if payload["body_window_current"] is not body_current: + raise DumbMoneyPromotionError("contract body-window projection mismatch") + if payload["eligible_live_input"] is not eligible: + raise DumbMoneyPromotionError("contract live-eligibility projection mismatch") + if not eligible: + raise DumbMoneyPromotionError( + "resolved contract is not an eligible current live input" + ) + return _ResolvedContract( + contract_schema=contract_schema, + body_digest=expected_digest, + envelope=dict(envelope), + verified=verified, + authority_state=authority_state, + ) diff --git a/dopey_live/dumbmoney_journal_anchor.py b/dopey_live/dumbmoney_journal_anchor.py new file mode 100644 index 0000000..b15fc86 --- /dev/null +++ b/dopey_live/dumbmoney_journal_anchor.py @@ -0,0 +1,595 @@ +"""Core-backed rollback detection for Dopey's venue-local state stores.""" + +from __future__ import annotations + +import hashlib +import secrets +from collections.abc import Callable +from dataclasses import dataclass +from datetime import UTC, datetime, timedelta +from typing import Any, Final +from urllib.parse import urlsplit + +from cryptography.exceptions import InvalidSignature +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey + +from .dumbmoney_authority import ( + DOPEY_VENUE, + IDENTIFIER_PATTERN, + DumbMoneyAuthorityError, + _decode_public_key, + _exact_fields, + _loads_strict_signed_json, + _sha256_text, + _urlsafe_b64decode, + _utc_timestamp, + _verify_signed_wrapper, + canonical_json_bytes, +) +from .dumbmoney_command_feed import ( + CommandFeedHttpRequest, + CommandFeedHttpResponse, + CommandFeedTransport, +) + +ANCHOR_REQUEST_SCHEMA: Final[str] = "dumbmoney.cell-journal-anchor-request.v1" +ANCHOR_RESPONSE_SCHEMA: Final[str] = "dumbmoney.cell-journal-anchor-response.v1" +ANCHOR_CHECKPOINT_SCHEMA: Final[str] = "dumbmoney.cell-journal-anchor-checkpoint.v1" +ANCHOR_BODY_SCHEMA: Final[str] = "dumbmoney.cell-journal-head-anchor.v1" +LEDGER_PROOF_SCHEMA: Final[str] = "dumbmoney.ledger-event-proof.v1" +CORE_SOURCE_ID: Final[str] = "dumbmoney-core" +ZERO_DIGEST: Final[str] = "0" * 64 +MAXIMUM_RESPONSE_BYTES: Final[int] = 524_288 +MAXIMUM_RESPONSE_AGE: Final[timedelta] = timedelta(seconds=30) +MAXIMUM_CLOCK_SKEW: Final[timedelta] = timedelta(seconds=5) +MAXIMUM_ANCHOR_TTL: Final[timedelta] = timedelta(seconds=120) +_RESPONSE_FIELDS: Final[frozenset[str]] = frozenset( + { + "schema", + "cell_id", + "request_nonce", + "observed_at", + "reused", + "anchor_envelope", + "ledger_proof", + "checkpoint", + "checkpoint_signature", + } +) +_CHECKPOINT_FIELDS: Final[frozenset[str]] = frozenset( + { + "schema", + "cell_id", + "request_nonce", + "account_hash", + "journal_name", + "journal_schema", + "journal_stream_id", + "journal_sequence", + "journal_head_sha256", + "anchor_body_digest", + "anchor_event_digest", + "reused", + "observed_at", + } +) +_SIGNATURE_FIELDS: Final[frozenset[str]] = frozenset( + {"algorithm", "signer_key_id", "signature"} +) +_ANCHOR_BODY_FIELDS: Final[frozenset[str]] = frozenset( + { + "schema", + "anchor_id", + "venue", + "account_hash", + "journal_name", + "journal_schema", + "journal_stream_id", + "journal_sequence", + "journal_head_sha256", + "previous_anchor_body_digest", + "anchored_at", + } +) +_LEDGER_PROOF_FIELDS: Final[frozenset[str]] = frozenset( + { + "schema", + "global_sequence", + "event_id", + "source_id", + "source_sequence", + "signer_key_id", + "nonce", + "event_schema", + "observed_at", + "received_at", + "correlation_id", + "causation_id", + "payload_digest", + "previous_source_digest", + "previous_global_digest", + "event_digest", + } +) + + +class JournalAnchorError(RuntimeError): + """A Core anchor, signature, or monotonicity check failed closed.""" + + +@dataclass(frozen=True) +class VerifiedJournalAnchor: + journal_name: str + journal_sequence: int + journal_head_sha256: str + anchor_body_digest: str + anchor_event_digest: str + reused: bool + observed_at: str + + +def _hash(payload: dict[str, Any]) -> str: + return hashlib.sha256(canonical_json_bytes(payload)).hexdigest() + + +def _nonnegative_int(value: Any, label: str) -> int: + if isinstance(value, bool) or not isinstance(value, int) or value < 0: + raise JournalAnchorError(f"{label} must be a nonnegative integer") + return value + + +def _positive_int(value: Any, label: str) -> int: + result = _nonnegative_int(value, label) + if result == 0: + raise JournalAnchorError(f"{label} must be positive") + return result + + +def _digest(value: Any, label: str) -> str: + try: + return _sha256_text(value, label) + except DumbMoneyAuthorityError as error: + raise JournalAnchorError(str(error)) from error + + +def _timestamp(value: Any, label: str) -> datetime: + try: + return _utc_timestamp(value, label) + except DumbMoneyAuthorityError as error: + raise JournalAnchorError(str(error)) from error + + +def _strict_response(body: bytes) -> dict[str, Any]: + if not isinstance(body, bytes) or not 1 <= len(body) <= MAXIMUM_RESPONSE_BYTES: + raise JournalAnchorError("Core anchor response size is invalid") + try: + return _loads_strict_signed_json( + body, + "Core anchor response", + require_canonical_bytes=False, + ) + except DumbMoneyAuthorityError as error: + raise JournalAnchorError(str(error)) from error + + +class CoreJournalAnchorClient: + """Publish exact local heads and verify Core's durable signed receipt.""" + + def __init__( + self, + *, + base_url: str, + transport: CommandFeedTransport, + cell_token_provider: Callable[[], str], + trusted_core_public_key: bytes, + expected_account_hash: str, + request_nonce_fn: Callable[[], str] = lambda: secrets.token_hex(32), + now_fn: Callable[[], datetime] = lambda: datetime.now(UTC), + ) -> None: + parsed = urlsplit(base_url) + try: + port = parsed.port + except ValueError as error: + raise JournalAnchorError("Core anchor base URL port is invalid") from error + if ( + parsed.scheme != "http" + or parsed.hostname not in {"127.0.0.1", "::1"} + or port is None + or not 1024 <= port <= 65_535 + or parsed.username is not None + or parsed.password is not None + or parsed.path not in {"", "/"} + or parsed.query + or parsed.fragment + ): + raise JournalAnchorError( + "Core anchor base URL must be literal loopback HTTP" + ) + try: + self._core_public_key = _decode_public_key(trusted_core_public_key) + except DumbMoneyAuthorityError as error: + raise JournalAnchorError("pinned Core public key is invalid") from error + self._core_key_id = hashlib.sha256(self._core_public_key).hexdigest() + self._base_url = base_url.rstrip("/") + self._transport = transport + self._cell_token_provider = cell_token_provider + self._account_hash = _digest( + expected_account_hash, + "expected_account_hash", + ) + self._request_nonce_fn = request_nonce_fn + self._now_fn = now_fn + self._last_nonce: str | None = None + + def stream_id(self, journal_name: str) -> str: + if not isinstance(journal_name, str) or not IDENTIFIER_PATTERN.fullmatch( + journal_name + ): + raise JournalAnchorError("journal_name must be a canonical identifier") + return _hash( + { + "schema": "dopey.journal-anchor-stream.v1", + "cell_id": DOPEY_VENUE, + "account_hash": self._account_hash, + "journal_name": journal_name, + } + ) + + def _verify_checkpoint( + self, + checkpoint: Any, + signature: Any, + ) -> dict[str, Any]: + if not isinstance(checkpoint, dict): + raise JournalAnchorError("Core anchor checkpoint must be an object") + try: + _exact_fields( + checkpoint, + _CHECKPOINT_FIELDS, + "Core anchor checkpoint", + ) + except DumbMoneyAuthorityError as error: + raise JournalAnchorError(str(error)) from error + if not isinstance(signature, dict): + raise JournalAnchorError( + "Core anchor checkpoint signature must be an object" + ) + try: + _exact_fields( + signature, + _SIGNATURE_FIELDS, + "Core anchor checkpoint signature", + ) + except DumbMoneyAuthorityError as error: + raise JournalAnchorError(str(error)) from error + if ( + signature.get("algorithm") != "Ed25519" + or signature.get("signer_key_id") != self._core_key_id + ): + raise JournalAnchorError( + "Core anchor checkpoint signature identity mismatch" + ) + try: + raw_signature = _urlsafe_b64decode(str(signature.get("signature", ""))) + if len(raw_signature) != 64: + raise ValueError("Ed25519 signature must be 64 bytes") + Ed25519PublicKey.from_public_bytes(self._core_public_key).verify( + raw_signature, + canonical_json_bytes(checkpoint), + ) + except (InvalidSignature, ValueError) as error: + raise JournalAnchorError( + "Core anchor checkpoint signature is invalid" + ) from error + return dict(checkpoint) + + @staticmethod + def _verify_ledger_proof( + proof: Any, + *, + signed: Any, + observed_at: datetime, + ) -> dict[str, Any]: + if not isinstance(proof, dict): + raise JournalAnchorError("anchor ledger proof must be an object") + try: + _exact_fields(proof, _LEDGER_PROOF_FIELDS, "anchor ledger proof") + except DumbMoneyAuthorityError as error: + raise JournalAnchorError(str(error)) from error + if proof.get("schema") != LEDGER_PROOF_SCHEMA: + raise JournalAnchorError("anchor ledger proof schema is unsupported") + global_sequence = _positive_int( + proof.get("global_sequence"), + "ledger proof global_sequence", + ) + source_sequence = _positive_int( + proof.get("source_sequence"), + "ledger proof source_sequence", + ) + received_at = _timestamp( + proof.get("received_at"), + "ledger proof received_at", + ) + record_observed = _timestamp( + proof.get("observed_at"), + "ledger proof observed_at", + ) + if received_at > observed_at or record_observed != max( + received_at, + signed.not_before_utc, + ): + raise JournalAnchorError("anchor ledger proof observation semantics differ") + previous_global = _digest( + proof.get("previous_global_digest"), + "ledger proof previous_global_digest", + ) + previous_source = _digest( + proof.get("previous_source_digest"), + "ledger proof previous_source_digest", + ) + if (global_sequence == 1) != (previous_global == ZERO_DIGEST): + raise JournalAnchorError( + "anchor ledger proof global predecessor is invalid" + ) + if (source_sequence == 1) != (previous_source == ZERO_DIGEST): + raise JournalAnchorError( + "anchor ledger proof source predecessor is invalid" + ) + expected = { + "event_id": signed.event_id, + "source_id": signed.source_id, + "source_sequence": signed.source_sequence, + "signer_key_id": signed.signer_key_id, + "event_schema": signed.body_schema, + "payload_digest": signed.body_digest, + } + for field, expected_value in expected.items(): + if proof.get(field) != expected_value: + raise JournalAnchorError( + f"anchor ledger proof {field} differs from envelope" + ) + event_digest = _digest( + proof.get("event_digest"), + "ledger proof event_digest", + ) + material = { + key: value + for key, value in proof.items() + if key not in {"schema", "event_digest"} + } + if _hash(material) != event_digest: + raise JournalAnchorError("anchor ledger proof digest is invalid") + return dict(proof) + + def anchor( + self, + *, + journal_name: str, + journal_schema: str, + journal_sequence: int, + journal_head_sha256: str, + ) -> VerifiedJournalAnchor: + if ( + not isinstance(journal_name, str) + or not IDENTIFIER_PATTERN.fullmatch(journal_name) + or not isinstance(journal_schema, str) + or not IDENTIFIER_PATTERN.fullmatch(journal_schema) + ): + raise JournalAnchorError( + "journal name and schema must be canonical identifiers" + ) + sequence = _nonnegative_int(journal_sequence, "journal_sequence") + head = _digest(journal_head_sha256, "journal_head_sha256") + if (sequence == 0) != (head == ZERO_DIGEST): + raise JournalAnchorError( + "journal sequence zero must exactly bind the zero head" + ) + stream_id = self.stream_id(journal_name) + nonce = _digest(self._request_nonce_fn(), "request_nonce") + if nonce == self._last_nonce: + raise JournalAnchorError("journal anchor nonce generator repeated a nonce") + self._last_nonce = nonce + request = { + "schema": ANCHOR_REQUEST_SCHEMA, + "cell_id": DOPEY_VENUE, + "account_hash": self._account_hash, + "journal_name": journal_name, + "journal_schema": journal_schema, + "journal_stream_id": stream_id, + "journal_sequence": sequence, + "journal_head_sha256": head, + "request_nonce": nonce, + } + token = self._cell_token_provider() + if ( + not isinstance(token, str) + or not token + or token != token.strip() + or any(character.isspace() for character in token) + ): + raise JournalAnchorError("cell token is not a canonical secret") + try: + response = self._transport( + CommandFeedHttpRequest( + method="POST", + url=( + f"{self._base_url}/v1/cells/{DOPEY_VENUE}/journal-heads:anchor" + ), + headers={ + "Authorization": f"Bearer {token}", + "Content-Type": "application/json", + "Accept": "application/json", + }, + timeout_seconds=5.0, + body=canonical_json_bytes(request), + ) + ) + except Exception as error: + raise JournalAnchorError( + f"journal anchor transport failed:{type(error).__name__}" + ) from error + finally: + token = "" + if not isinstance(response, CommandFeedHttpResponse) or response.status != 200: + raise JournalAnchorError("Core rejected the journal head anchor") + content_type = next( + ( + str(value).split(";", 1)[0].strip().lower() + for name, value in response.headers.items() + if str(name).lower() == "content-type" + ), + "", + ) + if content_type != "application/json": + raise JournalAnchorError("Core anchor response content type is invalid") + payload = _strict_response(response.body) + try: + _exact_fields(payload, _RESPONSE_FIELDS, "Core anchor response") + except DumbMoneyAuthorityError as error: + raise JournalAnchorError(str(error)) from error + if ( + payload.get("schema") != ANCHOR_RESPONSE_SCHEMA + or payload.get("cell_id") != DOPEY_VENUE + or payload.get("request_nonce") != nonce + or not isinstance(payload.get("reused"), bool) + ): + raise JournalAnchorError("Core anchor response fields or identity mismatch") + checkpoint = self._verify_checkpoint( + payload.get("checkpoint"), + payload.get("checkpoint_signature"), + ) + observed_at = _timestamp( + payload.get("observed_at"), + "anchor response observed_at", + ) + now = self._now_fn() + if now.tzinfo is None or now.utcoffset() is None: + raise JournalAnchorError("journal anchor clock must be timezone-aware") + now = now.astimezone(UTC) + if ( + observed_at > now + MAXIMUM_CLOCK_SKEW + or now - observed_at > MAXIMUM_RESPONSE_AGE + ): + raise JournalAnchorError("Core anchor response is not current") + expected_checkpoint = { + "schema": ANCHOR_CHECKPOINT_SCHEMA, + "cell_id": DOPEY_VENUE, + "request_nonce": nonce, + "account_hash": self._account_hash, + "journal_name": journal_name, + "journal_schema": journal_schema, + "journal_stream_id": stream_id, + "journal_sequence": sequence, + "journal_head_sha256": head, + "anchor_body_digest": checkpoint.get("anchor_body_digest"), + "anchor_event_digest": checkpoint.get("anchor_event_digest"), + "reused": payload["reused"], + "observed_at": payload["observed_at"], + } + if checkpoint != expected_checkpoint: + raise JournalAnchorError("Core anchor checkpoint differs from the request") + envelope = payload.get("anchor_envelope") + if not isinstance(envelope, dict): + raise JournalAnchorError("Core journal anchor envelope must be an object") + try: + signed = _verify_signed_wrapper( + envelope, + self._core_public_key, + observed_at, + enforce_current_window=False, + ) + except DumbMoneyAuthorityError as error: + raise JournalAnchorError( + "Core journal anchor envelope is invalid" + ) from error + if ( + signed.source_id != CORE_SOURCE_ID + or signed.body_schema != ANCHOR_BODY_SCHEMA + or signed.expires_at_utc - signed.not_before_utc > MAXIMUM_ANCHOR_TTL + ): + raise JournalAnchorError( + "Core journal anchor envelope authority is invalid" + ) + body = signed.body + try: + _exact_fields(body, _ANCHOR_BODY_FIELDS, "journal anchor body") + except DumbMoneyAuthorityError as error: + raise JournalAnchorError(str(error)) from error + anchored_at = _timestamp( + body.get("anchored_at"), + "anchor body anchored_at", + ) + if ( + body.get("venue") != DOPEY_VENUE + or body.get("account_hash") != self._account_hash + or body.get("journal_name") != journal_name + or body.get("journal_schema") != journal_schema + or body.get("journal_stream_id") != stream_id + or body.get("journal_sequence") != sequence + or body.get("journal_head_sha256") != head + or anchored_at > observed_at + ): + raise JournalAnchorError("journal anchor body differs from the local head") + previous = _digest( + body.get("previous_anchor_body_digest"), + "previous_anchor_body_digest", + ) + identity = { + "schema": ANCHOR_BODY_SCHEMA, + "venue": DOPEY_VENUE, + "account_hash": self._account_hash, + "journal_name": journal_name, + "journal_schema": journal_schema, + "journal_stream_id": stream_id, + "journal_sequence": sequence, + "journal_head_sha256": head, + "previous_anchor_body_digest": previous, + } + if body.get("anchor_id") != _hash(identity): + raise JournalAnchorError("journal anchor identity is invalid") + proof = self._verify_ledger_proof( + payload.get("ledger_proof"), + signed=signed, + observed_at=observed_at, + ) + for field in ("nonce", "correlation_id", "causation_id"): + if proof.get(field) != envelope.get(field): + raise JournalAnchorError( + f"anchor ledger proof {field} differs from envelope" + ) + body_digest = _digest( + checkpoint.get("anchor_body_digest"), + "checkpoint anchor_body_digest", + ) + event_digest = _digest( + checkpoint.get("anchor_event_digest"), + "checkpoint anchor_event_digest", + ) + if ( + body_digest != signed.body_digest + or body_digest != proof.get("payload_digest") + or event_digest != proof.get("event_digest") + ): + raise JournalAnchorError( + "journal anchor checkpoint proof binding is invalid" + ) + return VerifiedJournalAnchor( + journal_name=journal_name, + journal_sequence=sequence, + journal_head_sha256=head, + anchor_body_digest=body_digest, + anchor_event_digest=event_digest, + reused=bool(payload["reused"]), + observed_at=str(payload["observed_at"]), + ) + + +__all__ = [ + "ANCHOR_BODY_SCHEMA", + "ANCHOR_CHECKPOINT_SCHEMA", + "ANCHOR_REQUEST_SCHEMA", + "ANCHOR_RESPONSE_SCHEMA", + "CoreJournalAnchorClient", + "JournalAnchorError", + "VerifiedJournalAnchor", +] diff --git a/dopey_live/dumbmoney_promotion.py b/dopey_live/dumbmoney_promotion.py new file mode 100644 index 0000000..4d61a0c --- /dev/null +++ b/dopey_live/dumbmoney_promotion.py @@ -0,0 +1,585 @@ +"""Authenticated DumbMoney promotion lineage for Robinhood entry intents. + +An entry strategy hash is not authority. The only accepted lineage is an +AlphaPassportV1 signed by a sealed research key and a PromotionCertificateV1 +signed by a disjoint sealed promoter key. Their canonical body digests must +also be the one singleton tuple named by the active CapitalEnvelopeV1. +""" + +from __future__ import annotations + +import hashlib +from dataclasses import dataclass, replace +from datetime import datetime, timezone +from typing import Any, Final, Mapping, Protocol + +from .dumbmoney_authority import ( + DOPEY_VENUE, + EQUITY_INSTRUMENT_ID_PATTERN, + IDENTIFIER_PATTERN, + OPTION_INSTRUMENT_ID_PATTERN, + DumbMoneyAuthorityError, + VerifiedCapitalAuthority, + _decode_public_key, + _exact_fields, + _hash_array, + _identifier_array, + _positive_int, + _sha256_text, + _utc_timestamp, + _verify_signed_wrapper, + intent_authorized_instrument_id, + intent_incremental_risk_cents, +) +from .models import LiveExecutionIntent + + +ALPHA_PASSPORT_SCHEMA: Final[str] = "dumbmoney.alpha_passport.v1" +PROMOTION_CERTIFICATE_SCHEMA: Final[str] = "dumbmoney.promotion-certificate.v1" +LIVE_PROMOTION_STAGES: Final[frozenset[str]] = frozenset( + {"EXPLORATORY_LIVE", "AGGRESSIVE_BOUNDED"} +) +EVIDENCE_CLASSES: Final[frozenset[str]] = frozenset( + {"SYNTHETIC", "REPLAY", "BACKTEST", "PAPER", "FORWARD", "REALIZED"} +) +PROMOTION_STAGES: Final[frozenset[str]] = frozenset( + { + "IDEA", + "REPLAY", + "POINT_IN_TIME_BACKTEST", + "FORWARD_SHADOW", + "PAPER", + *LIVE_PROMOTION_STAGES, + } +) +ALPHA_PASSPORT_FIELDS: Final[frozenset[str]] = frozenset( + { + "schema", + "passport_id", + "strategy_lineage_id", + "venue", + "strategy_hash", + "artifact_hashes", + "evidence_verdict_hashes", + "intended_instruments", + "maximum_loss_cents", + "evidence_class", + "created_at", + "expires_at", + } +) +PROMOTION_CERTIFICATE_FIELDS: Final[frozenset[str]] = frozenset( + { + "schema", + "certificate_id", + "passport_digest", + "verdict_digests", + "stage", + "venue", + "instruments", + "maximum_loss_cents", + "rollback_triggers", + "policy_epoch", + "not_before", + "expires_at", + } +) + + +class DumbMoneyPromotionError(DumbMoneyAuthorityError): + """Raised when signed research-to-live lineage cannot be proven.""" + + +@dataclass(frozen=True) +class AlphaPassportV1: + passport_id: str + strategy_lineage_id: str + venue: str + strategy_hash: str + artifact_hashes: tuple[str, ...] + evidence_verdict_hashes: tuple[str, ...] + intended_instruments: tuple[str, ...] + maximum_loss_cents: int + evidence_class: str + created_at: str + expires_at: str + + +@dataclass(frozen=True) +class PromotionCertificateV1: + certificate_id: str + passport_digest: str + verdict_digests: tuple[str, ...] + stage: str + venue: str + instruments: tuple[str, ...] + maximum_loss_cents: int + rollback_triggers: tuple[str, ...] + policy_epoch: int + not_before: str + expires_at: str + + +@dataclass(frozen=True) +class VerifiedAlphaPassport: + passport: AlphaPassportV1 + event_id: str + body_digest: str + signer_key_id: str + wrapper_expires_at: str + + +@dataclass(frozen=True) +class VerifiedPromotionCertificate: + certificate: PromotionCertificateV1 + event_id: str + body_digest: str + signer_key_id: str + wrapper_expires_at: str + + +@dataclass(frozen=True) +class VerifiedPromotionBinding: + """Exact signed lineage pinned to one active capital fence.""" + + strategy_hash: str + passport_hash: str + promotion_hash: str + instrument_id: str + maximum_loss_cents: int + policy_epoch: int + capital_envelope_id: str + capital_event_id: str + capital_body_digest: str + capital_fencing_generation: int + passport_event_id: str + promotion_event_id: str + passport_signer_key_id: str + promotion_signer_key_id: str + expires_at: str + + def evidence(self) -> dict[str, Any]: + return { + "strategy_hash": self.strategy_hash, + "passport_hash": self.passport_hash, + "promotion_hash": self.promotion_hash, + "instrument_id": self.instrument_id, + "maximum_loss_cents": self.maximum_loss_cents, + "policy_epoch": self.policy_epoch, + "capital_envelope_id": self.capital_envelope_id, + "capital_event_id": self.capital_event_id, + "capital_body_digest": self.capital_body_digest, + "capital_fencing_generation": self.capital_fencing_generation, + "passport_event_id": self.passport_event_id, + "promotion_event_id": self.promotion_event_id, + "passport_signer_key_id": self.passport_signer_key_id, + "promotion_signer_key_id": self.promotion_signer_key_id, + "expires_at": self.expires_at, + } + + +class PromotionAuthorityResolver(Protocol): + """Resolve and authenticate the lineage immediately before use.""" + + def bind_intent( + self, + authority: VerifiedCapitalAuthority, + intent: LiveExecutionIntent, + now_utc: datetime, + *, + expected_binding: VerifiedPromotionBinding | None = None, + ) -> tuple[LiveExecutionIntent, VerifiedPromotionBinding]: ... + + +def _identifier(value: Any, label: str) -> str: + text = str(value) + if not IDENTIFIER_PATTERN.fullmatch(text): + raise DumbMoneyPromotionError(f"{label} is not a canonical identifier") + return text + + +def _instrument_array(payload: dict[str, Any], field: str) -> tuple[str, ...]: + values = _identifier_array(payload, field) + unsupported = tuple( + value + for value in values + if not ( + EQUITY_INSTRUMENT_ID_PATTERN.fullmatch(value) + or OPTION_INSTRUMENT_ID_PATTERN.fullmatch(value) + ) + ) + if unsupported: + raise DumbMoneyPromotionError( + f"{field} contains noncanonical Dopey instrument IDs: {unsupported}" + ) + return values + + +def _possibly_empty_hash_array( + payload: dict[str, Any], + field: str, +) -> tuple[str, ...]: + values = payload.get(field) + if not isinstance(values, list): + raise DumbMoneyPromotionError(f"{field} must be an array") + normalized = tuple(_sha256_text(value, field) for value in values) + if normalized != tuple(sorted(set(normalized))): + raise DumbMoneyPromotionError(f"{field} must be sorted and duplicate-free") + return normalized + + +def _sealed_role_keyring( + keys: Mapping[str, bytes], + role: str, +) -> dict[str, bytes]: + if not isinstance(keys, Mapping) or not 1 <= len(keys) <= 16: + raise DumbMoneyPromotionError( + f"sealed {role} public-key set must contain 1 to 16 keys" + ) + verified: dict[str, bytes] = {} + for raw_key_id, raw_key in keys.items(): + key_id = _sha256_text(raw_key_id, f"{role} key ID") + try: + public_key = _decode_public_key(raw_key) + except DumbMoneyAuthorityError as error: + raise DumbMoneyPromotionError( + f"sealed {role} public key is invalid: key_id={key_id}" + ) from error + if hashlib.sha256(public_key).hexdigest() != key_id: + raise DumbMoneyPromotionError( + f"sealed {role} key ID does not bind its key: key_id={key_id}" + ) + verified[key_id] = public_key + return verified + + +def validated_role_keyrings( + promoter_public_keys: Mapping[str, bytes], + research_public_keys: Mapping[str, bytes], + *, + core_public_key: bytes | None = None, +) -> tuple[dict[str, bytes], dict[str, bytes]]: + promoter = _sealed_role_keyring(promoter_public_keys, "promoter") + research = _sealed_role_keyring(research_public_keys, "research") + overlap = set(promoter) & set(research) + if overlap: + raise DumbMoneyPromotionError( + "sealed promoter and research public-key sets must be disjoint" + ) + if core_public_key is not None: + try: + core_key_id = hashlib.sha256( + _decode_public_key(core_public_key) + ).hexdigest() + except DumbMoneyAuthorityError as error: + raise DumbMoneyPromotionError( + "pinned Core public key is invalid" + ) from error + if core_key_id in promoter or core_key_id in research: + raise DumbMoneyPromotionError( + "Core, promoter, and research signing roles must be disjoint" + ) + return promoter, research + + +def _alpha_passport( + payload: dict[str, Any], + now_utc: datetime, +) -> AlphaPassportV1: + _exact_fields(payload, ALPHA_PASSPORT_FIELDS, "AlphaPassportV1") + if payload.get("schema") != ALPHA_PASSPORT_SCHEMA: + raise DumbMoneyPromotionError("AlphaPassportV1 schema is unsupported") + if payload.get("venue") != DOPEY_VENUE: + raise DumbMoneyPromotionError("AlphaPassportV1 is not for dopey_robinhood") + evidence_class = str(payload.get("evidence_class", "")) + if evidence_class not in EVIDENCE_CLASSES: + raise DumbMoneyPromotionError("AlphaPassportV1 evidence_class is unsupported") + created_at = str(payload.get("created_at", "")) + expires_at = str(payload.get("expires_at", "")) + created = _utc_timestamp(created_at, "passport.created_at") + expires = _utc_timestamp(expires_at, "passport.expires_at") + if expires <= created: + raise DumbMoneyPromotionError("AlphaPassportV1 validity interval is empty") + now = now_utc.astimezone(timezone.utc) + if not created <= now < expires: + raise DumbMoneyPromotionError("AlphaPassportV1 is not currently valid") + return AlphaPassportV1( + passport_id=_identifier(payload.get("passport_id"), "passport_id"), + strategy_lineage_id=_identifier( + payload.get("strategy_lineage_id"), + "strategy_lineage_id", + ), + venue=DOPEY_VENUE, + strategy_hash=_sha256_text( + payload.get("strategy_hash"), + "passport.strategy_hash", + ), + artifact_hashes=_hash_array(payload, "artifact_hashes"), + evidence_verdict_hashes=_possibly_empty_hash_array( + payload, + "evidence_verdict_hashes", + ), + intended_instruments=_instrument_array(payload, "intended_instruments"), + maximum_loss_cents=_positive_int(payload, "maximum_loss_cents"), + evidence_class=evidence_class, + created_at=created_at, + expires_at=expires_at, + ) + + +def _promotion_certificate( + payload: dict[str, Any], + now_utc: datetime, +) -> PromotionCertificateV1: + _exact_fields( + payload, + PROMOTION_CERTIFICATE_FIELDS, + "PromotionCertificateV1", + ) + if payload.get("schema") != PROMOTION_CERTIFICATE_SCHEMA: + raise DumbMoneyPromotionError("PromotionCertificateV1 schema is unsupported") + if payload.get("venue") != DOPEY_VENUE: + raise DumbMoneyPromotionError( + "PromotionCertificateV1 is not for dopey_robinhood" + ) + stage = str(payload.get("stage", "")) + if stage not in PROMOTION_STAGES: + raise DumbMoneyPromotionError("PromotionCertificateV1 stage is unsupported") + not_before = str(payload.get("not_before", "")) + expires_at = str(payload.get("expires_at", "")) + starts = _utc_timestamp(not_before, "promotion.not_before") + expires = _utc_timestamp(expires_at, "promotion.expires_at") + if expires <= starts: + raise DumbMoneyPromotionError( + "PromotionCertificateV1 validity interval is empty" + ) + now = now_utc.astimezone(timezone.utc) + if not starts <= now < expires: + raise DumbMoneyPromotionError("PromotionCertificateV1 is not currently valid") + rollback_triggers = _identifier_array(payload, "rollback_triggers") + return PromotionCertificateV1( + certificate_id=_identifier( + payload.get("certificate_id"), + "certificate_id", + ), + passport_digest=_sha256_text( + payload.get("passport_digest"), + "promotion.passport_digest", + ), + verdict_digests=_hash_array(payload, "verdict_digests"), + stage=stage, + venue=DOPEY_VENUE, + instruments=_instrument_array(payload, "instruments"), + maximum_loss_cents=_positive_int(payload, "maximum_loss_cents"), + rollback_triggers=rollback_triggers, + policy_epoch=_positive_int(payload, "policy_epoch"), + not_before=not_before, + expires_at=expires_at, + ) + + +def verify_alpha_passport_envelope( + envelope: dict[str, Any], + research_public_keys: Mapping[str, bytes], + now_utc: datetime, +) -> VerifiedAlphaPassport: + """Authenticate and parse one current research-signed passport.""" + + research = _sealed_role_keyring(research_public_keys, "research") + signer_key_id = _sha256_text( + envelope.get("signer_key_id"), + "passport signer_key_id", + ) + public_key = research.get(signer_key_id) + if public_key is None: + raise DumbMoneyPromotionError( + "AlphaPassportV1 signer is outside the sealed research key set" + ) + wrapper = _verify_signed_wrapper( + envelope, + public_key, + now_utc, + enforce_current_window=True, + ) + if wrapper.body_schema != ALPHA_PASSPORT_SCHEMA: + raise DumbMoneyPromotionError("resolved envelope is not AlphaPassportV1") + passport = _alpha_passport(wrapper.body, now_utc) + return VerifiedAlphaPassport( + passport=passport, + event_id=wrapper.event_id, + body_digest=wrapper.body_digest, + signer_key_id=wrapper.signer_key_id, + wrapper_expires_at=wrapper.expires_at, + ) + + +def verify_promotion_certificate_envelope( + envelope: dict[str, Any], + promoter_public_keys: Mapping[str, bytes], + now_utc: datetime, +) -> VerifiedPromotionCertificate: + """Authenticate and parse one current promoter-signed certificate.""" + + promoter = _sealed_role_keyring(promoter_public_keys, "promoter") + signer_key_id = _sha256_text( + envelope.get("signer_key_id"), + "promotion signer_key_id", + ) + public_key = promoter.get(signer_key_id) + if public_key is None: + raise DumbMoneyPromotionError( + "PromotionCertificateV1 signer is outside the sealed promoter key set" + ) + wrapper = _verify_signed_wrapper( + envelope, + public_key, + now_utc, + enforce_current_window=True, + ) + if wrapper.body_schema != PROMOTION_CERTIFICATE_SCHEMA: + raise DumbMoneyPromotionError("resolved envelope is not PromotionCertificateV1") + promotion = _promotion_certificate(wrapper.body, now_utc) + return VerifiedPromotionCertificate( + certificate=promotion, + event_id=wrapper.event_id, + body_digest=wrapper.body_digest, + signer_key_id=wrapper.signer_key_id, + wrapper_expires_at=wrapper.expires_at, + ) + + +def bind_verified_promotion( + authority: VerifiedCapitalAuthority, + intent: LiveExecutionIntent, + passport: VerifiedAlphaPassport, + promotion: VerifiedPromotionCertificate, + now_utc: datetime, + *, + expected_binding: VerifiedPromotionBinding | None = None, +) -> tuple[LiveExecutionIntent, VerifiedPromotionBinding]: + """Bind one locally generated intent to an authenticated singleton chain.""" + + if intent.side != "buy": + raise DumbMoneyPromotionError( + "promotion lineage may only be attached to new-risk buy intents" + ) + if now_utc.tzinfo is None: + raise DumbMoneyPromotionError("promotion binding requires an aware UTC clock") + if expected_binding is None: + if intent.passport_hash or intent.promotion_hash: + raise DumbMoneyPromotionError( + "local intent supplied its own passport or promotion digest" + ) + else: + if ( + intent.passport_hash != expected_binding.passport_hash + or intent.promotion_hash != expected_binding.promotion_hash + ): + raise DumbMoneyPromotionError( + "revalidated intent lineage differs from its authenticated binding" + ) + + if ( + len(authority.envelope.strategy_hashes) != 1 + or len(authority.envelope.passport_hashes) != 1 + or len(authority.envelope.promotion_hashes) != 1 + ): + raise DumbMoneyPromotionError( + "active capital authority is not a singleton lineage tuple" + ) + strategy_hash = authority.envelope.strategy_hashes[0] + passport_hash = authority.envelope.passport_hashes[0] + promotion_hash = authority.envelope.promotion_hashes[0] + if passport.body_digest != passport_hash: + raise DumbMoneyPromotionError( + "resolved passport digest differs from active capital authority" + ) + if promotion.body_digest != promotion_hash: + raise DumbMoneyPromotionError( + "resolved promotion digest differs from active capital authority" + ) + if promotion.certificate.passport_digest != passport.body_digest: + raise DumbMoneyPromotionError( + "PromotionCertificateV1 does not bind the resolved AlphaPassportV1" + ) + if passport.passport.strategy_hash != strategy_hash: + raise DumbMoneyPromotionError( + "AlphaPassportV1 strategy differs from active capital authority" + ) + if intent.source_hash != strategy_hash: + raise DumbMoneyPromotionError( + "local intent strategy differs from authenticated singleton lineage" + ) + if promotion.certificate.stage not in LIVE_PROMOTION_STAGES: + raise DumbMoneyPromotionError( + "PromotionCertificateV1 stage is not live-authorizing" + ) + if promotion.certificate.policy_epoch != authority.envelope.policy_epoch: + raise DumbMoneyPromotionError("promotion and capital policy epochs differ") + + instrument_id = intent_authorized_instrument_id(intent) + if ( + instrument_id not in authority.envelope.authorized_instruments + or instrument_id not in passport.passport.intended_instruments + or instrument_id not in promotion.certificate.instruments + ): + raise DumbMoneyPromotionError( + "intent instrument is not jointly authorized by capital, " + "passport, and promotion" + ) + risk_cents = intent_incremental_risk_cents(intent) + lineage_maximum_loss = min( + passport.passport.maximum_loss_cents, + promotion.certificate.maximum_loss_cents, + ) + if risk_cents > lineage_maximum_loss: + raise DumbMoneyPromotionError( + "intent worst-case loss exceeds authenticated promotion lineage" + ) + expiry_candidates = ( + authority.envelope.expires_at, + passport.wrapper_expires_at, + passport.passport.expires_at, + promotion.wrapper_expires_at, + promotion.certificate.expires_at, + ) + expires_at = min( + expiry_candidates, + key=lambda value: _utc_timestamp(value, "lineage expiry"), + ) + if now_utc.astimezone(timezone.utc) >= _utc_timestamp( + expires_at, + "lineage expiry", + ): + raise DumbMoneyPromotionError( + "authenticated promotion binding is already expired" + ) + binding = VerifiedPromotionBinding( + strategy_hash=strategy_hash, + passport_hash=passport_hash, + promotion_hash=promotion_hash, + instrument_id=instrument_id, + maximum_loss_cents=lineage_maximum_loss, + policy_epoch=authority.envelope.policy_epoch, + capital_envelope_id=authority.envelope.envelope_id, + capital_event_id=authority.event_id, + capital_body_digest=authority.body_digest, + capital_fencing_generation=authority.envelope.fencing_generation, + passport_event_id=passport.event_id, + promotion_event_id=promotion.event_id, + passport_signer_key_id=passport.signer_key_id, + promotion_signer_key_id=promotion.signer_key_id, + expires_at=expires_at, + ) + if expected_binding is not None and binding != expected_binding: + raise DumbMoneyPromotionError( + "promotion lineage or capital fence changed during execution" + ) + return ( + replace( + intent, + passport_hash=passport_hash, + promotion_hash=promotion_hash, + ), + binding, + ) diff --git a/dopey_live/dumbmoney_windows_service.py b/dopey_live/dumbmoney_windows_service.py new file mode 100644 index 0000000..0a06e86 --- /dev/null +++ b/dopey_live/dumbmoney_windows_service.py @@ -0,0 +1,1790 @@ +"""Private-local Windows service runner for DumbMoneyDopeyRobinhood. + +The production entry point accepts only release-manifest arguments, loads one +SHA-256-pinned public config from ProgramData, reads the two secret values from +Windows Credential Manager, and starts in RECONCILIATION_ONLY. Importing this +module performs no I/O, starts no thread, and makes no network request. +""" + +from __future__ import annotations + +import argparse +import base64 +import ctypes +import hashlib +import json +import os +import re +import signal +import socket +import struct +import subprocess +import sys +import threading +import uuid +from dataclasses import dataclass +from datetime import datetime, timedelta, timezone +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path +from typing import Any, Callable, Final, Mapping, Protocol +from urllib.parse import urlsplit + +import requests +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey + +from .control import ( + CONTROL_SCHEMA, + OFFICIAL_ROBINHOOD_MCP, + LiveBranchControl, +) +from .dumbmoney_authority import ( + DumbMoneyAuthorityError, + _decode_public_key, + _exact_fields, + _sha256_text, + _utc_timestamp, + _verify_signed_wrapper, + canonical_json_bytes, +) +from .dumbmoney_command_feed import ( + COMMAND_STATE_SCHEMA, + CommandFeedHttpRequest, + CommandFeedHttpResponse, + CommandFeedTransport, + DumbMoneyCommandDispatch, + DumbMoneyCommandFeedClient, + DumbMoneyCommandFeedError, + command_feed_head, +) +from .dumbmoney_contract_feed import DumbMoneyContractResolverClient +from .dumbmoney_journal_anchor import ( + CoreJournalAnchorClient, + JournalAnchorError, +) +from .ledger import ( + GENESIS_HASH, + LEDGER_SCHEMA, + LiveBranchLedgerError, + ledger_path, + paper_twin_path, + read_ledger, +) +from .robinhood_gateway import ( + LOCAL_MODEL_BASE_URL, + LOCAL_MODEL_PATTERN, + LOCAL_MODEL_PROVIDER, + RobinhoodAgenticGateway, +) +from .robinhood_mcp_direct import DeterministicRobinhoodMcpClient +from .service import run_live_branch_cycle + +SERVICE_NAME: Final[str] = "DumbMoneyDopeyRobinhood" +CONFIG_SCHEMA: Final[str] = "dopey.dumbmoney-robinhood-runner-config.v1" +CORE_READINESS_SCHEMA: Final[str] = "dumbmoney.readiness-descriptor.v1" +HEALTH_SCHEMA: Final[str] = "dumbmoney.health.v1" +SIGNED_ENVELOPE_SCHEMA: Final[str] = "dumbmoney.signed-envelope.v1" +CORE_ENDPOINT_REF: Final[str] = "endpoint-ref:DumbMoneyCore" +CELL_TOKEN_TARGET_REF: Final[str] = "credential-target:DumbMoney/DopeyCellToken" +ROBINHOOD_PROFILE_TARGET_REF: Final[str] = ( + "credential-target:DumbMoney/RobinhoodProfile" +) +START_MODE: Final[str] = "RECONCILIATION_ONLY" +READINESS_REF: Final[str] = "readiness-ref:DumbMoneyDopeyRobinhood" +CELL_TOKEN_SECRET_REF: Final[str] = "secret-ref:dumbmoney-dopey-cell-token" +ROBINHOOD_PROFILE_SECRET_REF: Final[str] = "secret-ref:robinhood-agentic-mcp-profile" +CONFIG_RELATIVE_PATH: Final[Path] = ( + Path("DumbMoney") / "config" / "dopey-robinhood-runner.v1.json" +) +CORE_READINESS_RELATIVE_PATH: Final[Path] = ( + Path("DumbMoney") / "readiness" / "DumbMoneyCore.json" +) +READINESS_RELATIVE_PATH: Final[Path] = ( + Path("DumbMoney") / "readiness" / f"{SERVICE_NAME}.json" +) +DATA_ROOT_RELATIVE_PATH: Final[Path] = Path("DumbMoney") / "dopey-robinhood" +OLLAMA_EVIDENCE_RELATIVE_PATH: Final[Path] = ( + Path("evidence") / "ollama-runtime-evidence.v1.json" +) +MAXIMUM_CONFIG_BYTES: Final[int] = 262_144 +MAXIMUM_READINESS_BYTES: Final[int] = 262_144 +MAXIMUM_OLLAMA_MODELS_BYTES: Final[int] = 1_048_576 +OLLAMA_MODELS_URL: Final[str] = f"{LOCAL_MODEL_BASE_URL.removesuffix('/v1')}/api/tags" +READINESS_KDF_DOMAIN: Final[bytes] = b"DumbMoneyDopeyRobinhood/readiness-ed25519/v1\0" +SHA256_PATTERN: Final[re.Pattern[str]] = re.compile(r"^[0-9a-f]{64}$") +WINDOWS_SID_PATTERN: Final[re.Pattern[str]] = re.compile( + r"^S-1-5-(?:[0-9]+-){1,14}[0-9]+$" +) +CONFIG_FIELDS: Final[frozenset[str]] = frozenset( + { + "schema", + "service_name", + "release_id", + "core_endpoint_ref", + "core_readiness_path", + "core_cell_token_target", + "robinhood_profile_target", + "start_mode", + "data_root", + "readiness_ref", + "readiness_path", + "core_public_key_base64url", + "operator_public_keys_base64url", + "promoter_public_keys_base64url", + "research_public_keys_base64url", + "evaluator_public_keys_base64url", + "expected_account_hash", + "fund_lock_sha256", + "service_manifest_sha256", + "role_public_keys_sha256", + "core_runner_config_sha256", + "risk_policy_sha256", + "readiness_signer_public_key_sha256", + "codex_executable_path", + "codex_executable_sha256", + "codex_local_provider", + "codex_local_model", + "codex_local_model_digest", + "ollama_executable_path", + "ollama_executable_sha256", + "ollama_process_identity_sid", + "ollama_runtime_evidence_sha256", + "poll_interval_seconds", + "readiness_ttl_seconds", + } +) +CORE_READINESS_FIELDS: Final[frozenset[str]] = frozenset( + { + "schema", + "service_name", + "release_id", + "instance_id", + "process_id", + "generation", + "observed_at", + "valid_until", + "endpoint", + "fund_lock_sha256", + "service_manifest_sha256", + "authority", + "health", + "capabilities", + } +) + + +class DumbMoneyWindowsServiceError(RuntimeError): + """A production service invariant failed closed.""" + + +class CredentialProvider(Protocol): + def read_bytes(self, target: str) -> bytes: ... + + +class WindowsCredentialManager: + """Read one Windows generic credential without logging its value.""" + + CRED_TYPE_GENERIC = 1 + + def read_bytes(self, target: str) -> bytes: + if not isinstance(target, str) or not re.fullmatch( + r"[A-Za-z0-9][A-Za-z0-9._/-]{0,127}", target + ): + raise DumbMoneyWindowsServiceError("Windows credential target is invalid") + if os.name != "nt": + raise DumbMoneyWindowsServiceError("Windows Credential Manager is required") + from ctypes import wintypes + + class FileTime(ctypes.Structure): + _fields_ = [ + ("low_datetime", wintypes.DWORD), + ("high_datetime", wintypes.DWORD), + ] + + class CredentialAttribute(ctypes.Structure): + _fields_ = [ + ("keyword", wintypes.LPWSTR), + ("flags", wintypes.DWORD), + ("value_size", wintypes.DWORD), + ("value", ctypes.POINTER(ctypes.c_ubyte)), + ] + + class Credential(ctypes.Structure): + _fields_ = [ + ("flags", wintypes.DWORD), + ("credential_type", wintypes.DWORD), + ("target_name", wintypes.LPWSTR), + ("comment", wintypes.LPWSTR), + ("last_written", FileTime), + ("credential_blob_size", wintypes.DWORD), + ("credential_blob", ctypes.POINTER(ctypes.c_ubyte)), + ("persist", wintypes.DWORD), + ("attribute_count", wintypes.DWORD), + ("attributes", ctypes.POINTER(CredentialAttribute)), + ("target_alias", wintypes.LPWSTR), + ("user_name", wintypes.LPWSTR), + ] + + pointer = ctypes.POINTER(Credential)() + advapi32 = ctypes.WinDLL("Advapi32.dll", use_last_error=True) + cred_read = advapi32.CredReadW + cred_read.argtypes = [ + wintypes.LPCWSTR, + wintypes.DWORD, + wintypes.DWORD, + ctypes.POINTER(ctypes.POINTER(Credential)), + ] + cred_read.restype = wintypes.BOOL + cred_free = advapi32.CredFree + cred_free.argtypes = [ctypes.c_void_p] + cred_free.restype = None + if not cred_read( + target, + self.CRED_TYPE_GENERIC, + 0, + ctypes.byref(pointer), + ): + error_code = ctypes.get_last_error() + raise DumbMoneyWindowsServiceError( + f"required Windows credential is unavailable; winerror={error_code}" + ) + try: + credential = pointer.contents + size = int(credential.credential_blob_size) + if not 1 <= size <= 16_384: + raise DumbMoneyWindowsServiceError( + "Windows credential blob size is invalid" + ) + return ctypes.string_at(credential.credential_blob, size) + finally: + cred_free(pointer) + + +@dataclass(frozen=True) +class DopeyRunnerConfig: + release_id: str + core_readiness_path: Path + data_root: Path + readiness_path: Path + core_public_key: bytes + operator_public_keys: Mapping[str, bytes] + promoter_public_keys: Mapping[str, bytes] + research_public_keys: Mapping[str, bytes] + evaluator_public_keys: Mapping[str, bytes] + expected_account_hash: str + fund_lock_sha256: str + service_manifest_sha256: str + role_public_keys_sha256: str + core_runner_config_sha256: str + risk_policy_sha256: str + readiness_signer_public_key_sha256: str + codex_executable_path: Path + codex_executable_sha256: str + codex_local_model: str + codex_local_model_digest: str + ollama_executable_path: Path + ollama_executable_sha256: str + ollama_process_identity_sid: str + ollama_runtime_evidence_sha256: str + poll_interval_seconds: int + readiness_ttl_seconds: int + config_sha256: str + + +@dataclass(frozen=True) +class CoreEndpoint: + base_url: str + instance_id: str + observed_at: str + valid_until: str + + +@dataclass +class RunnerState: + mode: str = START_MODE + health_status: str = "DEGRADED" + reason: str = "STARTING" + core_ready: bool = False + reconciled_once: bool = False + execution_enabled: bool = False + last_cycle_status: str = "not_started" + command_sequence: int = 0 + command_digest: str = "0" * 64 + rollback_anchor_current: bool = False + + +class LoopbackHttpTransport(CommandFeedTransport): + """HTTP transport that never uses proxies, redirects, or non-loopback hosts.""" + + def __init__(self) -> None: + self._session = requests.Session() + self._session.trust_env = False + + def __call__( + self, + request: CommandFeedHttpRequest, + ) -> CommandFeedHttpResponse: + parsed = urlsplit(request.url) + if ( + parsed.scheme != "http" + or parsed.hostname not in {"127.0.0.1", "::1"} + or parsed.username is not None + or parsed.password is not None + ): + raise DumbMoneyWindowsServiceError( + "Core transport attempted a non-loopback request" + ) + response = self._session.request( + request.method, + request.url, + headers=dict(request.headers), + data=request.body, + timeout=request.timeout_seconds, + allow_redirects=False, + ) + return CommandFeedHttpResponse( + status=response.status_code, + headers=dict(response.headers), + body=bytes(response.content), + ) + + +def _sha256_file(path: Path, label: str) -> str: + digest = hashlib.sha256() + try: + with path.open("rb") as handle: + for chunk in iter(lambda: handle.read(1024 * 1024), b""): + digest.update(chunk) + except OSError as error: + raise DumbMoneyWindowsServiceError(f"{label} cannot be read") from error + return digest.hexdigest() + + +def _windows_loopback_listener_owner( + host: str, + port: int, +) -> tuple[int, Path, str]: + """Resolve one exact IPv4 loopback listener to its PID and image path.""" + + if os.name != "nt": + raise DumbMoneyWindowsServiceError( + "Windows TCP listener ownership verification is required" + ) + if ( + host != "127.0.0.1" + or isinstance(port, bool) + or not isinstance(port, int) + or not 1 <= port <= 65_535 + ): + raise DumbMoneyWindowsServiceError("loopback listener endpoint is invalid") + from ctypes import wintypes + + class TcpRowOwnerPid(ctypes.Structure): + _fields_ = [ + ("state", wintypes.DWORD), + ("local_address", wintypes.DWORD), + ("local_port", wintypes.DWORD), + ("remote_address", wintypes.DWORD), + ("remote_port", wintypes.DWORD), + ("owning_process_id", wintypes.DWORD), + ] + + get_table = ctypes.WinDLL("Iphlpapi.dll", use_last_error=True).GetExtendedTcpTable + get_table.argtypes = [ + ctypes.c_void_p, + ctypes.POINTER(wintypes.DWORD), + wintypes.BOOL, + wintypes.ULONG, + ctypes.c_int, + wintypes.ULONG, + ] + get_table.restype = wintypes.DWORD + size = wintypes.DWORD(0) + error = get_table(None, ctypes.byref(size), False, socket.AF_INET, 3, 0) + if error not in {0, 122} or size.value <= ctypes.sizeof(wintypes.DWORD): + raise DumbMoneyWindowsServiceError( + "Windows TCP listener inventory is unavailable" + ) + buffer = ctypes.create_string_buffer(size.value) + error = get_table(buffer, ctypes.byref(size), False, socket.AF_INET, 3, 0) + if error != 0: + raise DumbMoneyWindowsServiceError( + "Windows TCP listener inventory cannot be read" + ) + count = ctypes.cast(buffer, ctypes.POINTER(wintypes.DWORD)).contents.value + rows_address = ctypes.addressof(buffer) + ctypes.sizeof(wintypes.DWORD) + rows = ctypes.cast(rows_address, ctypes.POINTER(TcpRowOwnerPid)) + owners: set[int] = set() + for index in range(count): + row = rows[index] + address = socket.inet_ntoa(struct.pack(" None: + if ( + _sha256_file( + self.static_evidence_path, + "Ollama static installation attestation", + ) + != self.static_evidence_sha256 + ): + raise DumbMoneyWindowsServiceError( + "Ollama static installation attestation SHA-256 mismatch" + ) + owner_before = self._verify_listener_owner() + response = self.transport( + CommandFeedHttpRequest( + method="GET", + url=OLLAMA_MODELS_URL, + headers={ + "Accept": "application/json", + "Cache-Control": "no-store", + }, + timeout_seconds=5.0, + ) + ) + if response.status != 200: + raise DumbMoneyWindowsServiceError( + "local Ollama model inventory is unavailable" + ) + if not 1 <= len(response.body) <= MAXIMUM_OLLAMA_MODELS_BYTES: + raise DumbMoneyWindowsServiceError( + "local Ollama model inventory size is invalid" + ) + payload = _strict_json(response.body, "local Ollama model inventory") + models = payload.get("models") + if not isinstance(models, list) or len(models) > 1_000: + raise DumbMoneyWindowsServiceError( + "local Ollama model inventory is invalid" + ) + matches = [ + item + for item in models + if isinstance(item, dict) + and item.get("name") == self.model + and item.get("model") == self.model + ] + if len(matches) != 1: + raise DumbMoneyWindowsServiceError( + "pinned local Ollama model is absent or ambiguous" + ) + if matches[0].get("digest") != self.digest: + raise DumbMoneyWindowsServiceError( + "pinned local Ollama model digest mismatch" + ) + owner_after = self._verify_listener_owner() + if owner_after != owner_before: + raise DumbMoneyWindowsServiceError( + "Ollama listener ownership changed during verification" + ) + + def _verify_listener_owner(self) -> tuple[int, Path, str]: + process_id, process_path, process_sid = self.listener_owner_resolver( + "127.0.0.1", + 11_434, + ) + if ( + isinstance(process_id, bool) + or not isinstance(process_id, int) + or process_id <= 0 + or process_path.resolve() != self.executable_path.resolve() + or process_sid != self.process_identity_sid + ): + raise DumbMoneyWindowsServiceError( + "Ollama listener is not owned by the pinned identity and executable" + ) + if _sha256_file(process_path, "Ollama listener executable") != ( + self.executable_sha256 + ): + raise DumbMoneyWindowsServiceError( + "Ollama listener executable SHA-256 mismatch" + ) + return process_id, process_path.resolve(), process_sid + + +def _strict_json(raw: bytes, label: str) -> dict[str, Any]: + def reject_duplicate(pairs: list[tuple[str, Any]]) -> dict[str, Any]: + result: dict[str, Any] = {} + for key, value in pairs: + if key in result: + raise DumbMoneyWindowsServiceError( + f"{label} contains a duplicate JSON key" + ) + result[key] = value + return result + + def reject_constant(value: str) -> None: + raise DumbMoneyWindowsServiceError( + f"{label} contains a non-finite number: {value}" + ) + + try: + payload = json.loads( + raw, + object_pairs_hook=reject_duplicate, + parse_constant=reject_constant, + ) + except (UnicodeDecodeError, json.JSONDecodeError) as error: + raise DumbMoneyWindowsServiceError( + f"{label} is not valid UTF-8 JSON" + ) from error + if not isinstance(payload, dict) or not all( + isinstance(key, str) for key in payload + ): + raise DumbMoneyWindowsServiceError(f"{label} must be a JSON object") + return payload + + +def _digest(value: Any, label: str) -> str: + try: + return _sha256_text(value, label) + except DumbMoneyAuthorityError as error: + raise DumbMoneyWindowsServiceError(str(error)) from error + + +def _windows_sid(value: Any, label: str) -> str: + if not isinstance(value, str) or not WINDOWS_SID_PATTERN.fullmatch(value): + raise DumbMoneyWindowsServiceError(f"{label} must be a canonical Windows SID") + return value + + +def _exact_path(value: Any, expected: Path, label: str) -> Path: + if not isinstance(value, str) or not value: + raise DumbMoneyWindowsServiceError(f"{label} must be an absolute path") + try: + resolved = Path(value).resolve() + except OSError as error: + raise DumbMoneyWindowsServiceError(f"{label} cannot be resolved") from error + if resolved != expected.resolve(): + raise DumbMoneyWindowsServiceError( + f"{label} differs from the fixed ProgramData path" + ) + return resolved + + +def _absolute_file_path(value: Any, label: str) -> Path: + if not isinstance(value, str) or not value: + raise DumbMoneyWindowsServiceError(f"{label} must be an absolute path") + path = Path(value) + if not path.is_absolute(): + raise DumbMoneyWindowsServiceError(f"{label} must be absolute") + try: + return path.resolve() + except OSError as error: + raise DumbMoneyWindowsServiceError(f"{label} cannot be resolved") from error + + +def _decode_key_map(value: Any, label: str) -> dict[str, bytes]: + if not isinstance(value, dict) or not 1 <= len(value) <= 16: + raise DumbMoneyWindowsServiceError(f"{label} must contain 1 to 16 public keys") + keys: dict[str, bytes] = {} + for raw_key_id, raw_key in value.items(): + key_id = _digest(raw_key_id, f"{label} key ID") + if not isinstance(raw_key, str): + raise DumbMoneyWindowsServiceError( + f"{label} public-key encoding must be a string" + ) + try: + key = _decode_public_key(raw_key.encode("ascii")) + except (UnicodeEncodeError, DumbMoneyAuthorityError) as error: + raise DumbMoneyWindowsServiceError( + f"{label} public key is invalid" + ) from error + if hashlib.sha256(key).hexdigest() != key_id: + raise DumbMoneyWindowsServiceError( + f"{label} key ID does not bind its public key" + ) + keys[key_id] = key + return keys + + +def load_runner_config( + path: Path, + expected_sha256: str, + program_data: Path, +) -> DopeyRunnerConfig: + """Load only the fixed public config and verify its exact raw bytes.""" + + expected_digest = _digest(expected_sha256, "config_sha256") + fixed_path = (program_data / CONFIG_RELATIVE_PATH).resolve() + if path.resolve() != fixed_path: + raise DumbMoneyWindowsServiceError( + "runner config path is not the fixed ProgramData path" + ) + try: + raw = path.read_bytes() + except OSError as error: + raise DumbMoneyWindowsServiceError( + "pinned public runner config is unavailable" + ) from error + if not 1 <= len(raw) <= MAXIMUM_CONFIG_BYTES: + raise DumbMoneyWindowsServiceError("runner config size is invalid") + if hashlib.sha256(raw).hexdigest() != expected_digest: + raise DumbMoneyWindowsServiceError( + "runner config does not match --config-sha256" + ) + payload = _strict_json(raw, "runner config") + try: + _exact_fields(payload, CONFIG_FIELDS, "Dopey runner config") + except DumbMoneyAuthorityError as error: + raise DumbMoneyWindowsServiceError(str(error)) from error + exact_values = { + "schema": CONFIG_SCHEMA, + "service_name": SERVICE_NAME, + "core_endpoint_ref": CORE_ENDPOINT_REF, + "core_cell_token_target": CELL_TOKEN_TARGET_REF, + "robinhood_profile_target": ROBINHOOD_PROFILE_TARGET_REF, + "start_mode": START_MODE, + "readiness_ref": READINESS_REF, + "codex_local_provider": LOCAL_MODEL_PROVIDER, + } + for field, expected in exact_values.items(): + if payload.get(field) != expected: + raise DumbMoneyWindowsServiceError( + f"runner config {field} is not the sealed value" + ) + release_id = payload.get("release_id") + if not isinstance(release_id, str) or not release_id.strip(): + raise DumbMoneyWindowsServiceError("runner config release_id must be nonempty") + core_key_value = payload.get("core_public_key_base64url") + if not isinstance(core_key_value, str): + raise DumbMoneyWindowsServiceError( + "runner config Core public key must be a string" + ) + try: + core_public_key = _decode_public_key(core_key_value.encode("ascii")) + except (UnicodeEncodeError, DumbMoneyAuthorityError) as error: + raise DumbMoneyWindowsServiceError( + "runner config Core public key is invalid" + ) from error + operator_keys = _decode_key_map( + payload.get("operator_public_keys_base64url"), + "operator", + ) + promoter_keys = _decode_key_map( + payload.get("promoter_public_keys_base64url"), + "promoter", + ) + research_keys = _decode_key_map( + payload.get("research_public_keys_base64url"), + "research", + ) + evaluator_keys = _decode_key_map( + payload.get("evaluator_public_keys_base64url"), + "evaluator", + ) + role_ids = ( + {hashlib.sha256(core_public_key).hexdigest()} + | set(operator_keys) + | set(promoter_keys) + | set(research_keys) + | set(evaluator_keys) + ) + expected_role_count = ( + 1 + + len(operator_keys) + + len(promoter_keys) + + len(research_keys) + + len(evaluator_keys) + ) + if len(role_ids) != expected_role_count: + raise DumbMoneyWindowsServiceError( + "Core/operator/promoter/research public-key roles overlap" + ) + poll_interval = payload.get("poll_interval_seconds") + if ( + isinstance(poll_interval, bool) + or not isinstance(poll_interval, int) + or not 5 <= poll_interval <= 300 + ): + raise DumbMoneyWindowsServiceError( + "poll_interval_seconds must be from 5 through 300" + ) + readiness_ttl = payload.get("readiness_ttl_seconds") + if ( + isinstance(readiness_ttl, bool) + or not isinstance(readiness_ttl, int) + or not 10 <= readiness_ttl <= 120 + ): + raise DumbMoneyWindowsServiceError( + "readiness_ttl_seconds must be from 10 through 120" + ) + local_model = payload.get("codex_local_model") + if ( + not isinstance(local_model, str) + or not LOCAL_MODEL_PATTERN.fullmatch(local_model) + or local_model.endswith((":latest", ":cloud")) + ): + raise DumbMoneyWindowsServiceError( + "codex_local_model must be an explicit non-cloud Ollama tag" + ) + return DopeyRunnerConfig( + release_id=release_id, + core_readiness_path=_exact_path( + payload.get("core_readiness_path"), + program_data / CORE_READINESS_RELATIVE_PATH, + "core_readiness_path", + ), + data_root=_exact_path( + payload.get("data_root"), + program_data / DATA_ROOT_RELATIVE_PATH, + "data_root", + ), + readiness_path=_exact_path( + payload.get("readiness_path"), + program_data / READINESS_RELATIVE_PATH, + "readiness_path", + ), + core_public_key=core_public_key, + operator_public_keys=operator_keys, + promoter_public_keys=promoter_keys, + research_public_keys=research_keys, + evaluator_public_keys=evaluator_keys, + expected_account_hash=_digest( + payload.get("expected_account_hash"), + "expected_account_hash", + ), + fund_lock_sha256=_digest( + payload.get("fund_lock_sha256"), + "fund_lock_sha256", + ), + service_manifest_sha256=_digest( + payload.get("service_manifest_sha256"), + "service_manifest_sha256", + ), + role_public_keys_sha256=_digest( + payload.get("role_public_keys_sha256"), + "role_public_keys_sha256", + ), + core_runner_config_sha256=_digest( + payload.get("core_runner_config_sha256"), + "core_runner_config_sha256", + ), + risk_policy_sha256=_digest( + payload.get("risk_policy_sha256"), + "risk_policy_sha256", + ), + readiness_signer_public_key_sha256=_digest( + payload.get("readiness_signer_public_key_sha256"), + "readiness_signer_public_key_sha256", + ), + codex_executable_path=_absolute_file_path( + payload.get("codex_executable_path"), + "codex_executable_path", + ), + codex_executable_sha256=_digest( + payload.get("codex_executable_sha256"), + "codex_executable_sha256", + ), + codex_local_model=local_model, + codex_local_model_digest=_digest( + payload.get("codex_local_model_digest"), + "codex_local_model_digest", + ), + ollama_executable_path=_absolute_file_path( + payload.get("ollama_executable_path"), + "ollama_executable_path", + ), + ollama_executable_sha256=_digest( + payload.get("ollama_executable_sha256"), + "ollama_executable_sha256", + ), + ollama_process_identity_sid=_windows_sid( + payload.get("ollama_process_identity_sid"), + "ollama_process_identity_sid", + ), + ollama_runtime_evidence_sha256=_digest( + payload.get("ollama_runtime_evidence_sha256"), + "ollama_runtime_evidence_sha256", + ), + poll_interval_seconds=poll_interval, + readiness_ttl_seconds=readiness_ttl, + config_sha256=expected_digest, + ) + + +def _credential_target(reference: str, expected: str) -> str: + if reference != expected or not reference.startswith("credential-target:"): + raise DumbMoneyWindowsServiceError( + "credential reference is not the sealed target" + ) + return reference.removeprefix("credential-target:") + + +def _secret_text(raw: bytes, label: str) -> str: + try: + value = raw.decode("utf-8") + except UnicodeDecodeError as error: + raise DumbMoneyWindowsServiceError( + f"{label} credential is not UTF-8" + ) from error + if ( + not 16 <= len(value) <= 16_384 + or value != value.strip() + or any(character in value for character in "\r\n\0") + ): + raise DumbMoneyWindowsServiceError(f"{label} credential has an invalid shape") + return value + + +def load_secrets( + provider: CredentialProvider, +) -> tuple[str, str]: + """Load exactly two Credential Manager targets with no fallback.""" + + cell_token = _secret_text( + provider.read_bytes( + _credential_target(CELL_TOKEN_TARGET_REF, CELL_TOKEN_TARGET_REF) + ), + CELL_TOKEN_SECRET_REF, + ) + robinhood_profile = _secret_text( + provider.read_bytes( + _credential_target( + ROBINHOOD_PROFILE_TARGET_REF, + ROBINHOOD_PROFILE_TARGET_REF, + ) + ), + ROBINHOOD_PROFILE_SECRET_REF, + ) + return cell_token, robinhood_profile + + +def _readiness_signer(cell_token: str) -> Ed25519PrivateKey: + seed = hashlib.sha256(READINESS_KDF_DOMAIN + cell_token.encode("utf-8")).digest() + return Ed25519PrivateKey.from_private_bytes(seed) + + +def _core_endpoint_from_readiness( + config: DopeyRunnerConfig, + now_utc: datetime, +) -> CoreEndpoint: + try: + raw = config.core_readiness_path.read_bytes() + except OSError as error: + raise DumbMoneyWindowsServiceError( + "signed Core readiness is unavailable" + ) from error + if not 1 <= len(raw) <= MAXIMUM_READINESS_BYTES: + raise DumbMoneyWindowsServiceError("signed Core readiness size is invalid") + envelope = _strict_json(raw, "signed Core readiness") + try: + wrapper = _verify_signed_wrapper( + envelope, + config.core_public_key, + now_utc, + enforce_current_window=True, + ) + except DumbMoneyAuthorityError as error: + raise DumbMoneyWindowsServiceError( + f"signed Core readiness is invalid: {error}" + ) from error + if wrapper.body_schema != CORE_READINESS_SCHEMA: + raise DumbMoneyWindowsServiceError("Core readiness body schema is unsupported") + body = wrapper.body + try: + _exact_fields(body, CORE_READINESS_FIELDS, "Core readiness descriptor") + except DumbMoneyAuthorityError as error: + raise DumbMoneyWindowsServiceError(str(error)) from error + if ( + body.get("schema") != CORE_READINESS_SCHEMA + or body.get("service_name") != "DumbMoneyCore" + or body.get("release_id") != config.release_id + ): + raise DumbMoneyWindowsServiceError( + "Core readiness identity or release binding mismatch" + ) + instance_id = str(body.get("instance_id", "")) + try: + if str(uuid.UUID(instance_id)) != instance_id: + raise ValueError + except (ValueError, AttributeError) as error: + raise DumbMoneyWindowsServiceError( + "Core readiness instance_id is invalid" + ) from error + process_id = body.get("process_id") + generation = body.get("generation") + if ( + isinstance(process_id, bool) + or not isinstance(process_id, int) + or process_id <= 0 + or isinstance(generation, bool) + or not isinstance(generation, int) + or generation < 0 + ): + raise DumbMoneyWindowsServiceError( + "Core readiness process or generation is invalid" + ) + observed_at = str(body.get("observed_at", "")) + valid_until = str(body.get("valid_until", "")) + observed = _utc_timestamp(observed_at, "Core readiness observed_at") + valid = _utc_timestamp(valid_until, "Core readiness valid_until") + now = now_utc.astimezone(timezone.utc) + if ( + not observed <= now < valid + or valid - observed > timedelta(seconds=120) + or wrapper.not_before_utc != observed + or wrapper.expires_at_utc != valid + ): + raise DumbMoneyWindowsServiceError("Core readiness window is invalid or stale") + for field, expected in ( + ("fund_lock_sha256", config.fund_lock_sha256), + ("service_manifest_sha256", config.service_manifest_sha256), + ): + if body.get(field) != expected: + raise DumbMoneyWindowsServiceError(f"Core readiness {field} pin mismatch") + authority = body.get("authority") + if not isinstance(authority, dict) or authority != { + "broker": "NONE", + "mode": "OFFLINE", + "execution_enabled": False, + }: + raise DumbMoneyWindowsServiceError( + "Core readiness claims invalid broker authority" + ) + health = body.get("health") + if not isinstance(health, dict): + raise DumbMoneyWindowsServiceError("Core readiness health must be an object") + health_pins = { + "core_public_key_sha256": hashlib.sha256(config.core_public_key).hexdigest(), + "role_public_keys_sha256": config.role_public_keys_sha256, + "runner_config_sha256": config.core_runner_config_sha256, + "risk_policy_sha256": config.risk_policy_sha256, + } + for field, expected in health_pins.items(): + if health.get(field) != expected: + raise DumbMoneyWindowsServiceError( + f"Core readiness health pin mismatch: {field}" + ) + if health.get("status") not in {"READY", "DEGRADED"}: + raise DumbMoneyWindowsServiceError("Core readiness health status is blocked") + endpoint = body.get("endpoint") + if not isinstance(endpoint, dict) or set(endpoint) != { + "transport", + "host", + "port", + "base_path", + }: + raise DumbMoneyWindowsServiceError("Core readiness endpoint is invalid") + host = endpoint.get("host") + port = endpoint.get("port") + if ( + endpoint.get("transport") != "http" + or host not in {"127.0.0.1", "::1"} + or endpoint.get("base_path") != "/" + or isinstance(port, bool) + or not isinstance(port, int) + or not 1024 <= port <= 65_535 + ): + raise DumbMoneyWindowsServiceError( + "Core readiness endpoint is not literal loopback HTTP" + ) + base_url = f"http://[{host}]:{port}" if host == "::1" else f"http://{host}:{port}" + return CoreEndpoint( + base_url=base_url, + instance_id=instance_id, + observed_at=observed_at, + valid_until=valid_until, + ) + + +def _core_liveness( + transport: CommandFeedTransport, + endpoint: CoreEndpoint, +) -> None: + response = transport( + CommandFeedHttpRequest( + method="GET", + url=f"{endpoint.base_url}/health/live", + headers={"Accept": "application/json"}, + timeout_seconds=5.0, + ) + ) + if ( + not isinstance(response, CommandFeedHttpResponse) + or response.status != 200 + or len(response.body) > 16_384 + ): + raise DumbMoneyWindowsServiceError("Core liveness probe failed") + payload = _strict_json(response.body, "Core liveness") + if payload != {"schema": HEALTH_SCHEMA, "status": "LIVE"}: + raise DumbMoneyWindowsServiceError("Core liveness payload is invalid") + + +def _blocked_dispatch(reason: str) -> DumbMoneyCommandDispatch: + return DumbMoneyCommandDispatch( + desired_mode="PAUSED", + kill_active=True, + allow_new_risk=False, + capital_authority=None, + fail_closed_reason=reason, + required_action="CANCEL_AND_RECONCILE", + caught_up=False, + next_sequence=0, + next_digest="0" * 64, + applied_event_ids=(), + ) + + +def _service_control(config_sha256: str) -> LiveBranchControl: + return LiveBranchControl( + schema_version=CONTROL_SCHEMA, + mode="LIVE", + account_label="agentic", + official_mcp_endpoint=OFFICIAL_ROBINHOOD_MCP, + live_branch_enabled=True, + stock_execution_enabled=True, + options_execution_enabled=False, + paper_twin_required=True, + no_day_trading_enforced=True, + use_full_available_balance=False, + unofficial_broker_api_allowed=False, + owner_authorization_id=f"sealed-service:{config_sha256}", + ) + + +class _HealthServer: + def __init__(self, state_reader: Callable[[], RunnerState]) -> None: + state = state_reader + + class Handler(BaseHTTPRequestHandler): + def do_GET(self) -> None: # noqa: N802 + if self.path == "/health/live": + self._write(200, {"schema": HEALTH_SCHEMA, "status": "LIVE"}) + return + if self.path == "/health/ready": + snapshot = state() + ready = ( + snapshot.health_status == "READY" + and snapshot.reconciled_once + and snapshot.execution_enabled + and snapshot.rollback_anchor_current + ) + self._write( + 200 if ready else 503, + { + "schema": HEALTH_SCHEMA, + "status": "READY" if ready else "NOT_READY", + "reason_codes": [] if ready else [snapshot.reason], + }, + ) + return + self._write(404, {"schema": HEALTH_SCHEMA, "status": "NOT_FOUND"}) + + def _write(self, status: int, payload: dict[str, Any]) -> None: + encoded = canonical_json_bytes(payload) + self.send_response(status) + self.send_header("Content-Type", "application/json") + self.send_header("Cache-Control", "no-store") + self.send_header("Content-Length", str(len(encoded))) + self.end_headers() + self.wfile.write(encoded) + + def log_message(self, _format: str, *_args: Any) -> None: + return + + self.server = ThreadingHTTPServer(("127.0.0.1", 0), Handler) + self.thread = threading.Thread( + target=self.server.serve_forever, + name="DumbMoneyDopeyRobinhood-health", + daemon=True, + ) + + @property + def port(self) -> int: + return int(self.server.server_address[1]) + + def start(self) -> None: + self.thread.start() + + def close(self) -> None: + self.server.shutdown() + self.server.server_close() + self.thread.join(timeout=5.0) + + +class DumbMoneyDopeyWindowsService: + """Continuous reconcile-first supervisor with an at-most-once write sink.""" + + def __init__( + self, + config: DopeyRunnerConfig, + cell_token: str, + robinhood_profile: str, + *, + transport: CommandFeedTransport, + gateway: RobinhoodAgenticGateway, + cycle_runner: Callable[..., dict[str, Any]] = run_live_branch_cycle, + clock: Callable[[], datetime] = lambda: datetime.now(timezone.utc), + journal_anchor_factory: ( + Callable[[str], CoreJournalAnchorClient] | None + ) = None, + ) -> None: + self.config = config + self.cell_token = cell_token + self.transport = transport + self.gateway = gateway + self.cycle_runner = cycle_runner + self.clock = clock + self._journal_anchor_factory = journal_anchor_factory or ( + lambda base_url: CoreJournalAnchorClient( + base_url=base_url, + transport=self.transport, + cell_token_provider=lambda: self.cell_token, + trusted_core_public_key=self.config.core_public_key, + expected_account_hash=self.config.expected_account_hash, + now_fn=self.clock, + ) + ) + self.instance_id = str(uuid.uuid4()) + self._state = RunnerState() + self._state_lock = threading.Lock() + self._readiness_sequence = 0 + self._signer = _readiness_signer(cell_token) + readiness_public = self._signer.public_key().public_bytes_raw() + if ( + hashlib.sha256(readiness_public).hexdigest() + != config.readiness_signer_public_key_sha256 + ): + raise DumbMoneyWindowsServiceError( + "cell credential does not bind the pinned readiness signer" + ) + if not robinhood_profile: + raise DumbMoneyWindowsServiceError( + "Robinhood Credential Manager profile is absent" + ) + self._health_server = _HealthServer(self.state) + + def state(self) -> RunnerState: + with self._state_lock: + return RunnerState(**self._state.__dict__) + + def _set_state(self, **changes: Any) -> None: + with self._state_lock: + for field, value in changes.items(): + setattr(self._state, field, value) + + def start(self) -> None: + self.config.data_root.mkdir(parents=True, exist_ok=True) + self.config.readiness_path.parent.mkdir(parents=True, exist_ok=True) + self._health_server.start() + self.write_readiness() + + def close(self) -> None: + self._health_server.close() + + def write_readiness(self) -> dict[str, Any]: + now = self.clock().astimezone(timezone.utc) + valid_until = now + timedelta(seconds=self.config.readiness_ttl_seconds) + snapshot = self.state() + self._readiness_sequence += 1 + body: dict[str, Any] = { + "schema": CORE_READINESS_SCHEMA, + "service_name": SERVICE_NAME, + "release_id": self.config.release_id, + "instance_id": self.instance_id, + "process_id": os.getpid(), + "generation": self._readiness_sequence, + "observed_at": _format_utc(now), + "valid_until": _format_utc(valid_until), + "endpoint": { + "transport": "http", + "host": "127.0.0.1", + "port": self._health_server.port, + "base_path": "/", + }, + "fund_lock_sha256": self.config.fund_lock_sha256, + "service_manifest_sha256": self.config.service_manifest_sha256, + "authority": { + "broker": "ROBINHOOD", + "mode": snapshot.mode, + "execution_enabled": snapshot.execution_enabled, + }, + "health": { + "status": snapshot.health_status, + "reason_codes": [snapshot.reason], + "core_ready": snapshot.core_ready, + "reconciled_once": snapshot.reconciled_once, + "last_cycle_status": snapshot.last_cycle_status, + "command_sequence": snapshot.command_sequence, + "command_digest": snapshot.command_digest, + "rollback_anchor_current": snapshot.rollback_anchor_current, + "runner_config_sha256": self.config.config_sha256, + "readiness_public_key_sha256": ( + self.config.readiness_signer_public_key_sha256 + ), + "codex_executable_sha256": (self.config.codex_executable_sha256), + "codex_local_provider": LOCAL_MODEL_PROVIDER, + "codex_local_model": self.config.codex_local_model, + "codex_local_model_digest": (self.config.codex_local_model_digest), + "ollama_executable_sha256": (self.config.ollama_executable_sha256), + "ollama_process_identity_sid": ( + self.config.ollama_process_identity_sid + ), + "ollama_runtime_evidence_sha256": ( + self.config.ollama_runtime_evidence_sha256 + ), + }, + "capabilities": [ + "authenticated-promotion-resolution", + "broker-reconciliation", + "cell-command-checkpoints", + "core-journal-head-anchors", + "single-at-most-once-mcp-boundary", + ], + } + unsigned: dict[str, Any] = { + "schema": SIGNED_ENVELOPE_SCHEMA, + "source_id": SERVICE_NAME, + "source_sequence": self._readiness_sequence, + "correlation_id": f"readiness-{self.instance_id}", + "causation_id": None, + "nonce": hashlib.sha256( + canonical_json_bytes( + { + "instance_id": self.instance_id, + "sequence": self._readiness_sequence, + "observed_at": body["observed_at"], + } + ) + ).hexdigest(), + "not_before": body["observed_at"], + "expires_at": body["valid_until"], + "body_schema": CORE_READINESS_SCHEMA, + "body_digest": hashlib.sha256(canonical_json_bytes(body)).hexdigest(), + "body": body, + "signature_algorithm": "Ed25519", + "signer_key_id": (self.config.readiness_signer_public_key_sha256), + } + unsigned["event_id"] = hashlib.sha256( + canonical_json_bytes(unsigned) + ).hexdigest() + signature = self._signer.sign(canonical_json_bytes(unsigned)) + envelope = { + **unsigned, + "signature": base64.urlsafe_b64encode(signature) + .decode("ascii") + .rstrip("="), + } + _atomic_write_json(self.config.readiness_path, envelope) + return envelope + + def _anchor_local_state(self, client: CoreJournalAnchorClient) -> None: + try: + command_sequence, command_digest = command_feed_head( + self.config.data_root / "control" / "command-feed-state.v1.json" + ) + live_records = read_ledger(ledger_path(self.config.data_root)) + paper_records = read_ledger(paper_twin_path(self.config.data_root)) + except (DumbMoneyCommandFeedError, LiveBranchLedgerError) as error: + raise JournalAnchorError( + "local journal integrity verification failed" + ) from error + heads = ( + ( + "command-feed", + COMMAND_STATE_SCHEMA, + command_sequence, + command_digest, + ), + ( + "live-branch-ledger", + LEDGER_SCHEMA, + len(live_records), + ( + str(live_records[-1]["record_hash"]) + if live_records + else GENESIS_HASH + ), + ), + ( + "paper-twin-ledger", + LEDGER_SCHEMA, + len(paper_records), + ( + str(paper_records[-1]["record_hash"]) + if paper_records + else GENESIS_HASH + ), + ), + ) + for name, schema, sequence, digest in heads: + client.anchor( + journal_name=name, + journal_schema=schema, + journal_sequence=sequence, + journal_head_sha256=digest, + ) + + def run_once(self) -> dict[str, Any]: + now = self.clock().astimezone(timezone.utc) + command_refresh: Callable[[datetime], DumbMoneyCommandDispatch] | None = None + promotion_resolver: DumbMoneyContractResolverClient | None = None + journal_anchor_client: CoreJournalAnchorClient | None = None + core_ready = False + try: + endpoint = _core_endpoint_from_readiness(self.config, now) + _core_liveness(self.transport, endpoint) + journal_anchor_client = self._journal_anchor_factory(endpoint.base_url) + self._anchor_local_state(journal_anchor_client) + command_client = DumbMoneyCommandFeedClient( + base_url=endpoint.base_url, + cell_bearer_provider=lambda: self.cell_token, + trusted_public_key=self.config.core_public_key, + trusted_operator_public_keys=self.config.operator_public_keys, + expected_account_hash=self.config.expected_account_hash, + state_path=( + self.config.data_root / "control" / "command-feed-state.v1.json" + ), + transport=self.transport, + ) + dispatch = command_client.poll_once(now) + self._anchor_local_state(journal_anchor_client) + command_refresh = command_client.poll_once + promotion_resolver = DumbMoneyContractResolverClient( + base_url=endpoint.base_url, + cell_bearer_provider=lambda: self.cell_token, + trusted_core_public_key=self.config.core_public_key, + trusted_promoter_public_keys=self.config.promoter_public_keys, + trusted_research_public_keys=self.config.research_public_keys, + trusted_evaluator_public_keys=self.config.evaluator_public_keys, + transport=self.transport, + ) + core_ready = ( + dispatch.caught_up + and dispatch.required_action == "APPLY_SIGNED_CONTROLS" + ) + except JournalAnchorError as error: + self._set_state( + mode=START_MODE, + health_status="BLOCKED", + reason=f"rollback_anchor_rejected:{type(error).__name__}", + core_ready=False, + reconciled_once=False, + execution_enabled=False, + last_cycle_status="blocked_rollback_anchor", + rollback_anchor_current=False, + ) + self.write_readiness() + return { + "cycle_status": "blocked_rollback_anchor", + "orders": [], + "broker_contacted": False, + } + except Exception as error: + dispatch = _blocked_dispatch( + f"core_readiness_or_transport_failed:{type(error).__name__}" + ) + prior = self.state() + execute_orders = ( + prior.reconciled_once + and prior.rollback_anchor_current + and core_ready + and dispatch.allow_new_risk + and not dispatch.kill_active + and command_refresh is not None + and promotion_resolver is not None + ) + result = self.cycle_runner( + self.config.data_root, + self.gateway, + now, + execute_orders, + dumbmoney_controls=dispatch, + dumbmoney_control_refresh=command_refresh, + dumbmoney_promotion_resolver=promotion_resolver, + authority_clock=self.clock, + live_branch_control=_service_control(self.config.config_sha256), + ) + rollback_anchor_current = False + rollback_anchor_error = "" + if journal_anchor_client is not None: + try: + self._anchor_local_state(journal_anchor_client) + rollback_anchor_current = True + except JournalAnchorError as error: + rollback_anchor_error = ( + f"rollback_anchor_rejected:{type(error).__name__}" + ) + cycle_status = str(result.get("cycle_status", "")) + preflight_reconciled = ( + cycle_status == "preflight_complete" + and not result.get("order_history_blocker") + and isinstance(result.get("account"), dict) + and result["account"].get("authenticated") is True + and result["account"].get("account_hash") + == self.config.expected_account_hash + ) + ambiguous_or_cancel = ( + "ambiguous" in cycle_status + or cycle_status.startswith("cancel_only") + or any( + "ambiguous" in str(order.get("status", "")) + for order in result.get("orders", []) + if isinstance(order, dict) + ) + ) + reconciled = ( + False + if ambiguous_or_cancel + else prior.reconciled_once or preflight_reconciled + ) + execution_enabled = ( + reconciled + and core_ready + and rollback_anchor_current + and dispatch.allow_new_risk + and not dispatch.kill_active + and not ambiguous_or_cancel + ) + healthy_statuses = { + "complete", + "market_closed_monitoring_complete", + "preflight_complete", + } + health_status = ( + "READY" + if execution_enabled and cycle_status in healthy_statuses + else "BLOCKED" + if rollback_anchor_error + else "DEGRADED" + ) + reason = ( + rollback_anchor_error + if rollback_anchor_error + else "ALL_CURRENT_CONTROLS_ALLOW" + if execution_enabled + else dispatch.fail_closed_reason + or cycle_status + or "RECONCILIATION_REQUIRED" + ) + self._set_state( + mode="AGGRESSIVE_BOUNDED" if execution_enabled else START_MODE, + health_status=health_status, + reason=reason, + core_ready=core_ready, + reconciled_once=reconciled, + execution_enabled=execution_enabled, + last_cycle_status=cycle_status or "unknown", + command_sequence=dispatch.next_sequence, + command_digest=dispatch.next_digest, + rollback_anchor_current=rollback_anchor_current, + ) + self.write_readiness() + return result + + def run_forever(self, stop_event: threading.Event) -> None: + self.start() + try: + while not stop_event.is_set(): + if stop_event.wait(self.config.poll_interval_seconds): + break + try: + self.run_once() + except Exception as error: + self._set_state( + mode=START_MODE, + health_status="BLOCKED", + reason=f"cycle_failed_closed:{type(error).__name__}", + core_ready=False, + reconciled_once=False, + execution_enabled=False, + last_cycle_status="cycle_failed_closed", + rollback_anchor_current=False, + ) + self.write_readiness() + finally: + self.close() + + +def _format_utc(value: datetime) -> str: + utc = value.astimezone(timezone.utc) + rendered = utc.isoformat(timespec="microseconds" if utc.microsecond else "seconds") + return rendered.replace("+00:00", "Z") + + +def _atomic_write_json(path: Path, payload: dict[str, Any]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + temporary = path.with_name(f".{path.name}.{os.getpid()}.{uuid.uuid4().hex}.tmp") + encoded = canonical_json_bytes(payload) + b"\n" + try: + with temporary.open("xb") as handle: + handle.write(encoded) + handle.flush() + os.fsync(handle.fileno()) + os.replace(temporary, path) + finally: + try: + temporary.unlink(missing_ok=True) + except OSError: + pass + + +class _DataRootLease: + def __init__(self, path: Path) -> None: + self.path = path + self.handle: Any = None + + def __enter__(self) -> "_DataRootLease": + self.path.parent.mkdir(parents=True, exist_ok=True) + self.handle = self.path.open("a+b") + try: + self.handle.seek(0) + import msvcrt + + msvcrt.locking(self.handle.fileno(), msvcrt.LK_NBLCK, 1) + except (OSError, ImportError) as error: + self.handle.close() + raise DumbMoneyWindowsServiceError( + "another Dopey service owns the ProgramData root" + ) from error + return self + + def __exit__(self, *_args: Any) -> None: + if self.handle is None or self.handle.closed: + return + try: + self.handle.seek(0) + import msvcrt + + msvcrt.locking(self.handle.fileno(), msvcrt.LK_UNLCK, 1) + finally: + self.handle.close() + + +def _program_data_path() -> Path: + value = os.environ.get("PROGRAMDATA", "") + if not value: + raise DumbMoneyWindowsServiceError( + "PROGRAMDATA is required for the sealed Windows service" + ) + path = Path(value).resolve() + if not path.is_absolute(): + raise DumbMoneyWindowsServiceError("PROGRAMDATA must be absolute") + return path + + +def _parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + prog=SERVICE_NAME, + description="Sealed DumbMoney Robinhood venue-cell service", + allow_abbrev=False, + ) + parser.add_argument("--core-endpoint-ref", required=True) + parser.add_argument("--core-cell-token-target", required=True) + parser.add_argument("--robinhood-profile-target", required=True) + parser.add_argument("--start-mode", required=True) + parser.add_argument("--readiness-ref", required=True) + parser.add_argument("--config-sha256", required=True) + return parser + + +def _validate_cli(arguments: argparse.Namespace) -> None: + expected = { + "core_endpoint_ref": CORE_ENDPOINT_REF, + "core_cell_token_target": CELL_TOKEN_TARGET_REF, + "robinhood_profile_target": ROBINHOOD_PROFILE_TARGET_REF, + "start_mode": START_MODE, + "readiness_ref": READINESS_REF, + } + for field, value in expected.items(): + if getattr(arguments, field) != value: + raise DumbMoneyWindowsServiceError( + f"sealed service argument mismatch: {field}" + ) + _digest(arguments.config_sha256, "--config-sha256") + + +def main(argv: list[str] | None = None) -> int: + """Synchronous PyInstaller-compatible service entry point.""" + + try: + arguments = _parser().parse_args(argv) + _validate_cli(arguments) + program_data = _program_data_path() + config_path = (program_data / CONFIG_RELATIVE_PATH).resolve() + config = load_runner_config( + config_path, + arguments.config_sha256, + program_data, + ) + cell_token, robinhood_profile = load_secrets(WindowsCredentialManager()) + parent_mcp = DeterministicRobinhoodMcpClient( + robinhood_profile, + config.expected_account_hash, + ) + gateway = RobinhoodAgenticGateway( + subprocess.run, + 60, + write_timeout_seconds=30, + temporary_root=config.data_root / "temp", + codex_executable_path=config.codex_executable_path, + codex_executable_sha256=config.codex_executable_sha256, + local_model=config.codex_local_model, + local_model_verifier=PinnedOllamaModelVerifier( + config.codex_local_model, + config.codex_local_model_digest, + config.ollama_executable_path, + config.ollama_executable_sha256, + config.ollama_process_identity_sid, + config.data_root / OLLAMA_EVIDENCE_RELATIVE_PATH, + config.ollama_runtime_evidence_sha256, + LoopbackHttpTransport(), + ), + expected_account_hash=config.expected_account_hash, + deterministic_mcp_call=parent_mcp, + ) + service = DumbMoneyDopeyWindowsService( + config, + cell_token, + robinhood_profile, + transport=LoopbackHttpTransport(), + gateway=gateway, + ) + stop_event = threading.Event() + + def stop(_signum: int, _frame: Any) -> None: + stop_event.set() + + for signal_name in ("SIGINT", "SIGTERM"): + candidate = getattr(signal, signal_name, None) + if candidate is not None: + signal.signal(candidate, stop) + with _DataRootLease(config.data_root / "service.lock"): + service.run_forever(stop_event) + return 0 + except KeyboardInterrupt: + return 0 + except (DumbMoneyWindowsServiceError, OSError, ValueError) as error: + print( + f"{SERVICE_NAME} failed closed: {type(error).__name__}", + file=sys.stderr, + ) + return 2 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/dopey_live/ledger.py b/dopey_live/ledger.py index 54056f5..abf02ec 100644 --- a/dopey_live/ledger.py +++ b/dopey_live/ledger.py @@ -305,6 +305,123 @@ def reserve_intent( return True +def activate_dumbmoney_fence( + repository_root: Path, + envelope_id: str, + event_id: str, + body_digest: str, + fencing_generation: int, + policy_epoch: int, +) -> dict[str, Any]: + """Persist the highest signed fund-control generation under the ledger lock. + + The existing local process and ledger locks prevent concurrent writers on + one host. This monotonic fence additionally rejects a stale control-plane + process even after it reacquires a local lock. + """ + if fencing_generation <= 0 or policy_epoch <= 0: + raise LiveBranchLedgerError( + "DumbMoney fence generation and policy epoch must be positive" + ) + for label, digest in ( + ("envelope_id", envelope_id), + ("event_id", event_id), + ("body_digest", body_digest), + ): + if not isinstance(digest, str) or len(digest) != 64 or digest.lower() != digest: + raise LiveBranchLedgerError( + f"DumbMoney fence {label} must be lowercase SHA-256 hex" + ) + try: + bytes.fromhex(digest) + except ValueError as error: + raise LiveBranchLedgerError( + f"DumbMoney fence {label} must be lowercase SHA-256 hex" + ) from error + path = ledger_path(repository_root) + with _exclusive_ledger_lock(path): + records = read_ledger(path) + latest: dict[str, Any] | None = None + for record in records: + if record.get("event_type") != "dumbmoney_fence_activated": + continue + payload = record.get("payload") + if isinstance(payload, dict): + latest = payload + if latest is not None: + latest_generation = int(latest.get("fencing_generation", 0)) + latest_epoch = int(latest.get("policy_epoch", 0)) + latest_envelope = str(latest.get("envelope_id", "")) + latest_event = str(latest.get("event_id", "")) + latest_body_digest = str(latest.get("body_digest", "")) + if fencing_generation < latest_generation: + raise LiveBranchLedgerError( + "DumbMoney CapitalEnvelopeV1 is fenced by a newer generation" + ) + if policy_epoch < latest_epoch: + raise LiveBranchLedgerError( + "DumbMoney CapitalEnvelopeV1 is fenced by a newer policy epoch" + ) + if fencing_generation == latest_generation: + if ( + envelope_id != latest_envelope + or event_id != latest_event + or body_digest != latest_body_digest + or policy_epoch != latest_epoch + ): + raise LiveBranchLedgerError( + "DumbMoney fence generation was reused with different authority" + ) + return dict(latest) + payload = { + "envelope_id": envelope_id, + "event_id": event_id, + "body_digest": body_digest, + "fencing_generation": fencing_generation, + "policy_epoch": policy_epoch, + } + _append_record_unlocked(path, "dumbmoney_fence_activated", payload) + return payload + + +def assert_current_dumbmoney_fence( + repository_root: Path, + envelope_id: str, + event_id: str, + body_digest: str, + fencing_generation: int, + policy_epoch: int, +) -> None: + path = ledger_path(repository_root) + with _exclusive_ledger_lock(path): + latest: dict[str, Any] | None = None + for record in read_ledger(path): + if record.get("event_type") != "dumbmoney_fence_activated": + continue + payload = record.get("payload") + if isinstance(payload, dict): + latest = payload + expected = ( + envelope_id, + event_id, + body_digest, + fencing_generation, + policy_epoch, + ) + observed = ( + str(latest.get("envelope_id", "")) if latest else "", + str(latest.get("event_id", "")) if latest else "", + str(latest.get("body_digest", "")) if latest else "", + int(latest.get("fencing_generation", 0)) if latest else 0, + int(latest.get("policy_epoch", 0)) if latest else 0, + ) + if observed != expected: + raise LiveBranchLedgerError( + "DumbMoney authority is no longer the active execution fence: " + f"expected={expected}, observed={observed}" + ) + + def _unresolved_submission_payload( records: tuple[dict[str, Any], ...], intent_id: str, diff --git a/dopey_live/models.py b/dopey_live/models.py index 8d917bc..e6e398b 100644 --- a/dopey_live/models.py +++ b/dopey_live/models.py @@ -25,6 +25,9 @@ class AccountSnapshot: equity_order_history_complete: bool option_order_history_complete: bool error: str + # SHA-256 of the broker account identifier. The raw identifier is retained + # only in the in-memory MCP attestation and is never serialized by DOPEY. + account_hash: str = "" def to_dict(self) -> dict[str, Any]: return asdict(self) @@ -83,6 +86,8 @@ class LiveExecutionIntent: execution_snapshot: EquityExecutionSnapshot | None option_contract: dict[str, Any] reason_codes: tuple[str, ...] + promotion_hash: str = "" + passport_hash: str = "" def to_dict(self) -> dict[str, Any]: return asdict(self) @@ -120,11 +125,12 @@ def live_execution_intent_from_dict(payload: dict[str, Any]) -> LiveExecutionInt if not isinstance(raw_option_contract, dict): raise ValueError("Option contract must be an object") migrated["option_contract"] = dict(raw_option_contract) + raw_limit_price = migrated.get( + "limit_price", + raw_option_contract.get("limit_price", 0.0), + ) migrated["limit_price"] = float( - migrated.get( - "limit_price", - raw_option_contract.get("limit_price", 0.0), - ) + 0.0 if raw_limit_price is None else raw_limit_price ) raw_execution_snapshot = migrated.get("execution_snapshot") if raw_execution_snapshot is None: @@ -146,6 +152,8 @@ def live_execution_intent_from_dict(payload: dict[str, Any]) -> LiveExecutionInt migrated["reason_codes"] = tuple( str(value) for value in migrated.get("reason_codes", ()) ) + migrated["promotion_hash"] = str(migrated.get("promotion_hash", "")) + migrated["passport_hash"] = str(migrated.get("passport_hash", "")) return LiveExecutionIntent(**migrated) @@ -165,3 +173,16 @@ class GatewayOrderResult: def to_dict(self) -> dict[str, Any]: return asdict(self) + + +@dataclass(frozen=True) +class GatewayCancelResult: + cancel_requested: bool + order_id: str + status: str + warnings: tuple[str, ...] + error: str + observed_tools: tuple[str, ...] + + def to_dict(self) -> dict[str, Any]: + return asdict(self) diff --git a/dopey_live/robinhood_gateway.py b/dopey_live/robinhood_gateway.py index 33cd208..9bf4245 100644 --- a/dopey_live/robinhood_gateway.py +++ b/dopey_live/robinhood_gateway.py @@ -1,7 +1,8 @@ -"""Official Robinhood Trading MCP connector mediated by an ephemeral Codex agent.""" +"""Robinhood connector with parent-only broker authority and legacy read-only Codex.""" from __future__ import annotations +import hashlib import json import logging import math @@ -10,7 +11,7 @@ import subprocess import tempfile from dataclasses import dataclass -from datetime import date, datetime +from datetime import date, datetime, timezone from decimal import Decimal, InvalidOperation from pathlib import Path from typing import Any, Callable, Final @@ -22,12 +23,20 @@ EQUITY_SNAPSHOT_SOURCE, EquityExecutionSnapshot, EquityQuoteSnapshot, + GatewayCancelResult, GatewayOrderResult, LiveExecutionIntent, ) MCP_SERVER_NAME: Final[str] = "robinhood-trading" +LOCAL_MODEL_PROVIDER: Final[str] = "ollama" +LOCAL_MODEL_BASE_URL: Final[str] = "http://127.0.0.1:11434/v1" +LOCAL_MODEL_PATTERN: Final[re.Pattern[str]] = re.compile( + r"^[a-z0-9][a-z0-9._-]*(?:/[a-z0-9][a-z0-9._-]*)*:" + r"[a-z0-9][a-z0-9._-]{0,63}$" +) +PRE_SUBMIT_SAFETY_MARGIN_SECONDS: Final[int] = 5 SYMBOL_PATTERN = re.compile(r"^[A-Z][A-Z0-9.]{0,9}$") DISABLED_CODEX_FEATURES: Final[tuple[str, ...]] = ( "apps", @@ -60,6 +69,13 @@ "get_portfolio", } ) +DETERMINISTIC_WRITE_TOOLS: Final[frozenset[str]] = frozenset( + { + "cancel_equity_order", + "place_equity_order", + "review_equity_order", + } +) PLACED_ORDER_STATUSES: Final[frozenset[str]] = frozenset( {"queued", "pending", "open", "confirmed", "partially_filled", "filled"} ) @@ -93,9 +109,7 @@ NOTIONAL_FIELDS: Final[frozenset[str]] = frozenset( {"amount", "dollar_amount", "notional", "notional_usd"} ) -LIMIT_PRICE_FIELDS: Final[frozenset[str]] = frozenset( - {"limit", "limit_price"} -) +LIMIT_PRICE_FIELDS: Final[frozenset[str]] = frozenset({"limit", "limit_price"}) ORDER_TYPE_FIELDS: Final[frozenset[str]] = frozenset({"order_type", "type"}) TIME_IN_FORCE_FIELDS: Final[frozenset[str]] = frozenset( {"time_in_force", "timeInForce"} @@ -118,6 +132,18 @@ ) ORDER_ID_FIELDS: Final[frozenset[str]] = frozenset({"order_id"}) ORDER_STATUS_FIELDS: Final[frozenset[str]] = frozenset({"status", "state"}) +CANCEL_ARGUMENT_FIELDS: Final[frozenset[str]] = ( + ACCOUNT_IDENTIFIER_FIELDS | ORDER_ID_FIELDS +) +CANCEL_RESULT_STATUSES: Final[frozenset[str]] = frozenset( + { + *RECOGNIZED_ORDER_STATUSES, + "canceling", + "cancelling", + "cancel_requested", + "not_open", + } +) ORDER_HISTORY_LIMIT: Final[int] = 1_000 LOGGER = logging.getLogger(__name__) Runner = Callable[..., subprocess.CompletedProcess[str]] @@ -131,6 +157,14 @@ class RobinhoodPlacementAmbiguousError(RobinhoodGatewayError): """Raised when an attempted placement cannot be proven placed or not placed.""" +class RobinhoodPreSubmitGuardError(RobinhoodGatewayError): + """Raised when authority is denied before the broker place call begins.""" + + +class RobinhoodCancellationAmbiguousError(RobinhoodGatewayError): + """Raised when an attempted cancellation cannot be proven requested or absent.""" + + class RobinhoodAuthRequiredError(RobinhoodGatewayError): """The MCP server refused the handshake for lack of OAuth - operator must re-login. @@ -424,9 +458,7 @@ def _single_decimal_field( field_label: str, ) -> Decimal: values = _recursive_field_values(value, field_names) - scalar_values = tuple( - item for item in values if not isinstance(item, (dict, list)) - ) + scalar_values = tuple(item for item in values if not isinstance(item, (dict, list))) decimals = {_decimal_value(item, field_label) for item in scalar_values} if len(decimals) != 1: raise RobinhoodGatewayError( @@ -490,7 +522,9 @@ def _agentic_account(calls: tuple[McpToolCallEvidence, ...]) -> AccountAttestati ACCOUNT_TYPE_FIELDS, "agentic_account_type", ) - label = sorted(labels, key=lambda item: ("agentic" not in item.casefold(), item))[0] + label = sorted( + labels, key=lambda item: ("agentic" not in item.casefold(), item) + )[0] matches.append( AccountAttestation( account_id=account_id, @@ -606,14 +640,24 @@ def _field_values_equal(field: str, normalized: Any, raw: Any) -> bool: "average_fill_price", "bid_price", "filled_quantity", + "fees_usd", "last_price", + "limit_price", "market_price", "market_value", + "notional_usd", "quantity", "strike", "unrealized_pnl", } - casefold_fields = {"option_type", "side", "status", "symbol"} + casefold_fields = { + "option_type", + "order_type", + "side", + "status", + "symbol", + "time_in_force", + } if field in numeric_fields: try: return _decimal_value(normalized, field) == _decimal_value(raw, field) @@ -621,7 +665,7 @@ def _field_values_equal(field: str, normalized: Any, raw: Any) -> bool: return False if field in casefold_fields: return str(normalized).strip().casefold() == str(raw).strip().casefold() - return normalized == raw + return bool(normalized == raw) def _bind_normalized_items( @@ -638,7 +682,9 @@ def _bind_normalized_items( ) def identity(item: dict[str, Any]) -> tuple[str, ...]: - values = tuple(str(item.get(field, "")).strip().casefold() for field in identity_fields) + values = tuple( + str(item.get(field, "")).strip().casefold() for field in identity_fields + ) if any(not value for value in values): raise RobinhoodGatewayError( f"Robinhood MCP item identity is incomplete: item={item_label}" @@ -661,13 +707,13 @@ def identity(item: dict[str, Any]) -> tuple[str, ...]: f"Robinhood normalized response contains a duplicate item: item={item_label}" ) observed_normalized.add(normalized_identity) - raw_item = raw_by_identity.get(normalized_identity) - if raw_item is None: + matched_raw_item = raw_by_identity.get(normalized_identity) + if matched_raw_item is None: raise RobinhoodGatewayError( f"Robinhood normalized item is absent from the MCP response: item={item_label}" ) for field in required_fields: - if field not in normalized_item or field not in raw_item: + if field not in normalized_item or field not in matched_raw_item: raise RobinhoodGatewayError( "Robinhood normalized or raw item lacks a required field: " f"item={item_label}, field={field}" @@ -675,7 +721,7 @@ def identity(item: dict[str, Any]) -> tuple[str, ...]: if not _field_values_equal( field, normalized_item[field], - raw_item[field], + matched_raw_item[field], ): raise RobinhoodGatewayError( "Robinhood normalized item differs from the MCP response: " @@ -683,6 +729,42 @@ def identity(item: dict[str, Any]) -> tuple[str, ...]: ) +def _bind_optional_item_fields( + normalized_items: tuple[dict[str, Any], ...], + raw_items: tuple[dict[str, Any], ...], + identity_field: str, + optional_fields: tuple[str, ...], + item_label: str, +) -> None: + raw_by_identity = { + str(item.get(identity_field, "")).strip(): item for item in raw_items + } + for normalized in normalized_items: + identity = str(normalized.get(identity_field, "")).strip() + raw = raw_by_identity.get(identity) + if raw is None: + raise RobinhoodGatewayError( + f"Robinhood optional-field identity is absent: item={item_label}" + ) + for field in optional_fields: + normalized_has = field in normalized + raw_has = field in raw + if normalized_has is not raw_has: + raise RobinhoodGatewayError( + "Robinhood normalized optional economics differ from MCP evidence: " + f"item={item_label}, field={field}" + ) + if normalized_has and not _field_values_equal( + field, + normalized[field], + raw[field], + ): + raise RobinhoodGatewayError( + "Robinhood normalized optional economics differ from MCP evidence: " + f"item={item_label}, field={field}" + ) + + def _attest_order_history_results( calls: tuple[McpToolCallEvidence, ...], ) -> tuple[bool, bool]: @@ -728,8 +810,7 @@ def _attest_order_history_results( frozenset({"next", "next_cursor"}), ) has_continuation = any( - value is not None and str(value).strip() - for value in continuation_values + value is not None and str(value).strip() for value in continuation_values ) has_filter = any( key not in ACCOUNT_IDENTIFIER_FIELDS @@ -775,8 +856,9 @@ def _canonical_raw_broker_item( ), "market_price": raw_item.get("market_price", 0.0), "market_value": raw_item.get("market_value", 0.0), - "unrealized_pnl": raw_item.get("unrealized_pnl", 0.0), } + if "unrealized_pnl" in raw_item: + canonical["unrealized_pnl"] = raw_item["unrealized_pnl"] if item_label == "option_position": canonical["contract_id"] = raw_item.get( "contract_id", @@ -814,6 +896,19 @@ def _canonical_raw_broker_item( _market_date_from_timestamp(filled_at or submitted_at), ), } + optional_aliases = { + "order_type": ("order_type", "type"), + "time_in_force": ("time_in_force", "timeInForce"), + "limit_price": ("limit_price", "limit", "price"), + "quantity": ("quantity", "asset_quantity", "share_quantity", "shares"), + "notional_usd": ("notional_usd", "notional", "amount", "dollar_amount"), + "fees_usd": ("fees_usd", "fees", "total_fees"), + } + for canonical_field, aliases in optional_aliases.items(): + for alias in aliases: + if alias in raw_item: + canonical[canonical_field] = raw_item[alias] + break if item_label == "option_order": canonical.update( { @@ -835,6 +930,98 @@ def _canonical_raw_broker_item( return canonical +def _strict_alias_text( + raw_item: dict[str, Any], + aliases: tuple[str, ...], + field_label: str, +) -> str: + values = [ + str(raw_item[alias]).strip() + for alias in aliases + if alias in raw_item and str(raw_item[alias]).strip() + ] + normalized = {value for value in values} + if len(normalized) != 1: + raise RobinhoodGatewayError( + "Deterministic Robinhood result has a missing or conflicting " + f"text field: field={field_label}" + ) + return next(iter(normalized)) + + +def _strict_alias_number( + raw_item: dict[str, Any], + aliases: tuple[str, ...], + field_label: str, +) -> Any: + values = [ + raw_item[alias] + for alias in aliases + if alias in raw_item and raw_item[alias] is not None + ] + if not values: + raise RobinhoodGatewayError( + f"Deterministic Robinhood result is missing field={field_label}" + ) + decimals = {_decimal_value(value, field_label) for value in values} + if len(decimals) != 1: + raise RobinhoodGatewayError( + "Deterministic Robinhood result has conflicting numeric aliases: " + f"field={field_label}" + ) + return values[0] + + +def _strict_parent_position( + raw_item: dict[str, Any], + *, + option: bool, +) -> dict[str, Any]: + if not isinstance(raw_item, dict): + raise RobinhoodGatewayError( + "Deterministic Robinhood position item must be an object" + ) + canonical: dict[str, Any] = { + "symbol": _strict_alias_text( + raw_item, + ("symbol", "chain_symbol"), + "position.symbol", + ), + "quantity": _strict_alias_number( + raw_item, + ("quantity",), + "position.quantity", + ), + "average_cost": _strict_alias_number( + raw_item, + ("average_cost", "average_buy_price", "average_price"), + "position.average_cost", + ), + "market_price": _strict_alias_number( + raw_item, + ("market_price",), + "position.market_price", + ), + "market_value": _strict_alias_number( + raw_item, + ("market_value",), + "position.market_value", + ), + "unrealized_pnl": _strict_alias_number( + raw_item, + ("unrealized_pnl",), + "position.unrealized_pnl", + ), + } + if option: + canonical["contract_id"] = _strict_alias_text( + raw_item, + ("contract_id", "option_id", "instrument_id"), + "position.contract_id", + ) + return canonical + + def _direct_records_with_fields( value: Any, required_fields: frozenset[str], @@ -864,7 +1051,9 @@ def _requested_symbols(arguments: dict[str, Any]) -> frozenset[str]: raise RobinhoodGatewayError( "Robinhood quote request symbol arguments must be strings" ) - symbols.update(str(value).strip().upper() for value in values if str(value).strip()) + symbols.update( + str(value).strip().upper() for value in values if str(value).strip() + ) if not symbols: raise RobinhoodGatewayError( "Robinhood quote request does not expose requested symbols" @@ -898,9 +1087,7 @@ def _attest_equity_execution_snapshot( ) quote_records = _direct_records_with_fields( _mcp_result_value(quote_calls[0]), - frozenset( - {"symbol", "bid_price", "ask_price", "last_price", "observed_at"} - ), + frozenset({"symbol", "bid_price", "ask_price", "last_price", "observed_at"}), ) _bind_normalized_items( normalized_quotes, @@ -1064,6 +1251,20 @@ def _validated_order(item: Any, index: int, option: bool) -> dict[str, Any]: raise RobinhoodGatewayError( f"Robinhood filled order average price must be positive: item={item_label}" ) + for field in ("limit_price", "quantity", "notional_usd", "fees_usd"): + if field in item: + value = _required_finite_item_number(item, field, item_label, True) + if value < 0.0: + raise RobinhoodGatewayError( + f"Robinhood broker order economics must be nonnegative: " + f"item={item_label}, field={field}" + ) + for field in ("order_type", "time_in_force"): + if field in item and not str(item.get(field, "")).strip(): + raise RobinhoodGatewayError( + f"Robinhood broker order field must be nonempty: " + f"item={item_label}, field={field}" + ) try: date.fromisoformat(str(item.get("market_date", ""))) except ValueError as error: @@ -1191,18 +1392,26 @@ def _attest_exact_order_fields(value: Any, intent: LiveExecutionIntent) -> None: def _attest_order_call( call: McpToolCallEvidence, intent: LiveExecutionIntent, - account_id: str, + account_id: str | None, ) -> dict[str, Any] | list[Any]: _attest_order_argument_allowlist(call.arguments, call.tool) - observed_account_id = _single_string_field( - call.arguments, - ACCOUNT_IDENTIFIER_FIELDS, - f"{call.tool}_account_id", - ) - if observed_account_id != account_id: - raise RobinhoodGatewayError( - f"Robinhood order call is not bound to the labeled Agentic account: tool={call.tool}" + if account_id is None: + if _recursive_field_values(call.arguments, ACCOUNT_IDENTIFIER_FIELDS): + raise RobinhoodGatewayError( + "Deterministic Robinhood order call unexpectedly supplied an " + f"account identifier: tool={call.tool}" + ) + else: + observed_account_id = _single_string_field( + call.arguments, + ACCOUNT_IDENTIFIER_FIELDS, + f"{call.tool}_account_id", ) + if observed_account_id != account_id: + raise RobinhoodGatewayError( + "Robinhood order call is not bound to the labeled Agentic " + f"account: tool={call.tool}" + ) _attest_exact_order_fields(call.arguments, intent) result_value = _mcp_result_value(call) _attest_exact_order_fields(result_value, intent) @@ -1275,9 +1484,20 @@ def _codex_exec_command( working_directory: str, result_path: Path, allowed_tools: tuple[str, ...], + bearer_token_env_var: str | None = None, + local_model: str | None = None, ) -> list[str]: if not allowed_tools: raise ValueError("allowed_tools must contain at least one MCP tool") + if bearer_token_env_var is not None: + raise ValueError( + "Broker bearer material is prohibited from generative subprocesses" + ) + if local_model is not None and ( + not LOCAL_MODEL_PATTERN.fullmatch(local_model) + or local_model.endswith((":latest", ":cloud")) + ): + raise ValueError("local_model must be an explicit non-cloud Ollama tag") enabled_tools = json.dumps( sorted(set(allowed_tools)), ensure_ascii=True, @@ -1296,20 +1516,47 @@ def _codex_exec_command( "--color", "never", "--json", - "-c", - f'mcp_servers.{MCP_SERVER_NAME}.url="{OFFICIAL_ROBINHOOD_MCP}"', - "-c", - f"mcp_servers.{MCP_SERVER_NAME}.enabled_tools={enabled_tools}", - "-c", - f'mcp_servers.{MCP_SERVER_NAME}.default_tools_approval_mode="writes"', ] - for tool in sorted(set(allowed_tools) & READ_ONLY_ROBINHOOD_TOOLS): + if local_model is not None: + command.extend( + ( + "--oss", + "--local-provider", + LOCAL_MODEL_PROVIDER, + "--model", + local_model, + "-c", + ( + f"model_providers.{LOCAL_MODEL_PROVIDER}.base_url=" + f'"{LOCAL_MODEL_BASE_URL}"' + ), + ) + ) + if local_model is None: command.extend( ( "-c", - f'mcp_servers.{MCP_SERVER_NAME}.tools.{tool}.approval_mode="approve"', + f'mcp_servers.{MCP_SERVER_NAME}.url="{OFFICIAL_ROBINHOOD_MCP}"', ) ) + command.extend( + ( + "-c", + f"mcp_servers.{MCP_SERVER_NAME}.enabled_tools={enabled_tools}", + "-c", + f'mcp_servers.{MCP_SERVER_NAME}.default_tools_approval_mode="writes"', + ) + ) + for tool in sorted(set(allowed_tools) & READ_ONLY_ROBINHOOD_TOOLS): + command.extend( + ( + "-c", + ( + f"mcp_servers.{MCP_SERVER_NAME}.tools." + f'{tool}.approval_mode="approve"' + ), + ) + ) for feature in DISABLED_CODEX_FEATURES: command.extend(("--disable", feature)) command.extend( @@ -1324,6 +1571,93 @@ def _codex_exec_command( return command +def _direct_call_evidence( + tool: str, + arguments: dict[str, Any], + raw_result: dict[str, Any], +) -> McpToolCallEvidence: + if raw_result.get("isError") is True or raw_result.get("is_error") is True: + raise RobinhoodGatewayError( + f"Deterministic Robinhood MCP tool returned an error: tool={tool}" + ) + structured = raw_result.get( + "structuredContent", + raw_result.get("structured_content"), + ) + content = raw_result.get("content") + normalized: dict[str, Any] = {} + if isinstance(structured, (dict, list)): + normalized["structured_content"] = structured + if isinstance(content, list): + normalized["content"] = content + if not normalized: + raise RobinhoodGatewayError( + f"Deterministic Robinhood MCP result lacks typed evidence: tool={tool}" + ) + return McpToolCallEvidence( + call_id=f"deterministic-{tool}", + tool=tool, + arguments=dict(arguments), + result=normalized, + ) + + +def _direct_result_warnings(value: Any) -> tuple[str, ...]: + warnings: list[str] = [] + for item in _recursive_field_values( + value, + frozenset({"alerts", "warning", "warnings"}), + ): + if isinstance(item, str): + warnings.append(item) + elif isinstance(item, list) and all(isinstance(entry, str) for entry in item): + warnings.extend(item) + else: + raise RobinhoodGatewayError( + "Deterministic Robinhood MCP warnings are not typed strings" + ) + if len(warnings) > 64 or any(len(warning) > 2_048 for warning in warnings): + raise RobinhoodGatewayError( + "Deterministic Robinhood MCP warnings exceed bounded limits" + ) + return tuple(warnings) + + +def _optional_direct_decimal( + value: Any, + field_names: frozenset[str], + field_label: str, +) -> float: + values = _recursive_field_values(value, field_names) + scalar_values = tuple(item for item in values if not isinstance(item, (dict, list))) + if not scalar_values: + return 0.0 + decimals = {_decimal_value(item, field_label) for item in scalar_values} + if len(decimals) != 1: + raise RobinhoodGatewayError( + "Deterministic Robinhood MCP result contains an ambiguous number: " + f"field={field_label}" + ) + return float(next(iter(decimals))) + + +def _optional_direct_string( + value: Any, + field_names: frozenset[str], + field_label: str, +) -> str: + values = _recursive_field_values(value, field_names) + normalized = {str(item).strip() for item in values if str(item).strip()} + if not normalized: + return "" + if len(normalized) != 1: + raise RobinhoodGatewayError( + "Deterministic Robinhood MCP result contains ambiguous text: " + f"field={field_label}" + ) + return next(iter(normalized)) + + def _gateway_order_result( payload: dict[str, Any], review_tool: str, @@ -1538,20 +1872,691 @@ def _gateway_order_result( ) +def _gateway_cancel_result( + payload: dict[str, Any], + cancel_tool: str, + expected_order_id: str, + calls: tuple[McpToolCallEvidence, ...], + account_id: str, +) -> GatewayCancelResult: + warnings = payload.get("warnings") + if not isinstance(warnings, list) or not all( + isinstance(value, str) for value in warnings + ): + raise RobinhoodCancellationAmbiguousError( + "Robinhood Agentic cancellation warnings must be an array of strings" + ) + cancel_calls = _tool_calls(calls, cancel_tool) + if len(cancel_calls) > 1: + raise RobinhoodCancellationAmbiguousError( + "Robinhood MCP transcript contains duplicate cancellation calls" + ) + cancel_requested = payload.get("cancel_requested") + if type(cancel_requested) is not bool: + raise RobinhoodCancellationAmbiguousError( + "Robinhood cancellation result must contain boolean cancel_requested" + ) + order_id = str(payload.get("order_id", "")).strip() + status = str(payload.get("status", "")).strip().lower() + error_text = str(payload.get("error", "")).strip() + if order_id and order_id != expected_order_id: + raise RobinhoodCancellationAmbiguousError( + "Robinhood cancellation result references a different order" + ) + if status not in CANCEL_RESULT_STATUSES: + raise RobinhoodCancellationAmbiguousError( + f"Robinhood cancellation status is unrecognized: status={status!r}" + ) + if cancel_calls: + call = cancel_calls[0] + unexpected = sorted(set(call.arguments) - set(CANCEL_ARGUMENT_FIELDS)) + if unexpected: + raise RobinhoodCancellationAmbiguousError( + "Robinhood cancellation call contains unexpected arguments: " + f"unexpected={unexpected}" + ) + observed_account_id = _single_string_field( + call.arguments, + ACCOUNT_IDENTIFIER_FIELDS, + "cancel_account_id", + ) + observed_order_id = _single_string_field( + call.arguments, + ORDER_ID_FIELDS, + "cancel_order_id", + ) + if observed_account_id != account_id or observed_order_id != expected_order_id: + raise RobinhoodCancellationAmbiguousError( + "Robinhood cancellation call is not bound to the exact Agentic order" + ) + result_value = _mcp_result_value(call) + transcript_order_id = _single_string_field( + result_value, + ORDER_ID_FIELDS, + "cancel_result_order_id", + ) + transcript_status = _single_string_field( + result_value, + ORDER_STATUS_FIELDS, + "cancel_result_status", + ).lower() + if ( + transcript_order_id != expected_order_id + or transcript_status != status + or not cancel_requested + ): + raise RobinhoodCancellationAmbiguousError( + "Robinhood normalized cancellation result is not bound to the MCP transcript" + ) + elif cancel_requested: + raise RobinhoodCancellationAmbiguousError( + "Robinhood reported a cancellation request without a cancellation tool call" + ) + if cancel_requested and (not order_id or error_text): + raise RobinhoodCancellationAmbiguousError( + "Robinhood accepted cancellation with contradictory normalized fields" + ) + return GatewayCancelResult( + cancel_requested=cancel_requested, + order_id=order_id, + status=status, + warnings=tuple(warnings), + error=error_text, + observed_tools=tuple(sorted({call.tool for call in calls})), + ) + + class RobinhoodAgenticGateway: """Connector class; trading decisions remain in pure upstream functions.""" - def __init__(self, runner: Runner, timeout_seconds: int) -> None: + def __init__( + self, + runner: Runner, + timeout_seconds: int, + *, + write_timeout_seconds: int | None = None, + mcp_bearer_token: str | None = None, + temporary_root: Path | None = None, + codex_executable_path: Path | None = None, + codex_executable_sha256: str | None = None, + local_model: str | None = None, + local_model_verifier: Callable[[], None] | None = None, + expected_account_hash: str | None = None, + deterministic_mcp_call: ( + Callable[ + [str, dict[str, Any], int, Callable[[], None] | None], + dict[str, Any], + ] + | None + ) = None, + ) -> None: if timeout_seconds <= 0: raise ValueError(f"timeout_seconds must be positive: {timeout_seconds}") - executable = shutil.which("codex") - if executable is None: - raise RobinhoodGatewayError( - "Codex CLI is required for the Robinhood Trading MCP gateway" + if write_timeout_seconds is None: + write_timeout_seconds = min(30, timeout_seconds) + if ( + isinstance(write_timeout_seconds, bool) + or not isinstance(write_timeout_seconds, int) + or write_timeout_seconds <= 0 + or write_timeout_seconds > timeout_seconds + ): + raise ValueError( + "write_timeout_seconds must be a positive integer no greater " + "than timeout_seconds" + ) + if mcp_bearer_token is not None: + raise ValueError( + "Broker bearer material is prohibited from the generative gateway; " + "use the deterministic parent-side MCP transport" ) + if deterministic_mcp_call is not None and ( + not isinstance(expected_account_hash, str) + or not re.fullmatch(r"[0-9a-f]{64}", expected_account_hash) + ): + raise ValueError( + "Deterministic parent-side MCP requires the pinned account hash" + ) + if deterministic_mcp_call is None and expected_account_hash is not None: + raise ValueError( + "expected_account_hash requires deterministic parent-side MCP" + ) + if local_model is not None and ( + not LOCAL_MODEL_PATTERN.fullmatch(local_model) + or local_model.endswith((":latest", ":cloud")) + ): + raise ValueError("local_model must be an explicit non-cloud Ollama tag") + if (local_model is None) != (local_model_verifier is None): + raise ValueError( + "local_model and local_model_verifier must be supplied together" + ) + if codex_executable_path is None: + if codex_executable_sha256 is not None: + raise ValueError( + "codex_executable_sha256 requires codex_executable_path" + ) + executable = shutil.which("codex") + if executable is None: + raise RobinhoodGatewayError( + "Codex CLI is required for the Robinhood Trading MCP gateway" + ) + self._codex_sha256: str | None = None + else: + if not codex_executable_path.is_absolute(): + raise ValueError("Codex executable path must be absolute") + executable_path = codex_executable_path.resolve() + if not executable_path.is_file(): + raise RobinhoodGatewayError( + "Pinned Codex CLI executable is unavailable" + ) + if not isinstance(codex_executable_sha256, str) or not re.fullmatch( + r"[0-9a-f]{64}", codex_executable_sha256 + ): + raise ValueError("Pinned Codex executable requires lowercase SHA-256") + executable = str(executable_path) + self._codex_sha256 = codex_executable_sha256 self._runner = runner self._timeout_seconds = timeout_seconds + self._write_timeout_seconds = write_timeout_seconds self._codex = executable + self._verify_codex_executable() + self._local_model = local_model + self._local_model_verifier = local_model_verifier + self._deterministic_mcp_call = deterministic_mcp_call + self._parent_only_mode = deterministic_mcp_call is not None + self._expected_account_hash = expected_account_hash + self._temporary_root = ( + temporary_root.resolve() if temporary_root is not None else None + ) + if self._temporary_root is not None: + self._temporary_root.mkdir(parents=True, exist_ok=True) + self._bearer_token_env_var: str | None = None + self._subprocess_env: dict[str, str] | None = None + + def _verify_codex_executable(self) -> None: + if self._codex_sha256 is None: + return + digest = hashlib.sha256() + try: + with Path(self._codex).open("rb") as handle: + for chunk in iter(lambda: handle.read(1024 * 1024), b""): + digest.update(chunk) + except OSError as error: + raise RobinhoodGatewayError( + "Pinned Codex CLI executable cannot be read" + ) from error + if digest.hexdigest() != self._codex_sha256: + raise RobinhoodGatewayError("Pinned Codex CLI executable SHA-256 mismatch") + + def _verify_local_model(self) -> None: + if self._local_model_verifier is None: + return + try: + self._local_model_verifier() + except Exception as error: + raise RobinhoodGatewayError( + "Pinned local Ollama model verification failed" + ) from error + + def _call_deterministic_mcp( + self, + tool: str, + arguments: dict[str, Any], + timeout_seconds: int, + *, + pre_call_guard: Callable[[], None] | None = None, + ) -> McpToolCallEvidence: + if ( + self._deterministic_mcp_call is None + or tool not in DETERMINISTIC_WRITE_TOOLS + ): + raise RobinhoodGatewayError( + "Deterministic Robinhood MCP write transport is unavailable" + ) + try: + raw_result = self._deterministic_mcp_call( + tool, + dict(arguments), + timeout_seconds, + pre_call_guard, + ) + except RobinhoodGatewayError: + raise + except Exception as error: + raise RobinhoodGatewayError( + "Deterministic Robinhood MCP call failed: " + f"tool={tool}, error={type(error).__name__}" + ) from error + if not isinstance(raw_result, dict): + raise RobinhoodGatewayError( + f"Deterministic Robinhood MCP result is not an object: tool={tool}" + ) + return _direct_call_evidence(tool, arguments, raw_result) + + def _call_deterministic_read( + self, + tool: str, + arguments: dict[str, Any], + ) -> McpToolCallEvidence: + if ( + self._deterministic_mcp_call is None + or tool not in READ_ONLY_ROBINHOOD_TOOLS + ): + raise RobinhoodGatewayError( + "Deterministic parent-side Robinhood read transport is unavailable" + ) + try: + raw_result = self._deterministic_mcp_call( + tool, + dict(arguments), + self._timeout_seconds, + None, + ) + except RobinhoodGatewayError: + raise + except Exception as error: + raise RobinhoodGatewayError( + "Deterministic parent-side Robinhood read failed: " + f"tool={tool}, error={type(error).__name__}" + ) from error + if not isinstance(raw_result, dict): + raise RobinhoodGatewayError( + f"Deterministic Robinhood read result is not an object: tool={tool}" + ) + return _direct_call_evidence(tool, arguments, raw_result) + + def _get_account_snapshot_deterministically(self) -> AccountSnapshot: + calls: list[McpToolCallEvidence] = [ + self._call_deterministic_read("get_accounts", {}) + ] + account = _agentic_account(tuple(calls)) + observed_account_hash = hashlib.sha256( + account.account_id.encode("utf-8") + ).hexdigest() + if observed_account_hash != self._expected_account_hash: + raise RobinhoodGatewayError( + "Deterministic Robinhood read is not bound to the pinned " + "Agentic account" + ) + account_arguments = {"account_number": account.account_id} + for tool in ( + "get_portfolio", + "get_equity_positions", + "get_option_positions", + ): + calls.append(self._call_deterministic_read(tool, account_arguments)) + for tool in ("get_equity_orders", "get_option_orders"): + calls.append( + self._call_deterministic_read( + tool, + { + **account_arguments, + "limit": ORDER_HISTORY_LIMIT, + }, + ) + ) + evidence = tuple(calls) + attested_account = _attest_account_scope( + evidence, + ( + "get_portfolio", + "get_equity_positions", + "get_option_positions", + "get_equity_orders", + "get_option_orders", + ), + ) + portfolio = _mcp_result_value(_tool_calls(evidence, "get_portfolio")[0]) + buying_power = float( + _single_decimal_field( + portfolio, + frozenset({"buying_power", "buying_power_usd"}), + "buying_power_usd", + ) + ) + total_equity = float( + _single_decimal_field( + portfolio, + frozenset( + { + "equity", + "portfolio_value", + "total_equity", + "total_equity_usd", + "total_value", + } + ), + "total_equity_usd", + ) + ) + if ( + not math.isfinite(buying_power) + or buying_power < 0.0 + or not math.isfinite(total_equity) + or total_equity < 0.0 + ): + raise RobinhoodGatewayError( + "Deterministic Robinhood portfolio values must be finite " + "and nonnegative" + ) + equity_positions = tuple( + _validated_equity_position( + _strict_parent_position(item, option=False), + index, + ) + for index, item in enumerate( + _result_items( + _tool_calls(evidence, "get_equity_positions")[0], + frozenset({"equity_positions", "positions", "results"}), + "equity_position", + ) + ) + ) + option_positions = tuple( + _validated_option_position( + _strict_parent_position(item, option=True), + index, + ) + for index, item in enumerate( + _result_items( + _tool_calls(evidence, "get_option_positions")[0], + frozenset({"option_positions", "positions", "results"}), + "option_position", + ) + ) + ) + equity_orders = tuple( + _validated_order( + _canonical_raw_broker_item(item, "equity_order"), + index, + False, + ) + for index, item in enumerate( + _result_items( + _tool_calls(evidence, "get_equity_orders")[0], + frozenset({"equity_orders", "orders", "results"}), + "equity_order", + ) + ) + ) + option_orders = tuple( + _validated_order( + _canonical_raw_broker_item(item, "option_order"), + index, + True, + ) + for index, item in enumerate( + _result_items( + _tool_calls(evidence, "get_option_orders")[0], + frozenset({"option_orders", "orders", "results"}), + "option_order", + ) + ) + ) + equity_complete, option_complete = _attest_order_history_results(evidence) + errors: list[str] = [] + if not equity_complete: + errors.append("equity_order_history_incomplete") + if not option_complete: + errors.append("option_order_history_incomplete") + return AccountSnapshot( + authenticated=True, + account_type=attested_account.account_type, + buying_power_usd=buying_power, + total_equity_usd=total_equity, + equity_positions=equity_positions, + option_positions=option_positions, + recent_equity_orders=equity_orders, + recent_option_orders=option_orders, + equity_order_history_complete=equity_complete, + option_order_history_complete=option_complete, + error=";".join(errors), + account_hash=observed_account_hash, + ) + + def _get_equity_snapshot_deterministically( + self, + intent: LiveExecutionIntent, + ) -> EquityExecutionSnapshot: + expected_symbols = frozenset({intent.symbol, intent.analysis_symbol}) + arguments = {"symbols": sorted(expected_symbols)} + quote_call = self._call_deterministic_read( + "get_equity_quotes", + arguments, + ) + tradability_call = self._call_deterministic_read( + "get_equity_tradability", + arguments, + ) + quote_records = _direct_records_with_fields( + _mcp_result_value(quote_call), + frozenset( + { + "symbol", + "bid_price", + "ask_price", + "last_price", + "observed_at", + } + ), + ) + tradability_records = _direct_records_with_fields( + _mcp_result_value(tradability_call), + frozenset({"symbol", "tradable"}), + ) + quotes_by_symbol = { + str(item["symbol"]).strip().upper(): item for item in quote_records + } + tradability_by_symbol = { + str(item["symbol"]).strip().upper(): item for item in tradability_records + } + if ( + len(quotes_by_symbol) != len(quote_records) + or len(tradability_by_symbol) != len(tradability_records) + or frozenset(quotes_by_symbol) != expected_symbols + or frozenset(tradability_by_symbol) != expected_symbols + ): + raise RobinhoodGatewayError( + "Deterministic Robinhood quote response is missing, duplicated, " + "or contains an unrequested symbol" + ) + + def quote(symbol: str) -> EquityQuoteSnapshot: + raw_quote = quotes_by_symbol[symbol] + raw_tradability = tradability_by_symbol[symbol] + _aware_timestamp( + raw_quote["observed_at"], + "observed_at", + f"equity_quote[{symbol}]", + False, + ) + return _equity_quote_snapshot( + { + "quote": { + "symbol": symbol, + "bid_price": raw_quote["bid_price"], + "ask_price": raw_quote["ask_price"], + "last_price": raw_quote["last_price"], + "observed_at": raw_quote["observed_at"], + "tradable": raw_tradability["tradable"], + } + }, + "quote", + symbol, + ) + + snapshot = EquityExecutionSnapshot( + execution_quote=quote(intent.symbol), + analysis_quote=quote(intent.analysis_symbol), + source=EQUITY_SNAPSHOT_SOURCE, + ) + _attest_equity_execution_snapshot( + (quote_call, tradability_call), + intent, + snapshot, + ) + return snapshot + + @staticmethod + def _deterministic_equity_arguments( + intent: LiveExecutionIntent, + ) -> dict[str, Any]: + if intent.instrument != "equity": + raise RobinhoodGatewayError( + "Deterministic live execution is currently equity-only" + ) + if not math.isfinite(intent.quantity) or intent.quantity <= 0.0: + raise RobinhoodGatewayError( + "Deterministic equity execution requires an exact positive quantity" + ) + arguments: dict[str, Any] = { + "side": intent.side, + "symbol": intent.symbol, + "quantity": intent.quantity, + "order_type": intent.order_type, + "time_in_force": intent.time_in_force, + } + if intent.order_type == "limit": + arguments["limit_price"] = intent.limit_price + return arguments + + def _place_equity_order_deterministically( + self, + intent: LiveExecutionIntent, + pre_submit_guard: Callable[[], datetime] | None, + ) -> GatewayOrderResult: + arguments = self._deterministic_equity_arguments(intent) + review_call = self._call_deterministic_mcp( + "review_equity_order", + arguments, + self._write_timeout_seconds, + ) + review_result = _attest_order_call(review_call, intent, None) + review_succeeded = _single_bool_field( + review_result, + frozenset({"approved", "review_succeeded"}), + "review_succeeded", + ) + warnings = _direct_result_warnings(review_result) + if not review_succeeded: + return GatewayOrderResult( + review_succeeded=False, + placed=False, + order_id="", + status="blocked_by_broker_review", + filled_quantity=0.0, + average_fill_price=0.0, + submitted_at="", + filled_at="", + warnings=warnings, + error="broker_review_denied", + observed_tools=("review_equity_order",), + ) + try: + place_call = self._call_deterministic_mcp( + "place_equity_order", + arguments, + self._write_timeout_seconds, + pre_call_guard=( + None + if pre_submit_guard is None + else lambda: self._validate_pre_submit_guard(pre_submit_guard) + ), + ) + place_result = _attest_order_call(place_call, intent, None) + placed = _single_bool_field( + place_result, + frozenset({"placed"}), + "placed", + ) + order_id = _single_string_field( + place_result, + ORDER_ID_FIELDS, + "order_id", + ) + status = _single_string_field( + place_result, + ORDER_STATUS_FIELDS, + "status", + ).lower() + if not placed or status not in PLACED_ORDER_STATUSES: + raise RobinhoodGatewayError( + "Deterministic Robinhood placement result is not a " + "recognized placed order" + ) + return GatewayOrderResult( + review_succeeded=True, + placed=True, + order_id=order_id, + status=status, + filled_quantity=_optional_direct_decimal( + place_result, + frozenset({"filled_quantity"}), + "filled_quantity", + ), + average_fill_price=_optional_direct_decimal( + place_result, + frozenset({"average_fill_price"}), + "average_fill_price", + ), + submitted_at=_optional_direct_string( + place_result, + frozenset({"submitted_at"}), + "submitted_at", + ), + filled_at=_optional_direct_string( + place_result, + frozenset({"filled_at"}), + "filled_at", + ), + warnings=tuple( + dict.fromkeys((*warnings, *_direct_result_warnings(place_result))) + ), + error="", + observed_tools=( + "place_equity_order", + "review_equity_order", + ), + ) + except RobinhoodPreSubmitGuardError: + raise + except RobinhoodGatewayError as error: + raise RobinhoodPlacementAmbiguousError( + "Deterministic Robinhood placement has an unknown broker " + "outcome and must not be retried" + ) from error + + def _validate_pre_submit_guard( + self, + pre_submit_guard: Callable[[], datetime], + ) -> None: + try: + authority_deadline = pre_submit_guard() + except RobinhoodPreSubmitGuardError: + raise + except Exception as error: + raise RobinhoodPreSubmitGuardError( + "Final broker-sink pre-submit guard denied the write: " + f"{type(error).__name__}: {error}" + ) from error + if ( + not isinstance(authority_deadline, datetime) + or authority_deadline.tzinfo is None + or authority_deadline.utcoffset() is None + ): + raise RobinhoodPreSubmitGuardError( + "Final broker-sink guard must return an aware authority deadline" + ) + remaining_seconds = ( + authority_deadline.astimezone(timezone.utc) - datetime.now(timezone.utc) + ).total_seconds() + required_seconds = ( + self._write_timeout_seconds + PRE_SUBMIT_SAFETY_MARGIN_SECONDS + ) + if remaining_seconds <= required_seconds: + raise RobinhoodPreSubmitGuardError( + "Capital authority has insufficient remaining validity for " + "the bounded external write operation" + ) def _run( self, @@ -1560,13 +2565,37 @@ def _run( required_tools: tuple[str, ...], allowed_tools: tuple[str, ...], attempt_limit: int, + timeout_seconds: int | None = None, ) -> GatewayEnvelope: + if self._parent_only_mode: + raise RobinhoodGatewayError( + "Parent-only broker mode prohibits generative MCP fallback" + ) if attempt_limit <= 0: raise ValueError(f"attempt_limit must be positive: {attempt_limit}") + if self._bearer_token_env_var is not None and not set(allowed_tools).issubset( + READ_ONLY_ROBINHOOD_TOOLS + ): + forbidden = sorted(set(allowed_tools) - READ_ONLY_ROBINHOOD_TOOLS) + raise RobinhoodGatewayError( + "Credential-bound model execution may expose only read-only " + f"Robinhood tools: forbidden={forbidden}" + ) + effective_timeout = ( + self._timeout_seconds if timeout_seconds is None else timeout_seconds + ) + if effective_timeout <= 0 or effective_timeout > self._timeout_seconds: + raise ValueError( + "operation timeout must be positive and no greater than " + "the gateway timeout" + ) last_error = "" for attempt in range(1, attempt_limit + 1): + self._verify_codex_executable() + self._verify_local_model() with tempfile.TemporaryDirectory( - prefix="dopey-robinhood-agentic-" + prefix="dopey-robinhood-agentic-", + dir=self._temporary_root, ) as temporary: result_path = Path(temporary) / "result.json" command = _codex_exec_command( @@ -1574,6 +2603,8 @@ def _run( temporary, result_path, allowed_tools, + self._bearer_token_env_var, + self._local_model, ) try: completed = self._runner( @@ -1583,8 +2614,9 @@ def _run( encoding="utf-8", errors="strict", capture_output=True, - timeout=self._timeout_seconds, + timeout=effective_timeout, check=False, + env=self._subprocess_env, ) except (OSError, subprocess.TimeoutExpired) as error: last_error = f"{type(error).__name__}: {error}" @@ -1713,6 +2745,8 @@ def _run( ) def get_account_snapshot(self) -> AccountSnapshot: + if getattr(self, "_deterministic_mcp_call", None) is not None: + return self._get_account_snapshot_deterministically() prompt = """Standing owner authorization applies. Use only Robinhood Trading MCP tools get_accounts, get_portfolio, get_equity_positions, get_option_positions, get_equity_orders, and get_option_orders. Do not call any @@ -1726,9 +2760,14 @@ def get_account_snapshot(self) -> AccountSnapshot: of objects containing only symbol, contract_id, quantity, average_cost, market_price, market_value, and unrealized_pnl), recent_equity_orders (array containing only orders from the current and immediately prior Robinhood trading session with symbol, side, status, submitted_at, - filled_at, filled_quantity, average_fill_price, and market_date), recent_option_orders (the same - fields plus expiry, strike, option_type, and contract_id), and error (string). Use 0 or an empty array when a numeric or position value is -unavailable. Include equity_order_history_complete and option_order_history_complete booleans; set + filled_at, filled_quantity, average_fill_price, market_date, and every broker-exposed order_type, + time_in_force, limit_price, quantity, notional_usd, and fees_usd field), recent_option_orders (the same + fields plus expiry, strike, option_type, and contract_id), and error (string). Use an empty array +when a position collection is unavailable. Every nonzero position must retain broker-exposed +quantity, average_cost, market_price, market_value, and unrealized_pnl; if any one is unavailable, +leave it absent and explain the omission in error. fees_usd and order economics must likewise remain +absent when the MCP result does not expose them; never infer, zero-fill, or fabricate those fields. +Include equity_order_history_complete and option_order_history_complete booleans; set each true only when the corresponding order-history tool completed successfully and the returned array covers the current and immediately prior Robinhood trading session. Include order_id in every order object. Set account_type to the selected account's brokerage_account_type, never its @@ -1779,9 +2818,10 @@ def get_account_snapshot(self) -> AccountSnapshot: raise RobinhoodGatewayError( "Robinhood Agentic authenticated field must be boolean" ) - if not isinstance(payload.get("account_type"), str) or not str( - payload.get("account_type", "") - ).strip(): + if ( + not isinstance(payload.get("account_type"), str) + or not str(payload.get("account_type", "")).strip() + ): raise RobinhoodGatewayError( "Robinhood Agentic account_type must be a nonempty string" ) @@ -1792,9 +2832,10 @@ def get_account_snapshot(self) -> AccountSnapshot: raise RobinhoodGatewayError( "Robinhood normalized account_type is not bound to get_accounts" ) - if type(payload.get("equity_order_history_complete")) is not bool or type( - payload.get("option_order_history_complete") - ) is not bool: + if ( + type(payload.get("equity_order_history_complete")) is not bool + or type(payload.get("option_order_history_complete")) is not bool + ): raise RobinhoodGatewayError( "Robinhood Agentic order-history completeness fields must be boolean" ) @@ -1867,8 +2908,7 @@ def get_account_snapshot(self) -> AccountSnapshot: ) if ( payload["equity_order_history_complete"] is not equity_history_complete - or payload["option_order_history_complete"] - is not option_history_complete + or payload["option_order_history_complete"] is not option_history_complete ): raise RobinhoodGatewayError( "Robinhood normalized history completeness is not bound to MCP results" @@ -1931,30 +2971,32 @@ def get_account_snapshot(self) -> AccountSnapshot: "average_fill_price", "market_date", ) + raw_equity_orders = tuple( + _canonical_raw_broker_item(item, "equity_order") + for item in _result_items( + equity_order_call, + frozenset({"equity_orders", "orders", "results"}), + "equity_order", + ) + ) + raw_option_orders = tuple( + _canonical_raw_broker_item(item, "option_order") + for item in _result_items( + option_order_call, + frozenset({"option_orders", "orders", "results"}), + "option_order", + ) + ) _bind_normalized_items( validated_equity_orders, - tuple( - _canonical_raw_broker_item(item, "equity_order") - for item in _result_items( - equity_order_call, - frozenset({"equity_orders", "orders", "results"}), - "equity_order", - ) - ), + raw_equity_orders, ("order_id",), equity_order_fields, "equity_order", ) _bind_normalized_items( validated_option_orders, - tuple( - _canonical_raw_broker_item(item, "option_order") - for item in _result_items( - option_order_call, - frozenset({"option_orders", "orders", "results"}), - "option_order", - ) - ), + raw_option_orders, ("order_id",), ( *equity_order_fields, @@ -1965,6 +3007,28 @@ def get_account_snapshot(self) -> AccountSnapshot: ), "option_order", ) + optional_order_economics = ( + "order_type", + "time_in_force", + "limit_price", + "quantity", + "notional_usd", + "fees_usd", + ) + _bind_optional_item_fields( + validated_equity_orders, + raw_equity_orders, + "order_id", + optional_order_economics, + "equity_order", + ) + _bind_optional_item_fields( + validated_option_orders, + raw_option_orders, + "order_id", + optional_order_economics, + "option_order", + ) return AccountSnapshot( authenticated=payload.get("authenticated") is True, account_type=str(payload.get("account_type", "")), @@ -1979,6 +3043,9 @@ def get_account_snapshot(self) -> AccountSnapshot: option_order_history_complete=payload.get("option_order_history_complete") is True, error=str(payload.get("error", "")), + account_hash=hashlib.sha256( + account_attestation.account_id.encode("utf-8") + ).hexdigest(), ) def get_equity_execution_snapshot( @@ -1995,6 +3062,8 @@ def get_equity_execution_snapshot( raise RobinhoodGatewayError( "Equity execution snapshot requires valid execution and analysis symbols" ) + if getattr(self, "_deterministic_mcp_call", None) is not None: + return self._get_equity_snapshot_deterministically(intent) prompt = f"""Use only Robinhood Trading MCP tools get_equity_quotes and get_equity_tradability. Do not call account, portfolio, order-history, review, place, replace, cancel, watchlist, scan, shell, browser, search, or any other tool. Request current quotes and @@ -2016,8 +3085,84 @@ def get_equity_execution_snapshot( _attest_equity_execution_snapshot(envelope.calls, intent, snapshot) return snapshot + def cancel_order( + self, + instrument: str, + order_id: str, + ) -> GatewayCancelResult: + """Cancel one exact open order through the official Agentic MCP only.""" + normalized_order_id = order_id.strip() + if ( + not normalized_order_id + or len(normalized_order_id) > 256 + or any(character.isspace() for character in normalized_order_id) + ): + raise RobinhoodGatewayError("Cancellation requires a safe broker order_id") + if instrument == "equity": + cancel_tool = "cancel_equity_order" + elif instrument == "option": + cancel_tool = "cancel_option_order" + else: + raise RobinhoodGatewayError( + f"Unsupported cancellation instrument: {instrument!r}" + ) + if self._deterministic_mcp_call is None: + raise RobinhoodGatewayError( + "Live cancellation requires deterministic parent-side MCP" + ) + if instrument != "equity": + raise RobinhoodGatewayError( + "Deterministic live cancellation is currently equity-only" + ) + try: + call = self._call_deterministic_mcp( + cancel_tool, + {"order_id": normalized_order_id}, + self._write_timeout_seconds, + ) + result = _mcp_result_value(call) + observed_order_id = _single_string_field( + result, + ORDER_ID_FIELDS, + "order_id", + ) + if observed_order_id != normalized_order_id: + raise RobinhoodGatewayError( + "Deterministic Robinhood cancellation result changed " + "the exact order ID" + ) + status = _single_string_field( + result, + ORDER_STATUS_FIELDS, + "status", + ).lower() + if status not in RECOGNIZED_ORDER_STATUSES | { + "canceling", + "cancelling", + }: + raise RobinhoodGatewayError( + "Deterministic Robinhood cancellation status is unknown" + ) + return GatewayCancelResult( + cancel_requested=True, + order_id=normalized_order_id, + status=status, + warnings=_direct_result_warnings(result), + error="", + observed_tools=(cancel_tool,), + ) + except RobinhoodGatewayError as error: + raise RobinhoodCancellationAmbiguousError( + "Deterministic Robinhood cancellation has an unknown " + "broker outcome; poll order history before another attempt" + ) from error + def place_order( - self, intent: LiveExecutionIntent, paper_twin_record_hash: str + self, + intent: LiveExecutionIntent, + paper_twin_record_hash: str, + *, + pre_submit_guard: Callable[[], datetime] | None = None, ) -> GatewayOrderResult: if not SYMBOL_PATTERN.fullmatch(intent.symbol): raise RobinhoodGatewayError(f"Invalid order symbol: {intent.symbol!r}") @@ -2039,89 +3184,24 @@ def place_order( raise RobinhoodGatewayError( "Long-equity entries require a validated snapshot and positive limit_price" ) - if intent.instrument == "equity": - review_tool = "review_equity_order" - place_tool = "place_equity_order" - elif intent.instrument == "option": - review_tool = "review_option_order" - place_tool = "place_option_order" - else: + if intent.instrument not in {"equity", "option"}: raise RobinhoodGatewayError( f"Unsupported instrument: {intent.instrument!r}" ) - exact_intent = json.dumps(intent.to_dict(), sort_keys=True, ensure_ascii=False) - prompt = f"""Standing owner authorization applies to this exact DOPEY Live Branch order. -Use only Robinhood Trading MCP tools get_accounts, get_portfolio, get_equity_quotes, -get_equity_tradability, get_equity_orders, get_option_orders, {review_tool}, and {place_tool}. - Select only the dedicated Agentic trading account. The get_accounts result must expose exactly - one account explicitly labeled Agentic. Pass that exact account ID in every account, history, - review, and placement tool call. Before review, inspect today's equity and option -order history and stop if this exact order would -buy after a same-session sale or sell after a same-session purchase of the same stock or exact -option contract. If and only if the no-day-trade history check passes, the review succeeds, the - account has sufficient buying power/position, the instrument is - tradable, the order remains long-only or sell-to-close, and there is no broker pre-trade blocker, - place exactly the reviewed order without changing symbol, side, amount, contract, order type, or - time-in-force. For every equity buy, request fresh quotes for both symbol and analysis_symbol, - require the execution ask to be at or below the exact limit_price, and place only the exact limit - order. If the broker cannot express the exact amount as a limit order, return - blocked_exact_limit_order_unsupported without placing. When daily_reset is true, treat - analysis_symbol as the thesis authority and symbol only as the execution vehicle. Stop unless a - bullish analysis price is at or above analysis_reference_price, or a bearish analysis price is at - or below analysis_reference_price, and stop if the analysis invalidation or target has already - been crossed. Never infer the thesis from the leveraged ETF chart, never submit a direct short - sale, and never average down. Do not place any additional order. Do not reveal account - identifiers or credentials. -DOPEY already staged mandatory paper twin {paper_twin_record_hash}. -Exact intent JSON: {exact_intent} - Return only JSON with order_history_complete (boolean), review_succeeded (boolean), placed - (boolean), order_id (string), status (string), filled_quantity (number), average_fill_price - (number), submitted_at (string), filled_at (string), warnings (array of strings), and error (string). -order_history_complete must be true before review or placement.""" - required_tools = ( - "get_accounts", - "get_portfolio", - "get_equity_quotes", - "get_equity_tradability", - "get_equity_orders", - "get_option_orders", - ) - try: - envelope = self._run( - f"place_{intent.instrument}_order", - prompt, - required_tools, - (*required_tools, review_tool, place_tool), - 1, + if intent.instrument != "equity": + raise RobinhoodGatewayError( + "Deterministic live execution is currently equity-only" ) - except RobinhoodGatewayError as error: - raise RobinhoodPlacementAmbiguousError( - "Robinhood placement attempt has unknown broker outcome and must not be retried: " - f"instrument={intent.instrument}, symbol={intent.symbol}, intent_id={intent.intent_id}, " - f"error={error}" - ) from error - account = _attest_account_scope( - envelope.calls, - ("get_portfolio", "get_equity_orders", "get_option_orders"), - ) - equity_history_complete, option_history_complete = ( - _attest_order_history_results(envelope.calls) - ) - if type(envelope.payload.get("order_history_complete")) is not bool: - raise RobinhoodPlacementAmbiguousError( - "Robinhood normalized order-history completeness is not boolean" + if pre_submit_guard is None: + raise RobinhoodGatewayError( + "Deterministic placement requires the final signed-authority guard" ) - if envelope.payload["order_history_complete"] is not ( - equity_history_complete and option_history_complete - ): - raise RobinhoodPlacementAmbiguousError( - "Robinhood normalized order-history completeness is not bound to MCP results" + self._validate_pre_submit_guard(pre_submit_guard) + if getattr(self, "_deterministic_mcp_call", None) is None: + raise RobinhoodGatewayError( + "Live placement requires deterministic parent-side MCP" ) - return _gateway_order_result( - envelope.payload, - review_tool, - place_tool, + return self._place_equity_order_deterministically( intent, - envelope.calls, - account.account_id, + pre_submit_guard, ) diff --git a/dopey_live/robinhood_mcp_direct.py b/dopey_live/robinhood_mcp_direct.py new file mode 100644 index 0000000..ec09fd2 --- /dev/null +++ b/dopey_live/robinhood_mcp_direct.py @@ -0,0 +1,512 @@ +"""Deterministic, model-inaccessible Robinhood MCP write transport. + +The local agent never receives these tools. This client opens one bounded +Streamable HTTP MCP session, verifies the selected tool schema, makes exactly +one typed call, and closes the session. It has no retry path. +""" + +from __future__ import annotations + +import hashlib +import json +from dataclasses import dataclass +from typing import Any, Callable, Final + +import requests + +from .control import OFFICIAL_ROBINHOOD_MCP +from .robinhood_gateway import ( + READ_ONLY_ROBINHOOD_TOOLS, + RobinhoodGatewayError, + _agentic_account, + _direct_call_evidence, +) + + +MCP_PROTOCOL_VERSION: Final[str] = "2025-06-18" +MAXIMUM_MCP_RESPONSE_BYTES: Final[int] = 2_097_152 +EXPECTED_TOOL_PROPERTIES: Final[dict[str, frozenset[str]]] = { + "review_equity_order": frozenset( + { + "side", + "symbol", + "quantity", + "order_type", + "limit_price", + "time_in_force", + } + ), + "place_equity_order": frozenset( + { + "side", + "amount", + "symbol", + "quantity", + "order_type", + "limit_price", + "time_in_force", + } + ), + "cancel_equity_order": frozenset({"order_id"}), +} +EXPECTED_REQUIRED_PROPERTIES: Final[dict[str, frozenset[str]]] = { + "review_equity_order": frozenset({"side", "symbol"}), + "place_equity_order": frozenset({"side", "symbol", "order_type"}), + "cancel_equity_order": frozenset({"order_id"}), +} +READ_ARGUMENT_CONTRACTS: Final[dict[str, dict[str, str]]] = { + "get_accounts": {}, + "get_portfolio": {"account_number": "string"}, + "get_equity_positions": {"account_number": "string"}, + "get_option_positions": {"account_number": "string"}, + "get_equity_orders": { + "account_number": "string", + "limit": "integer", + }, + "get_option_orders": { + "account_number": "string", + "limit": "integer", + }, + "get_equity_quotes": {"symbols": "string_array"}, + "get_equity_tradability": {"symbols": "string_array"}, +} + + +def _strict_json(raw: bytes, label: str) -> dict[str, Any]: + def reject_duplicate(pairs: list[tuple[str, Any]]) -> dict[str, Any]: + result: dict[str, Any] = {} + for key, value in pairs: + if key in result: + raise RobinhoodGatewayError(f"{label} contains a duplicate JSON key") + result[key] = value + return result + + def reject_constant(value: str) -> None: + raise RobinhoodGatewayError(f"{label} contains a non-finite number: {value}") + + try: + payload = json.loads( + raw, + object_pairs_hook=reject_duplicate, + parse_constant=reject_constant, + ) + except (UnicodeDecodeError, json.JSONDecodeError) as error: + raise RobinhoodGatewayError(f"{label} is not valid UTF-8 JSON") from error + if not isinstance(payload, dict): + raise RobinhoodGatewayError(f"{label} must be a JSON object") + return payload + + +def _json_rpc_from_response( + response: requests.Response, + request_id: int, +) -> dict[str, Any]: + body = bytes(response.content) + if not 1 <= len(body) <= MAXIMUM_MCP_RESPONSE_BYTES: + raise RobinhoodGatewayError("Robinhood MCP response size is invalid") + content_type = response.headers.get("Content-Type", "").lower() + candidates: list[dict[str, Any]] = [] + if "text/event-stream" in content_type: + for line in body.splitlines(): + if not line.startswith(b"data:"): + continue + raw_event = line.removeprefix(b"data:").strip() + if raw_event: + candidates.append(_strict_json(raw_event, "Robinhood MCP SSE event")) + elif "application/json" in content_type: + candidates.append(_strict_json(body, "Robinhood MCP response")) + else: + raise RobinhoodGatewayError( + "Robinhood MCP response Content-Type is unsupported" + ) + matches = [ + item + for item in candidates + if item.get("jsonrpc") == "2.0" and item.get("id") == request_id + ] + if len(matches) != 1: + raise RobinhoodGatewayError( + "Robinhood MCP response does not bind one request ID" + ) + message = matches[0] + if "error" in message: + raise RobinhoodGatewayError("Robinhood MCP returned a JSON-RPC error") + result = message.get("result") + if not isinstance(result, dict): + raise RobinhoodGatewayError("Robinhood MCP result must be an object") + return result + + +def _verify_tool_schema(tool: str, tools_result: dict[str, Any]) -> None: + tools = tools_result.get("tools") + if not isinstance(tools, list): + raise RobinhoodGatewayError("Robinhood MCP tools/list result is invalid") + matches = [ + item for item in tools if isinstance(item, dict) and item.get("name") == tool + ] + if len(matches) != 1: + raise RobinhoodGatewayError( + "Robinhood MCP selected tool is absent or ambiguous" + ) + schema = matches[0].get("inputSchema") + if not isinstance(schema, dict) or schema.get("type") != "object": + raise RobinhoodGatewayError( + "Robinhood MCP selected tool input schema is invalid" + ) + properties = schema.get("properties") + required = schema.get("required", []) + if ( + not isinstance(properties, dict) + or frozenset(properties) != EXPECTED_TOOL_PROPERTIES[tool] + or not isinstance(required, list) + or not all(isinstance(item, str) for item in required) + or frozenset(required) != EXPECTED_REQUIRED_PROPERTIES[tool] + ): + raise RobinhoodGatewayError( + "Robinhood MCP selected tool schema drifted from the sealed contract" + ) + + +def _verify_read_tool_schema( + tool: str, + arguments: dict[str, Any], + tools_result: dict[str, Any], +) -> None: + tools = tools_result.get("tools") + if not isinstance(tools, list): + raise RobinhoodGatewayError("Robinhood MCP tools/list result is invalid") + matches = [ + item for item in tools if isinstance(item, dict) and item.get("name") == tool + ] + if len(matches) != 1: + raise RobinhoodGatewayError( + "Robinhood MCP selected read tool is absent or ambiguous" + ) + schema = matches[0].get("inputSchema") + if not isinstance(schema, dict) or schema.get("type") != "object": + raise RobinhoodGatewayError( + "Robinhood MCP selected read tool input schema is invalid" + ) + properties = schema.get("properties", {}) + required = schema.get("required", []) + expected_contract = READ_ARGUMENT_CONTRACTS.get(tool) + if expected_contract is None or frozenset(arguments) != frozenset( + expected_contract + ): + raise RobinhoodGatewayError( + "Robinhood MCP read arguments differ from the sealed contract" + ) + if ( + not isinstance(properties, dict) + or not isinstance(required, list) + or not all(isinstance(item, str) for item in required) + or not frozenset(arguments).issubset(properties) + or not frozenset(required).issubset(arguments) + ): + raise RobinhoodGatewayError( + "Robinhood MCP read arguments do not satisfy the live tool schema" + ) + for field, expected_type in expected_contract.items(): + property_schema = properties.get(field) + if not isinstance(property_schema, dict): + raise RobinhoodGatewayError("Robinhood MCP read property schema is invalid") + if expected_type == "string": + valid_schema = property_schema.get("type") == "string" + valid_value = ( + isinstance(arguments[field], str) + and bool(arguments[field].strip()) + and len(arguments[field]) <= 512 + ) + elif expected_type == "integer": + valid_schema = property_schema.get("type") == "integer" + valid_value = ( + not isinstance(arguments[field], bool) + and isinstance(arguments[field], int) + and arguments[field] == 1_000 + ) + else: + items = property_schema.get("items") + valid_schema = ( + property_schema.get("type") == "array" + and isinstance(items, dict) + and items.get("type") == "string" + ) + value = arguments[field] + valid_value = ( + isinstance(value, list) + and 1 <= len(value) <= 2 + and all(isinstance(item, str) and bool(item.strip()) for item in value) + and value == sorted(set(value)) + ) + if not valid_schema or not valid_value: + raise RobinhoodGatewayError( + "Robinhood MCP read property type drifted from the sealed contract" + ) + + +def _verify_arguments(tool: str, arguments: dict[str, Any]) -> None: + keys = frozenset(arguments) + if not EXPECTED_REQUIRED_PROPERTIES[tool].issubset(keys): + raise RobinhoodGatewayError( + "Deterministic Robinhood MCP call lacks required arguments" + ) + if not keys.issubset(EXPECTED_TOOL_PROPERTIES[tool]): + raise RobinhoodGatewayError( + "Deterministic Robinhood MCP call contains unexpected arguments" + ) + if tool in {"review_equity_order", "place_equity_order"}: + if "amount" in keys or "quantity" not in keys: + raise RobinhoodGatewayError( + "Deterministic equity orders require exact quantity mode" + ) + + +@dataclass +class DeterministicRobinhoodMcpClient: + """One-shot official MCP caller unavailable to the generative subprocess.""" + + bearer_token: str + expected_account_hash: str + session: requests.Session | None = None + + def __post_init__(self) -> None: + if ( + not isinstance(self.bearer_token, str) + or not 16 <= len(self.bearer_token) <= 16_384 + or self.bearer_token != self.bearer_token.strip() + or any(character in self.bearer_token for character in "\r\n\0") + ): + raise ValueError("Robinhood MCP bearer profile is invalid") + if ( + not isinstance(self.expected_account_hash, str) + or len(self.expected_account_hash) != 64 + or any( + character not in "0123456789abcdef" + for character in self.expected_account_hash + ) + ): + raise ValueError("Robinhood MCP expected account hash is invalid") + if self.session is None: + self.session = requests.Session() + self.session.trust_env = False + + def _request( + self, + method: str, + payload: dict[str, Any] | None, + timeout_seconds: int, + *, + request_id: int | None, + session_id: str | None, + ) -> tuple[dict[str, Any] | None, str | None]: + if self.session is None: + raise RobinhoodGatewayError("Robinhood MCP HTTP session is unavailable") + headers = { + "Accept": "application/json, text/event-stream", + "Authorization": f"Bearer {self.bearer_token}", + "Cache-Control": "no-store", + "Content-Type": "application/json", + "MCP-Protocol-Version": MCP_PROTOCOL_VERSION, + } + if session_id is not None: + headers["Mcp-Session-Id"] = session_id + try: + response = self.session.request( + method, + OFFICIAL_ROBINHOOD_MCP, + headers=headers, + json=payload, + timeout=timeout_seconds, + allow_redirects=False, + ) + except requests.RequestException as error: + raise RobinhoodGatewayError( + "Robinhood MCP deterministic transport failed" + ) from error + if response.status_code not in {200, 202}: + raise RobinhoodGatewayError( + "Robinhood MCP deterministic transport returned a non-success status" + ) + returned_session = response.headers.get("Mcp-Session-Id") + if returned_session is not None and ( + not returned_session + or len(returned_session) > 512 + or any(character in returned_session for character in "\r\n\0") + ): + raise RobinhoodGatewayError("Robinhood MCP session ID is invalid") + if request_id is None: + return None, returned_session + return _json_rpc_from_response(response, request_id), returned_session + + def _call_initialized( + self, + tool: str, + arguments: dict[str, Any], + timeout_seconds: int, + session_id: str | None, + pre_call_guard: Callable[[], None] | None, + ) -> dict[str, Any]: + self._request( + "POST", + { + "jsonrpc": "2.0", + "method": "notifications/initialized", + }, + timeout_seconds, + request_id=None, + session_id=session_id, + ) + tools_result, _ = self._request( + "POST", + { + "jsonrpc": "2.0", + "id": 2, + "method": "tools/list", + "params": {}, + }, + timeout_seconds, + request_id=2, + session_id=session_id, + ) + if tools_result is None: + raise RobinhoodGatewayError("Robinhood MCP tools/list result is absent") + if tool in EXPECTED_TOOL_PROPERTIES: + _verify_tool_schema(tool, tools_result) + else: + _verify_read_tool_schema(tool, arguments, tools_result) + next_request_id = 3 + if tool in EXPECTED_TOOL_PROPERTIES: + _verify_read_tool_schema("get_accounts", {}, tools_result) + account_result, _ = self._request( + "POST", + { + "jsonrpc": "2.0", + "id": next_request_id, + "method": "tools/call", + "params": { + "name": "get_accounts", + "arguments": {}, + }, + }, + timeout_seconds, + request_id=next_request_id, + session_id=session_id, + ) + if account_result is None: + raise RobinhoodGatewayError("Robinhood MCP account proof is absent") + account = _agentic_account( + ( + _direct_call_evidence( + "get_accounts", + {}, + account_result, + ), + ) + ) + if ( + hashlib.sha256(account.account_id.encode("utf-8")).hexdigest() + != self.expected_account_hash + ): + raise RobinhoodGatewayError( + "Robinhood MCP session is not bound to the pinned Agentic account" + ) + next_request_id += 1 + if tool == "place_equity_order": + if pre_call_guard is None: + raise RobinhoodGatewayError( + "Robinhood placement requires an immediate authority guard" + ) + pre_call_guard() + elif pre_call_guard is not None: + raise RobinhoodGatewayError( + "Immediate authority guard is only valid for placement" + ) + call_result, _ = self._request( + "POST", + { + "jsonrpc": "2.0", + "id": next_request_id, + "method": "tools/call", + "params": { + "name": tool, + "arguments": dict(arguments), + }, + }, + timeout_seconds, + request_id=next_request_id, + session_id=session_id, + ) + if call_result is None: + raise RobinhoodGatewayError("Robinhood MCP tools/call result is absent") + return call_result + + def __call__( + self, + tool: str, + arguments: dict[str, Any], + timeout_seconds: int, + pre_call_guard: Callable[[], None] | None = None, + ) -> dict[str, Any]: + if tool not in EXPECTED_TOOL_PROPERTIES and tool not in ( + READ_ONLY_ROBINHOOD_TOOLS + ): + raise RobinhoodGatewayError( + "Robinhood deterministic tool is outside the sealed allowlist" + ) + if ( + isinstance(timeout_seconds, bool) + or not isinstance(timeout_seconds, int) + or not 1 <= timeout_seconds <= 60 + ): + raise ValueError("Robinhood deterministic timeout is invalid") + if tool in EXPECTED_TOOL_PROPERTIES: + _verify_arguments(tool, arguments) + elif pre_call_guard is not None: + raise RobinhoodGatewayError( + "Immediate authority guard is only valid for placement" + ) + initialize, session_id = self._request( + "POST", + { + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": { + "protocolVersion": MCP_PROTOCOL_VERSION, + "capabilities": {}, + "clientInfo": { + "name": "DumbMoneyDopeyRobinhood", + "version": "1", + }, + }, + }, + timeout_seconds, + request_id=1, + session_id=None, + ) + try: + if initialize is None or initialize.get("protocolVersion") != ( + MCP_PROTOCOL_VERSION + ): + raise RobinhoodGatewayError( + "Robinhood MCP protocol version is not the sealed version" + ) + return self._call_initialized( + tool, + arguments, + timeout_seconds, + session_id, + pre_call_guard, + ) + finally: + if session_id is not None: + try: + self._request( + "DELETE", + None, + timeout_seconds, + request_id=None, + session_id=session_id, + ) + except RobinhoodGatewayError: + pass diff --git a/dopey_live/service.py b/dopey_live/service.py index 356216b..f77ee83 100644 --- a/dopey_live/service.py +++ b/dopey_live/service.py @@ -9,14 +9,30 @@ import subprocess from datetime import date, datetime, timedelta, timezone from pathlib import Path -from typing import Any +from typing import Any, Callable from zoneinfo import ZoneInfo import exchange_calendars as xcals -from .control import load_live_branch_control +from .control import LiveBranchControl, load_live_branch_control +from .dumbmoney_command_feed import DumbMoneyCommandDispatch +from .dumbmoney_authority import ( + DumbMoneyAuthorityContext, + DumbMoneyAuthorityError, + VerifiedCapitalAuthority, + authorize_intent, + authorized_settled_cash_usd, +) +from .dumbmoney_promotion import ( + PromotionAuthorityResolver, + VerifiedPromotionBinding, +) from .ledger import ( + LiveBranchLedgerError, + activate_dumbmoney_fence, append_record, + assert_current_dumbmoney_fence, + artifact_root, ledger_path, paper_twin_path, read_ledger, @@ -31,6 +47,7 @@ from .robinhood_gateway import ( RobinhoodAgenticGateway, RobinhoodAuthRequiredError, + RobinhoodCancellationAmbiguousError, RobinhoodConsentRequiredError, RobinhoodGatewayError, RobinhoodPlacementAmbiguousError, @@ -43,6 +60,7 @@ build_entry_intents, prepare_equity_entry_for_execution, research_maturity_blockers, + validate_option_entry_for_execution, ) @@ -55,6 +73,8 @@ ) RECOGNIZED_ORDER_STATUSES = ACTIVE_ORDER_STATUSES | TERMINAL_ORDER_STATUSES MAX_ORDER_INTENTS_PER_CYCLE = 1 +DUMBMONEY_KILL_FILENAME = "DUMBMONEY_KILL.v1" +DUMBMONEY_HISTORY_POLL_LIMIT = 3 def _regular_market_state(now_utc: datetime) -> tuple[bool, str]: @@ -169,7 +189,13 @@ def _paper_twin_execution_payload( quote = snapshot.execution_quote marketable = intent.limit_price >= quote.ask_price filled_quantity = ( - round(intent.notional_usd / quote.ask_price, 8) if marketable else 0.0 + ( + intent.quantity + if intent.quantity > 0.0 + else round(intent.notional_usd / quote.ask_price, 8) + ) + if marketable + else 0.0 ) if marketable and filled_quantity <= 0.0: raise ValueError( @@ -180,7 +206,9 @@ def _paper_twin_execution_payload( return { **base, "paper_twin_execution_status": "independent_quote_observation_complete", - "paper_fill_status": "simulated_filled" if marketable else "simulated_not_filled", + "paper_fill_status": "simulated_filled" + if marketable + else "simulated_not_filled", "paper_filled_quantity": filled_quantity, "paper_average_fill_price": quote.ask_price if marketable else None, "market_observation_source": snapshot.source, @@ -383,7 +411,10 @@ def _open_live_positions( # _settle_owner_liquidations. Subtraction order vs system sells does not # matter: both only ever subtract nonnegative quantities. for record in records: - if str(record.get("event_type", "")) != "live_position_owner_liquidation_observed": + if ( + str(record.get("event_type", "")) + != "live_position_owner_liquidation_observed" + ): continue payload = record.get("payload") if not isinstance(payload, dict): @@ -897,9 +928,7 @@ def _order_history_blocker( evidence_order_ids = payload.get("evidence_broker_order_ids", []) if isinstance(evidence_order_ids, list): local_order_ids.update( - str(value) - for value in evidence_order_ids - if str(value).strip() + str(value) for value in evidence_order_ids if str(value).strip() ) continue placed_records.append(payload) @@ -966,15 +995,570 @@ def _order_history_blocker( return "" +def dumbmoney_kill_path(repository_root: Path) -> Path: + return artifact_root(repository_root) / DUMBMONEY_KILL_FILENAME + + +def _active_attributed_orders( + repository_root: Path, + account: AccountSnapshot, +) -> tuple[tuple[str, str], ...]: + instruments_by_order_id: dict[str, str] = {} + for record in read_ledger(ledger_path(repository_root)): + if record.get("event_type") not in { + "live_order_result", + "live_order_submission_ambiguous", + }: + continue + payload = record.get("payload") + if not isinstance(payload, dict): + continue + is_definite_placement = ( + record.get("event_type") == "live_order_result" + and payload.get("placed") is True + ) + is_attributed_ambiguous_dispatch = ( + record.get("event_type") == "live_order_submission_ambiguous" + and payload.get("in_flight_authority_revoked") is True + ) + if not (is_definite_placement or is_attributed_ambiguous_dispatch): + continue + order_id = str(payload.get("order_id", "")).strip() + instrument = str(payload.get("instrument", "")).strip() + if order_id and instrument in {"equity", "option"}: + instruments_by_order_id[order_id] = instrument + active: list[tuple[str, str]] = [] + for instrument, orders in ( + ("equity", account.recent_equity_orders), + ("option", account.recent_option_orders), + ): + for order in orders: + order_id = str(order.get("order_id", "")).strip() + status = str(order.get("status", "")).strip().lower() + if ( + order_id + and status in ACTIVE_ORDER_STATUSES + and instruments_by_order_id.get(order_id) == instrument + ): + active.append((instrument, order_id)) + return tuple(sorted(set(active))) + + +def _run_cancel_only_degraded( + repository_root: Path, + gateway: RobinhoodAgenticGateway, + account: AccountSnapshot, + now_utc: datetime, + reason: str, + request_cancellation: bool, + *, + effective_kill_active: bool | None = None, +) -> dict[str, Any]: + attempts: list[dict[str, Any]] = [] + current_account = account + targets = _active_attributed_orders(repository_root, current_account) + if request_cancellation: + for instrument, order_id in targets: + append_record( + ledger_path(repository_root), + "dumbmoney_cancel_started", + { + "instrument": instrument, + "order_id": order_id, + "reason": reason, + "new_exposure_allowed": False, + }, + ) + outcome: dict[str, Any] + try: + cancellation = gateway.cancel_order(instrument, order_id) + except RobinhoodCancellationAmbiguousError as error: + outcome = { + "instrument": instrument, + "order_id": order_id, + "cancel_requested": None, + "status": "ambiguous_cancel_history_poll_required", + "error": str(error), + } + append_record( + ledger_path(repository_root), + "dumbmoney_cancel_ambiguous", + outcome, + ) + except RobinhoodGatewayError as error: + outcome = { + "instrument": instrument, + "order_id": order_id, + "cancel_requested": False, + "status": "cancel_gateway_blocked", + "error": str(error), + } + append_record( + ledger_path(repository_root), + "dumbmoney_cancel_blocked", + outcome, + ) + else: + outcome = { + "instrument": instrument, + "order_id": order_id, + **cancellation.to_dict(), + } + append_record( + ledger_path(repository_root), + "dumbmoney_cancel_result", + outcome, + ) + attempts.append(outcome) + poll_count = 0 + poll_errors: list[str] = [] + for _ in range(DUMBMONEY_HISTORY_POLL_LIMIT if request_cancellation else 1): + poll_count += 1 + try: + current_account = gateway.get_account_snapshot() + except RobinhoodGatewayError as error: + poll_errors.append(f"{type(error).__name__}: {error}") + continue + _reconcile_live_order_statuses(repository_root, current_account) + if not _active_attributed_orders(repository_root, current_account): + break + unresolved = _active_attributed_orders(repository_root, current_account) + return { + "cycle_status": "cancel_only_degraded", + "authority_state": "CANCEL_ONLY_DEGRADED", + "cancel_only_reason": reason, + "new_exposure_allowed": False, + "kill_active": ( + dumbmoney_kill_path(repository_root).is_file() + if effective_kill_active is None + else ( + effective_kill_active or dumbmoney_kill_path(repository_root).is_file() + ) + ), + "cancel_requested": request_cancellation, + "cancel_target_count": len(targets), + "cancel_attempts": attempts, + "history_poll_count": poll_count, + "history_poll_errors": poll_errors, + "unresolved_attributed_orders": [ + {"instrument": instrument, "order_id": order_id} + for instrument, order_id in unresolved + ], + "account": current_account.to_dict(), + "orders": [], + } + + +def _required_exact_number( + order: dict[str, Any], + fields: tuple[str, ...], + expected: float, +) -> bool: + values = [order[field] for field in fields if field in order] + if not values: + return False + try: + return len({float(value) for value in values}) == 1 and math.isclose( + float(values[0]), + expected, + rel_tol=0.0, + abs_tol=1e-9, + ) + except (TypeError, ValueError): + return False + + +def _exact_ambiguous_history_match( + order: dict[str, Any], + intent: LiveExecutionIntent, + market_date: str, + attempted_at: datetime, +) -> bool: + if ( + str(order.get("symbol", "")).upper() != intent.symbol + or str(order.get("side", "")).lower() != intent.side + or str(order.get("market_date", "")) != market_date + ): + return False + try: + submitted_at = datetime.fromisoformat( + str(order.get("submitted_at", "")).replace("Z", "+00:00") + ) + except ValueError: + return False + if ( + submitted_at.tzinfo is None + or abs((submitted_at - attempted_at).total_seconds()) > 300 + ): + return False + if str(order.get("order_type", "")).lower() != intent.order_type: + return False + if str(order.get("time_in_force", "")).lower() != intent.time_in_force: + return False + if intent.order_type != "market" and not _required_exact_number( + order, + ("limit", "limit_price"), + intent.limit_price, + ): + return False + if intent.quantity > 0.0: + if not _required_exact_number( + order, + ("asset_quantity", "quantity", "share_quantity", "shares"), + intent.quantity, + ): + return False + elif not _required_exact_number( + order, + ("amount", "dollar_amount", "notional", "notional_usd"), + intent.notional_usd, + ): + return False + return _order_matches_intent(order, intent) + + +def _witnessed_daily_loss_cents( + repository_root: Path, + account: AccountSnapshot, + market_date: str, +) -> int: + """Derive a conservative daily loss from complete broker history + local lineage. + + A filled order without explicit broker fee evidence, local attribution, or + enough prior long inventory makes the baseline unknowable and therefore + blocks new exposure. + """ + if ( + not market_date + or not account.equity_order_history_complete + or not account.option_order_history_complete + ): + raise DumbMoneyAuthorityError( + "Complete broker order history is required for daily-loss authority" + ) + intents_by_order_id: dict[str, LiveExecutionIntent] = {} + for record in read_ledger(ledger_path(repository_root)): + if record.get("event_type") != "live_order_result": + continue + payload = record.get("payload") + if not isinstance(payload, dict) or payload.get("placed") is not True: + continue + order_id = str(payload.get("order_id", "")).strip() + raw_intent = payload.get("intent") + if not order_id or not isinstance(raw_intent, dict): + raise DumbMoneyAuthorityError( + "Placed local order lacks broker identity or typed intent" + ) + try: + intents_by_order_id[order_id] = live_execution_intent_from_dict(raw_intent) + except (TypeError, ValueError) as error: + raise DumbMoneyAuthorityError( + "Placed local order has an invalid typed intent" + ) from error + broker_orders = [ + *account.recent_equity_orders, + *account.recent_option_orders, + ] + try: + broker_orders.sort( + key=lambda order: ( + datetime.fromisoformat( + str(order.get("submitted_at", "")).replace("Z", "+00:00") + ), + str(order.get("order_id", "")), + ) + ) + except ValueError as error: + raise DumbMoneyAuthorityError( + "Broker order history has an invalid submission timestamp" + ) from error + lots: dict[str, list[list[float]]] = {} + daily_pnl_usd = 0.0 + for order in broker_orders: + status = str(order.get("status", "")).lower() + if status not in {"filled", "partially_filled"}: + continue + order_id = str(order.get("order_id", "")).strip() + intent = intents_by_order_id.get(order_id) + if intent is None: + raise DumbMoneyAuthorityError( + "Filled broker order is not attributed to a typed local intent" + ) + if str( + order.get("side", "") + ).lower() != intent.side or not _order_matches_intent(order, intent): + raise DumbMoneyAuthorityError( + "Broker fill identity conflicts with its typed local intent" + ) + try: + quantity = float(order.get("filled_quantity", 0.0)) + average_price = float(order.get("average_fill_price", 0.0)) + except (TypeError, ValueError) as error: + raise DumbMoneyAuthorityError( + "Broker fill economics are invalid" + ) from error + fee_values = [order[field] for field in ("fees_usd", "fees") if field in order] + if len(fee_values) != 1: + raise DumbMoneyAuthorityError( + "Filled broker order lacks one explicit fee observation" + ) + try: + fee_usd = float(fee_values[0]) + except (TypeError, ValueError) as error: + raise DumbMoneyAuthorityError("Broker fill fee is invalid") from error + if ( + not math.isfinite(quantity) + or not math.isfinite(average_price) + or not math.isfinite(fee_usd) + or quantity <= 0.0 + or average_price <= 0.0 + or fee_usd < 0.0 + ): + raise DumbMoneyAuthorityError( + "Broker fill quantity, price, and fee must be finite and bounded" + ) + key = _position_key(intent) + multiplier = 100.0 if intent.instrument == "option" else 1.0 + if intent.side == "buy": + lots.setdefault(key, []).append([quantity, average_price]) + if str(order.get("market_date", "")) == market_date: + daily_pnl_usd -= fee_usd + continue + remaining = quantity + realized = -fee_usd + position_lots = lots.setdefault(key, []) + while remaining > 1e-9 and position_lots: + lot_quantity, lot_price = position_lots[0] + consumed = min(remaining, lot_quantity) + realized += (average_price - lot_price) * consumed * multiplier + remaining -= consumed + lot_quantity -= consumed + if lot_quantity <= 1e-9: + position_lots.pop(0) + else: + position_lots[0][0] = lot_quantity + if remaining > 1e-9: + raise DumbMoneyAuthorityError( + "Broker sell fill exceeds witnessed long inventory" + ) + if str(order.get("market_date", "")) == market_date: + daily_pnl_usd += realized + if not math.isfinite(daily_pnl_usd): + raise DumbMoneyAuthorityError("Witnessed daily PnL is not finite") + unrealized_loss_usd = 0.0 + for position in (*account.equity_positions, *account.option_positions): + try: + quantity = float(position.get("quantity", 0.0)) + unrealized_pnl = float(position["unrealized_pnl"]) + except (KeyError, TypeError, ValueError) as error: + raise DumbMoneyAuthorityError( + "Nonzero broker position lacks witnessed unrealized PnL" + ) from error + if not math.isfinite(quantity) or not math.isfinite(unrealized_pnl): + raise DumbMoneyAuthorityError( + "Broker position unrealized PnL evidence is not finite" + ) + if quantity != 0.0: + unrealized_loss_usd += max(0.0, -unrealized_pnl) + realized_loss_cents = max(0, math.ceil(-daily_pnl_usd * 100.0 - 1e-9)) + unrealized_loss_cents = math.ceil(unrealized_loss_usd * 100.0 - 1e-9) + return realized_loss_cents + unrealized_loss_cents + + +def _poll_ambiguous_placement_history( + repository_root: Path, + gateway: RobinhoodAgenticGateway, + intent: LiveExecutionIntent, + market_date: str, + attempted_at: datetime, + paper_twin_record_hash: str, +) -> tuple[dict[str, Any] | None, AccountSnapshot | None, int]: + known_order_ids = { + str(record.get("payload", {}).get("order_id", "")) + for record in read_ledger(ledger_path(repository_root)) + if isinstance(record.get("payload"), dict) + and str(record.get("payload", {}).get("order_id", "")) + } + candidates: dict[str, dict[str, Any]] = {} + latest_account: AccountSnapshot | None = None + poll_errors: list[str] = [] + poll_count = 0 + for poll_index in range(1, DUMBMONEY_HISTORY_POLL_LIMIT + 1): + poll_count = poll_index + try: + latest_account = gateway.get_account_snapshot() + except RobinhoodGatewayError as error: + poll_errors.append(f"{type(error).__name__}: {error}") + continue + orders = ( + latest_account.recent_equity_orders + if intent.instrument == "equity" + else latest_account.recent_option_orders + ) + for order in orders: + order_id = str(order.get("order_id", "")).strip() + if ( + order_id + and order_id not in known_order_ids + and _exact_ambiguous_history_match( + order, + intent, + market_date, + attempted_at, + ) + ): + candidates[order_id] = order + append_record( + ledger_path(repository_root), + "dumbmoney_ambiguous_history_poll", + { + "intent_id": intent.intent_id, + "poll_index": poll_index, + "candidate_order_ids": sorted(candidates), + "candidate_count": len(candidates), + "history_complete": ( + latest_account.equity_order_history_complete + if intent.instrument == "equity" + else latest_account.option_order_history_complete + ), + }, + ) + if latest_account is None or len(candidates) != 1: + if poll_errors: + append_record( + ledger_path(repository_root), + "dumbmoney_ambiguous_history_poll_failed", + { + "intent_id": intent.intent_id, + "poll_error_count": len(poll_errors), + "errors": poll_errors, + }, + ) + return None, latest_account, poll_count + order = next(iter(candidates.values())) + history_complete = ( + latest_account.equity_order_history_complete + if intent.instrument == "equity" + else latest_account.option_order_history_complete + ) + if not history_complete: + return None, latest_account, poll_count + payload = { + "intent_id": intent.intent_id, + "instrument": intent.instrument, + "symbol": intent.symbol, + "side": intent.side, + "market_date": market_date, + "paper_twin_record_hash": paper_twin_record_hash, + "intent": intent.to_dict(), + "review_succeeded": True, + "placed": True, + "order_id": str(order["order_id"]), + "status": str(order.get("status", "")).lower(), + "filled_quantity": _filled_quantity( + order, + f"ambiguous_history_order:{order['order_id']}", + ), + "average_fill_price": float(order.get("average_fill_price", 0.0)), + "submitted_at": str(order.get("submitted_at", "")), + "filled_at": str(order.get("filled_at", "")), + "warnings": ["automatically_reconciled_from_exact_complete_broker_history"], + "error": "", + "observed_tools": ["get_accounts", "get_equity_orders", "get_option_orders"], + "automatic_history_reconciled": True, + "placement_retried": False, + } + reconciliation_record = append_record( + ledger_path(repository_root), + "live_order_result", + payload, + ) + return ( + { + **payload, + "automatic_history_record_hash": reconciliation_record["record_hash"], + }, + latest_account, + poll_count, + ) + + def run_live_branch_cycle( repository_root: Path, gateway: RobinhoodAgenticGateway, now_utc: datetime, execute_orders: bool, + *, + dumbmoney_authority: DumbMoneyAuthorityContext | None = None, + dumbmoney_controls: DumbMoneyCommandDispatch | None = None, + dumbmoney_control_refresh: ( + Callable[[datetime], DumbMoneyCommandDispatch] | None + ) = None, + dumbmoney_promotion_resolver: PromotionAuthorityResolver | None = None, + authority_clock: Callable[[], datetime] | None = None, + live_branch_control: LiveBranchControl | None = None, ) -> dict[str, Any]: - control = load_live_branch_control() - base = _base_cycle(now_utc) - if not control.live_branch_enabled: + control = ( + live_branch_control + if live_branch_control is not None + else load_live_branch_control() + ) + if dumbmoney_authority is not None and dumbmoney_controls is not None: + result = { + **_base_cycle(now_utc), + "dumbmoney_authority_enabled": False, + "dumbmoney_command_feed": dumbmoney_controls.evidence(), + "cycle_status": "blocked_dumbmoney_control_source_conflict", + "orders": [], + } + write_latest_cycle(repository_root, result) + return result + if dumbmoney_control_refresh is not None and dumbmoney_controls is None: + result = { + **_base_cycle(now_utc), + "dumbmoney_authority_enabled": False, + "dumbmoney_command_feed": None, + "cycle_status": "blocked_dumbmoney_control_refresh_without_snapshot", + "orders": [], + } + write_latest_cycle(repository_root, result) + return result + effective_authority = ( + dumbmoney_controls.capital_authority + if dumbmoney_controls is not None + else dumbmoney_authority + ) + dumbmoney_configured = ( + dumbmoney_controls is not None or dumbmoney_authority is not None + ) + feed_kill_active = ( + dumbmoney_controls.kill_active if dumbmoney_controls is not None else False + ) + local_kill_active = ( + dumbmoney_configured and dumbmoney_kill_path(repository_root).is_file() + ) + effective_kill_active = feed_kill_active or local_kill_active + feed_blocks_new_risk = ( + dumbmoney_controls is not None and not dumbmoney_controls.allow_new_risk + ) + feed_refresh_missing = ( + execute_orders + and dumbmoney_controls is not None + and dumbmoney_controls.allow_new_risk + and dumbmoney_control_refresh is None + ) + feed_blocks_new_risk = feed_blocks_new_risk or feed_refresh_missing + base = { + **_base_cycle(now_utc), + "dumbmoney_authority_enabled": effective_authority is not None, + "dumbmoney_command_feed": ( + dumbmoney_controls.evidence() if dumbmoney_controls is not None else None + ), + } + kill_requested = dumbmoney_configured and ( + effective_kill_active or feed_blocks_new_risk + ) + if not control.live_branch_enabled and not kill_requested: result = {**base, "cycle_status": "disabled_by_owner_control", "orders": []} write_latest_cycle(repository_root, result) return result @@ -1037,6 +1621,68 @@ def run_live_branch_cycle( } write_latest_cycle(repository_root, result) return result + if dumbmoney_controls is not None and effective_kill_active: + degraded = _run_cancel_only_degraded( + repository_root, + gateway, + account, + now_utc, + ( + dumbmoney_controls.fail_closed_reason + or "dumbmoney_command_feed_kill_active" + ), + True, + effective_kill_active=True, + ) + result = {**base, **degraded} + write_latest_cycle(repository_root, result) + return result + if dumbmoney_controls is not None and feed_blocks_new_risk: + degraded = _run_cancel_only_degraded( + repository_root, + gateway, + account, + now_utc, + ( + "dumbmoney_command_feed_refresh_missing_at_broker_sink" + if feed_refresh_missing + else dumbmoney_controls.fail_closed_reason + or "dumbmoney_command_feed_paused" + ), + False, + effective_kill_active=False, + ) + result = {**base, **degraded} + write_latest_cycle(repository_root, result) + return result + verified_authority: VerifiedCapitalAuthority | None = None + if effective_authority is not None: + try: + verified_authority = effective_authority.verify( + now_utc, + account.account_hash, + ) + activate_dumbmoney_fence( + repository_root, + verified_authority.envelope.envelope_id, + verified_authority.event_id, + verified_authority.body_digest, + verified_authority.envelope.fencing_generation, + verified_authority.envelope.policy_epoch, + ) + except (DumbMoneyAuthorityError, LiveBranchLedgerError, ValueError) as error: + degraded = _run_cancel_only_degraded( + repository_root, + gateway, + account, + now_utc, + f"capital_authority_invalid:{error}", + dumbmoney_kill_path(repository_root).is_file(), + effective_kill_active=effective_kill_active, + ) + result = {**base, **degraded} + write_latest_cycle(repository_root, result) + return result reconciled_orders = _reconcile_live_order_statuses(repository_root, account) legacy_twin_observations = _complete_legacy_twin_observations(repository_root) owner_liquidation_settlements = _settle_owner_liquidations( @@ -1049,6 +1695,27 @@ def run_live_branch_cycle( market_date, control.options_execution_enabled, ) + if ( + verified_authority is not None + and dumbmoney_kill_path(repository_root).is_file() + ): + degraded = _run_cancel_only_degraded( + repository_root, + gateway, + account, + now_utc, + "dumbmoney_kill_active", + True, + effective_kill_active=True, + ) + result = { + **base, + **degraded, + "capital_authority": verified_authority.evidence(), + "reconciled_order_status_count": len(reconciled_orders), + } + write_latest_cycle(repository_root, result) + return result if not execute_orders or not regular_market_open: result = { **base, @@ -1070,6 +1737,11 @@ def run_live_branch_cycle( "options_execution_enabled": control.options_execution_enabled, "reconciled_order_status_count": len(reconciled_orders), "order_history_blocker": history_blocker, + "capital_authority": ( + verified_authority.evidence() + if verified_authority is not None + else None + ), "orders": [], } write_latest_cycle(repository_root, result) @@ -1077,6 +1749,24 @@ def run_live_branch_cycle( unresolved_submission_present = ( history_blocker == "local_live_order_submission_unresolved" ) + if verified_authority is not None and history_blocker: + degraded = _run_cancel_only_degraded( + repository_root, + gateway, + account, + now_utc, + f"order_history_blocker:{history_blocker}", + False, + ) + result = { + **base, + **degraded, + "capital_authority": verified_authority.evidence(), + "order_history_blocker": history_blocker, + "reconciled_order_status_count": len(reconciled_orders), + } + write_latest_cycle(repository_root, result) + return result if history_blocker and not unresolved_submission_present: result = { **base, @@ -1135,6 +1825,37 @@ def run_live_branch_cycle( } write_latest_cycle(repository_root, result) return result + entry_cash_usd = settled_funds.spendable_settled_usd + witnessed_daily_loss_cents: int | None = None + if verified_authority is not None: + try: + witnessed_daily_loss_cents = _witnessed_daily_loss_cents( + repository_root, + account, + market_date, + ) + entry_cash_usd = authorized_settled_cash_usd( + verified_authority, + account, + settled_funds.spendable_settled_usd, + witnessed_daily_loss_cents, + ) + except DumbMoneyAuthorityError as error: + degraded = _run_cancel_only_degraded( + repository_root, + gateway, + account, + now_utc, + f"capital_allocation_blocked:{error}", + False, + ) + result = { + **base, + **degraded, + "capital_authority": verified_authority.evidence(), + } + write_latest_cycle(repository_root, result) + return result all_exit_intents = _build_exit_intents( repository_root, account, @@ -1149,7 +1870,7 @@ def run_live_branch_cycle( if maturity_blockers or unresolved_submission_present else build_entry_intents( repository_root, - settled_funds.spendable_settled_usd, + entry_cash_usd, now_utc.astimezone(NEW_YORK).date(), control.stock_execution_enabled, control.options_execution_enabled and regular_market_open, @@ -1176,6 +1897,8 @@ def run_live_branch_cycle( outcomes: list[dict[str, Any]] = [] for candidate_intent in intents_to_process: intent = candidate_intent + blocked_payload: dict[str, Any] + promotion_binding: VerifiedPromotionBinding | None = None day_trade_blocker = _day_trade_blocker(account, intent, market_date) if day_trade_blocker: blocked_payload = { @@ -1200,7 +1923,7 @@ def run_live_branch_cycle( intent, execution_snapshot, now_utc, - settled_funds.spendable_settled_usd, + entry_cash_usd, ) except (LiveSignalError, RobinhoodGatewayError) as error: blocked_payload = { @@ -1220,6 +1943,74 @@ def run_live_branch_cycle( ) outcomes.append(blocked_payload) continue + if intent.instrument == "option" and intent.side == "buy": + try: + validate_option_entry_for_execution(intent, now_utc) + except LiveSignalError as error: + blocked_payload = { + "intent_id": intent.intent_id, + "instrument": intent.instrument, + "symbol": intent.symbol, + "side": intent.side, + "market_date": market_date, + "status": "blocked_option_execution_snapshot_invalid", + "reason": str(error), + "intent": intent.to_dict(), + } + append_record( + ledger_path(repository_root), + "option_execution_preflight_block", + blocked_payload, + ) + outcomes.append(blocked_payload) + continue + capital_authority_evidence: dict[str, Any] | None = None + if verified_authority is not None: + try: + if intent.side == "buy": + if dumbmoney_promotion_resolver is None: + raise DumbMoneyAuthorityError( + "authenticated DumbMoney promotion resolver is absent" + ) + intent, promotion_binding = ( + dumbmoney_promotion_resolver.bind_intent( + verified_authority, + intent, + now_utc, + ) + ) + authorize_intent( + verified_authority, + account, + intent, + witnessed_daily_loss_cents, + ) + except DumbMoneyAuthorityError as error: + blocked_payload = { + "intent_id": intent.intent_id, + "instrument": intent.instrument, + "symbol": intent.symbol, + "side": intent.side, + "market_date": market_date, + "status": "blocked_dumbmoney_capital_envelope", + "reason": str(error), + "intent": intent.to_dict(), + } + append_record( + ledger_path(repository_root), + "dumbmoney_capital_preflight_block", + blocked_payload, + ) + outcomes.append(blocked_payload) + continue + capital_authority_evidence = { + **verified_authority.evidence(), + "promotion_binding": ( + promotion_binding.evidence() + if promotion_binding is not None + else None + ), + } reserved = reserve_intent( repository_root, intent.intent_id, @@ -1229,6 +2020,7 @@ def run_live_branch_cycle( "side": intent.side, "market_date": market_date, "intent": intent.to_dict(), + "capital_authority": capital_authority_evidence, }, ) if not reserved: @@ -1255,6 +2047,272 @@ def run_live_branch_cycle( intent, str(twin["record_hash"]), ) + pre_submit_guard: Callable[[], datetime] | None = None + if verified_authority is not None and effective_authority is not None: + try: + sink_now = ( + authority_clock() + if authority_clock is not None + else datetime.now(timezone.utc) + ) + sink_authority = effective_authority.verify( + sink_now, + account.account_hash, + ) + if sink_authority.event_id != verified_authority.event_id: + raise DumbMoneyAuthorityError( + "Capital authority changed during the execution cycle" + ) + assert_current_dumbmoney_fence( + repository_root, + sink_authority.envelope.envelope_id, + sink_authority.event_id, + sink_authority.body_digest, + sink_authority.envelope.fencing_generation, + sink_authority.envelope.policy_epoch, + ) + sink_account = gateway.get_account_snapshot() + if ( + not sink_account.authenticated + or sink_account.account_hash != account.account_hash + ): + raise DumbMoneyAuthorityError( + "Broker-sink account attestation changed during execution" + ) + sink_daily_loss_cents = _witnessed_daily_loss_cents( + repository_root, + sink_account, + market_date, + ) + if intent.side == "buy": + if ( + dumbmoney_promotion_resolver is None + or promotion_binding is None + ): + raise DumbMoneyAuthorityError( + "authenticated DumbMoney promotion binding is absent" + ) + intent, promotion_binding = ( + dumbmoney_promotion_resolver.bind_intent( + sink_authority, + intent, + sink_now, + expected_binding=promotion_binding, + ) + ) + authorize_intent( + sink_authority, + sink_account, + intent, + sink_daily_loss_cents, + ) + if intent.instrument == "option" and intent.side == "buy": + validate_option_entry_for_execution(intent, sink_now) + account = sink_account + capital_authority_evidence = { + **sink_authority.evidence(), + "verified_again_at_broker_sink": True, + "verified_at": sink_now.astimezone(timezone.utc).isoformat(), + "witnessed_daily_loss_cents": sink_daily_loss_cents, + "promotion_binding": ( + promotion_binding.evidence() + if promotion_binding is not None + else None + ), + } + + def final_broker_sink_guard( + *, + expected_event_id: str = sink_authority.event_id, + expected_envelope_id: str = (sink_authority.envelope.envelope_id), + expected_body_digest: str = sink_authority.body_digest, + expected_generation: int = ( + sink_authority.envelope.fencing_generation + ), + expected_policy_epoch: int = (sink_authority.envelope.policy_epoch), + expected_account_hash: str = sink_account.account_hash, + guarded_intent: LiveExecutionIntent = intent, + expected_promotion_binding: ( + VerifiedPromotionBinding | None + ) = promotion_binding, + ) -> datetime: + guard_now = ( + authority_clock() + if authority_clock is not None + else datetime.now(timezone.utc) + ) + if dumbmoney_kill_path(repository_root).is_file(): + raise RobinhoodGatewayError( + "Local DumbMoney kill became active before broker submission" + ) + if dumbmoney_controls is not None and ( + dumbmoney_controls.kill_active + or not dumbmoney_controls.allow_new_risk + ): + raise RobinhoodGatewayError( + "DumbMoney command controls deny broker submission" + ) + try: + refreshed_context = effective_authority + if dumbmoney_control_refresh is not None: + try: + refreshed_controls = dumbmoney_control_refresh( + guard_now + ) + except Exception as error: + raise DumbMoneyAuthorityError( + "DumbMoney command refresh failed closed: " + f"{type(error).__name__}" + ) from error + if not isinstance( + refreshed_controls, + DumbMoneyCommandDispatch, + ): + raise DumbMoneyAuthorityError( + "DumbMoney command refresh returned an " + "unsupported control snapshot" + ) + if ( + refreshed_controls.kill_active + or not refreshed_controls.allow_new_risk + or refreshed_controls.capital_authority is None + ): + raise DumbMoneyAuthorityError( + "Refreshed DumbMoney command controls deny " + "broker submission" + ) + refreshed_context = refreshed_controls.capital_authority + if refreshed_context is None: + raise DumbMoneyAuthorityError( + "Final DumbMoney capital authority is absent" + ) + guarded_authority = refreshed_context.verify( + guard_now, + expected_account_hash, + ) + guarded_identity = ( + guarded_authority.envelope.envelope_id, + guarded_authority.event_id, + guarded_authority.body_digest, + guarded_authority.envelope.fencing_generation, + guarded_authority.envelope.policy_epoch, + ) + expected_identity = ( + expected_envelope_id, + expected_event_id, + expected_body_digest, + expected_generation, + expected_policy_epoch, + ) + if guarded_identity != expected_identity: + raise DumbMoneyAuthorityError( + "Capital authority changed before broker submission" + ) + assert_current_dumbmoney_fence( + repository_root, + guarded_authority.envelope.envelope_id, + guarded_authority.event_id, + guarded_authority.body_digest, + guarded_authority.envelope.fencing_generation, + guarded_authority.envelope.policy_epoch, + ) + guarded_binding = expected_promotion_binding + if guarded_intent.side == "buy": + if ( + dumbmoney_promotion_resolver is None + or expected_promotion_binding is None + ): + raise DumbMoneyAuthorityError( + "Final authenticated promotion binding is absent" + ) + _, guarded_binding = ( + dumbmoney_promotion_resolver.bind_intent( + guarded_authority, + guarded_intent, + guard_now, + expected_binding=expected_promotion_binding, + ) + ) + authorize_intent( + guarded_authority, + sink_account, + guarded_intent, + sink_daily_loss_cents, + ) + if ( + guarded_intent.instrument == "option" + and guarded_intent.side == "buy" + ): + validate_option_entry_for_execution( + guarded_intent, + guard_now, + ) + capital_deadline = datetime.fromisoformat( + guarded_authority.envelope.expires_at.replace( + "Z", + "+00:00", + ) + ) + if guarded_binding is None: + return capital_deadline + promotion_deadline = datetime.fromisoformat( + guarded_binding.expires_at.replace("Z", "+00:00") + ) + return min(capital_deadline, promotion_deadline) + except ( + DumbMoneyAuthorityError, + LiveBranchLedgerError, + LiveSignalError, + ) as error: + raise RobinhoodGatewayError( + "Final DumbMoney broker-sink guard denied submission: " + f"{error}" + ) from error + + pre_submit_guard = final_broker_sink_guard + except ( + DumbMoneyAuthorityError, + LiveBranchLedgerError, + LiveSignalError, + RobinhoodGatewayError, + ) as error: + blocked_payload = { + "intent_id": intent.intent_id, + "instrument": intent.instrument, + "symbol": intent.symbol, + "side": intent.side, + "market_date": market_date, + "paper_twin_record_hash": twin["record_hash"], + "paper_twin_execution_record_hash": paper_twin_execution[ + "record_hash" + ], + "intent": intent.to_dict(), + "capital_authority": capital_authority_evidence, + "review_succeeded": False, + "placed": False, + "order_id": "", + "status": "blocked_dumbmoney_broker_sink_authority", + "warnings": [], + "error": str(error), + "observed_tools": [], + } + live_record = append_record( + ledger_path(repository_root), + "live_order_result", + blocked_payload, + ) + _append_paper_twin_outcome( + repository_root, + intent.intent_id, + str(twin["record_hash"]), + str(live_record["record_hash"]), + "blocked_dumbmoney_broker_sink_authority", + False, + None, + None, + ) + outcomes.append(blocked_payload) + continue append_record( ledger_path(repository_root), "live_order_submission_started", @@ -1265,14 +2323,121 @@ def run_live_branch_cycle( "side": intent.side, "market_date": market_date, "paper_twin_record_hash": twin["record_hash"], - "paper_twin_execution_record_hash": paper_twin_execution[ - "record_hash" - ], + "paper_twin_execution_record_hash": paper_twin_execution["record_hash"], "intent": intent.to_dict(), + "capital_authority": capital_authority_evidence, }, ) + if pre_submit_guard is not None: + append_record( + ledger_path(repository_root), + "dumbmoney_order_dispatching", + { + "intent_id": intent.intent_id, + "instrument": intent.instrument, + "symbol": intent.symbol, + "market_date": market_date, + "status": "DISPATCHING", + "external_write_boundary": ( + "single_at_most_once_deterministic_robinhood_place_call" + ), + "placement_retries_allowed": 0, + "capital_authority": capital_authority_evidence, + }, + ) try: - order_result = gateway.place_order(intent, str(twin["record_hash"])) + if pre_submit_guard is None: + order_result = gateway.place_order( + intent, + str(twin["record_hash"]), + ) + else: + order_result = gateway.place_order( + intent, + str(twin["record_hash"]), + pre_submit_guard=pre_submit_guard, + ) + in_flight_revocation_error = "" + if pre_submit_guard is not None: + try: + pre_submit_guard() + except RobinhoodGatewayError as error: + in_flight_revocation_error = str(error) + if in_flight_revocation_error: + ambiguous_payload = { + "intent_id": intent.intent_id, + "instrument": intent.instrument, + "symbol": intent.symbol, + "side": intent.side, + "market_date": market_date, + "paper_twin_record_hash": twin["record_hash"], + "paper_twin_execution_record_hash": paper_twin_execution[ + "record_hash" + ], + "intent": intent.to_dict(), + "review_succeeded": order_result.review_succeeded, + "placed": None, + "order_id": order_result.order_id, + "status": "ambiguous_in_flight_authority_revoked", + "warnings": list(order_result.warnings), + "error": in_flight_revocation_error, + "observed_tools": list(order_result.observed_tools), + "capital_authority": capital_authority_evidence, + "placement_retried": False, + "in_flight_authority_revoked": True, + "broker_result_before_revocation_detection": ( + order_result.to_dict() + ), + } + ambiguous_record = append_record( + ledger_path(repository_root), + "live_order_submission_ambiguous", + ambiguous_payload, + ) + cancel_reconciliation: dict[str, Any] + try: + revocation_account = gateway.get_account_snapshot() + except RobinhoodGatewayError as error: + cancel_reconciliation = { + "cycle_status": "cancel_only_degraded", + "authority_state": "CANCEL_ONLY_DEGRADED", + "cancel_only_reason": ( + "in_flight_authority_revoked_account_poll_failed" + ), + "new_exposure_allowed": False, + "kill_active": True, + "cancel_requested": False, + "history_poll_errors": [str(error)], + "orders": [], + } + else: + cancel_reconciliation = _run_cancel_only_degraded( + repository_root, + gateway, + revocation_account, + datetime.now(timezone.utc), + "in_flight_authority_revoked", + True, + effective_kill_active=True, + ) + account = AccountSnapshot(**cancel_reconciliation["account"]) + _append_paper_twin_outcome( + repository_root, + intent.intent_id, + str(twin["record_hash"]), + str(ambiguous_record["record_hash"]), + "ambiguous_in_flight_authority_revoked", + None, + None, + None, + ) + outcomes.append( + { + **ambiguous_payload, + "cancel_reconciliation": cancel_reconciliation, + } + ) + continue except RobinhoodPlacementAmbiguousError as error: ambiguous_payload = { "intent_id": intent.intent_id, @@ -1281,9 +2446,7 @@ def run_live_branch_cycle( "side": intent.side, "market_date": market_date, "paper_twin_record_hash": twin["record_hash"], - "paper_twin_execution_record_hash": paper_twin_execution[ - "record_hash" - ], + "paper_twin_execution_record_hash": paper_twin_execution["record_hash"], "intent": intent.to_dict(), "review_succeeded": False, "placed": None, @@ -1292,6 +2455,8 @@ def run_live_branch_cycle( "warnings": [], "error": str(error), "observed_tools": [], + "capital_authority": capital_authority_evidence, + "placement_retried": False, } ambiguous_record = append_record( ledger_path(repository_root), @@ -1320,7 +2485,45 @@ def run_live_branch_cycle( None, None, ) - outcomes.append(ambiguous_payload) + reconciled_payload: dict[str, Any] | None = None + history_poll_count = 0 + if verified_authority is not None: + reconciled_payload, polled_account, history_poll_count = ( + _poll_ambiguous_placement_history( + repository_root, + gateway, + intent, + market_date, + sink_now, + str(twin["record_hash"]), + ) + ) + if polled_account is not None: + account = polled_account + if reconciled_payload is None: + outcomes.append( + { + **ambiguous_payload, + "automatic_history_poll_count": history_poll_count, + "authority_state": ( + "CANCEL_ONLY_DEGRADED" + if verified_authority is not None + else "LEGACY_MANUAL_RECONCILIATION" + ), + } + ) + else: + _append_paper_twin_outcome( + repository_root, + intent.intent_id, + str(twin["record_hash"]), + str(reconciled_payload["automatic_history_record_hash"]), + str(reconciled_payload["status"]), + True, + float(reconciled_payload["filled_quantity"]), + float(reconciled_payload["average_fill_price"]), + ) + outcomes.append(reconciled_payload) continue except RobinhoodGatewayError as error: blocked_payload = { @@ -1330,9 +2533,7 @@ def run_live_branch_cycle( "side": intent.side, "market_date": market_date, "paper_twin_record_hash": twin["record_hash"], - "paper_twin_execution_record_hash": paper_twin_execution[ - "record_hash" - ], + "paper_twin_execution_record_hash": paper_twin_execution["record_hash"], "intent": intent.to_dict(), "review_succeeded": False, "placed": False, @@ -1409,13 +2610,20 @@ def run_live_branch_cycle( ) outcomes.append(live_payload) blocked_order_count = sum( - str(outcome.get("status", "")).startswith(("blocked_", "ambiguous_submission_")) + str(outcome.get("status", "")).startswith(("blocked_", "ambiguous_")) + for outcome in outcomes + ) + dumbmoney_cancel_only = verified_authority is not None and any( + outcome.get("authority_state") == "CANCEL_ONLY_DEGRADED" + or outcome.get("status") == "ambiguous_in_flight_authority_revoked" for outcome in outcomes ) result = { **base, "cycle_status": ( - "complete_exits_only_unresolved_submission_entries_blocked" + "cancel_only_degraded" + if dumbmoney_cancel_only + else "complete_exits_only_unresolved_submission_entries_blocked" if unresolved_submission_present and exit_intents else "blocked_unresolved_submission_no_exit_intents" if unresolved_submission_present @@ -1434,6 +2642,20 @@ def run_live_branch_cycle( "options_execution_enabled": control.options_execution_enabled, "use_full_available_balance": control.use_full_available_balance, "settled_funds": settled_funds.to_dict(), + "dumbmoney_authorized_entry_cash_usd": ( + entry_cash_usd if verified_authority is not None else None + ), + "witnessed_daily_loss_cents": witnessed_daily_loss_cents, + "capital_authority": ( + verified_authority.evidence() if verified_authority is not None else None + ), + "authority_state": ( + "CANCEL_ONLY_DEGRADED" + if dumbmoney_cancel_only + else "ACTIVE" + if verified_authority is not None + else "LEGACY_DISABLED" + ), "reconciled_order_status_count": len(reconciled_orders), "legacy_twin_observation_count": len(legacy_twin_observations), "owner_liquidation_settlement_count": len(owner_liquidation_settlements), @@ -1456,4 +2678,8 @@ def run_live_branch_cycle( def default_gateway() -> RobinhoodAgenticGateway: - return RobinhoodAgenticGateway(subprocess.run, 60) + return RobinhoodAgenticGateway( + subprocess.run, + 60, + write_timeout_seconds=30, + ) diff --git a/dopey_live/signals.py b/dopey_live/signals.py index b0a28db..1508de2 100644 --- a/dopey_live/signals.py +++ b/dopey_live/signals.py @@ -10,7 +10,7 @@ from datetime import date, datetime, timedelta, timezone from decimal import ROUND_DOWN, ROUND_UP, Decimal from pathlib import Path -from typing import Any, Final +from typing import Any, Final, cast from zoneinfo import ZoneInfo import exchange_calendars as xcals @@ -22,6 +22,7 @@ ) from .models import ( + AnalysisRelationship, EQUITY_SNAPSHOT_SOURCE, EquityExecutionSnapshot, EquityQuoteSnapshot, @@ -45,9 +46,7 @@ ACTION_READINESS_ARTIFACT_TYPE: Final[str] = "action_readiness_gate" RELEASE_GATE_VERSION: Final[str] = "dopey-live-pivot-release-gate-v1" CALIBRATION_VERSION: Final[str] = "dopey-vnext-calibration-v2" -CHAMPION_HARDENING_VERSION: Final[str] = ( - "dopey-public-champion-hardening-gate-v1" -) +CHAMPION_HARDENING_VERSION: Final[str] = "dopey-public-champion-hardening-gate-v1" MAX_GATE_RECOMPUTE_AGE: Final[timedelta] = timedelta(minutes=5) MAX_RESEARCH_INPUT_AGE: Final[timedelta] = timedelta(hours=36) MAX_ARTIFACT_FUTURE_SKEW: Final[timedelta] = timedelta(seconds=5) @@ -274,8 +273,7 @@ def _artifact_integrity_blockers( and fitness_determinism.get("deterministic") is True ) hardened_champion_available = bool( - champion.get("artifact_type") - == "public_champion_hardening_gate_v1_summary" + champion.get("artifact_type") == "public_champion_hardening_gate_v1_summary" and champion.get("public_champion_hardening_gate_version") == CHAMPION_HARDENING_VERSION and type(champion.get("hardened_champion_count")) is int @@ -315,15 +313,12 @@ def _artifact_integrity_blockers( "action_release_binding_invalid", ), ( - calibration.get("artifact_type") - == "vnext_probability_calibration_summary" + calibration.get("artifact_type") == "vnext_probability_calibration_summary" and calibration.get("calibration_version") == CALIBRATION_VERSION, "forward_calibration_schema_invalid", ), ( - SHA256_PATTERN.fullmatch( - str(calibration.get("reproducibility_hash", "")) - ) + SHA256_PATTERN.fullmatch(str(calibration.get("reproducibility_hash", ""))) is not None and calibration.get("reproducibility_hash") == _canonical_summary_hash(calibration), @@ -343,15 +338,13 @@ def _artifact_integrity_blockers( "forward_calibration_stale", ), ( - champion.get("artifact_type") - == "public_champion_hardening_gate_v1_summary" + champion.get("artifact_type") == "public_champion_hardening_gate_v1_summary" and champion.get("public_champion_hardening_gate_version") == CHAMPION_HARDENING_VERSION, "champion_hardening_schema_invalid", ), ( - SHA256_PATTERN.fullmatch(str(champion.get("summary_hash", ""))) - is not None + SHA256_PATTERN.fullmatch(str(champion.get("summary_hash", ""))) is not None and champion.get("summary_hash") == _stable_summary_hash(champion), "champion_hardening_hash_invalid", ), @@ -373,8 +366,7 @@ def _artifact_integrity_blockers( "nightly_schema_invalid", ), ( - SHA256_PATTERN.fullmatch(str(nightly.get("summary_hash", ""))) - is not None + SHA256_PATTERN.fullmatch(str(nightly.get("summary_hash", ""))) is not None and nightly.get("summary_hash") == _stable_summary_hash(nightly), "nightly_hash_invalid", ), @@ -392,8 +384,7 @@ def _artifact_integrity_blockers( "release_champion_binding_invalid", ), ( - release_checks_object.get("full_nightly_validation_green") - is nightly_green, + release_checks_object.get("full_nightly_validation_green") is nightly_green, "release_nightly_binding_invalid", ), ( @@ -413,9 +404,7 @@ def _artifact_integrity_blockers( def research_maturity_blockers(repository_root: Path) -> tuple[str, ...]: artifact_root = repository_root.resolve() / "artifacts" / "dopey" action_path = artifact_root / "broker_readiness" / "action_readiness_gate.json" - release_path = ( - artifact_root / "broker_readiness" / "live_pivot_release_gate.json" - ) + release_path = artifact_root / "broker_readiness" / "live_pivot_release_gate.json" calibration_path = ( artifact_root / "vnext" @@ -751,11 +740,22 @@ def prepare_equity_entry_for_execution( ) if notional_usd <= 0.0: raise LiveSignalError("Equity limit order has no positive risk-capped notional") + quantity = float( + (Decimal(str(notional_usd)) / Decimal(str(limit_price))).quantize( + Decimal("0.000001"), + rounding=ROUND_DOWN, + ) + ) + if quantity <= 0.0: + raise LiveSignalError( + "Equity limit order has no positive deterministic share quantity" + ) return replace( intent, order_type="limit", limit_price=limit_price, notional_usd=notional_usd, + quantity=quantity, execution_snapshot=snapshot, reason_codes=( *intent.reason_codes, @@ -1038,7 +1038,10 @@ def build_leveraged_etf_entry_intents( source_hash=source_hash, thesis_direction=str(plan.get("exposure_direction", "")), analysis_symbol=analysis_symbol, - analysis_relationship=str(plan.get("analysis_relationship", "")), + analysis_relationship=cast( + AnalysisRelationship, + str(plan.get("analysis_relationship", "")), + ), analysis_reference_price=float(entry_plan["analysis_reference_price"]), analysis_invalidation_price=float( exit_plan["analysis_invalidation_price"] @@ -1147,10 +1150,18 @@ def build_option_entry_intents( direction = str(thesis.get("direction", "")) if (direction, option_type) not in {("bullish", "call"), ("bearish", "put")}: continue - ask = float(primary.get("ask", 0.0)) + try: + ask = float(primary.get("ask", 0.0)) + multiplier = int(primary.get("multiplier", 0)) + expiry = date.fromisoformat(str(primary.get("expiry", ""))) + except (TypeError, ValueError): + continue contract_score = float(primary.get("contract_score", 0.0)) if ( ask <= 0.0 + or not math.isfinite(ask) + or multiplier != 100 + or expiry < market_date or contract_score <= 0.0 or str(primary.get("hard_reject_reason", "")) or not _option_quote_matches_market_session(primary, market_date) @@ -1173,6 +1184,7 @@ def build_option_entry_intents( continue option_contract = { "contract_symbol_key": str(primary.get("contract_symbol_key", "")), + "contract_id": str(primary.get("contract_id", "")), "expiry": str(primary.get("expiry", "")), "option_type": str( primary.get("option_type", selection.get("option_type", "")) @@ -1182,6 +1194,7 @@ def build_option_entry_intents( "order_action": "buy_to_open", "defined_risk": True, "quote_observed_at": str(primary["quote_observed_at"]), + "multiplier": multiplier, "maximum_loss_usd": round(premium_per_contract * quantity, 2), } source = {"thesis": thesis, "contract": selection} @@ -1236,6 +1249,64 @@ def build_option_entry_intents( return tuple(intents) +def validate_option_entry_for_execution( + intent: LiveExecutionIntent, + now_utc: datetime, + maximum_quote_age: timedelta = timedelta(seconds=60), +) -> None: + """Require a fresh, exact, fully bounded long-premium option debit.""" + if intent.instrument != "option" or intent.side != "buy": + raise LiveSignalError( + "Option execution validation is limited to long-premium entries" + ) + if now_utc.tzinfo is None: + raise LiveSignalError("Option execution validation requires aware now_utc") + contract = intent.option_contract + try: + observed_at = datetime.fromisoformat( + str(contract.get("quote_observed_at", "")).replace("Z", "+00:00") + ) + expiry = date.fromisoformat(str(contract.get("expiry", ""))) + multiplier = int(contract.get("multiplier", 0)) + maximum_loss = Decimal(str(contract.get("maximum_loss_usd", ""))) + exact_debit = ( + Decimal(str(intent.limit_price)) + * Decimal(str(intent.quantity)) + * Decimal(multiplier) + ) + notional = Decimal(str(intent.notional_usd)) + except (ValueError, TypeError, ArithmeticError) as error: + raise LiveSignalError( + "Option execution contract has invalid quote, expiry, multiplier, or debit fields" + ) from error + if observed_at.tzinfo is None: + raise LiveSignalError("Option execution quote must be timezone-aware") + quote_age = now_utc.astimezone(timezone.utc) - observed_at.astimezone(timezone.utc) + if quote_age < timedelta(seconds=-5) or quote_age > maximum_quote_age: + raise LiveSignalError( + "Option execution quote is outside the 60-second freshness window" + ) + if expiry < now_utc.astimezone(NEW_YORK).date(): + raise LiveSignalError("Option contract is expired") + if multiplier != 100: + raise LiveSignalError("Option contract multiplier must be exactly 100") + if ( + intent.order_type != "limit" + or intent.limit_price <= 0.0 + or intent.quantity <= 0.0 + or not str(contract.get("contract_id", "")).strip() + or contract.get("defined_risk") is not True + or contract.get("order_action") != "buy_to_open" + ): + raise LiveSignalError( + "Option entry must be an exact defined-risk buy-to-open limit order" + ) + if exact_debit <= 0 or maximum_loss != exact_debit or notional != exact_debit: + raise LiveSignalError( + "Option max loss and notional must equal exact limit debit" + ) + + def build_entry_intents( repository_root: Path, buying_power_usd: float, diff --git a/operator_tools/install_live_branch_schedule.ps1 b/operator_tools/install_live_branch_schedule.ps1 index 46484dd..b8b13b5 100644 --- a/operator_tools/install_live_branch_schedule.ps1 +++ b/operator_tools/install_live_branch_schedule.ps1 @@ -5,9 +5,13 @@ $ErrorActionPreference = "Stop" Set-StrictMode -Version Latest $repoRoot = (Resolve-Path -LiteralPath (Join-Path $PSScriptRoot "..")).Path -$expectedRoot = "C:\Users\CH\dopey" -if (-not $repoRoot.Equals($expectedRoot, [System.StringComparison]::OrdinalIgnoreCase)) { - throw "Repository root mismatch: expected=$expectedRoot actual=$repoRoot" +$projectMarker = Join-Path $repoRoot "pyproject.toml" +$commandMarker = Join-Path $repoRoot "operator_tools\live_branch.cmd" +if ( + -not (Test-Path -LiteralPath $projectMarker -PathType Leaf) -or + -not (Test-Path -LiteralPath $commandMarker -PathType Leaf) +) { + throw "Resolved repository root lacks required DOPEY release markers: root=$repoRoot" } $systemRoot = [Environment]::GetEnvironmentVariable("SystemRoot") diff --git a/operator_tools/live_branch.cmd b/operator_tools/live_branch.cmd index 41b640f..2b02e5a 100644 --- a/operator_tools/live_branch.cmd +++ b/operator_tools/live_branch.cmd @@ -1,6 +1,6 @@ @echo off -rem DOPEY Live Branch production cycle. This launcher is intentionally separate -rem from every paper-research command and control surface. +rem DOPEY Live Branch legacy read-only observation cycle. Production execution +rem is available only through the supervised DumbMoney Windows service. setlocal set ROOT=%~dp0.. cd /d "%ROOT%" @@ -9,7 +9,7 @@ set LOG_FILE=%LOG_DIR%\live_branch_scheduler.log if not exist "%LOG_DIR%" mkdir "%LOG_DIR%" for %%A in ("%LOG_FILE%") do if %%~zA GTR 1048576 move /y "%LOG_FILE%" "%LOG_FILE%.old" >nul echo ==== DOPEY Live Branch %date% %time% ==== >> "%LOG_FILE%" -"%ROOT%\.venv\Scripts\python.exe" -m operator_tools.operator_live_branch --execute >> "%LOG_FILE%" 2>&1 +"%ROOT%\.venv\Scripts\python.exe" -m operator_tools.operator_live_branch >> "%LOG_FILE%" 2>&1 set LIVE_RC=%errorlevel% echo ==== Live Branch complete rc=%LIVE_RC% %time% ==== >> "%LOG_FILE%" exit /b %LIVE_RC% diff --git a/operator_tools/operator_live_branch.py b/operator_tools/operator_live_branch.py index 00bc905..df7c508 100644 --- a/operator_tools/operator_live_branch.py +++ b/operator_tools/operator_live_branch.py @@ -1,4 +1,5 @@ """Run or preflight the DOPEY Live Branch.""" + from __future__ import annotations import argparse @@ -8,8 +9,11 @@ from contextlib import contextmanager from datetime import datetime, timedelta, timezone from pathlib import Path -from typing import Iterator +from typing import Callable, Iterator +from dopey_live.dumbmoney_authority import DumbMoneyAuthorityContext +from dopey_live.dumbmoney_command_feed import DumbMoneyCommandDispatch +from dopey_live.dumbmoney_promotion import PromotionAuthorityResolver from dopey_live.ledger import write_latest_cycle from dopey_live.service import default_gateway, run_live_branch_cycle @@ -123,16 +127,50 @@ def _exclusive_live_process(now_utc: datetime) -> Iterator[None]: lock_path.unlink() -def _run_with_crash_tombstone(execute_orders: bool) -> dict[str, object]: +def _run_with_crash_tombstone( + execute_orders: bool, + dumbmoney_authority: DumbMoneyAuthorityContext | None = None, + *, + dumbmoney_controls: DumbMoneyCommandDispatch | None = None, + dumbmoney_control_refresh: ( + Callable[[datetime], DumbMoneyCommandDispatch] | None + ) = None, + dumbmoney_promotion_resolver: PromotionAuthorityResolver | None = None, +) -> dict[str, object]: + if execute_orders: + raise RuntimeError( + "Legacy operator execution is disabled; use the supervised " + "DumbMoney Windows service" + ) started_at_utc = datetime.now(timezone.utc) try: with _exclusive_live_process(started_at_utc): - return run_live_branch_cycle( + resolved_controls = dumbmoney_controls + if ( + resolved_controls is None + and dumbmoney_authority is None + and dumbmoney_control_refresh is not None + ): + resolved_controls = dumbmoney_control_refresh(started_at_utc) + arguments = ( REPOSITORY_ROOT, default_gateway(), started_at_utc, execute_orders, ) + if ( + dumbmoney_authority is None + and resolved_controls is None + and dumbmoney_control_refresh is None + ): + return run_live_branch_cycle(*arguments) + return run_live_branch_cycle( + *arguments, + dumbmoney_authority=dumbmoney_authority, + dumbmoney_controls=resolved_controls, + dumbmoney_control_refresh=dumbmoney_control_refresh, + dumbmoney_promotion_resolver=dumbmoney_promotion_resolver, + ) except Exception as error: tombstone: dict[str, object] = { "schema_version": "dopey-live-branch-cycle-v2", @@ -156,11 +194,92 @@ def _run_with_crash_tombstone(execute_orders: bool) -> dict[str, object]: raise +def run_supervised_dumbmoney_cycle( + command_feed_poll: Callable[[datetime], DumbMoneyCommandDispatch], + promotion_resolver: PromotionAuthorityResolver, + *, + execute_orders: bool, +) -> dict[str, object]: + """Run one process-locked cycle with initial and broker-boundary feed polls.""" + + return _run_with_crash_tombstone( + execute_orders, + dumbmoney_control_refresh=command_feed_poll, + dumbmoney_promotion_resolver=promotion_resolver, + ) + + def main() -> int: - parser = argparse.ArgumentParser(description="Operate DOPEY Live Branch through official Robinhood Agentic MCP") - parser.add_argument("--execute", action="store_true", help="Submit eligible reviewed orders while the market is open") + parser = argparse.ArgumentParser( + description="Operate DOPEY Live Branch through official Robinhood Agentic MCP" + ) + parser.add_argument( + "--execute", + action="store_true", + help=( + "Disabled legacy switch; production execution requires the " + "supervised DumbMoney Windows service" + ), + ) + parser.add_argument( + "--dumbmoney-strict", + action="store_true", + help=( + "Require complete DumbMoney authority inputs for read-only legacy preflight" + ), + ) + parser.add_argument( + "--dumbmoney-envelope", + type=Path, + help="Opt in to DumbMoney authority with this signed CapitalEnvelopeV1", + ) + parser.add_argument( + "--dumbmoney-public-key", + type=Path, + help="Trusted DumbMoney Ed25519 public key (raw, hex, or base64url)", + ) + parser.add_argument( + "--dumbmoney-account-hash", + help="Pinned lowercase SHA-256 of the dedicated Agentic account ID", + ) arguments = parser.parse_args() - result = _run_with_crash_tombstone(bool(arguments.execute)) + if arguments.execute: + parser.error( + "--execute is disabled in the legacy operator; use " + "dopey_live.dumbmoney_windows_service" + ) + dumbmoney_values = ( + arguments.dumbmoney_envelope, + arguments.dumbmoney_public_key, + arguments.dumbmoney_account_hash, + ) + if any(value is not None for value in dumbmoney_values) and not all( + value is not None for value in dumbmoney_values + ): + parser.error( + "DumbMoney mode requires --dumbmoney-envelope, " + "--dumbmoney-public-key, and --dumbmoney-account-hash together" + ) + if arguments.dumbmoney_strict and not all( + value is not None for value in dumbmoney_values + ): + parser.error( + "--dumbmoney-strict requires the complete DumbMoney envelope, " + "public key, and account hash" + ) + authority = ( + DumbMoneyAuthorityContext.from_files( + arguments.dumbmoney_envelope, + arguments.dumbmoney_public_key, + arguments.dumbmoney_account_hash, + ) + if all(value is not None for value in dumbmoney_values) + else None + ) + result = _run_with_crash_tombstone( + False, + authority, + ) print(json.dumps(result, indent=2, sort_keys=True, ensure_ascii=False)) cycle_status = str(result.get("cycle_status", "")) successful_statuses = { diff --git a/tests/_dummy_v2_manifest.txt b/tests/_dummy_v2_manifest.txt new file mode 100644 index 0000000..21aa0d1 --- /dev/null +++ b/tests/_dummy_v2_manifest.txt @@ -0,0 +1 @@ +2E08B856193A6542D3B19CD5EE6EE9C6D1B7B607DC73DC875F75B2F5933DD59D C:\src\engine\dopey-dumbmoney\tests\dummy_blunder.py diff --git a/tests/dummy_blunder.py b/tests/dummy_blunder.py new file mode 100644 index 0000000..4a504b4 --- /dev/null +++ b/tests/dummy_blunder.py @@ -0,0 +1 @@ +# original blunder content diff --git a/tests/fixtures/dumbmoney/capital-envelope-cap-order-negative.v1.json b/tests/fixtures/dumbmoney/capital-envelope-cap-order-negative.v1.json new file mode 100644 index 0000000..9a771ef --- /dev/null +++ b/tests/fixtures/dumbmoney/capital-envelope-cap-order-negative.v1.json @@ -0,0 +1,23 @@ +{ + "fixture_schema": "dopey.dumbmoney-capital-envelope-negative-cases.v1", + "cases": [ + { + "name": "max_order_exceeds_max_correlated", + "overrides": { + "max_order_risk_cents": 301, + "max_correlated_risk_cents": 300, + "max_open_risk_cents": 1250 + }, + "expected_error": "max_order_risk_cents <= max_correlated_risk_cents <= max_open_risk_cents" + }, + { + "name": "max_correlated_exceeds_max_open", + "overrides": { + "max_order_risk_cents": 100, + "max_correlated_risk_cents": 1251, + "max_open_risk_cents": 1250 + }, + "expected_error": "max_order_risk_cents <= max_correlated_risk_cents <= max_open_risk_cents" + } + ] +} diff --git a/tests/fixtures/dumbmoney/signed-capital-envelope.v1.json b/tests/fixtures/dumbmoney/signed-capital-envelope.v1.json new file mode 100644 index 0000000..7327a1d --- /dev/null +++ b/tests/fixtures/dumbmoney/signed-capital-envelope.v1.json @@ -0,0 +1,50 @@ +{ + "fixture_schema": "dumbmoney.signed-capital-envelope-fixture.v1", + "public_key_base64url": "A6EHv_POEL4dcN0Y50vAmWfk1jCbpQ1fHdyGZBJVMbg", + "signing_payload_sha256": "97f8ecdf0d6b48b310c06cdeb37654fb171f4fa62bf91b427965191643d7ee1e", + "envelope": { + "schema": "dumbmoney.signed-envelope.v1", + "source_id": "dumbmoney-core", + "source_sequence": 1, + "event_id": "95fd19cc7bcbc89fe3c7985ee04d502704344b2f09c1eb6dce575b41c0ae579c", + "correlation_id": "fixture-capital-001", + "causation_id": null, + "nonce": "fixture-nonce-001", + "not_before": "2099-01-01T00:00:00Z", + "expires_at": "2099-01-01T00:01:00Z", + "body_schema": "dumbmoney.capital-envelope.v1", + "body_digest": "fcf86b4c257c0fea9ba65051366b76738563598d4217c4950ef3cf63ed1927d0", + "body": { + "schema": "dumbmoney.capital-envelope.v1", + "envelope_id": "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee", + "mandate_id": "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", + "venue": "dopey_robinhood", + "account_hash": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "strategy_hashes": [ + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + ], + "passport_hashes": [ + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + ], + "promotion_hashes": [ + "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff" + ], + "authorized_instruments": [ + "equity:ACME" + ], + "authorized_mode": "LIVE", + "max_order_risk_cents": 100, + "max_open_risk_cents": 1250, + "max_correlated_risk_cents": 300, + "max_daily_loss_cents": 300, + "max_open_orders": 5, + "fencing_generation": 7, + "policy_epoch": 1, + "not_before": "2099-01-01T00:00:00Z", + "expires_at": "2099-01-01T00:01:00Z" + }, + "signature_algorithm": "Ed25519", + "signer_key_id": "56475aa75463474c0285df5dbf2bcab73da651358839e9b77481b2eab107708c", + "signature": "ga9nH3SEc6uGk8U_Q-tqWXa76n_-OSq8VhUAPHlNdTp5jzGmcZkN8JIjXuhpidniz4NOuTjpPWUE9vvtiqQ7CQ" + } +} diff --git a/tests/test_dumbmoney_command_feed.py b/tests/test_dumbmoney_command_feed.py new file mode 100644 index 0000000..f8c91b1 --- /dev/null +++ b/tests/test_dumbmoney_command_feed.py @@ -0,0 +1,1119 @@ +from __future__ import annotations + +import base64 +import hashlib +import json +from datetime import datetime, timedelta, timezone +from pathlib import Path +from types import SimpleNamespace +from typing import Any, Callable +from unittest.mock import patch +from urllib.parse import parse_qs, urlsplit + +import pytest +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey + +import dopey_live.service as live_service +from dopey_live.dumbmoney_authority import ( + VerifiedCapitalAuthority, + canonical_json_bytes, +) +from dopey_live.dumbmoney_command_feed import ( + CommandFeedHttpRequest, + CommandFeedHttpResponse, + poll_once, +) +from dopey_live.ledger import append_record, ledger_path, read_ledger +from dopey_live.dumbmoney_promotion import VerifiedPromotionBinding +from dopey_live.models import ( + AccountSnapshot, + GatewayCancelResult, + GatewayOrderResult, + LiveExecutionIntent, +) +from dopey_live.service import run_live_branch_cycle + + +NOW = datetime(2099, 1, 1, 0, 0, 30, tzinfo=timezone.utc) +ACCOUNT_HASH = "c" * 64 +STRATEGY_HASH = "a" * 64 +PASSPORT_HASH = "d" * 64 +PROMOTION_HASH = "f" * 64 +ZERO_DIGEST = "0" * 64 +TEST_REQUEST_NONCE = "7" * 64 + + +def _format_utc(value: datetime) -> str: + rendered = value.astimezone(timezone.utc).isoformat( + timespec="microseconds" if value.microsecond else "seconds" + ) + return rendered.replace("+00:00", "Z") + + +def _sign( + private_key: Ed25519PrivateKey, + body: dict[str, Any], + *, + source_sequence: int, + not_before: datetime, + expires_at: datetime, +) -> dict[str, Any]: + public_key = private_key.public_key().public_bytes_raw() + unsigned: dict[str, Any] = { + "schema": "dumbmoney.signed-envelope.v1", + "source_id": "dumbmoney-core", + "source_sequence": source_sequence, + "correlation_id": "feed-test", + "causation_id": None, + "nonce": f"feed-test-{source_sequence}", + "not_before": _format_utc(not_before), + "expires_at": _format_utc(expires_at), + "body_schema": body["schema"], + "body_digest": hashlib.sha256(canonical_json_bytes(body)).hexdigest(), + "body": body, + "signature_algorithm": "Ed25519", + "signer_key_id": hashlib.sha256(public_key).hexdigest(), + } + event_material = dict(unsigned) + unsigned["event_id"] = hashlib.sha256( + canonical_json_bytes(event_material) + ).hexdigest() + signature = private_key.sign(canonical_json_bytes(unsigned)) + return { + **unsigned, + "signature": base64.urlsafe_b64encode(signature).decode("ascii").rstrip("="), + } + + +def _kill_body(active: bool, generation: int = 1) -> dict[str, Any]: + return { + "schema": "dumbmoney.kill-state.v1", + "active": active, + "generation": generation, + "reason": "feed test kill state", + "changed_by": "owner", + "policy_epoch": 1, + "changed_at": _format_utc(NOW - timedelta(seconds=1)), + } + + +def _mode_body(mode: str = "LIVE", revision: int = 1) -> dict[str, Any]: + return { + "schema": "dumbmoney.desired-mode.v1", + "venue": "dopey_robinhood", + "mode": mode, + "revision": revision, + "reason": "feed test desired mode", + "policy_epoch": 1, + "not_before": _format_utc(NOW - timedelta(seconds=5)), + "expires_at": _format_utc(NOW + timedelta(minutes=10)), + } + + +def _capital_body(generation: int = 1) -> dict[str, Any]: + return { + "schema": "dumbmoney.capital-envelope.v1", + "envelope_id": hashlib.sha256(f"capital-{generation}".encode()).hexdigest(), + "mandate_id": "9" * 64, + "venue": "dopey_robinhood", + "account_hash": ACCOUNT_HASH, + "strategy_hashes": [STRATEGY_HASH], + "passport_hashes": [PASSPORT_HASH], + "promotion_hashes": [PROMOTION_HASH], + "authorized_instruments": ["equity:ACME"], + "authorized_mode": "LIVE", + "max_order_risk_cents": 100, + "max_open_risk_cents": 500, + "max_correlated_risk_cents": 300, + "max_daily_loss_cents": 300, + "max_open_orders": 1, + "fencing_generation": generation, + "policy_epoch": 1, + "not_before": _format_utc(NOW), + "expires_at": _format_utc(NOW + timedelta(seconds=60)), + } + + +def _command( + envelope: dict[str, Any], + global_sequence: int, + *, + valid_now: bool = True, +) -> dict[str, Any]: + body = envelope["body"] + positive_grant = ( + body["schema"] == "dumbmoney.capital-envelope.v1" + or (body["schema"] == "dumbmoney.desired-mode.v1" and body["mode"] == "LIVE") + or (body["schema"] == "dumbmoney.kill-state.v1" and body["active"] is False) + ) + return { + "global_sequence": global_sequence, + "event_id": envelope["event_id"], + "body_schema": envelope["body_schema"], + "valid_now": valid_now, + "transport_window_current": valid_now, + "authority_effect": ( + "APPLY_FAIL_CLOSED" + if not positive_grant + else "APPLY_POSITIVE" + if valid_now + else "HISTORICAL_ONLY" + ), + "envelope": envelope, + } + + +def _page( + commands: list[dict[str, Any]], + *, + checkpoint_key: Ed25519PrivateKey, + request_nonce: str = TEST_REQUEST_NONCE, + after_sequence: int = 0, + after_digest: str = ZERO_DIGEST, + next_sequence: int | None = None, + next_digest: str | None = None, + ledger_head_sequence: int | None = None, + required_action: str = "APPLY_SIGNED_CONTROLS", +) -> dict[str, Any]: + projected_commands: list[dict[str, Any]] = [] + previous_global_digest = after_digest + for command in commands: + envelope = command["envelope"] + proof: dict[str, Any] = { + "schema": "dumbmoney.ledger-event-proof.v1", + "global_sequence": command["global_sequence"], + "event_id": envelope["event_id"], + "source_id": envelope["source_id"], + "source_sequence": envelope["source_sequence"], + "signer_key_id": envelope["signer_key_id"], + "nonce": envelope["nonce"], + "event_schema": envelope["body_schema"], + "observed_at": _format_utc(NOW), + "received_at": _format_utc(NOW), + "correlation_id": envelope["correlation_id"], + "causation_id": envelope["causation_id"], + "payload_digest": envelope["body_digest"], + "previous_source_digest": ( + ZERO_DIGEST + if envelope["source_sequence"] == 1 + else hashlib.sha256( + ( + f"source-head-{envelope['source_id']}-" + f"{envelope['source_sequence'] - 1}" + ).encode() + ).hexdigest() + ), + "previous_global_digest": previous_global_digest, + } + event_core = {key: value for key, value in proof.items() if key != "schema"} + event_digest = hashlib.sha256(canonical_json_bytes(event_core)).hexdigest() + proof["event_digest"] = event_digest + projected = { + **command, + "event_digest": event_digest, + "ledger_proof": proof, + } + projected_commands.append(projected) + previous_global_digest = event_digest + resolved_next = ( + next_sequence + if next_sequence is not None + else ( + int(projected_commands[-1]["global_sequence"]) + if projected_commands + else after_sequence + ) + ) + resolved_digest = ( + next_digest + if next_digest is not None + else ( + str(projected_commands[-1]["event_digest"]) + if projected_commands + and projected_commands[-1]["global_sequence"] == resolved_next + else after_digest + ) + ) + head = ledger_head_sequence if ledger_head_sequence is not None else resolved_next + head_digest = ( + resolved_digest + if head == resolved_next + else hashlib.sha256(f"ledger-head-{head}".encode()).hexdigest() + ) + checkpoint = { + "schema": "dumbmoney.cell-command-checkpoint.v1", + "cell_id": "dopey_robinhood", + "request_nonce": request_nonce, + "after_sequence": after_sequence, + "after_digest": after_digest, + "ordered_commands": [ + {key: value for key, value in command.items() if key != "envelope"} + for command in projected_commands + ], + "next_sequence": resolved_next, + "next_digest": resolved_digest, + "ledger_head_sequence": head, + "ledger_head_digest": head_digest, + "observed_at": _format_utc(NOW), + "required_action": required_action, + } + signature = checkpoint_key.sign(canonical_json_bytes(checkpoint)) + checkpoint_public = checkpoint_key.public_key().public_bytes_raw() + return { + "schema": "dumbmoney.cell-command-page.v1", + "cell_id": "dopey_robinhood", + "request_nonce": request_nonce, + "observed_at": _format_utc(NOW), + "after_sequence": after_sequence, + "after_digest": after_digest, + "next_sequence": resolved_next, + "next_digest": resolved_digest, + "ledger_head_sequence": head, + "ledger_head_digest": head_digest, + "has_more": resolved_next < head, + "required_action": required_action, + "commands": projected_commands, + "checkpoint": checkpoint, + "checkpoint_signature": { + "algorithm": "Ed25519", + "signer_key_id": hashlib.sha256(checkpoint_public).hexdigest(), + "signature": base64.urlsafe_b64encode(signature) + .decode("ascii") + .rstrip("="), + }, + } + + +def _response(page: dict[str, Any], status: int = 200) -> CommandFeedHttpResponse: + return CommandFeedHttpResponse( + status=status, + headers={"Content-Type": "application/json; charset=utf-8"}, + body=json.dumps(page, sort_keys=True, separators=(",", ":")).encode(), + ) + + +def _resign_checkpoint( + page: dict[str, Any], + checkpoint_key: Ed25519PrivateKey, +) -> None: + signature = checkpoint_key.sign(canonical_json_bytes(page["checkpoint"])) + public_key = checkpoint_key.public_key().public_bytes_raw() + page["checkpoint_signature"] = { + "algorithm": "Ed25519", + "signer_key_id": hashlib.sha256(public_key).hexdigest(), + "signature": base64.urlsafe_b64encode(signature).decode("ascii").rstrip("="), + } + + +class QueueTransport: + def __init__( + self, + *responses: CommandFeedHttpResponse | BaseException, + ) -> None: + self.responses = list(responses) + self.requests: list[CommandFeedHttpRequest] = [] + + def __call__( + self, + request: CommandFeedHttpRequest, + ) -> CommandFeedHttpResponse: + self.requests.append(request) + response = self.responses.pop(0) + if isinstance(response, BaseException): + raise response + return response + + +def _poll( + tmp_path: Path, + transport: QueueTransport, + core_public_key: bytes, + operator_public_key: bytes, + *, + request_nonce: str = TEST_REQUEST_NONCE, +): + operator_key_id = hashlib.sha256(operator_public_key).hexdigest() + with patch( + "dopey_live.dumbmoney_command_feed.secrets.token_hex", + return_value=request_nonce, + ): + return poll_once( + base_url="http://127.0.0.1:8765", + cell_bearer_provider=( + lambda: "dopey-cell-token-with-adequate-entropy-123456" + ), + trusted_public_key=core_public_key, + trusted_operator_public_keys={ + operator_key_id: operator_public_key, + }, + expected_account_hash=ACCOUNT_HASH, + state_path=tmp_path / "command-state.json", + transport=transport, + now_utc=NOW, + ) + + +def test_mode_and_capital_dispatch_survive_restart_cursor(tmp_path: Path) -> None: + core_key = Ed25519PrivateKey.generate() + operator_key = Ed25519PrivateKey.generate() + core_public_key = core_key.public_key().public_bytes_raw() + operator_public_key = operator_key.public_key().public_bytes_raw() + kill = _sign( + operator_key, + _kill_body(False), + source_sequence=1, + not_before=NOW - timedelta(seconds=5), + expires_at=NOW + timedelta(minutes=10), + ) + mode = _sign( + operator_key, + _mode_body(), + source_sequence=2, + not_before=NOW - timedelta(seconds=5), + expires_at=NOW + timedelta(minutes=10), + ) + capital = _sign( + core_key, + _capital_body(), + source_sequence=3, + not_before=NOW, + expires_at=NOW + timedelta(seconds=60), + ) + commands = [ + _command(kill, 1), + _command(mode, 2), + _command(capital, 3), + ] + first_page = _page(commands, checkpoint_key=core_key) + first_transport = QueueTransport(_response(first_page)) + + first = _poll( + tmp_path, + first_transport, + core_public_key, + operator_public_key, + ) + + assert first.allow_new_risk is True + assert first.desired_mode == "LIVE" + assert first.kill_active is False + assert first.capital_authority is not None + assert ( + first.capital_authority.verify(NOW, ACCOUNT_HASH).event_id + == (capital["event_id"]) + ) + assert first.next_sequence == 3 + request = first_transport.requests[0] + assert request.method == "GET" + assert request.headers["Authorization"].startswith("Bearer ") + query = parse_qs(urlsplit(request.url).query) + assert query == { + "after": ["0"], + "cursor": [ZERO_DIGEST], + "limit": ["250"], + "request_nonce": [TEST_REQUEST_NONCE], + } + + second_page = _page( + [], + checkpoint_key=core_key, + after_sequence=3, + after_digest=first.next_digest, + next_sequence=3, + next_digest=first.next_digest, + ) + second_transport = QueueTransport(_response(second_page)) + restarted = _poll( + tmp_path, + second_transport, + core_public_key, + operator_public_key, + ) + + assert restarted.allow_new_risk is True + assert restarted.next_sequence == 3 + assert parse_qs(urlsplit(second_transport.requests[0].url).query)["after"] == ["3"] + + +@pytest.mark.parametrize( + "case", + ["checkpoint_signature", "ledger_proof"], +) +def test_checkpoint_and_ledger_proof_tamper_fail_closed( + tmp_path: Path, + case: str, +) -> None: + core_key = Ed25519PrivateKey.generate() + operator_key = Ed25519PrivateKey.generate() + core_public_key = core_key.public_key().public_bytes_raw() + operator_public_key = operator_key.public_key().public_bytes_raw() + kill = _sign( + operator_key, + _kill_body(True), + source_sequence=1, + not_before=NOW - timedelta(seconds=5), + expires_at=NOW + timedelta(minutes=10), + ) + page = _page( + [_command(kill, 1)], + checkpoint_key=core_key, + required_action="CANCEL_AND_RECONCILE", + ) + if case == "checkpoint_signature": + page["checkpoint"]["required_action"] = "PAUSE_NEW_RISK" + else: + page["commands"][0]["ledger_proof"]["payload_digest"] = "e" * 64 + page["checkpoint"]["ordered_commands"][0]["ledger_proof"]["payload_digest"] = ( + "e" * 64 + ) + _resign_checkpoint(page, core_key) + + result = _poll( + tmp_path, + QueueTransport(_response(page)), + core_public_key, + operator_public_key, + ) + + assert result.allow_new_risk is False + assert result.kill_active is True + assert result.next_sequence == 0 + assert not (tmp_path / "command-state.json").exists() + + +def test_replayed_signed_page_under_different_request_nonce_fails_closed( + tmp_path: Path, +) -> None: + core_key = Ed25519PrivateKey.generate() + operator_key = Ed25519PrivateKey.generate() + replayed_page = _page( + [], + checkpoint_key=core_key, + request_nonce=TEST_REQUEST_NONCE, + required_action="CANCEL_AND_RECONCILE", + ) + transport = QueueTransport(_response(replayed_page)) + + result = _poll( + tmp_path, + transport, + core_key.public_key().public_bytes_raw(), + operator_key.public_key().public_bytes_raw(), + request_nonce="8" * 64, + ) + + assert result.allow_new_risk is False + assert result.kill_active is True + assert result.next_sequence == 0 + assert parse_qs(urlsplit(transport.requests[0].url).query)["request_nonce"] == [ + "8" * 64 + ] + assert not (tmp_path / "command-state.json").exists() + + +@pytest.mark.parametrize("case", ["operator_control_by_core", "capital_by_operator"]) +def test_command_schema_signer_role_mismatch_fails_closed( + tmp_path: Path, + case: str, +) -> None: + core_key = Ed25519PrivateKey.generate() + operator_key = Ed25519PrivateKey.generate() + if case == "operator_control_by_core": + envelope = _sign( + core_key, + _kill_body(True), + source_sequence=1, + not_before=NOW - timedelta(seconds=5), + expires_at=NOW + timedelta(minutes=10), + ) + required_action = "CANCEL_AND_RECONCILE" + else: + envelope = _sign( + operator_key, + _capital_body(), + source_sequence=1, + not_before=NOW, + expires_at=NOW + timedelta(seconds=60), + ) + required_action = "PAUSE_NEW_RISK" + page = _page( + [_command(envelope, 1)], + checkpoint_key=core_key, + required_action=required_action, + ) + + result = _poll( + tmp_path, + QueueTransport(_response(page)), + core_key.public_key().public_bytes_raw(), + operator_key.public_key().public_bytes_raw(), + ) + + assert result.allow_new_risk is False + assert result.kill_active is True + assert result.next_sequence == 0 + assert not (tmp_path / "command-state.json").exists() + + +class KillGateway: + def __init__(self, open_account: AccountSnapshot) -> None: + self.account = open_account + self.cancel_calls: list[tuple[str, str]] = [] + + def get_account_snapshot(self) -> AccountSnapshot: + return self.account + + def cancel_order( + self, + instrument: str, + order_id: str, + ) -> GatewayCancelResult: + self.cancel_calls.append((instrument, order_id)) + canceled_order = { + **self.account.recent_equity_orders[0], + "status": "canceled", + } + self.account = AccountSnapshot( + **{ + **self.account.to_dict(), + "recent_equity_orders": (canceled_order,), + } + ) + return GatewayCancelResult( + cancel_requested=True, + order_id=order_id, + status="cancel_requested", + warnings=(), + error="", + observed_tools=("cancel_equity_order",), + ) + + +def _account_with_open_order() -> AccountSnapshot: + return AccountSnapshot( + authenticated=True, + account_type="individual", + buying_power_usd=100.0, + total_equity_usd=100.0, + equity_positions=(), + option_positions=(), + recent_equity_orders=( + { + "order_id": "order-1", + "symbol": "ACME", + "side": "buy", + "status": "open", + "market_date": "2099-01-01", + "submitted_at": "2099-01-01T00:00:00Z", + "filled_at": "", + "filled_quantity": 0.0, + "average_fill_price": 0.0, + }, + ), + recent_option_orders=(), + equity_order_history_complete=True, + option_order_history_complete=True, + error="", + account_hash=ACCOUNT_HASH, + ) + + +def _entry_intent() -> LiveExecutionIntent: + return LiveExecutionIntent( + intent_id="feed-race-entry", + instrument="equity", + symbol="ACME", + side="buy", + order_type="limit", + limit_price=100.0, + time_in_force="day", + extended_hours=False, + notional_usd=1.0, + quantity=0.01, + source_as_of_date="2098-12-31", + source_hash=STRATEGY_HASH, + thesis_direction="bullish", + analysis_symbol="ACME", + analysis_relationship="self", + analysis_reference_price=100.0, + analysis_invalidation_price=90.0, + analysis_target_price=120.0, + daily_leverage_multiple=1.0, + daily_reset=False, + maximum_holding_sessions=5, + reference_price=100.0, + invalidation_price=90.0, + target_price=120.0, + expected_hold_trading_days=5, + execution_snapshot=None, + option_contract={}, + reason_codes=("feed-race-fixture",), + promotion_hash=PROMOTION_HASH, + passport_hash=PASSPORT_HASH, + ) + + +class RevocationRaceGateway: + def __init__(self, account: AccountSnapshot) -> None: + self.account = account + self.placement_calls = 0 + self.guard_calls = 0 + + def get_account_snapshot(self) -> AccountSnapshot: + return self.account + + def get_equity_execution_snapshot( + self, + _intent: LiveExecutionIntent, + ) -> object: + return object() + + def place_order( + self, + _intent: LiveExecutionIntent, + _paper_twin_record_hash: str, + *, + pre_submit_guard: Callable[[], datetime], + ) -> GatewayOrderResult: + pre_submit_guard() + self.guard_calls += 1 + self.placement_calls += 1 + return GatewayOrderResult( + review_succeeded=True, + placed=True, + order_id="broker-order-race-1", + status="queued", + filled_quantity=0.0, + average_fill_price=0.0, + submitted_at=_format_utc(NOW), + filled_at="", + warnings=(), + error="", + observed_tools=("review_equity_order", "place_equity_order"), + ) + + +class StaticPromotionResolver: + def bind_intent( + self, + authority: VerifiedCapitalAuthority, + intent: LiveExecutionIntent, + _now_utc: datetime, + *, + expected_binding: VerifiedPromotionBinding | None = None, + ) -> tuple[LiveExecutionIntent, VerifiedPromotionBinding]: + binding = VerifiedPromotionBinding( + strategy_hash=STRATEGY_HASH, + passport_hash=PASSPORT_HASH, + promotion_hash=PROMOTION_HASH, + instrument_id="equity:ACME", + maximum_loss_cents=100, + policy_epoch=authority.envelope.policy_epoch, + capital_envelope_id=authority.envelope.envelope_id, + capital_event_id=authority.event_id, + capital_body_digest=authority.body_digest, + capital_fencing_generation=authority.envelope.fencing_generation, + passport_event_id="4" * 64, + promotion_event_id="5" * 64, + passport_signer_key_id="6" * 64, + promotion_signer_key_id="7" * 64, + expires_at=authority.envelope.expires_at, + ) + if expected_binding is not None: + assert binding == expected_binding + return intent, binding + + +def test_kill_arriving_during_atomic_write_is_ambiguous_and_never_retried( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + core_key = Ed25519PrivateKey.generate() + operator_key = Ed25519PrivateKey.generate() + core_public_key = core_key.public_key().public_bytes_raw() + operator_public_key = operator_key.public_key().public_bytes_raw() + initial_commands = [ + _command( + _sign( + operator_key, + _kill_body(False), + source_sequence=1, + not_before=NOW - timedelta(seconds=5), + expires_at=NOW + timedelta(minutes=10), + ), + 1, + ), + _command( + _sign( + operator_key, + _mode_body(), + source_sequence=2, + not_before=NOW - timedelta(seconds=5), + expires_at=NOW + timedelta(minutes=10), + ), + 2, + ), + _command( + _sign( + core_key, + _capital_body(), + source_sequence=3, + not_before=NOW, + expires_at=NOW + timedelta(seconds=60), + ), + 3, + ), + ] + initial = _poll( + tmp_path, + QueueTransport(_response(_page(initial_commands, checkpoint_key=core_key))), + core_public_key, + operator_public_key, + ) + assert initial.allow_new_risk is True + + kill_during_write = _sign( + operator_key, + _kill_body(True, generation=2), + source_sequence=4, + not_before=NOW - timedelta(seconds=5), + expires_at=NOW + timedelta(minutes=10), + ) + refresh_transport = QueueTransport( + _response( + _page( + [], + checkpoint_key=core_key, + after_sequence=3, + after_digest=initial.next_digest, + next_sequence=3, + next_digest=initial.next_digest, + ) + ), + _response( + _page( + [_command(kill_during_write, 4)], + checkpoint_key=core_key, + after_sequence=3, + after_digest=initial.next_digest, + required_action="CANCEL_AND_RECONCILE", + ) + ), + ) + + def refresh(_now_utc: datetime): + return _poll( + tmp_path, + refresh_transport, + core_public_key, + operator_public_key, + ) + + account = AccountSnapshot( + authenticated=True, + account_type="individual", + buying_power_usd=100.0, + total_equity_usd=100.0, + equity_positions=(), + option_positions=(), + recent_equity_orders=(), + recent_option_orders=(), + equity_order_history_complete=True, + option_order_history_complete=True, + error="", + account_hash=ACCOUNT_HASH, + ) + gateway = RevocationRaceGateway(account) + intent = _entry_intent() + monkeypatch.setattr( + live_service, + "load_live_branch_control", + lambda: SimpleNamespace( + live_branch_enabled=True, + stock_execution_enabled=True, + options_execution_enabled=False, + use_full_available_balance=False, + ), + ) + monkeypatch.setattr( + live_service, + "_robinhood_stock_submission_state", + lambda _now: (True, "2099-01-01", True), + ) + monkeypatch.setattr( + live_service, + "research_maturity_blockers", + lambda _root: (), + ) + monkeypatch.setattr( + live_service, + "build_entry_intents", + lambda *_args, **_kwargs: (intent,), + ) + monkeypatch.setattr( + live_service, + "prepare_equity_entry_for_execution", + lambda *_args, **_kwargs: intent, + ) + + missing_refresh = run_live_branch_cycle( + tmp_path, + gateway, # type: ignore[arg-type] + NOW, + True, + dumbmoney_controls=initial, + authority_clock=lambda: NOW, + ) + assert missing_refresh["new_exposure_allowed"] is False + assert missing_refresh["cancel_only_reason"] == ( + "dumbmoney_command_feed_refresh_missing_at_broker_sink" + ) + assert gateway.placement_calls == 0 + + result = run_live_branch_cycle( + tmp_path, + gateway, # type: ignore[arg-type] + NOW, + True, + dumbmoney_controls=initial, + dumbmoney_control_refresh=refresh, + dumbmoney_promotion_resolver=StaticPromotionResolver(), + authority_clock=lambda: NOW, + ) + + assert gateway.guard_calls == 1 + assert gateway.placement_calls == 1 + assert len(refresh_transport.requests) == 2 + assert result["orders"][0]["status"] == ("ambiguous_in_flight_authority_revoked") + assert result["orders"][0]["placement_retried"] is False + event_types = [ + str(record["event_type"]) for record in read_ledger(ledger_path(tmp_path)) + ] + assert event_types.index("dumbmoney_order_dispatching") < event_types.index( + "live_order_submission_ambiguous" + ) + assert "live_order_result" not in event_types + + +def test_signed_kill_delivery_enters_existing_cancel_only_path( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + core_key = Ed25519PrivateKey.generate() + operator_key = Ed25519PrivateKey.generate() + core_public_key = core_key.public_key().public_bytes_raw() + operator_public_key = operator_key.public_key().public_bytes_raw() + kill = _sign( + operator_key, + _kill_body(True), + source_sequence=1, + not_before=NOW - timedelta(seconds=5), + expires_at=NOW + timedelta(minutes=10), + ) + dispatch = _poll( + tmp_path, + QueueTransport( + _response( + _page( + [_command(kill, 1)], + checkpoint_key=core_key, + required_action="CANCEL_AND_RECONCILE", + ) + ) + ), + core_public_key, + operator_public_key, + ) + assert dispatch.kill_active is True + append_record( + ledger_path(tmp_path), + "live_order_result", + { + "intent_id": "intent-1", + "instrument": "equity", + "placed": True, + "order_id": "order-1", + "status": "open", + }, + ) + monkeypatch.setattr( + live_service, + "load_live_branch_control", + lambda: SimpleNamespace(live_branch_enabled=False), + ) + monkeypatch.setattr( + live_service, + "_robinhood_stock_submission_state", + lambda _now: (True, "2099-01-01", True), + ) + gateway = KillGateway(_account_with_open_order()) + + result = run_live_branch_cycle( + tmp_path, + gateway, # type: ignore[arg-type] + NOW, + True, + dumbmoney_controls=dispatch, + ) + + assert result["authority_state"] == "CANCEL_ONLY_DEGRADED" + assert result["kill_active"] is True + assert gateway.cancel_calls == [("equity", "order-1")] + assert result["new_exposure_allowed"] is False + + +@pytest.mark.parametrize("case", ["tamper", "generation_gap"]) +def test_tamper_and_generation_gap_do_not_advance_cursor( + tmp_path: Path, + case: str, +) -> None: + core_key = Ed25519PrivateKey.generate() + operator_key = Ed25519PrivateKey.generate() + core_public_key = core_key.public_key().public_bytes_raw() + operator_public_key = operator_key.public_key().public_bytes_raw() + generation = 2 if case == "generation_gap" else 1 + kill = _sign( + operator_key, + _kill_body(True, generation), + source_sequence=1, + not_before=NOW - timedelta(seconds=5), + expires_at=NOW + timedelta(minutes=10), + ) + if case == "tamper": + kill["body"]["reason"] = "tampered" + + result = _poll( + tmp_path, + QueueTransport( + _response( + _page( + [_command(kill, 1)], + checkpoint_key=core_key, + ) + ) + ), + core_public_key, + operator_public_key, + ) + + assert result.allow_new_risk is False + assert result.kill_active is True + assert result.next_sequence == 0 + assert not (tmp_path / "command-state.json").exists() + + +@pytest.mark.parametrize( + ("response", "reason"), + [ + ( + CommandFeedHttpResponse( + status=401, + headers={"Content-Type": "application/json"}, + body=b"{}", + ), + "authentication", + ), + (TimeoutError("fixture transport timeout"), "transport"), + ], +) +def test_auth_and_transport_failure_are_fail_closed_unknown_kill( + tmp_path: Path, + response: CommandFeedHttpResponse | BaseException, + reason: str, +) -> None: + core_key = Ed25519PrivateKey.generate() + operator_key = Ed25519PrivateKey.generate() + result = _poll( + tmp_path, + QueueTransport(response), + core_key.public_key().public_bytes_raw(), + operator_key.public_key().public_bytes_raw(), + ) + + assert result.allow_new_risk is False + assert result.kill_active is True + assert reason in result.fail_closed_reason + assert result.next_sequence == 0 + + +def test_expired_active_kill_bootstrap_advances_and_remains_active( + tmp_path: Path, +) -> None: + core_key = Ed25519PrivateKey.generate() + operator_key = Ed25519PrivateKey.generate() + core_public_key = core_key.public_key().public_bytes_raw() + operator_public_key = operator_key.public_key().public_bytes_raw() + kill = _sign( + operator_key, + _kill_body(True), + source_sequence=1, + not_before=NOW - timedelta(minutes=10), + expires_at=NOW - timedelta(minutes=5), + ) + first_page = _page( + [_command(kill, 1, valid_now=False)], + checkpoint_key=core_key, + required_action="CANCEL_AND_RECONCILE", + ) + + first = _poll( + tmp_path, + QueueTransport(_response(first_page)), + core_public_key, + operator_public_key, + ) + + assert first.next_sequence == 1 + assert first.kill_active is True + assert first.allow_new_risk is False + persisted = json.loads( + (tmp_path / "command-state.json").read_text(encoding="utf-8") + ) + assert persisted["next_sequence"] == 1 + assert persisted["kill_envelope"]["event_id"] == kill["event_id"] + + restart_page = _page( + [], + checkpoint_key=core_key, + after_sequence=1, + after_digest=first.next_digest, + next_sequence=1, + next_digest=first.next_digest, + required_action="CANCEL_AND_RECONCILE", + ) + restarted = _poll( + tmp_path, + QueueTransport(_response(restart_page)), + core_public_key, + operator_public_key, + ) + assert restarted.kill_active is True + assert restarted.next_sequence == 1 + + +def test_page_omission_or_cursor_mismatch_never_reuses_positive_authority( + tmp_path: Path, +) -> None: + core_key = Ed25519PrivateKey.generate() + operator_key = Ed25519PrivateKey.generate() + core_public_key = core_key.public_key().public_bytes_raw() + operator_public_key = operator_key.public_key().public_bytes_raw() + mismatched = _page( + [], + checkpoint_key=core_key, + after_sequence=1, + after_digest="1" * 64, + next_sequence=1, + next_digest="1" * 64, + ) + + result = _poll( + tmp_path, + QueueTransport(_response(mismatched)), + core_public_key, + operator_public_key, + ) + + assert result.allow_new_risk is False + assert result.kill_active is True + assert result.next_sequence == 0 diff --git a/tests/test_dumbmoney_contract_feed.py b/tests/test_dumbmoney_contract_feed.py new file mode 100644 index 0000000..2549df0 --- /dev/null +++ b/tests/test_dumbmoney_contract_feed.py @@ -0,0 +1,592 @@ +from __future__ import annotations + +import base64 +import hashlib +import json +from dataclasses import replace +from datetime import datetime, timedelta, timezone +from typing import Any +from urllib.parse import parse_qs, urlsplit + +import pytest +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey + +from dopey_live.dumbmoney_authority import ( + VerifiedCapitalAuthority, + canonical_json_bytes, + verify_signed_capital_envelope, +) +from dopey_live.dumbmoney_command_feed import ( + CommandFeedHttpRequest, + CommandFeedHttpResponse, +) +from dopey_live.dumbmoney_contract_feed import ( + CONTRACT_RESOLUTION_CHECKPOINT_SCHEMA, + CONTRACT_RESOLUTION_SCHEMA, + DumbMoneyContractResolverClient, +) +from dopey_live.dumbmoney_promotion import ( + ALPHA_PASSPORT_SCHEMA, + PROMOTION_CERTIFICATE_SCHEMA, + DumbMoneyPromotionError, +) +from dopey_live.models import LiveExecutionIntent + + +NOW = datetime(2099, 1, 1, 0, 0, 30, tzinfo=timezone.utc) +ACCOUNT_HASH = "c" * 64 +STRATEGY_HASH = "a" * 64 +ZERO_DIGEST = "0" * 64 + + +def _format_utc(value: datetime) -> str: + rendered = value.astimezone(timezone.utc).isoformat( + timespec="microseconds" if value.microsecond else "seconds" + ) + return rendered.replace("+00:00", "Z") + + +def _sign_envelope( + private_key: Ed25519PrivateKey, + body: dict[str, Any], + *, + source_id: str, + source_sequence: int, + not_before: datetime, + expires_at: datetime, +) -> dict[str, Any]: + public_key = private_key.public_key().public_bytes_raw() + unsigned: dict[str, Any] = { + "schema": "dumbmoney.signed-envelope.v1", + "source_id": source_id, + "source_sequence": source_sequence, + "correlation_id": "promotion-fixture", + "causation_id": None, + "nonce": f"{source_id}-nonce-{source_sequence}", + "not_before": _format_utc(not_before), + "expires_at": _format_utc(expires_at), + "body_schema": body["schema"], + "body_digest": hashlib.sha256(canonical_json_bytes(body)).hexdigest(), + "body": body, + "signature_algorithm": "Ed25519", + "signer_key_id": hashlib.sha256(public_key).hexdigest(), + } + unsigned["event_id"] = hashlib.sha256(canonical_json_bytes(unsigned)).hexdigest() + signature = private_key.sign(canonical_json_bytes(unsigned)) + return { + **unsigned, + "signature": base64.urlsafe_b64encode(signature).decode("ascii").rstrip("="), + } + + +def _passport_body() -> dict[str, Any]: + return { + "schema": ALPHA_PASSPORT_SCHEMA, + "passport_id": "passport-dopey-1", + "strategy_lineage_id": "lineage-dopey-1", + "venue": "dopey_robinhood", + "strategy_hash": STRATEGY_HASH, + "artifact_hashes": ["1" * 64], + "evidence_verdict_hashes": ["2" * 64], + "intended_instruments": ["equity:ACME"], + "maximum_loss_cents": 100, + "evidence_class": "FORWARD", + "created_at": _format_utc(NOW - timedelta(days=1)), + "expires_at": _format_utc(NOW + timedelta(days=1)), + } + + +def _promotion_body( + passport_digest: str, + *, + stage: str = "EXPLORATORY_LIVE", +) -> dict[str, Any]: + return { + "schema": PROMOTION_CERTIFICATE_SCHEMA, + "certificate_id": "promotion-dopey-1", + "passport_digest": passport_digest, + "verdict_digests": ["3" * 64], + "stage": stage, + "venue": "dopey_robinhood", + "instruments": ["equity:ACME"], + "maximum_loss_cents": 100, + "rollback_triggers": ["AMBIGUOUS_OUTCOME", "KILL_ACTIVE"], + "policy_epoch": 1, + "not_before": _format_utc(NOW - timedelta(days=1)), + "expires_at": _format_utc(NOW + timedelta(days=1)), + } + + +def _capital_body( + passport_digest: str, + promotion_digest: str, +) -> dict[str, Any]: + return { + "schema": "dumbmoney.capital-envelope.v1", + "envelope_id": "e" * 64, + "mandate_id": "d" * 64, + "venue": "dopey_robinhood", + "account_hash": ACCOUNT_HASH, + "strategy_hashes": [STRATEGY_HASH], + "passport_hashes": [passport_digest], + "promotion_hashes": [promotion_digest], + "authorized_instruments": ["equity:ACME"], + "authorized_mode": "LIVE", + "max_order_risk_cents": 100, + "max_open_risk_cents": 1250, + "max_correlated_risk_cents": 300, + "max_daily_loss_cents": 300, + "max_open_orders": 5, + "fencing_generation": 7, + "policy_epoch": 1, + "not_before": _format_utc(NOW), + "expires_at": _format_utc(NOW + timedelta(seconds=60)), + } + + +def _intent() -> LiveExecutionIntent: + return LiveExecutionIntent( + intent_id="intent-1", + instrument="equity", + symbol="ACME", + side="buy", + order_type="limit", + limit_price=100.0, + time_in_force="day", + extended_hours=False, + notional_usd=1.0, + quantity=0.01, + source_as_of_date="2098-12-31", + source_hash=STRATEGY_HASH, + thesis_direction="bullish", + analysis_symbol="ACME", + analysis_relationship="self", + analysis_reference_price=100.0, + analysis_invalidation_price=90.0, + analysis_target_price=120.0, + daily_leverage_multiple=1.0, + daily_reset=False, + maximum_holding_sessions=5, + reference_price=100.0, + invalidation_price=90.0, + target_price=120.0, + expected_hold_trading_days=5, + execution_snapshot=None, + option_contract={}, + reason_codes=("resolver-fixture",), + ) + + +def _ledger_proof( + envelope: dict[str, Any], + *, + global_sequence: int, +) -> dict[str, Any]: + received_at = NOW - timedelta(seconds=1) + observed_at = max( + received_at, + datetime.fromisoformat(envelope["not_before"].replace("Z", "+00:00")), + ) + proof: dict[str, Any] = { + "schema": "dumbmoney.ledger-event-proof.v1", + "global_sequence": global_sequence, + "event_id": envelope["event_id"], + "source_id": envelope["source_id"], + "source_sequence": envelope["source_sequence"], + "signer_key_id": envelope["signer_key_id"], + "nonce": envelope["nonce"], + "event_schema": envelope["body_schema"], + "observed_at": _format_utc(observed_at), + "received_at": _format_utc(received_at), + "correlation_id": envelope["correlation_id"], + "causation_id": envelope["causation_id"], + "payload_digest": envelope["body_digest"], + "previous_source_digest": ZERO_DIGEST, + "previous_global_digest": (ZERO_DIGEST if global_sequence == 1 else "9" * 64), + } + proof["event_digest"] = hashlib.sha256( + canonical_json_bytes( + {key: value for key, value in proof.items() if key != "schema"} + ) + ).hexdigest() + return proof + + +def _resolution( + core_key: Ed25519PrivateKey, + envelope: dict[str, Any], + request_nonce: str, + capital_digest: str, + authority_state: dict[str, Any], + *, + global_sequence: int, + eligible: bool = True, +) -> dict[str, Any]: + proof = _ledger_proof(envelope, global_sequence=global_sequence) + checkpoint: dict[str, Any] = { + "schema": CONTRACT_RESOLUTION_CHECKPOINT_SCHEMA, + "cell_id": "dopey_robinhood", + "request_nonce": request_nonce, + "requested_body_digest": envelope["body_digest"], + "capital_envelope_digest": capital_digest, + "fencing_generation": 7, + "observed_at": _format_utc(NOW), + "contract_schema": envelope["body_schema"], + "transport_window_current": True, + "body_window_current": True, + "eligible_live_input": eligible, + "authority_state": authority_state, + "ledger_proof": proof, + "envelope": envelope, + } + public_key = core_key.public_key().public_bytes_raw() + signature = core_key.sign(canonical_json_bytes(checkpoint)) + return { + "schema": CONTRACT_RESOLUTION_SCHEMA, + **{key: value for key, value in checkpoint.items() if key != "schema"}, + "checkpoint": checkpoint, + "checkpoint_signature": { + "algorithm": "Ed25519", + "signer_key_id": hashlib.sha256(public_key).hexdigest(), + "signature": base64.urlsafe_b64encode(signature) + .decode("ascii") + .rstrip("="), + }, + } + + +def _resign_resolution( + response: dict[str, Any], + core_key: Ed25519PrivateKey, +) -> dict[str, Any]: + signature = core_key.sign(canonical_json_bytes(response["checkpoint"])) + response["checkpoint_signature"]["signature"] = ( + base64.urlsafe_b64encode(signature).decode("ascii").rstrip("=") + ) + return response + + +def _authority_state( + authority: VerifiedCapitalAuthority, + *, + passport_event_digest: str, + promotion_event_digest: str, + evaluator_key_ids: tuple[str, str], +) -> dict[str, Any]: + return { + "schema": "dumbmoney.cell-authority-state.v1", + "evaluated_at": _format_utc(NOW), + "authority_valid_until": _format_utc(NOW + timedelta(seconds=30)), + "policy_epoch": authority.envelope.policy_epoch, + "mandate_id": authority.envelope.mandate_id, + "mandate_event_digest": "4" * 64, + "kill_clear": True, + "kill_generation": 1, + "kill_event_digest": "5" * 64, + "desired_mode": "LIVE", + "desired_mode_revision": 1, + "desired_mode_event_digest": "6" * 64, + "capital_envelope_digest": authority.body_digest, + "capital_event_digest": "7" * 64, + "fencing_generation": authority.envelope.fencing_generation, + "strategy_hash": authority.envelope.strategy_hashes[0], + "passport_digest": authority.envelope.passport_hashes[0], + "passport_event_digest": passport_event_digest, + "promotion_digest": authority.envelope.promotion_hashes[0], + "promotion_event_digest": promotion_event_digest, + "verdicts": [ + { + "verdict_digest": "8" * 64, + "verdict_event_digest": "a" * 64, + "verdict_id": "verdict-integrity-1", + "court": "integrity", + "decision": "PASS", + "signer_key_id": evaluator_key_ids[0], + "evaluated_at": _format_utc(NOW - timedelta(seconds=5)), + "expires_at": _format_utc(NOW + timedelta(seconds=45)), + "transport_expires_at": _format_utc(NOW + timedelta(seconds=45)), + }, + { + "verdict_digest": "b" * 64, + "verdict_event_digest": "c" * 64, + "verdict_id": "verdict-statistics-1", + "court": "statistics", + "decision": "PASS", + "signer_key_id": evaluator_key_ids[1], + "evaluated_at": _format_utc(NOW - timedelta(seconds=5)), + "expires_at": _format_utc(NOW + timedelta(seconds=45)), + "transport_expires_at": _format_utc(NOW + timedelta(seconds=45)), + }, + ], + "ledger_head_sequence": 2, + "ledger_head_digest": promotion_event_digest, + } + + +class ResolutionTransport: + def __init__( + self, + core_key: Ed25519PrivateKey, + authority: VerifiedCapitalAuthority, + envelopes: dict[str, dict[str, Any]], + evaluator_key_ids: tuple[str, str], + *, + response_mutator: Any = None, + ) -> None: + self.core_key = core_key + self.authority = authority + self.envelopes = envelopes + self.evaluator_key_ids = evaluator_key_ids + self.response_mutator = response_mutator + self.requests: list[CommandFeedHttpRequest] = [] + + def __call__(self, request: CommandFeedHttpRequest) -> CommandFeedHttpResponse: + self.requests.append(request) + parsed = urlsplit(request.url) + digest = parsed.path.rsplit("/", 1)[-1] + query = parse_qs(parsed.query) + assert query["capital_envelope_digest"] == [self.authority.body_digest] + nonce = query["request_nonce"][0] + envelope = self.envelopes[digest] + passport_envelope = self.envelopes[self.authority.envelope.passport_hashes[0]] + promotion_envelope = self.envelopes[self.authority.envelope.promotion_hashes[0]] + authority_state = _authority_state( + self.authority, + passport_event_digest=_ledger_proof( + passport_envelope, + global_sequence=1, + )["event_digest"], + promotion_event_digest=_ledger_proof( + promotion_envelope, + global_sequence=2, + )["event_digest"], + evaluator_key_ids=self.evaluator_key_ids, + ) + response = _resolution( + self.core_key, + envelope, + nonce, + self.authority.body_digest, + authority_state, + global_sequence=( + 1 if envelope["body_schema"] == ALPHA_PASSPORT_SCHEMA else 2 + ), + ) + if self.response_mutator is not None: + response = self.response_mutator(response) + response = _resign_resolution(response, self.core_key) + return CommandFeedHttpResponse( + status=200, + headers={"Content-Type": "application/json"}, + body=json.dumps(response, separators=(",", ":")).encode("utf-8"), + ) + + +def _runtime( + *, + promotion_signer: str = "promoter", + stage: str = "EXPLORATORY_LIVE", + response_mutator: Any = None, +) -> tuple[ + DumbMoneyContractResolverClient, + VerifiedCapitalAuthority, + ResolutionTransport, +]: + core_key = Ed25519PrivateKey.generate() + promoter_key = Ed25519PrivateKey.generate() + research_key = Ed25519PrivateKey.generate() + evaluator_key_1 = Ed25519PrivateKey.generate() + evaluator_key_2 = Ed25519PrivateKey.generate() + passport = _sign_envelope( + research_key, + _passport_body(), + source_id="research", + source_sequence=1, + not_before=NOW - timedelta(seconds=10), + expires_at=NOW + timedelta(minutes=10), + ) + signing_key = research_key if promotion_signer == "research" else promoter_key + promotion = _sign_envelope( + signing_key, + _promotion_body(passport["body_digest"], stage=stage), + source_id="promoter", + source_sequence=1, + not_before=NOW - timedelta(seconds=10), + expires_at=NOW + timedelta(minutes=10), + ) + capital = _sign_envelope( + core_key, + _capital_body(passport["body_digest"], promotion["body_digest"]), + source_id="dumbmoney-core", + source_sequence=1, + not_before=NOW, + expires_at=NOW + timedelta(seconds=60), + ) + core_public = core_key.public_key().public_bytes_raw() + authority = verify_signed_capital_envelope( + capital, + core_public, + NOW, + ACCOUNT_HASH, + ) + transport = ResolutionTransport( + core_key, + authority, + { + passport["body_digest"]: passport, + promotion["body_digest"]: promotion, + }, + ( + hashlib.sha256(evaluator_key_1.public_key().public_bytes_raw()).hexdigest(), + hashlib.sha256(evaluator_key_2.public_key().public_bytes_raw()).hexdigest(), + ), + response_mutator=response_mutator, + ) + promoter_public = promoter_key.public_key().public_bytes_raw() + research_public = research_key.public_key().public_bytes_raw() + evaluator_public_1 = evaluator_key_1.public_key().public_bytes_raw() + evaluator_public_2 = evaluator_key_2.public_key().public_bytes_raw() + client = DumbMoneyContractResolverClient( + base_url="http://127.0.0.1:8765", + cell_bearer_provider=lambda: "cell-token-from-credential-manager", + trusted_core_public_key=core_public, + trusted_promoter_public_keys={ + hashlib.sha256(promoter_public).hexdigest(): promoter_public, + }, + trusted_research_public_keys={ + hashlib.sha256(research_public).hexdigest(): research_public, + }, + trusted_evaluator_public_keys={ + hashlib.sha256(evaluator_public_1).hexdigest(): evaluator_public_1, + hashlib.sha256(evaluator_public_2).hexdigest(): evaluator_public_2, + }, + transport=transport, + ) + return client, authority, transport + + +def test_nonce_and_fence_bound_resolver_hydrates_exact_lineage() -> None: + client, authority, transport = _runtime() + + intent, binding = client.bind_intent(authority, _intent(), NOW) + + assert intent.passport_hash == authority.envelope.passport_hashes[0] + assert intent.promotion_hash == authority.envelope.promotion_hashes[0] + assert binding.strategy_hash == STRATEGY_HASH + assert binding.capital_body_digest == authority.body_digest + assert binding.capital_fencing_generation == 7 + assert len(transport.requests) == 2 + nonces = [ + parse_qs(urlsplit(request.url).query)["request_nonce"][0] + for request in transport.requests + ] + assert len(set(nonces)) == 2 + assert all(len(nonce) == 64 for nonce in nonces) + assert all( + request.headers["Authorization"] == "Bearer cell-token-from-credential-manager" + for request in transport.requests + ) + + +def test_resolver_rejects_caller_supplied_or_wrong_role_lineage() -> None: + client, authority, _ = _runtime() + with pytest.raises(DumbMoneyPromotionError, match="supplied its own"): + client.bind_intent( + authority, + replace(_intent(), promotion_hash=authority.envelope.promotion_hashes[0]), + NOW, + ) + + wrong_role_client, wrong_role_authority, _ = _runtime(promotion_signer="research") + with pytest.raises(DumbMoneyPromotionError, match="sealed promoter"): + wrong_role_client.bind_intent(wrong_role_authority, _intent(), NOW) + + +def test_resolver_rejects_nonlive_stage_even_if_projection_lies() -> None: + client, authority, _ = _runtime(stage="PAPER") + + with pytest.raises( + DumbMoneyPromotionError, + match="stage is not live-authorizing|live-eligibility projection", + ): + client.bind_intent(authority, _intent(), NOW) + + +def test_resolver_rejects_response_transplanted_across_capital_fence() -> None: + def mutate(response: dict[str, Any]) -> dict[str, Any]: + response["capital_envelope_digest"] = "8" * 64 + return response + + client, authority, _ = _runtime(response_mutator=mutate) + + with pytest.raises( + DumbMoneyPromotionError, + match="checkpoint does not bind|active capital envelope", + ): + client.bind_intent(authority, _intent(), NOW) + + +def test_resolver_rejects_expired_authority_state() -> None: + def mutate(response: dict[str, Any]) -> dict[str, Any]: + response["authority_state"]["authority_valid_until"] = _format_utc( + NOW - timedelta(seconds=1) + ) + return response + + client, authority, _ = _runtime(response_mutator=mutate) + + with pytest.raises( + DumbMoneyPromotionError, + match="authority_state is stale", + ): + client.bind_intent(authority, _intent(), NOW) + + +def test_resolver_rejects_unsealed_authority_verdict_signer() -> None: + def mutate(response: dict[str, Any]) -> dict[str, Any]: + response["authority_state"]["verdicts"][0]["signer_key_id"] = "f" * 64 + return response + + client, authority, _ = _runtime(response_mutator=mutate) + + with pytest.raises( + DumbMoneyPromotionError, + match="verdict signer is not sealed", + ): + client.bind_intent(authority, _intent(), NOW) + + +def test_resolver_rejects_contract_proof_outside_authority_state() -> None: + def mutate(response: dict[str, Any]) -> dict[str, Any]: + if response["contract_schema"] == PROMOTION_CERTIFICATE_SCHEMA: + response["authority_state"]["promotion_event_digest"] = "f" * 64 + return response + + client, authority, _ = _runtime(response_mutator=mutate) + + with pytest.raises( + DumbMoneyPromotionError, + match="proof event digest", + ): + client.bind_intent(authority, _intent(), NOW) + + +def test_resolver_rejects_authority_state_change_between_lineage_reads() -> None: + calls = 0 + + def mutate(response: dict[str, Any]) -> dict[str, Any]: + nonlocal calls + calls += 1 + if calls == 2: + state = response["authority_state"] + state["ledger_head_sequence"] = 3 + state["ledger_head_digest"] = "f" * 64 + return response + + client, authority, _ = _runtime(response_mutator=mutate) + + with pytest.raises( + DumbMoneyPromotionError, + match="one authority state", + ): + client.bind_intent(authority, _intent(), NOW) diff --git a/tests/test_dumbmoney_direct_mcp.py b/tests/test_dumbmoney_direct_mcp.py new file mode 100644 index 0000000..c668e09 --- /dev/null +++ b/tests/test_dumbmoney_direct_mcp.py @@ -0,0 +1,887 @@ +from __future__ import annotations + +import hashlib +import json +from dataclasses import dataclass +from datetime import datetime, timedelta, timezone +from types import SimpleNamespace +from typing import Any + +import pytest +import requests + +from dopey_live import service as live_service +from dopey_live.robinhood_gateway import ( + RobinhoodAgenticGateway, + RobinhoodGatewayError, + RobinhoodPlacementAmbiguousError, +) +from dopey_live.robinhood_mcp_direct import DeterministicRobinhoodMcpClient + + +TOKEN = "sealed-robinhood-profile" +ACCOUNT_ID = "agentic-account-1" +ACCOUNT_HASH = hashlib.sha256(ACCOUNT_ID.encode()).hexdigest() + + +@dataclass +class FakeResponse: + status_code: int + payload: dict[str, Any] | None + headers: dict[str, str] + + @property + def content(self) -> bytes: + if self.payload is None: + return b"" + return json.dumps( + self.payload, + sort_keys=True, + separators=(",", ":"), + ).encode() + + +class FakeSession: + def __init__( + self, + *, + drift_schema: bool = False, + fail_after_place_call: bool = False, + ) -> None: + self.trust_env = True + self.drift_schema = drift_schema + self.fail_after_place_call = fail_after_place_call + self.calls: list[dict[str, Any]] = [] + + def request(self, method: str, url: str, **kwargs: Any) -> FakeResponse: + self.calls.append({"method": method, "url": url, **kwargs}) + payload = kwargs.get("json") + if method == "DELETE": + return FakeResponse(202, None, {}) + if payload["method"] == "initialize": + return FakeResponse( + 200, + { + "jsonrpc": "2.0", + "id": 1, + "result": { + "protocolVersion": "2025-06-18", + "capabilities": {}, + "serverInfo": {"name": "robinhood", "version": "1"}, + }, + }, + { + "Content-Type": "application/json", + "Mcp-Session-Id": "session-1", + }, + ) + if payload["method"] == "notifications/initialized": + return FakeResponse(202, None, {}) + if payload["method"] == "tools/list": + properties = { + "side": {"type": "string"}, + "amount": {"type": "number"}, + "symbol": {"type": "string"}, + "quantity": {"type": "number"}, + "order_type": {"type": "string"}, + "limit_price": {"type": "number"}, + "time_in_force": {"type": "string"}, + } + if self.drift_schema: + properties["destination"] = {"type": "string"} + return FakeResponse( + 200, + { + "jsonrpc": "2.0", + "id": 2, + "result": { + "tools": [ + { + "name": "get_accounts", + "inputSchema": { + "type": "object", + "properties": {}, + "required": [], + }, + }, + { + "name": "place_equity_order", + "inputSchema": { + "type": "object", + "properties": properties, + "required": ["side", "symbol", "order_type"], + }, + }, + ] + }, + }, + {"Content-Type": "application/json"}, + ) + assert payload["method"] == "tools/call" + if payload["params"]["name"] == "get_accounts": + return FakeResponse( + 200, + { + "jsonrpc": "2.0", + "id": payload["id"], + "result": { + "structuredContent": { + "accounts": [ + { + "account_number": ACCOUNT_ID, + "account_label": "Agentic", + "brokerage_account_type": "individual", + } + ] + }, + "isError": False, + }, + }, + {"Content-Type": "application/json"}, + ) + if self.fail_after_place_call: + raise requests.ConnectionError("connection lost after request write") + return FakeResponse( + 200, + { + "jsonrpc": "2.0", + "id": payload["id"], + "result": { + "structuredContent": { + **payload["params"]["arguments"], + "placed": True, + "order_id": "order-1", + "status": "queued", + }, + "isError": False, + }, + }, + {"Content-Type": "application/json"}, + ) + + +class ReadSchemaSession: + def __init__(self, *, type_drift: bool) -> None: + self.trust_env = True + self.type_drift = type_drift + self.calls: list[dict[str, Any]] = [] + + def request(self, method: str, url: str, **kwargs: Any) -> FakeResponse: + self.calls.append({"method": method, "url": url, **kwargs}) + payload = kwargs.get("json") + if method == "DELETE": + return FakeResponse(202, None, {}) + if payload["method"] == "initialize": + return FakeResponse( + 200, + { + "jsonrpc": "2.0", + "id": 1, + "result": { + "protocolVersion": "2025-06-18", + "capabilities": {}, + }, + }, + { + "Content-Type": "application/json", + "Mcp-Session-Id": "read-session-1", + }, + ) + if payload["method"] == "notifications/initialized": + return FakeResponse(202, None, {}) + if payload["method"] == "tools/list": + return FakeResponse( + 200, + { + "jsonrpc": "2.0", + "id": 2, + "result": { + "tools": [ + { + "name": "get_portfolio", + "inputSchema": { + "type": "object", + "properties": { + "account_number": { + "type": ( + "integer" + if self.type_drift + else "string" + ) + } + }, + "required": ["account_number"], + }, + } + ] + }, + }, + {"Content-Type": "application/json"}, + ) + assert payload["method"] == "tools/call" + return FakeResponse( + 200, + { + "jsonrpc": "2.0", + "id": payload["id"], + "result": { + "structuredContent": { + "buying_power": "100", + "total_equity": "100", + }, + "isError": False, + }, + }, + {"Content-Type": "application/json"}, + ) + + +def _place_arguments() -> dict[str, Any]: + return { + "side": "buy", + "symbol": "SPY", + "quantity": 0.125, + "order_type": "limit", + "limit_price": 600.00, + "time_in_force": "day", + } + + +class ParentReadTransport: + def __init__( + self, + *, + account_id: str = ACCOUNT_ID, + buying_power: Any = "1000.00", + total_equity: Any = "1250.00", + equity_positions: list[dict[str, Any]] | None = None, + quote_observed_at: str = "2099-01-01T00:00:00Z", + extra_quote_symbol: str = "", + history_next: str | None = None, + fail_tool: str = "", + ) -> None: + self.account_id = account_id + self.buying_power = buying_power + self.total_equity = total_equity + self.equity_positions = [] if equity_positions is None else equity_positions + self.quote_observed_at = quote_observed_at + self.extra_quote_symbol = extra_quote_symbol + self.history_next = history_next + self.fail_tool = fail_tool + self.calls: list[tuple[str, dict[str, Any], Any]] = [] + + def __call__( + self, + tool: str, + arguments: dict[str, Any], + _timeout: int, + pre_call_guard: Any, + ) -> dict[str, Any]: + self.calls.append((tool, dict(arguments), pre_call_guard)) + if tool == self.fail_tool: + raise RobinhoodGatewayError("parent read failed") + if pre_call_guard is not None: + raise AssertionError("read calls must not carry a write guard") + if tool == "get_accounts": + value: dict[str, Any] = { + "accounts": [ + { + "account_number": self.account_id, + "account_label": "Agentic", + "brokerage_account_type": "individual", + } + ] + } + elif tool == "get_portfolio": + value = { + "buying_power": self.buying_power, + "total_equity": self.total_equity, + } + elif tool == "get_equity_positions": + value = {"positions": self.equity_positions} + elif tool == "get_option_positions": + value = {"positions": []} + elif tool in {"get_equity_orders", "get_option_orders"}: + value = { + "orders": [], + "history_complete": True, + "next": self.history_next, + } + elif tool == "get_equity_quotes": + symbols = [ + *arguments["symbols"], + *([self.extra_quote_symbol] if self.extra_quote_symbol else []), + ] + value = { + "quotes": [ + { + "symbol": symbol, + "bid_price": "99.90", + "ask_price": "100.00", + "last_price": "99.95", + "observed_at": self.quote_observed_at, + } + for symbol in symbols + ] + } + elif tool == "get_equity_tradability": + symbols = [ + *arguments["symbols"], + *([self.extra_quote_symbol] if self.extra_quote_symbol else []), + ] + value = { + "results": [{"symbol": symbol, "tradable": True} for symbol in symbols] + } + else: + raise AssertionError(f"unexpected tool: {tool}") + return {"structuredContent": value, "isError": False} + + +def test_direct_mcp_makes_one_exact_call_after_schema_verification() -> None: + session = FakeSession() + client = DeterministicRobinhoodMcpClient( # type: ignore[arg-type] + TOKEN, + ACCOUNT_HASH, + session, + ) + guard_observations: list[int] = [] + + result = client( + "place_equity_order", + _place_arguments(), + 30, + lambda: guard_observations.append(len(session.calls)), + ) + + assert result["structuredContent"]["order_id"] == "order-1" + tool_calls = [ + call + for call in session.calls + if isinstance(call.get("json"), dict) + and call["json"].get("method") == "tools/call" + and call["json"]["params"]["name"] == "place_equity_order" + ] + assert len(tool_calls) == 1 + assert guard_observations == [4] + assert tool_calls[0]["json"]["params"] == { + "name": "place_equity_order", + "arguments": _place_arguments(), + } + assert session.trust_env is False + assert all(call["allow_redirects"] is False for call in session.calls) + assert all( + call["headers"]["Authorization"] == f"Bearer {TOKEN}" for call in session.calls + ) + + +def test_direct_mcp_blocks_schema_drift_before_the_write_call() -> None: + session = FakeSession(drift_schema=True) + client = DeterministicRobinhoodMcpClient( # type: ignore[arg-type] + TOKEN, + ACCOUNT_HASH, + session, + ) + + with pytest.raises(RobinhoodGatewayError, match="schema drifted"): + client("place_equity_order", _place_arguments(), 30) + + assert not any( + isinstance(call.get("json"), dict) + and call["json"].get("method") == "tools/call" + for call in session.calls + ) + + +def test_direct_mcp_rejects_notional_mode_and_unknown_tools_locally() -> None: + client = DeterministicRobinhoodMcpClient( + TOKEN, + ACCOUNT_HASH, + FakeSession(), # type: ignore[arg-type] + ) + with pytest.raises(RobinhoodGatewayError, match="exact quantity mode"): + client( + "place_equity_order", + { + "side": "buy", + "symbol": "SPY", + "amount": 10.0, + "order_type": "market", + }, + 30, + ) + with pytest.raises(RobinhoodGatewayError, match="outside the sealed allowlist"): + client("place_option_order", {}, 30) + + +def test_direct_read_schema_type_drift_blocks_and_closes_session() -> None: + session = ReadSchemaSession(type_drift=True) + client = DeterministicRobinhoodMcpClient( # type: ignore[arg-type] + TOKEN, + ACCOUNT_HASH, + session, + ) + + with pytest.raises(RobinhoodGatewayError, match="property type drifted"): + client( + "get_portfolio", + {"account_number": ACCOUNT_ID}, + 30, + ) + + assert not any( + isinstance(call.get("json"), dict) + and call["json"].get("method") == "tools/call" + for call in session.calls + ) + assert [call["method"] for call in session.calls].count("DELETE") == 1 + + +def test_direct_mcp_account_mismatch_blocks_before_place() -> None: + session = FakeSession() + client = DeterministicRobinhoodMcpClient( # type: ignore[arg-type] + TOKEN, + "f" * 64, + session, + ) + + with pytest.raises(RobinhoodGatewayError, match="pinned Agentic account"): + client( + "place_equity_order", + _place_arguments(), + 30, + lambda: None, + ) + + assert not any( + isinstance(call.get("json"), dict) + and call["json"].get("method") == "tools/call" + and call["json"]["params"]["name"] == "place_equity_order" + for call in session.calls + ) + + +def test_direct_mcp_connection_loss_after_place_call_is_not_retried() -> None: + session = FakeSession(fail_after_place_call=True) + client = DeterministicRobinhoodMcpClient( # type: ignore[arg-type] + TOKEN, + ACCOUNT_HASH, + session, + ) + + with pytest.raises(RobinhoodGatewayError, match="transport failed"): + client( + "place_equity_order", + _place_arguments(), + 30, + lambda: None, + ) + + place_calls = [ + call + for call in session.calls + if isinstance(call.get("json"), dict) + and call["json"].get("method") == "tools/call" + and call["json"]["params"]["name"] == "place_equity_order" + ] + assert len(place_calls) == 1 + + +def test_gateway_deterministically_reviews_then_places_exact_quantity_once() -> None: + calls: list[tuple[str, dict[str, Any]]] = [] + runner_calls: list[bool] = [] + + def direct_call( + tool: str, + arguments: dict[str, Any], + _timeout: int, + pre_call_guard: Any, + ) -> dict[str, Any]: + if tool == "place_equity_order": + assert pre_call_guard is not None + pre_call_guard() + else: + assert pre_call_guard is None + calls.append((tool, arguments)) + common = { + **arguments, + "warnings": [], + } + if tool == "review_equity_order": + return { + "structuredContent": { + **common, + "review_succeeded": True, + } + } + return { + "structuredContent": { + **common, + "placed": True, + "order_id": "order-1", + "status": "queued", + } + } + + gateway = RobinhoodAgenticGateway( + lambda *_args, **_kwargs: runner_calls.append(True), # type: ignore[arg-type] + 60, + write_timeout_seconds=30, + expected_account_hash=ACCOUNT_HASH, + deterministic_mcp_call=direct_call, + ) + intent = SimpleNamespace( + instrument="equity", + side="buy", + symbol="SPY", + quantity=0.125, + notional_usd=75.0, + order_type="limit", + limit_price=600.0, + time_in_force="day", + extended_hours=False, + execution_snapshot=object(), + intent_id="intent-1", + ) + guard_calls: list[bool] = [] + + result = gateway.place_order( # type: ignore[arg-type] + intent, + "a" * 64, + pre_submit_guard=lambda: ( + guard_calls.append(True) + or datetime.now(timezone.utc) + timedelta(seconds=60) + ), + ) + + expected = _place_arguments() + assert calls == [ + ("review_equity_order", expected), + ("place_equity_order", expected), + ] + assert len(guard_calls) == 2 + assert result.placed is True + assert result.order_id == "order-1" + assert runner_calls == [] + + +def test_gateway_marks_post_call_connection_loss_ambiguous_without_retry() -> None: + calls: list[str] = [] + guard_calls: list[bool] = [] + + def direct_call( + tool: str, + arguments: dict[str, Any], + _timeout: int, + pre_call_guard: Any, + ) -> dict[str, Any]: + calls.append(tool) + if tool == "review_equity_order": + return { + "structuredContent": { + **arguments, + "review_succeeded": True, + "warnings": [], + } + } + assert pre_call_guard is not None + pre_call_guard() + raise RobinhoodGatewayError("connection lost after write") + + gateway = RobinhoodAgenticGateway( + lambda *_args, **_kwargs: (_ for _ in ()).throw( + AssertionError("model runner must never receive broker authority") + ), + 60, + write_timeout_seconds=30, + expected_account_hash=ACCOUNT_HASH, + deterministic_mcp_call=direct_call, + ) + intent = SimpleNamespace( + instrument="equity", + side="buy", + symbol="SPY", + quantity=0.125, + notional_usd=75.0, + order_type="limit", + limit_price=600.0, + time_in_force="day", + extended_hours=False, + execution_snapshot=object(), + intent_id="intent-loss", + ) + + with pytest.raises( + RobinhoodPlacementAmbiguousError, + match="unknown broker outcome", + ): + gateway.place_order( # type: ignore[arg-type] + intent, + "a" * 64, + pre_submit_guard=lambda: ( + guard_calls.append(True) + or datetime.now(timezone.utc) + timedelta(seconds=60) + ), + ) + + assert calls == ["review_equity_order", "place_equity_order"] + assert len(guard_calls) == 2 + + +def test_parent_only_reads_exact_account_and_quote_sequences_without_model() -> None: + transport = ParentReadTransport() + runner_calls: list[bool] = [] + gateway = RobinhoodAgenticGateway( + lambda *_args, **_kwargs: runner_calls.append(True), # type: ignore[arg-type] + 60, + expected_account_hash=ACCOUNT_HASH, + deterministic_mcp_call=transport, + ) + + account = gateway.get_account_snapshot() + intent = SimpleNamespace( + instrument="equity", + side="buy", + symbol="SPY", + analysis_symbol="SPY", + ) + quote = gateway.get_equity_execution_snapshot(intent) # type: ignore[arg-type] + + assert account.account_hash == ACCOUNT_HASH + assert account.authenticated is True + assert quote.execution_quote.symbol == "SPY" + assert runner_calls == [] + assert [(tool, arguments) for tool, arguments, _ in transport.calls] == [ + ("get_accounts", {}), + ("get_portfolio", {"account_number": ACCOUNT_ID}), + ("get_equity_positions", {"account_number": ACCOUNT_ID}), + ("get_option_positions", {"account_number": ACCOUNT_ID}), + ( + "get_equity_orders", + {"account_number": ACCOUNT_ID, "limit": 1000}, + ), + ( + "get_option_orders", + {"account_number": ACCOUNT_ID, "limit": 1000}, + ), + ("get_equity_quotes", {"symbols": ["SPY"]}), + ("get_equity_tradability", {"symbols": ["SPY"]}), + ] + + +def test_parent_read_wrong_account_stops_before_scoped_calls() -> None: + transport = ParentReadTransport(account_id="other-agentic-account") + gateway = RobinhoodAgenticGateway( + lambda *_args, **_kwargs: (_ for _ in ()).throw( + AssertionError("model fallback is prohibited") + ), + 60, + expected_account_hash=ACCOUNT_HASH, + deterministic_mcp_call=transport, + ) + + with pytest.raises(RobinhoodGatewayError, match="pinned Agentic account"): + gateway.get_account_snapshot() + + assert [tool for tool, _, _ in transport.calls] == ["get_accounts"] + + +@pytest.mark.parametrize( + ("buying_power", "total_equity"), + [ + ("-1", "100"), + ("100", "1e9999"), + ], +) +def test_parent_read_rejects_negative_or_overflow_portfolio( + buying_power: str, + total_equity: str, +) -> None: + transport = ParentReadTransport( + buying_power=buying_power, + total_equity=total_equity, + ) + gateway = RobinhoodAgenticGateway( + lambda *_args, **_kwargs: (_ for _ in ()).throw( + AssertionError("model fallback is prohibited") + ), + 60, + expected_account_hash=ACCOUNT_HASH, + deterministic_mcp_call=transport, + ) + + with pytest.raises(RobinhoodGatewayError, match="finite and nonnegative"): + gateway.get_account_snapshot() + + +def test_parent_read_does_not_zero_fill_missing_position_economics() -> None: + transport = ParentReadTransport( + equity_positions=[ + { + "symbol": "SPY", + "quantity": "1", + "market_price": "100", + "market_value": "100", + "unrealized_pnl": "0", + } + ] + ) + gateway = RobinhoodAgenticGateway( + lambda *_args, **_kwargs: (_ for _ in ()).throw( + AssertionError("model fallback is prohibited") + ), + 60, + expected_account_hash=ACCOUNT_HASH, + deterministic_mcp_call=transport, + ) + + with pytest.raises(RobinhoodGatewayError, match="position.average_cost"): + gateway.get_account_snapshot() + + +def test_parent_read_rejects_conflicting_position_aliases() -> None: + transport = ParentReadTransport( + equity_positions=[ + { + "symbol": "SPY", + "quantity": "1", + "average_cost": "99", + "average_price": "100", + "market_price": "100", + "market_value": "100", + "unrealized_pnl": "1", + } + ] + ) + gateway = RobinhoodAgenticGateway( + lambda *_args, **_kwargs: (_ for _ in ()).throw( + AssertionError("model fallback is prohibited") + ), + 60, + expected_account_hash=ACCOUNT_HASH, + deterministic_mcp_call=transport, + ) + + with pytest.raises(RobinhoodGatewayError, match="conflicting numeric aliases"): + gateway.get_account_snapshot() + + +def test_parent_read_preserves_incomplete_history_as_blocking_evidence() -> None: + transport = ParentReadTransport(history_next="next-page") + gateway = RobinhoodAgenticGateway( + lambda *_args, **_kwargs: (_ for _ in ()).throw( + AssertionError("model fallback is prohibited") + ), + 60, + expected_account_hash=ACCOUNT_HASH, + deterministic_mcp_call=transport, + ) + + account = gateway.get_account_snapshot() + + assert account.equity_order_history_complete is False + assert account.option_order_history_complete is False + assert "equity_order_history_incomplete" in account.error + assert "option_order_history_incomplete" in account.error + + +def test_parent_read_rejects_naive_quote_timestamp_and_never_falls_back() -> None: + transport = ParentReadTransport( + quote_observed_at="2099-01-01T00:00:00", + ) + runner_calls: list[bool] = [] + gateway = RobinhoodAgenticGateway( + lambda *_args, **_kwargs: runner_calls.append(True), # type: ignore[arg-type] + 60, + expected_account_hash=ACCOUNT_HASH, + deterministic_mcp_call=transport, + ) + intent = SimpleNamespace( + instrument="equity", + side="buy", + symbol="SPY", + analysis_symbol="SPY", + ) + + with pytest.raises(RobinhoodGatewayError, match="timezone-aware"): + gateway.get_equity_execution_snapshot(intent) # type: ignore[arg-type] + + assert runner_calls == [] + + +def test_parent_read_rejects_unrequested_quote_symbol() -> None: + transport = ParentReadTransport(extra_quote_symbol="QQQ") + gateway = RobinhoodAgenticGateway( + lambda *_args, **_kwargs: (_ for _ in ()).throw( + AssertionError("model fallback is prohibited") + ), + 60, + expected_account_hash=ACCOUNT_HASH, + deterministic_mcp_call=transport, + ) + intent = SimpleNamespace( + instrument="equity", + side="buy", + symbol="SPY", + analysis_symbol="SPY", + ) + + with pytest.raises(RobinhoodGatewayError, match="unrequested symbol"): + gateway.get_equity_execution_snapshot(intent) # type: ignore[arg-type] + + +def test_parent_read_transport_failure_never_falls_back_to_model() -> None: + transport = ParentReadTransport(fail_tool="get_accounts") + runner_calls: list[bool] = [] + gateway = RobinhoodAgenticGateway( + lambda *_args, **_kwargs: runner_calls.append(True), # type: ignore[arg-type] + 60, + expected_account_hash=ACCOUNT_HASH, + deterministic_mcp_call=transport, + ) + + with pytest.raises(RobinhoodGatewayError, match="parent read failed"): + gateway.get_account_snapshot() + + assert runner_calls == [] + + +def test_operator_default_gateway_cannot_place_or_cancel_through_model( + monkeypatch: pytest.MonkeyPatch, +) -> None: + runner_calls: list[bool] = [] + monkeypatch.setattr( + live_service.subprocess, + "run", + lambda *_args, **_kwargs: runner_calls.append(True), + ) + gateway = live_service.default_gateway() + intent = SimpleNamespace( + instrument="equity", + side="sell", + symbol="SPY", + quantity=0.125, + notional_usd=0.0, + order_type="limit", + limit_price=600.0, + time_in_force="day", + extended_hours=False, + execution_snapshot=None, + intent_id="legacy-denied", + ) + + with pytest.raises( + RobinhoodGatewayError, + match="deterministic parent-side MCP", + ): + gateway.place_order( # type: ignore[arg-type] + intent, + "a" * 64, + pre_submit_guard=lambda: datetime.now(timezone.utc) + timedelta(seconds=60), + ) + with pytest.raises( + RobinhoodGatewayError, + match="deterministic parent-side MCP", + ): + gateway.cancel_order("equity", "order-1") + + assert runner_calls == [] diff --git a/tests/test_dumbmoney_journal_anchor.py b/tests/test_dumbmoney_journal_anchor.py new file mode 100644 index 0000000..38c5841 --- /dev/null +++ b/tests/test_dumbmoney_journal_anchor.py @@ -0,0 +1,236 @@ +from __future__ import annotations + +import base64 +import hashlib +import json +from datetime import UTC, datetime, timedelta +from typing import Any + +import pytest +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey + +from dopey_live.dumbmoney_authority import canonical_json_bytes +from dopey_live.dumbmoney_command_feed import ( + CommandFeedHttpRequest, + CommandFeedHttpResponse, +) +from dopey_live.dumbmoney_journal_anchor import ( + ANCHOR_BODY_SCHEMA, + ANCHOR_CHECKPOINT_SCHEMA, + ANCHOR_RESPONSE_SCHEMA, + CoreJournalAnchorClient, + JournalAnchorError, +) + +NOW = datetime(2026, 7, 26, 22, 0, 0, tzinfo=UTC) +ACCOUNT_HASH = hashlib.sha256(b"dopey-account").hexdigest() +PRIVATE_KEY = Ed25519PrivateKey.from_private_bytes(bytes(range(32))) +PUBLIC_KEY = PRIVATE_KEY.public_key().public_bytes_raw() +SIGNER_KEY_ID = hashlib.sha256(PUBLIC_KEY).hexdigest() +ZERO_DIGEST = "0" * 64 + + +def _z(value: datetime) -> str: + return value.astimezone(UTC).isoformat().replace("+00:00", "Z") + + +def _hash(value: str | dict[str, Any]) -> str: + material = ( + value.encode("utf-8") if isinstance(value, str) else canonical_json_bytes(value) + ) + return hashlib.sha256(material).hexdigest() + + +def _signature(value: dict[str, Any]) -> str: + return ( + base64.urlsafe_b64encode(PRIVATE_KEY.sign(canonical_json_bytes(value))) + .decode("ascii") + .rstrip("=") + ) + + +def _signed_anchor( + body: dict[str, Any], + observed_at: datetime, +) -> dict[str, Any]: + unsigned = { + "schema": "dumbmoney.signed-envelope.v1", + "source_id": "dumbmoney-core", + "source_sequence": 1, + "correlation_id": "journal-anchor:dopey_robinhood:test-journal", + "causation_id": None, + "nonce": _hash("anchor-event-nonce"), + "not_before": _z(observed_at), + "expires_at": _z(observed_at + timedelta(seconds=120)), + "body_schema": ANCHOR_BODY_SCHEMA, + "body_digest": _hash(body), + "body": body, + "signature_algorithm": "Ed25519", + "signer_key_id": SIGNER_KEY_ID, + } + envelope = {**unsigned, "event_id": _hash(unsigned)} + envelope["signature"] = _signature(envelope) + return envelope + + +def _response( + request: dict[str, Any], + *, + observed_at: datetime = NOW, +) -> dict[str, Any]: + identity = { + "schema": ANCHOR_BODY_SCHEMA, + "venue": "dopey_robinhood", + "account_hash": request["account_hash"], + "journal_name": request["journal_name"], + "journal_schema": request["journal_schema"], + "journal_stream_id": request["journal_stream_id"], + "journal_sequence": request["journal_sequence"], + "journal_head_sha256": request["journal_head_sha256"], + "previous_anchor_body_digest": ZERO_DIGEST, + } + body = { + **identity, + "anchor_id": _hash(identity), + "anchored_at": _z(observed_at), + } + envelope = _signed_anchor(body, observed_at) + event_material = { + "global_sequence": 1, + "event_id": envelope["event_id"], + "source_id": envelope["source_id"], + "source_sequence": envelope["source_sequence"], + "signer_key_id": envelope["signer_key_id"], + "nonce": envelope["nonce"], + "event_schema": envelope["body_schema"], + "observed_at": _z(observed_at), + "received_at": _z(observed_at), + "correlation_id": envelope["correlation_id"], + "causation_id": envelope["causation_id"], + "payload_digest": envelope["body_digest"], + "previous_source_digest": ZERO_DIGEST, + "previous_global_digest": ZERO_DIGEST, + } + event_digest = _hash(event_material) + checkpoint = { + "schema": ANCHOR_CHECKPOINT_SCHEMA, + "cell_id": "dopey_robinhood", + "request_nonce": request["request_nonce"], + "account_hash": request["account_hash"], + "journal_name": request["journal_name"], + "journal_schema": request["journal_schema"], + "journal_stream_id": request["journal_stream_id"], + "journal_sequence": request["journal_sequence"], + "journal_head_sha256": request["journal_head_sha256"], + "anchor_body_digest": envelope["body_digest"], + "anchor_event_digest": event_digest, + "reused": False, + "observed_at": _z(observed_at), + } + return { + "schema": ANCHOR_RESPONSE_SCHEMA, + "cell_id": "dopey_robinhood", + "request_nonce": request["request_nonce"], + "observed_at": _z(observed_at), + "reused": False, + "anchor_envelope": envelope, + "ledger_proof": { + "schema": "dumbmoney.ledger-event-proof.v1", + **event_material, + "event_digest": event_digest, + }, + "checkpoint": checkpoint, + "checkpoint_signature": { + "algorithm": "Ed25519", + "signer_key_id": SIGNER_KEY_ID, + "signature": _signature(checkpoint), + }, + } + + +class _Transport: + def __init__(self, mutate: Any = None, *, status: int = 200) -> None: + self.mutate = mutate + self.status = status + self.calls: list[CommandFeedHttpRequest] = [] + + def __call__( + self, + request: CommandFeedHttpRequest, + ) -> CommandFeedHttpResponse: + self.calls.append(request) + assert request.body is not None + payload = _response(json.loads(request.body)) + if self.mutate is not None: + self.mutate(payload) + return CommandFeedHttpResponse( + status=self.status, + headers={"Content-Type": "application/json"}, + body=canonical_json_bytes(payload), + ) + + +def _client(transport: Any, now: datetime = NOW) -> CoreJournalAnchorClient: + return CoreJournalAnchorClient( + base_url="http://127.0.0.1:49152", + transport=transport, + cell_token_provider=lambda: "t" * 64, + trusted_core_public_key=PUBLIC_KEY, + expected_account_hash=ACCOUNT_HASH, + request_nonce_fn=lambda: _hash("request-nonce"), + now_fn=lambda: now, + ) + + +def test_exact_core_anchor_checkpoint_and_proof_are_accepted() -> None: + transport = _Transport() + client = _client(transport) + head = _hash("journal-head") + + result = client.anchor( + journal_name="live-branch-ledger", + journal_schema="dopey-live-branch-ledger-v1", + journal_sequence=7, + journal_head_sha256=head, + ) + + assert result.journal_sequence == 7 + assert result.journal_head_sha256 == head + assert len(transport.calls) == 1 + request = transport.calls[0] + assert request.method == "POST" + assert request.url.endswith("/v1/cells/dopey_robinhood/journal-heads:anchor") + assert request.headers["Authorization"] == f"Bearer {'t' * 64}" + + +def test_tampered_checkpoint_and_rejected_anchor_fail_closed() -> None: + def tamper(payload: dict[str, Any]) -> None: + payload["checkpoint"]["journal_sequence"] = 6 + + with pytest.raises(JournalAnchorError, match="signature is invalid"): + _client(_Transport(tamper)).anchor( + journal_name="live-branch-ledger", + journal_schema="dopey-live-branch-ledger-v1", + journal_sequence=7, + journal_head_sha256=_hash("journal-head"), + ) + + with pytest.raises(JournalAnchorError, match="Core rejected"): + _client(_Transport(status=409)).anchor( + journal_name="command-feed", + journal_schema="dopey.dumbmoney-command-feed-state.v1", + journal_sequence=0, + journal_head_sha256=ZERO_DIGEST, + ) + + +def test_zero_sequence_and_head_must_move_together() -> None: + client = _client(_Transport()) + + with pytest.raises(JournalAnchorError, match="zero must exactly bind"): + client.anchor( + journal_name="command-feed", + journal_schema="dopey.dumbmoney-command-feed-state.v1", + journal_sequence=0, + journal_head_sha256=_hash("not-zero"), + ) diff --git a/tests/test_dumbmoney_live_authority.py b/tests/test_dumbmoney_live_authority.py new file mode 100644 index 0000000..f39a4de --- /dev/null +++ b/tests/test_dumbmoney_live_authority.py @@ -0,0 +1,1126 @@ +from __future__ import annotations + +import base64 +import hashlib +import json +import sys +from dataclasses import replace +from datetime import datetime, timedelta, timezone +from pathlib import Path +from typing import Any + +import pytest +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey + +from dopey_live.dumbmoney_authority import ( + DumbMoneyAuthorityContext, + DumbMoneyAuthorityError, + account_open_risk_cents, + authorize_intent, + authorized_settled_cash_usd, + canonical_json_bytes, + verify_signed_capital_envelope, + _urlsafe_b64decode, +) +from dopey_live.ledger import ( + LiveBranchLedgerError, + activate_dumbmoney_fence, + append_record, + assert_current_dumbmoney_fence, + ledger_path, + read_ledger, +) +from dopey_live.models import ( + AccountSnapshot, + GatewayCancelResult, + LiveExecutionIntent, +) +from dopey_live.robinhood_gateway import ( + McpToolCallEvidence, + RobinhoodAgenticGateway, + RobinhoodCancellationAmbiguousError, + RobinhoodGatewayError, + _canonical_raw_broker_item, + _gateway_cancel_result, + _validated_equity_position, +) +from dopey_live.service import ( + _poll_ambiguous_placement_history, + _run_cancel_only_degraded, + _witnessed_daily_loss_cents, +) +from dopey_live.signals import LiveSignalError, validate_option_entry_for_execution +from operator_tools import operator_live_branch + + +FIXTURE_PATH = ( + Path(__file__).parent / "fixtures" / "dumbmoney" / "signed-capital-envelope.v1.json" +) +NEGATIVE_CAP_FIXTURE_PATH = ( + Path(__file__).parent + / "fixtures" + / "dumbmoney" + / "capital-envelope-cap-order-negative.v1.json" +) +FIXTURE_FILE_SHA256 = "b0fe2fa0261b2dc36e4043e8c33b91b1016676e04a25e4b4d5bd66eea0c9079a" +NOW = datetime(2099, 1, 1, 0, 0, 30, tzinfo=timezone.utc) + + +def _fixture() -> tuple[dict[str, Any], bytes, dict[str, Any]]: + fixture = json.loads(FIXTURE_PATH.read_text(encoding="utf-8")) + public_key = base64.urlsafe_b64decode( + fixture["public_key_base64url"] + + "=" * (-len(fixture["public_key_base64url"]) % 4) + ) + return fixture["envelope"], public_key, fixture + + +def _account( + *, + orders: tuple[dict[str, Any], ...] = (), + positions: tuple[dict[str, Any], ...] = (), +) -> AccountSnapshot: + return AccountSnapshot( + authenticated=True, + account_type="individual", + buying_power_usd=100.0, + total_equity_usd=100.0, + equity_positions=positions, + option_positions=(), + recent_equity_orders=orders, + recent_option_orders=(), + equity_order_history_complete=True, + option_order_history_complete=True, + error="", + account_hash="c" * 64, + ) + + +def _equity_intent( + *, + notional_usd: float = 1.0, + quantity: float = 0.01, + promotion_hash: str | None = None, + passport_hash: str | None = None, +) -> LiveExecutionIntent: + return LiveExecutionIntent( + intent_id="intent-1", + instrument="equity", + symbol="ACME", + side="buy", + order_type="limit", + limit_price=100.0, + time_in_force="day", + extended_hours=False, + notional_usd=notional_usd, + quantity=quantity, + source_as_of_date="2098-12-31", + source_hash="a" * 64, + thesis_direction="bullish", + analysis_symbol="ACME", + analysis_relationship="self", + analysis_reference_price=100.0, + analysis_invalidation_price=90.0, + analysis_target_price=120.0, + daily_leverage_multiple=1.0, + daily_reset=False, + maximum_holding_sessions=5, + reference_price=100.0, + invalidation_price=90.0, + target_price=120.0, + expected_hold_trading_days=5, + execution_snapshot=None, + option_contract={}, + reason_codes=("fixture",), + promotion_hash=( + promotion_hash + or str(_fixture()[0]["body"].get("promotion_hashes", ["b" * 64])[0]) + ), + passport_hash=( + passport_hash + or str(_fixture()[0]["body"].get("passport_hashes", ["b" * 64])[0]) + ), + ) + + +def test_blunder_signed_fixture_cross_validates_byte_for_byte() -> None: + envelope, public_key, fixture = _fixture() + signing_payload = { + key: value for key, value in envelope.items() if key != "signature" + } + + assert hashlib.sha256(FIXTURE_PATH.read_bytes()).hexdigest() == ( + FIXTURE_FILE_SHA256 + ) + assert hashlib.sha256(canonical_json_bytes(signing_payload)).hexdigest() == ( + "97f8ecdf0d6b48b310c06cdeb37654fb171f4fa62bf91b427965191643d7ee1e" + ) + assert fixture["signing_payload_sha256"] == ( + "97f8ecdf0d6b48b310c06cdeb37654fb171f4fa62bf91b427965191643d7ee1e" + ) + assert envelope["body_digest"] == ( + "fcf86b4c257c0fea9ba65051366b76738563598d4217c4950ef3cf63ed1927d0" + ) + assert envelope["event_id"] == ( + "95fd19cc7bcbc89fe3c7985ee04d502704344b2f09c1eb6dce575b41c0ae579c" + ) + assert envelope["body"]["promotion_hashes"] == ["f" * 64] + assert envelope["body"]["authorized_instruments"] == ["equity:ACME"] + authority = verify_signed_capital_envelope( + envelope, + public_key, + NOW, + "c" * 64, + ) + + assert authority.event_id == envelope["event_id"] + assert authority.envelope.venue == "dopey_robinhood" + assert authority.envelope.fencing_generation == 7 + + +def test_signed_envelope_rejects_tamper_expiry_and_wrong_account() -> None: + envelope, public_key, _ = _fixture() + tampered = json.loads(json.dumps(envelope)) + tampered["body"]["max_order_risk_cents"] = 200 + + with pytest.raises(DumbMoneyAuthorityError, match="body digest mismatch"): + verify_signed_capital_envelope(tampered, public_key, NOW, "c" * 64) + with pytest.raises(DumbMoneyAuthorityError, match="not currently valid"): + verify_signed_capital_envelope( + envelope, + public_key, + datetime(2099, 1, 1, 0, 1, tzinfo=timezone.utc), + "c" * 64, + ) + with pytest.raises(DumbMoneyAuthorityError, match="different account"): + verify_signed_capital_envelope(envelope, public_key, NOW, "d" * 64) + + +def test_authority_file_requires_strict_canonical_json(tmp_path: Path) -> None: + envelope, public_key, _ = _fixture() + envelope_path = tmp_path / "capital-envelope.json" + public_key_path = tmp_path / "core.pub" + envelope_path.write_bytes(canonical_json_bytes(envelope)) + public_key_path.write_bytes(public_key) + + context = DumbMoneyAuthorityContext.from_files( + envelope_path, + public_key_path, + "c" * 64, + ) + + assert context.signed_envelope == envelope + assert context.trusted_public_key == public_key + + +@pytest.mark.parametrize( + "raw, match", + [ + ( + b'{"schema":"first","schema":"second"}', + "duplicate JSON key", + ), + ( + b'{"body":{"schema":"first","schema":"second"}}', + "duplicate JSON key", + ), + ( + b'{"value":NaN}', + "non-finite JSON number", + ), + ( + b'{"value":Infinity}', + "non-finite JSON number", + ), + ( + b'{"value":1e9999}', + "non-finite JSON number", + ), + ( + b'{"value":1.5}', + "float outside the signed integer domain", + ), + ], +) +def test_authority_file_rejects_ambiguous_json( + tmp_path: Path, + raw: bytes, + match: str, +) -> None: + envelope_path = tmp_path / "capital-envelope.json" + public_key_path = tmp_path / "core.pub" + envelope_path.write_bytes(raw) + public_key_path.write_bytes(b"x" * 32) + + with pytest.raises(DumbMoneyAuthorityError, match=match): + DumbMoneyAuthorityContext.from_files( + envelope_path, + public_key_path, + "c" * 64, + ) + + +def test_authority_file_rejects_noncanonical_bytes(tmp_path: Path) -> None: + envelope, public_key, _ = _fixture() + envelope_path = tmp_path / "capital-envelope.json" + public_key_path = tmp_path / "core.pub" + envelope_path.write_text( + json.dumps(envelope, indent=2, ensure_ascii=False) + "\n", + encoding="utf-8", + ) + public_key_path.write_bytes(public_key) + + with pytest.raises(DumbMoneyAuthorityError, match="exact canonical JSON bytes"): + DumbMoneyAuthorityContext.from_files( + envelope_path, + public_key_path, + "c" * 64, + ) + + +def test_monotonic_fence_survives_local_lock_reacquisition(tmp_path: Path) -> None: + activate_dumbmoney_fence( + tmp_path, + "e" * 64, + "1" * 64, + "2" * 64, + 7, + 1, + ) + activate_dumbmoney_fence( + tmp_path, + "e" * 64, + "1" * 64, + "2" * 64, + 7, + 1, + ) + assert_current_dumbmoney_fence( + tmp_path, + "e" * 64, + "1" * 64, + "2" * 64, + 7, + 1, + ) + + with pytest.raises(LiveBranchLedgerError, match="newer generation"): + activate_dumbmoney_fence( + tmp_path, + "f" * 64, + "2" * 64, + "3" * 64, + 6, + 1, + ) + with pytest.raises(LiveBranchLedgerError, match="reused"): + activate_dumbmoney_fence( + tmp_path, + "e" * 64, + "9" * 64, + "2" * 64, + 7, + 1, + ) + with pytest.raises(LiveBranchLedgerError, match="reused"): + activate_dumbmoney_fence( + tmp_path, + "e" * 64, + "1" * 64, + "8" * 64, + 7, + 1, + ) + + activate_dumbmoney_fence( + tmp_path, + "f" * 64, + "3" * 64, + "4" * 64, + 8, + 2, + ) + with pytest.raises(LiveBranchLedgerError, match="no longer"): + assert_current_dumbmoney_fence( + tmp_path, + "e" * 64, + "1" * 64, + "2" * 64, + 7, + 1, + ) + + +def test_capital_intersects_settled_cash_open_risk_and_order_limit() -> None: + envelope, public_key, _ = _fixture() + authority = verify_signed_capital_envelope(envelope, public_key, NOW, "c" * 64) + account = _account() + + assert authorized_settled_cash_usd(authority, account, 100.0, 0) == 3.0 + assert authorize_intent(authority, account, _equity_intent(), 0) == 100 + + with pytest.raises(DumbMoneyAuthorityError, match="max_order_risk"): + authorize_intent( + authority, + account, + _equity_intent(notional_usd=2.0, quantity=0.02), + 0, + ) + exposed = _account( + positions=( + { + "symbol": "ACME", + "quantity": 1.0, + "average_cost": 2.5, + "market_price": 2.5, + "market_value": 2.5, + "unrealized_pnl": 0.0, + }, + ) + ) + assert account_open_risk_cents(exposed) == 250 + assert authorized_settled_cash_usd(authority, exposed, 100.0, 0) == 0.5 + + +def test_intent_requires_exact_instrument_promotion_and_daily_loss_headroom() -> None: + envelope, public_key, _ = _fixture() + authority = verify_signed_capital_envelope(envelope, public_key, NOW, "c" * 64) + intent = _equity_intent() + + assert authorize_intent(authority, _account(), intent, 200) == 100 + with pytest.raises(DumbMoneyAuthorityError, match="daily-loss capacity"): + authorize_intent(authority, _account(), intent, 201) + with pytest.raises(DumbMoneyAuthorityError, match="daily-loss capacity"): + authorize_intent(authority, _account(), intent, 299) + with pytest.raises(DumbMoneyAuthorityError, match="promotion hash"): + authorize_intent( + authority, + _account(), + replace(intent, promotion_hash="e" * 64), + 0, + ) + with pytest.raises(DumbMoneyAuthorityError, match="passport hash"): + authorize_intent( + authority, + _account(), + replace(intent, passport_hash="e" * 64), + 0, + ) + with pytest.raises(DumbMoneyAuthorityError, match="exact broker instrument"): + authorize_intent( + authority, + _account(), + replace( + intent, + symbol="OTHER", + analysis_symbol="OTHER", + ), + 0, + ) + + +def test_sell_requires_exact_fresh_long_inventory_and_cannot_oversell() -> None: + envelope, public_key, _ = _fixture() + authority = verify_signed_capital_envelope(envelope, public_key, NOW, "c" * 64) + exit_intent = replace( + _equity_intent(quantity=1.0), + side="sell", + notional_usd=0.0, + ) + holding = { + "symbol": "ACME", + "quantity": 1.0, + "average_cost": 100.0, + "market_price": 99.0, + "market_value": 99.0, + "unrealized_pnl": -1.0, + } + + assert ( + authorize_intent( + authority, + _account(positions=(holding,)), + exit_intent, + None, + ) + == 0 + ) + with pytest.raises(DumbMoneyAuthorityError, match="matching long"): + authorize_intent(authority, _account(), exit_intent, None) + with pytest.raises(DumbMoneyAuthorityError, match="oversell"): + authorize_intent( + authority, + _account(positions=(holding,)), + replace(exit_intent, quantity=1.01), + None, + ) + active_sell = { + "order_id": "sell-open", + "symbol": "ACME", + "side": "sell", + "status": "open", + "market_date": "2099-01-01", + "submitted_at": "2099-01-01T00:00:00Z", + "filled_at": "", + "filled_quantity": 0.0, + "average_fill_price": 0.0, + } + with pytest.raises(DumbMoneyAuthorityError, match="active sell"): + authorize_intent( + authority, + _account(orders=(active_sell,), positions=(holding,)), + exit_intent, + None, + ) + + +def test_daily_loss_and_existing_entry_order_fail_closed() -> None: + envelope, public_key, _ = _fixture() + authority = verify_signed_capital_envelope(envelope, public_key, NOW, "c" * 64) + + with pytest.raises(DumbMoneyAuthorityError, match="daily loss is required"): + authorized_settled_cash_usd(authority, _account(), 100.0, None) + with pytest.raises(DumbMoneyAuthorityError, match="max_daily_loss"): + authorize_intent(authority, _account(), _equity_intent(), 300) + + active_entry = { + "order_id": "open-buy", + "symbol": "OTHER", + "side": "buy", + "status": "open", + "market_date": "2099-01-01", + "submitted_at": "2099-01-01T00:00:00+00:00", + "filled_at": "", + "filled_quantity": 0.0, + "average_fill_price": 0.0, + } + with pytest.raises(DumbMoneyAuthorityError, match="active entry order"): + authorize_intent( + authority, + _account(orders=(active_entry,)), + _equity_intent(), + 0, + ) + + +def test_option_position_risk_uses_multiplier_and_rejects_missing_evidence() -> None: + option_position = { + "symbol": "ACME", + "contract_id": "option-1", + "quantity": 2.0, + "average_cost": 1.25, + "market_price": 1.0, + "market_value": 200.0, + "unrealized_pnl": -50.0, + } + account = AccountSnapshot( + **{ + **_account().to_dict(), + "option_positions": (option_position,), + } + ) + assert account_open_risk_cents(account) == 25_000 + + incomplete = AccountSnapshot( + **{ + **_account().to_dict(), + "option_positions": ( + { + "symbol": "ACME", + "contract_id": "option-1", + "quantity": 1.0, + }, + ), + } + ) + with pytest.raises(DumbMoneyAuthorityError, match="complete finite risk"): + account_open_risk_cents(incomplete) + + +def _signed_capital_body( + body: dict[str, Any], + wrapper_not_before: str, + wrapper_expires_at: str, +) -> tuple[dict[str, Any], bytes]: + private_key = Ed25519PrivateKey.generate() + public_key = private_key.public_key().public_bytes_raw() + unsigned: dict[str, Any] = { + "schema": "dumbmoney.signed-envelope.v1", + "source_id": "dumbmoney-core", + "source_sequence": 1, + "correlation_id": "window-test", + "causation_id": None, + "nonce": "window-test-1", + "not_before": wrapper_not_before, + "expires_at": wrapper_expires_at, + "body_schema": "dumbmoney.capital-envelope.v1", + "body_digest": hashlib.sha256(canonical_json_bytes(body)).hexdigest(), + "body": body, + "signature_algorithm": "Ed25519", + "signer_key_id": hashlib.sha256(public_key).hexdigest(), + } + event_material = dict(unsigned) + unsigned["event_id"] = hashlib.sha256( + canonical_json_bytes(event_material) + ).hexdigest() + signature = private_key.sign(canonical_json_bytes(unsigned)) + return { + **unsigned, + "signature": base64.urlsafe_b64encode(signature).decode().rstrip("="), + }, public_key + + +def _signed_envelope_for_window( + wrapper_not_before: str, + wrapper_expires_at: str, + body_not_before: str, + body_expires_at: str, +) -> tuple[dict[str, Any], bytes]: + body = { + **_fixture()[0]["body"], + "not_before": body_not_before, + "expires_at": body_expires_at, + "account_hash": "c" * 64, + } + return _signed_capital_body( + body, + wrapper_not_before, + wrapper_expires_at, + ) + + +def test_capital_envelope_cap_order_negative_fixture_fails_closed() -> None: + fixture = json.loads(NEGATIVE_CAP_FIXTURE_PATH.read_text(encoding="utf-8")) + assert fixture["fixture_schema"] == ( + "dopey.dumbmoney-capital-envelope-negative-cases.v1" + ) + cases = fixture["cases"] + assert [case["name"] for case in cases] == [ + "max_order_exceeds_max_correlated", + "max_correlated_exceeds_max_open", + ] + for case in cases: + body = { + **_fixture()[0]["body"], + **case["overrides"], + } + envelope, public_key = _signed_capital_body( + body, + "2099-01-01T00:00:00Z", + "2099-01-01T00:01:00Z", + ) + with pytest.raises( + DumbMoneyAuthorityError, + match=str(case["expected_error"]), + ): + verify_signed_capital_envelope( + envelope, + public_key, + NOW, + "c" * 64, + ) + + +def test_capital_envelope_rejects_mix_and_match_lineage_sets() -> None: + body = { + **_fixture()[0]["body"], + "strategy_hashes": ["a" * 64, "b" * 64], + "passport_hashes": ["c" * 64, "d" * 64], + "promotion_hashes": ["e" * 64, "f" * 64], + } + envelope, public_key = _signed_capital_body( + body, + "2099-01-01T00:00:00Z", + "2099-01-01T00:01:00Z", + ) + + with pytest.raises( + DumbMoneyAuthorityError, + match="exactly one bound strategy/passport/promotion tuple", + ): + verify_signed_capital_envelope( + envelope, + public_key, + NOW, + "c" * 64, + ) + + +def test_signature_encoding_and_authority_window_are_canonical_and_short() -> None: + envelope, _, _ = _fixture() + signature = envelope["signature"] + with pytest.raises(ValueError, match="canonical"): + _urlsafe_b64decode(signature[:-1] + "x") + + wider, public_key = _signed_envelope_for_window( + "2099-01-01T00:00:00Z", + "2099-01-01T00:02:00Z", + "2099-01-01T00:00:00Z", + "2099-01-01T00:02:00Z", + ) + with pytest.raises(DumbMoneyAuthorityError, match="60-second"): + verify_signed_capital_envelope(wider, public_key, NOW, "c" * 64) + + mismatched, public_key = _signed_envelope_for_window( + "2099-01-01T00:00:00Z", + "2099-01-01T00:01:00Z", + "2099-01-01T00:00:01Z", + "2099-01-01T00:01:00Z", + ) + with pytest.raises(DumbMoneyAuthorityError, match="must match exactly"): + verify_signed_capital_envelope(mismatched, public_key, NOW, "c" * 64) + + +def test_daily_loss_is_derived_from_attributed_fee_bearing_broker_fills( + tmp_path: Path, +) -> None: + buy = _equity_intent(notional_usd=100.0, quantity=1.0) + sell = replace( + buy, + intent_id="intent-sell", + side="sell", + notional_usd=0.0, + quantity=1.0, + ) + for order_id, intent in (("buy-1", buy), ("sell-1", sell)): + append_record( + ledger_path(tmp_path), + "live_order_result", + { + "intent_id": intent.intent_id, + "instrument": intent.instrument, + "symbol": intent.symbol, + "side": intent.side, + "market_date": "2099-01-01", + "intent": intent.to_dict(), + "placed": True, + "order_id": order_id, + "status": "filled", + "filled_quantity": 1.0, + "average_fill_price": 100.0 if intent.side == "buy" else 98.0, + }, + ) + broker_orders = ( + { + "order_id": "buy-1", + "symbol": "ACME", + "side": "buy", + "status": "filled", + "market_date": "2099-01-01", + "submitted_at": "2099-01-01T00:00:10+00:00", + "filled_at": "2099-01-01T00:00:11+00:00", + "filled_quantity": 1.0, + "average_fill_price": 100.0, + "fees_usd": 0.10, + }, + { + "order_id": "sell-1", + "symbol": "ACME", + "side": "sell", + "status": "filled", + "market_date": "2099-01-01", + "submitted_at": "2099-01-01T00:00:20+00:00", + "filled_at": "2099-01-01T00:00:21+00:00", + "filled_quantity": 1.0, + "average_fill_price": 98.0, + "fees_usd": 0.10, + }, + ) + assert ( + _witnessed_daily_loss_cents( + tmp_path, + _account(orders=broker_orders), + "2099-01-01", + ) + == 220 + ) + losing_and_winning_positions = ( + { + "symbol": "LOSS", + "quantity": 1.0, + "average_cost": 10.0, + "market_price": 5.0, + "market_value": 5.0, + "unrealized_pnl": -5.0, + }, + { + "symbol": "GAIN", + "quantity": 1.0, + "average_cost": 10.0, + "market_price": 20.0, + "market_value": 20.0, + "unrealized_pnl": 10.0, + }, + ) + assert ( + _witnessed_daily_loss_cents( + tmp_path, + _account(orders=broker_orders, positions=losing_and_winning_positions), + "2099-01-01", + ) + == 720 + ) + missing_unrealized = ( + { + "symbol": "LOSS", + "quantity": 1.0, + "average_cost": 10.0, + "market_price": 5.0, + "market_value": 5.0, + }, + ) + with pytest.raises(DumbMoneyAuthorityError, match="unrealized PnL"): + _witnessed_daily_loss_cents( + tmp_path, + _account(orders=broker_orders, positions=missing_unrealized), + "2099-01-01", + ) + + missing_fee = tuple( + {key: value for key, value in order.items() if key != "fees_usd"} + for order in broker_orders + ) + with pytest.raises(DumbMoneyAuthorityError, match="fee observation"): + _witnessed_daily_loss_cents( + tmp_path, + _account(orders=missing_fee), + "2099-01-01", + ) + + +def test_raw_broker_position_cannot_fabricate_missing_unrealized_pnl() -> None: + canonical = _canonical_raw_broker_item( + { + "symbol": "ACME", + "quantity": 1.0, + "average_cost": 100.0, + "market_price": 99.0, + "market_value": 99.0, + }, + "equity_position", + ) + + assert "unrealized_pnl" not in canonical + with pytest.raises(RobinhoodGatewayError, match="unrealized_pnl"): + _validated_equity_position(canonical, 0) + + +def test_gateway_final_guard_denies_before_external_write( + monkeypatch: pytest.MonkeyPatch, +) -> None: + gateway = object.__new__(RobinhoodAgenticGateway) + gateway._write_timeout_seconds = 30 + run_calls: list[str] = [] + + def unexpected_run(*_args: Any, **_kwargs: Any) -> None: + run_calls.append("called") + raise AssertionError("external process must not start") + + monkeypatch.setattr(gateway, "_run", unexpected_run) + exit_intent = replace( + _equity_intent(quantity=1.0), + side="sell", + notional_usd=0.0, + ) + + with pytest.raises(RobinhoodGatewayError, match="kill became active"): + gateway.place_order( + exit_intent, + "a" * 64, + pre_submit_guard=lambda: (_ for _ in ()).throw( + RobinhoodGatewayError("kill became active") + ), + ) + with pytest.raises( + RobinhoodGatewayError, + match="insufficient remaining validity", + ): + gateway.place_order( + exit_intent, + "a" * 64, + pre_submit_guard=lambda: datetime.now(timezone.utc) + timedelta(seconds=35), + ) + + assert run_calls == [] + + +def test_strict_operator_rejects_execute_without_full_authority( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr( + sys, + "argv", + ["operator_live_branch", "--dumbmoney-strict", "--execute"], + ) + + with pytest.raises(SystemExit) as error: + operator_live_branch.main() + + assert error.value.code == 2 + + +def test_operator_rejects_plain_execute_before_gateway_or_model_creation( + monkeypatch: pytest.MonkeyPatch, +) -> None: + gateway_calls: list[bool] = [] + monkeypatch.setattr( + sys, + "argv", + ["operator_live_branch", "--execute"], + ) + monkeypatch.setattr( + operator_live_branch, + "default_gateway", + lambda: gateway_calls.append(True), + ) + + with pytest.raises(SystemExit) as error: + operator_live_branch.main() + + assert error.value.code == 2 + assert gateway_calls == [] + + +def test_programmatic_legacy_execute_rejected_before_gateway_creation( + monkeypatch: pytest.MonkeyPatch, +) -> None: + gateway_calls: list[bool] = [] + monkeypatch.setattr( + operator_live_branch, + "default_gateway", + lambda: gateway_calls.append(True), + ) + + with pytest.raises(RuntimeError, match="Legacy operator execution is disabled"): + operator_live_branch._run_with_crash_tombstone(True) + + assert gateway_calls == [] + + +def test_cancel_result_is_bound_to_exact_account_and_order() -> None: + call = McpToolCallEvidence( + call_id="cancel-1", + tool="cancel_equity_order", + arguments={"account_id": "agentic-account", "order_id": "order-1"}, + result={ + "structured_content": { + "order_id": "order-1", + "status": "cancel_requested", + } + }, + ) + result = _gateway_cancel_result( + { + "cancel_requested": True, + "order_id": "order-1", + "status": "cancel_requested", + "warnings": [], + "error": "", + }, + "cancel_equity_order", + "order-1", + (call,), + "agentic-account", + ) + assert result.cancel_requested is True + + with pytest.raises( + RobinhoodCancellationAmbiguousError, + match="exact Agentic order", + ): + _gateway_cancel_result( + { + "cancel_requested": True, + "order_id": "order-1", + "status": "cancel_requested", + "warnings": [], + "error": "", + }, + "cancel_equity_order", + "order-1", + ( + McpToolCallEvidence( + call_id="cancel-2", + tool="cancel_equity_order", + arguments={ + "account_id": "other-account", + "order_id": "order-1", + }, + result=call.result, + ), + ), + "agentic-account", + ) + + +class CancelGateway: + def __init__(self) -> None: + self.cancel_calls: list[tuple[str, str]] = [] + self.polls = 0 + + def cancel_order(self, instrument: str, order_id: str) -> GatewayCancelResult: + self.cancel_calls.append((instrument, order_id)) + return GatewayCancelResult( + cancel_requested=True, + order_id=order_id, + status="cancel_requested", + warnings=(), + error="", + observed_tools=("cancel_equity_order",), + ) + + def get_account_snapshot(self) -> AccountSnapshot: + self.polls += 1 + return _account( + orders=( + { + "order_id": "order-1", + "symbol": "ACME", + "side": "buy", + "status": "canceled", + "market_date": "2099-01-01", + "submitted_at": "2099-01-01T00:00:30+00:00", + "filled_at": "", + "filled_quantity": 0.0, + "average_fill_price": 0.0, + }, + ) + ) + + +def test_kill_cancel_workflow_is_bounded_and_reconciles_history(tmp_path: Path) -> None: + intent = _equity_intent() + append_record( + ledger_path(tmp_path), + "live_order_result", + { + "intent_id": intent.intent_id, + "instrument": "equity", + "symbol": "ACME", + "side": "buy", + "market_date": "2099-01-01", + "intent": intent.to_dict(), + "placed": True, + "order_id": "order-1", + "status": "open", + "filled_quantity": 0.0, + "average_fill_price": 0.0, + }, + ) + open_account = _account( + orders=( + { + "order_id": "order-1", + "symbol": "ACME", + "side": "buy", + "status": "open", + "market_date": "2099-01-01", + "submitted_at": "2099-01-01T00:00:30+00:00", + "filled_at": "", + "filled_quantity": 0.0, + "average_fill_price": 0.0, + }, + ) + ) + gateway = CancelGateway() + + result = _run_cancel_only_degraded( + tmp_path, + gateway, # type: ignore[arg-type] + open_account, + NOW, + "dumbmoney_kill_active", + True, + ) + + assert result["cycle_status"] == "cancel_only_degraded" + assert gateway.cancel_calls == [("equity", "order-1")] + assert gateway.polls == 1 + assert result["unresolved_attributed_orders"] == [] + + +class HistoryOnlyGateway: + def __init__(self, account: AccountSnapshot) -> None: + self.account = account + self.poll_count = 0 + + def get_account_snapshot(self) -> AccountSnapshot: + self.poll_count += 1 + return self.account + + +def test_ambiguous_placement_polls_history_without_retrying_placement( + tmp_path: Path, +) -> None: + intent = _equity_intent() + broker_order = { + "order_id": "broker-1", + "symbol": "ACME", + "side": "buy", + "status": "open", + "market_date": "2099-01-01", + "submitted_at": "2099-01-01T00:00:30+00:00", + "filled_at": "", + "filled_quantity": 0.0, + "average_fill_price": 0.0, + "order_type": "limit", + "time_in_force": "day", + "limit_price": 100.0, + "quantity": 0.01, + } + gateway = HistoryOnlyGateway(_account(orders=(broker_order,))) + + reconciled, _, poll_count = _poll_ambiguous_placement_history( + tmp_path, + gateway, # type: ignore[arg-type] + intent, + "2099-01-01", + NOW, + "paper-hash", + ) + + assert poll_count == 3 + assert gateway.poll_count == 3 + assert reconciled is not None + assert reconciled["order_id"] == "broker-1" + assert reconciled["placement_retried"] is False + assert any( + record["event_type"] == "live_order_result" + for record in read_ledger(ledger_path(tmp_path)) + ) + + +def test_option_entry_requires_fresh_quote_multiplier_expiry_and_exact_debit() -> None: + intent = LiveExecutionIntent( + **{ + **_equity_intent(notional_usd=125.0, quantity=1.0).to_dict(), + "instrument": "option", + "limit_price": 1.25, + "option_contract": { + "contract_id": "option-1", + "expiry": "2099-01-15", + "multiplier": 100, + "maximum_loss_usd": 125.0, + "quote_observed_at": "2099-01-01T00:00:00Z", + "defined_risk": True, + "order_action": "buy_to_open", + }, + } + ) + validate_option_entry_for_execution(intent, NOW) + + with pytest.raises(LiveSignalError, match="multiplier"): + validate_option_entry_for_execution( + LiveExecutionIntent( + **{ + **intent.to_dict(), + "option_contract": {**intent.option_contract, "multiplier": 10}, + } + ), + NOW, + ) + with pytest.raises(LiveSignalError, match="freshness"): + validate_option_entry_for_execution( + intent, + datetime(2099, 1, 1, 0, 2, tzinfo=timezone.utc), + ) diff --git a/tests/test_dumbmoney_windows_service.py b/tests/test_dumbmoney_windows_service.py new file mode 100644 index 0000000..8f1c61d --- /dev/null +++ b/tests/test_dumbmoney_windows_service.py @@ -0,0 +1,770 @@ +from __future__ import annotations + +import base64 +import hashlib +import json +import multiprocessing +import os +import socket +import sys +from datetime import datetime, timedelta, timezone +from pathlib import Path +from typing import Any + +import pytest +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey + +import dopey_live.dumbmoney_windows_service as windows_service +from dopey_live.dumbmoney_authority import ( + _verify_signed_wrapper, + canonical_json_bytes, +) +from dopey_live.dumbmoney_command_feed import ( + CommandFeedHttpResponse, + DumbMoneyCommandDispatch, +) +from dopey_live.dumbmoney_journal_anchor import JournalAnchorError +from dopey_live.dumbmoney_windows_service import ( + CELL_TOKEN_TARGET_REF, + CONFIG_RELATIVE_PATH, + CORE_ENDPOINT_REF, + CORE_READINESS_SCHEMA, + READINESS_REF, + ROBINHOOD_PROFILE_TARGET_REF, + START_MODE, + CoreEndpoint, + DopeyRunnerConfig, + DumbMoneyDopeyWindowsService, + DumbMoneyWindowsServiceError, + PinnedOllamaModelVerifier, + _core_endpoint_from_readiness, + _DataRootLease, + _readiness_signer, + _windows_loopback_listener_owner, + load_runner_config, + load_secrets, +) +from dopey_live.robinhood_gateway import ( + RobinhoodAgenticGateway, + RobinhoodGatewayError, + _codex_exec_command, +) + +NOW = datetime(2099, 1, 1, 0, 0, 30, tzinfo=timezone.utc) +CELL_TOKEN = "cell-token-from-windows-credential-manager" +PROFILE = "robinhood-profile-from-windows-credential-manager" +OLLAMA_SID = "S-1-5-21-1-2-3-1001" + + +def _probe_data_root_lease(path: str, queue: Any) -> None: + try: + with _DataRootLease(Path(path)): + queue.put("acquired") + except DumbMoneyWindowsServiceError: + queue.put("blocked") + + +def _format_utc(value: datetime) -> str: + return ( + value.astimezone(timezone.utc) + .isoformat(timespec="seconds") + .replace("+00:00", "Z") + ) + + +def _key_entry( + private_key: Ed25519PrivateKey, +) -> tuple[str, str, bytes]: + public_key = private_key.public_key().public_bytes_raw() + key_id = hashlib.sha256(public_key).hexdigest() + encoded = base64.urlsafe_b64encode(public_key).decode().rstrip("=") + return key_id, encoded, public_key + + +def _config_payload( + program_data: Path, + core_key: Ed25519PrivateKey, +) -> dict[str, Any]: + core_id, core_encoded, _ = _key_entry(core_key) + del core_id + operator_id, operator_encoded, _ = _key_entry(Ed25519PrivateKey.generate()) + promoter_id, promoter_encoded, _ = _key_entry(Ed25519PrivateKey.generate()) + research_id, research_encoded, _ = _key_entry(Ed25519PrivateKey.generate()) + evaluator_id, evaluator_encoded, _ = _key_entry(Ed25519PrivateKey.generate()) + readiness_public = _readiness_signer(CELL_TOKEN).public_key().public_bytes_raw() + executable_path = Path(sys.executable).resolve() + return { + "schema": "dopey.dumbmoney-robinhood-runner-config.v1", + "service_name": "DumbMoneyDopeyRobinhood", + "release_id": "dumbmoney-test-release", + "core_endpoint_ref": CORE_ENDPOINT_REF, + "core_readiness_path": str( + program_data / "DumbMoney" / "readiness" / "DumbMoneyCore.json" + ), + "core_cell_token_target": CELL_TOKEN_TARGET_REF, + "robinhood_profile_target": ROBINHOOD_PROFILE_TARGET_REF, + "start_mode": START_MODE, + "data_root": str(program_data / "DumbMoney" / "dopey-robinhood"), + "readiness_ref": READINESS_REF, + "readiness_path": str( + program_data / "DumbMoney" / "readiness" / "DumbMoneyDopeyRobinhood.json" + ), + "core_public_key_base64url": core_encoded, + "operator_public_keys_base64url": { + operator_id: operator_encoded, + }, + "promoter_public_keys_base64url": { + promoter_id: promoter_encoded, + }, + "research_public_keys_base64url": { + research_id: research_encoded, + }, + "evaluator_public_keys_base64url": { + evaluator_id: evaluator_encoded, + }, + "expected_account_hash": "c" * 64, + "fund_lock_sha256": "d" * 64, + "service_manifest_sha256": "e" * 64, + "role_public_keys_sha256": "f" * 64, + "core_runner_config_sha256": "1" * 64, + "risk_policy_sha256": "2" * 64, + "readiness_signer_public_key_sha256": hashlib.sha256( + readiness_public + ).hexdigest(), + "codex_executable_path": str(executable_path), + "codex_executable_sha256": hashlib.sha256( + executable_path.read_bytes() + ).hexdigest(), + "codex_local_provider": "ollama", + "codex_local_model": "gpt-oss:20b", + "codex_local_model_digest": "3" * 64, + "ollama_executable_path": str(executable_path), + "ollama_executable_sha256": hashlib.sha256( + executable_path.read_bytes() + ).hexdigest(), + "ollama_process_identity_sid": OLLAMA_SID, + "ollama_runtime_evidence_sha256": "4" * 64, + "poll_interval_seconds": 15, + "readiness_ttl_seconds": 90, + } + + +def _write_config( + program_data: Path, + core_key: Ed25519PrivateKey, +) -> tuple[DopeyRunnerConfig, dict[str, Any]]: + payload = _config_payload(program_data, core_key) + path = program_data / CONFIG_RELATIVE_PATH + path.parent.mkdir(parents=True) + raw = canonical_json_bytes(payload) + b"\n" + path.write_bytes(raw) + digest = hashlib.sha256(raw).hexdigest() + return load_runner_config(path, digest, program_data), payload + + +def _signed_core_readiness( + config: DopeyRunnerConfig, + core_key: Ed25519PrivateKey, +) -> dict[str, Any]: + body = { + "schema": CORE_READINESS_SCHEMA, + "service_name": "DumbMoneyCore", + "release_id": config.release_id, + "instance_id": "8c2df19e-a2dc-4ad4-9b37-c3f03b6c02c6", + "process_id": 42, + "generation": 0, + "observed_at": _format_utc(NOW - timedelta(seconds=5)), + "valid_until": _format_utc(NOW + timedelta(seconds=85)), + "endpoint": { + "transport": "http", + "host": "127.0.0.1", + "port": 49152, + "base_path": "/", + }, + "fund_lock_sha256": config.fund_lock_sha256, + "service_manifest_sha256": config.service_manifest_sha256, + "authority": { + "broker": "NONE", + "mode": "OFFLINE", + "execution_enabled": False, + }, + "health": { + "status": "READY", + "core_public_key_sha256": hashlib.sha256( + config.core_public_key + ).hexdigest(), + "role_public_keys_sha256": config.role_public_keys_sha256, + "runner_config_sha256": config.core_runner_config_sha256, + "risk_policy_sha256": config.risk_policy_sha256, + }, + "capabilities": ["cell-command-checkpoints", "event-ledger"], + } + unsigned: dict[str, Any] = { + "schema": "dumbmoney.signed-envelope.v1", + "source_id": "DumbMoneyCore", + "source_sequence": 1, + "correlation_id": "core-readiness-test", + "causation_id": None, + "nonce": "core-readiness-nonce-1", + "not_before": body["observed_at"], + "expires_at": body["valid_until"], + "body_schema": CORE_READINESS_SCHEMA, + "body_digest": hashlib.sha256(canonical_json_bytes(body)).hexdigest(), + "body": body, + "signature_algorithm": "Ed25519", + "signer_key_id": hashlib.sha256(config.core_public_key).hexdigest(), + } + unsigned["event_id"] = hashlib.sha256(canonical_json_bytes(unsigned)).hexdigest() + signature = core_key.sign(canonical_json_bytes(unsigned)) + return { + **unsigned, + "signature": base64.urlsafe_b64encode(signature).decode().rstrip("="), + } + + +def test_public_config_is_exact_path_and_exact_raw_sha( + tmp_path: Path, +) -> None: + core_key = Ed25519PrivateKey.generate() + config, payload = _write_config(tmp_path, core_key) + + assert config.expected_account_hash == "c" * 64 + assert config.data_root == (tmp_path / "DumbMoney" / "dopey-robinhood").resolve() + + path = tmp_path / CONFIG_RELATIVE_PATH + path.write_bytes(canonical_json_bytes({**payload, "release_id": "tampered"})) + with pytest.raises( + DumbMoneyWindowsServiceError, + match="does not match --config-sha256", + ): + load_runner_config(path, config.config_sha256, tmp_path) + + +class FakeCredentials: + def __init__(self) -> None: + self.targets: list[str] = [] + + def read_bytes(self, target: str) -> bytes: + self.targets.append(target) + if target == "DumbMoney/DopeyCellToken": + return CELL_TOKEN.encode() + if target == "DumbMoney/RobinhoodProfile": + return PROFILE.encode() + raise AssertionError("unexpected credential target") + + +def test_secrets_load_only_from_two_fixed_credential_targets( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv("DUMBMONEY_DOPEY_CELL_TOKEN", "forbidden-env-token") + monkeypatch.setenv("ROBINHOOD_PROFILE", "forbidden-env-profile") + provider = FakeCredentials() + + assert load_secrets(provider) == (CELL_TOKEN, PROFILE) + assert provider.targets == [ + "DumbMoney/DopeyCellToken", + "DumbMoney/RobinhoodProfile", + ] + + +def test_core_endpoint_requires_current_signed_pinned_readiness( + tmp_path: Path, +) -> None: + core_key = Ed25519PrivateKey.generate() + config, _ = _write_config(tmp_path, core_key) + readiness = _signed_core_readiness(config, core_key) + config.core_readiness_path.parent.mkdir(parents=True, exist_ok=True) + config.core_readiness_path.write_bytes(canonical_json_bytes(readiness)) + + endpoint = _core_endpoint_from_readiness(config, NOW) + + assert endpoint.base_url == "http://127.0.0.1:49152" + tampered = json.loads(json.dumps(readiness)) + tampered["body"]["endpoint"]["host"] = "example.com" + config.core_readiness_path.write_bytes(canonical_json_bytes(tampered)) + with pytest.raises( + DumbMoneyWindowsServiceError, + match="body digest mismatch", + ): + _core_endpoint_from_readiness(config, NOW) + config.core_readiness_path.write_bytes(canonical_json_bytes(readiness)) + with pytest.raises( + DumbMoneyWindowsServiceError, + match="not currently valid", + ): + _core_endpoint_from_readiness( + config, + NOW + timedelta(minutes=2), + ) + + +class FakeCommandClient: + dispatch: DumbMoneyCommandDispatch + + def __init__(self, **_kwargs: Any) -> None: + pass + + def poll_once(self, _now: datetime) -> DumbMoneyCommandDispatch: + return self.dispatch + + +class FakeResolver: + def __init__(self, **_kwargs: Any) -> None: + pass + + +class FakeJournalAnchor: + def __init__(self, *, reject: bool = False) -> None: + self.reject = reject + self.calls: list[dict[str, Any]] = [] + + def anchor(self, **kwargs: Any) -> None: + self.calls.append(dict(kwargs)) + if self.reject: + raise JournalAnchorError("Core rejected rolled-back journal") + + +def test_service_starts_reconciliation_only_then_allows_next_cycle( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + core_key = Ed25519PrivateKey.generate() + config, _ = _write_config(tmp_path, core_key) + FakeCommandClient.dispatch = DumbMoneyCommandDispatch( + desired_mode="LIVE", + kill_active=False, + allow_new_risk=True, + capital_authority=None, + fail_closed_reason="", + required_action="APPLY_SIGNED_CONTROLS", + caught_up=True, + next_sequence=7, + next_digest="7" * 64, + applied_event_ids=(), + ) + monkeypatch.setattr( + windows_service, + "_core_endpoint_from_readiness", + lambda *_args: CoreEndpoint( + "http://127.0.0.1:49152", + "8c2df19e-a2dc-4ad4-9b37-c3f03b6c02c6", + _format_utc(NOW), + _format_utc(NOW + timedelta(seconds=90)), + ), + ) + monkeypatch.setattr( + windows_service, + "_core_liveness", + lambda *_args: None, + ) + monkeypatch.setattr( + windows_service, + "DumbMoneyCommandFeedClient", + FakeCommandClient, + ) + monkeypatch.setattr( + windows_service, + "DumbMoneyContractResolverClient", + FakeResolver, + ) + execute_values: list[bool] = [] + anchor_client = FakeJournalAnchor() + + def cycle_runner( + _root: Path, + _gateway: object, + _now: datetime, + execute_orders: bool, + **_kwargs: Any, + ) -> dict[str, Any]: + execute_values.append(execute_orders) + return { + "cycle_status": "complete" if execute_orders else "preflight_complete", + "order_history_blocker": "", + "account": { + "authenticated": True, + "account_hash": config.expected_account_hash, + }, + "orders": [], + } + + service = DumbMoneyDopeyWindowsService( + config, + CELL_TOKEN, + PROFILE, + transport=lambda _request: (_ for _ in ()).throw( + AssertionError("transport was monkeypatched away") + ), + gateway=object(), # type: ignore[arg-type] + cycle_runner=cycle_runner, + clock=lambda: NOW, + journal_anchor_factory=lambda _base_url: anchor_client, # type: ignore[arg-type] + ) + try: + first = service.run_once() + second = service.run_once() + readiness = json.loads(config.readiness_path.read_text()) + verified = _verify_signed_wrapper( + readiness, + _readiness_signer(CELL_TOKEN).public_key().public_bytes_raw(), + NOW, + enforce_current_window=True, + ) + finally: + service._health_server.server.server_close() + + assert first["cycle_status"] == "preflight_complete" + assert second["cycle_status"] == "complete" + assert execute_values == [False, True] + assert len(anchor_client.calls) == 18 + assert service.state().mode == "AGGRESSIVE_BOUNDED" + assert service.state().rollback_anchor_current is True + assert verified.body["authority"]["execution_enabled"] is True + assert verified.body["health"]["command_sequence"] == 7 + assert verified.body["health"]["rollback_anchor_current"] is True + + +def test_rollback_anchor_rejection_blocks_before_broker_contact( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + core_key = Ed25519PrivateKey.generate() + config, _ = _write_config(tmp_path, core_key) + monkeypatch.setattr( + windows_service, + "_core_endpoint_from_readiness", + lambda *_args: CoreEndpoint( + "http://127.0.0.1:49152", + "8c2df19e-a2dc-4ad4-9b37-c3f03b6c02c6", + _format_utc(NOW), + _format_utc(NOW + timedelta(seconds=90)), + ), + ) + monkeypatch.setattr( + windows_service, + "_core_liveness", + lambda *_args: None, + ) + cycle_calls: list[bool] = [] + anchor_client = FakeJournalAnchor(reject=True) + service = DumbMoneyDopeyWindowsService( + config, + CELL_TOKEN, + PROFILE, + transport=lambda _request: (_ for _ in ()).throw( + AssertionError("command transport must not run") + ), + gateway=object(), # type: ignore[arg-type] + cycle_runner=lambda *_args, **_kwargs: cycle_calls.append(True), # type: ignore[arg-type] + clock=lambda: NOW, + journal_anchor_factory=lambda _base_url: anchor_client, # type: ignore[arg-type] + ) + try: + result = service.run_once() + readiness = json.loads(config.readiness_path.read_text()) + finally: + service._health_server.server.server_close() + + assert result == { + "cycle_status": "blocked_rollback_anchor", + "orders": [], + "broker_contacted": False, + } + assert cycle_calls == [] + assert service.state().health_status == "BLOCKED" + assert service.state().rollback_anchor_current is False + assert readiness["body"]["authority"]["execution_enabled"] is False + assert readiness["body"]["health"]["rollback_anchor_current"] is False + + +def test_corrupt_local_journal_blocks_before_broker_contact( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + core_key = Ed25519PrivateKey.generate() + config, _ = _write_config(tmp_path, core_key) + command_state = config.data_root / "control" / "command-feed-state.v1.json" + command_state.parent.mkdir(parents=True) + command_state.write_text("{not-json", encoding="utf-8") + monkeypatch.setattr( + windows_service, + "_core_endpoint_from_readiness", + lambda *_args: CoreEndpoint( + "http://127.0.0.1:49152", + "8c2df19e-a2dc-4ad4-9b37-c3f03b6c02c6", + _format_utc(NOW), + _format_utc(NOW + timedelta(seconds=90)), + ), + ) + monkeypatch.setattr( + windows_service, + "_core_liveness", + lambda *_args: None, + ) + cycle_calls: list[bool] = [] + anchor_client = FakeJournalAnchor() + service = DumbMoneyDopeyWindowsService( + config, + CELL_TOKEN, + PROFILE, + transport=lambda _request: (_ for _ in ()).throw( + AssertionError("command transport must not run") + ), + gateway=object(), # type: ignore[arg-type] + cycle_runner=lambda *_args, **_kwargs: cycle_calls.append(True), # type: ignore[arg-type] + clock=lambda: NOW, + journal_anchor_factory=lambda _base_url: anchor_client, # type: ignore[arg-type] + ) + try: + result = service.run_once() + finally: + service._health_server.server.server_close() + + assert result["cycle_status"] == "blocked_rollback_anchor" + assert result["broker_contacted"] is False + assert cycle_calls == [] + assert anchor_client.calls == [] + + +def test_service_startup_writes_readiness_without_broker_call( + tmp_path: Path, +) -> None: + core_key = Ed25519PrivateKey.generate() + config, _ = _write_config(tmp_path, core_key) + cycle_calls: list[bool] = [] + service = DumbMoneyDopeyWindowsService( + config, + CELL_TOKEN, + PROFILE, + transport=lambda _request: (_ for _ in ()).throw( + AssertionError("startup must not call Core") + ), + gateway=object(), # type: ignore[arg-type] + cycle_runner=lambda *_args, **_kwargs: cycle_calls.append(True), # type: ignore[arg-type] + clock=lambda: NOW, + ) + + service.start() + service.close() + + assert cycle_calls == [] + readiness = json.loads(config.readiness_path.read_text()) + assert readiness["body"]["authority"] == { + "broker": "ROBINHOOD", + "mode": "RECONCILIATION_ONLY", + "execution_enabled": False, + } + + +def test_profile_is_in_memory_only_and_child_executable_is_sha_pinned( + tmp_path: Path, +) -> None: + with pytest.raises(ValueError, match="prohibited"): + RobinhoodAgenticGateway( + lambda *_args, **_kwargs: (_ for _ in ()).throw( + AssertionError("test must not spawn a process") + ), + 60, + mcp_bearer_token=PROFILE, + ) + + executable = tmp_path / "codex.exe" + executable.write_bytes(b"sealed-codex-fixture") + executable_sha256 = hashlib.sha256(executable.read_bytes()).hexdigest() + with pytest.raises(ValueError, match="prohibited"): + RobinhoodAgenticGateway( + lambda *_args, **_kwargs: (_ for _ in ()).throw( + AssertionError("test must not spawn a process") + ), + 60, + mcp_bearer_token=PROFILE, + codex_executable_path=executable, + codex_executable_sha256=executable_sha256, + ) + with pytest.raises(ValueError, match="prohibited"): + _codex_exec_command( + str(executable), + str(tmp_path), + tmp_path / "result.json", + ("get_accounts",), + "DUMBMONEY_ROBINHOOD_MCP_BEARER", + ) + gateway = RobinhoodAgenticGateway( + lambda *_args, **_kwargs: (_ for _ in ()).throw( + AssertionError("test must not spawn a process") + ), + 60, + temporary_root=tmp_path / "temp", + codex_executable_path=executable, + codex_executable_sha256=executable_sha256, + local_model="gpt-oss:20b", + local_model_verifier=lambda: None, + expected_account_hash="a" * 64, + deterministic_mcp_call=( + lambda _tool, _arguments, _timeout, _pre_call_guard: {} + ), + ) + command = _codex_exec_command( + str(executable), + str(tmp_path), + tmp_path / "result.json", + ("get_accounts",), + None, + "gpt-oss:20b", + ) + + joined_command = " ".join(command) + assert PROFILE not in joined_command + assert command[command.index("--local-provider") + 1] == "ollama" + assert command[command.index("--model") + 1] == "gpt-oss:20b" + assert "--oss" in command + assert not any("openai" in value.lower() for value in command) + assert "mcp_servers." not in joined_command + assert "agent.robinhood.com" not in joined_command + assert "DUMBMONEY_ROBINHOOD_MCP_BEARER" not in joined_command + assert gateway._subprocess_env is None + assert gateway._bearer_token_env_var is None + + executable.write_bytes(b"path-hijack") + with pytest.raises(RobinhoodGatewayError, match="SHA-256 mismatch"): + gateway._verify_codex_executable() + + +def test_pinned_ollama_model_is_verified_by_exact_local_inventory_digest( + tmp_path: Path, +) -> None: + calls: list[Any] = [] + executable_path = Path(sys.executable).resolve() + executable_sha256 = hashlib.sha256(executable_path.read_bytes()).hexdigest() + evidence_path = tmp_path / "ollama-runtime-evidence.v1.json" + evidence_path.write_bytes(b'{"evidence_kind":"STATIC_INSTALLATION_ATTESTATION"}\n') + evidence_sha256 = hashlib.sha256(evidence_path.read_bytes()).hexdigest() + owner_calls: list[tuple[str, int]] = [] + + def transport(request: Any) -> CommandFeedHttpResponse: + calls.append(request) + return CommandFeedHttpResponse( + status=200, + headers={"Content-Type": "application/json"}, + body=canonical_json_bytes( + { + "models": [ + { + "name": "gpt-oss:20b", + "model": "gpt-oss:20b", + "digest": "3" * 64, + } + ] + } + ), + ) + + def listener_owner(host: str, port: int) -> tuple[int, Path, str]: + owner_calls.append((host, port)) + return 42, executable_path, OLLAMA_SID + + verifier = PinnedOllamaModelVerifier( + "gpt-oss:20b", + "3" * 64, + executable_path, + executable_sha256, + OLLAMA_SID, + evidence_path, + evidence_sha256, + transport, + listener_owner, + ) + verifier() + + assert len(calls) == 1 + assert owner_calls == [ + ("127.0.0.1", 11_434), + ("127.0.0.1", 11_434), + ] + assert calls[0].url == "http://127.0.0.1:11434/api/tags" + assert calls[0].method == "GET" + + wrong = PinnedOllamaModelVerifier( + "gpt-oss:20b", + "4" * 64, + executable_path, + executable_sha256, + OLLAMA_SID, + evidence_path, + evidence_sha256, + transport, + listener_owner, + ) + with pytest.raises( + DumbMoneyWindowsServiceError, + match="model digest mismatch", + ): + wrong() + + wrong_owner = PinnedOllamaModelVerifier( + "gpt-oss:20b", + "3" * 64, + executable_path, + executable_sha256, + OLLAMA_SID, + evidence_path, + evidence_sha256, + transport, + lambda _host, _port: ( + 42, + executable_path.with_name("not-ollama.exe"), + OLLAMA_SID, + ), + ) + with pytest.raises( + DumbMoneyWindowsServiceError, + match="not owned by the pinned identity and executable", + ): + wrong_owner() + + +@pytest.mark.skipif(os.name != "nt", reason="Windows TCP owner API") +def test_windows_tcp_owner_resolves_exact_loopback_listener_process() -> None: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as listener: + listener.bind(("127.0.0.1", 0)) + listener.listen() + port = int(listener.getsockname()[1]) + + process_id, image_path, process_sid = _windows_loopback_listener_owner( + "127.0.0.1", + port, + ) + + assert process_id == os.getpid() + assert image_path.is_file() + assert image_path.name.casefold() == "python.exe" + assert process_sid.startswith("S-1-5-") + + +@pytest.mark.skipif(os.name != "nt", reason="Windows byte-range process lock") +def test_data_root_lease_blocks_concurrent_writer_and_releases_after_exit( + tmp_path: Path, +) -> None: + lock_path = tmp_path / "service.lock" + context = multiprocessing.get_context("spawn") + + with _DataRootLease(lock_path): + blocked_queue = context.Queue() + blocked = context.Process( + target=_probe_data_root_lease, + args=(str(lock_path), blocked_queue), + ) + blocked.start() + blocked.join(10) + assert blocked.exitcode == 0 + assert blocked_queue.get(timeout=2) == "blocked" + + acquired_queue = context.Queue() + acquired = context.Process( + target=_probe_data_root_lease, + args=(str(lock_path), acquired_queue), + ) + acquired.start() + acquired.join(10) + assert acquired.exitcode == 0 + assert acquired_queue.get(timeout=2) == "acquired" diff --git a/tests/test_no_live_artifact_bindings.py b/tests/test_no_live_artifact_bindings.py index b107fe6..3c8b3b3 100644 --- a/tests/test_no_live_artifact_bindings.py +++ b/tests/test_no_live_artifact_bindings.py @@ -18,7 +18,7 @@ import sys from pathlib import Path -import conftest +from tests import conftest # Vendored third-party trees and PowerShell-only dirs: nothing to import. _WALK_EXCLUDED_TOP_LEVEL = {"dopey_open_source_vendor"} diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..09fd8ac --- /dev/null +++ b/uv.lock @@ -0,0 +1,1309 @@ +version = 1 +revision = 3 +requires-python = ">=3.11" +resolution-markers = [ + "python_full_version >= '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.14' and sys_platform == 'emscripten'", + "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'emscripten'", + "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version < '3.12' and sys_platform == 'win32'", + "python_full_version < '3.12' and sys_platform == 'emscripten'", + "python_full_version < '3.12' and sys_platform != 'emscripten' and sys_platform != 'win32'", +] + +[[package]] +name = "beautifulsoup4" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "soupsieve" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/43/65/318323f98dbee45d42dff61d8f047181bc6f2268a9068cfad035a46be5af/beautifulsoup4-4.15.0.tar.gz", hash = "sha256:288e3ca7d54b06f2ac191970bc275c1939cb46d450b255bf6718b04aa37ab4f7", size = 632571, upload-time = "2026-06-07T16:44:20.453Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/c6/92fcd42f1ba33e1184263f25bfabf3d27c383410470f169e4b8163bf9c17/beautifulsoup4-4.15.0-py3-none-any.whl", hash = "sha256:d6f88de62e1d4e38ecb1077eb9724cd0eff29d2a08ca16a401e9b9e93f117cf9", size = 109924, upload-time = "2026-06-07T16:44:21.566Z" }, +] + +[[package]] +name = "bottle" +version = "0.13.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7a/71/cca6167c06d00c81375fd668719df245864076d284f7cb46a694cbeb5454/bottle-0.13.4.tar.gz", hash = "sha256:787e78327e12b227938de02248333d788cfe45987edca735f8f88e03472c3f47", size = 98717, upload-time = "2025-06-15T10:08:59.439Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/83/f6/b55ec74cfe68c6584163faa311503c20b0da4c09883a41e8e00d6726c954/bottle-0.13.4-py2.py3-none-any.whl", hash = "sha256:045684fbd2764eac9cdeb824861d1551d113e8b683d8d26e296898d3dd99a12e", size = 103807, upload-time = "2025-06-15T10:08:57.691Z" }, +] + +[[package]] +name = "certifi" +version = "2026.7.22" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a3/c2/24167ea9858356b47a87a50d39908bfdb72ceeefe0041586e704e5376b3a/certifi-2026.7.22.tar.gz", hash = "sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55", size = 138112, upload-time = "2026-07-22T03:35:12.644Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983, upload-time = "2026-07-22T03:35:11.276Z" }, +] + +[[package]] +name = "cffi" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/57/5f/ff100cae70ebe9d8df1c01a00e510e45d9adb5c1fdda84791b199141de97/cffi-2.1.0.tar.gz", hash = "sha256:efc1cdd798b1aaf39b4610bba7aad28c9bea9b910f25c784ccf9ec1fa719d1f9", size = 531036, upload-time = "2026-07-06T21:34:30.382Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d3/67/85c89a59ba36a671e79638f44d466749f08179266a57e4f2ffdf92174072/cffi-2.1.0-cp311-cp311-macosx_10_15_x86_64.whl", hash = "sha256:02cb7ff33ded4f1532476731f89ede53e2e488a8e6205515a82144246ffa7dcc", size = 183845, upload-time = "2026-07-06T21:32:26.32Z" }, + { url = "https://files.pythonhosted.org/packages/ea/dd/e3b0baa2d3d6a857ac72b7efbf18e32e487c9cdafcc13049ad765495b15e/cffi-2.1.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f5bce581e6b8c235e566a14768a943b172ada3ed73537bb0c0be1edee312d4e7", size = 184186, upload-time = "2026-07-06T21:32:28.025Z" }, + { url = "https://files.pythonhosted.org/packages/65/68/9f3ef890cf3c6ab97bd531c5677f67613d302165d16f8142b2811782a614/cffi-2.1.0-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:30b65779d598c370374fefabf138d456fd6f3216bfa7bedfab1ba82025b0cd93", size = 211892, upload-time = "2026-07-06T21:32:29.565Z" }, + { url = "https://files.pythonhosted.org/packages/22/d7/1a74539db16d8bfd839ff1515948948efbb162e574650fd3d846896eea95/cffi-2.1.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:88023dfe18799507b73f1dbb0d14326a17465de1bc9c9c7655c22845e9ddc3a2", size = 218793, upload-time = "2026-07-06T21:32:30.951Z" }, + { url = "https://files.pythonhosted.org/packages/ec/d1/9a5b7169499e8e8d8e636de70b97ac7c9447104d2ff1a2cd94790cea5162/cffi-2.1.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:0a96b74cda968eebbad56d973efe5098974f0a9fb323865bf99ea1fd24e3e64c", size = 205737, upload-time = "2026-07-06T21:32:32.216Z" }, + { url = "https://files.pythonhosted.org/packages/ba/b0/e131a9c41f10607926278453d9596163594fe1c4ebc46efe3b5e5b34eb84/cffi-2.1.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a5781494d4d400a3f47f8f1da94b324f6e6b440a53387774002890a2a2f4b50f", size = 204909, upload-time = "2026-07-06T21:32:33.655Z" }, + { url = "https://files.pythonhosted.org/packages/fb/d2/4398416cd699b35167947c6e22aca52c47e69ad5695073c9f1f2c52e04aa/cffi-2.1.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aa7a1b53a2a4452ada2d1b5dade9960b2522f1e61293a811a077439e39029565", size = 217883, upload-time = "2026-07-06T21:32:35.173Z" }, + { url = "https://files.pythonhosted.org/packages/a2/a5/d4fe77b589e5e82d43ebc809bf2e6474afe8e48e32ea050b9357645b6471/cffi-2.1.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:9d8272c0e483b024e1b9ad029821470ed8ec65631dbd90217469da0e7cd89f1c", size = 221251, upload-time = "2026-07-06T21:32:36.527Z" }, + { url = "https://files.pythonhosted.org/packages/22/f0/a2fc43084c0433caf7f461bccc013e28f848d04ee1c5ed7fce71423cf4d9/cffi-2.1.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:7762faa47e8ff7eb80bd261d9a7d8eea2d8baa69de5e95b70c1f338bbe712f02", size = 214250, upload-time = "2026-07-06T21:32:37.852Z" }, + { url = "https://files.pythonhosted.org/packages/04/8c/b925975448cf20634a9fbd5efceb807219db452653648d2897c0989cab2d/cffi-2.1.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:89095c1968b4ba8285840e131bf2891b09ae137fe2146905acae0354fbce1b5e", size = 219441, upload-time = "2026-07-06T21:32:39.146Z" }, + { url = "https://files.pythonhosted.org/packages/eb/da/5c4918a2d61d86fa927d716cb3d8e4626ef8dc8f605a599d32f33897f59a/cffi-2.1.0-cp311-cp311-win32.whl", hash = "sha256:64c753a0f87a256020004f37a1c8c02c480e725f910f0b2a0f3f07debd1b2479", size = 174496, upload-time = "2026-07-06T21:32:40.467Z" }, + { url = "https://files.pythonhosted.org/packages/f9/c8/6c2de1d55cf35ef8b92885d5ef280790f0fb9634d87ea1cc315176aecd61/cffi-2.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:4f26194e3d95e06501b942642855aed4f953d55e95d7d01b7c4483db3ecff458", size = 185113, upload-time = "2026-07-06T21:32:41.761Z" }, + { url = "https://files.pythonhosted.org/packages/9e/4e/e8d7cb5783f1841a3c8fb3a7735838d7484d08ec08c9f984b14cac1ac0e9/cffi-2.1.0-cp311-cp311-win_arm64.whl", hash = "sha256:35aaea0c7ee0e58a5cd8c2fd1a48fdf7ece0d2699b7ecdda08194e9ce5dd9b3d", size = 179927, upload-time = "2026-07-06T21:32:42.961Z" }, + { url = "https://files.pythonhosted.org/packages/1e/85/990925db5df586ec90beb97529c853497e7f85ba0234830447faf41c3057/cffi-2.1.0-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:df2b82571a1b30f58a87bf4e5a9e78d2b1eff6c6ce8fd3aa3757221f93f0863f", size = 184829, upload-time = "2026-07-06T21:32:44.324Z" }, + { url = "https://files.pythonhosted.org/packages/4b/92/e7bb136ad6b5352603732cf907ef862ca103f20f2031c1735a46300c20c9/cffi-2.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:78474632761faa0fb96f30b1c928c84ebcf68713cbb80d15bab09dfe61640fde", size = 184728, upload-time = "2026-07-06T21:32:45.683Z" }, + { url = "https://files.pythonhosted.org/packages/c3/c0/d1ec30ffb370f748f2fb54425972bfef9871e0132e82fb589c46b6676049/cffi-2.1.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:5972433ad71a9e46516584ef60a0fda12d9dc459938d1539c3ddecf9bdc1368d", size = 214815, upload-time = "2026-07-06T21:32:48.557Z" }, + { url = "https://files.pythonhosted.org/packages/1b/dc/5620cf930688be01f2d673804291de757a934c90b946dbdc3d84130c2ea4/cffi-2.1.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b6422532152adf4e59b110cb2808cee7a033800952f5c036b4af047ee43199e7", size = 222429, upload-time = "2026-07-06T21:32:49.848Z" }, + { url = "https://files.pythonhosted.org/packages/4b/a4/77b53abbf7a1e0beb9637edbef2a94d15f9c822f591e85d439ffd91519a6/cffi-2.1.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:46b1c8db8f6122420f32d02fffb924c2fe9bc772d228c7c711748fff56aabb2b", size = 210315, upload-time = "2026-07-06T21:32:51.221Z" }, + { url = "https://files.pythonhosted.org/packages/58/0c/f528df19cc94b675087324d4760d9e6d5bfae97d6217aa4fac43de4f5fcc/cffi-2.1.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9fafc5aa2e2a39aaf7f8cc0c1f044a9b07fca12e558dca53a3cc5c654ad67a7", size = 208859, upload-time = "2026-07-06T21:32:52.512Z" }, + { url = "https://files.pythonhosted.org/packages/62/f2/c9522a81c32132799a1972c39f5c5f8b4c8b9f00488a23feaa6c06f07741/cffi-2.1.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1e9f50d192a3e525b15a75ab5114e442d83d657b7ec29182a991bc9a88fd3a66", size = 221844, upload-time = "2026-07-06T21:32:53.704Z" }, + { url = "https://files.pythonhosted.org/packages/6e/28/bd53988b9833e8f8ad539d26f4c07a6b3f6bcb1e9e02e7ca038250b3428d/cffi-2.1.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:98fff996e983a36d3aa2eca83af40c5821202e7e6f32d13ae94e3d2286f10cfe", size = 225287, upload-time = "2026-07-06T21:32:54.907Z" }, + { url = "https://files.pythonhosted.org/packages/79/99/0d0fd37f055224085f42bbb2c022d002e17dde4a97972822327b07d84101/cffi-2.1.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:379de10ce1ba048b1448599d1b37b24caee16309d1ac98d3982fc997f768700b", size = 223681, upload-time = "2026-07-06T21:32:56.329Z" }, + { url = "https://files.pythonhosted.org/packages/b0/80/c138990aa2a70b1a269f6e06348729836d733d6f970867943f61d367f8cc/cffi-2.1.0-cp312-cp312-win32.whl", hash = "sha256:9b8f0f26ca4e7513c534d351eca551947d053fac438f2a04ac96d882909b0d3a", size = 175269, upload-time = "2026-07-06T21:32:57.777Z" }, + { url = "https://files.pythonhosted.org/packages/a8/eb/f636456ff21a83fc13c032b58cc5dde061691546ac79efa284b2989b7982/cffi-2.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:c97f080ea627e2863524c5af3836e2270b5f5dfff1f104392b959f8df0c5d384", size = 185881, upload-time = "2026-07-06T21:32:59.253Z" }, + { url = "https://files.pythonhosted.org/packages/dd/2c/400ea43e721727dca8a65c4521390e9196757caba4a45643acb2b63271b8/cffi-2.1.0-cp312-cp312-win_arm64.whl", hash = "sha256:6d194185eabd279f1c05ebe3504265ddfc5ad2b58d0714f7db9f01da592e9eb6", size = 180088, upload-time = "2026-07-06T21:33:02.278Z" }, + { url = "https://files.pythonhosted.org/packages/96/88/a996879e2eeccb815f6e3a5967b12a308257412acec882039d386bd2aa7b/cffi-2.1.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:10537b1df4967ca26d21e5072d7d54188354483b91dc75058968d3f0cf13fbda", size = 194331, upload-time = "2026-07-06T21:33:03.697Z" }, + { url = "https://files.pythonhosted.org/packages/58/85/7ae00d5c8dd6266f4e944c3db630f3c5c9a98b61d469c714d848b1d8138a/cffi-2.1.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:a95b05f9baf29b91171b3a8bd2020b028835243e7b0ff6bb23e2a3c228518b1b", size = 196966, upload-time = "2026-07-06T21:33:05.353Z" }, + { url = "https://files.pythonhosted.org/packages/8c/e9/45c3a76ad8d43ad9261f4c95436da61128d3ca545d72b9612c0ab5be0b1c/cffi-2.1.0-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:15faec4adfff450819f3aee0e2e02c812de6edb88203aa58807955db2003472a", size = 184795, upload-time = "2026-07-06T21:33:06.699Z" }, + { url = "https://files.pythonhosted.org/packages/84/4c/82f132cb4418ee6d953d982b19191e87e2a6372c8a4ce36e50b69d6ade4a/cffi-2.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:716ff8ec22f20b4d988b12884086bcef0fc99737043e503f7a3935a6be99b1ea", size = 184746, upload-time = "2026-07-06T21:33:08.071Z" }, + { url = "https://files.pythonhosted.org/packages/a0/1c/4ed5a0e5bdca6cbc275556de3328dd1b76fd0c11cc13c88fe66d1d8715f2/cffi-2.1.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:63960549e4f8dc41e31accb97b975abaecfc44c03e396c093a6436763c2ea7db", size = 214747, upload-time = "2026-07-06T21:33:09.671Z" }, + { url = "https://files.pythonhosted.org/packages/3a/a6/e879bb68cc23a2bc9ba8f4b7d8019f0c2694bad2ab6c4a3701d429439f58/cffi-2.1.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ff067a8d8d880e7809e4ac88eb009bb848870115317b306666502ccad30b147f", size = 222392, upload-time = "2026-07-06T21:33:10.896Z" }, + { url = "https://files.pythonhosted.org/packages/88/f6/01890cfd63c08f8eb96a8319b0443690197d240a8bd6346048cf7bde9190/cffi-2.1.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:3b926723c13eba9f81d2ef3820d63aeceec3b2d4639906047bf675cb8a7a500d", size = 210285, upload-time = "2026-07-06T21:33:12.251Z" }, + { url = "https://files.pythonhosted.org/packages/a6/cf/2b684132056f438567b61e19d690dd31cd0921ace051e0a458be6074369e/cffi-2.1.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:47ff3a8bfd8cb9da1af7524b965127095055654c177fcfc7578debcb015eecd0", size = 208801, upload-time = "2026-07-06T21:33:13.617Z" }, + { url = "https://files.pythonhosted.org/packages/6f/08/f2e7d62c460faae0926f2d6e423694aa409ced3bc1fe2927a0a6e5f05416/cffi-2.1.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:799416bae98336e400981ff6e532d67d5c709cfb30afb79865a1315f94b0e224", size = 221808, upload-time = "2026-07-06T21:33:15.466Z" }, + { url = "https://files.pythonhosted.org/packages/38/37/04f54b8e63a02f3d908332c9effbf8c366167c6f733ed8a3d4f79b7e2a1e/cffi-2.1.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:961be50688f7fba2fa65f63712d3b9b341a22311f5253460ce933f52f0de1c8c", size = 225241, upload-time = "2026-07-06T21:33:16.869Z" }, + { url = "https://files.pythonhosted.org/packages/a9/d6/c72eecca433cd3e681c65ed313ab4835d9d4a379704d0f628a6a05f51c2e/cffi-2.1.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:bf5c6cf48238b0eb4c086978c492ad1cbc22373fc5b2d7353b3a598ce6db887a", size = 223588, upload-time = "2026-07-06T21:33:18.239Z" }, + { url = "https://files.pythonhosted.org/packages/c6/4b/e706f67279140f92939da3475ad610df18bfd52d50f14953a8e5fede71d5/cffi-2.1.0-cp313-cp313-win32.whl", hash = "sha256:db3eb7d46527159a878ec3460e9d40615bc25ba337d477db681aea6e4f05c5d2", size = 175248, upload-time = "2026-07-06T21:33:19.799Z" }, + { url = "https://files.pythonhosted.org/packages/5a/47/59eb7975cb0e4ef0afa764ea945b29a5bb4537a9f771cb7d6c8a5dd74c95/cffi-2.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:8e74a6135550c4748af665b1b1118b6aab33b1fc6a16f9aff630af107c3b4512", size = 185717, upload-time = "2026-07-06T21:33:21.47Z" }, + { url = "https://files.pythonhosted.org/packages/5a/af/34fee85c48f8d94efc8597bc09470c9dd274c145f1c12e0fbc6ab6d38d74/cffi-2.1.0-cp313-cp313-win_arm64.whl", hash = "sha256:2282cd5e38aa8accd03e99d1256af8411c84cdbee6a89d841b563fdbd1f3e50f", size = 180114, upload-time = "2026-07-06T21:33:22.515Z" }, + { url = "https://files.pythonhosted.org/packages/d8/f0/81478e482afa03f6d18dc8f2afb5edc45b3080853b634b5ed91961be0998/cffi-2.1.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:d2117334c3af3bdcb9a88522b844a2bdb5efdc4f71c6c822df55486ae1c3347a", size = 194142, upload-time = "2026-07-06T21:33:23.657Z" }, + { url = "https://files.pythonhosted.org/packages/7d/95/8de304305cd9204974b0ca051b86d307cafca13aa575a0ef1b44d92c0d8c/cffi-2.1.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:702c436735fbe99d59ada02a1f65cfc0d31c0ee8b7290912f8fbc5cd1e4b16c3", size = 196819, upload-time = "2026-07-06T21:33:25.007Z" }, + { url = "https://files.pythonhosted.org/packages/20/71/7c8372d30e42415602ed9f268f7cfd66f1b855fed881ecd168bcb45dbc0b/cffi-2.1.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:1ff3456eab0d889592d1936d6125bbfbc7ae4d3354a700f8bd80450a66445d4d", size = 184965, upload-time = "2026-07-06T21:33:26.605Z" }, + { url = "https://files.pythonhosted.org/packages/d6/5c/584e626835f0375c928176c04137c96927165cb8733cdb3150ec04e5ee5e/cffi-2.1.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c4165821e131d6d4ca444347c2b694e2311bcfa3fe5a861cc72968f28867beac", size = 184952, upload-time = "2026-07-06T21:33:27.823Z" }, + { url = "https://files.pythonhosted.org/packages/2e/d2/065fcae1c73979fac8e054462478d0ff8a29c40cdc2ed7ea5676a061df53/cffi-2.1.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:276f20fffd7b396e12516ba8edf9509210ac248cbbc5acbc39cd512f9f59ebe6", size = 222353, upload-time = "2026-07-06T21:33:29.178Z" }, + { url = "https://files.pythonhosted.org/packages/ed/a5/e8bbb1ce5b3ac2f53ad6a10bde44318a5a8d99d4f4a000d44a6e39aeb3e4/cffi-2.1.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:7d5980a3433d4b71a5e120f9dd551403d7824e31e2e67124fe2769c404c06913", size = 210051, upload-time = "2026-07-06T21:33:30.534Z" }, + { url = "https://files.pythonhosted.org/packages/28/ed/c127d3ac36e899c965e3361357c3befacd6578c03f40125183e41c3b219e/cffi-2.1.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:6ca4919c6e4f89aa99c42510b42cf54596892c00b3f9077f6bdd1505e24b9c8d", size = 208630, upload-time = "2026-07-06T21:33:31.753Z" }, + { url = "https://files.pythonhosted.org/packages/cc/d7/97d3136f81db489ec8d1d67748c110d6c994268fd7528014aa9f2b085e4e/cffi-2.1.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d53d10f7da99ae46f7373b9150393e9c5eab9b224909982b43832668de4779f5", size = 221593, upload-time = "2026-07-06T21:33:33.044Z" }, + { url = "https://files.pythonhosted.org/packages/d3/27/93195977168ee63aed233a1a0993a2178798654d1f4bddcdd321d6fd3b21/cffi-2.1.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c351efb95e832a853a29361675f33a7ce53de1a109cd73fd47af0712213aa4ce", size = 225146, upload-time = "2026-07-06T21:33:34.224Z" }, + { url = "https://files.pythonhosted.org/packages/b3/c1/6dbd291ee2ae5a50a034aa057207081f545923bbf15dad4511e985aafff5/cffi-2.1.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:dbf7c7a88e2bac086f06d14577332760bdeecc42bdec8ac4077f6260557d9326", size = 223240, upload-time = "2026-07-06T21:33:35.57Z" }, + { url = "https://files.pythonhosted.org/packages/0f/6f/ade5ce9863a57992a6ea3d0d10d7e29b8749fc127204b3d493d667b2815f/cffi-2.1.0-cp314-cp314-win32.whl", hash = "sha256:1854b724d00f6654c742097d5387569021be12d3a0f770eae1df8f8acfcc6acd", size = 177723, upload-time = "2026-07-06T21:33:51.626Z" }, + { url = "https://files.pythonhosted.org/packages/41/de/92b9eeed4ae4a21d6fd9b2a2c8505cbed573299902ea73981cc13f7ff62c/cffi-2.1.0-cp314-cp314-win_amd64.whl", hash = "sha256:1b96bfe2c4bd825681b7d311ad6d9b7280a091f43e8f63da5729638083cd3bfb", size = 187937, upload-time = "2026-07-06T21:33:53.403Z" }, + { url = "https://files.pythonhosted.org/packages/2e/1a/cc6ae6c2913a03aab8898eee57963cf1035b8df5872ed8b9115fcc7e2be8/cffi-2.1.0-cp314-cp314-win_arm64.whl", hash = "sha256:7d28dff1db6764108bc30788d85d61c876beff416d9a49cb9dd7c5a9f34f5804", size = 183001, upload-time = "2026-07-06T21:33:54.74Z" }, + { url = "https://files.pythonhosted.org/packages/14/f0/134c00ce0779ec86dea2aa1aac69339c2741a8045072676763512363a2ea/cffi-2.1.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7ea6b3e2c4250ff1de21c630fe72d0f63eb95c2c32ffbf64a358cf4a8836d714", size = 188538, upload-time = "2026-07-06T21:33:36.792Z" }, + { url = "https://files.pythonhosted.org/packages/50/d8/3b86aba791cb610d24e8a3e1b2cd529e71fa15096b04e4d4e360049d4a4c/cffi-2.1.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6af371f3767faeffc6ac1ef57cdfd25844403e9d3f476c5537caee499de96376", size = 188230, upload-time = "2026-07-06T21:33:38.011Z" }, + { url = "https://files.pythonhosted.org/packages/14/d0/117dcd9209255ad8571fbc8c92ef32593a1d294dcec91ddc4e4db50606f2/cffi-2.1.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:eb4e8997a49aa2c08a3e43c9045d224448b8941d88e7ac163c7d383e560cbf98", size = 223899, upload-time = "2026-07-06T21:33:39.514Z" }, + { url = "https://files.pythonhosted.org/packages/b6/3d/f20f8b886b254e3ad10e15cd4186d3aed49f3e6a35ab37aab9f8f25f7c03/cffi-2.1.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:bf01d8c84cbea96b944c73b22182e6c7c432b3475632b8111dbfdc95ddad6e13", size = 211652, upload-time = "2026-07-06T21:33:40.851Z" }, + { url = "https://files.pythonhosted.org/packages/28/3b/fad54de07260b93ddeef4b96d0131d57ea900675df1d410ae1deee52d7a6/cffi-2.1.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:33eb1ad83ebe8f313e0df035c406227d55a79456704a863fad9842136af5ad7d", size = 210755, upload-time = "2026-07-06T21:33:42.183Z" }, + { url = "https://files.pythonhosted.org/packages/cc/82/3d5c705acb7abbba9bbd7d79b8e62e0f25b6120eb7ae6ac49f1b721722fe/cffi-2.1.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ac0f1a2d0cfa7eea3f2aaf006ab6e70e8feeb16b75d65b7e5939982ca2f11056", size = 223933, upload-time = "2026-07-06T21:33:43.603Z" }, + { url = "https://files.pythonhosted.org/packages/6c/d0/47e338384ab6b1004241002fa616301020cea4fc95f283506565d252f276/cffi-2.1.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c16914df9fb7f500e440e6875fa23ff5e0b31db01fa9c06af98d59a91f0dc2e4", size = 226749, upload-time = "2026-07-06T21:33:45.046Z" }, + { url = "https://files.pythonhosted.org/packages/70/25/65bd5b58ea4bfdfc15cde02cb5365f89ef8ab8b2adfb8fe5c4bd4233382f/cffi-2.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5ecbd0499275d57506d397eebe1981cee87b47fcd9ef5c22cab7ed7644a39a94", size = 225703, upload-time = "2026-07-06T21:33:46.374Z" }, + { url = "https://files.pythonhosted.org/packages/dc/78/aa01ac599a8a4322533d45a1f9bc93b338276d2d59dabbe7c6d92a775c81/cffi-2.1.0-cp314-cp314t-win32.whl", hash = "sha256:7d034dcffa09e9a46c93fa3a3be402096cb5354ac6e41ab8e5cc9cd8b642ad76", size = 182857, upload-time = "2026-07-06T21:33:47.696Z" }, + { url = "https://files.pythonhosted.org/packages/b9/26/d00496b22de4d4228f32dde94ad996f350c8aad676d63bcca0743c8dea4d/cffi-2.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:0582a58f3051372229ca8e7f5f589f9e5632678208d8636fea3676711fdf7fe5", size = 194065, upload-time = "2026-07-06T21:33:48.953Z" }, + { url = "https://files.pythonhosted.org/packages/d5/dd/0c7dbf815a579ff005008a2d815a55d6bb047c349eef536d9dc53d3f0a8d/cffi-2.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:510aeeeac94811b138077451da1fb18b308a5feab47dd2b603af55804155e1c8", size = 186404, upload-time = "2026-07-06T21:33:50.309Z" }, + { url = "https://files.pythonhosted.org/packages/55/c7/8c8c50cb11c6750051daf12164098a9a6f027ac4356967fd4d800a07f242/cffi-2.1.0-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:2e9dabb9abcb7ad15938c7196ad5c1718a4e6d33cc79b4c0209bdb64c4a54a5c", size = 194121, upload-time = "2026-07-06T21:33:56.109Z" }, + { url = "https://files.pythonhosted.org/packages/99/e2/67680bf19a6b60d2bb7ff83baefa2a4c3d2d7dc0f3277034b802e1fc504c/cffi-2.1.0-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:37f525a7e7e50c017fdebe58b787be310ad59357ae43a053943a6e1a6c526001", size = 196820, upload-time = "2026-07-06T21:33:57.288Z" }, + { url = "https://files.pythonhosted.org/packages/ed/da/4bbe583a3b3a5c8c60892124fe17f3fa3656523faf0d3484eae90f091853/cffi-2.1.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:95f2954c2c9473d892eca6e0409f3568b37ab62a8eedb122461f73cc273476e3", size = 184936, upload-time = "2026-07-06T21:33:58.765Z" }, + { url = "https://files.pythonhosted.org/packages/e5/4b/1f4c36ab273980d7aa75bb126ea4f8971f24a96108acad3a0a084028c57b/cffi-2.1.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:cdf2448aab5f661c9315308ec8b93f4e8a1a67a3c733f8631067a2b67d5913dc", size = 185045, upload-time = "2026-07-06T21:34:00.085Z" }, + { url = "https://files.pythonhosted.org/packages/ef/c3/ad299dc38f3583f8d916b299f028af418a9ec98bc695fcbebeae7420691c/cffi-2.1.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:90bec57cf82089383bd06a605b3eb8daebf7e5a668520beaf6e327a83a947699", size = 222342, upload-time = "2026-07-06T21:34:01.814Z" }, + { url = "https://files.pythonhosted.org/packages/eb/d8/df4543cc087245044ed02ef3ad8e0a26619d0075ac7a77a12dc81177851b/cffi-2.1.0-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6274dcb2d15cef48daa73ed1be5a40d501d74dccd0cd6db364776d12cb6ba022", size = 210073, upload-time = "2026-07-06T21:34:03.255Z" }, + { url = "https://files.pythonhosted.org/packages/2c/0e/fac738d73728c6cea2a88a2883dca54892496cbba88a1dc1f2909cb8a6f5/cffi-2.1.0-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:2b71d409cccee78310ab5dec549aed052aaea483346e282c7b02362596e01bb0", size = 208551, upload-time = "2026-07-06T21:34:04.433Z" }, + { url = "https://files.pythonhosted.org/packages/e6/3f/0b04a700dd64f465c93020253a793a82c9b4dff9961f48facd0df945d9b8/cffi-2.1.0-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7d3538f9c0e50670f4deb93dbb696576e60590369cae2faf7de681e597a8a1f1", size = 221649, upload-time = "2026-07-06T21:34:06.157Z" }, + { url = "https://files.pythonhosted.org/packages/5d/7c/b7379a5704c79eda57ce075869ba70a0368d1c850f803b3c0d078d39dcaf/cffi-2.1.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:8f9ec95b8a043d3dfbc74d9abc6f7baf524dd27a8dc160b0a32ff9cdab650c28", size = 225203, upload-time = "2026-07-06T21:34:07.489Z" }, + { url = "https://files.pythonhosted.org/packages/5a/02/d5e6c43ea85c41bda2a184a3418f195fe7cf602967a8d2b94e085b83deef/cffi-2.1.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:af5e2915d41fe6c961694d7bfdc8562942638200f3ce2765dfb8b745cf997629", size = 223263, upload-time = "2026-07-06T21:34:08.712Z" }, + { url = "https://files.pythonhosted.org/packages/2c/d8/772b8259bf75749adffb1c546828978381fb516f60cf701f6c83daf60c85/cffi-2.1.0-cp315-cp315-win32.whl", hash = "sha256:0a42c688d19fca6e095a53c6a6e2295a5b050a8b289f109adab02a9e61a25de6", size = 177696, upload-time = "2026-07-06T21:34:26.355Z" }, + { url = "https://files.pythonhosted.org/packages/2f/dd/afa2191fc6d57fedd26e5844a2fe2fcc0bbfa00961bbaa5a41e4921e7cca/cffi-2.1.0-cp315-cp315-win_amd64.whl", hash = "sha256:bccbbb5ee76a61f9d99b5bf3846a51d7fca4b6a732fe46f89295610edaf41853", size = 187914, upload-time = "2026-07-06T21:34:27.58Z" }, + { url = "https://files.pythonhosted.org/packages/05/ef/6cd4f8c671517162379dc79cfae5aea9106bc38abb89628d5c16adf6a838/cffi-2.1.0-cp315-cp315-win_arm64.whl", hash = "sha256:8d35c139744adb3e727cd51b1a18324bbe44b8bd41bf8322bca4d41289f48eda", size = 183004, upload-time = "2026-07-06T21:34:28.905Z" }, + { url = "https://files.pythonhosted.org/packages/11/b6/12fc55092817a5faa26fb8c40c7f9d662e11a46ee248c137aafc42517d92/cffi-2.1.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:f9912624a0c0b834b7520d7769b3644453aabc0a7e1c839da7359f050750e9bc", size = 188378, upload-time = "2026-07-06T21:34:09.926Z" }, + { url = "https://files.pythonhosted.org/packages/8d/2e/cdac88979f295fde5daa69622c7d2111e56e7ceb94f211357fbe452339e4/cffi-2.1.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:df92f2aba50eb4d96718b68ef76f2e57a57b54f2fa62333496d16c6d585a85ca", size = 188319, upload-time = "2026-07-06T21:34:11.101Z" }, + { url = "https://files.pythonhosted.org/packages/e0/27/1d0b408497e41a74795af122d7b603c418c5fed0171450f899afd04e594f/cffi-2.1.0-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0520e1f4c35f44e209cbbb421b67eec42e6a157f59444dfb6058874ff3610e5d", size = 223904, upload-time = "2026-07-06T21:34:12.606Z" }, + { url = "https://files.pythonhosted.org/packages/8b/31/e115c985105dd7ffb32444505f18ceb874bb42d992af05d5dced7ecf1980/cffi-2.1.0-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:3681e031db29958a7502f5c0c9d6bbc4c36cb20f7b104086fa642d1799631ff8", size = 211554, upload-time = "2026-07-06T21:34:13.987Z" }, + { url = "https://files.pythonhosted.org/packages/5a/67/9e6e09409336d9e515c58367e7cfcf4f89df06ad25252675595a58eb59d5/cffi-2.1.0-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:762f99479dcb369f60ab9017ad4ab97a36a1dd7c1ee5a3b15db0f4b8659120cd", size = 210795, upload-time = "2026-07-06T21:34:15.972Z" }, + { url = "https://files.pythonhosted.org/packages/19/e5/d3cc82a4a0be7902af279c04181ad038449c096734464a5ae1de3e1401bd/cffi-2.1.0-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0611e7ebf90573a535ebdc33ae9da222d037853983e13359f580fab781ca017f", size = 223843, upload-time = "2026-07-06T21:34:17.509Z" }, + { url = "https://files.pythonhosted.org/packages/b9/65/b434abc97ce7cecc2c640fde160507c0ecc7e21544b483ba3325d2e2ea17/cffi-2.1.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:86cf8755a791f72c85dc287128cc62d4f24d392e3f1e15837245623f4a33cccc", size = 226773, upload-time = "2026-07-06T21:34:19.05Z" }, + { url = "https://files.pythonhosted.org/packages/b5/9f/d4dc66ca651eb1145a133314cda721abf13cfac3d28c4a0402263ae6ad75/cffi-2.1.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:ba00f661f8ba35d075c937174e27c2c421cec3942fd2e0ea3e66996757c0fdd9", size = 225719, upload-time = "2026-07-06T21:34:20.576Z" }, + { url = "https://files.pythonhosted.org/packages/68/5a/e536c528bc8057496c360c0978559a2dc45653f89dd6151078aa7d8fca1a/cffi-2.1.0-cp315-cp315t-win32.whl", hash = "sha256:cb96698e3c7413d906ce83f8ffd245ec1bd94707541f299d0ce4d6b0193e982b", size = 182760, upload-time = "2026-07-06T21:34:22.059Z" }, + { url = "https://files.pythonhosted.org/packages/d3/0b/0ffe8b82d3875bced5fa1e7986a7a46b748262a40ab7f60b475eb9fb1bb3/cffi-2.1.0-cp315-cp315t-win_amd64.whl", hash = "sha256:f146d154428a2523f9cc7936c02353c2459b8f6cf07d3cd1ee1c0a611109c5d5", size = 193769, upload-time = "2026-07-06T21:34:23.589Z" }, + { url = "https://files.pythonhosted.org/packages/a0/17/1073b53b68c9b5ca6914adf5f8bf55aacc2d3be102418c90700160ea8605/cffi-2.1.0-cp315-cp315t-win_arm64.whl", hash = "sha256:cbb7640ce37159548d2147b5b8c241f962143d4c71231431820783f4dc78f210", size = 186405, upload-time = "2026-07-06T21:34:24.857Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.9" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bd/2a/23f34ec9d04624958e137efdc394888716353190e75f25dd22c7a2c7a8aa/charset_normalizer-3.4.9.tar.gz", hash = "sha256:673611bbd43f0810bec0b0f028ddeaaa501190339cac411f347ac76917c3ae7b", size = 152439, upload-time = "2026-07-07T14:34:58.454Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/e3/85ec501f206fb049259288c1f3506e53876937fb00edb47009348e66756b/charset_normalizer-3.4.9-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:0e94703ec9684807f20cfb5eed95c70f67f2a8f21ad620146d7b5a13677b93e5", size = 317075, upload-time = "2026-07-07T14:32:56.021Z" }, + { url = "https://files.pythonhosted.org/packages/c3/69/2a5385192e67175f7d8bd5ce4f57c24bc956439adeae5c13a99aa28a53d1/charset_normalizer-3.4.9-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2a441ea71902098ffe78c5abe6c494f44160b4af614ed16c3d9a3b1d17fd8ee2", size = 213837, upload-time = "2026-07-07T14:32:57.78Z" }, + { url = "https://files.pythonhosted.org/packages/b3/46/03ddc7da576d814fe0a36dd1f0fd3258e95404b4b2e3c026b7923d7e133f/charset_normalizer-3.4.9-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:304b13570067b2547562e308af560b3963857b1fa90bd6afd978130130fe2d6a", size = 235503, upload-time = "2026-07-07T14:32:59.205Z" }, + { url = "https://files.pythonhosted.org/packages/4e/6e/de0229a7ef40f6f9d28a837eebf4ec47bdca5dab4e900c84f22919af636a/charset_normalizer-3.4.9-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4773092f8019072343a7447203308b176e10199920eb02d6195e81bbb3274c29", size = 229944, upload-time = "2026-07-07T14:33:00.803Z" }, + { url = "https://files.pythonhosted.org/packages/a5/34/49b9060e8418b14fb5cba9cf6bfb383111e2538a03a1fb18e66a95aeb3d5/charset_normalizer-3.4.9-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:04ce310cb89c15df659582aee80a0603788732a5e017d5bd5c81158106ce249c", size = 221276, upload-time = "2026-07-07T14:33:02.199Z" }, + { url = "https://files.pythonhosted.org/packages/44/95/80282cce0fae9c3061203d723ee87da996aed79679e65d8935050ee7ca1f/charset_normalizer-3.4.9-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:c0323c9daef75ef2e5083624b4585018a0c9d5e3b40f607eed81a311270b934b", size = 205260, upload-time = "2026-07-07T14:33:03.698Z" }, + { url = "https://files.pythonhosted.org/packages/0c/74/2f62c8821b969ea3bd67cc2e6976834f48ca5d12664d2559ebcd9bcfbed7/charset_normalizer-3.4.9-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:871ff67ea1aad4dfd91736464934d56b32dac49f9fbe16cddba36198a7b3a0db", size = 217786, upload-time = "2026-07-07T14:33:05.12Z" }, + { url = "https://files.pythonhosted.org/packages/d9/8d/feabb82cb49fcad14515b1d7d1ca4787b0da7fc723a212bf89bc9e0fac52/charset_normalizer-3.4.9-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:67830fc78e67501f47bb950471b2dcb9b35b140084429318e862895a8e89c993", size = 216798, upload-time = "2026-07-07T14:33:06.629Z" }, + { url = "https://files.pythonhosted.org/packages/a5/ff/c946d63bc3786d5b84d960b0f7ab7e25b828486a946b5aa997625bcaf6a6/charset_normalizer-3.4.9-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:3d92613ec25e43b05f042302531ec0f00b8445190e43325880cbd6ab7c2581da", size = 206429, upload-time = "2026-07-07T14:33:08.006Z" }, + { url = "https://files.pythonhosted.org/packages/af/ba/5e5007c370702f85d2ef75791fac7943ed41e080364a673b20142e430e3e/charset_normalizer-3.4.9-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:280081916dc341820640489a66e4696049401ef1cf6dd672f672e70ad915aca3", size = 223066, upload-time = "2026-07-07T14:33:09.783Z" }, + { url = "https://files.pythonhosted.org/packages/83/d5/9096aa3cf532dfad237861544eb47a0f20d5adbf1039760fed8eaae935d9/charset_normalizer-3.4.9-cp311-cp311-win32.whl", hash = "sha256:ac351b3b8014eead140e77e9717e2992c6bbe30b63bc3422422eb84865412e3d", size = 150456, upload-time = "2026-07-07T14:33:11.217Z" }, + { url = "https://files.pythonhosted.org/packages/ed/a1/e29995109e455dc8eff8d0fac6ae509be39561318a7cfeac5d33ad029213/charset_normalizer-3.4.9-cp311-cp311-win_amd64.whl", hash = "sha256:6366a16e1a25018694d6a5d784d09b046edc9eac40ea2b54065c3052672516a1", size = 161410, upload-time = "2026-07-07T14:33:12.743Z" }, + { url = "https://files.pythonhosted.org/packages/4f/8d/1569f4d0032d6ba2a4fe4591c35bf87868c600c41a71eb5c2e1ffa8464c2/charset_normalizer-3.4.9-cp311-cp311-win_arm64.whl", hash = "sha256:1d22856ffbe153a602df38e4a5464f0b748a54002e0d69ac6d2ad0a197cc99ec", size = 152649, upload-time = "2026-07-07T14:33:14.173Z" }, + { url = "https://files.pythonhosted.org/packages/70/4a/ecbd131485c07fcdfad54e28946d513e3da22ef3b4bd854dcafae54ec739/charset_normalizer-3.4.9-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:45b0cc4e3556cd875e09102988d1ab8356c998b596c9fced84547c8138b487a0", size = 319300, upload-time = "2026-07-07T14:33:15.666Z" }, + { url = "https://files.pythonhosted.org/packages/ec/96/5d9364e3342d69f3a045e1777bc47c85c383e6e9466d561b33fdb419d1f9/charset_normalizer-3.4.9-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9b2aff1c7b3884512b9512c3eaadd9bab39fb45042ffaaa1dd08ff2b9f8109d9", size = 215802, upload-time = "2026-07-07T14:33:17.031Z" }, + { url = "https://files.pythonhosted.org/packages/4b/4c/5361f9aa7f2cb58d94f2ab831b3d493f69efb1d239654b4744e3c09527cb/charset_normalizer-3.4.9-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9104ed0bd76a429d46f9ec0dbc9b08ad1d2dcdf2b00a5a0daa1c145329b35b44", size = 237171, upload-time = "2026-07-07T14:33:18.576Z" }, + { url = "https://files.pythonhosted.org/packages/50/78/ce342ca4ff30b2eb49fe6d9578df85974f90c67d294113e94efdd9664cbd/charset_normalizer-3.4.9-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7b86a2b16095d250c6f58b3d9b2eee6f4147754344f3dab0922f7c9bf7d226c9", size = 233075, upload-time = "2026-07-07T14:33:20.084Z" }, + { url = "https://files.pythonhosted.org/packages/01/c4/4fa4c8b3097a11f3c5f09a35b72ed6855fb1d332469504962ab7bafcc702/charset_normalizer-3.4.9-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e226f6218febc71f6c1fc2fafb91c226f75bdc1d8fb12d66823716e891608fd", size = 224256, upload-time = "2026-07-07T14:33:21.747Z" }, + { url = "https://files.pythonhosted.org/packages/87/3a/ad914516df7e358a81aae018caa5e0470ba827fa6d763b1d2e87d920a5f6/charset_normalizer-3.4.9-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:90c44bc373b7687f6948b693cceaea1348ae0975d7474746559494468e3c1d84", size = 208784, upload-time = "2026-07-07T14:33:23.313Z" }, + { url = "https://files.pythonhosted.org/packages/d7/74/3c12f9755717dfe5c5c87da63f35d765fa0c00382ec26bf23f7fae34f2ba/charset_normalizer-3.4.9-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9cdef90ae47919cae358d8ab15797a800ed41da7aba5d72419fb510729e2ed4b", size = 219928, upload-time = "2026-07-07T14:33:24.814Z" }, + { url = "https://files.pythonhosted.org/packages/33/9a/895095b83e7907abd6d3d99aad3a38ad0d9686cc186cb0c94c24320fe63e/charset_normalizer-3.4.9-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:60f44ade2cf573dad7a277e6f8ca9a51a21dda572b13bd7d8539bb3cd5dbedde", size = 218489, upload-time = "2026-07-07T14:33:26.42Z" }, + { url = "https://files.pythonhosted.org/packages/a1/34/ef5c05f412f42520d7709b7d3784d19640839eb7366ded1755511585429f/charset_normalizer-3.4.9-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:a1786910334ed46ab1dd73222f2cd1e05c2c3bb39f6dddb4f8b36fc382058a39", size = 210267, upload-time = "2026-07-07T14:33:27.952Z" }, + { url = "https://files.pythonhosted.org/packages/83/dc/9b29fa4412b318bf3bfea985c35d67eb55e04b59a7c3f2237168b0e0be6f/charset_normalizer-3.4.9-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:03d07803992c6c7bbc976327f34b18b6160327fc81cb82c9d504720ac0be3b62", size = 226030, upload-time = "2026-07-07T14:33:29.397Z" }, + { url = "https://files.pythonhosted.org/packages/0e/42/6dbc00b8cd16011691203e33570fa42ed5746599a2e878112d16eab403a3/charset_normalizer-3.4.9-cp312-cp312-win32.whl", hash = "sha256:78841cccf1af7b40f6f716338d50c0902dbe88d9f800b3c973b7a9a0a693a642", size = 151185, upload-time = "2026-07-07T14:33:30.781Z" }, + { url = "https://files.pythonhosted.org/packages/80/cc/f920afd1a23c58ccd53c1d36085a71893a4737ff5e66e0371efab6809850/charset_normalizer-3.4.9-cp312-cp312-win_amd64.whl", hash = "sha256:4b3dac63058cc36820b0dd072f89898604e2d39686fe05321729d00d8ac185a0", size = 162557, upload-time = "2026-07-07T14:33:32.176Z" }, + { url = "https://files.pythonhosted.org/packages/f0/e6/0386d43a261ff4e4b30c5857af7df877254b46bec7b9d1b74b6bf969a90b/charset_normalizer-3.4.9-cp312-cp312-win_arm64.whl", hash = "sha256:78fa18e436a1a0e58dbd7e02fc4473f3f32cceb12df9dfca542d075961c307d2", size = 152665, upload-time = "2026-07-07T14:33:33.711Z" }, + { url = "https://files.pythonhosted.org/packages/b2/06/97ec2aeae780b31d742b6352218b43841a6871e2564578ca522dce4a45c3/charset_normalizer-3.4.9-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:440eede837960000d74978f0eba527be106b5b9aee0daf779d395276ed0b0614", size = 317688, upload-time = "2026-07-07T14:33:35.408Z" }, + { url = "https://files.pythonhosted.org/packages/d0/39/8ff066c672434225f8d25f8b739f992af250944392173dcc88362681c9bf/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:21e764fd1e70b6a3e205a0e46f3051701f98a8cb3fad66eeb80e48bb502f8698", size = 214982, upload-time = "2026-07-07T14:33:36.996Z" }, + { url = "https://files.pythonhosted.org/packages/92/8f/3a47a3667c83c2df9483d91644c6c107de3bf8874aa1793da9d3012eb986/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e4fd89cc178bced6ad29cb3e6dd4aa63fa5017c3524dbd0b25998fb64a87cc8b", size = 236460, upload-time = "2026-07-07T14:33:38.536Z" }, + { url = "https://files.pythonhosted.org/packages/f1/60/b22cdbee7e4013dab8b0d7647fc6181120fbbbc8f7025c226d15bd5a47fc/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bd47ba7fc3ca94896759ea0109775132d3e7ab921fbf54038e1bab2e46c313c9", size = 232003, upload-time = "2026-07-07T14:33:40.059Z" }, + { url = "https://files.pythonhosted.org/packages/ea/f8/72eb13dcabe7257035cea8aefd922caad2f110d252bf9f67c4c2ca763aee/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:84fd18bcc17526fc2b3c1af7d2b9217d32c9c04448c16ec693b9b4f1985c3d33", size = 223149, upload-time = "2026-07-07T14:33:41.631Z" }, + { url = "https://files.pythonhosted.org/packages/b0/3e/faee8f9de92b14ee1198e9163252bb15efee7301b31256a3b6d9ebfdd0dd/charset_normalizer-3.4.9-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:5b10cd92fc5c498b35a8635df6d5a100207f88b63a4dc1de7ef9a548e1e2cd63", size = 207901, upload-time = "2026-07-07T14:33:43.209Z" }, + { url = "https://files.pythonhosted.org/packages/3a/25/45f30093ae27dd7b92a793b61882a38685f993700113ca36e0c9c14965e1/charset_normalizer-3.4.9-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a4fbdde9dd4a9ce5fd52c2b3a347bb50cc89483ef783f1cb00d408c13f7a96c0", size = 219176, upload-time = "2026-07-07T14:33:44.725Z" }, + { url = "https://files.pythonhosted.org/packages/48/18/c8f397329c35e32f6a837e488986f4ae03bd2abebc453b48714991630c2f/charset_normalizer-3.4.9-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:416c229f77e5ea25b3dfd4b582f8d73d7e43c22320302b9ab128a2d3a0b38efe", size = 217356, upload-time = "2026-07-07T14:33:46.192Z" }, + { url = "https://files.pythonhosted.org/packages/86/7e/5ce0bba863470fd1902d5e5843968951bddf38abe4742fc97116ef4598b3/charset_normalizer-3.4.9-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:75286256590a6320cf106a0d28970d3560aad9ee09aa7b34fb40524792436d35", size = 209614, upload-time = "2026-07-07T14:33:47.705Z" }, + { url = "https://files.pythonhosted.org/packages/6c/ef/2473d3c4d869155be4af1191111d59c4d5c4e0173026f7e85b176e23bf65/charset_normalizer-3.4.9-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:69b157c5d3292bcd443faca052f3096f637f1e074b98212a933c074ae23dc3b8", size = 224991, upload-time = "2026-07-07T14:33:49.238Z" }, + { url = "https://files.pythonhosted.org/packages/d0/a3/53ddae3db108a088156aa8ddfafd411ebbc1340f48c5573f697b27f69a39/charset_normalizer-3.4.9-cp313-cp313-win32.whl", hash = "sha256:51307f5c71007673a2bf8232ad973483d281e74cb99c8c5a990af1eefa6277d9", size = 150622, upload-time = "2026-07-07T14:33:50.711Z" }, + { url = "https://files.pythonhosted.org/packages/e8/ef/6953a77c7cf2c2ff9998e6f575ab3e380119f100223381565a4f94c1f836/charset_normalizer-3.4.9-cp313-cp313-win_amd64.whl", hash = "sha256:fe2c7201c642b7c308f1675355ad7ff7b66acfe3541625efe5a3ad38f29d6115", size = 161947, upload-time = "2026-07-07T14:33:52.197Z" }, + { url = "https://files.pythonhosted.org/packages/6e/fb/d560d1d1555debbfe7849d9cac6145c1b537709d79576bf22557ed803b82/charset_normalizer-3.4.9-cp313-cp313-win_arm64.whl", hash = "sha256:611057cc5d5c0afc743ba8be6bd828c17e0aaa8643f9d0a9b9bb7dea80eb8012", size = 152594, upload-time = "2026-07-07T14:33:53.486Z" }, + { url = "https://files.pythonhosted.org/packages/7e/8d/496817fa0944239ecae662dd57ea765cfeaec6a735f9f025d4b7b72e7143/charset_normalizer-3.4.9-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:0327fcd59a935777d83410750c50600ee9571af2846f71ce40f25b13da1ef380", size = 317253, upload-time = "2026-07-07T14:33:54.994Z" }, + { url = "https://files.pythonhosted.org/packages/2b/f9/ef4a69ea338ad3c0deceea0f5f7d2380ae8b52132b06d652cb0d2cd86706/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8a79d9f4d8001473a30c163556b3c3bfebec837495a412dde78b51672f6134f9", size = 215898, upload-time = "2026-07-07T14:33:56.334Z" }, + { url = "https://files.pythonhosted.org/packages/8c/e7/5ddfd76fc061eb52de219658a4aa431cbacadf0a0219c8854f00da50d289/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:33bdcc2a32c0a0e861f60841a512c8acc658c87c2ac59d89e3a46dacf7d866e4", size = 236718, upload-time = "2026-07-07T14:33:57.9Z" }, + { url = "https://files.pythonhosted.org/packages/49/ba/768fa3f36048d81c477a0ce61f813bc1454d80917ccfe550abd9f44f5e24/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f840ed6d8ecba8255df8c42b87fadeda98ddfc6eeec05e2dc66e26d46dd6f58a", size = 232519, upload-time = "2026-07-07T14:33:59.811Z" }, + { url = "https://files.pythonhosted.org/packages/f4/c4/b3e049d2aa3766180c78507110543d9d50894cc97f57de543f1be521dcdc/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c25fe15c70c59eb7c5ce8c06a1f3fa1da0ecc5ea1e7a5922c40fd2fa9b0d5046", size = 223143, upload-time = "2026-07-07T14:34:01.517Z" }, + { url = "https://files.pythonhosted.org/packages/19/79/55c32d06d76ae4feafe053f061f3e3ab70bcf19f4007797ce8c3efda7830/charset_normalizer-3.4.9-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:f7fb7d750cfa0a070d2c24e831fd3481019a60dd317ea2b39acbcebc08b6ed81", size = 206742, upload-time = "2026-07-07T14:34:03.04Z" }, + { url = "https://files.pythonhosted.org/packages/10/e0/47c079dd82d217c807479cd59ffd30af56307ea31c108b75758970459ad3/charset_normalizer-3.4.9-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4d1c96a7a18b9690a4d46df09e3e3382406ae3213727cd1019ebade1c4a81917", size = 219191, upload-time = "2026-07-07T14:34:04.657Z" }, + { url = "https://files.pythonhosted.org/packages/42/ab/b9bc2e77d6b44a7e46ef62ec5cac1c9a6ba7b9135a5d560f002696ec9995/charset_normalizer-3.4.9-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a4cfde78a9f2880208d16a93b795726a3017d5977e08d1e162a7a31322479c41", size = 218328, upload-time = "2026-07-07T14:34:06.115Z" }, + { url = "https://files.pythonhosted.org/packages/f1/78/c9c71d599f5aa2d42bcdd35cbbd46d7f535351a57e40ff7d8e5a7e219401/charset_normalizer-3.4.9-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:d4d6fcde76f94f5cb9e43e9e9a61f16dacefd228cbbf6f1a09bd9b219a92f1a1", size = 207406, upload-time = "2026-07-07T14:34:07.554Z" }, + { url = "https://files.pythonhosted.org/packages/f6/39/c914445c321a845097ce4f6ac7de9a18228a77b766272125a1ce00d851eb/charset_normalizer-3.4.9-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:898f0e9068ca27d37f8e83a5b962821df851532e6c4a7d615c1c033f9da6eedf", size = 225157, upload-time = "2026-07-07T14:34:09.061Z" }, + { url = "https://files.pythonhosted.org/packages/9b/f2/c0d4b8508565a36bc5c624e88ed297f5b0b1095011034d7f5b83a69908b5/charset_normalizer-3.4.9-cp314-cp314-win32.whl", hash = "sha256:c1c948747b03be832dceed96ca815cef7360de9aa19d37c730f8e3f6101aca48", size = 151095, upload-time = "2026-07-07T14:34:10.901Z" }, + { url = "https://files.pythonhosted.org/packages/49/fd/a1d26144398c67486422a72bf5812cda22cb4ccfcd95a290fb41ceb4b8e2/charset_normalizer-3.4.9-cp314-cp314-win_amd64.whl", hash = "sha256:16b65ea0f2465b6fb52aa22de5eca612aa964ddfec00a912e26f4656cbef890b", size = 162796, upload-time = "2026-07-07T14:34:12.47Z" }, + { url = "https://files.pythonhosted.org/packages/20/95/d75e82f8ce9fd323ebf059c16c9aadefb22a1ecde13b7840b35835e4886c/charset_normalizer-3.4.9-cp314-cp314-win_arm64.whl", hash = "sha256:40a126142a56b2dfc0aacbad1de8310cbf60da7656db0e6b16eebd48e3e93519", size = 153334, upload-time = "2026-07-07T14:34:14.044Z" }, + { url = "https://files.pythonhosted.org/packages/00/5e/17398df3a139985ba9d11ed072531986f408c8fca952835ef1ab1820c02b/charset_normalizer-3.4.9-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:609b3ba8fcc0fb5ab7af00719d0fb6ad0cb518e48e7712d12fd68f1327951198", size = 338848, upload-time = "2026-07-07T14:34:15.688Z" }, + { url = "https://files.pythonhosted.org/packages/cd/91/7253a32e86b7e1d1239b1b36ba6dd0f021a21107ab33054b53119cc083b9/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:51447e9aa2684679af07ca5021c3db526e0284347ebf4ffcec1154c3350cfe32", size = 223022, upload-time = "2026-07-07T14:34:17.248Z" }, + { url = "https://files.pythonhosted.org/packages/cb/32/2e64bd2be10e89c61e57ebe6a93fd98ae88eb7ebe414b5121f22c96c69eb/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cc1b0fff8ead343dae06305f954eb8468ba0ec1a97881f42489d198e4ce3c632", size = 241590, upload-time = "2026-07-07T14:34:18.813Z" }, + { url = "https://files.pythonhosted.org/packages/3d/ef/d96ec496cfea0c21db43b0ad03891308b02388d054cc902cf0e5a1ad6a88/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fa36ec09ef71d158186bc79e359ff5fdd6e7996fe8ab638f00d6b93139ba4fcf", size = 239584, upload-time = "2026-07-07T14:34:20.52Z" }, + { url = "https://files.pythonhosted.org/packages/d4/ce/9af95f7876194bd7a14e3dfe4a4de2e0bff02666a3910d72beafd06cc297/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:df115d4d83168fdf2cae48ef1ff6d1cb4c466364e30861b37121de0f3bf1b990", size = 230224, upload-time = "2026-07-07T14:34:22.189Z" }, + { url = "https://files.pythonhosted.org/packages/52/94/af74dde74a3996bd959c350709bfe50e297823d70a8c1cbd54b838880863/charset_normalizer-3.4.9-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:f86c6358749bd4fda175388691e3ba8c46e24c5347d0afd20f9b7edfc9faf07d", size = 212667, upload-time = "2026-07-07T14:34:23.857Z" }, + { url = "https://files.pythonhosted.org/packages/ee/f0/f1c4fe746c395922961b5916ed1d7d6e7d4c84851d19ed43cc89980ec953/charset_normalizer-3.4.9-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:32286a2c8d167e897177b673176c1e3e00d4057caf5d2b64eef9a3666b03018e", size = 227179, upload-time = "2026-07-07T14:34:25.586Z" }, + { url = "https://files.pythonhosted.org/packages/e4/56/6c745619ac397e8871e2bcd3cea1eec86b877488f33888b3aef5c3ed506e/charset_normalizer-3.4.9-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:83aed2c10721ddd90f68140685391b50811a880af20654c59af6b6c66c40513c", size = 225372, upload-time = "2026-07-07T14:34:27.212Z" }, + { url = "https://files.pythonhosted.org/packages/78/ad/98aae8630ac71f16711968e38a5acfecce41b778bf2f0312851020f565a8/charset_normalizer-3.4.9-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cd6c3d4b783c556fa00bf540854e42f135e2f256abd29669fcd0da0f2dec79c2", size = 215222, upload-time = "2026-07-07T14:34:28.774Z" }, + { url = "https://files.pythonhosted.org/packages/f7/40/9593d54209765207a7f11073c06494c1721e4ca4a0a426c597679bf7f91e/charset_normalizer-3.4.9-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ee2f2a527e3c1a6e6411eb4209642e138b544a2d72fe5d0d76daf77b24063534", size = 231958, upload-time = "2026-07-07T14:34:30.345Z" }, + { url = "https://files.pythonhosted.org/packages/b1/27/693ee5e8a18191eb38647360c51cd505013e2bd3b366aa43fd5344c21e3c/charset_normalizer-3.4.9-cp314-cp314t-win32.whl", hash = "sha256:0d861473f743244d349b50f850d10eb87aeb22bbdcc8e64f79273c94af5a8226", size = 155580, upload-time = "2026-07-07T14:34:31.884Z" }, + { url = "https://files.pythonhosted.org/packages/80/3f/bd97d3d9c613013d07cb7733d299385b41df37f0471310f5a73dc359f0b8/charset_normalizer-3.4.9-cp314-cp314t-win_amd64.whl", hash = "sha256:9b8e0f3107e2200b76f6054de99016eac3ee6762713587b36baaa7e4bd2ae177", size = 167620, upload-time = "2026-07-07T14:34:33.438Z" }, + { url = "https://files.pythonhosted.org/packages/3d/c6/eee9dca4439b1061f76373f06ea855678cc4a64c1c3c90b50e479edbb8eb/charset_normalizer-3.4.9-cp314-cp314t-win_arm64.whl", hash = "sha256:19ac87f93086ce37b86e098888555c4b4bc48102279bae3350098c0ed664b501", size = 158037, upload-time = "2026-07-07T14:34:35.018Z" }, + { url = "https://files.pythonhosted.org/packages/98/2b/f97f1c193fb855c345d678f5077d6926034db0722df74c8f057020e05a25/charset_normalizer-3.4.9-py3-none-any.whl", hash = "sha256:68e5f26a1ad57ded6d1cfb85331d1c1a195314756471d97758c48498bb4dcdf5", size = 64538, upload-time = "2026-07-07T14:34:56.993Z" }, +] + +[[package]] +name = "clr-loader" +version = "0.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/46/7eea92b6aa2d68af78e049cbecec5f757f1aad44ecdecdc16bbad7eead51/clr_loader-0.3.1.tar.gz", hash = "sha256:2e073e9aaf49d1ae2f56ecba27987ad5fb68be4bcd9dd34a5bed8f0e4e128366", size = 86805, upload-time = "2026-04-18T17:49:44.287Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5e/da/ec1a6e36624000b6df0dd61183c42342ee5814c073315e802cadaad04d2f/clr_loader-0.3.1-py3-none-any.whl", hash = "sha256:cbad189de20d202a7d621956b0fc38049e13c9bf7ca2923441eff725cd121aa1", size = 55730, upload-time = "2026-04-18T17:49:42.99Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "cryptography" +version = "49.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1f/99/d1c90d6041656cc6ee229dc99cd67fd0cd5aec3c5f7d72fffc27cc750054/cryptography-49.0.0.tar.gz", hash = "sha256:f89660a348f4f78a92366240a61404e337586ef7f5909a2fef59ca88ef505493", size = 854345, upload-time = "2026-06-12T20:02:30.512Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9b/22/adf66990e63584a68dfb50c24f48a125c07b1699899381c8151e63ed458c/cryptography-49.0.0-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:966fe0e9c67490071f14c0d2b1cb2dfb3023c5ce39457343931415f08382f2db", size = 4032100, upload-time = "2026-06-12T20:02:32.143Z" }, + { url = "https://files.pythonhosted.org/packages/09/41/3797cfaf69cae04a13ee78ebd83f0678d9c02b4779d21ce24445326f1a69/cryptography-49.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:36d1709f992593689b45bda411498d62c6e365f2ca00b84657d4dadd24de16db", size = 4692978, upload-time = "2026-06-12T20:01:21.305Z" }, + { url = "https://files.pythonhosted.org/packages/e6/8b/43011f7ebe515a8aa20d61f290a326cd890c2e738e16e59eaff8d9c3a412/cryptography-49.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0e959b578856a3924bc0cbb710fc12c387b9412a951389f3ca61704a9e25f325", size = 4716422, upload-time = "2026-06-12T20:01:48.566Z" }, + { url = "https://files.pythonhosted.org/packages/4a/91/01ce7303a4579e6d3a6abef01bd322848e9ea7a219adcabc5048b9033571/cryptography-49.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:53ecee2e23f7169b6117e99fc8a944e5e50f79e69758a83b52a00cb98ab2b2d2", size = 4700503, upload-time = "2026-06-12T20:02:47.091Z" }, + { url = "https://files.pythonhosted.org/packages/62/99/a2c95cf8293f07491e9e27c20cc4dcd18176d944e674679adeb1d0173fd6/cryptography-49.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:2eda353d8a27bcbcaa4cbed18994a74ab4d19a2ca897db188ea269ab9b71419b", size = 5309779, upload-time = "2026-06-12T20:02:08.987Z" }, + { url = "https://files.pythonhosted.org/packages/20/2c/0622f20ff02b2ef32558733443805dc82fd4c275be01b2d19d14676f3a1b/cryptography-49.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:2afe9051da7ae7bd5905da5a949280c7d2bb75682e188f650a9d0f2756b834c6", size = 4749683, upload-time = "2026-06-12T20:02:03.335Z" }, + { url = "https://files.pythonhosted.org/packages/a3/5b/c5246635d5fd3b64e0d45ae10e99fd32fe9676a79915ccfe5a61ba9af1a5/cryptography-49.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:0b82e28ee398a386f0807bba7884d30f25218855690f45115831bcce5d90822c", size = 4337874, upload-time = "2026-06-12T20:02:54.323Z" }, + { url = "https://files.pythonhosted.org/packages/6d/88/05563c7fe2e914e87d1a536d06fe83e66b4e1d95cb593e05aea375531da8/cryptography-49.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:ccac2bfebc306b862133e3bb71f3f6ee8bb525240089b2d952e4144b3a6d5da7", size = 4700283, upload-time = "2026-06-12T20:01:34.822Z" }, + { url = "https://files.pythonhosted.org/packages/c4/b6/d7696e4e890d6ae1469935164c9e5215c557671cb78d6e3f458ccceaa632/cryptography-49.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:d0527ce944105f257f605a827d6ebead966c752038b6e8656abb9c5edee6fc68", size = 5265844, upload-time = "2026-06-12T20:01:24.09Z" }, + { url = "https://files.pythonhosted.org/packages/a9/3c/f3ad17eecc1a57b0ba236dc01f90e783c51f4a2f35f64777cc4f47a184b2/cryptography-49.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:cbc77da8c523d5abd028635ba850a6966fcee2c82e2bf65a41d1d8afe0f98be9", size = 4749290, upload-time = "2026-06-12T20:01:30.848Z" }, + { url = "https://files.pythonhosted.org/packages/4f/01/339573cf1023163a400b0b5d16f6d507de413b9f60be6fd1b77feeaf6737/cryptography-49.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:b87e65d263b3e5d3bb92a57e2a6638e2f31110fa7aa890c7b2dbba42248d0a3f", size = 4834612, upload-time = "2026-06-12T20:01:29.246Z" }, + { url = "https://files.pythonhosted.org/packages/71/fd/577302e213a1be9468f92d1afef66fcf1ef83d516819d9992ca547f592bd/cryptography-49.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:66ec79c3904820572d7e987abdf304281f141d37ad9a489b8e97066e7b9b6459", size = 4980804, upload-time = "2026-06-12T20:01:42.853Z" }, + { url = "https://files.pythonhosted.org/packages/1f/09/f42b1d190c5ba75f72062a387f8030d1d75f6ab035788f1d9c4b01de6525/cryptography-49.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:e5dfc1e64de5677cec922ffa8da89c546d0415bf6efdf081842e5d44c84e1f0e", size = 3810026, upload-time = "2026-06-12T20:02:39.262Z" }, + { url = "https://files.pythonhosted.org/packages/ec/9e/db72b3ae7fc9cfad53e630e56c6ae83b9b6ff0bf3718ffb8012d20b3aabf/cryptography-49.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:73a205dce83953d131a4aa1e0fd917a2fd1c5b1eef251e9d7152efefcbf5caf7", size = 4013892, upload-time = "2026-06-12T20:02:10.735Z" }, + { url = "https://files.pythonhosted.org/packages/86/12/c48a424f38db03027be9f7ed5c7dc5de9933dbee992865f98b13727a009d/cryptography-49.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:196ecd6a36e4e9aa10270393bb98d8df88fccee0bf1e5128b91ae4eb4375896d", size = 4678835, upload-time = "2026-06-12T20:02:48.743Z" }, + { url = "https://files.pythonhosted.org/packages/68/28/8a3ad4653662c93fc44dc4e5d8fd374c25c42e07b34bbfbadf49cf57a5a8/cryptography-49.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7abcee80084cda3f7691f3eb1ce480d8df49cec637b429aa35986c1de71738aa", size = 4697239, upload-time = "2026-06-12T20:02:56.03Z" }, + { url = "https://files.pythonhosted.org/packages/a8/b2/2193fc74f81aee4f9b62733133b73b5176718932ed8f2e4b03fa040480a6/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:4ae387c9cb68ea569ca17e490d66d8142b81c3cc814bf179974b7d146e490bbb", size = 4685593, upload-time = "2026-06-12T20:02:50.666Z" }, + { url = "https://files.pythonhosted.org/packages/47/f1/1d3eaa243bfc5de4a187b22aa8c048b3e4980bfbe830ac46e6bac2e66947/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:f37d847238971164fdbc68ade6f6574aecc9c0af714190e2083429ff68f4ce9d", size = 5289961, upload-time = "2026-06-12T20:01:46.468Z" }, + { url = "https://files.pythonhosted.org/packages/58/39/2d51306721330c486495853eda1c567880ff036de15a14c4b74f399934af/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:c2bc30226390d60ea19d9f82b19db005fe0452154a23c1c410c12ea801e43561", size = 4731145, upload-time = "2026-06-12T20:02:16.832Z" }, + { url = "https://files.pythonhosted.org/packages/17/50/983e838c7fd0d87fd8c969bcdd328edaf5f756e38df5281637424c155873/cryptography-49.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:07cab27cc7b7e0fd28e5e26bb9eeedde5c135c868b46de4a27845abe94af6122", size = 4321719, upload-time = "2026-06-12T20:02:52.611Z" }, + { url = "https://files.pythonhosted.org/packages/a7/f5/8f571d7e27c55bce9f76f026143bcb1e040a4233149ecca0bea5fa5dd5f7/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:b20133d204d2bb56ba047642199603876c872026ca53e79c35b83772ab2cc505", size = 4685209, upload-time = "2026-06-12T20:02:07.282Z" }, + { url = "https://files.pythonhosted.org/packages/e7/84/0e27016a6fc5a0886f797018b26aa42f40c09a82332bff77822a451deaaa/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:b970c6da94d5bb18629db453d14f2a1300f6bf59b61e9b82377931ef95504866", size = 5246285, upload-time = "2026-06-12T20:01:32.439Z" }, + { url = "https://files.pythonhosted.org/packages/11/2d/5e1fb307cb5931881516b464c98774b3f2c36b5d4bb9a2830253cf553cad/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:d8ecde755e2e91bf773fc94e8c9d730cd7f2007004cb492263a794ec3899a1c8", size = 4730441, upload-time = "2026-06-12T20:02:01.469Z" }, + { url = "https://files.pythonhosted.org/packages/e4/c0/bff5a02ee731d207d6a1ed51732549d8c53d2bc8da1d10ec6f2844201d68/cryptography-49.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e3fb64c420688e5319ae25113a354015abbd8dffbfbc41781a1ea66fc7622ac3", size = 4815869, upload-time = "2026-06-12T20:01:36.574Z" }, + { url = "https://files.pythonhosted.org/packages/b9/26/814681d14248d95d73d5c3eea0c39a94eb8302df966f670a2c60de90974b/cryptography-49.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32703d93296f5c1f4b53349ad3a250c2cae0fdecd3a3dd5d47e616d8d616af27", size = 4960948, upload-time = "2026-06-12T20:02:18.688Z" }, + { url = "https://files.pythonhosted.org/packages/4c/fe/93ecac273d3738939d023612ad12cca9a3740a5345d69fda04134c43fd96/cryptography-49.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:33cd0565932807baddb67b96dbee92f2c374b5c89dee09fd74079aeb8c8dba61", size = 3799153, upload-time = "2026-06-12T20:01:39.059Z" }, + { url = "https://files.pythonhosted.org/packages/19/2a/5bb823f5bedcf80718cea7fbc95ec5515cca3769633c4b01a32be7f30e7c/cryptography-49.0.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ec5e529fb80935c94fe7b729f9972b50e351a0e6b50aa294fd5cabb109fcc29a", size = 4025947, upload-time = "2026-06-12T20:01:25.745Z" }, + { url = "https://files.pythonhosted.org/packages/3d/df/40577043ca124e17012f408ddddaeb213b856336ac82ddb3bc915f39e29f/cryptography-49.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f78ff2c9ed8dc2d036b0f4d640e22522213d047c1b14e61205a7e55c80a494d4", size = 4692429, upload-time = "2026-06-12T20:01:53.628Z" }, + { url = "https://files.pythonhosted.org/packages/2c/99/2d13299eb3dd27b02dcfaafcc91d6b5cb3329f7cbd6d8f51921acd566c1a/cryptography-49.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:35b151772baff2c74cba7fa290ceaff4c3b11c0c881eb93eb5dbc05a7cfbba18", size = 4700968, upload-time = "2026-06-12T20:02:45.383Z" }, + { url = "https://files.pythonhosted.org/packages/a5/4d/9c0cd02f95e2602dd5e563da149ee0830abef3537be8b34dc56281ebe27a/cryptography-49.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:0f21641cf4b30fca7aee061ced0ec7ad7b073518088b7c9969a297c0ae796c69", size = 4697758, upload-time = "2026-06-12T20:01:41.13Z" }, + { url = "https://files.pythonhosted.org/packages/24/01/186c825898477d77e2324d5360fefe622ff1d8d1963ec0554e2cada8ec77/cryptography-49.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:9e82dcc8e56052715fb18b2429e3bca4823b1629136a2084fc45a9a5cecb9b64", size = 5298863, upload-time = "2026-06-12T20:02:24.579Z" }, + { url = "https://files.pythonhosted.org/packages/b8/7b/62cbbab75d0659865bf0273790031544a0b16c8072d258f9428dcd8190dc/cryptography-49.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:6f2debedf9ca60cf1d5bd466475638af5130f89965605cd818484d19987d3a21", size = 4735983, upload-time = "2026-06-12T20:01:50.14Z" }, + { url = "https://files.pythonhosted.org/packages/6c/72/3e798c064bc39e471008075d0f9bc9daf77a80879c092e4a8e170c585ed4/cryptography-49.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:8c25ceb16df5b9435f3f6a9829204985b0e0cbee3b48aacd432c7d2c850b44d9", size = 4334173, upload-time = "2026-06-12T20:01:44.743Z" }, + { url = "https://files.pythonhosted.org/packages/f0/ee/6fca21d1ac73e06f8bef71940abfd4d2f6472b4bca284d770f32bd4086f6/cryptography-49.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:28d8b15e6275f12c8a207dc309dfa957903c927d08d0cc937ee3f63f200693cc", size = 4697298, upload-time = "2026-06-12T20:02:20.918Z" }, + { url = "https://files.pythonhosted.org/packages/67/d0/a5fcd3515f0bae49a7b6d0413cc1bdccdcc1fc0047037a0d480642cdc5d6/cryptography-49.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:6fc361c34fb6aac015ce19435876635e5c6d21db31998b0920f675f131e043b8", size = 5254338, upload-time = "2026-06-12T20:02:22.737Z" }, + { url = "https://files.pythonhosted.org/packages/a0/84/84fe36f19caf857d61cb7fc9c63035a47ffabd84ea12d1d393148efa3615/cryptography-49.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:2400ef9c9e2299a25614eb1dea3db54a69b1349efd043bfac9c67630d136df36", size = 4735650, upload-time = "2026-06-12T20:02:41.389Z" }, + { url = "https://files.pythonhosted.org/packages/6c/a0/db537264e234f7273a73ec020873d6d6b39dfd8a53db78b550ca8320440e/cryptography-49.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:67e1d20ad9ef3a563c59ef22e7a8a0b8210bd26604369ea4a30a7c66aefe504e", size = 4834820, upload-time = "2026-06-12T20:01:51.847Z" }, + { url = "https://files.pythonhosted.org/packages/93/77/8df9eb486495979bccecd1062e2eaf435250e84437040295b57d09048b0b/cryptography-49.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:42b0684e0e40cf26122427802486f6d93aea593612603a94fbf260c7eb1e9c1b", size = 4967968, upload-time = "2026-06-12T20:02:12.524Z" }, + { url = "https://files.pythonhosted.org/packages/c2/e6/f60198ea8d9dfa15fff9ed4ca02ce362f6eadd9ba757dcc50634c4257b63/cryptography-49.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:026ac7423e6fa66872d3bf889be5974507da3944f866f704fa200eadacd00001", size = 3785547, upload-time = "2026-06-12T20:02:26.847Z" }, + { url = "https://files.pythonhosted.org/packages/63/d3/4a83af35d65e3fad632c926fad684c193ea4398569ccb0bbbc7fe8f5dc9a/cryptography-49.0.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:fc1e275c2f1d97b1a6450b8b0ea3ebfa6e087a611c2b26cb2404d48588abab7b", size = 3993685, upload-time = "2026-06-12T20:02:14.883Z" }, + { url = "https://files.pythonhosted.org/packages/d6/a7/f9dac0ab7f80368c56993a7bf638ef9935f825c91902798481fac0898138/cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:c83782480a4a9da4d0feb51950131ba32e12e70813848b3343f6e18c28a66838", size = 4676239, upload-time = "2026-06-12T20:02:28.793Z" }, + { url = "https://files.pythonhosted.org/packages/d7/70/2ba3769dd0ae167e2f33dfa9592d45db6ff9a61d62ca1a5b3d1bdd09068f/cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:b39efa323140595abd3ecca8529d321ae50f55f3aa3ba9cc81ea56a6011953d5", size = 4715584, upload-time = "2026-06-12T20:01:27.495Z" }, + { url = "https://files.pythonhosted.org/packages/94/64/2923570ac1c0bd3a737aa366ac3abbbbde273042308b8cde95e2364a6e6a/cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:b47db11c2c3525083296069b98ac5221907455e989ae0c2e3008bde851921615", size = 4675885, upload-time = "2026-06-12T20:01:55.49Z" }, + { url = "https://files.pythonhosted.org/packages/ab/f8/614dc7e051418cfe53d55173c1e24c6b0085e89996fe90508c2fdf769aef/cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:084ef1af862eb07ec46d25f68689f2102a9fc0e05ce7b80f14f5fe51e4eef0f6", size = 4715449, upload-time = "2026-06-12T20:02:05.469Z" }, + { url = "https://files.pythonhosted.org/packages/aa/50/a9caea39ad19c431c1a3f8a31114df65b260cdfe67786b6c7e7c040c4c44/cryptography-49.0.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:be9fcb48a55f023493482827d4f459bd263cc20efde64f204b97c123201850c6", size = 3783731, upload-time = "2026-06-12T20:02:43.319Z" }, +] + +[[package]] +name = "curl-cffi" +version = "0.15.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "cffi" }, + { name = "rich" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/48/5b/89fcfebd3e5e85134147ac99e9f2b2271165fd4d71984fc65da5f17819b7/curl_cffi-0.15.0.tar.gz", hash = "sha256:ea0c67652bf6893d34ee0f82c944f37e488f6147e9421bef1771cc6545b02ded", size = 196437, upload-time = "2026-04-03T11:12:31.525Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5e/42/54ddd442c795f30ce5dd4e49f87ce77505958d3777cd96a91567a3975d2a/curl_cffi-0.15.0-cp310-abi3-macosx_10_9_x86_64.whl", hash = "sha256:bda66404010e9ed743b1b83c20c86f24fe21a9a6873e17479d6e67e29d8ded28", size = 2795267, upload-time = "2026-04-03T11:11:46.48Z" }, + { url = "https://files.pythonhosted.org/packages/83/2d/3915e238579b3c5a92cead5c79130c3b8d20caaba7616cc4d894650e1d6b/curl_cffi-0.15.0-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:a25620d9bf989c9c029a7d1642999c4c265abb0bad811deb2f77b0b5b2b12e5b", size = 2573544, upload-time = "2026-04-03T11:11:47.951Z" }, + { url = "https://files.pythonhosted.org/packages/2a/b3/9d2f1057749a1b07ba1989db3c1503ce8bed998310bae9aea2c43aa64f20/curl_cffi-0.15.0-cp310-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:582e570aa2586b96ed47cf4a17586b9a3c462cbe43f780487c3dc245c6ef1527", size = 10515369, upload-time = "2026-04-03T11:11:50.126Z" }, + { url = "https://files.pythonhosted.org/packages/b5/1d/6d10dded5ce3fd8157e558ebd97d09e551b77a62cdc1c31e93d0a633cee5/curl_cffi-0.15.0-cp310-abi3-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:838e48212447d9c81364b04707a5c861daf08f8320f9ecb3406a8919d1d5c3b3", size = 10160045, upload-time = "2026-04-03T11:11:52.664Z" }, + { url = "https://files.pythonhosted.org/packages/5c/12/c70b835487ace3b9ba1502631912e3440082b8ae3a162f60b59cb0b6444d/curl_cffi-0.15.0-cp310-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2b6c847d86283b07ae69bb72c82eb8a59242277142aa35b89850f89e792a02fc", size = 11090433, upload-time = "2026-04-03T11:11:55.049Z" }, + { url = "https://files.pythonhosted.org/packages/ea/0d/78edcc4f71934225db99df68197a107386d59080742fc7bf6bb4d007924f/curl_cffi-0.15.0-cp310-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:9e5e69eee735f659287e2c84444319d68a1fa68dd37abf228943a4074864283a", size = 10479178, upload-time = "2026-04-03T11:11:57.685Z" }, + { url = "https://files.pythonhosted.org/packages/5b/84/1e101c1acb1ea2f0b4992f5c3024f596d8e21db0d53540b9d583f673c4e7/curl_cffi-0.15.0-cp310-abi3-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:aa1323950224db24f4c510d010b3affa02196ca853fb424191fa917a513d3f4b", size = 10317051, upload-time = "2026-04-03T11:12:00.295Z" }, + { url = "https://files.pythonhosted.org/packages/28/42/8ef236b22a6c23d096c85a1dc507efe37bfdfc7a2f8a4b34efb590197369/curl_cffi-0.15.0-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:41f80170ba844009273b2660da1964ec31e99e5719d16b3422ada87177e32e13", size = 11299660, upload-time = "2026-04-03T11:12:02.791Z" }, + { url = "https://files.pythonhosted.org/packages/1d/01/56aeb055d962da87a1be0d74c6c644e251c7e88129b5471dc44ac724e678/curl_cffi-0.15.0-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:1977e1e12cfb5c11352cbb74acef1bed24eb7d226dab61ca57c168c21acd4d61", size = 11945049, upload-time = "2026-04-03T11:12:05.912Z" }, + { url = "https://files.pythonhosted.org/packages/d8/8c/2abf99a38d6340d66cf0557e0c750ef3f8883dfc5d450087e01c85861343/curl_cffi-0.15.0-cp310-abi3-win_amd64.whl", hash = "sha256:5a0c1896a0d5a5ac1eb89cd24b008d2b718dd1df6fd2f75451b59ca66e49e572", size = 1661649, upload-time = "2026-04-03T11:12:07.948Z" }, + { url = "https://files.pythonhosted.org/packages/3d/39/dfd54f2240d3a9b96d77bacc62b97813b35e2aa8ecf5cd5013c683f1ba96/curl_cffi-0.15.0-cp310-abi3-win_arm64.whl", hash = "sha256:a6d57f8389273a3a1f94370473c74897467bcc36af0a17336989780c507fa43d", size = 1410741, upload-time = "2026-04-03T11:12:10.073Z" }, + { url = "https://files.pythonhosted.org/packages/19/6a/c24df8a4fc22fa84070dcd94abeba43c15e08cc09e35869565c0bad196fd/curl_cffi-0.15.0-cp313-abi3-android_24_arm64_v8a.whl", hash = "sha256:4682dc38d4336e0eb0b185374db90a760efde63cbea994b4e63f3521d44c4c92", size = 7190427, upload-time = "2026-04-03T11:12:12.142Z" }, + { url = "https://files.pythonhosted.org/packages/11/56/132225cb3491d07cc6adcce5fe395e059bde87c68cff1ef87a31c88c7819/curl_cffi-0.15.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:967ad7355bd8e9586f8c2d02eaa99953747549e7ea4a9b25cd53353e6b67fe6d", size = 2795723, upload-time = "2026-04-03T11:12:13.668Z" }, + { url = "https://files.pythonhosted.org/packages/07/8f/f4f83cd303bef7e8f1749512e5dd157e7e5d08b0a36c8211f9640a2757bf/curl_cffi-0.15.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7e63539d0d839d0a8c5eacf86229bc68c57803547f35e0db7ee0986328b478c3", size = 2573739, upload-time = "2026-04-03T11:12:15.08Z" }, + { url = "https://files.pythonhosted.org/packages/e8/5c/643d65c7fc9acd742876aa55c2d7823c438cb7665810acd2e66c9976c4d9/curl_cffi-0.15.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:08c799b89740b9bc49c09fbc3d5907f13ac1f845ca52620507ef9466d4639dd5", size = 10521046, upload-time = "2026-04-03T11:12:17.034Z" }, + { url = "https://files.pythonhosted.org/packages/7f/0b/9b8037113c93f4c5323096163471fa7c35c7676c3f608eeaf1287cd99d58/curl_cffi-0.15.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7b7a92767a888ee90147e18964b396d8435ff42737030d6fb00824ffd6094805", size = 11096115, upload-time = "2026-04-03T11:12:19.694Z" }, + { url = "https://files.pythonhosted.org/packages/5f/96/fff2fcbd924ef4042e0d67379f751a8a4e3186a91e75e35a4cf218b306ee/curl_cffi-0.15.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:829cc357061ecb99cc2d406301f609a039e05665322f5c025ec67c38b0dc49ce", size = 11305346, upload-time = "2026-04-03T11:12:22.151Z" }, + { url = "https://files.pythonhosted.org/packages/53/1b/304b253a45ab28691c8c5e8cca1e6cbb9cf8e46dfceae4648dd536f75e73/curl_cffi-0.15.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:408d6f14e346841cd889c2e0962832bb235ba3b6749ebf609f347f747da5e60f", size = 11949834, upload-time = "2026-04-03T11:12:24.986Z" }, + { url = "https://files.pythonhosted.org/packages/5a/ff/4723d92f08259c707a974aba27a08d0a822b9555e35ca581bf18d055a364/curl_cffi-0.15.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b624c7ce087bfda967a013ed0a64702a525444e5b6e97d23534d567ccc6525aa", size = 1702771, upload-time = "2026-04-03T11:12:28.201Z" }, + { url = "https://files.pythonhosted.org/packages/59/8c/36bbe06d66fa2b765e4a07199f643a59a9cd1a754207a96335402a9520f4/curl_cffi-0.15.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0b6c0543b993996670e9e4b78e305a2d60809d5681903ffb5568e21a387434d3", size = 1466312, upload-time = "2026-04-03T11:12:30.054Z" }, +] + +[[package]] +name = "dopey" +version = "0.1.0" +source = { editable = "." } +dependencies = [ + { name = "cryptography" }, + { name = "exchange-calendars" }, + { name = "requests" }, + { name = "tzdata" }, + { name = "yfinance" }, +] + +[package.optional-dependencies] +desktop = [ + { name = "pystray" }, + { name = "pywebview" }, +] +dev = [ + { name = "pytest" }, + { name = "pyyaml" }, + { name = "ruff" }, +] + +[package.metadata] +requires-dist = [ + { name = "cryptography", specifier = "==49.0.0" }, + { name = "exchange-calendars", specifier = "==4.13.2" }, + { name = "pystray", marker = "extra == 'desktop'", specifier = "==0.19.5" }, + { name = "pytest", marker = "extra == 'dev'", specifier = "==9.1.1" }, + { name = "pywebview", marker = "extra == 'desktop'", specifier = "==6.2.1" }, + { name = "pyyaml", marker = "extra == 'dev'", specifier = "==6.0.3" }, + { name = "requests", specifier = "==2.34.2" }, + { name = "ruff", marker = "extra == 'dev'", specifier = "==0.15.20" }, + { name = "tzdata", specifier = "==2026.2" }, + { name = "yfinance", specifier = "==1.5.1" }, +] +provides-extras = ["desktop", "dev"] + +[[package]] +name = "exchange-calendars" +version = "4.13.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "korean-lunar-calendar" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "numpy", version = "2.5.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "pandas" }, + { name = "pyluach" }, + { name = "toolz" }, + { name = "tzdata" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/47/73/460b0ece4e7444e3098b7738974c822787e134578d069d3a88b4be17d50a/exchange_calendars-4.13.2.tar.gz", hash = "sha256:a9459425dd64142cd54fbc639847403c7e0c33d60fbc326c94fc1d6bd127f002", size = 4137321, upload-time = "2026-03-10T03:24:38.536Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c8/4c/0469b40057bc9f8d9594dcc6024202626b981ae4b52dfcd304552e8e1c3a/exchange_calendars-4.13.2-py3-none-any.whl", hash = "sha256:fc5a2ad0d61b5c3a6539a3061cd4cbb55c59f4a903455cec7926e4b798919996", size = 213306, upload-time = "2026-03-10T03:24:37.055Z" }, +] + +[[package]] +name = "idna" +version = "3.18" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "korean-lunar-calendar" +version = "0.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d5/9f/6160e95f3934026194dc7a0b5c78fe5c6e74761830ddea3e9cfc38e3f983/korean_lunar_calendar-0.4.0.tar.gz", hash = "sha256:be56f27bc0594fdbbdf7bbe00f504a9f929a31e311bd7d9bb93561b645afade7", size = 13425, upload-time = "2026-06-15T14:19:57.163Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/88/f56033b09fdca5c8c5e5fc2f3513950a05cd62caa9bd8e6d2750a887c86e/korean_lunar_calendar-0.4.0-py3-none-any.whl", hash = "sha256:c042e20de0bb702add6bec8d0f6da1ea8d3b170838e63846f70420cf341fe4e7", size = 11323, upload-time = "2026-06-15T14:19:56.089Z" }, +] + +[[package]] +name = "markdown-it-py" +version = "4.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49", size = 82454, upload-time = "2026-05-07T12:08:28.36Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687, upload-time = "2026-05-07T12:08:27.182Z" }, +] + +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, +] + +[[package]] +name = "multitasking" +version = "0.0.13" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/be/c3/ac2cc9307fb15cc28ed6d4a9266b216c83ee7fe64299f0264047982bce88/multitasking-0.0.13.tar.gz", hash = "sha256:d896b5df877c9ca5eeddbf0e5994124694d6cb535aba698fb23344c7025155a1", size = 20585, upload-time = "2026-04-23T12:14:15.049Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d3/1c/24dbf69b247f287401c904a396233a43c89fd4fb9b7cd2e50e430e9cd57c/multitasking-0.0.13-py3-none-any.whl", hash = "sha256:ec9243af140c67bfe52dc98d7173c294512735a88e8425c458b250db99dc2b48", size = 16380, upload-time = "2026-04-23T12:14:13.776Z" }, +] + +[[package]] +name = "numpy" +version = "2.4.6" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.12' and sys_platform == 'win32'", + "python_full_version < '3.12' and sys_platform == 'emscripten'", + "python_full_version < '3.12' and sys_platform != 'emscripten' and sys_platform != 'win32'", +] +sdist = { url = "https://files.pythonhosted.org/packages/d0/ad/fed0499ce6a338d2a03ebae59cd15093910c8875328855781952abf6c2fe/numpy-2.4.6.tar.gz", hash = "sha256:f3a3570c4a2a16746ac2c31a7c7c7b0c186b95ce902e33db6f28094ed7387dda", size = 20735807, upload-time = "2026-05-18T23:37:14.07Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/49/ec46835a70be8fa6446c495126ac84fdb28cb2558e1620ffb87a10c8b64c/numpy-2.4.6-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:0280e0356c0829a18d9de1cb7eee50ec22ca639878d7240307ca0943d73cd2c4", size = 16969194, upload-time = "2026-05-18T23:33:13.503Z" }, + { url = "https://files.pythonhosted.org/packages/0e/0d/f5957185c0ee2f3e12f78715aa9e3b353fd83633316c8532b38faa37e3f6/numpy-2.4.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:110f8b71aacb688ec69062bb7f6938a0f8acb01b7c1c4beb453c65b6d234584d", size = 14964111, upload-time = "2026-05-18T23:33:17.795Z" }, + { url = "https://files.pythonhosted.org/packages/ad/40/40a40ee0ddf7ceb782c49af278894b686e586d65d8c1889c8b5da01a3d7d/numpy-2.4.6-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:4cfe66903cc32a9921a6733d96b19bb6abf310397581bbad89c228f5abaf0ee8", size = 5469159, upload-time = "2026-05-18T23:33:20.654Z" }, + { url = "https://files.pythonhosted.org/packages/63/13/f9a8046535cb21deae82f8d03de9617e08882d274fad2539630761888228/numpy-2.4.6-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:8155154c7c691289fe18f510b5d4657c68c67989f293f0535a91360392ff6538", size = 6798936, upload-time = "2026-05-18T23:33:22.987Z" }, + { url = "https://files.pythonhosted.org/packages/33/a8/6fa8c1a345a8c85dbb21932c447bee07c30a2c2a3f31e369c0a84b300147/numpy-2.4.6-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ab0a9c4ffb1a6d95ef519fe4247dba8eb6b18ad93999f76b7f657039acabd47", size = 15966692, upload-time = "2026-05-18T23:33:26.62Z" }, + { url = "https://files.pythonhosted.org/packages/02/03/74fe2a4cb3817d94d86402f2506554130a2f01414e299b5a843e5a8a957f/numpy-2.4.6-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:89cd468399cfd2504718f0ba50e410dca55a170b61a02ad92bb18c8a65186e93", size = 16918164, upload-time = "2026-05-18T23:33:29.955Z" }, + { url = "https://files.pythonhosted.org/packages/c5/80/3615be3313f7e7696609bc194b9f0101da809df79e859bdb84e0cd043f46/numpy-2.4.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c2d37ab77531417474168eb79d6d80b14f821a966818505d03013d0833edb7a8", size = 17322877, upload-time = "2026-05-18T23:33:34.724Z" }, + { url = "https://files.pythonhosted.org/packages/ca/ac/a691e0fe2675e370d0e08ff905adc49a1c8830e8cae03efe4477e92cd55d/numpy-2.4.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f407cb6b8e9d6d8c626bc73c945db1706035af8fd632295547bf1c9e46d092d6", size = 18651487, upload-time = "2026-05-18T23:33:38.217Z" }, + { url = "https://files.pythonhosted.org/packages/15/a7/9bc1cd626d7bf6869bfedf27b91b6ab5dd607758bf8e959d6fa80c6a59cb/numpy-2.4.6-cp311-cp311-win32.whl", hash = "sha256:ddea102b48f9e339f3948bf22040944184627a30fdf7f858667673b9c5f033c8", size = 6233945, upload-time = "2026-05-18T23:33:41.331Z" }, + { url = "https://files.pythonhosted.org/packages/c5/31/7fc6239c12bce7e931463251cca4426c465e1876ba3cc785402ef4dd8f4e/numpy-2.4.6-cp311-cp311-win_amd64.whl", hash = "sha256:1e254a00cdf42b1e4d5b3d68d33af63268d41340d8885df2ab6470f2e1500147", size = 12608406, upload-time = "2026-05-18T23:33:44.131Z" }, + { url = "https://files.pythonhosted.org/packages/27/83/140f85a466595a16382996a1bf06b2b54bcd597488921b0c9daaeeda72af/numpy-2.4.6-cp311-cp311-win_arm64.whl", hash = "sha256:ed9749eef4cbd126da3dc1d6bcb3a57f5eb7ac6a6484146bdbf743f552dfc577", size = 10479528, upload-time = "2026-05-18T23:33:50.725Z" }, + { url = "https://files.pythonhosted.org/packages/95/2a/3d7b5ac8aac24feaf9ad7ed58f45b0bbc06d37e4338ae84c9f2298b570f9/numpy-2.4.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:001fbb8e08d942dd57599e781f2472269ee7f2755fae407b4f67b2f0b17da3f1", size = 16689119, upload-time = "2026-05-18T23:33:54.065Z" }, + { url = "https://files.pythonhosted.org/packages/ea/12/92c4c131527599e8288d6918e888d88726f84d805d784b771f32408aeaef/numpy-2.4.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ebfb099f8dcf083deef3ac1ca4c1503f387cf76296fcb3816b66f5ecb5f54fdb", size = 14699246, upload-time = "2026-05-18T23:33:57.621Z" }, + { url = "https://files.pythonhosted.org/packages/ad/fe/c0a6b7b2ca128a8fb228575147073b660656734b8ebe4d76c8fd748dcc79/numpy-2.4.6-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:3213d622a0283a39a93d188f3cf72b26862df52fbb4ca3697f51705016523d41", size = 5204410, upload-time = "2026-05-18T23:34:00.302Z" }, + { url = "https://files.pythonhosted.org/packages/f3/d4/9770d14ba719432bb90a421bfd443872ed0f70f7264b64bec12ea363d5fd/numpy-2.4.6-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:357cc07a6d7b0b182ff02249616a03742827ebb1277546b5c7cd7f7620a45698", size = 6551240, upload-time = "2026-05-18T23:34:02.852Z" }, + { url = "https://files.pythonhosted.org/packages/c9/c6/50a46a6205feba2343f1d6d17438107c5dc491ed1c736e6ea68689fd906b/numpy-2.4.6-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f9fb9157b4ce2971008323afe46053787b526ef624fea915b261468a8421a0f", size = 15671012, upload-time = "2026-05-18T23:34:05.485Z" }, + { url = "https://files.pythonhosted.org/packages/99/60/14115e6364fa676c5397c2ad3004e527e9aa487abf5d0706ec81bbd08529/numpy-2.4.6-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:90f9849678c75fe7afa2d348ac842c168b0a4d3d61919687216dfc547976d853", size = 16645538, upload-time = "2026-05-18T23:34:09.265Z" }, + { url = "https://files.pythonhosted.org/packages/ae/c5/693cbe59e57db94d2231fa519ca3978dc9e19da5a8f088588f5c6e947ff2/numpy-2.4.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c1a2af6c6ef86344a6b0db6b97834208bf598db514f2b155042439b62605601a", size = 17020706, upload-time = "2026-05-18T23:34:13.053Z" }, + { url = "https://files.pythonhosted.org/packages/ef/fc/85b7c4eff9b4966ade25c2273cf7e7012e92366c032058653934b37de044/numpy-2.4.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e5805d5a22fd19c8ccff10a9561f9df94436b0545619ea579db2d3c35294bce2", size = 18368541, upload-time = "2026-05-18T23:34:17.024Z" }, + { url = "https://files.pythonhosted.org/packages/f6/81/e1b27545deedce7f4a0b348618c6b62d74e36a4dc9ccd42f3eb2f85eee32/numpy-2.4.6-cp312-cp312-win32.whl", hash = "sha256:e3eeb0aabd6bd5ce64faae67e9935203a6991b4bc2a485a767fbafb2c5125f45", size = 5962825, upload-time = "2026-05-18T23:34:20.3Z" }, + { url = "https://files.pythonhosted.org/packages/ab/ca/feab00bd44aa5fe1ad2c18f08b4d3bb92e26484b0b1d1443897809ed528c/numpy-2.4.6-cp312-cp312-win_amd64.whl", hash = "sha256:d8e8286dd7cea7895157318d1b91cdacac64c479f3cbc8dce548331728484751", size = 12321687, upload-time = "2026-05-18T23:34:23.095Z" }, + { url = "https://files.pythonhosted.org/packages/63/cf/5a6d34850a39d1093558564f77ee8e8e0bee5061151b8f05a55711001ec7/numpy-2.4.6-cp312-cp312-win_arm64.whl", hash = "sha256:4081eb135ac24158bd51cdfbef16f1c64df7063b1143f24731387137c092bec8", size = 10221482, upload-time = "2026-05-18T23:34:25.876Z" }, + { url = "https://files.pythonhosted.org/packages/fb/82/bdab26d7438c6791ca31b7c024ca37c1eab8b726ba236129005cd4a06e45/numpy-2.4.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:511dbaf848decaaaf4b4ca48032619fb3138710c4bf7da7617765edad1ef96b0", size = 16684648, upload-time = "2026-05-18T23:34:29.41Z" }, + { url = "https://files.pythonhosted.org/packages/1b/30/a80189bcc7f5e4258b3fbc3968d909d1756f54d023299ecc39ad6fdb9ef8/numpy-2.4.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bf162abab1c1a736333192707cef898e735a5ca00f38f27eeedf44b39d9e85eb", size = 14693902, upload-time = "2026-05-18T23:34:33.013Z" }, + { url = "https://files.pythonhosted.org/packages/97/12/70b5d0d7c15e1ebb8a6a84a8caa1d19e181d84fb58bb6d70aca29099dec1/numpy-2.4.6-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:043191bfa8eab18c776647b62723ac9dddece59743b13f49b2016094129c2b3f", size = 5198992, upload-time = "2026-05-18T23:34:36.132Z" }, + { url = "https://files.pythonhosted.org/packages/ba/8c/ebd2a8f8a83541f8d38cc5667e8c2b69cecfd30da6e45693e8158857d44b/numpy-2.4.6-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:6180d8b35af935aed8ece3a85e0a43f87393ae0ac87c8d2c8bd2c993f7270ef3", size = 6546944, upload-time = "2026-05-18T23:34:38.484Z" }, + { url = "https://files.pythonhosted.org/packages/bb/c5/7b863a97a91671a0338f4253bd3b5a3d3852f0692dae91711c9f4a10e787/numpy-2.4.6-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:72fbe16c6fac95aedf5937fa873445cec2110be35d8a4e9433d7501fd98dae6b", size = 15669392, upload-time = "2026-05-18T23:34:41.257Z" }, + { url = "https://files.pythonhosted.org/packages/a5/9d/3584b9984ca4c047aea75214ce1a4c4c73d849bd71b604264b7f5653f8a8/numpy-2.4.6-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a7830bab239b79cda9c08c2da014761cafb48da6150e1da17ac06283f43b6089", size = 16633220, upload-time = "2026-05-18T23:34:45.075Z" }, + { url = "https://files.pythonhosted.org/packages/05/ae/7c67fba23bd98caec7c99261f3a16072ade14813486b0282cb29846de832/numpy-2.4.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ef4aea96ce4d3b074422cb4f2f64e216bf9e213004bb58ecfdf50ea02ea8eb9a", size = 17020800, upload-time = "2026-05-18T23:34:49.065Z" }, + { url = "https://files.pythonhosted.org/packages/d9/5d/3b6725cb31d983c5e66916f5d36f6d7e5521129e4c4404d64f918292a5b6/numpy-2.4.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:dfa20cc6ca228e6b155b11da03825975ce66aea520985dbbddf0f2a5a495c605", size = 18357600, upload-time = "2026-05-18T23:34:52.709Z" }, + { url = "https://files.pythonhosted.org/packages/f7/da/2ccc6c2fe8898dee01d90c75c5f5f914a23daf99e3e0f59516a08760c8b5/numpy-2.4.6-cp313-cp313-win32.whl", hash = "sha256:56b39e5e0622a09a25bf5baf62f4bcf0cb8a41ae6e2819cf49bbc5a74c083f91", size = 5961134, upload-time = "2026-05-18T23:34:55.618Z" }, + { url = "https://files.pythonhosted.org/packages/b5/cd/9cc4dc876fb065d5c220aae4d5e14826b2715331bb7618ce1fb07a679d99/numpy-2.4.6-cp313-cp313-win_amd64.whl", hash = "sha256:c4fc99836233ea196540b17ab0983aff60ed07941751930f5f4d05bc3b3b7359", size = 12318598, upload-time = "2026-05-18T23:34:58.928Z" }, + { url = "https://files.pythonhosted.org/packages/39/1e/c0bcba1f8694116485fe28fd1be698c278fcda4141c5b0e53a2aed8b12a8/numpy-2.4.6-cp313-cp313-win_arm64.whl", hash = "sha256:a7c711e21628b52034bb5ab8d1bce291f752fcc5e92accc615778acee1ff4778", size = 10222272, upload-time = "2026-05-18T23:35:02.167Z" }, + { url = "https://files.pythonhosted.org/packages/63/6d/cc5619247c8f4204e507f5883528372e4ac4bb189e579fb859a12e480b1f/numpy-2.4.6-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:112b06a867b235ef466ed3508ddf0238050df9c727cafb5301ac385b899189a1", size = 14821197, upload-time = "2026-05-18T23:35:05.468Z" }, + { url = "https://files.pythonhosted.org/packages/00/58/f1c39161c87d9e9bed660f1ed4bafc0e403d5ec9650b6dd77aead07d489b/numpy-2.4.6-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:eaf7fa2de5c0be8ae6ff8e9bea2ccd725e980541244521d8d4b5f3354a27babe", size = 5326287, upload-time = "2026-05-18T23:35:08.693Z" }, + { url = "https://files.pythonhosted.org/packages/af/57/3917ab0fd97f271a8694513581b8a36c655f111c446852c302f04ccdb6fc/numpy-2.4.6-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:7265a2f3d436e54ef9f2b52b5c937e6be778781bd97a590319d7348f1c1ca997", size = 6646763, upload-time = "2026-05-18T23:35:11.459Z" }, + { url = "https://files.pythonhosted.org/packages/eb/0f/037e64c494b67581ae18193d770adef354c41f3f2c8ebf865602d949bf8f/numpy-2.4.6-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f74a575920ab21fe304421a3fc28793d82e299cae9eccb37084e9fc7f3617c20", size = 15728070, upload-time = "2026-05-18T23:35:14.79Z" }, + { url = "https://files.pythonhosted.org/packages/21/a6/5d2bae9c9542eb4df16dc9c46dc79c186e9bad53805dfa5399a6023c6db0/numpy-2.4.6-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ede83e07a75dd06bc501566c1eca2afc0d61677c1472ac9ad93fdee6e638a48d", size = 16681752, upload-time = "2026-05-18T23:35:18.836Z" }, + { url = "https://files.pythonhosted.org/packages/92/14/23d1dfb410ae362cd59ce53e936b1513d545eb40db3949ced632e19a459e/numpy-2.4.6-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:68bb27509ac1b9a3443094260f6326150663b06abe40b73a2f81160623da5b67", size = 17086024, upload-time = "2026-05-18T23:35:22.52Z" }, + { url = "https://files.pythonhosted.org/packages/4b/6e/23595a2c642cdf3bc567877064bdd7f91c8b0038a4453cf2daf7248eafe9/numpy-2.4.6-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:a0df0043bdb289bde1f62da130d20df23d58b45429f752bc7a8fc5325a225ecd", size = 18403398, upload-time = "2026-05-18T23:35:26.398Z" }, + { url = "https://files.pythonhosted.org/packages/8a/90/0ac3bc947217e66dec77e7cbc6a1979d1af70b6461b82f620d3bccd5e4c8/numpy-2.4.6-cp313-cp313t-win32.whl", hash = "sha256:29a287e0cf63ff528da061de6b9f64a4618da591ca1046aafc54062e40ca7eab", size = 6084971, upload-time = "2026-05-18T23:35:29.387Z" }, + { url = "https://files.pythonhosted.org/packages/77/71/5673e351671a1d2bd6063b91b44f70c0affea7d1516fa7a6572941ba4aa1/numpy-2.4.6-cp313-cp313t-win_amd64.whl", hash = "sha256:25c692919ac5a01f170a3bfcd62d745b24fd095c353d50812637d6fcab442e75", size = 12458532, upload-time = "2026-05-18T23:35:32.175Z" }, + { url = "https://files.pythonhosted.org/packages/3f/88/19d3503c5046e688f049274b27a3ef3d771152fa80d3ba3d01a3dff61abe/numpy-2.4.6-cp313-cp313t-win_arm64.whl", hash = "sha256:1e978ec1e8bd0e0e4de6bb75de9d30cbb74db6b6a2bb727618613703ca0167dd", size = 10291881, upload-time = "2026-05-18T23:35:35.465Z" }, + { url = "https://files.pythonhosted.org/packages/f8/91/3ab2044d05fd16d343c5ac2e69b127f1b2854040dd20b193257c78028bd3/numpy-2.4.6-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:06ca2f61ec4385a07a6977c55ba998a4466c123642b4a32694d3128fce18c079", size = 16683458, upload-time = "2026-05-18T23:35:38.353Z" }, + { url = "https://files.pythonhosted.org/packages/8e/62/764ce66fa4147ae6d73071a3abf804ffe606f174618697c571acdf26a7c9/numpy-2.4.6-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:38efbc8de75c7a0fc1ac190162d892787f3f47b57cc291231aafee36b80982b7", size = 14704559, upload-time = "2026-05-18T23:35:42.14Z" }, + { url = "https://files.pythonhosted.org/packages/60/61/23f27c172f022e04025b7dc2367f4d63c1a398120607ec896228649a6f48/numpy-2.4.6-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:d581b735e177fdcdce6fed8e7e8880a3fb6ee4e3653a3ac6af01c6f4c03effc5", size = 5209716, upload-time = "2026-05-18T23:35:45.377Z" }, + { url = "https://files.pythonhosted.org/packages/03/71/21cf70dc6ea3e3acb95fc53a265b2fc248b981f0194ceb5b475271b8809d/numpy-2.4.6-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:0a041d3d761dc3c35cc56ce0351506a02bcbc25f7b169f652435141a17db9096", size = 6543947, upload-time = "2026-05-18T23:35:47.926Z" }, + { url = "https://files.pythonhosted.org/packages/d5/91/64288395ee1799bd2e0b04a305dce9666da90c961e1f3fe982a05ee1c036/numpy-2.4.6-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:40fdc1ae7125e518ea98e53e69a4ebc27e1fd50510c47b7ea130cf21e5e1d42b", size = 15685197, upload-time = "2026-05-18T23:35:50.863Z" }, + { url = "https://files.pythonhosted.org/packages/f3/eb/ebffaa97dc55502df69584a8f0dcf07f69a3e0b3e2323670a2722db9aa39/numpy-2.4.6-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a2c306dea656c12c68f51f4cea133cbe78ca7435eb28c735eac1d3ebe73be6e8", size = 16638245, upload-time = "2026-05-18T23:35:54.752Z" }, + { url = "https://files.pythonhosted.org/packages/b8/0b/54f9da33128d7e350fab89c7455902eeae70349ee52bddb448dc4a576f45/numpy-2.4.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:33111801a01c12a8a1e3721f0a9232f8cfc8ae2c6b7098167e6f623c6073f402", size = 17036587, upload-time = "2026-05-18T23:35:58.355Z" }, + { url = "https://files.pythonhosted.org/packages/b6/f0/fdebc1052db1cc37c64beb22072d67cd6d1c71adca1299f53dec2b5e20d3/numpy-2.4.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ae506e6902902557576a26ff33eda8695e7ecb3cb36c3b573a0765dee114ebdb", size = 18363226, upload-time = "2026-05-18T23:36:02.845Z" }, + { url = "https://files.pythonhosted.org/packages/aa/b4/298628d98c72b57e57f7165ae6a481a1deaf6f3c28262a6e4c739c275930/numpy-2.4.6-cp314-cp314-win32.whl", hash = "sha256:aaf159caa35993cb1f56fb9b8e4610d35758e7ca005412eb1daa856a78c9c4b1", size = 6010196, upload-time = "2026-05-18T23:36:05.92Z" }, + { url = "https://files.pythonhosted.org/packages/df/ac/46de6dda46478f7942f839e094970be2d4a861e005c4b3bf07c92e291a09/numpy-2.4.6-cp314-cp314-win_amd64.whl", hash = "sha256:b507f5c4c1d508876d1819b6bf9a49d365b96320b5d4993426b33a23ca4b8261", size = 12450334, upload-time = "2026-05-18T23:36:09.107Z" }, + { url = "https://files.pythonhosted.org/packages/78/92/b8b798ac784102c0da830d2257d59358e3d3d90d1e2b3f2575dad976c5cf/numpy-2.4.6-cp314-cp314-win_arm64.whl", hash = "sha256:6f41ae150c4e32db4f3310cdaf64b1593a03dbabe29eec77fc9b50fe64061df6", size = 10495678, upload-time = "2026-05-18T23:36:12.766Z" }, + { url = "https://files.pythonhosted.org/packages/30/34/ec28d1aa8115971537c01469ab2011ee96827930f0a124de1000cc2a7ed7/numpy-2.4.6-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ece3d2cfe132e7d51f44a832b303895e6f2d499c5e74dfbdb06ee246147a304a", size = 14823672, upload-time = "2026-05-18T23:36:16.473Z" }, + { url = "https://files.pythonhosted.org/packages/16/bd/f6d1fede4e54e8042a7ff97bb495510f3c220f94bcd9e8b228e87c92cc0d/numpy-2.4.6-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:e3e5193ef5a3dc73bceee50f7fdc2c90dbb76c42df8d8fae3d1067a583df579e", size = 5328731, upload-time = "2026-05-18T23:36:19.767Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f0/e105b9e2fd728a9910103884decd6951d9dd73896b914a98d9a231de02ee/numpy-2.4.6-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:17f9ade344e7d9b464a084d69bcf18fc691cb1db67c62ed80820bf4926d78f0e", size = 6649805, upload-time = "2026-05-18T23:36:22.266Z" }, + { url = "https://files.pythonhosted.org/packages/82/dd/1206a7ca6ab15e3f02069707ca96222e202af681bb73756da7527f3cb837/numpy-2.4.6-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9cd5ffd25db4e7ba6a375693b3fc0fc1791ec636c17db3720da19bde7180ec43", size = 15730496, upload-time = "2026-05-18T23:36:25.713Z" }, + { url = "https://files.pythonhosted.org/packages/51/e7/38d3ea825dcab85a591734decb2f6c67caa7c8367d374df1a1c3842f9b07/numpy-2.4.6-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7d92c3819208a60205a12a245c91ad70cb0a85336659b19b834205573ac8456e", size = 16679616, upload-time = "2026-05-18T23:36:29.652Z" }, + { url = "https://files.pythonhosted.org/packages/93/b7/caabfdf53edf663e0b4eb74d7d405d83baef09eb5e83bcd32d601d72b93e/numpy-2.4.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e85b752a1e912b70eaad4fafbd4d1238007ab221de2009b9a2f5ae7461239895", size = 17085145, upload-time = "2026-05-18T23:36:33.449Z" }, + { url = "https://files.pythonhosted.org/packages/f9/45/68d7c33a6bcf3e5aa3bdbd57a367e6f615286dfd6482f97e8ffeb734306e/numpy-2.4.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:29cb7f67d10b479ff07c17d33e39f78c07f71c40ef30d63c153d340e96cd3fb4", size = 18403813, upload-time = "2026-05-18T23:36:37.369Z" }, + { url = "https://files.pythonhosted.org/packages/9c/50/0753655aa844c99cd9e018aacf76f130f1bd81d881bb74bc0aef5d73a8ba/numpy-2.4.6-cp314-cp314t-win32.whl", hash = "sha256:260a5d70215b61ab4fadf5c7baacd64821842975eea312125ed3c39a6391b063", size = 6156982, upload-time = "2026-05-18T23:36:40.817Z" }, + { url = "https://files.pythonhosted.org/packages/b2/d4/7c67becf668f973cb490cec3e98dfd799d866f9c989a54d355672cfa0db6/numpy-2.4.6-cp314-cp314t-win_amd64.whl", hash = "sha256:81a1cca95ed5bb92aa8b10dd2cdc9a0d3853a50fad926c28b5d7e8ea54389627", size = 12638908, upload-time = "2026-05-18T23:36:43.996Z" }, + { url = "https://files.pythonhosted.org/packages/43/bb/e1c71a4295b1b1d1393d50dbb4f2a36283c6859d9d3892e84f00ec5a91d5/numpy-2.4.6-cp314-cp314t-win_arm64.whl", hash = "sha256:0c9136e14ed34a9e343a31c533d78a9813a69a3148332bce5e9821cb2f996e66", size = 10565867, upload-time = "2026-05-18T23:36:47.114Z" }, + { url = "https://files.pythonhosted.org/packages/de/12/b422cc84439adc0d00de605bf4a308890ae5c26f2c71fbd73e5d08fbb0dd/numpy-2.4.6-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:55cced7c52e981362f708ad635198e97a752dfba412cc03c23bbf3bd8d5cd662", size = 16847511, upload-time = "2026-05-18T23:36:50.673Z" }, + { url = "https://files.pythonhosted.org/packages/44/53/f481bef68011740f8849418d82db07230e825013f31f4eef5ba5b805316a/numpy-2.4.6-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:d6da64deb6b8ed903e7560180a92f2d804ee1ba5eeb849ac2748b8c1aba1f6d7", size = 14889064, upload-time = "2026-05-18T23:36:53.879Z" }, + { url = "https://files.pythonhosted.org/packages/7f/57/42ed575c10ced8af951d426bc4e1f8aff16fd851db33f067036215a7f860/numpy-2.4.6-pp311-pypy311_pp73-macosx_14_0_arm64.whl", hash = "sha256:68a5124b13fa6cc2086764a20005d30bc0548146f7f5322f02fce212ca14317f", size = 5394157, upload-time = "2026-05-18T23:36:57.194Z" }, + { url = "https://files.pythonhosted.org/packages/6a/ef/f66cc724fcc36c1e364c67f51ae9146090b8b584f27d58b97fdae3edd737/numpy-2.4.6-pp311-pypy311_pp73-macosx_14_0_x86_64.whl", hash = "sha256:948424b06129ce883307e8cff868c31396d8dc7630a59c61d70d98dbe70f222c", size = 6708728, upload-time = "2026-05-18T23:36:59.575Z" }, + { url = "https://files.pythonhosted.org/packages/1a/9c/c531f2293b91265d8b48e9b329f54fdd7ffae73cb4134ea10cca4237e9cc/numpy-2.4.6-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5dbbdb29840ca3d91ee0fece42fc29278886d908280bfec0a5846c6f901a3eb0", size = 15798374, upload-time = "2026-05-18T23:37:02.674Z" }, + { url = "https://files.pythonhosted.org/packages/1a/b0/413077f6b1153ed3cba361401c6783bbad6114804a000cc22eb71c13e190/numpy-2.4.6-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8ad03c0965fb3c692200e74d458ca28c1dbb4ce96f9a479a8aa041ad5fabca02", size = 16747286, upload-time = "2026-05-18T23:37:06.327Z" }, + { url = "https://files.pythonhosted.org/packages/15/ce/e5ec180bc41812edcd8daeb8639d205622c0e8c02259d8ab25a0201b3c2a/numpy-2.4.6-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:2803abfebfc990042cd494d8ce2d5f82e9d847af6d35ec486923aa19dbad5e73", size = 12504263, upload-time = "2026-05-18T23:37:09.715Z" }, +] + +[[package]] +name = "numpy" +version = "2.5.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.14' and sys_platform == 'emscripten'", + "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'emscripten'", + "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", +] +sdist = { url = "https://files.pythonhosted.org/packages/22/fd/89965aa4ac08c74998539fcbf24fa3540f3e15237fbeb6bcf9c908f4aade/numpy-2.5.1.tar.gz", hash = "sha256:a48a113e6afea91f5608793bafa7ef2ad481fefbda87ec5069f483de61cb9fa3", size = 20755553, upload-time = "2026-07-04T17:08:00.933Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/7b/14687aa674250e5e546f616f486b0d56d3631cd5b2415739141ce40bdcea/numpy-2.5.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2c889b56fe48b1018f764b0eec8df59ab654e9148aa91faa12596043500de277", size = 16801574, upload-time = "2026-07-04T17:06:12.423Z" }, + { url = "https://files.pythonhosted.org/packages/e1/19/cc5bb2a3f2913d27d6dbb2c78d25921fabaedc6741d4a5a615a11f3c5bf3/numpy-2.5.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ab451b59c5643c570974c43aef780703ef1d3b4965d2be07afd530615a9358d1", size = 11772250, upload-time = "2026-07-04T17:06:15.726Z" }, + { url = "https://files.pythonhosted.org/packages/42/77/fdf34a71dd30f54979b18603bee915e0aaf825b07afe79acd60b04b691e2/numpy-2.5.1-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:78798bd5b9ad744056af8efa90e3b9ddaa53272a0848a483084a1cc0a13b2dc0", size = 5331516, upload-time = "2026-07-04T17:06:17.913Z" }, + { url = "https://files.pythonhosted.org/packages/ce/e2/eb7efa015b4cce41e2517bf182a7fce0d7d5b9d9ed76a29bfa0f4fe4505c/numpy-2.5.1-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:2ae0ca40bcb22d6ba59c1dfd5446f49940b0f2d821fde133f10dda11f816b84e", size = 6664863, upload-time = "2026-07-04T17:06:20.02Z" }, + { url = "https://files.pythonhosted.org/packages/a9/4b/a2b32dd94ee9ffbeecb28152240042a3949db33b1c834d44090b80e1b3b8/numpy-2.5.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:61ac47e772e6b8ea489e1d2f441a34c5c3ac17327e7ce294cbdf535795ad4e75", size = 15167977, upload-time = "2026-07-04T17:06:21.621Z" }, + { url = "https://files.pythonhosted.org/packages/b8/a9/6e73d68500f80773f65f0654ea932019d6694329a0eb0ed0533de38df376/numpy-2.5.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:59fda5e192b570217ec2580c96f00e9a7e12ef6866a900eb089b62c1a32545ca", size = 16672469, upload-time = "2026-07-04T17:06:24.064Z" }, + { url = "https://files.pythonhosted.org/packages/24/7d/ad3e59015135f5261c95fd4cafeff159c955febd83a99a1d9250c4233815/numpy-2.5.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f7119ebff1a9829e9f431a4f9d28e703023bb6b9fe7c8f724467dbfc27c94ab3", size = 16527531, upload-time = "2026-07-04T17:06:26.69Z" }, + { url = "https://files.pythonhosted.org/packages/83/d0/a39b2fbcde9cb17a1dac678f254b33a6336298af9df338824c685425d5e8/numpy-2.5.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e824c2acf8862052246be5a44c15da1777940c60d010dd2aab897824d9c430f9", size = 18431940, upload-time = "2026-07-04T17:06:29.521Z" }, + { url = "https://files.pythonhosted.org/packages/04/12/cff070947791c1ed425ff76413189adbdc2fbe215eba7ce7fa454a03c7f8/numpy-2.5.1-cp312-cp312-win32.whl", hash = "sha256:08d60c810432eb83360958dea0999ac4cfb94531ea8efcbf0b7f277c2068aeb2", size = 6066764, upload-time = "2026-07-04T17:06:32.571Z" }, + { url = "https://files.pythonhosted.org/packages/65/66/53f31807a48a750f9d748da273bc3fcedd12b27ff1f3e373bfec55ef2dc0/numpy-2.5.1-cp312-cp312-win_amd64.whl", hash = "sha256:f7d60026c0bdb1380e83bfa7a0419c4577ee4b9a08880afcb6dadeb74c649fa2", size = 12430966, upload-time = "2026-07-04T17:06:34.926Z" }, + { url = "https://files.pythonhosted.org/packages/2b/2a/d1a88066b1c14186f5d3c0d18c94f17b064511982bab0578d49ee9d43c29/numpy-2.5.1-cp312-cp312-win_arm64.whl", hash = "sha256:17a25e09640602e10bc8de0e6fa2b3fd68eedd84ba6d7842dc8f32f9ab87bd0b", size = 10350488, upload-time = "2026-07-04T17:06:37.785Z" }, + { url = "https://files.pythonhosted.org/packages/eb/07/ec2a3f0c91761581d4b7104a740791800025983f9a4dc4e73f91a99aeac4/numpy-2.5.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0bfebd8695f9863592fe744be833a258120b14a9f39da255e8aa8fade2c0ddd1", size = 16796419, upload-time = "2026-07-04T17:06:40.37Z" }, + { url = "https://files.pythonhosted.org/packages/ab/ab/ddb499fc4f8780354395face5b65c7fd107bcd6e1d667a5f07d046956f6f/numpy-2.5.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:30b44a6b53a7ae63c54c089a8726e5563ed302716c5b7ccc85afade40b0e7ff6", size = 11765832, upload-time = "2026-07-04T17:06:42.768Z" }, + { url = "https://files.pythonhosted.org/packages/88/b3/3c28c558a09fc72100c646dac6d2fce8e834c471b0edca01a29996706117/numpy-2.5.1-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:6165343f81b56ef8f514f396989e529b61d9dc709b99421b07e9f3e698e2287d", size = 5325143, upload-time = "2026-07-04T17:06:45.466Z" }, + { url = "https://files.pythonhosted.org/packages/5e/0e/ce19b985bb15c596f4f05954e76cccc77c845083b3b8f938a6c68e523128/numpy-2.5.1-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:4939237038ada79308dda3204ac6462df056b5672b2e25db1149cf873668b3e1", size = 6659749, upload-time = "2026-07-04T17:06:47.288Z" }, + { url = "https://files.pythonhosted.org/packages/2e/20/1ee6614d64332a1bba6411f38e68cb79eec1b2459e20a623777c5c5492a2/numpy-2.5.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c6759f538fb912fc46de0a6b1758ccf7b57bc7c7ebebc23974fdac3de8db0cd", size = 15164716, upload-time = "2026-07-04T17:06:49.494Z" }, + { url = "https://files.pythonhosted.org/packages/ed/a7/2bcd3fdbb87804755c35b729bf8709d62025c5f4cfd7d5b2415997097515/numpy-2.5.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9726558e8db4a5bf7929a70ae50f63abda4daf0efe810e3bfbab95976f75fc1a", size = 16661440, upload-time = "2026-07-04T17:06:52.061Z" }, + { url = "https://files.pythonhosted.org/packages/fc/d7/a41e3310c886fe457d36e670bbf24fae411aca8a7b6ad92a32afd924077c/numpy-2.5.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:3935f3b419b244a02732676fa5317a9193cc596a4c0646db07e5b421229ac9f7", size = 16526305, upload-time = "2026-07-04T17:06:54.605Z" }, + { url = "https://files.pythonhosted.org/packages/53/75/4333a9a707c1edd3a4e1a0c58eca52c0f31e55089fa80db02b5565b24df7/numpy-2.5.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:dc932a65ded7ce9013d120845a2514dcccb1a67bfc8deb8d37633762951904a6", size = 18423008, upload-time = "2026-07-04T17:06:57.54Z" }, + { url = "https://files.pythonhosted.org/packages/ee/90/e314a32b1c11a2ffe818ddad3a57b50b4b6e1b6c487192eb50cdef0415d0/numpy-2.5.1-cp313-cp313-win32.whl", hash = "sha256:4b4ff1608417eb7a59da7b967bbb798cacfe071d2caf526a24281cd562072ed9", size = 6063885, upload-time = "2026-07-04T17:07:00.14Z" }, + { url = "https://files.pythonhosted.org/packages/10/70/800b3fca480af32df9e8ea9f3d4a0c8feb4b32d7f195d174eabbda4829ad/numpy-2.5.1-cp313-cp313-win_amd64.whl", hash = "sha256:6c3fe51bc6a16453d452997053454f309e8e0ed7b42d6b361ce4ac8c32913d74", size = 12425674, upload-time = "2026-07-04T17:07:02.387Z" }, + { url = "https://files.pythonhosted.org/packages/8b/0b/196350c122f50f6ca56846f2d71efd5e0d24b7b2e07355e019b2e2c7a11e/numpy-2.5.1-cp313-cp313-win_arm64.whl", hash = "sha256:f7feb014281029e628ba2d5a007407443b06e418b6fe451d1e2adcbc8eba0107", size = 10350256, upload-time = "2026-07-04T17:07:04.878Z" }, + { url = "https://files.pythonhosted.org/packages/db/f4/731b6085a83faf6ca843394cbd5e217280c214399f7e8b21b9f552af0ae2/numpy-2.5.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:7c786fe9a5bbe360022e584c5a34cf6b54265c71bd7ec8ac3d8fec38968071f8", size = 16795063, upload-time = "2026-07-04T17:07:07.374Z" }, + { url = "https://files.pythonhosted.org/packages/bf/64/0e215f2048dd11a55bb989ed41b3585ef57452404e638d703a211a3e4157/numpy-2.5.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:32985c896d897419ef8da6917872d80b78ad0ea26d85b23245c7366ffde76d75", size = 11776652, upload-time = "2026-07-04T17:07:09.907Z" }, + { url = "https://files.pythonhosted.org/packages/b5/59/2b844c7a6e9deff69b404a66221e1542937734f65d5e6e39411876053862/numpy-2.5.1-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:efd736408cc97c79b9e6917338dfc8f06013b2274f992e96b1d9a81a71e2a2c2", size = 5335944, upload-time = "2026-07-04T17:07:12.227Z" }, + { url = "https://files.pythonhosted.org/packages/86/51/9bf7cb2cabcebc9e017e4ec7e6322b378317a542c08b4cb68479c1efc716/numpy-2.5.1-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:ab84dc6b074fa881cae55bea94cc4f68e285181ba7f32497bf7dee6b1496165b", size = 6656266, upload-time = "2026-07-04T17:07:14.368Z" }, + { url = "https://files.pythonhosted.org/packages/83/3e/fb7615b211b82a32f44d5180a6d421b61f84d4fadd578b48ba4ac34e189f/numpy-2.5.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:caf3e317d33d60c37986b452613f4ab51246d0691350c03d0cb4a898627f4a95", size = 15179720, upload-time = "2026-07-04T17:07:16.272Z" }, + { url = "https://files.pythonhosted.org/packages/41/5f/0f992cb24560673496c5d68de61913b57166ce530ffda07c1f280e0cc464/numpy-2.5.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:54ad769f17bc2d833b620851989f62054fb9ab93c969d9e1dc3c8e3d56beea21", size = 16664835, upload-time = "2026-07-04T17:07:19.021Z" }, + { url = "https://files.pythonhosted.org/packages/a2/2f/97d6475ee91afe2587797d09446f9d3e475ad4cb681662d824809327b75a/numpy-2.5.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c12afb53450fa976d4c681c50a7423729a4c51c0465ed9f32b8a9cabbc472373", size = 16539135, upload-time = "2026-07-04T17:07:22.015Z" }, + { url = "https://files.pythonhosted.org/packages/c4/5b/4db81e4ba0be7e2776b1de68c82aa862c7f8ec27e1b4927d4ae075e20678/numpy-2.5.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e8c11c405efc5ff6816d5983c96cdfa215bab3428961243af3ff59b228490438", size = 18426684, upload-time = "2026-07-04T17:07:24.941Z" }, + { url = "https://files.pythonhosted.org/packages/1f/64/c0ba2d90724d450279a7df8f32057241070250a26a7e2b5337d77347f481/numpy-2.5.1-cp314-cp314-win32.whl", hash = "sha256:f2479a47f8d5932d1718168a681ad6e536a9df484c83cfcf9de365e164537ace", size = 6116103, upload-time = "2026-07-04T17:07:27.622Z" }, + { url = "https://files.pythonhosted.org/packages/c1/1a/837f9ed7405adcd7a40538792eb169eddd8fa5630c16a1ef49dae71a30f4/numpy-2.5.1-cp314-cp314-win_amd64.whl", hash = "sha256:24d0eb82c0541d3415a33425db64ae439dffccd7b4dbcb30e7c35120205c506a", size = 12562177, upload-time = "2026-07-04T17:07:29.887Z" }, + { url = "https://files.pythonhosted.org/packages/22/ed/49707938b6dd0a78a9178dd93227dc89e4c11af47f5c798d70366e8d0483/numpy-2.5.1-cp314-cp314-win_arm64.whl", hash = "sha256:5a4c988b38d261deeeaad9954e3deb091ad905c94e8bb6708654ef1d97f286b0", size = 10627739, upload-time = "2026-07-04T17:07:32.568Z" }, + { url = "https://files.pythonhosted.org/packages/a6/c7/bb4b882cfe7f299cbc8b66e42e7dd78cf9d14e40f9469fc5e3db7e15b3bd/numpy-2.5.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a33276be12fa045805f477f22482088b66bb758ffbe89a9d21457de863a32e22", size = 11894709, upload-time = "2026-07-04T17:07:34.941Z" }, + { url = "https://files.pythonhosted.org/packages/40/3f/5af7f4a7f6224aef48017aa82bb6174c7a659d724be0c75017b7e64a55b4/numpy-2.5.1-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:f089d7b00756190aacf1f5d34bdf38c3c430ac82b4f868f8cede73380460fce7", size = 5453810, upload-time = "2026-07-04T17:07:37.495Z" }, + { url = "https://files.pythonhosted.org/packages/20/c9/3474309bc94d634d3f9c3eddf03250ecb8c22cd948ef16fef69a77cc5d7b/numpy-2.5.1-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:09e9bfd8d2cf479c7d174804fb3811c53a8e9f20a37444008606b57d6b7a826d", size = 6761189, upload-time = "2026-07-04T17:07:39.563Z" }, + { url = "https://files.pythonhosted.org/packages/90/8a/558ae39fdd55d7e7f7fef9a84a6e964ac6b23edbd2a07e52bb084500507d/numpy-2.5.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e68d8dd1e7eba712948f2053a29ec86917bc70ba1358df869d9f06649ef9cf09", size = 15225039, upload-time = "2026-07-04T17:07:41.682Z" }, + { url = "https://files.pythonhosted.org/packages/63/27/ca7392b2d030277bdf0273e7d23255b3ee57d57a7c170a6f4fb3981e1e5d/numpy-2.5.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:99d5095fa265a0c4152e7bb12759e14381ef5496152f1ce58f44bdf55c44beb4", size = 16701306, upload-time = "2026-07-04T17:07:44.611Z" }, + { url = "https://files.pythonhosted.org/packages/02/42/03d53ae7996c44d4374a8262e9dc41671fd56cbb98f7d47ef85cf5da4c6b/numpy-2.5.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ab87a91b3cc3382b8956095bd8f95e00cf679bb81554339be1a2ba404a1473c1", size = 16589955, upload-time = "2026-07-04T17:07:47.694Z" }, + { url = "https://files.pythonhosted.org/packages/7b/15/6c1784ae469640e65db111e9a34b3d0f14d91e8a38b9ce34810ced370dbb/numpy-2.5.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:224ca51130ef7da85bea2191625181cb4f337f9cb64b471f10c1a12aa8b60077", size = 18464252, upload-time = "2026-07-04T17:07:50.684Z" }, + { url = "https://files.pythonhosted.org/packages/94/a8/f98e50356cf167df656c526c2dfeec2d7dde182f2a3da4b458a5938e2776/numpy-2.5.1-cp314-cp314t-win32.whl", hash = "sha256:6eab239876581b2b3c5a242281b6007bbdbcd1c7085d7709bb57c5929b11e6bf", size = 6263298, upload-time = "2026-07-04T17:07:53.445Z" }, + { url = "https://files.pythonhosted.org/packages/72/ac/96ae880cdecad0b3275d9359fcec72667b49a4863c9f12942e43679dda02/numpy-2.5.1-cp314-cp314t-win_amd64.whl", hash = "sha256:83ce9c80d5b521b0d77ddcbe5447c218d247929b6cc056ca5351342accfff0af", size = 12748623, upload-time = "2026-07-04T17:07:55.384Z" }, + { url = "https://files.pythonhosted.org/packages/a1/5a/4d2b1601df3602dba7a14f3348ba9bfe94a18adb428e693df6154c293831/numpy-2.5.1-cp314-cp314t-win_arm64.whl", hash = "sha256:5a6db61f9aaa57e369905c67d852045d3c4f7126405b29d09b19dec118e9c9cb", size = 10697674, upload-time = "2026-07-04T17:07:58.506Z" }, +] + +[[package]] +name = "packaging" +version = "26.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, +] + +[[package]] +name = "pandas" +version = "3.0.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "numpy", version = "2.5.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "python-dateutil" }, + { name = "tzdata", marker = "sys_platform == 'emscripten' or sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/be/4f/5f3422a2afec5ffc46308b79e53291365a93748b498ac2e58bead0197916/pandas-3.0.5.tar.gz", hash = "sha256:dca3734d6ab7c906e6730f0788b0a1dbb9f2467731f9711f77995c8e9d62d712", size = 4658219, upload-time = "2026-07-22T22:19:28.819Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/48/ef/f1fd7431d635bf20015489bf0bd69c17fff1018de773540f651455a3916b/pandas-3.0.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:2946e77e4a53cd248cbde631a12f0e51c8324ce354c3eba4d20147c1ad6f4282", size = 10397178, upload-time = "2026-07-22T22:17:48.274Z" }, + { url = "https://files.pythonhosted.org/packages/31/b4/0eafac990a431561187694126de01f9b12559549b4d86360c0c4bd870fde/pandas-3.0.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:71ecc8fb7ed1a7aa4392316b5309a6347e8e7f832f38fd897846b3a1457a9298", size = 9990736, upload-time = "2026-07-22T22:17:52.388Z" }, + { url = "https://files.pythonhosted.org/packages/de/21/359880af3ea9b7cb23bea5b51e8e70ef3866c03be09da9a2787e18e330a8/pandas-3.0.5-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b173f5951ff6b8b0ec7675e20dff3c97b7e7a57dfcce387c2d7c5afe87cb7899", size = 10814438, upload-time = "2026-07-22T22:17:54.708Z" }, + { url = "https://files.pythonhosted.org/packages/d1/50/d6cc4d7e508bbccf5d6027314a8312bc7ac73d0ec7f195f53838daafab40/pandas-3.0.5-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2c0cf1dd9b55a22d105fc46c1b489af3bd42264fcba7c66297bf47a9a1d9c78a", size = 11323634, upload-time = "2026-07-22T22:17:56.858Z" }, + { url = "https://files.pythonhosted.org/packages/70/2b/d5f0a8c90dd0ae04e64ba53b871afb796ec026b615086d382ddc2ade729b/pandas-3.0.5-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0fac0010c75e4efb6b99e249c183a8993ce0dc95c240f9b120a5e67c727b7928", size = 11850860, upload-time = "2026-07-22T22:17:59.1Z" }, + { url = "https://files.pythonhosted.org/packages/5c/30/183aec2e19adf778a98d29b5729a0a68f4cc4ebf9b9c3b70d0297355bcb1/pandas-3.0.5-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:08d24fe11a17dc33bd6e937dc9c665f9cba08fbdc9f657f405713515febe300d", size = 12411100, upload-time = "2026-07-22T22:18:01.485Z" }, + { url = "https://files.pythonhosted.org/packages/fa/9a/31f4983f191af51ab2a8f2d0c7b33dff3a84da26533f982fff02c2f9e28b/pandas-3.0.5-cp311-cp311-win_amd64.whl", hash = "sha256:b1261758dfb6cf12c3cff8300e21cefad30e7ec709abb4c24ac7318e6a52462a", size = 9968804, upload-time = "2026-07-22T22:18:03.903Z" }, + { url = "https://files.pythonhosted.org/packages/49/97/7886c89a39045c69ad82cbceaf3343810480c8ef49a216319ce8183860a6/pandas-3.0.5-cp311-cp311-win_arm64.whl", hash = "sha256:679f4e85b30ddb1515458ab1e788d3e260eae369b1f78da7a3aa4cac8ebf4a2a", size = 9205447, upload-time = "2026-07-22T22:18:06.134Z" }, + { url = "https://files.pythonhosted.org/packages/1c/54/1dc810ea558d1320b597aa140a514f2fdf1d2ea09c38cf556f13ea712ec9/pandas-3.0.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:fa290c16964d4963fbfbc358928239cf3bd755b20e988ce944877def2f44471d", size = 10411717, upload-time = "2026-07-22T22:18:08.307Z" }, + { url = "https://files.pythonhosted.org/packages/68/56/fbe81c09195924d8b7b8d4461a20458fe80a6a5ed6b24f0314da684277e1/pandas-3.0.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:c2e26bb46934b8a2ca0c3de1d3d606fc5f6746584791b2db264d58cf370e08dc", size = 9957095, upload-time = "2026-07-22T22:18:10.6Z" }, + { url = "https://files.pythonhosted.org/packages/e0/51/fac252f4a913ed5eabf3c11b880a9e8d5a6c10f0b2129d0462212d238b4d/pandas-3.0.5-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:73fa87b08a7ef706f8aafda39ddaccf2a99047bea62d8c88a0361bcafb2237bc", size = 10485458, upload-time = "2026-07-22T22:18:12.834Z" }, + { url = "https://files.pythonhosted.org/packages/12/98/e976540c1addf70442be7842a18cf70884a964abbf69442504f4d2939989/pandas-3.0.5-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d373ce03ffd84010ed9839fa73672a9c8256990532e158440c0085db7d914b34", size = 10998091, upload-time = "2026-07-22T22:18:15.209Z" }, + { url = "https://files.pythonhosted.org/packages/a4/8c/1f29b5be8d3fc47dd7567eb167fabba2085879b31e0287ce7cba6d3d2ff4/pandas-3.0.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2a29c53d85ea98c5e792c59ef82ee9fbe6ca902c0d0adb6b23f45ef894cd7bf6", size = 11499501, upload-time = "2026-07-22T22:18:17.689Z" }, + { url = "https://files.pythonhosted.org/packages/9d/e2/bd9c98ad2df7b38bde002adde4cdf353519da51881634323b126c55997f9/pandas-3.0.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a5ad3b02ed6bc7d7ae9b70804b2c6aa31827489d150f8e623ce82491b82085d7", size = 12060559, upload-time = "2026-07-22T22:18:20.147Z" }, + { url = "https://files.pythonhosted.org/packages/f3/9a/ffbd852d58bd74a617fe2f8ee6a58a96982271ce41cf981eab22190b4a4b/pandas-3.0.5-cp312-cp312-pyemscripten_2024_0_wasm32.whl", hash = "sha256:b2acb4650527eec6822c3dadb2b771277b65e7dae7a267d4bccf65fd1bb3fbce", size = 7197652, upload-time = "2026-07-22T22:18:22.502Z" }, + { url = "https://files.pythonhosted.org/packages/70/b5/d2d3e9ae73362ba4229651b0ee1455cf78073a1ce585f6ff693782ce263e/pandas-3.0.5-cp312-cp312-win_amd64.whl", hash = "sha256:80a611068e8a3ac23f7398c6c14eb46dc974e5cc9997f653e2dcfd1da74edd41", size = 9831691, upload-time = "2026-07-22T22:18:24.534Z" }, + { url = "https://files.pythonhosted.org/packages/52/51/dea1e89d6a6796b9c43f85a09b484ee03edb8a4c4842e73e200a8c11301c/pandas-3.0.5-cp312-cp312-win_arm64.whl", hash = "sha256:25ff585b972a18ef1fe9ffa3ac6544d9950508aa76832e5147640b6022821e49", size = 9105796, upload-time = "2026-07-22T22:18:27.064Z" }, + { url = "https://files.pythonhosted.org/packages/bf/09/7b95c4a0025227d6f118c4039b423412ac6a982db02864166185d812fbc7/pandas-3.0.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c1c05a767fe8e5b4fe9e1c29806829c582052eaedb9120a3da83ba3f69e24a5b", size = 10385742, upload-time = "2026-07-22T22:18:29.346Z" }, + { url = "https://files.pythonhosted.org/packages/8d/0c/dc78fd8c4da477b4b5e8ad37295af352190d21ef63a9ee1bc071753074cc/pandas-3.0.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:b86765f268b56f7e665b93bce9d5df69dee7f99e595cf8fb839483ab315942a3", size = 9932067, upload-time = "2026-07-22T22:18:31.833Z" }, + { url = "https://files.pythonhosted.org/packages/3e/71/3592c055cf44df9808550f9368ceda80ff2b224d355ef73fe251dcda1802/pandas-3.0.5-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c597ecf5616b5c420372c1d4d4c00dbbfba7398bea857dcc984347e1ea48417b", size = 10466756, upload-time = "2026-07-22T22:18:34.195Z" }, + { url = "https://files.pythonhosted.org/packages/e3/70/4363150359f95b4cb4bcbb34ca23572bb5495749a621a8f3d5a1ddfd293c/pandas-3.0.5-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4b11c36e218331d0387cbe3a0a5f75162357a1d92d57b2b08a336ff94b19b2be", size = 10938525, upload-time = "2026-07-22T22:18:36.81Z" }, + { url = "https://files.pythonhosted.org/packages/f7/d0/317e7a0c67c0e69fa905a0161409397a7dc2d46ff611f6ca4803352c042b/pandas-3.0.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:cf52e1f61d229496da17dc7ab54acdee627357e7008fd4fecba3d0ba2937fa58", size = 11489303, upload-time = "2026-07-22T22:18:39.287Z" }, + { url = "https://files.pythonhosted.org/packages/f1/8d/36dade89b49e4f9d5cbdbe863772581f98c0c6d78fc39ad4c557f6f2e17e/pandas-3.0.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:db172144bb56422bd157812f3b021eacc255451470b31e2c633c349490a1cfee", size = 11989004, upload-time = "2026-07-22T22:18:42.208Z" }, + { url = "https://files.pythonhosted.org/packages/9c/ba/18c4ec8a746e177da05a9e7a7963781d8ea195780724f854601b6ebd6b78/pandas-3.0.5-cp313-cp313-win_amd64.whl", hash = "sha256:0d298e951f23016ce4699951d044ae6418dbc91bf68cefca0f77666fcbb4e5c6", size = 9826896, upload-time = "2026-07-22T22:18:44.539Z" }, + { url = "https://files.pythonhosted.org/packages/de/ec/28a57266b753799a87b8bc79e7887ac6fd981b8c6d2978a0b7e7b6bd708c/pandas-3.0.5-cp313-cp313-win_arm64.whl", hash = "sha256:66266d3442a5e8b3c90274c2b8b230bee42dd1c286bc822cc2f9f2c7e12b883e", size = 9094790, upload-time = "2026-07-22T22:18:47.468Z" }, + { url = "https://files.pythonhosted.org/packages/51/2f/cf6aae281264f4463f0875bcbb15fd2bb6d291cc535187dad1732475e4a9/pandas-3.0.5-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2f264fc46911cc8131a7322a16199bbf8e353d27c10bb211f5bd0c814324dc36", size = 10390034, upload-time = "2026-07-22T22:18:49.818Z" }, + { url = "https://files.pythonhosted.org/packages/06/ec/5189518c7a7659c4bdcc6b1eb32c46c6f3c86b0661ffd84143d1112c7732/pandas-3.0.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:53730687fcd161883b24e10411c06d6a4c0f2275d2faf3bb2bc25deb4ba8007c", size = 9980065, upload-time = "2026-07-22T22:18:52.249Z" }, + { url = "https://files.pythonhosted.org/packages/ea/f1/598503ce8d7e3c35601e0747ba288c7864baae66380725bc12f13f884dfe/pandas-3.0.5-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:960d3ebcf249f75206899fcd2c6de53f736b7265759ced0d3e559df0b8b709b0", size = 10545532, upload-time = "2026-07-22T22:18:54.813Z" }, + { url = "https://files.pythonhosted.org/packages/fa/de/ceae2adf7034e07e9910299fe412e1819c4f0dd520700a888bcb03625448/pandas-3.0.5-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9e94c2c5ca43bd3ca32bf64d32308887b65e5f9bfd8023ea52755107a999f93b", size = 10963120, upload-time = "2026-07-22T22:18:57.42Z" }, + { url = "https://files.pythonhosted.org/packages/66/25/86e0f4451874eb79e688deeebe3c451fec4557f8952005818d800ee8ac7e/pandas-3.0.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e819dd5f62966b481a8cb649d3299ebd886a1ea91ed5a99bf7ce77c98d18ab94", size = 11563178, upload-time = "2026-07-22T22:18:59.729Z" }, + { url = "https://files.pythonhosted.org/packages/f3/45/8643daa3b4147e433adfcccefdd0380d3aad79d86b15d8999730fe1944d5/pandas-3.0.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:3c5ed2e7c06e91d340dfd091d7934f9bc82e4a36b95f647f090b9d1c9ac649da", size = 12028708, upload-time = "2026-07-22T22:19:02.164Z" }, + { url = "https://files.pythonhosted.org/packages/96/58/ad979ae617615576e8aafd569c9d4b62f1191d896e38f51d66ba06f3b89a/pandas-3.0.5-cp314-cp314-win_amd64.whl", hash = "sha256:cd8f7c6dc98527058ee6264219343f5392240a6f1bfa654fc5d79023020d0c92", size = 9951806, upload-time = "2026-07-22T22:19:04.596Z" }, + { url = "https://files.pythonhosted.org/packages/69/32/7ac03886b304049a9d2625ee88f59af760d8a93bd30ed9239bce7b9869a8/pandas-3.0.5-cp314-cp314-win_arm64.whl", hash = "sha256:5183427f5a8156d480f30333777bc978be93650a49a7c01db26adffe95b31e85", size = 9238297, upload-time = "2026-07-22T22:19:06.836Z" }, + { url = "https://files.pythonhosted.org/packages/be/ed/1d1f2ee5547d5167face2376d11c8b2a4c7bfff5a416ee7a9046891fab1e/pandas-3.0.5-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:303da736987d481074ca720ada325f8bd80c64ebc2d45ed79b29df3aaa4a26ca", size = 10849690, upload-time = "2026-07-22T22:19:09.391Z" }, + { url = "https://files.pythonhosted.org/packages/57/55/17e17152e98fbb0c4b1e562bc65387a2f20a80db0f4a86bf8d3a0e4248d4/pandas-3.0.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:3b2801bbb049d0136f6c213eae02b5fca969384fc2064dd728d8620552aa49da", size = 10509945, upload-time = "2026-07-22T22:19:11.773Z" }, + { url = "https://files.pythonhosted.org/packages/88/90/817d44dbf83facf9556f33576d9af0a241981e7bb5c00606c0bcb5df8dda/pandas-3.0.5-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cce3a9d11d2b1f82c69a27ec1f4948a170e2c403c4bbfa8cca62e3fdebe2ef3a", size = 10392197, upload-time = "2026-07-22T22:19:14.024Z" }, + { url = "https://files.pythonhosted.org/packages/f1/da/889f00c0a6f5aa1545add70abbf01502dff87ab577adb855bd631c54d2f2/pandas-3.0.5-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ef01af4d8dc6cd2c8d6c7736f149574ef93fe043811eeb5e445f2647154b5040", size = 10862726, upload-time = "2026-07-22T22:19:16.351Z" }, + { url = "https://files.pythonhosted.org/packages/bc/98/f1e934fb3c98fce859c6147c6785816c7b5b9ab7821115c5d8c4de9842b9/pandas-3.0.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e2759e890db96dfcffdbd9b86c3c2cb6afaf58def482820317e06163ec1066cd", size = 11414864, upload-time = "2026-07-22T22:19:18.981Z" }, + { url = "https://files.pythonhosted.org/packages/fe/be/d448af7d657d82e1888dd8551f79c6d6fb161080b5b9752d84d910ec2319/pandas-3.0.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b58b1b39d46a5862e3fb18f50d1a201398619d16a0f9f73f57eea5583cf0e63c", size = 11925105, upload-time = "2026-07-22T22:19:21.515Z" }, + { url = "https://files.pythonhosted.org/packages/29/c1/ccb4238212c8c4f496c584f3044d94e0c030ed8e1d68999db46c91c2242f/pandas-3.0.5-cp314-cp314t-win_amd64.whl", hash = "sha256:1c10461f6eeb35d8f05b6184c65c8b9991663b66c46b1d559b682cb34ae7c6ea", size = 10387612, upload-time = "2026-07-22T22:19:24.257Z" }, + { url = "https://files.pythonhosted.org/packages/d2/cf/6a51b2c38980e04c279fd2fa908a1b0982064e860444acfca4ec2e2c8359/pandas-3.0.5-cp314-cp314t-win_arm64.whl", hash = "sha256:3c5015fd1730fbf883647e88068176c839c102cea883ba1769a6f4593bfc1f8c", size = 9509776, upload-time = "2026-07-22T22:19:26.694Z" }, +] + +[[package]] +name = "peewee" +version = "4.2.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2b/6d/45b139fba589e185dcee447414c2e0efaebb0a247c06329fdc9bd84ba4aa/peewee-4.2.6.tar.gz", hash = "sha256:f40655c64a62eaa447af228e4b84a5e80d3c812d2de34c7b9c621a3ef0c6555c", size = 779493, upload-time = "2026-07-17T18:11:21.3Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c0/63/26ec789e68e994d64ed957dd6ee5a08bb78c2f401c40e46fc142669cd8b0/peewee-4.2.6-py3-none-any.whl", hash = "sha256:b54c0f6e09c987465f8268bb2b4c1cba2e1b2788fcc0974c8e2233af1fd5af8f", size = 173774, upload-time = "2026-07-17T18:11:19.787Z" }, +] + +[[package]] +name = "pillow" +version = "12.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1c/3d/bb7fca845737cf9d7dbde16ed1843984665ff2e0a518f5db43e77ec540b9/pillow-12.3.0.tar.gz", hash = "sha256:3b8182a766685eaa002637e28b4ec8d6b18819a0c71f579bf0dbaa5830297cce", size = 47025035, upload-time = "2026-07-01T11:56:38.965Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/c8/0a78b0e02d7ac54bc03e5321c9220da52f0c2ea83b21f7c40e7f3169c502/pillow-12.3.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:00808c5e14ef63ac5161091d242999076604ff74b883423a11e5d7bbb38bf756", size = 5392415, upload-time = "2026-07-01T11:53:47.162Z" }, + { url = "https://files.pythonhosted.org/packages/b2/5b/a02d30018abd97ced9f5a6c63d28597694a00d066516b9c1c6de45859fc9/pillow-12.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:37d6d0a00072fd2948eb22bce7e1475f34569d90c87c59f7a2ec59541b77f7a6", size = 4785266, upload-time = "2026-07-01T11:53:49.079Z" }, + { url = "https://files.pythonhosted.org/packages/c8/98/766667a4be768150a202836acd9fad19c06824ca86c4286d3cf6b274964e/pillow-12.3.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bcb46e2f9feff8d06323983bd83ed00c201fdcab3d74973e7072a889b3979fcd", size = 6263814, upload-time = "2026-07-01T11:53:51.32Z" }, + { url = "https://files.pythonhosted.org/packages/3b/2d/ede717bc1144f63886c21fd349bb95860b0d1a21149ff16f2bb362b612b6/pillow-12.3.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:23d27a3e0307ec2244cc51e7287b919aa68d097504ebe19df4e76a98a3eea5bd", size = 6934408, upload-time = "2026-07-01T11:53:53.487Z" }, + { url = "https://files.pythonhosted.org/packages/a3/48/9c58b685e69d49c31af6c8eb9012055fab7e665785165c84796e2c73ce72/pillow-12.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:4f883547d4b7f0495ebe7056b0cc2aea76094e7a4abc8e933540f3271df27d9c", size = 6337160, upload-time = "2026-07-01T11:53:55.457Z" }, + { url = "https://files.pythonhosted.org/packages/ff/fa/dc2a5c0ba6df93f67c31d34b808b7ce440b40cdbf96f0b81cde1d1e6fa93/pillow-12.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:236ff70b9312fb68943c703aa842ca6a758abfa45ac187a5e7c1452e96ef72b5", size = 7045172, upload-time = "2026-07-01T11:53:57.736Z" }, + { url = "https://files.pythonhosted.org/packages/86/a5/444817a4d4c4c2417df00513086ca196f388d8f9ef40c2e4ccd1ad1af54b/pillow-12.3.0-cp311-cp311-win32.whl", hash = "sha256:10e41f0fbf1eec8cfd234b8fe17a4caac7c9d0db4c204d3c173a8f9f6ef3232b", size = 6472232, upload-time = "2026-07-01T11:53:59.767Z" }, + { url = "https://files.pythonhosted.org/packages/63/c6/4bad1b18d132a50b27e1365e1ab163616f7a5bb56d330f66f9d1d9d4f9d4/pillow-12.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:8e95e1385e4998ae9694eeaa4730ba5457ff61185b3a55e2e7bea0880aef452a", size = 7233653, upload-time = "2026-07-01T11:54:02.066Z" }, + { url = "https://files.pythonhosted.org/packages/fd/16/00f91ab7760dc842f5aad55217e80fc4a7067a0604535249bc8a2d6d9870/pillow-12.3.0-cp311-cp311-win_arm64.whl", hash = "sha256:ebaea975e03d3141d9d3a507df75c9b3ec90fa9d2ffd07567b3a978d9d790b26", size = 2568195, upload-time = "2026-07-01T11:54:04.622Z" }, + { url = "https://files.pythonhosted.org/packages/37/bf/fb3ebff8ddcb76aac5a01389251bbbb9519922a9b520d8247c1ca864a25d/pillow-12.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ba09209fbe443b4acccebe845d8a138b89a8f4fbaeedd44953490b5315d5e965", size = 5345969, upload-time = "2026-07-01T11:54:06.397Z" }, + { url = "https://files.pythonhosted.org/packages/d8/66/9a386a92561f402389a4fc70c18838bf6d35eb5eb5c6850b4b2dc64f5048/pillow-12.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ffd0c5368496f41b0944be820fcb7a838aa6e623d250b01acf2643939c3f99d7", size = 4780323, upload-time = "2026-07-01T11:54:09.351Z" }, + { url = "https://files.pythonhosted.org/packages/25/27/ac8f99618ffd3dde21db0f4d4b1d2ab00c0880595bfd17df103f7f39fd0c/pillow-12.3.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d9c7f76c0673154f044e9d78c8655fb4213f6ca31a836df48b40fe5d187717b9", size = 6266838, upload-time = "2026-07-01T11:54:11.71Z" }, + { url = "https://files.pythonhosted.org/packages/84/21/a35af28dcc61f37ed850a2d64c65c701321dfbf25085e469d5559360cbbf/pillow-12.3.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:78cb2c6865a35ab8ff8b75fd122f6033b92a62c82801110e48ddd6c936a45d91", size = 6940830, upload-time = "2026-07-01T11:54:13.732Z" }, + { url = "https://files.pythonhosted.org/packages/eb/51/8b08617af3ad95e33ce6d7dd2c99ed6c8298f7fb131636303956be022e25/pillow-12.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e491916b378fba47242221bb9ead245211b70d504f495d105d17b14a24b4907c", size = 6344383, upload-time = "2026-07-01T11:54:15.756Z" }, + { url = "https://files.pythonhosted.org/packages/1d/72/cf78ac9780bb93c28328f408973845a309d4d145041665f734572ced1b52/pillow-12.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0dd2064cbc55aaec028ef5fbb60fa47bb6c3e7918e07ff17935284b227a9d2df", size = 7052934, upload-time = "2026-07-01T11:54:17.721Z" }, + { url = "https://files.pythonhosted.org/packages/20/20/25e0f4dc178a6bc0696793720055519a0de89e7661dae886992decbd2f81/pillow-12.3.0-cp312-cp312-win32.whl", hash = "sha256:dbce0b29841537a2fa4a214c2bbf14de3587c9680caa9b4e217568472490b28f", size = 6472684, upload-time = "2026-07-01T11:54:19.839Z" }, + { url = "https://files.pythonhosted.org/packages/45/89/da2f7971a317f83d807fdd4065c0af40208e59e692cc43d315a71a0e96d1/pillow-12.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:a2b55dd6b2a4c4b7d87ffa56bdb33fdc5fdb9a462173861a7bc097f17d91cb09", size = 7227137, upload-time = "2026-07-01T11:54:22.025Z" }, + { url = "https://files.pythonhosted.org/packages/de/47/4845a0a6c0dbf1db8456bd9fc791f13c5ced7ced20606d08a0aacfd25b49/pillow-12.3.0-cp312-cp312-win_arm64.whl", hash = "sha256:331b624368d4f1d069149002f25f44bc61c8919ce8ddb3c45bdad8f6e2d89510", size = 2568267, upload-time = "2026-07-01T11:54:24.051Z" }, + { url = "https://files.pythonhosted.org/packages/9d/ac/31fb64e1e7efb5a4b50cd3d92049ba89ac6e4d8d3bb6a74e15048ca3353e/pillow-12.3.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:21900ce7ba264168cd50defae43cd75d25c833ad4ad6e73ffc5596d12e25ac89", size = 4161684, upload-time = "2026-07-01T11:54:25.934Z" }, + { url = "https://files.pythonhosted.org/packages/87/b4/9805e23d2b4d77842b468513841fda254ee42f0289d25088340e4ff46e2d/pillow-12.3.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:4e8c2a84d977f50b9daed6eeaf3baef67d00d5d74d932288f02cb94518ee3ace", size = 4255487, upload-time = "2026-07-01T11:54:27.935Z" }, + { url = "https://files.pythonhosted.org/packages/df/39/ecf519435a200c693fe053a6ee4d835b41cf963a4dfc2551c4e637cb2a71/pillow-12.3.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:ae26d61dfa7a47befdc7572b521024e8745f3d809bd95ca9505a7bba9ef849ec", size = 3696433, upload-time = "2026-07-01T11:54:29.813Z" }, + { url = "https://files.pythonhosted.org/packages/42/92/2fc3ffad878ae8dd5469ec1bc8eb83b71f48e13efdf68f02709003982a32/pillow-12.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7a743ff716f746fc19a9557f60dab1600d4613255f8a7aeb3cdde4db7eb15a66", size = 5345889, upload-time = "2026-07-01T11:54:31.97Z" }, + { url = "https://files.pythonhosted.org/packages/10/76/8803c13605b763d33d156c4678fc77f8443389c0c51c8aef707bb02015f4/pillow-12.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d69141514cc30b774ceea5e3ed3a6635c8d8a96edf664689b890f4089111fb35", size = 4780109, upload-time = "2026-07-01T11:54:34.026Z" }, + { url = "https://files.pythonhosted.org/packages/1f/01/e18aff37cb0b4aac47ac90f016d347a49aca667ef97f190b06ac2aabc928/pillow-12.3.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f7401aebd7f581d7f83a439d87d474999317ee099218e5ad25d125290990ba65", size = 6263736, upload-time = "2026-07-01T11:54:36.131Z" }, + { url = "https://files.pythonhosted.org/packages/f7/62/de5bdd77d935331f4f802edc11e4d82950f642caad6cb2f949837b8560e2/pillow-12.3.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0847a763afefb695bc912d7c131e7e0632d4edc1d8698f58ddabec8e46b8b6d3", size = 6937129, upload-time = "2026-07-01T11:54:38.216Z" }, + { url = "https://files.pythonhosted.org/packages/70/4d/105627a13300c5e0df1d174230b32fd1273062c96f7745fd552b945d1e1d/pillow-12.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:571b9fcb07b97ef3a492028fb3d2dc0993ca23a06138b0315286566d29ef718a", size = 6339562, upload-time = "2026-07-01T11:54:40.354Z" }, + { url = "https://files.pythonhosted.org/packages/6b/1d/f13de01a553988ab895ba1c722e06cf3144d4f57656fd5b81b6d881f1179/pillow-12.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:756c768d0c9c2955feb7a56c37ea24aea2e369f8d36a88da270b6a9f19e62b5e", size = 7049439, upload-time = "2026-07-01T11:54:42.489Z" }, + { url = "https://files.pythonhosted.org/packages/c9/f9/066794cca041b969964f779ee5fa66a9498bbf34248ac39c5d7954e4198f/pillow-12.3.0-cp313-cp313-win32.whl", hash = "sha256:a876864214e136f0eb367788dbd7df045f4806801518e2cfe9e13229cfe06d8f", size = 6473287, upload-time = "2026-07-01T11:54:44.9Z" }, + { url = "https://files.pythonhosted.org/packages/a6/9b/7a58e61d62be561da3a356fe2384d4059a6345fc130e23ef1c36a5b81d24/pillow-12.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:1cca606cd25738df4ed873d5ad46bbdb3d83b5cbca291f6b4ff13a4df6b0bbe8", size = 7239691, upload-time = "2026-07-01T11:54:47.141Z" }, + { url = "https://files.pythonhosted.org/packages/aa/b0/c4ed4f0ef8f8fa5ee8351537db6650bb8189f7e118842978dd6589065692/pillow-12.3.0-cp313-cp313-win_arm64.whl", hash = "sha256:b629de27fda84b42cde7edef0d85f13b958b47f6e9bbcbba9b673c562a89bd8b", size = 2568185, upload-time = "2026-07-01T11:54:49.137Z" }, + { url = "https://files.pythonhosted.org/packages/dc/01/001f65b68192f0228cc1dbbc8d2530ab5d58b61037ba0587f946fea607cd/pillow-12.3.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:9cf95fe4d0f84c82d282745d9bb08ad9f926efa00be4697e767b814ce40d4330", size = 4161736, upload-time = "2026-07-01T11:54:51.156Z" }, + { url = "https://files.pythonhosted.org/packages/1a/d2/0219746d0fd16fc8a84498e79452375be3797d3ce4044596ce565164b84f/pillow-12.3.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:8728f216dcdb6e6d555cf971cb34076139ad74b31fc2c14da4fafc741c5f6217", size = 4255435, upload-time = "2026-07-01T11:54:53.414Z" }, + { url = "https://files.pythonhosted.org/packages/c8/02/8d0bc62ef0302318c46ff2a512822d2610e81c7aa46c9b3abe6cbaca5ad0/pillow-12.3.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:a45650e8ce7fafffd731db8550230db6b0d306d181a90b67d3e6bca2f1990930", size = 3696262, upload-time = "2026-07-01T11:54:55.739Z" }, + { url = "https://files.pythonhosted.org/packages/85/e2/73c77d218410b14f5f2d565e8a998d5317b7b9c75368d29985139f7a46f0/pillow-12.3.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ba54cfebe86920a559a7c4d6b9050791c20513650a1952ebe3368c7dc70306f8", size = 5350344, upload-time = "2026-07-01T11:54:57.657Z" }, + { url = "https://files.pythonhosted.org/packages/c7/da/32c752228ae345f489e3a42499d817b6c3996da7e8a3bc7a04fc806b243b/pillow-12.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e158cb00350dc278f3b91551101aa7d12415a66ebf2c91d8d5ac14e56ddd3ad0", size = 4780131, upload-time = "2026-07-01T11:54:59.713Z" }, + { url = "https://files.pythonhosted.org/packages/b1/9d/8b2c807dbef61a5197c047afe99823787eb66f63daf9fb2432f91d6f0462/pillow-12.3.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e9aeb04d6aef139de265b29683e119b638208f88cf73cdd1658aa07221165321", size = 6263757, upload-time = "2026-07-01T11:55:01.778Z" }, + { url = "https://files.pythonhosted.org/packages/5c/44/c85361f65dbe00eea8576ee467c768d25129989efb76e94f205e9ca9bb46/pillow-12.3.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:251bf95b67017e27b13d82f5b326234ca62d70f9cf4c2b9032de2358a3b12c7b", size = 6936962, upload-time = "2026-07-01T11:55:03.93Z" }, + { url = "https://files.pythonhosted.org/packages/18/7e/e483414b35800b86b6f08dbbc7803fb5cd52c4d6f897f47d53ea2c7e6f65/pillow-12.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fe3cca2e4e8a592be0f269a1ca4835c25199d9f3ce815c8491048f785b0a0198", size = 6339171, upload-time = "2026-07-01T11:55:05.989Z" }, + { url = "https://files.pythonhosted.org/packages/f0/f4/68c491844841ede6bed70189546b3ee9731cf9f2cbad396faff5e1ccba45/pillow-12.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:23aceaa007d6172b02c277f0cd359c79492bbb14f7072b4ede9fbcaf20648130", size = 7048116, upload-time = "2026-07-01T11:55:08.131Z" }, + { url = "https://files.pythonhosted.org/packages/a3/34/77f3f793fed8efc7d243f21b33c5a3f0d1c97ee70346d3db855587e155ff/pillow-12.3.0-cp314-cp314-win32.whl", hash = "sha256:af8d94b0db561cf68b88a267c5c44b49e134f525d0dc2cb7ed413a66bc23559a", size = 6467209, upload-time = "2026-07-01T11:55:10.408Z" }, + { url = "https://files.pythonhosted.org/packages/f1/e0/492879f69d94f91f60fc8cd05ba03650e9520afebb2fb7aa12777d7c7f38/pillow-12.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:fdafc9cce40277e0f7a0feabce0ee50dd2fa1800f3b38015e51296b5e814048d", size = 7237707, upload-time = "2026-07-01T11:55:12.745Z" }, + { url = "https://files.pythonhosted.org/packages/c9/ac/6b11f2875f1c2ac040d84e1bbf9cf22a88038f901ca1037898b280b38365/pillow-12.3.0-cp314-cp314-win_arm64.whl", hash = "sha256:e91206ee562682b51b98ef4b26a6ef48fd84e15fd4c4bc5ec768eb641d206838", size = 2565995, upload-time = "2026-07-01T11:55:14.736Z" }, + { url = "https://files.pythonhosted.org/packages/52/69/c2208e56af9bfc1913afb24020297a691eb1d4ef688474c8a04913f65e04/pillow-12.3.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:164b31cd1a0490ab6efae01aa5df49da7061be0af1b30e035b6e9a1bfe34ee6e", size = 5352503, upload-time = "2026-07-01T11:55:17.076Z" }, + { url = "https://files.pythonhosted.org/packages/07/70/e5686d753e898a45d778ff1718dba8516ead6ab6b95d85fc8c4b70650cf2/pillow-12.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5afb51d599ea772b8365ae807ae557f18bccfe46ab261fd1c2a9ed700fc6eb17", size = 4782956, upload-time = "2026-07-01T11:55:19.448Z" }, + { url = "https://files.pythonhosted.org/packages/d5/37/25c6692f06927ee973ff18c8d9ee98ad0b4d84ee67a09610c2dd1447958e/pillow-12.3.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3edce1d53195db527e0191f84b71d02022de0540bf43a16ed734ed7537b07385", size = 6322855, upload-time = "2026-07-01T11:55:21.613Z" }, + { url = "https://files.pythonhosted.org/packages/cc/91/420637fcb8f1bc11029e403b4538e6694744428d8246118e45719f944556/pillow-12.3.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bf16ba1b4d0b6b7c8e534936632270cf70eb00dbe09005bc345b2677b726855c", size = 6989642, upload-time = "2026-07-01T11:55:24.006Z" }, + { url = "https://files.pythonhosted.org/packages/10/08/b94d7811281ccf0d143a1cf768d1c49e1e54af63e7b708ab2ee3eb87face/pillow-12.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:24870b09b224f7ae3c39ed07d10e819d06f8720bc551847b1d623832b5b0e28d", size = 6391281, upload-time = "2026-07-01T11:55:26.252Z" }, + { url = "https://files.pythonhosted.org/packages/d2/87/24233f785f55474dc02ce3e739c5528a77e3a862e9333d1dd7a25cc31f70/pillow-12.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:30f2aa603c41533cc25c05acd0da21636e84a315768feb631c937177db558931", size = 7096716, upload-time = "2026-07-01T11:55:28.318Z" }, + { url = "https://files.pythonhosted.org/packages/23/26/fcb2f6e37175b04f53570b59937867e2b80ee1685e744023153028fc14f9/pillow-12.3.0-cp314-cp314t-win32.whl", hash = "sha256:4b0a7fe987b14c31ebda6083f74f22b561fd3739bc0ac51e019622e3d72668c7", size = 6474125, upload-time = "2026-07-01T11:55:30.956Z" }, + { url = "https://files.pythonhosted.org/packages/90/de/3634abee5f1c9e13c56787b7d5517b0ba8d6de51700b95578cf338349c9f/pillow-12.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:962864dc93511324d51ddbb5b9f8731bf71675b93ca612a07441896f4688fb8c", size = 7242939, upload-time = "2026-07-01T11:55:34.044Z" }, + { url = "https://files.pythonhosted.org/packages/ce/2a/fd13f8eb24de5714a6eb444a3d67e2842c6c576e159a43793adf23051351/pillow-12.3.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0740a512dc522224c77d9aa5a8d70d8b7d73fb91f2c21125d8d025d3b8990e45", size = 2567506, upload-time = "2026-07-01T11:55:35.988Z" }, + { url = "https://files.pythonhosted.org/packages/5d/dc/8fdce34ec725a33c81c6ba122b904d6b9024e50ea9ac7bede62fab54506c/pillow-12.3.0-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:0feb2e9d6ad6c9e3c06effe9d00f3f1e618a6643273576b016f591e9315a7139", size = 4162063, upload-time = "2026-07-01T11:55:37.941Z" }, + { url = "https://files.pythonhosted.org/packages/76/66/2044b9a63d3b84ff048228dfcb7cd9bf0df983e8470971bf7d4c57b693de/pillow-12.3.0-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:9e881fca225083806662a5c43d627d215f258ff43c890f831966c7d7ba9c7402", size = 4255549, upload-time = "2026-07-01T11:55:40.022Z" }, + { url = "https://files.pythonhosted.org/packages/52/7e/1f67e6f4ece6b582ee4b539decbcc9f848dc245a93ed8cd7338bafef72f1/pillow-12.3.0-cp315-cp315-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:4998562bf62a445225f22e07c896bb04b35b1b1f2eb6d760584c9c51d7a5f78c", size = 3696331, upload-time = "2026-07-01T11:55:41.98Z" }, + { url = "https://files.pythonhosted.org/packages/12/40/d306fc2c8e4d45d7f175c77edca7063be7b86fe7fe6e68f4353bf71d808c/pillow-12.3.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:dc624f6bc473dacdf7ef7eb8678d0d08edf15cd94fad6ae5c7d6cc67a4e4902f", size = 5350370, upload-time = "2026-07-01T11:55:44.028Z" }, + { url = "https://files.pythonhosted.org/packages/dd/44/668fb1437e8ce420f62d6106eb66e44a5971602a4d794615bdf79315d82d/pillow-12.3.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:71d6097b330eea8fd15097780c8e89cb1a8ce7838669f48c5bacd6f663dd4701", size = 4780147, upload-time = "2026-07-01T11:55:46.073Z" }, + { url = "https://files.pythonhosted.org/packages/0c/08/93fa2e70e30a2d81547e481b6ee2bb9522117221fb1e0ce4b5df70967677/pillow-12.3.0-cp315-cp315-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:28ce87c5ab450a9dd970b52e5aca5fe63ed432d18a2eaddd1979a00a1ba24ace", size = 6273659, upload-time = "2026-07-01T11:55:48.264Z" }, + { url = "https://files.pythonhosted.org/packages/f8/6d/043e96ff814fc31a33077e4cba86082167db520c93632afdf2042febbb0c/pillow-12.3.0-cp315-cp315-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6b02afb9b97f65fbca5f31db6a2a3ba21aa93030225f150fa3f249717e938fb4", size = 6947439, upload-time = "2026-07-01T11:55:50.503Z" }, + { url = "https://files.pythonhosted.org/packages/af/92/ba71d2ee2ac0edf3fa33bd9d5ee9ee080da70b1766f3ca3934f9938ddac9/pillow-12.3.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:1182d52bc2d5e5d7d0949503aa7e36d12f42205dc287e4883f407b1988820d39", size = 6353577, upload-time = "2026-07-01T11:55:52.697Z" }, + { url = "https://files.pythonhosted.org/packages/0f/ce/e63064e2122923ff687c8ad792d0d736a7b3920a56a46982e81a7fdd25d6/pillow-12.3.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:e795b7eb908249c4e43c7c99fac7c2c75dab0c43566e37db472a355f63693d71", size = 7060394, upload-time = "2026-07-01T11:55:55.149Z" }, + { url = "https://files.pythonhosted.org/packages/54/76/a09cc3ccc8d773a7283d34c38bec1708f9e3cc932093cbc4c5e71ac4060b/pillow-12.3.0-cp315-cp315-win32.whl", hash = "sha256:57b3d78c95ba9059768b10e28b813002261d3f3dfc55cc48b0c988f625175827", size = 6467375, upload-time = "2026-07-01T11:55:57.769Z" }, + { url = "https://files.pythonhosted.org/packages/3e/03/1846c49ba3b1d5550392a4bbd06d6fb4578e1cd91a803198b5c90f5f7d53/pillow-12.3.0-cp315-cp315-win_amd64.whl", hash = "sha256:fa4ecea169a355be7a3ade2c783e2ed12f0e40d2c5621cda8b3297faf7fbb9f5", size = 7237048, upload-time = "2026-07-01T11:55:59.975Z" }, + { url = "https://files.pythonhosted.org/packages/fb/bb/89f35dcc79610423f9f195504d7def7f0d1416a711541b42867e25fe3412/pillow-12.3.0-cp315-cp315-win_arm64.whl", hash = "sha256:877c3f311ff35410f690861c4409e7ccbf0cd2f878e50628a28e5a0bb689e658", size = 2566006, upload-time = "2026-07-01T11:56:02.143Z" }, + { url = "https://files.pythonhosted.org/packages/30/88/707027ba09942dfa2c28759b5c222d769290a41c6d20ea60ec250801941f/pillow-12.3.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:e9871b1ffbfa9656b60aeee92ed5136a5742696006fa322b29ea3d8da0ecc9cf", size = 5352509, upload-time = "2026-07-01T11:56:04.2Z" }, + { url = "https://files.pythonhosted.org/packages/b0/6d/00352fa25332c2569cd387851f568cc5a4b75a9adbfb37ac4fbce4c02eec/pillow-12.3.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:53aa02d20d10c3d814d536aa4e5ac9b84ca0ff5a88377963b085ad6822f93e64", size = 4783167, upload-time = "2026-07-01T11:56:06.631Z" }, + { url = "https://files.pythonhosted.org/packages/13/4f/9e049dfa21af7c22427275720e2490267ba8138120add5c4c574deb69782/pillow-12.3.0-cp315-cp315t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:446c34dcc4324b084a53b705127dc15717b22c5e140ae0a3c38349d4efec071e", size = 6329237, upload-time = "2026-07-01T11:56:08.868Z" }, + { url = "https://files.pythonhosted.org/packages/36/16/cf6eeaae8d0fce8dd390a33437cf68c5d5bd73834a2bc6e2f14efda0ab45/pillow-12.3.0-cp315-cp315t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cf1845d02ad822a369a49f2bb9345b1614744267682e7a03527dc3bf6eea1777", size = 6997047, upload-time = "2026-07-01T11:56:11.379Z" }, + { url = "https://files.pythonhosted.org/packages/1e/69/dbf769bdd55f48bf5733cac28edc6364ffaa072ec9ba336266e4fe66be55/pillow-12.3.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:186941b6aef820ad110fb01fb06eb925374dc3a21b17e37ec9a53b250c6fe2d1", size = 6400440, upload-time = "2026-07-01T11:56:13.908Z" }, + { url = "https://files.pythonhosted.org/packages/a0/e1/ffc9cfc2eea0d178da8018e18e959301ad9d6bc9f3edb7181e748a474b97/pillow-12.3.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:f13c32a3abd6079a66d9526e18dad9b6d280384d49d7c54040cd57b6424041d9", size = 7105895, upload-time = "2026-07-01T11:56:16.575Z" }, + { url = "https://files.pythonhosted.org/packages/18/f0/a5595c1e8c3ae44b9828cb2f0fa8155e5095ef04d6327b8f61cf44a3df85/pillow-12.3.0-cp315-cp315t-win32.whl", hash = "sha256:1657923d2d45afb66526e5b933e5b3052e6bdea196c90d3abb2424e18c77dae8", size = 6474384, upload-time = "2026-07-01T11:56:18.855Z" }, + { url = "https://files.pythonhosted.org/packages/e4/04/62bcd9f844984c5938d3b05264a61d797a29d3e0812341a8204af70bbdee/pillow-12.3.0-cp315-cp315t-win_amd64.whl", hash = "sha256:8cd2f7bdda092d99c9fc2fb7391354f306d01443d22785d0cbfafa2e2c8bb418", size = 7243537, upload-time = "2026-07-01T11:56:21.214Z" }, + { url = "https://files.pythonhosted.org/packages/3d/68/1f3066acedf37673694a7141381d8f811ae97f30d34413d236abe7d489f1/pillow-12.3.0-cp315-cp315t-win_arm64.whl", hash = "sha256:06ff022112bc9cbf83b60f8e028d94ad87b60621706487e65f673de61610ab59", size = 2567491, upload-time = "2026-07-01T11:56:23.506Z" }, + { url = "https://files.pythonhosted.org/packages/75/18/2e8b40223153ccbc60df07f9e8928dc0c76202aa4e55ae9f53962b6510d6/pillow-12.3.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:b3c777e849237620b022f7f297dd67705f9f5cf1685f09f02e46f93e92725468", size = 5302510, upload-time = "2026-07-01T11:56:25.736Z" }, + { url = "https://files.pythonhosted.org/packages/46/3e/51fabf59d5ab801ceab709453d3ab6b180083496579549de4c45ced6528a/pillow-12.3.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:b343699e8308bdc51978310e1c959c584e7869cc8c40780058c87da7781a1e94", size = 4736058, upload-time = "2026-07-01T11:56:28.041Z" }, + { url = "https://files.pythonhosted.org/packages/bf/20/22fe9384b7949e25fb1293bcfc84fb82590ff4ea6b37c95b24d26d793d86/pillow-12.3.0-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbd139c8447d25dd750ab79ee274cc5e1fe80fc56340ab10b18a195e1b6eca3e", size = 5237776, upload-time = "2026-07-01T11:56:30.263Z" }, + { url = "https://files.pythonhosted.org/packages/08/14/f6ba68107680ffa74b39985f3f30884e41318fbc4250caa423c79b4788bb/pillow-12.3.0-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e7e480451b9fa137494bccd3a7d69adbe8ac65a87d97be61e11f1b1050a5bac3", size = 5860358, upload-time = "2026-07-01T11:56:32.68Z" }, + { url = "https://files.pythonhosted.org/packages/36/54/0169bc772ec491108b62f644f8ecf1fe5d8ae5ebafde2ee2142210166903/pillow-12.3.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:04f01d28a6aaff387bf842a13be313df23ba0597a44f1a976c9feb3c6ff4711a", size = 7231786, upload-time = "2026-07-01T11:56:35.046Z" }, +] + +[[package]] +name = "platformdirs" +version = "4.11.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/78/9b/560e4be8e26f6fd133a03630a8df0c663b9e8d61b4ade152b72005aec83b/platformdirs-4.11.0.tar.gz", hash = "sha256:0555d18370482847566ffabcaa53ad7c6c1c29f195989ae1ed634a05f76ea1e0", size = 31953, upload-time = "2026-07-21T13:09:36.565Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7d/68/d8d58938dfb1370b266a1a729e6d77a985be23689a0496498ee17b2cbf90/platformdirs-4.11.0-py3-none-any.whl", hash = "sha256:360ccded2b7fce0af0ff80cc8f5942a1c5d99b0e856033acb030bfc634709e74", size = 23247, upload-time = "2026-07-21T13:09:35.422Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "protobuf" +version = "7.35.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/da/01/9ef0afd7999eb9badb3a768b4aedd78c86d4c65cfaf1958ab276199e76b4/protobuf-7.35.1.tar.gz", hash = "sha256:ce115a26fe0c39a2c29973d914d327e516a6455464489fe3cd1e51a1b354f81a", size = 458717, upload-time = "2026-06-11T21:55:40.257Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/03/8aeeb7458d22546bf64b5250ca1daeb5ff757d900e8e4a7476c6f0db843e/protobuf-7.35.1-cp310-abi3-macosx_10_9_universal2.whl", hash = "sha256:24f857477359a85c0c235261b8ba905fd51b2562f4a64ca1df5473f29850cbf6", size = 433226, upload-time = "2026-06-11T21:55:31.719Z" }, + { url = "https://files.pythonhosted.org/packages/37/4b/dfb89eb0e652a1ff073c39a59fb5e3a83cfe9b57a2c83fa6d78270101767/protobuf-7.35.1-cp310-abi3-manylinux2014_aarch64.whl", hash = "sha256:11d6b0ec246892d85215b0a13ca6e0233cf5284b68f0ac02646427f4ff88a799", size = 328847, upload-time = "2026-06-11T21:55:34.035Z" }, + { url = "https://files.pythonhosted.org/packages/0f/58/dc12f2cd484951524af6e3382c785869b9b3fb5e52ee95ae23add53ee8f9/protobuf-7.35.1-cp310-abi3-manylinux2014_s390x.whl", hash = "sha256:b73f9489a4b8b1c9cb1f8ed951c736392592edb24b9d6819f36d2e10b171d5b4", size = 344030, upload-time = "2026-06-11T21:55:34.941Z" }, + { url = "https://files.pythonhosted.org/packages/e4/be/5b3cfe508bfab6761414ff944e3366eb13be4fd71efcd69450f89ba39f43/protobuf-7.35.1-cp310-abi3-manylinux2014_x86_64.whl", hash = "sha256:74758715c53d7158fb76caf4f0cfdacc5329a4b1bb994f865d6cf302d413a1c4", size = 327130, upload-time = "2026-06-11T21:55:35.921Z" }, + { url = "https://files.pythonhosted.org/packages/d8/bc/6d6c7ba8709c85f8f2c390b2b118d6fb08a783676a572271851bf45a7d22/protobuf-7.35.1-cp310-abi3-win32.whl", hash = "sha256:353652e4efd0bca5b5fc2656abf8307ef351f0cf938c9eba09f0e09c20a25c30", size = 428945, upload-time = "2026-06-11T21:55:37.034Z" }, + { url = "https://files.pythonhosted.org/packages/0a/19/8d0cb6f20a1ef7b18f1c8986ad5783f22f84cce39c6ce9a6e645ea55192e/protobuf-7.35.1-cp310-abi3-win_amd64.whl", hash = "sha256:230a75ddfc2de4806e56696ce9640c1cdfdb6543b7cfce98d42a4c0a0e7bdb87", size = 439996, upload-time = "2026-06-11T21:55:38.123Z" }, + { url = "https://files.pythonhosted.org/packages/19/c7/5f7c636ec43e0c545e28d1f1db71990108306f7bdcb89f069ba97e428e7f/protobuf-7.35.1-py3-none-any.whl", hash = "sha256:4bc97768d8fe4ad6743c8a19403e314511ed9f6d13205b687e52421c023ac1b9", size = 171659, upload-time = "2026-06-11T21:55:39.155Z" }, +] + +[[package]] +name = "proxy-tools" +version = "0.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f2/cf/77d3e19b7fabd03895caca7857ef51e4c409e0ca6b37ee6e9f7daa50b642/proxy_tools-0.1.0.tar.gz", hash = "sha256:ccb3751f529c047e2d8a58440d86b205303cf0fe8146f784d1cbcd94f0a28010", size = 2978, upload-time = "2014-05-05T21:02:24.606Z" } + +[[package]] +name = "pycparser" +version = "3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, +] + +[[package]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, +] + +[[package]] +name = "pyluach" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/11/11/42568c1568a75f8803c59f26d29af01a0890352b7a8e03d41ecda8bfb5dd/pyluach-2.3.0.tar.gz", hash = "sha256:ec6e30669d1df50c9ca160486da44a8195bb4c7a5d3d533990d0c5b03accd281", size = 26910, upload-time = "2025-09-09T20:24:39.651Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/c8/f96208ade3ca4c23b372497d0788bcf0f2e0ff4310e5ee693366bc33fdf0/pyluach-2.3.0-py3-none-any.whl", hash = "sha256:4497b731aef59508b079dbf5f00bc5bf4329ac45090a6cd37b5a83756f0e69ab", size = 25914, upload-time = "2025-09-09T20:24:37.831Z" }, +] + +[[package]] +name = "pyobjc-core" +version = "12.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b4/b1/729f7458a63758bd21716648a8abcd9a0c8f2d2e9897763c8a1a1c7fd31b/pyobjc_core-12.2.1.tar.gz", hash = "sha256:7a7b9b018402342cf32bf1956366896350fbe5c0478cb3ef59778f77abed7f07", size = 1063383, upload-time = "2026-06-19T16:19:39.357Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/92/87/16564ef5e4568ee0edd9e712d8111dc8b67621d6bb6ff430646ee2d637dd/pyobjc_core-12.2.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:24b76a63caf0b5369d4a377c7c0438cd70df81539057af3db839bfaa3579e04a", size = 6484662, upload-time = "2026-06-19T16:04:44.979Z" }, + { url = "https://files.pythonhosted.org/packages/8c/88/300ad283bed0c971c52dcac6f70113e138169d4ce6d856ddd03d16081e51/pyobjc_core-12.2.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:a64232bb27ed101d4adc7d42b0e64a6d3331aac7bee7861c037a6777a163f10b", size = 6433347, upload-time = "2026-06-19T16:04:49.341Z" }, + { url = "https://files.pythonhosted.org/packages/3e/1e/b9b0ddffae66996b8779f1f7958adc9f21c13a0448cd3be8d7fe589b5b0f/pyobjc_core-12.2.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:af101222762665a4125157906cb4b23f5d5a63d3851d5e0504f72a1eaaa2cfd2", size = 6436004, upload-time = "2026-06-19T16:04:53.257Z" }, + { url = "https://files.pythonhosted.org/packages/8f/26/bd309ede07784c6e5fac4b440c90a5f72a66da7859ed303a9392fe8a5f3f/pyobjc_core-12.2.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:efe465e3ecc6fc73f7c7622620345d134a8d34564ab1c29d8247e45f4ed55071", size = 6687044, upload-time = "2026-06-19T16:04:57.42Z" }, + { url = "https://files.pythonhosted.org/packages/bd/8a/cfa4f56939d554dbb342ec6e5226a441e2f552bc2002a0ddf7705bb11bef/pyobjc_core-12.2.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:2b8fc0531c27277325e113ac00b8a72a82e6145f0a88175b9425d8de814ff69a", size = 6429289, upload-time = "2026-06-19T16:05:02.191Z" }, + { url = "https://files.pythonhosted.org/packages/42/74/446c89bc18103aaa4a00d1fb85ff8acace9a0dc3f362d9678ebf7571e275/pyobjc_core-12.2.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:9bef500f979e22d54f9da3aaebf6a48f873234b324858bd69256055a318955c7", size = 6690181, upload-time = "2026-06-19T16:05:06.201Z" }, + { url = "https://files.pythonhosted.org/packages/99/c7/0121ee4c616af07ad2de8cd1a286f6978dc9a227eb58b7c2e875cb68a1df/pyobjc_core-12.2.1-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:047c226eeb58a2993ace5e8904e71cc9426ee20d064c617f8fbf32717d37093e", size = 6487078, upload-time = "2026-06-19T16:05:10.093Z" }, + { url = "https://files.pythonhosted.org/packages/b5/a8/cb9fcc150f97d0bf22a2028f88b24cc35949beb1bcc7b8bc5c17d4401677/pyobjc_core-12.2.1-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:1188613805336270279570467e4455b74cb6c0f60913ac74c917ee1c37cfaecb", size = 6733064, upload-time = "2026-06-19T16:05:14.313Z" }, +] + +[[package]] +name = "pyobjc-framework-cocoa" +version = "12.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyobjc-core" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/51/34/fbe38a204643aa4e1b91391cdce07a34da565a69171ebcad08de7438a556/pyobjc_framework_cocoa-12.2.1.tar.gz", hash = "sha256:b94b37fe5730e5ae1fb0052912cd174e6ec329b0bfba4a012ae5db1014b5864b", size = 3125751, upload-time = "2026-06-19T16:20:05.159Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/d6/dc66ea8519a0475efbccf73f82cc28066339bb300a27f5e1bf91ab1d7002/pyobjc_framework_cocoa-12.2.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:dc6da84f4fc62cc25463bbb85e77a57b8d5ac6caf9a60702daf2edb601332f15", size = 387298, upload-time = "2026-06-19T16:07:37.412Z" }, + { url = "https://files.pythonhosted.org/packages/f7/cf/1b3b32b2f28f66cc053c3438ef4e6df36a1591945bf05e7399da18d74553/pyobjc_framework_cocoa-12.2.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:28b9b8bab1c36efb94744786918752d0c1842f5fbb67e7d5ca97b5f736512080", size = 388113, upload-time = "2026-06-19T16:07:38.9Z" }, + { url = "https://files.pythonhosted.org/packages/cc/46/68e8e4d926a2f70fed0437047bc3f9fe08af8fe620d94d80656ebc3cfa9b/pyobjc_framework_cocoa-12.2.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:3b74a78fa7803e547b32e5e8ec1b49987b52fe318383e793bc6cd49b80efbd9f", size = 388183, upload-time = "2026-06-19T16:07:40.483Z" }, + { url = "https://files.pythonhosted.org/packages/2e/f3/dfc9af4c9eb2e5389c860ad5ef252be9fe456db09f39d537555dc5057aa1/pyobjc_framework_cocoa-12.2.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:dc2eaca2f13c7bcd8e41e51a372e47825dea9dd3126108760eed7ba883d2945c", size = 392275, upload-time = "2026-06-19T16:07:42.078Z" }, + { url = "https://files.pythonhosted.org/packages/ec/c8/b90baa8f3592eded79b4be98fb59d2b8dc16b62361e34292bd95806ebd9f/pyobjc_framework_cocoa-12.2.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:b386c324d64ae565c1f6b7dfb77be68f640a1c7c23caa6966ab661131f519561", size = 388357, upload-time = "2026-06-19T16:07:43.364Z" }, + { url = "https://files.pythonhosted.org/packages/98/d8/64a94651b9294702d55e748d94de30e25bc59d0784526be7643f4467eccd/pyobjc_framework_cocoa-12.2.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:a6c584e2af0813cb2f6103b184e632665a26f58c1bd5b08ffd6e95a19c617f7b", size = 392404, upload-time = "2026-06-19T16:07:44.955Z" }, + { url = "https://files.pythonhosted.org/packages/5c/cc/26e8a7bf1f5e8caa38b7f80d486296f9fd3c97e71ad7e5444ef22e802758/pyobjc_framework_cocoa-12.2.1-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:b6023657b8d6cc049a21bd6b4752425f2f53c42f9f0b02d64c7608cc484bf103", size = 388589, upload-time = "2026-06-19T16:07:46.276Z" }, + { url = "https://files.pythonhosted.org/packages/b6/f3/eedf743a303ea742b8e082afe3613fb4d6618bc1a48cf2568b004ce906f7/pyobjc_framework_cocoa-12.2.1-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:c685ccd8e266a07cf912a2c5a13b1f2eff2a868a1aff163b4801b4687bd425e1", size = 392691, upload-time = "2026-06-19T16:07:47.477Z" }, +] + +[[package]] +name = "pyobjc-framework-quartz" +version = "12.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyobjc-core" }, + { name = "pyobjc-framework-cocoa" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3b/f6/2a8b84dbf1fe7c04dd96ea73d991678d4e09a909f51971ecc51629bb2ab4/pyobjc_framework_quartz-12.2.1.tar.gz", hash = "sha256:b3b8b6f71e66147f8ff9e6213864cc8527e3a0b1ee90835b93ce221f4802d9b0", size = 3215521, upload-time = "2026-06-19T16:21:30.199Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b9/08/527d1ff856e2f2446b5887be01989cc08f9adaf3de7d4eb13d07826c362f/pyobjc_framework_quartz-12.2.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:60f29408b4f9ed5391a29c6b63e2aa56ddfb8b66b3fb47962930427981e14462", size = 217998, upload-time = "2026-06-19T16:16:02.978Z" }, + { url = "https://files.pythonhosted.org/packages/14/fc/d7c7b3134cdbd1a487f3f77b5be125d87a6c9e7d9411035739d99335cc0c/pyobjc_framework_quartz-12.2.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:de9c8cca7e95290c8d540466af11c7cdfe3a5458e6f56c34006d5b45243f9ed9", size = 219000, upload-time = "2026-06-19T16:16:04.29Z" }, + { url = "https://files.pythonhosted.org/packages/0a/4b/861f91a1565d3189ee899e177b915551fb9a7e2ca25414025a8974f04e74/pyobjc_framework_quartz-12.2.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:54c9bc7f507192691841ee4eba5bf36990b259df83ac728efed2d7ea1cd021e4", size = 219403, upload-time = "2026-06-19T16:16:05.645Z" }, + { url = "https://files.pythonhosted.org/packages/ba/b5/b27010d2f288737f627f74be6d5549f49c841542365c84b9a3011fe39ce7/pyobjc_framework_quartz-12.2.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:bfc0d2badd819823d21df8069dcf9544ce360ed747a8895c51bdb25d8d125f45", size = 224458, upload-time = "2026-06-19T16:16:07.252Z" }, + { url = "https://files.pythonhosted.org/packages/8b/5d/85ffd9d433989205d572a50d625c63b29c05e0c5235a725f15ae1023672c/pyobjc_framework_quartz-12.2.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:ceb56939c337b36d9d81185ade31f77dc52c85cf79bb16e53e9b32f54b6bb3f5", size = 219769, upload-time = "2026-06-19T16:16:08.814Z" }, + { url = "https://files.pythonhosted.org/packages/e2/d6/b917e4b63d72ea84a27121076f3033f23f6497c0e6ce8d304766c899897f/pyobjc_framework_quartz-12.2.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:8105c98b798f2bf81c05c54bddeeadbf62f0b5dfec13bd6e719dd2cdf7e1cddf", size = 224717, upload-time = "2026-06-19T16:16:10.215Z" }, + { url = "https://files.pythonhosted.org/packages/04/e2/f3c1ed3228f7430ef5ade23db6f1fcbae99290f177ce5653348fd9e05f4d/pyobjc_framework_quartz-12.2.1-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:bbc214f1a216b5d3651bc832d0ac4589f029f3f37cd6cbb370aac12a7c77942c", size = 219825, upload-time = "2026-06-19T16:16:11.433Z" }, + { url = "https://files.pythonhosted.org/packages/66/2a/2c99a5ad2fe0a11600ea123b8e9a08ff138fcb2ad1e13e376f4bd4aa1d96/pyobjc_framework_quartz-12.2.1-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:ca61624a0b0e6286d8a0f97f47eb9011e4e81e9a339db436d48af527e7065bb1", size = 224770, upload-time = "2026-06-19T16:16:13.035Z" }, +] + +[[package]] +name = "pyobjc-framework-security" +version = "12.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyobjc-core" }, + { name = "pyobjc-framework-cocoa" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/44/b8/4267b802d8dba6de468e7d0765b05cc4e146fa376ed9f55e0b6461016bef/pyobjc_framework_security-12.2.1.tar.gz", hash = "sha256:d7831b1537f4346892e7f2f0e2b09d79bee98919b0767f4061278d0e03028f2d", size = 181065, upload-time = "2026-06-19T16:21:40.151Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/be/ac/f2ff946edfaf16b4ce5e31afac5e519f83705c0f4842fd25134ecb8f2f4a/pyobjc_framework_security-12.2.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:ce461296b003b2ba17c8b65f6339f9d2fd5dcfa2b3b52ddc0a696334cc8974c5", size = 41306, upload-time = "2026-06-19T16:17:16.816Z" }, + { url = "https://files.pythonhosted.org/packages/4e/5b/2719bc4062e6c27083191fd20e365ae02d0bf1c22f4d1a88211e3d96b369/pyobjc_framework_security-12.2.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:76ff6e44e62d3e15651540493879bf16687d862c4f10f3cadade757811c8b8d0", size = 41300, upload-time = "2026-06-19T16:17:17.702Z" }, + { url = "https://files.pythonhosted.org/packages/15/90/dccd4cd6877ef208957dc1f3675287d8614a4dcd2a3ee0a5e56f5fb5a1ba/pyobjc_framework_security-12.2.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:990013baba29d6f985d8950b23701129b2597b3d16f628b785fe97596d8a8de3", size = 41299, upload-time = "2026-06-19T16:17:18.511Z" }, + { url = "https://files.pythonhosted.org/packages/ce/af/f9e8040e0c3ef6a50392a46ad1df482a666aa615180d40730b00282ff81f/pyobjc_framework_security-12.2.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:066a3e5e9d368e7a6ba8dd52be2077a634ef12a54fbfcc78b3b8154a8f988a1d", size = 42179, upload-time = "2026-06-19T16:17:19.48Z" }, + { url = "https://files.pythonhosted.org/packages/c9/3c/76e2a8bb8d5fe48f0e8e25c6abec1609f3667cc39935017badfe9e9603f2/pyobjc_framework_security-12.2.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:5319ae49b8874363ab51c6ff4d85d4ea0cfa6d836fe0306e901ba9ae560b880d", size = 41370, upload-time = "2026-06-19T16:17:20.501Z" }, + { url = "https://files.pythonhosted.org/packages/14/6e/7120956e9833b2c70757eec1f65f57c191e00662cf74c4545d88315643fa/pyobjc_framework_security-12.2.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:21618431e0dbfbd3d4029445e3118af88e5d7e52ddecf9a2d17c759c51628d85", size = 42926, upload-time = "2026-06-19T16:17:21.425Z" }, + { url = "https://files.pythonhosted.org/packages/b3/ff/0bafc557523e5755f74dd5363386a1e9b03f611e2e36df0737a508cd5ab4/pyobjc_framework_security-12.2.1-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:fa192e9df479375e6242adcadb9a44f32907dd7fe1207608710cd3af65fe3c84", size = 41376, upload-time = "2026-06-19T16:17:22.337Z" }, + { url = "https://files.pythonhosted.org/packages/47/33/33d266117e46fef148caa4f986b3d896cb9bfd76bef48bd761cb60c758ee/pyobjc_framework_security-12.2.1-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:07cd044a7996f9a897040c49055fa3bdf565acac4a25b834a72e60602376146d", size = 42944, upload-time = "2026-06-19T16:17:23.371Z" }, +] + +[[package]] +name = "pyobjc-framework-uniformtypeidentifiers" +version = "12.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyobjc-core" }, + { name = "pyobjc-framework-cocoa" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/74/a1/108fa1e5a3dd8aff626f98fb97de370323b290404b04ffa2ef9420665ed3/pyobjc_framework_uniformtypeidentifiers-12.2.1.tar.gz", hash = "sha256:1fb89d13aa3c2df8e6d6536f6df3493fe5a6caefd2a5adebf17c5af3b29ed4a2", size = 20679, upload-time = "2026-06-19T16:21:55.739Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e3/44/18a7b3c3b4f9f6784fddf64ed5a2c148577d0300705a50e8ab81da8fc71d/pyobjc_framework_uniformtypeidentifiers-12.2.1-py2.py3-none-any.whl", hash = "sha256:ea08413ad895a7dfea13670e26548bcf5b00154084cdfb5d8f96603320e77cf3", size = 5042, upload-time = "2026-06-19T16:18:54.085Z" }, +] + +[[package]] +name = "pyobjc-framework-webkit" +version = "12.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyobjc-core" }, + { name = "pyobjc-framework-cocoa" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/11/d2/b230c594f70ecb970b4cef67bae2648d1bfa5b381e9b7e3710bf24ec8887/pyobjc_framework_webkit-12.2.1.tar.gz", hash = "sha256:a56acae55b50d549b20dff2921ad1099add8fbc377d0de09ddc2ba50957f7def", size = 332374, upload-time = "2026-06-19T16:22:01.988Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f0/d3/2ab99d3975dd4624dd943e5a7c8d37e40258d3c9fcf4f26baf09a24e6c9b/pyobjc_framework_webkit-12.2.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:af5c4ccdf03845adac082823a3b4341b5b2fe62d2d664550afa705b5286a06fc", size = 50264, upload-time = "2026-06-19T16:19:30.607Z" }, + { url = "https://files.pythonhosted.org/packages/84/47/7a2099eb2e062c6230a9440f1795cf34056ca5e16ef25c8aad7c059b8734/pyobjc_framework_webkit-12.2.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:7e04dcc08cdc59380113ea1232af75a0a04c2426418ebe967b4c0045c973f776", size = 50372, upload-time = "2026-06-19T16:19:31.581Z" }, + { url = "https://files.pythonhosted.org/packages/2a/a4/202ec288808011d3f459d000d593e88b1118f2d1d5a4dfaaf5232f2c2ac2/pyobjc_framework_webkit-12.2.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:23bee8bf7077f91da4e3ae54a00c7f5e4414319e15f98be8584dbd67c4043fae", size = 50387, upload-time = "2026-06-19T16:19:32.522Z" }, + { url = "https://files.pythonhosted.org/packages/95/a4/f796e94b43a66704b6ae17c747c7b97fd4b79348f1cfa9bef7b008aaa718/pyobjc_framework_webkit-12.2.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:00ffb254f97e9ffdd0a82c1faa61a07f6072ba900fa8aba70c83c21198b52e4e", size = 50853, upload-time = "2026-06-19T16:19:33.43Z" }, + { url = "https://files.pythonhosted.org/packages/ae/f6/d24716fef19ccc3d880e99029458803f0174c05df310d991eb97ea3a0799/pyobjc_framework_webkit-12.2.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:67030258c3cd66e8495ccfccef3d2d58010ff0209284c5115e5afdb0e9fd6de1", size = 50499, upload-time = "2026-06-19T16:19:34.45Z" }, + { url = "https://files.pythonhosted.org/packages/a8/6c/817119a52efcc229a30ceff56a0641005a431806a1f555e0571626ba313a/pyobjc_framework_webkit-12.2.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:5d91527c9950c79269dd0d70f2bb8668c298dd06930637c1c063ce5f274a87e5", size = 50967, upload-time = "2026-06-19T16:19:35.474Z" }, + { url = "https://files.pythonhosted.org/packages/2d/59/5fac0754d53b2a72aed6f424dfc72e5fa245f83cb57c2e00d02e45390fca/pyobjc_framework_webkit-12.2.1-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:657825081484c9920c50b76b469b9583f116225b0449c9d95c46cbc8c640adc8", size = 50498, upload-time = "2026-06-19T16:19:36.397Z" }, + { url = "https://files.pythonhosted.org/packages/da/0c/e997e33d99d4ad91da2cf70f0e51ac39b03c58ba210548e9e944bbb421be/pyobjc_framework_webkit-12.2.1-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:f46adcc6227873f2b14d74b2e789c937f227722274ab59b9fa3c04c6ecb46dd5", size = 50958, upload-time = "2026-06-19T16:19:37.424Z" }, +] + +[[package]] +name = "pystray" +version = "0.19.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pillow" }, + { name = "pyobjc-framework-quartz", marker = "sys_platform == 'darwin'" }, + { name = "python-xlib", marker = "sys_platform == 'linux'" }, + { name = "six" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/5c/64/927a4b9024196a4799eba0180e0ca31568426f258a4a5c90f87a97f51d28/pystray-0.19.5-py2.py3-none-any.whl", hash = "sha256:a0c2229d02cf87207297c22d86ffc57c86c227517b038c0d3c59df79295ac617", size = 49068, upload-time = "2023-09-17T13:44:26.872Z" }, +] + +[[package]] +name = "pytest" +version = "9.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, +] + +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, +] + +[[package]] +name = "python-xlib" +version = "0.33" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/86/f5/8c0653e5bb54e0cbdfe27bf32d41f27bc4e12faa8742778c17f2a71be2c0/python-xlib-0.33.tar.gz", hash = "sha256:55af7906a2c75ce6cb280a584776080602444f75815a7aff4d287bb2d7018b32", size = 269068, upload-time = "2022-12-25T18:53:00.824Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fc/b8/ff33610932e0ee81ae7f1269c890f697d56ff74b9f5b2ee5d9b7fa2c5355/python_xlib-0.33-py2.py3-none-any.whl", hash = "sha256:c3534038d42e0df2f1392a1b30a15a4ff5fdc2b86cfa94f072bf11b10a164398", size = 182185, upload-time = "2022-12-25T18:52:58.662Z" }, +] + +[[package]] +name = "pythonnet" +version = "3.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "clr-loader" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/05/57/da1992e44663b71365c6e842c8d7fa453d4ec45fb99a68cfee5b7e944d3c/pythonnet-3.1.0.tar.gz", hash = "sha256:7b34c382905d10a371509ffafd64cae0416305c28817738a9cd138336f4e9991", size = 250599, upload-time = "2026-05-23T20:30:21.578Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ac/4b/52414f442624d2589f5374a48c08d5ae94f24bea67fc13a20a752884e5b7/pythonnet-3.1.0-cp310.cp311.cp312.cp313.cp314-none-any.whl", hash = "sha256:698dd88edc198819ad63b624a6ebe76208c7b46e4fe13626f65e484f0358d6ba", size = 217578, upload-time = "2026-05-23T20:30:19.527Z" }, + { url = "https://files.pythonhosted.org/packages/db/67/031124fdcb937c266a3265118525bbf6dc13b8c79786d6a7290aecb6e7bb/pythonnet-3.1.0-cp310.cp311.cp312.cp313.cp314-none-win32.win_amd64.whl", hash = "sha256:7bdd4de03df3547a48122a3989265c8b31d5be0d19dadffa009eec7df8085e0b", size = 1644898, upload-time = "2026-05-23T20:30:16.213Z" }, +] + +[[package]] +name = "pytz" +version = "2026.3.post1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fb/48/fb042503b6ca6cd271261dc559fd6432f7d8c713153e9ec5c591af4dfc1c/pytz-2026.3.post1.tar.gz", hash = "sha256:2211d3fcf9a797d3405cac96ac7f61d80e6a644f72a3309607282fe8a2010c5d", size = 319745, upload-time = "2026-07-25T15:12:07.385Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0f/7b/39c34ca613b0b198cb866466651b26b045e2009864c5183c979a3b83f383/pytz-2026.3.post1-py2.py3-none-any.whl", hash = "sha256:dd95840dd199baea12d9cc096a1d452caa6596a1c1e4b5f3dbd1541855d5e815", size = 508283, upload-time = "2026-07-25T15:12:05.782Z" }, +] + +[[package]] +name = "pywebview" +version = "6.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "bottle" }, + { name = "proxy-tools" }, + { name = "pyobjc-core", marker = "sys_platform == 'darwin'" }, + { name = "pyobjc-framework-cocoa", marker = "sys_platform == 'darwin'" }, + { name = "pyobjc-framework-quartz", marker = "sys_platform == 'darwin'" }, + { name = "pyobjc-framework-security", marker = "sys_platform == 'darwin'" }, + { name = "pyobjc-framework-uniformtypeidentifiers", marker = "sys_platform == 'darwin'" }, + { name = "pyobjc-framework-webkit", marker = "sys_platform == 'darwin'" }, + { name = "pythonnet", marker = "sys_platform == 'win32'" }, + { name = "qtpy", marker = "sys_platform == 'openbsd6'" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/59/4a/05307135dafba67778669d194bd1a3822a7685ec9ee8a6d7e70856c1a551/pywebview-6.2.1.tar.gz", hash = "sha256:71b7136752e40824655304d938efb62014218d1a90bd8e87e1cbdb1ce9c466af", size = 513126, upload-time = "2026-04-15T09:02:16.595Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3d/25/9491695c22c4842c5b3903b4dc172e0eecf67a27c0af34a71512c9b76a0a/pywebview-6.2.1-py3-none-any.whl", hash = "sha256:9d07275f53894ab4d5e2e0e996227193e7187dec276d9b624dccbce029216b46", size = 525463, upload-time = "2026-04-15T09:02:10.186Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" }, + { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" }, + { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" }, + { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" }, + { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" }, + { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" }, + { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" }, + { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" }, + { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" }, + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + +[[package]] +name = "qtpy" +version = "2.4.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "packaging" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/70/01/392eba83c8e47b946b929d7c46e0f04b35e9671f8bb6fc36b6f7945b4de8/qtpy-2.4.3.tar.gz", hash = "sha256:db744f7832e6d3da90568ba6ccbca3ee2b3b4a890c3d6fbbc63142f6e4cdf5bb", size = 66982, upload-time = "2025-02-11T15:09:25.759Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/76/37c0ccd5ab968a6a438f9c623aeecc84c202ab2fabc6a8fd927580c15b5a/QtPy-2.4.3-py3-none-any.whl", hash = "sha256:72095afe13673e017946cc258b8d5da43314197b741ed2890e563cf384b51aa1", size = 95045, upload-time = "2025-02-11T15:09:24.162Z" }, +] + +[[package]] +name = "requests" +version = "2.34.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-05-14T19:25:27.735Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" }, +] + +[[package]] +name = "rich" +version = "15.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c0/8f/0722ca900cc807c13a6a0c696dacf35430f72e0ec571c4275d2371fca3e9/rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36", size = 230680, upload-time = "2026-04-12T08:24:00.75Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654, upload-time = "2026-04-12T08:24:02.83Z" }, +] + +[[package]] +name = "ruff" +version = "0.15.20" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/dc/35b341fc554ba02f217fc10da57d1a75168cfbcf75b0ef2202176d4c4f2d/ruff-0.15.20.tar.gz", hash = "sha256:1416eb04349192646b54de98f146c4f59afe37d0decfc02c3cbbf396f3a28566", size = 4755489, upload-time = "2026-06-25T17:20:37.578Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/d9/2d5014f0253ba541d2061d9fa7193f48e941c8b21bb88a7ff9bbe0bd0596/ruff-0.15.20-py3-none-linux_armv6l.whl", hash = "sha256:00e188c53e499c3c1637f73c91dcf2fb56d576cab76ce1be50a27c4e80e37078", size = 10839665, upload-time = "2026-06-25T17:19:44.702Z" }, + { url = "https://files.pythonhosted.org/packages/c6/d3/ac1798ba64f670698867fcfc591d50e7e421bef137db564858f619a30fcf/ruff-0.15.20-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:9ebd1fd9b9c95fc0bd7b2761aebec1f030013d2e193a2901b224af68fe47251b", size = 11208649, upload-time = "2026-06-25T17:19:48.787Z" }, + { url = "https://files.pythonhosted.org/packages/47/47/d3ac899991202095dfcf3d5176be4272642be3cf981a2f1a30f72a2afb95/ruff-0.15.20-py3-none-macosx_11_0_arm64.whl", hash = "sha256:c5b16cdd67ca108185cd36dce98c576350c03b1660a751de725fb049193a0632", size = 10622638, upload-time = "2026-06-25T17:19:51.354Z" }, + { url = "https://files.pythonhosted.org/packages/33/13/4e043fe30aa94d4ff5213a9881fc296d12960f5971b234a5263fdc225312/ruff-0.15.20-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3413bb3c3d2ca6a8208f1f4809cd2dca3c6de6d0b491c0e70847672bde6e6efd", size = 10984227, upload-time = "2026-06-25T17:19:54.044Z" }, + { url = "https://files.pythonhosted.org/packages/76/e6/92e7bf40388bc5800073b96564f56264f7e48bfd1a498f5ced6ae6d5a769/ruff-0.15.20-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bd7ec42b3bb3da066488db093308a69c4ac5ee6d2af333a86ba6e2eb2e7dd44b", size = 10622882, upload-time = "2026-06-25T17:19:57.037Z" }, + { url = "https://files.pythonhosted.org/packages/13/7a/43460be3f24495a3aa46d4b16873e2c4941b3b5f0b00cf88c03b7b94b339/ruff-0.15.20-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e1a36ad0eb77fba9aabfb69ede54de6f376d04ac18ebea022847046d340a8267", size = 11474808, upload-time = "2026-06-25T17:20:00.357Z" }, + { url = "https://files.pythonhosted.org/packages/27/a0/f37077884873221c6b33b4ab49eb18f9f88e54a16a25a5bca59bef46dd66/ruff-0.15.20-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b6df3b1e4610432f0386dba04d853b5f08cbbc903410c6fcc02f620f05aff53c", size = 12293094, upload-time = "2026-06-25T17:20:03.446Z" }, + { url = "https://files.pythonhosted.org/packages/a6/74/165545b60256a9704c21ac0ec4a0d07933b320812f9584836c9f4aca4292/ruff-0.15.20-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e89f198a1ea6ef0d727c1cf16088bc91a6cb0ab947dedc966715691647186eae", size = 11526176, upload-time = "2026-06-25T17:20:06.301Z" }, + { url = "https://files.pythonhosted.org/packages/86/b1/a976a136d40ade83ce743578399865f57001003a409acadc0ecbb3051082/ruff-0.15.20-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:309809086c2acb67624950a3c8133e80f32d0d3e27106c0cd60ff26657c9f24b", size = 11520767, upload-time = "2026-06-25T17:20:09.191Z" }, + { url = "https://files.pythonhosted.org/packages/19/0f/f032696cb01c9b54c0263fa393474d7758f1cdc021a01b04e3cbc2500999/ruff-0.15.20-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:2d2374caa2f2c2f9e2b7da0a50802cfb8b79f55a9b5e49379f564544fbf56487", size = 11500132, upload-time = "2026-06-25T17:20:13.602Z" }, + { url = "https://files.pythonhosted.org/packages/4b/f4/51b1a14bc69e8c224b15dab9cce8e99b425e0455d462caa2b3c9be2b6a8e/ruff-0.15.20-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:a1ed17b65293e0c2f22fc387bc13198a5de94bf4429589b0ff6946b0feaf21a3", size = 10943828, upload-time = "2026-06-25T17:20:16.635Z" }, + { url = "https://files.pythonhosted.org/packages/71/4b/fe267640783cd02bf6c5cc290b1df1051be2ec294c678b5c15fe19e52343/ruff-0.15.20-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:f701305e66b38ea6c91882490eb73459796808e4c6362a1b765255e0cdcd4053", size = 10645418, upload-time = "2026-06-25T17:20:19.4Z" }, + { url = "https://files.pythonhosted.org/packages/b0/c0/a65aa4ec2f5e87a1df32dc3ec1fede434fe3dfd5cbcf3b503cafc676ab54/ruff-0.15.20-py3-none-musllinux_1_2_i686.whl", hash = "sha256:5b9c0c367ad8e5d0d5b5b8537864c469a0a0e55417aadfbeca41fa61333be9f4", size = 11211770, upload-time = "2026-06-25T17:20:22.033Z" }, + { url = "https://files.pythonhosted.org/packages/5a/a4/0caa331d954ae2723d729d351c989cb4ca8b6077d5c6c2cb6de75e98c041/ruff-0.15.20-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:01cc00dd58f0df339d0e902219dd53990ea99996a0344e5d9cc8d45d5307e460", size = 11618698, upload-time = "2026-06-25T17:20:25.259Z" }, + { url = "https://files.pythonhosted.org/packages/10/9b/5f14927848d2fd4aa891fd88d883788c5a7baba561c7874732364045708c/ruff-0.15.20-py3-none-win32.whl", hash = "sha256:ed65ef510e43a137207e0f01cfcf998aeddb1aeeda5c9d35023e910284d7cf21", size = 10857322, upload-time = "2026-06-25T17:20:28.612Z" }, + { url = "https://files.pythonhosted.org/packages/fa/f0/fe47c501f9dea92a26d788ff98bb5d92ed4cb4c88792c5c88af6b697dc8e/ruff-0.15.20-py3-none-win_amd64.whl", hash = "sha256:a525c81c70fb0380344dd1d8745d8cc1c890b7fc94a58d5a07bd8eb9557b8415", size = 11993274, upload-time = "2026-06-25T17:20:31.871Z" }, + { url = "https://files.pythonhosted.org/packages/d7/2b/9555445e1201d92b3195f45cdb153a0b68f24e0a4273f6e3d5ab46e212bb/ruff-0.15.20-py3-none-win_arm64.whl", hash = "sha256:2f5b2a6d614e8700388806a14996c40fab2c47b819ef57d790a34878858ed9ca", size = 11343498, upload-time = "2026-06-25T17:20:35.03Z" }, +] + +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, +] + +[[package]] +name = "soupsieve" +version = "2.9.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d9/38/e12680bbe6b4f8f3d17adcaf38d26850aa756c85cf4a80e79fc12a018fe8/soupsieve-2.9.1.tar.gz", hash = "sha256:c33e6605bbc71dd628b00c632d58ae607c22bade247e52553928f83bbb75b4ba", size = 122261, upload-time = "2026-07-21T16:57:17.452Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0f/2c/437fe806897c2d6cfdc3ee43a18da8bf8e568530a4ae9bac781541ca9896/soupsieve-2.9.1-py3-none-any.whl", hash = "sha256:4f4477399246b7a0c720a88ca2454b11cd6bb9ae4c9d170140786e916776c14c", size = 37404, upload-time = "2026-07-21T16:57:16.421Z" }, +] + +[[package]] +name = "toolz" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/11/d6/114b492226588d6ff54579d95847662fc69196bdeec318eb45393b24c192/toolz-1.1.0.tar.gz", hash = "sha256:27a5c770d068c110d9ed9323f24f1543e83b2f300a687b7891c1a6d56b697b5b", size = 52613, upload-time = "2025-10-17T04:03:21.661Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/12/5911ae3eeec47800503a238d971e51722ccea5feb8569b735184d5fcdbc0/toolz-1.1.0-py3-none-any.whl", hash = "sha256:15ccc861ac51c53696de0a5d6d4607f99c210739caf987b5d2054f3efed429d8", size = 58093, upload-time = "2025-10-17T04:03:20.435Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, +] + +[[package]] +name = "tzdata" +version = "2026.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ba/19/1b9b0e29f30c6d35cb345486df41110984ea67ae69dddbc0e8a100999493/tzdata-2026.2.tar.gz", hash = "sha256:9173fde7d80d9018e02a662e168e5a2d04f87c41ea174b139fbef642eda62d10", size = 198254, upload-time = "2026-04-24T15:22:08.651Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ce/e4/dccd7f47c4b64213ac01ef921a1337ee6e30e8c6466046018326977efd95/tzdata-2026.2-py2.py3-none-any.whl", hash = "sha256:bbe9af844f658da81a5f95019480da3a89415801f6cc966806612cc7169bffe7", size = 349321, upload-time = "2026-04-24T15:22:05.876Z" }, +] + +[[package]] +name = "urllib3" +version = "2.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, +] + +[[package]] +name = "websockets" +version = "16.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/21/f7/bc3a25c5ec26ce62ce487690becc2f3710bbc7b33338f005ad390db0b986/websockets-16.1.1.tar.gz", hash = "sha256:db234eda965dcce15df96bb9709f587cd87d4d52aaf0e80e2f34ec04c7670c57", size = 182204, upload-time = "2026-07-17T22:51:05.858Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2b/03/47debfe28e9d6d354be5d777b67fd44c359b9eb299a5d103500bd7cc3e37/websockets-16.1.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:d0fcf657e9f13ff4b177960ab2200237b12994232dfb6df16f1cfe1d4339f93c", size = 179566, upload-time = "2026-07-17T22:48:49.596Z" }, + { url = "https://files.pythonhosted.org/packages/72/93/31efa1ed78c17e5cfc229fd449e3966e1b9cc15753204cd585cc8dd01f4a/websockets-16.1.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b852788aa51764e2d8e4cf5493d559326bcae5e38d16ba25ffa322b034df272a", size = 177250, upload-time = "2026-07-17T22:48:50.942Z" }, + { url = "https://files.pythonhosted.org/packages/01/4a/542378ab3972b0c1cf1df3df3eff9591cea0d30c58c3aa3c4ddbc244e787/websockets-16.1.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1427fb4cf0d72f66333e2cacc3ff5f575bf2d7008166ce991a4a470b21d51a22", size = 177528, upload-time = "2026-07-17T22:48:52.59Z" }, + { url = "https://files.pythonhosted.org/packages/33/d9/162321f63c7eed558e9e1798ed7a1e34a4f6dab51f35419e4ed7a4907979/websockets-16.1.1-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:da4ca1a9d72f9030b3146b8d7022719a9f3d478f61efe6f7dd51d243f61c51b2", size = 186859, upload-time = "2026-07-17T22:48:53.915Z" }, + { url = "https://files.pythonhosted.org/packages/de/09/87df740f7430ce564bd52402e9c9458d4d0459cc7d2ee29e530c8204851b/websockets-16.1.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:86d7f0f8bdb25d2c632b72527325e4776430fd5bc61b9118de4e2b8ddb5f5b01", size = 188095, upload-time = "2026-07-17T22:48:55.384Z" }, + { url = "https://files.pythonhosted.org/packages/d2/12/3d2703af7cc095f3c81904c92208cc1ae79affbc67376944b50ee9301f73/websockets-16.1.1-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7dfcad78ea1492ee3a9ec765cb7f51bbc17d477107aaf6b22abf7b2558d1c5a0", size = 191385, upload-time = "2026-07-17T22:48:56.742Z" }, + { url = "https://files.pythonhosted.org/packages/1d/69/986aa0234a964a00f5149cfc46e136e96c8faad1c783474550f40d31aef4/websockets-16.1.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:fb9a0a6dc3d1b3986cb88091b6899f0396651e0f74e2c9766ab8d6ffc3842e29", size = 188653, upload-time = "2026-07-17T22:48:58.134Z" }, + { url = "https://files.pythonhosted.org/packages/35/6b/10f9d03e3970a69ba67bd3b46b87a929b586d0300fadbfe14f57c1f85490/websockets-16.1.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:29dfa8114c4a620c69591c5973860f768eac29d3fd6904f37f34266cb219c512", size = 187426, upload-time = "2026-07-17T22:48:59.515Z" }, + { url = "https://files.pythonhosted.org/packages/56/db/bb3aad62bf63d8bb3f0634b2eabffcfb3677a34bd19492110ff6869cf703/websockets-16.1.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6ff9417c0ada4d0f7d212f928303e5579bdf3ace4c802fa4afabb30995da58c3", size = 184882, upload-time = "2026-07-17T22:49:00.916Z" }, + { url = "https://files.pythonhosted.org/packages/6c/4c/c09a2ea9bfbeccce52fdc383e5f28af4bc8843338aabac28c81489af6120/websockets-16.1.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8fe0b50da2d84535fb4f7b4bfa951280f97ce3d558a0443b541166d609e67b57", size = 187584, upload-time = "2026-07-17T22:49:02.283Z" }, + { url = "https://files.pythonhosted.org/packages/c7/8b/31bb4eb4d9eaacf1fdd39d115772a8aeaedfc19b5dc262e57ffbc8a9d42c/websockets-16.1.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:34420aaa64440ebd51ac72ca8a45ef4626429438c9b02e633ae412ed43f925d3", size = 186174, upload-time = "2026-07-17T22:49:03.973Z" }, + { url = "https://files.pythonhosted.org/packages/2f/e4/dc02d725610a1ad49e193ef91a548194d71bdc6cdf27da83067dd1f73995/websockets-16.1.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:a6a61aff018180c9c50b7b0da33bfd29d378af3497429c95006c589a23a11648", size = 187986, upload-time = "2026-07-17T22:49:05.553Z" }, + { url = "https://files.pythonhosted.org/packages/e0/73/30ed84c8bfd14c73d4af29d5ed9323c3073b48e0b7b23b67070f4e7fd59b/websockets-16.1.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:04fd29a0e2fe9414a95b00e92c67ae51bf900c50c0f8a4b2dafdad621f49ea1d", size = 185565, upload-time = "2026-07-17T22:49:06.959Z" }, + { url = "https://files.pythonhosted.org/packages/7d/d3/4be8d4959f51e31b4f8fc0ece12b45bd3b6c0d15ea23b9990d9c11fc805f/websockets-16.1.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:5c31aa7e39ee3e8a358573257f1c0bb5c52430d1b637030dd9c8cc2c282926be", size = 186598, upload-time = "2026-07-17T22:49:08.293Z" }, + { url = "https://files.pythonhosted.org/packages/26/fa/abb38597a52d84ed9cfacadc7a0c6f2db282c0ab23cdf72b58a666a21227/websockets-16.1.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:d14bfb217eb4701e850f1525c9d29d79c44794cdf1c299ead25f39f8c78dea81", size = 186834, upload-time = "2026-07-17T22:49:09.766Z" }, + { url = "https://files.pythonhosted.org/packages/59/80/1119ad08a228b90c4eb77fbe48df7836731a605f5f881ba701ca826a4a65/websockets-16.1.1-cp311-cp311-win32.whl", hash = "sha256:2e28e602bb13da44fbe518c1781a88e3b9d4c3d48d02c9bad83e546164336f57", size = 179940, upload-time = "2026-07-17T22:49:11.196Z" }, + { url = "https://files.pythonhosted.org/packages/71/b2/e511c1c6f64a95c2f3fc54bffda0e14eaa7e9442be605c29270f7589b918/websockets-16.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:7421fad442de870a8cbf2287d1cad7e706ece0dbfeba5e911df132cbdc1cb56a", size = 180239, upload-time = "2026-07-17T22:49:12.519Z" }, + { url = "https://files.pythonhosted.org/packages/17/9d/681cda21c9eee743203a6cb79b9d3d05adad9aa60ec660c6c9bf4dd619ca/websockets-16.1.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:cc97814dfb786a83b6e2dc2e79351e1b83e6d715647d6887fcabd83026417a00", size = 179600, upload-time = "2026-07-17T22:49:13.92Z" }, + { url = "https://files.pythonhosted.org/packages/fb/8d/6195a88b45e8d2a8f745fc2046e36f885a3c9763e6767d2c46229bf9510c/websockets-16.1.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:e047dc87ef7ca50f4d309bf775ad4a71711c58556d75d7bd0604b2317f43e94b", size = 177272, upload-time = "2026-07-17T22:49:15.453Z" }, + { url = "https://files.pythonhosted.org/packages/73/e3/fe2d498c64dea0095c9a9f9a351af4cd6eef31b618395582bc1f38ba45ff/websockets-16.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:01fbdcbac298efe19360b94bc0039c8f746f0220ba570f327577bfee81059175", size = 177542, upload-time = "2026-07-17T22:49:16.875Z" }, + { url = "https://files.pythonhosted.org/packages/fe/ed/f1831681fce0e3242346e5458486003c5f124ed69e5e0b847fd029db4973/websockets-16.1.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0f62863e8a00a6d33c3d6566ec0b89f23787b747ffe0c3bc71ec0e76b82c94b1", size = 187137, upload-time = "2026-07-17T22:49:18.323Z" }, + { url = "https://files.pythonhosted.org/packages/6f/79/4ff9dcc1bb46f6b4c536936dde1fd60f9b564f3304307274db97f4c9496d/websockets-16.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8087e82f842609734c9b5a1330464f8e94e346ba0e18c832c08bafa4b0d63c15", size = 188374, upload-time = "2026-07-17T22:49:19.65Z" }, + { url = "https://files.pythonhosted.org/packages/62/c3/5c49b6efb36cab733d23773f6de575e1dba65736ead17d5d2b2a1daef779/websockets-16.1.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2bb5d041a8307d2e18782e7ce777f6fdb1e8c2f5d09291484b18c294b789d9aa", size = 191155, upload-time = "2026-07-17T22:49:21.331Z" }, + { url = "https://files.pythonhosted.org/packages/6e/f6/56ccceda3a4838d18f1d40821480da4775397e8b1eecf4031e20c50e2e90/websockets-16.1.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1db4de4a0e95673f7545d393c49eeb0c2f18ac1ef93073218c79d5cdb2ee75ab", size = 189011, upload-time = "2026-07-17T22:49:22.889Z" }, + { url = "https://files.pythonhosted.org/packages/86/d6/ad5286241a2bce1107e2798d3bfbd62cf79aee167bdb654f8cb1e9dbf949/websockets-16.1.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f17dbe07eb3ea7f99e4df9b7e0efefe80fbf30d37a8cc4d561a0aed310bc8847", size = 187766, upload-time = "2026-07-17T22:49:24.339Z" }, + { url = "https://files.pythonhosted.org/packages/bc/67/d65c970b7e347fdca69479beb7811c2060529956730a7a4e3ae7c66b0e31/websockets-16.1.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4b57693728576d84ede0a77987ab16881b783d2cd9f1dc180a8fbbc3f79c4428", size = 185173, upload-time = "2026-07-17T22:49:25.743Z" }, + { url = "https://files.pythonhosted.org/packages/1d/5b/14af3cd4ee69d8ea9baca58f3dc3cfb1ba78332a347fd478cb096549d60e/websockets-16.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2a636ff1e7a5c4edf71ef0e79adae7f25dba93b4fcbe3dc958733477ffeb0eaf", size = 187809, upload-time = "2026-07-17T22:49:27.147Z" }, + { url = "https://files.pythonhosted.org/packages/7b/11/be301710d70de97e3e7b3586e6d492c9c06d6a61bf1c2202c36cf0c75607/websockets-16.1.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:d6bec75c290fe484a8ba4cacdf838501e17c06ecfbbf31eede81a9e431bd7751", size = 186412, upload-time = "2026-07-17T22:49:28.611Z" }, + { url = "https://files.pythonhosted.org/packages/db/07/fe1435bf6fe738a3d3b54dbe0c18dabf12cba4d909ac8b58b539ce27c1f4/websockets-16.1.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:54509b8e92fee4453e152b7558ddef37ce9705a044922f2095a6105e3f80c96f", size = 188290, upload-time = "2026-07-17T22:49:29.965Z" }, + { url = "https://files.pythonhosted.org/packages/8a/0a/81f394aff8efcbb01208c1ced77df0a3c7fcce584a88c7273663697946c2/websockets-16.1.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:f0aa4aad3b1b69ad3fd85a0fd0952ec64331c762bd77ec51cc814170873890b2", size = 185844, upload-time = "2026-07-17T22:49:31.447Z" }, + { url = "https://files.pythonhosted.org/packages/39/5c/dd485b995473f415510251fe9bd708f2d24458f439fce958daf8d66dc7c6/websockets-16.1.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:42290eb6db4ccaca7012656738214f8514082fb6fa40cdeb61bb9a471b52e383", size = 186823, upload-time = "2026-07-17T22:49:33.104Z" }, + { url = "https://files.pythonhosted.org/packages/9d/0b/f78de76ff446f1e66af12b43c48a35f31744de93cfdec2f4ea67d5d7bbf1/websockets-16.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:53260c8930da5771cec89439bff99c20c8cb03ddb9588b980697355a83cd4bd3", size = 187102, upload-time = "2026-07-17T22:49:34.616Z" }, + { url = "https://files.pythonhosted.org/packages/37/a1/4cf892007778eaf84ad162bfc98046e0ed89b63ac55949e3236626b2a23f/websockets-16.1.1-cp312-cp312-win32.whl", hash = "sha256:1d27fa8462ad6a1cb36206a3d0640b2333340def181fae11ed7f9adeaa5c0747", size = 179943, upload-time = "2026-07-17T22:49:36.213Z" }, + { url = "https://files.pythonhosted.org/packages/d9/de/6abe251d28c3a3f217096575400b27750b18e0b1d2fff3a2a239960fea07/websockets-16.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:b436f6ec4fc3a6b4237c84d3f83170ed2b40bb584222f0ac47a0c8a5921980c7", size = 180243, upload-time = "2026-07-17T22:49:37.626Z" }, + { url = "https://files.pythonhosted.org/packages/ce/fd/6ec6c6d2850aea25b1b2aa9901a016980bb87d01e89b3eb00470b1b5d471/websockets-16.1.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ab59169ace05dcb49a1d4118f0bde139557adf45091bd85747e36bf5de984dd1", size = 179587, upload-time = "2026-07-17T22:49:38.959Z" }, + { url = "https://files.pythonhosted.org/packages/5f/d8/1d299d2dd34087db39831a34cc645ef8a6f89d78efada6983093513cd81c/websockets-16.1.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5e3b7d601f6f84156b08cc4a5e541c2b50ad7b36cfc302b657a12477c904a5df", size = 177272, upload-time = "2026-07-17T22:49:40.293Z" }, + { url = "https://files.pythonhosted.org/packages/3d/86/0a70d3ae2f0f2256bb41302d9804dbca65d4360281e7feb3e1f94102ac46/websockets-16.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cd2ca96a082a36964aca83e992f72abeb61b7306c1a6cba4c7d06a7b93750cac", size = 177530, upload-time = "2026-07-17T22:49:41.786Z" }, + { url = "https://files.pythonhosted.org/packages/b5/c2/c676c69444d9db448b3f0a55a98dcc534affce0bce961d9d2f0b8499b10a/websockets-16.1.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f5d497865f05bb222cab7016c6034542e84e5f29f49c6fd3f4939cda7197b5b8", size = 187197, upload-time = "2026-07-17T22:49:43.658Z" }, + { url = "https://files.pythonhosted.org/packages/0b/13/88137fbaf726ebe29d62c1117fa11fa2bbb6209dc79d4ad738efbe36a2aa/websockets-16.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bae954c382e013d5ea5b190d2830526bfa45ad121c326da0049b8c769f185db6", size = 188433, upload-time = "2026-07-17T22:49:45.147Z" }, + { url = "https://files.pythonhosted.org/packages/01/6d/46c2f2ce6751cb26f39293e1ecbf8544cb01321397cd476c2756b98c216d/websockets-16.1.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e09f753a169951eb4f28c2c774f71069304f66e7277e0f5a2892423599cfa854", size = 189868, upload-time = "2026-07-17T22:49:46.581Z" }, + { url = "https://files.pythonhosted.org/packages/29/2b/170a9e8097636cfde4dc3c592b6e00b18a44a2f5407606d96ca542dd5838/websockets-16.1.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:024193f8551a2b0eafbdd160911012c4e6c228c28430c84433253299a9e42d6a", size = 189059, upload-time = "2026-07-17T22:49:47.972Z" }, + { url = "https://files.pythonhosted.org/packages/a7/48/f0d4ebc9ab4b473b8861b9e20fdb663d515d42f7befdf62cdb60fee7a1ec/websockets-16.1.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:aabe464bfd13bd25f4821faf111da6fefdc389f870265a53105580e45b0a2e49", size = 187814, upload-time = "2026-07-17T22:49:49.344Z" }, + { url = "https://files.pythonhosted.org/packages/d5/ba/39a41d3ae8e72696a9492581900611c5a91e2b07563b0bcd2523adea9854/websockets-16.1.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a28fcbc9b6baf54a2e23f8655f308e4ccc6afdd7266f8fe7954f320dcda0f785", size = 185229, upload-time = "2026-07-17T22:49:50.787Z" }, + { url = "https://files.pythonhosted.org/packages/3c/36/ac15b604f850d1907f0a85ed721cefe47cd45034b3620069b829746cccbe/websockets-16.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:79eace538c6a97e96d0d03d4f9d314f9677f5ed85a8a984992ffd90b13cb8a56", size = 187874, upload-time = "2026-07-17T22:49:52.228Z" }, + { url = "https://files.pythonhosted.org/packages/a8/f3/3fbd5d71d59299c3770faa5884d4f45070236ca5a35ab3a61830812c409a/websockets-16.1.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:496af849a472b531f758dbd4d61338f5000538cb1a7b3d20d9d32a264517f509", size = 186469, upload-time = "2026-07-17T22:49:53.776Z" }, + { url = "https://files.pythonhosted.org/packages/b4/fc/dd90349bba58af2a53ef2ddd9c32716c81eb6d59a0687939fff561860878/websockets-16.1.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5283810d2646741a0d8da2aa733d6aefa0545809afccb2a5d105a26bc45125f1", size = 188347, upload-time = "2026-07-17T22:49:55.202Z" }, + { url = "https://files.pythonhosted.org/packages/4c/f3/f73ba86427682da59b78c11d77ba56d5b801c32e84afe79b274bbd6a9bb2/websockets-16.1.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:4e3b680b1e0a27457e727a0d572fd81dffa87b6dbf8b228ab57da64f7d85aead", size = 185903, upload-time = "2026-07-17T22:49:56.75Z" }, + { url = "https://files.pythonhosted.org/packages/34/7c/f95eb20e80104173b3a0a092291f89ea4047ef6e608e0a57ca06eb14eecb/websockets-16.1.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:69159730a823dde3ea8d08783e8d47ef135a6d7e8d44eb127e32b321c9db8e3e", size = 186855, upload-time = "2026-07-17T22:49:58.467Z" }, + { url = "https://files.pythonhosted.org/packages/b0/35/dd875b3e050ff232d60fa377707f890e369f74d134f1be32e8f68879747c/websockets-16.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ed5bb271084b46530ee2ddc0410537a9961152c5ccba2fc98c5276d992ccba87", size = 187140, upload-time = "2026-07-17T22:50:00.016Z" }, + { url = "https://files.pythonhosted.org/packages/e8/dc/5cbfcb41824502f6af93b8f3943a4d06c67c23c7d2e31eb18748c4a5b2a7/websockets-16.1.1-cp313-cp313-win32.whl", hash = "sha256:cfb70b4eb56cac4da0a83588f3ad50d46beb0690391082f3d4e2d488c70b68ea", size = 179928, upload-time = "2026-07-17T22:50:01.685Z" }, + { url = "https://files.pythonhosted.org/packages/b0/c1/71e5deb5b7f8f226997ab64908c184ac3105c0155ce2d486f318e5dd08a8/websockets-16.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:d9531d9cbeac99af6f038fb1bc351403531f7d634a2c2e10e2f7c854c6ed5b68", size = 180242, upload-time = "2026-07-17T22:50:03.117Z" }, + { url = "https://files.pythonhosted.org/packages/73/a2/ba78a164eeea4620df4a4df4bd2ed6017438c4655cc0f36f2c0bc0432355/websockets-16.1.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:443aefe96b7fdb132e2a70806cca1f2af49bb3f28e47abcd7c2e9dcf4d8fa1b8", size = 179635, upload-time = "2026-07-17T22:50:05.001Z" }, + { url = "https://files.pythonhosted.org/packages/b9/08/d26d7a7628cd4ac34cbbdb63ac80914ca842ed8e42938c40a53567806df3/websockets-16.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:6456ff333092d509127d75a638cb411afae8ff17f092635015d1902efec8a293", size = 177320, upload-time = "2026-07-17T22:50:06.427Z" }, + { url = "https://files.pythonhosted.org/packages/0f/45/ebec83e6269536aa5932533c67b0af5c781f3e73fdbcd68672dcf43f4f44/websockets-16.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fce6c48559c86d1ac3632ccb1bebc7d5442fbe79bd9bb0e40379ee54be2a4051", size = 177544, upload-time = "2026-07-17T22:50:07.834Z" }, + { url = "https://files.pythonhosted.org/packages/c9/d5/abc614d2297f6c1c3e01e61260364457a47c25cc1cf6a879038902bc6aa8/websockets-16.1.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:92b820d345f7a3fc7b8163949ee92df910f290c3fc517b3d5301c78065adafe1", size = 187270, upload-time = "2026-07-17T22:50:09.275Z" }, + { url = "https://files.pythonhosted.org/packages/52/71/4c99af3b87dff1b2927981f6876607d4acb45338c665242168d3982f7758/websockets-16.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2a606d9c24035242a3e256e9d5b77ed9cd6bccfcb7cf993e5ca3c0f6f68fb6a7", size = 188509, upload-time = "2026-07-17T22:50:10.722Z" }, + { url = "https://files.pythonhosted.org/packages/9b/b4/5c8ca14b0df7eb84ed0524165c5359150210140817a3312aee57bf62a1cf/websockets-16.1.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:414e596c75f74e0994084694189d7dc9229fb278e33064d6784b73ffbba3ca31", size = 189882, upload-time = "2026-07-17T22:50:12.293Z" }, + { url = "https://files.pythonhosted.org/packages/25/c1/bedfba9e70557129cb8083748d167bdcc01483dedf0f0df143676df05cbe/websockets-16.1.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:536676848fc5961aca9d20389951f59169508f765637a172403dc5434d722fa0", size = 189114, upload-time = "2026-07-17T22:50:13.789Z" }, + { url = "https://files.pythonhosted.org/packages/df/09/aa835b2787835aebd839114be5de51b797cb480b63ba42b26d34dfe147cb/websockets-16.1.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:97fd3a0e8b53efa41970ac1dff3d8cf0d2884cadeb4caaf95db7ad1526926ee3", size = 187861, upload-time = "2026-07-17T22:50:15.179Z" }, + { url = "https://files.pythonhosted.org/packages/20/26/f6408330694dbc9830857d9d23bc14ac4f6875127a480cfdda8d5ca21198/websockets-16.1.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7b1b19636af86a3c7995d4d028dbe376f39b4bf31541146f9c123582a6c94562", size = 185286, upload-time = "2026-07-17T22:50:16.741Z" }, + { url = "https://files.pythonhosted.org/packages/17/9a/e0675e70dd8a80762cf35bb18799d3f290a4890ffe6439bc51d222796083/websockets-16.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:41c8e77f17294c0ac18008a7309b99b34ee72247ef10b6dff4c3f8b5ac29896b", size = 187935, upload-time = "2026-07-17T22:50:18.213Z" }, + { url = "https://files.pythonhosted.org/packages/33/c1/3234cfb86afde01b81e9bddcc6e534c440975d60a13991259e833069ab3e/websockets-16.1.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:9f63bcef7f4b02b06b35fc01c93b96c43b5e88e1e8868676caacf493d5a31f3a", size = 186444, upload-time = "2026-07-17T22:50:19.67Z" }, + { url = "https://files.pythonhosted.org/packages/89/87/9c15206e1d778923d8daa9657de07aa62ea815e13448319c98458c37b281/websockets-16.1.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:dab9eb87869da2d6ed3af3f3adf28414baae6ec9d4df355ffc18889132f3436c", size = 188409, upload-time = "2026-07-17T22:50:21.28Z" }, + { url = "https://files.pythonhosted.org/packages/f2/00/cf5de5c67676de2d3eef8b2a518f168f6796595447a5b7161ba0d012915c/websockets-16.1.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:43e3a9fdd7cbf7ba6040c31fae0faf84ca1474fef777c4e37912f1540f854499", size = 185958, upload-time = "2026-07-17T22:50:22.719Z" }, + { url = "https://files.pythonhosted.org/packages/62/c0/731b6ddede2e4136912ec4cff2cffbda35af73546be4762c3d7bd3bd79af/websockets-16.1.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:056ae37939ed7e9974f364f5864e76e49182622d8f9751ac1903c0d09b013985", size = 186911, upload-time = "2026-07-17T22:50:24.108Z" }, + { url = "https://files.pythonhosted.org/packages/8c/7f/39c634472c4469a24a7c09cecddffb08fac6d0e74f73881a94ee8a40a196/websockets-16.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a0eadbbf2c30f01efa58e1f110eb6fa293261f6b0b1aa38f7f48707107690af9", size = 187204, upload-time = "2026-07-17T22:50:25.548Z" }, + { url = "https://files.pythonhosted.org/packages/26/89/9667c256c256dafcc62d21328ce7a40067da857969b68ee9af375b0aaf72/websockets-16.1.1-cp314-cp314-win32.whl", hash = "sha256:195c978b065fa40910582464f99d6b15c8b314c68e0546549a55ed83f4735328", size = 179603, upload-time = "2026-07-17T22:50:27.086Z" }, + { url = "https://files.pythonhosted.org/packages/bd/dd/1c099d6c0fc5deb6b46ccdbb6981fdb4b12c917869cb3952408409dc18db/websockets-16.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:4e8d01cc3bcae7bbf8167f944aeafefed590fae5693552bba9794a9df68371cc", size = 179948, upload-time = "2026-07-17T22:50:28.521Z" }, + { url = "https://files.pythonhosted.org/packages/35/25/9956b2d5e0529d5d23924f21bba1440d4c5c88a562e4f08550871ffa97a7/websockets-16.1.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:0ffd3031ea8bda8d61762e84220186105ba3b748b3c8da2ae4f7816fac03e573", size = 179963, upload-time = "2026-07-17T22:50:29.982Z" }, + { url = "https://files.pythonhosted.org/packages/17/06/55ffc976c488b6aee9ea05761ff7c4e88e7c1fd82818c8ca7b556ad2f90c/websockets-16.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:84a2cef8deffbd9ab8ee0ea546a2a6a7030c28f44e6cdd4547dbfeb489eb8999", size = 177497, upload-time = "2026-07-17T22:50:31.396Z" }, + { url = "https://files.pythonhosted.org/packages/0c/e8/f7dac2e980bacc92bdc26cebae4ae4d50cae5380732c50980598fc0bbae4/websockets-16.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:3df13f73af9b3b38ab1195eb299ecb67a4330c911c97ae04043ff74085728abe", size = 177698, upload-time = "2026-07-17T22:50:32.829Z" }, + { url = "https://files.pythonhosted.org/packages/b2/39/26762f734113e22da2b942c3aca85798e0c0405d64c256549540ff31e5a1/websockets-16.1.1-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:23253dd5bcae3f9aaee0a1d30967a8dbd52e5d3cff93a2e5b84df57b77d4750d", size = 187561, upload-time = "2026-07-17T22:50:34.24Z" }, + { url = "https://files.pythonhosted.org/packages/11/94/c3f330851806b9b02138b774d593478323e73c99238681b4b93efe64e02d/websockets-16.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9c1c5705e314449e3308872fe084b8571ce078ee4fc55a98a769bdefe5917392", size = 188732, upload-time = "2026-07-17T22:50:36.088Z" }, + { url = "https://files.pythonhosted.org/packages/d1/f2/eb2c450f052de334ae33cf200ece6e87b0e14d186807074e4eb1cd2cdea2/websockets-16.1.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:69e52d175a0a7d1e13b4b67ad41c560b7d98e8c6f6126eb0bda496c784faf8c7", size = 190872, upload-time = "2026-07-17T22:50:38.008Z" }, + { url = "https://files.pythonhosted.org/packages/70/31/2ac8cecf3a74f7fed9132129fc3d90b3998a1554570c11a69b2a8c20332d/websockets-16.1.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1f79c89b5eb034d1722938a891916582f8f7f503f58ca22518a63c3f2cd18499", size = 189305, upload-time = "2026-07-17T22:50:39.53Z" }, + { url = "https://files.pythonhosted.org/packages/6a/cf/8ab19650d3c0d4562c92e70ab47c257c4aa5c6a713ed87fe63766b31fefc/websockets-16.1.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:39f2a024af5c345ffe8fcf1ee18c049c024c94df393bb09b044a6917c77bde43", size = 188033, upload-time = "2026-07-17T22:50:40.912Z" }, + { url = "https://files.pythonhosted.org/packages/66/d7/a49a38a6127a4acb134fb1912b215d900cc657605cff32445bf519f3acc4/websockets-16.1.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:952303a7318d4cbe1011400839bb2051c9f84fa0a35923267f5daba34b15d458", size = 185748, upload-time = "2026-07-17T22:50:42.559Z" }, + { url = "https://files.pythonhosted.org/packages/95/3e/ad1fa40388c7f2e0bb2c7930d0090b6c5498594bd1cdaec18864df3d9e97/websockets-16.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:249116b4a76063d930a46391ad56e135c286e4562a18309029fc2c73f4ed4c62", size = 188285, upload-time = "2026-07-17T22:50:43.974Z" }, + { url = "https://files.pythonhosted.org/packages/35/b8/d5db28ca264b9104f82196f92dc8843e35fd391f763d42e4ad358f5bc97e/websockets-16.1.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:61922544a0587a13fd3f53e4c0e5e606510c7b0d9d22c8444e5fae22a06b38cb", size = 186777, upload-time = "2026-07-17T22:50:45.474Z" }, + { url = "https://files.pythonhosted.org/packages/42/9c/726cb39d0cc43ae848dce4aa2acb04eecc6738b1264ec6d700bf6bcfb9f8/websockets-16.1.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:46dcaa042cd1de6c59e7d9269fa63ff7572b6df40510600b678f0826b3c7af51", size = 188682, upload-time = "2026-07-17T22:50:46.973Z" }, + { url = "https://files.pythonhosted.org/packages/be/c7/1168704de8c2dd483edabe4a22cbe4465dd8be8dd95561d214f9fe092871/websockets-16.1.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:38565aca3e01ea8734e578fb2118dade0ecb0250533f29e22b8d1a7a196cf4d0", size = 186377, upload-time = "2026-07-17T22:50:48.413Z" }, + { url = "https://files.pythonhosted.org/packages/ca/40/f9ff2d630ffce4e7dfea0b2288e1caf9ebbf9ff8a9ec9396136ce8b94935/websockets-16.1.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:42f599f4d48c7e1a3338fdaac3acd075be3b3cf02d4b274f3bf2767aedd3d217", size = 187148, upload-time = "2026-07-17T22:50:49.845Z" }, + { url = "https://files.pythonhosted.org/packages/b5/71/e177c8299f78d7cbe2d14df228643c10c70c0e86e108e092056bbcc16e46/websockets-16.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:dcc04fedf83effaeb9cce98abc9469bb1b42ef85f03e01c8c1f4438ef7555737", size = 187578, upload-time = "2026-07-17T22:50:51.619Z" }, + { url = "https://files.pythonhosted.org/packages/49/b2/b6987faf330f5af5c787a2610124c2e8403d51724f9001ec4fff6311fe7a/websockets-16.1.1-cp314-cp314t-win32.whl", hash = "sha256:8483c2096363120eea8b07c06ae7304d520f686665fffd4811fad423930a65d7", size = 179729, upload-time = "2026-07-17T22:50:53.269Z" }, + { url = "https://files.pythonhosted.org/packages/a2/6e/fbac6ed878dd362fbad7d415fa4f84d38e3e33fed8cde45c64e783acf826/websockets-16.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:bcce07e23e5769375158f5efdcdafa8d5cd014b93c6683865b840ed65b96f231", size = 180072, upload-time = "2026-07-17T22:50:54.969Z" }, + { url = "https://files.pythonhosted.org/packages/e1/ed/71fea6e141590cafc40b14dc5943b0845606bee87bdb52a21b6a73eb4311/websockets-16.1.1-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:820fb8450edddae3812fd58cbc08e2bf22812cb248ecb5f06dbb82119a56e869", size = 177185, upload-time = "2026-07-17T22:50:56.665Z" }, + { url = "https://files.pythonhosted.org/packages/01/ec/00e7eeca200facf9266a83e4cbbf1bed0e67fba1d4d45031d3e5b3d81b5c/websockets-16.1.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:125f22dbefaf1554fea66fc83851490edb284ce4f501d37ffed2752f418332d9", size = 177459, upload-time = "2026-07-17T22:50:58.197Z" }, + { url = "https://files.pythonhosted.org/packages/75/fd/5774c4b33f7c0d8f0c51809c8b3a93456c48e3543579262cfa64eb5f522e/websockets-16.1.1-pp311-pypy311_pp73-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:30bbe120437b5648a77d3519b7024ea09530e0b5b18d3698c5a0ae536fe0cc2e", size = 178294, upload-time = "2026-07-17T22:50:59.641Z" }, + { url = "https://files.pythonhosted.org/packages/37/c3/48e2c03d2bd79bb45948841c592d24156312dd5f58cdf8f549febe652fb6/websockets-16.1.1-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b6b9dadbef0cccd9f4c4ee96b08898afa73e26803bbe0f6aeb5bb12b0074206d", size = 179190, upload-time = "2026-07-17T22:51:01.129Z" }, + { url = "https://files.pythonhosted.org/packages/2d/3f/73e511ecf2496ceac57dd4ed8388efe2bcf0769338a2dbf242c8366ae87e/websockets-16.1.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:56cd5fc4f10a9ea8aa0804bddb7b42506cf9e136046f3b4c27de8fec9e2ecba5", size = 180330, upload-time = "2026-07-17T22:51:02.603Z" }, + { url = "https://files.pythonhosted.org/packages/be/4d/2d0d67834092e354d2b0498f014a41249a89556bc406cf86f3e1557bb463/websockets-16.1.1-py3-none-any.whl", hash = "sha256:6abbd3e82c731c8e531714466acd5d87b5e88ac3243465337ba71d68e23ae7e3", size = 173814, upload-time = "2026-07-17T22:51:04.184Z" }, +] + +[[package]] +name = "yfinance" +version = "1.5.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "beautifulsoup4" }, + { name = "curl-cffi" }, + { name = "multitasking" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "numpy", version = "2.5.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "pandas" }, + { name = "peewee" }, + { name = "platformdirs" }, + { name = "protobuf" }, + { name = "pytz" }, + { name = "requests" }, + { name = "websockets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/75/f1/095457c99cd5fb44802e0dafbb17051e0bfdbf264bd4531556e662f57fbf/yfinance-1.5.1.tar.gz", hash = "sha256:89c48a1d45fb870f8e3066c22643c6911118ede9cead747b48925ce8e01a6940", size = 167897, upload-time = "2026-06-28T18:13:59.593Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2b/53/ba0f45c93c45cafe010c9fe2f509c70d1ad0f96ae7b6ca93369db0c17942/yfinance-1.5.1-py2.py3-none-any.whl", hash = "sha256:a5c9cfc1b9c990f217b643e4fb92444e023cc02b2bacdea9c1fb472509fdfe22", size = 144223, upload-time = "2026-06-28T18:13:58.349Z" }, +] diff --git a/verifier/protected_manifest.py b/verifier/protected_manifest.py index 5104732..e7769f6 100644 --- a/verifier/protected_manifest.py +++ b/verifier/protected_manifest.py @@ -108,9 +108,16 @@ "environments/environment_v1.json", "dopey_live/__init__.py", "dopey_live/control.py", + "dopey_live/dumbmoney_authority.py", + "dopey_live/dumbmoney_command_feed.py", + "dopey_live/dumbmoney_contract_feed.py", + "dopey_live/dumbmoney_journal_anchor.py", + "dopey_live/dumbmoney_promotion.py", + "dopey_live/dumbmoney_windows_service.py", "dopey_live/ledger.py", "dopey_live/models.py", "dopey_live/robinhood_gateway.py", + "dopey_live/robinhood_mcp_direct.py", "dopey_live/service.py", "dopey_live/settlement.py", "dopey_live/signals.py", From 64da8851658bb337b81f7dfd95c3103d57f4257e Mon Sep 17 00:00:00 2001 From: cjharriskc-ai Date: Mon, 27 Jul 2026 01:34:18 -0500 Subject: [PATCH 2/3] Refresh Dopey public source inventory --- PUBLIC_SOURCE_MANIFEST.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/PUBLIC_SOURCE_MANIFEST.json b/PUBLIC_SOURCE_MANIFEST.json index a5ce517..8212e87 100644 --- a/PUBLIC_SOURCE_MANIFEST.json +++ b/PUBLIC_SOURCE_MANIFEST.json @@ -17,7 +17,7 @@ "operator_agent_configuration_included": false, "aggregate_non_account_validation_metadata_included": true, "source_inventory": { - "public_file_count": 2679, + "public_file_count": 2700, "public_source_size_class": "under_50_mib", "excluded_file_count": 4514 }, From 30cd325ec273be2a02c30004ddcb1542166a7e55 Mon Sep 17 00:00:00 2001 From: cjharriskc-ai Date: Mon, 27 Jul 2026 01:43:42 -0500 Subject: [PATCH 3/3] Decouple parent-only MCP from Codex CLI --- PROTECTED_MANIFEST.json | 4 ++-- dopey_live/robinhood_gateway.py | 14 +++++++++----- tests/test_dumbmoney_direct_mcp.py | 7 +++++++ 3 files changed, 18 insertions(+), 7 deletions(-) diff --git a/PROTECTED_MANIFEST.json b/PROTECTED_MANIFEST.json index c88fa5c..e4d40cf 100644 --- a/PROTECTED_MANIFEST.json +++ b/PROTECTED_MANIFEST.json @@ -77,7 +77,7 @@ "dopey_live/dumbmoney_windows_service.py": "4cba589b6e0a05f69916fdd8ca45321898d8c8fc07a44c6f05a5385010bc0a47", "dopey_live/ledger.py": "934d5a03e5a378400ec5fd787e270a948c57e0235615ca0b57ab78e6c1b993b7", "dopey_live/models.py": "e1f78db857fae5c72ca368724b78096c5d3ba7b3497c5a52c29d9ddeca0f05a9", - "dopey_live/robinhood_gateway.py": "e6c3a07b2013d5a26bddb5f9d665d3cd137ccee2b5581f1e22de75d2d0492fb3", + "dopey_live/robinhood_gateway.py": "a12e7c8e60067fabca3fabd7d274cfc117bcf55ab6b71082f98c1855b73c1cff", "dopey_live/robinhood_mcp_direct.py": "a181bebbb99e094440a2ae0353ad100a1883ce0c57bdef16c0c94ef6b69d6c7a", "dopey_live/service.py": "899611c7d0dcd1240ef3467ec39f24defdc1b6cb754c64ad049407116e3f8b46", "dopey_live/settlement.py": "336892885775e3476010e5a6b319c9f4299c92758c0c7fc4f997787f049d8dd9", @@ -96,6 +96,6 @@ "verifier/path_policy.py": "740d9947b7b12cec4db86f3aae9c9f6c26c029a7243efb047d1fe57ee39c3ea5", "verifier/protected_manifest.py": "2dd350cbe84c3638c78d164a85acb7207a472d5398c593792aa913d18e2a6343" }, - "manifest_hash": "894c621aab645f30eef84802ed987138da4fa35236a083fa996bccaebbb56470", + "manifest_hash": "75408a8d57f7b349700f602574eabeab8142419a4af7085834ecfadfcb76123e", "schema_version": "dopey-protected-manifest-v1" } diff --git a/dopey_live/robinhood_gateway.py b/dopey_live/robinhood_gateway.py index 9bf4245..9d943f4 100644 --- a/dopey_live/robinhood_gateway.py +++ b/dopey_live/robinhood_gateway.py @@ -2034,11 +2034,15 @@ def __init__( raise ValueError( "codex_executable_sha256 requires codex_executable_path" ) - executable = shutil.which("codex") - if executable is None: - raise RobinhoodGatewayError( - "Codex CLI is required for the Robinhood Trading MCP gateway" - ) + if deterministic_mcp_call is not None: + executable = "" + else: + discovered_executable = shutil.which("codex") + if discovered_executable is None: + raise RobinhoodGatewayError( + "Codex CLI is required for the Robinhood Trading MCP gateway" + ) + executable = discovered_executable self._codex_sha256: str | None = None else: if not codex_executable_path.is_absolute(): diff --git a/tests/test_dumbmoney_direct_mcp.py b/tests/test_dumbmoney_direct_mcp.py index c668e09..fe497ab 100644 --- a/tests/test_dumbmoney_direct_mcp.py +++ b/tests/test_dumbmoney_direct_mcp.py @@ -2,6 +2,7 @@ import hashlib import json +import sys from dataclasses import dataclass from datetime import datetime, timedelta, timezone from types import SimpleNamespace @@ -11,6 +12,7 @@ import requests from dopey_live import service as live_service +from dopey_live import robinhood_gateway as gateway_module from dopey_live.robinhood_gateway import ( RobinhoodAgenticGateway, RobinhoodGatewayError, @@ -854,6 +856,11 @@ def test_operator_default_gateway_cannot_place_or_cancel_through_model( "run", lambda *_args, **_kwargs: runner_calls.append(True), ) + monkeypatch.setattr( + gateway_module.shutil, + "which", + lambda executable: sys.executable if executable == "codex" else None, + ) gateway = live_service.default_gateway() intent = SimpleNamespace( instrument="equity",