diff --git a/.ci/auth-boundaries/TEST_STRUCTURE_DEBT.json b/.ci/auth-boundaries/TEST_STRUCTURE_DEBT.json index d00cb3f13..4798f4ae5 100644 --- a/.ci/auth-boundaries/TEST_STRUCTURE_DEBT.json +++ b/.ci/auth-boundaries/TEST_STRUCTURE_DEBT.json @@ -2,11 +2,11 @@ "entries": [ { "capability": "oversized_authorization_structure", - "content_sha256": "52925f333aa6dec0a6355e76c8352fbf7f7fff4d6de898a98834b4d13f15c393", - "end_line": 1270, + "content_sha256": "b4f2fe63f88cacf12830a5cc556f8dc627e74deac80c1b6183d13a8a1a4f52ce", + "end_line": 1265, "hard_limit": 1200, "kind": "production_file", - "observed_lines": 1270, + "observed_lines": 1265, "path": "backend/app/modules/authorization/kernel.py", "qualified_symbol": null, "removal_chunk": "WS-AUTH-003-CLOSE", @@ -26,27 +26,15 @@ }, { "capability": "oversized_authorization_structure", - "content_sha256": "75bcb2639cd1f099597c8b07efa9b6d78d334ea3f092a1bf89ef638417b5856f", - "end_line": 494, - "hard_limit": 100, - "kind": "production_function", - "observed_lines": 101, - "path": "backend/app/modules/authorization/kernel.py", - "qualified_symbol": "AuthorizationService._prepare_prelocked", - "removal_chunk": "WS-AUTH-003-CLOSE", - "start_line": 394 - }, - { - "capability": "oversized_authorization_structure", - "content_sha256": "a2bcc3b6fbfb59e65e5c606b777378451733f5fd11c9aeccf9f2e05773b2361f", + "content_sha256": "bae6d1bf2568947ee4125b3389d861fb7c077a346da1efcef3167b68ce7cafd5", "end_line": 818, "hard_limit": 100, "kind": "production_function", - "observed_lines": 106, + "observed_lines": 105, "path": "backend/app/modules/authorization/kernel.py", "qualified_symbol": "AuthorizationService._require_prelocked", "removal_chunk": "WS-AUTH-003-CLOSE", - "start_line": 713 + "start_line": 714 }, { "capability": "oversized_authorization_structure", @@ -86,11 +74,11 @@ }, { "capability": "oversized_authorization_structure", - "content_sha256": "c20ff97f3860c899f5395fb3341123e7ec5302468ea7cef63e5f87672791e419", - "end_line": 2467, + "content_sha256": "508b934117c60535e2bf3aa1e6db8d516e90d98312e427340f2f63206a7601f5", + "end_line": 2453, "hard_limit": 1200, "kind": "test_file", - "observed_lines": 2467, + "observed_lines": 2453, "path": "backend/tests/test_artifact_admission.py", "qualified_symbol": null, "removal_chunk": "WS-AUTH-003-CLOSE", @@ -110,11 +98,11 @@ }, { "capability": "oversized_authorization_structure", - "content_sha256": "b89babd7cd41d37a0f2024f9064bbf45af102b9df70981a27e8f208a5e7b7716", - "end_line": 10788, + "content_sha256": "9b3626c6c8932572b1c7a98f05cc8d90be34f757a4ddbef93f6f8569ca200416", + "end_line": 10756, "hard_limit": 1200, "kind": "test_file", - "observed_lines": 10788, + "observed_lines": 10756, "path": "backend/tests/test_authorization.py", "qualified_symbol": null, "removal_chunk": "WS-AUTH-003-CLOSE", @@ -168,18 +156,6 @@ "removal_chunk": "WS-AUTH-003-CLOSE", "start_line": 653 }, - { - "capability": "oversized_authorization_structure", - "content_sha256": "6a1c0791d31f717e7c7cea14c4d106bc2c76ce05a9b456f7f0fd831718f145e8", - "end_line": 1034, - "hard_limit": 120, - "kind": "test_function", - "observed_lines": 140, - "path": "backend/tests/test_artifact_internal_authorization.py", - "qualified_symbol": "test_verification_claim_and_terminal_failures_roll_back_both_sides", - "removal_chunk": "WS-AUTH-003-CLOSE", - "start_line": 895 - }, { "capability": "oversized_authorization_structure", "content_sha256": "34f9c463ba512e3aecb3dd772bcb52f209a37f16e1b102700d94eb02aa9990db", @@ -255,134 +231,134 @@ { "capability": "oversized_authorization_structure", "content_sha256": "10003810b90ad8ea86434aaae1a38e650c5df4bebd09bb0577ac3ebb7a522351", - "end_line": 2689, + "end_line": 2657, "hard_limit": 120, "kind": "test_function", "observed_lines": 121, "path": "backend/tests/test_authorization.py", "qualified_symbol": "test_identity_link_lifecycle_route_preserves_outcome_transaction_contract", "removal_chunk": "WS-AUTH-003-CLOSE", - "start_line": 2569 + "start_line": 2537 }, { "capability": "oversized_authorization_structure", "content_sha256": "b4d58c998c9b8b2efedcdd67d1beb2e804c7f6dfca61b4818d6e95d8268c6f08", - "end_line": 6920, + "end_line": 6888, "hard_limit": 120, "kind": "test_function", "observed_lines": 122, "path": "backend/tests/test_authorization.py", "qualified_symbol": "test_identity_link_lifecycle_service_applies_success_and_guards_conflicts", "removal_chunk": "WS-AUTH-003-CLOSE", - "start_line": 6799 + "start_line": 6767 }, { "capability": "oversized_authorization_structure", "content_sha256": "0858a7b107e68244cd719c9521856944411f88442818ae491ebb58a45ef4f417", - "end_line": 5799, + "end_line": 5767, "hard_limit": 120, "kind": "test_function", "observed_lines": 126, "path": "backend/tests/test_authorization.py", "qualified_symbol": "test_prepared_actor_authority_crossed_mutations_complete_in_both_orders", "removal_chunk": "WS-AUTH-003-CLOSE", - "start_line": 5674 + "start_line": 5642 }, { "capability": "oversized_authorization_structure", "content_sha256": "56e6f85cc1b712b6410bee986d54e59e4dbad4c3f73c0599f12fe284587577a7", - "end_line": 6116, + "end_line": 6084, "hard_limit": 120, "kind": "test_function", "observed_lines": 312, "path": "backend/tests/test_authorization.py", "qualified_symbol": "test_prepared_crosses_real_lifecycle_service_transactions", "removal_chunk": "WS-AUTH-003-CLOSE", - "start_line": 5805 + "start_line": 5773 }, { "capability": "oversized_authorization_structure", "content_sha256": "5cbc72df2c822f2f57db164c0ea4aa2f2d76d591313f69d6496fc26b127b581e", - "end_line": 3787, + "end_line": 3755, "hard_limit": 120, "kind": "test_function", "observed_lines": 149, "path": "backend/tests/test_authorization.py", "qualified_symbol": "test_project_11c2_reads_require_exact_admin_context_and_role_allowlist", "removal_chunk": "WS-AUTH-003-CLOSE", - "start_line": 3639 + "start_line": 3607 }, { "capability": "oversized_authorization_structure", "content_sha256": "478d47861e68cfa6571199b0f69785c8f3f5a3483774cee0929b6a13dba6a648", - "end_line": 2132, + "end_line": 2134, "hard_limit": 120, "kind": "test_function", "observed_lines": 302, "path": "backend/tests/test_authorization.py", "qualified_symbol": "test_project_mutation_resources_and_prepared_scopes_are_closed", "removal_chunk": "WS-AUTH-003-CLOSE", - "start_line": 1831 + "start_line": 1833 }, { "capability": "oversized_authorization_structure", "content_sha256": "3d2a9843510cdb61b34500aa79a8ed3572df72723ab401f0245300e10c70044e", - "end_line": 9065, + "end_line": 9033, "hard_limit": 120, "kind": "test_function", "observed_lines": 174, "path": "backend/tests/test_authorization.py", "qualified_symbol": "test_project_read_permissions_have_postgresql_role_scope_matrix", "removal_chunk": "WS-AUTH-003-CLOSE", - "start_line": 8892 + "start_line": 8860 }, { "capability": "oversized_authorization_structure", "content_sha256": "0da4e29b29ebd44bd9049145ee4148531320bce53859ad5341e5f57261e5a884", - "end_line": 10501, + "end_line": 10469, "hard_limit": 120, "kind": "test_function", "observed_lines": 227, "path": "backend/tests/test_authorization.py", "qualified_symbol": "test_project_role_and_all_operation_mappings_commit_one_linked_pair", "removal_chunk": "WS-AUTH-003-CLOSE", - "start_line": 10275 + "start_line": 10243 }, { "capability": "oversized_authorization_structure", "content_sha256": "8deb42b92c936b62417f3bfe307b4a886612b8e217af8e5abc14a5ae3222452a", - "end_line": 1542, + "end_line": 1544, "hard_limit": 120, "kind": "test_function", "observed_lines": 195, "path": "backend/tests/test_authorization.py", "qualified_symbol": "test_project_role_mutation_routes_conceal_denials_and_preserve_self_guards_atomically", "removal_chunk": "WS-AUTH-003-CLOSE", - "start_line": 1348 + "start_line": 1350 }, { "capability": "oversized_authorization_structure", "content_sha256": "19e0d2a736a60fcd30e5c8e18f0641c6b66ed3a669e2d18dd5784195990d1752", - "end_line": 1778, + "end_line": 1780, "hard_limit": 120, "kind": "test_function", "observed_lines": 218, "path": "backend/tests/test_authorization.py", "qualified_symbol": "test_project_role_mutation_routes_enforce_project_lifecycle_without_disclosure", "removal_chunk": "WS-AUTH-003-CLOSE", - "start_line": 1561 + "start_line": 1563 }, { "capability": "oversized_authorization_structure", "content_sha256": "217ea21f4a2cbd31a4428ba1f01a9fcd90e73ee95fedb2587100a847e8921926", - "end_line": 10030, + "end_line": 9998, "hard_limit": 120, "kind": "test_function", "observed_lines": 154, "path": "backend/tests/test_authorization.py", "qualified_symbol": "test_service_actor_replay_fails_closed_on_committed_state_drift", "removal_chunk": "WS-AUTH-003-CLOSE", - "start_line": 9877 + "start_line": 9845 }, { "capability": "oversized_authorization_structure", @@ -423,14 +399,14 @@ { "capability": "oversized_authorization_structure", "content_sha256": "32083a125798736fa17533aaa767c0236ae3042de4f7d8debdce5f15e0a801fe", - "end_line": 9347, + "end_line": 9315, "hard_limit": 100, "kind": "test_helper", "observed_lines": 130, "path": "backend/tests/test_authorization.py", "qualified_symbol": "_operation_success", "removal_chunk": "WS-AUTH-003-CLOSE", - "start_line": 9218 + "start_line": 9186 } ], "exceptions": [], diff --git a/.ci/behavior-ownership/partition.v1.json b/.ci/behavior-ownership/partition.v1.json index bf2650013..224da1520 100644 --- a/.ci/behavior-ownership/partition.v1.json +++ b/.ci/behavior-ownership/partition.v1.json @@ -52,6 +52,26 @@ "group": "artifacts", "target": "backend/app/adapters/checkers/external_service.py" }, + { + "group": "artifacts", + "target": "backend/app/adapters/checkers/model_execution.py" + }, + { + "group": "artifacts", + "target": "backend/app/adapters/checkers/model_provider.py" + }, + { + "group": "artifacts", + "target": "backend/app/adapters/checkers/model_proxy.py" + }, + { + "group": "artifacts", + "target": "backend/app/adapters/checkers/model_proxy_transport.py" + }, + { + "group": "artifacts", + "target": "backend/app/adapters/checkers/model_runtime.py" + }, { "group": "shared", "target": "backend/app/adapters/guide_document_tools/child.py" @@ -120,6 +140,14 @@ "group": "lifecycle", "target": "backend/app/adapters/project_agents/result_validation.py" }, + { + "group": "lifecycle", + "target": "backend/app/adapters/project_agents/setup_model_handler.py" + }, + { + "group": "lifecycle", + "target": "backend/app/adapters/project_agents/setup_model_proxy.py" + }, { "group": "lifecycle", "target": "backend/app/adapters/projects/contribution_validation.py" @@ -152,6 +180,10 @@ "group": "shared", "target": "backend/app/api/deps/authorization.py" }, + { + "group": "artifacts", + "target": "backend/app/api/deps/checker_models.py" + }, { "group": "artifacts", "target": "backend/app/api/deps/checker_recovery.py" @@ -376,6 +408,14 @@ "group": "auth", "target": "backend/app/interfaces/auth.py" }, + { + "group": "artifacts", + "target": "backend/app/interfaces/checker_model_provider.py" + }, + { + "group": "artifacts", + "target": "backend/app/interfaces/checker_model_proxy.py" + }, { "group": "artifacts", "target": "backend/app/interfaces/external_checker_execution.py" @@ -400,6 +440,10 @@ "group": "lifecycle", "target": "backend/app/interfaces/project_guide_runtime.py" }, + { + "group": "shared", + "target": "backend/app/interfaces/setup_model_proxy.py" + }, { "group": "shared", "target": "backend/app/main.py" @@ -456,6 +500,10 @@ "group": "shared", "target": "backend/app/modules/api_controls/service.py" }, + { + "group": "artifacts", + "target": "backend/app/modules/artifacts/api/checker_output_authority.py" + }, { "group": "artifacts", "target": "backend/app/modules/artifacts/api/operator_authority.py" @@ -480,6 +528,10 @@ "group": "artifacts", "target": "backend/app/modules/artifacts/authorization.py" }, + { + "group": "artifacts", + "target": "backend/app/modules/artifacts/checker_model_evidence.py" + }, { "group": "artifacts", "target": "backend/app/modules/artifacts/checker_output_bindings.py" @@ -624,6 +676,10 @@ "group": "auth", "target": "backend/app/modules/authorization/acceptance_source_contracts.py" }, + { + "group": "auth", + "target": "backend/app/modules/authorization/action_definitions.py" + }, { "group": "auth", "target": "backend/app/modules/authorization/adapter_binding_authorization.py" @@ -728,6 +784,10 @@ "group": "auth", "target": "backend/app/modules/authorization/catalogue.py" }, + { + "group": "auth", + "target": "backend/app/modules/authorization/checker_output_authorization.py" + }, { "group": "auth", "target": "backend/app/modules/authorization/checker_registry_authorization.py" @@ -780,6 +840,10 @@ "group": "auth", "target": "backend/app/modules/authorization/domain/audit_targets.py" }, + { + "group": "auth", + "target": "backend/app/modules/authorization/domain/checker_outputs.py" + }, { "group": "auth", "target": "backend/app/modules/authorization/domain/checker_registry.py" @@ -836,6 +900,10 @@ "group": "auth", "target": "backend/app/modules/authorization/domain/management_reads.py" }, + { + "group": "auth", + "target": "backend/app/modules/authorization/domain/model_usage.py" + }, { "group": "auth", "target": "backend/app/modules/authorization/domain/outbox_dispatch.py" @@ -864,6 +932,10 @@ "group": "auth", "target": "backend/app/modules/authorization/domain/prepared_adapter_bindings.py" }, + { + "group": "auth", + "target": "backend/app/modules/authorization/domain/prepared_checker_runtime.py" + }, { "group": "auth", "target": "backend/app/modules/authorization/domain/prepared_compilation.py" @@ -972,6 +1044,10 @@ "group": "auth", "target": "backend/app/modules/authorization/management_read.py" }, + { + "group": "auth", + "target": "backend/app/modules/authorization/model_usage_authorization.py" + }, { "group": "auth", "target": "backend/app/modules/authorization/models.py" @@ -1180,6 +1256,10 @@ "group": "artifacts", "target": "backend/app/modules/checkers/api/materialization.py" }, + { + "group": "artifacts", + "target": "backend/app/modules/checkers/api/model_profiles.py" + }, { "group": "artifacts", "target": "backend/app/modules/checkers/api/output_custody.py" @@ -1244,6 +1324,10 @@ "group": "artifacts", "target": "backend/app/modules/checkers/history.py" }, + { + "group": "artifacts", + "target": "backend/app/modules/checkers/model_usage_custody.py" + }, { "group": "artifacts", "target": "backend/app/modules/checkers/models.py" @@ -1444,6 +1528,46 @@ "group": "lifecycle", "target": "backend/app/modules/contributions/units/service.py" }, + { + "group": "shared", + "target": "backend/app/modules/model_usage/api.py" + }, + { + "group": "shared", + "target": "backend/app/modules/model_usage/grants.py" + }, + { + "group": "shared", + "target": "backend/app/modules/model_usage/inspection.py" + }, + { + "group": "shared", + "target": "backend/app/modules/model_usage/models.py" + }, + { + "group": "shared", + "target": "backend/app/modules/model_usage/output_custody.py" + }, + { + "group": "shared", + "target": "backend/app/modules/model_usage/owner_ports.py" + }, + { + "group": "shared", + "target": "backend/app/modules/model_usage/profiles.py" + }, + { + "group": "shared", + "target": "backend/app/modules/model_usage/repository.py" + }, + { + "group": "shared", + "target": "backend/app/modules/model_usage/service.py" + }, + { + "group": "shared", + "target": "backend/app/modules/model_usage/setup.py" + }, { "group": "shared", "target": "backend/app/modules/outbox/api.py" @@ -1628,6 +1752,10 @@ "group": "lifecycle", "target": "backend/app/modules/projects/guide_compilation/live.py" }, + { + "group": "lifecycle", + "target": "backend/app/modules/projects/guide_compilation/model_usage.py" + }, { "group": "lifecycle", "target": "backend/app/modules/projects/guide_compilation/models.py" @@ -2196,6 +2324,10 @@ "group": "shared", "target": "backend/app/workers/celery_app.py" }, + { + "group": "artifacts", + "target": "backend/app/workers/checker_model_proxy.py" + }, { "group": "shared", "target": "backend/app/workers/errors.py" @@ -2377,7 +2509,7 @@ "target": "backend/scripts/validate_test_lane_evidence.py" } ], - "authority_digest": "8a42d5c62f07ce625a6f837dddeb7ae6e0f1c159bb74d81cb5f9820bf03c333f", + "authority_digest": "cbc1693904d86116e7bc0038d353d79e933be2778aa44d9e4cf3dbef06d6867c", "protected_base_commit": "7676ce4347db0c9694962a9b587a20765e16eac6", "schema": "workstream.behavior-ownership-partition.v1" } diff --git a/.ci/behavior-ownership/shared/module-boundary-validator.json b/.ci/behavior-ownership/shared/module-boundary-validator.json index 322401c55..ae0be76d0 100644 --- a/.ci/behavior-ownership/shared/module-boundary-validator.json +++ b/.ci/behavior-ownership/shared/module-boundary-validator.json @@ -37,7 +37,7 @@ "target": "backend/scripts/module_boundaries.py", "tests": [ "backend/tests/architecture/test_module_boundaries.py::test_repository_matches_the_protected_module_boundary", - "backend/tests/architecture/test_module_boundaries.py::test_registry_names_exactly_nine_business_and_three_supporting_modules", + "backend/tests/architecture/test_module_boundaries.py::test_registry_names_exactly_ten_business_and_three_supporting_modules", "backend/tests/architecture/test_module_boundaries.py::test_new_private_edge_fails_exact_ledger_comparison", "backend/tests/architecture/test_module_boundaries.py::test_import_from_modules_package_resolves_registered_alias", "backend/tests/architecture/test_module_boundaries.py::test_unregistered_top_level_module_file_fails_closed", diff --git a/.ci/module-boundaries/registry.v1.json b/.ci/module-boundaries/registry.v1.json index 67468eac7..1ecb74479 100644 --- a/.ci/module-boundaries/registry.v1.json +++ b/.ci/module-boundaries/registry.v1.json @@ -7,6 +7,7 @@ "tasks", "artifacts", "checkers", + "model_usage", "reviews", "contributions", "compensation" diff --git a/.commitrail/changes/checker-model-runtime.md b/.commitrail/changes/checker-model-runtime.md new file mode 100644 index 000000000..beef905dc --- /dev/null +++ b/.commitrail/changes/checker-model-runtime.md @@ -0,0 +1,446 @@ +# Shared budgeted model access for setup and POST checker agents + +- Initiative: None +- Durable disposition: Planned +- Intended merge outcome: Give setup inference and policy-bound POST agents one + project-funded model-use boundary with authentic workload lineage, immutable + evidence and recovery that cannot silently repeat a paid effect. + +## Intent + +The user resumed PILOT-05 for the real Terminal Systems project and then expanded +its intended outcome to the existing setup inference adapters. POST images own +their rubrics, bounded agent loops and interpretation; setup retains its existing +strict draft/review/correction SDK loops. Workstream owns one model-use authority, +project lifetime budget, provider effect and evidence boundary across both. No +project rule or generic agent framework belongs in the shared Rust runner. + +## Intended merge behavior + +One MODEL-USAGE owner supplies immutable project profiles, a non-resetting +project-lifetime account, bounded run envelopes, paid effects and private ART +evidence to both setup inference and approved POST images. Setup retains its +native OpenAI Agents SDK and OmniCoreAgent loops through the trusted proxy; POST +retains project-owned rubrics and invocation-local transport. Strong typed setup +and checker subjects share the meter without fabricating a CheckerRun for setup. +The linear `0062_model_usage` migration follows main's +`0061_project_catalogue_read` (which follows `0060_audit_restore_path`); +intermediate upgrades retain main data and leave one head. + +## Bounded change + +### Allowed + +- A provider-neutral MODEL-USAGE owner for project profiles, one project lifetime + account, reservations, settlement, paid effects and protected evidence. +- Strong owner-specific setup and CHECKERS subject contracts. Setup binds the + exact compilation attempt, setup generation and snapshotted runtime hash; POST + binds exact Submission/requirement/registry/configuration and current run/lease. +- Existing pinned OpenAI Agents SDK and OmniCoreAgent request boundaries, without + replacing either agent loop or forwarding arbitrary HTTP. +- CHECKERS selected POST integration and existing AUTH, PROJECTS and TASK ports + for fresh owner currentness and Operator provisioning. +- Existing ART generated-output custody to retain bounded private model + request/response evidence, with matching authority, read concealment and + exact setup/checker lineage. +- Typed provider capability under `backend/app/interfaces/`, concrete adapter + and explicit ADR-0014 composition; fixed settings outside images. +- One linear Alembic continuation with preflight, intermediate upgrade and + retained-data proof; affected schema snapshots, permission catalogues and MCP + contracts updated together where changed. +- Shared Rust runner and SDK: invocation-local socket transport only, preserving + network denial, read-only submission material, cleanup and output budgets. +- Focused PostgreSQL/MinIO, transport, provider and public lifecycle tests; + affected CI allocation, owner specifications, runbook and roadmap. +- Reconcile the existing CLI retry-source decoder and its process tests with + the bounded model recovery candidates returned by that public read. + +### Not allowed + +No provider credential or global proxy socket in project images; no contributor +or project-owner billing, alternate checker implementation kind, project rules +in Rust, generic agent framework, arbitrary forwarding proxy, unrestricted +provider/model selection, automatic paid retry after ambiguous delivery, or +task/revision spending on infrastructure failures. Historical setup spend lacks +authenticated pricing/usage facts and must not be guessed as zero or backfilled; +paid work stays disabled until an Operator provisions an externally reconciled +remaining allowance within the already-approved US$10 total rehearsal ceiling. +No live paid call, merge or hosted deployment is authorized here. + +## Design and decisions + +An immutable project model-use profile binds provider/API/model snapshot, +generation parameters, per-call and whole-run dispatch/token/byte/deadline +ceilings, supported tool/request shape and immutable integer pricing. The common +profile is not registry-owned; the POST owner separately binds it to an exact +registry/image/configuration while setup snapshots the same selector into its +attempt runtime configuration. Reserve the complete selector triple +`model_profile_id`, `model_profile_version`, `model_profile_digest` in the flat +PM-approved image configuration. A typed exact profile reader checks project, +provider/API/model and profile identity during policy compilation, setup request, +readiness/activation and runtime. Partial selectors fail; existing owner hashes +retain custody without a new checker kind or TASK lineage column. +The user authorized US$10 total for the Terminal Systems rehearsal; configure +10,000,000,000 USD nanodollars and preserve all reservations and settled spend +across retries and configuration versions. No live call may precede enforcement. +An Operator sets a finite project budget; profiles do not +allow images to change that budget or choose a different model. Monetary amounts +use integer USD nanodollars with explicit immutable pricing provenance. + +Each owner allocates a finite phase/call slot before provider I/O; transport +arrival order and in-memory counters cannot mint durable identities. POST grants +bind current execution and exact Submission/requirement/registry/configuration. +Setup grants bind the exact compilation attempt/generation/runtime hash without a +fabricated CheckerRun. Rust exposes only invocation-local capabilities. Native +setup wrappers intercept each real pinned-SDK request; project images receive a +restricted model session. Credentials, global endpoints and opaque backend grants +never enter image input or logs. + +Whole-run reservation is sized for every possible actual provider dispatch, +including correction and supported retries. Hidden SDK retries are disabled or +metered individually; ambiguity prohibits the next dispatch. Restart replays +cached exact provider responses only when canonical request hashes match, so SDK +state and tool-call identities reconstruct without another paid create. Known +provider IDs may be retrieved; unknown effects retain reservation and cannot be +recreated. New children remain limited to proven no-effect or fully settled +eligible failures. + +The common effect row has exactly one SQL-enforced strong owner binding. Checker +lineage retains composite submission/registry/configuration custody; setup lineage +uses composite compilation-attempt/project/setup-generation/runtime-hash custody. +No owner-kind/owner-ID pair or JSON-only lineage is sufficient. AUTH plus the +affected PROJECTS/TASK owner locks precede MODEL locks; narrow late completion can +finish an already-paid exact effect without re-entering those owners, while a new +dispatch always requires fresh owner currentness first. + +The paid slot identity is `(project, assignment, submission, submission_version, +requirement, registry_digest, configuration_hash, profile_version, slot_index)`. +Neither worker lease nor checker retry generation belongs in this key. The first +provider request hash is immutable: a later changed prompt conflicts. Successor +checker runs reuse the same cached or recoverable call. An authorized child +attempt is possible only after a proven no-effect outcome or an authenticated +terminal provider failure with no usable output and fully settled usage. It +requires explicit Operator recovery authority and a new atomic reservation; +the image cannot select its attempt number. + +Create or attach the stable slot and verify its request evidence first. Reserve +the worst-case allowed input/output charge just before first dispatch, after +fresh authority/currentness, committing reservation and dispatch intent together +before provider I/O. Preserve +AUTH/TASK-before-CHECKERS custody; budget locks must never lead back to TASK. +Concurrent callers serialize the same slot and project budget. Persist dispatch +intent before create, persist provider identity immediately when returned, and +settle measured usage exactly once. Lost create responses remain uncertain and +retain their reservation; absence of a provider ID is not proof of no effect. +Known provider IDs may be retrieved without another create. Cached completion +is immutable and survives process, lease and Operator retries. + +Setup requests disable provider-side storage. A known provider ID is therefore a +durable observation, not by itself a recovery source: if the process is lost before +response ART custody, the exact effect remains uncertain and keeps its reservation. +Only retained verified response evidence or an authenticated provider/no-effect +proof may settle or release it; an Operator read cannot manufacture certainty. + +Keep the existing CheckerRun lifecycle. A run may finish `infrastructure_failed` +when its model response is unavailable, while the independent paid call remains +`dispatching` or `uncertain`. Operator retry attaches the same slot; it may only +recover that effect, never create again because a generation changed. A late +response may complete the original call after its run terminalized, allowing the +next current run to consume its cached evidence. Only an explicitly authorized +child of a proven no-effect or fully settled terminal failure receives fresh +dispatch authority. An uncertain effect or terminal failure with missing usage +remains ineligible. + +Extend the existing ART generated-output custody with a paid-call-stable +reservation for two bounded canonical JSON slots: request before dispatch and +response/usage on known completion. Do not write transcript bytes to PostgreSQL, +Redis or outbox payloads. Ledger rows contain exact content identities and hashes. +Once dispatch intent is committed, the call-effect owner retains narrowly scoped +authority to finish/recover those exact evidence slots independently of the +original worker lease. It cannot start a new effect under that authority. Fresh +current-run authority links verified call evidence to each consuming run; stale +images cannot spend or gain read access to another invocation. This extends the +same ART put/verify/bind lifecycle, not a parallel artifact store. The unused +run-only write selector and deny-only producer composition are replaced; retained +historical rows keep their immutable ancestry. Private call bindings remain +retained independently of the original checker run. + +Execution-trial reservation, engine behavior and trial-evidence ownership remain +in issue #493. This change exposes the bounded multi-call/model-evidence session +used by that project runtime, but does not claim execution-tool custody or add a +trial table to the independent #492 migration. + +The local relay root must be mounted at the same Docker-daemon-visible path, +with private same-UID socket custody. gVisor requires the administrator-provided +`host-uds=open` configuration for this mounted socket; Docker development proof +does not qualify gVisor behavior. + +Explicit service actions separate `checker.model_call.dispatch`, +`checker.model_call.recover` and `checker.model_call.complete` under existing +AUTH grants. Dispatch takes exact current execution facts; recover can only +inspect the existing effect; complete takes immutable call-attempt/effect facts +and may retain late evidence. Operator project budget/profile provisioning and +return exact configuration and lifetime budget counters. Broader PM cost-read +surfaces remain in PILOT-05; this bounded runtime exposes Operator controls. + +A dedicated singleton backend process owns the private model listener, rather +than binding it in every API lifespan. Local compose opts into the `model-proxy` +profile. Its graceful drain includes margin beyond the maximum provider deadline; +abrupt termination still retains the original dispatch intent and reservation. + +Implementation order: provider-free profile/slot/budget contracts and PostgreSQL +proof; AUTH and profile approval/readiness; ART evidence custody with MinIO crash +proof; typed fake provider/proxy; invocation-local Rust socket; selected POST +finalization/retry integration; offline project proof; capped live rehearsal. + +Provider output and usage must satisfy the locked bounds. Budget exhaustion, +provider outage, invalid provider response and uncertain delivery are +infrastructure outcomes, never contributor-fixable failures. Private evidence +does not appear in contributor findings or exported provider coordinates. + +## Acceptance criteria + +- One digest-pinned image uses its invocation socket to obtain a bounded model + response while provider credentials and the global endpoint remain unavailable. +- Two concurrent distinct calls cannot exceed the project budget; duplicate + requests yield one provider create and one settlement. +- Provider acceptance followed by response loss, process/lease restart and + Operator checker retry cannot create a second paid call. A generation-keyed + mutant must fail this regression. +- A changed prompt, substituted project/Submission/profile, stale lease, expired + grant, PRE invocation or unentitled image cannot spend. +- Request/response evidence, exact pricing and usage survive restart and replay; + raw content remains under existing private ART custody. +- Budget/provider failure produces infrastructure recovery and no revision + consumption. The existing non-model image path retains its behavior. +- Real PostgreSQL proves locking, rollback, settlement and linear upgrades; + MinIO proves evidence custody. Offline provider tests precede capped live calls. + +## Risk and review routing + +Risk class: L0. Required architecture/plan, authorization/security, database and +QA review, plus CI/docs for affected changes. Human focus: spend authorization, +ambiguous provider effects, immutable policy identity, owner boundaries and +honest separation of local proof from deployed operation. + +## Evidence + +The canonical parent schema installs linearly through +`0060_audit_restore_path`, `0061_project_catalogue_read`, and +`0062_model_usage`; its measured public-schema digest is +`d14a06c088aae0e8876bebb637a7b3fb0c19344f8cc4fdfd31aa2c1a47348593`. +The migration and owner guard retain immutable prepare/dispatch/terminal +authority facts instead of reconstructing them from a later worker lease. A +real PostgreSQL selection passed 20/20 checker and setup authority cases, +including every foreign digest branch, a renewed legitimate checker grant, +successor-run attachment to one paid effect, known-failed settlement, and stale +or reused authorization rejection. Independent QA reran seven discriminating +authority/renewal cases and killed a mutant that restored latest-grant lookup. + +The controlled composed boundary has separate current evidence for its ordinary +and recovery paths. Four ordinary completion, retained-result and concurrent +callback cases passed with real PostgreSQL and MinIO. The concurrent callback +is held before first response publication so the second caller must recover the +known provider identifier; both replies use one effect, response receipt and +settlement. Proven no-effect and fully settled failure each passed their public +Operator recovery journey with exactly one additional pre-authorized dispatch, +while the original effect and evidence remain immutable. Database and MinIO +cleanup completed for each owned run. The setup handler separately proves that +two simultaneous resumptions share one durable paid effect and provider create; +the scripted setup coordinator convergence test is intentionally not represented +as paid-effect deduplication. + +Private request/response evidence uses the existing generated-output ART path. +Manual setup correction dispatch passed five public HTTP/PostgreSQL profile +selection, replay and denial cases: a created attempt remains pinned to profile +version 1 even after version 2 is published. Actual MinIO tests passed 11/11 +transport-timeout, outer-cancellation, precondition and malformed-observation +cases after the S3 adapter was made to join its single SDK body consumer before +returning; no source reads continue after timeout cleanup. + +The current hosted configuration passes 38/38 focused tests after reconciling +main's cached proof-image policy with the private model-proxy image identity. +Current main's Smol microVM engine was also reconciled without claiming a socket +capability it does not provide: its configuration rejects the host model relay, +and a model-entitled request on a Smol-only node closes as infrastructure +unavailable before VM admission. The combined checker-service unit selection +passes 44/44; model-enabled POST remains bound to the intended `runsc`/`gvisor` +deployment path, which still requires deployed qualification. +An actual parent Rust relay image previously passed a controlled Docker smoke +with no network, read-only input/root filesystem, only the invocation socket +exposed, the global grant concealed, and owned image/container cleanup +confirmed. Static API/lane, CLI, MCP, Ruff, behavior ownership and structural +debt checks pass their affected selections. These are controlled local proofs, +not hosted deployment or composed gVisor qualification. + +Independent QA issued a PASS for the reviewed MODEL runtime atoms and the +discriminating PostgreSQL/composed callback tests. Attempts to obtain the +separate security-review verdict ended in platform review errors, so no security +sign-off is claimed. Current hosted CI, retained-data qualification on the +stacked migration, and remaining independent review gates must complete before +the draft is marked ready. No live provider call or paid spend occurred. The +historical setup spend remains unreconciled; deployment and any paid rehearsal +remain disabled until the Operator supplies the externally reconciled remaining +allowance within the existing US$10 aggregate ceiling. + +## Reconciliation + +The earlier PILOT-05 hold is lifted only for this required runtime scope. The +user approved a hard US$10 aggregate rehearsal cap; enforce it before live calls. +Review current main and open migrations before allocating a +revision number. Required execution trials and authenticated prior-work context +must still be supplied before final guide activation. + +### Active setup-output custody ownership + +The setup ART continuation is limited to public setup selector/reservation +types and a MODEL-USAGE-owned resolver, the existing ART generated-output +admission/storage/verification/binding path, its prepared AUTH resource, the +`0062_model_usage` ART constraints/guards, adapter composition, and focused +AUTH/PostgreSQL/MinIO regressions. Its owned paths are +`backend/app/modules/model_usage/api.py`, a new cohesive resolver under +`backend/app/modules/model_usage/`, +`backend/app/modules/checkers/api/output_custody.py`, the existing +`backend/app/modules/artifacts/` checker-output API, custody, binding, service, +schema, model and adapter-composition files, +`backend/app/adapters/model_usage/__init__.py`, +`backend/app/modules/authorization/checker_output_authorization.py`, +`backend/app/modules/authorization/domain/checker_outputs.py`, +`backend/app/modules/authorization/domain/audit.py`, +`backend/alembic/versions/0062_model_usage.py`, and only their focused tests and +schema inventory/fingerprint updates. This continuation does not edit +`backend/app/modules/model_usage/setup.py`, SDK/transport/provider dispatch, +PROJECTS owners, or CHECKERS ORM. It adds only the closed setup-model-output +case; independent trial-evidence types remain outside this branch. + +### Exact effect inspection + +The shared owner exposes one bounded System Operator read at +`GET /api/v1/operations/projects/{project_id}/model-usage/effects/{effect_id}`. +Fresh `project.model_usage.read` authority reuses the existing +`project.model_budget.manage` permission and binds the request, actor, project +and exact effect before MODEL-USAGE is read. Missing and foreign effects are +concealed. The response contains only the typed stable setup/checker owner, +profile selector, state, reservations/settlement, usage and ART evidence +identities; it never returns provider response coordinates, request/response +bytes, credentials, list access or mutation authority. The action/permission +pair is added to the same linear `0062_model_usage` audit allowlist and the MCP +authorization-context snapshot. + +## Historical checker-only review + +The following review is retained as evidence for the earlier checker-only +checkpoint. Its head, paths, migration names and verdict do not qualify the +current shared setup/POST candidate. Current exact-head recovery qualification, +full CI and independent security/authorization/database/QA review remain pending. + +### Independent QA, test-delta and CI review + +Review dimensions: `qa`, `test_delta`, and `ci_integrity`. Review target: +base and merge base `6fa9a9b557fa3ae22c16af67bca25dc868f67b38`, frozen head +`57b3619a22fb23995d663cb747a074e93af9225f`. The start snapshot and the +end-of-evidence snapshot both reported that exact head, `final_ready: true`, and +a clean worktree. This section is the only worktree mutation made after the end +snapshot. The final merge differs from runtime commit `63614e145` only in the +separate terminal-systems PRE change record and roadmap prose; this review did +not repeat the PRE review. + +Verdict: **PASS WITH LOW RISKS** for the three review dimensions. No runtime, +test-selection, or required-job defect was confirmed. Finding `QA-CI-001` is a +non-blocking reliability risk in one Rust unit test. Publication remains +separately blocked on Commitrail branch reconciliation: comparison with main +includes the PRE record, while comparison with the PRE head includes the newer +main record. That gate must not be weakened. + +### Finding + +- **QA-CI-001 — severity Low; confidence high; non-blocking — timing-sensitive + relay assertion.** Target `57b3619a2`; location + `external_checkers/workstream-checker-service/src/model_relay.rs:891` and + `:910`; failure pattern `FP-TIME-BOUNDARY-FLAKE`. The required exact-head + Rust workspace invocation failed once because + `distinct_invocations_and_frame_bounds_do_not_cross` did not observe + `first.finish().failed`. The test gives the relay only 100 ms to accept and + classify the oversized frame. The exact node passed immediately in + isolation, passed 30 further isolated repetitions, and the whole 44-test + workspace passed on rerun. This does not establish a relay runtime failure, + but a loaded runner can produce a false required-check failure. Use a + deterministic readiness handshake, or a materially larger deadline while + retaining a separate elapsed-time bound. Verification should repeat the + workspace under scheduler load and show the oversized-frame assertion still + reaches the intended branch. + +### Atomic traceability + +| Behavior atom | Production owner and changed boundary | Discriminating evidence | Result and custody | +| --- | --- | --- | --- | +| A published POST profile fixes provider, API, model snapshot, token ceiling, pricing and maximum reservation before spend. | `backend/app/modules/checkers/model_calls/profiles.py`, `backend/app/modules/checkers/model_calls/service.py:550`, `backend/app/adapters/checkers/model_provider.py:113` | Exact-head contract, provider-adapter, proxy-orchestration and AUTH selection; source inspection of stored background response creation and strict usage normalization. | Pass in the reviewer-run 76-test selection. This is controlled-provider contract evidence, not a live-provider claim. | +| One stable paid slot survives checker retry; ambiguous provider effects are not recreated; lifetime reserved plus settled spend cannot exceed the project ceiling. | `backend/app/modules/checkers/model_calls/service.py:683`, `:911`, `:1186`; migration `0059_checker_model_runtime.py` | Exact-head owned PostgreSQL/MinIO nodes `minio-retry-retains-paid-effect` and `test_concurrent_dispatch_reserves_one_lifetime_budget`. | Pass. Metadata binds schema `0059`, head `57b3619a2`, owned PG and MinIO namespaces, and successful cleanup. | +| Request and response evidence stays in private ART custody and completion consumes the retained paid effect. | `backend/app/adapters/checkers/model_proxy.py:142`, `:231`, `:276`; ART model-call evidence and binding modules | The exact MinIO composed node exercises real local PostgreSQL and MinIO with the controlled proxy/provider/image boundary; canonical ART, lineage, rollback, currentness and old-tail cases were also inspected in the supplied artifacts. | Exact composed boundary passes. The broader supplied artifacts were produced on the same runtime tree before commit/final-doc merge and are producer-attested rather than self-authenticating exact-head proof. | +| Operator recovery creates a bounded authorized child only after proven no-effect or fully settled failure. | `backend/app/modules/checkers/model_calls/service.py:607`, `:1045`, `:1102`; checker retry source | Exact-head composed `operator-recovers-no-effect`; supplied six-case composed artifact additionally covers settled-failure recovery. | No-effect recovery passes at exact head. Settled-failure coverage is inspected producer-attested evidence and is not promoted to exact-head execution. | +| Prepared checker-output AUTH accepts its JSON packet while still rejecting string-coerced integers. | `backend/app/modules/authorization/domain/checker_outputs.py:213`; `backend/tests/authorization/checker_models/test_contracts.py:141` | Exact-head positive tests plus an in-memory old strict-Python-decoder mutant. | Pass; both WRITE and BIND tests fail at the intended validation assertion under the mutant, before no unrelated setup. | +| A checker image receives one invocation-local relay socket, with no network, a read-only root and a closed response contract. | `external_checkers/workstream-checker-service/src/model_relay.rs:1`, service container arguments, `docker-compose.agent-loop.yml:59` and `:103` | Exact-head Python transport tests, exact-head Rust workspace rerun, exact Compose render, and inspected real-Docker relay proof. | Controlled boundaries pass, subject to `QA-CI-001`. The Docker artifact proves Docker only; it is not gVisor qualification. | +| CLI and MCP surfaces preserve the public model-operation and authorization vocabulary. | `cli/internal/api/checker_models.go`, `cli/internal/api/checker_operations.go`, MCP contracts and access-read tests | Inspected CLI operation artifact (including Go build/vet) and corrected MCP contract artifact, plus actual-backend permission/role evidence supplied by the producer. | Pass within artifact custody. GitHub authentication was invalid, so no hosted check result was available to independently bind these runs to the PR. | +| The changed cross-cutting surface selects all required backend, external checker, MinIO, CLI, MCP and semantic-lane jobs. | `backend/scripts/select_ci_scope.py:121`, `backend/scripts/test_lane_catalogue.py:485`, `.github/workflows/backend.yml` | Reviewer executed the selector on the exact base/head pair; it returned `unknown_or_cross_cutting_path` with every required boolean true and the full lane matrix. Catalogue ownership and the final required-job aggregator were inspected. | Pass. No hosted workflow was run during this review. | + +### Evidence executed by this reviewer + +- `/dev/shm/model-qa-exact-head-minio-v5.log` and `.json`: three selected + tests passed against a fresh owned PostgreSQL database and MinIO namespace; + tree, migration head, provisioning, probe and cleanup fields are present. +- `/dev/shm/model-qa-exact-head-pure.log`: 76 selected contract, runtime, + proxy, provider, transport and authorization tests passed. +- `/dev/shm/model-qa-parser-mutant-exact.log`: the old decoder produced both + expected failures, killing the targeted regression mutant. +- `/dev/shm/model-qa-exact-head-rust.log`: the first workspace run exposed + `QA-CI-001`; `/dev/shm/model-qa-exact-head-rust-retry.log` passed the isolated + node, 30 additional repetitions passed, and + `/dev/shm/model-qa-exact-head-rust-rerun.log` passed the workspace rerun. +- Exact-head Compose rendering passed the proxy/socket/network/read-only mount + assertions. Exact base/head CI selection produced + `/dev/shm/model-qa-ci-scope-*.out` with full scope. +- `/dev/shm/model-qa-end-target.json` records the clean final evidence + snapshot before this required review-record write. + +### Inspected evidence and remaining limits + +The review also inspected the supplied composed, ledger/recovery, signed-public, +canonical ART, old-tail, retained-upgrade, provider/proxy, output-contract, +CLI, MCP, Rust and real-Docker artifacts. The original old-tail artifact was +excluded because its database was cleaned too early; only `tail-v2` was used. +Test names and counts were not treated as runtime proof: assertions, setup +completion, metadata custody and owner code were inspected. No live provider +call, hosted CI run, gVisor run, or complete real-project execution occurred, +so this verdict makes none of those claims. + + +Manual guide correction dispatch must select the same immutable project model +profile as automatic setup before request facts and runtime hashes are locked. +The real public correction regression exposed a missing selection: the worker +correctly refused the already-created attempt because its configuration had no +profile. The repair shares selection in the existing PROJECTS request-input +module, supplies the MODEL-USAGE read port at composition, and preserves an +existing attempt's exact configuration on replay. A later Operator profile +publication must not change that attempt. This owns the affected correction +dispatch, live coordinator, request-input and composition files plus their +existing public and boundary tests; it adds no new route or model call. + +An S3 write timeout may cancel the pinned aiohttp request while its separately +scheduled body-writer task is still advancing the single committed scratch +stream. Recovery must own that task to quiescence before it validates, observes, +or releases the sealed source. This bounded repair owns only the S3-compatible +adapter and its focused tests: it must reproduce the actual asynchronous-writer +race, prevent concurrent advancement and post-return source access, preserve +the source's finite scratch deadline, and never accept an incomplete source as +a replay. A canceled partial write is unavailable/uncertain and remains +recoverable only through the existing complete provider-object observation; +increasing transport timeouts is not a correctness fix. + +The pre-repair probe used the pinned aiohttp 3.14.3 writer against a local +stalled HTTP endpoint and deterministically produced the qualification failure: +request timeout followed by `anext(): asynchronous generator is already +running`. With explicit consumer ownership, the same probe joins the writer and +classifies the still-incomplete body unavailable. Eleven focused S3 cases pass +against MinIO, covering transport timeout, outer caller cancellation, no source +access after return, early 412 replay, missing replay, malformed sealed bytes, +unconsumed success, and independent uncertain-write observation. diff --git a/backend/alembic/env.py b/backend/alembic/env.py index 234edda18..ef0782c92 100644 --- a/backend/alembic/env.py +++ b/backend/alembic/env.py @@ -21,7 +21,7 @@ target_metadata = Base.metadata _BASELINE_REVISION = "0001_uuid7_v01" -_CURRENT_HEAD_REVISION = "0061_project_catalogue_read" +_CURRENT_HEAD_REVISION = "0062_model_usage" _RECREATE_GUIDANCE = ( "Workstream v0.1 requires a fresh database; recreate this database before " "running the 0001_uuid7_v01 migration" @@ -114,6 +114,7 @@ def do_run_migrations(connection: Connection) -> None: ("0058_compensation_unit_create",), ("0059_neutral_guide_runtime",), ("0060_audit_restore_path",), + ("0061_project_catalogue_read",), (_CURRENT_HEAD_REVISION,), ): raise RuntimeError(_RECREATE_GUIDANCE) diff --git a/backend/alembic/versions/0062_model_usage.py b/backend/alembic/versions/0062_model_usage.py new file mode 100644 index 000000000..fb44aeec6 --- /dev/null +++ b/backend/alembic/versions/0062_model_usage.py @@ -0,0 +1,2911 @@ +"""Add shared project-budgeted model-use custody for setup and checkers.""" + +from alembic import op +from sqlalchemy import text + + +revision = "0062_model_usage" +down_revision = "0061_project_catalogue_read" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + """Create shared MODEL-USAGE owners before ART and AUTH extensions.""" + op.execute("SET LOCAL search_path=pg_catalog,public,pg_temp") + _upgrade_setup_runtime_configuration_guard() + for statement in _TABLES: + op.execute(statement) + for statement in _FUNCTIONS: + op.execute(statement) + for statement in _TRIGGERS: + op.execute(statement) + _upgrade_invocation_grants() + _upgrade_art_custody() + _upgrade_authorization_vocabulary() + + +def _upgrade_setup_runtime_configuration_guard() -> None: + """Admit only an absent/null or exact complete model-profile selector.""" + signature = "public.guard_project_guide_runtime_configuration()" + definition = ( + op.get_bind() + .execute( + text( + "select pg_catalog.pg_get_functiondef(cast(:signature as pg_catalog.regprocedure))" + ), + {"signature": signature}, + ) + .scalar_one() + ) + allowed_anchor = "'maximum_tool_calls','maximum_tool_output_characters'] <> '{}'::jsonb" + allowed_replacement = ( + "'maximum_tool_calls','maximum_tool_output_characters'," + "'model_profile_id','model_profile_version','model_profile_digest'] <> '{}'::jsonb" + ) + validation_anchor = " OR jsonb_typeof(config->'adapter_profile') IS DISTINCT FROM 'string'" + profile_validation = r""" + OR ( + ((NOT config ? 'model_profile_id' OR config->'model_profile_id'='null'::jsonb) + AND (NOT config ? 'model_profile_version' OR + config->'model_profile_version'='null'::jsonb) + AND (NOT config ? 'model_profile_digest' OR + config->'model_profile_digest'='null'::jsonb)) + OR + (jsonb_typeof(config->'model_profile_id')='string' + AND config->>'model_profile_id' ~ + '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$' + AND public._guide_runtime_integer( + config,'model_profile_version',1,2147483647) IS TRUE + AND jsonb_typeof(config->'model_profile_digest')='string' + AND config->>'model_profile_digest' ~ '^sha256:[0-9a-f]{64}$') + ) IS NOT TRUE +""" + if ( + definition.count(allowed_anchor) != 1 + or definition.count(validation_anchor) != 1 + or "model_profile_id" in definition + ): + raise RuntimeError("setup runtime configuration predecessor changed") + op.execute( + definition.replace(allowed_anchor, allowed_replacement).replace( + validation_anchor, profile_validation + validation_anchor + ) + ) + + +def _upgrade_invocation_grants() -> None: + """Create bounded invocation sessions after their retained call-ledger parents.""" + op.execute( + r""" +CREATE TABLE public.checker_model_invocation_grants ( + id uuid NOT NULL, + token_hash varchar(71) NOT NULL, + project_id uuid NOT NULL, + checker_run_id uuid NOT NULL, + registry_entry_id uuid NOT NULL, + execution_facts jsonb NOT NULL, + paid_slot jsonb NOT NULL, + external_request_digest varchar(71) NOT NULL, + expires_at timestamptz NOT NULL, + issued_at timestamptz DEFAULT clock_timestamp() NOT NULL, + authorization_evidence_id uuid NOT NULL, + CONSTRAINT pk_checker_model_invocation_grants PRIMARY KEY(id), + CONSTRAINT ck_checker_model_invocation_grants_id_uuid7 CHECK ( + (get_byte(uuid_send(id),6)>>4)=7 AND (get_byte(uuid_send(id),8)&192)=128), + CONSTRAINT ck_checker_model_invocation_grants_digests CHECK ( + token_hash ~ '^sha256:[0-9a-f]{64}$' AND + external_request_digest ~ '^sha256:[0-9a-f]{64}$'), + CONSTRAINT ck_checker_model_invocation_grants_json CHECK ( + jsonb_typeof(execution_facts)='object' AND jsonb_typeof(paid_slot)='object'), + CONSTRAINT uq_checker_model_invocation_grants_token UNIQUE(token_hash), + CONSTRAINT uq_checker_model_invocation_grants_authority + UNIQUE(authorization_evidence_id), + CONSTRAINT fk_checker_model_invocation_grants_project FOREIGN KEY(project_id) + REFERENCES public.projects(id) ON DELETE RESTRICT, + CONSTRAINT fk_checker_model_invocation_grants_run FOREIGN KEY(checker_run_id) + REFERENCES public.checker_runs(id) ON DELETE RESTRICT, + CONSTRAINT fk_checker_model_invocation_grants_registry FOREIGN KEY(registry_entry_id) + REFERENCES public.external_checker_registry_entries(id) ON DELETE RESTRICT, + CONSTRAINT fk_checker_model_invocation_grants_authority + FOREIGN KEY(authorization_evidence_id) + REFERENCES public.audit_events(id) ON DELETE RESTRICT +) + """ + ) + op.execute( + r""" +CREATE INDEX ix_checker_model_invocation_grants_run + ON public.checker_model_invocation_grants(checker_run_id) + """ + ) + op.execute( + r""" +CREATE FUNCTION public.guard_checker_model_invocation_grant() RETURNS trigger +LANGUAGE plpgsql SET search_path=pg_catalog,public,pg_temp AS $$ +DECLARE authority public.audit_events%ROWTYPE; +DECLARE authority_actor public.actor_profiles%ROWTYPE; +DECLARE authority_facts jsonb; +DECLARE authority_resource_digest varchar; +DECLARE task_project uuid; +DECLARE assignment_task uuid; +DECLARE assignment_project uuid; +DECLARE submission_task uuid; +DECLARE run public.checker_runs%ROWTYPE; +DECLARE registry public.external_checker_registry_entries%ROWTYPE; +DECLARE profile public.model_usage_profiles%ROWTYPE; +DECLARE binding_matches integer; +BEGIN + IF TG_OP='DELETE' THEN + RAISE EXCEPTION 'checker model invocation grants cannot be deleted' + USING ERRCODE='55000'; + END IF; + IF TG_OP='UPDATE' THEN + RAISE EXCEPTION 'checker model invocation grant facts are immutable' + USING ERRCODE='55000'; + END IF; + + IF jsonb_typeof(NEW.execution_facts)<>'object' OR + jsonb_typeof(NEW.paid_slot)<>'object' OR + (SELECT count(*) FROM jsonb_object_keys(NEW.execution_facts))<>11 OR + NOT NEW.execution_facts ?& ARRAY[ + 'project_id','task_id','assignment_id','submission_id','submission_version', + 'evaluation_request_id','evaluation_generation','checker_run_id', + 'worker_lease_id','worker_lease_generation','worker_lease_expires_at'] OR + (SELECT count(*) FROM jsonb_object_keys(NEW.paid_slot))<>11 OR + NOT NEW.paid_slot ?& ARRAY[ + 'project_id','assignment_id','submission_id','submission_version', + 'requirement_id','registry_entry_digest','configuration_sha256','profile_id', + 'profile_version','profile_digest','slot_index'] OR + jsonb_typeof(NEW.execution_facts->'submission_version')<>'number' OR + jsonb_typeof(NEW.execution_facts->'evaluation_generation')<>'number' OR + jsonb_typeof(NEW.execution_facts->'worker_lease_generation')<>'number' OR + jsonb_typeof(NEW.paid_slot->'submission_version')<>'number' OR + jsonb_typeof(NEW.paid_slot->'profile_version')<>'number' OR + jsonb_typeof(NEW.paid_slot->'slot_index')<>'number' OR + (NEW.execution_facts->>'submission_version')::integer<=0 OR + (NEW.execution_facts->>'evaluation_generation')::integer<=0 OR + (NEW.execution_facts->>'worker_lease_generation')::integer<=0 OR + (NEW.paid_slot->>'submission_version')::integer<=0 OR + (NEW.paid_slot->>'profile_version')::integer<=0 OR + (NEW.paid_slot->>'slot_index')::integer<>0 OR + NEW.execution_facts->'submission_version' IS DISTINCT FROM + to_jsonb((NEW.execution_facts->>'submission_version')::integer) OR + NEW.execution_facts->'evaluation_generation' IS DISTINCT FROM + to_jsonb((NEW.execution_facts->>'evaluation_generation')::integer) OR + NEW.execution_facts->'worker_lease_generation' IS DISTINCT FROM + to_jsonb((NEW.execution_facts->>'worker_lease_generation')::integer) OR + NEW.paid_slot->'submission_version' IS DISTINCT FROM + to_jsonb((NEW.paid_slot->>'submission_version')::integer) OR + NEW.paid_slot->'profile_version' IS DISTINCT FROM + to_jsonb((NEW.paid_slot->>'profile_version')::integer) OR + NEW.paid_slot->'slot_index' IS DISTINCT FROM + to_jsonb((NEW.paid_slot->>'slot_index')::integer) OR + coalesce(NEW.paid_slot->>'requirement_id','') + !~ '^[a-z][a-z0-9_.-]{0,99}$' OR + coalesce(NEW.paid_slot->>'registry_entry_digest','') + !~ '^sha256:[0-9a-f]{64}$' OR + coalesce(NEW.paid_slot->>'configuration_sha256','') + !~ '^sha256:[0-9a-f]{64}$' OR + coalesce(NEW.paid_slot->>'profile_digest','') + !~ '^sha256:[0-9a-f]{64}$' THEN + RAISE EXCEPTION 'checker model invocation grant facts are invalid' + USING ERRCODE='23514'; + END IF; + + SELECT * INTO authority FROM public.audit_events + WHERE id=NEW.authorization_evidence_id FOR SHARE; + authority_facts:=pg_catalog.jsonb_build_object( + 'execution',NEW.execution_facts,'slot',NEW.paid_slot, + 'external_request_digest',NEW.external_request_digest, + 'token_hash',NEW.token_hash); + authority_resource_digest:=public.model_usage_json_digest( + pg_catalog.jsonb_build_object('resource_context',pg_catalog.jsonb_build_object( + 'resource_type','checker_model_call', + 'resource_id',(NEW.execution_facts->>'evaluation_request_id')::uuid, + 'scope_project_id',NEW.project_id, + 'action_id','checker.model_call.grant','facts',authority_facts, + 'facts_digest',public.model_usage_json_digest(pg_catalog.jsonb_build_object( + 'action','checker.model_call.grant','facts',authority_facts))))); + SELECT * INTO authority_actor FROM public.actor_profiles + WHERE id=authority.actor_id::uuid FOR SHARE; + IF authority.id IS NULL OR + authority_actor.id IS NULL OR + authority.event_domain IS DISTINCT FROM 'authority' OR + authority.event_type IS DISTINCT FROM 'SensitiveAuthorizationAllowed' OR + authority.actor_ref_kind IS DISTINCT FROM 'actor_profile' OR + authority.action_id IS DISTINCT FROM 'checker.model_call.grant' OR + authority.permission_id IS DISTINCT FROM 'checker.model_call.grant' OR + authority.project_id IS NOT NULL OR + authority.resource_type IS NOT NULL OR + authority.resource_id IS NOT NULL OR + authority.denial_code IS NOT NULL OR + authority.after_facts::jsonb IS DISTINCT FROM pg_catalog.jsonb_build_object( + 'allowed',true,'resource_context_digest',authority_resource_digest) OR + authority_actor.actor_kind IS DISTINCT FROM 'service' OR + authority_actor.status IS DISTINCT FROM 'active' OR + authority_actor.service_identity IS DISTINCT FROM 'workstream.checker.post_submit' THEN + RAISE EXCEPTION 'checker model invocation grant authority is invalid' + USING ERRCODE='23514'; + END IF; + + PERFORM 1 FROM public.projects WHERE id=NEW.project_id FOR SHARE; + SELECT project_id INTO task_project FROM public.workstream_tasks + WHERE id=(NEW.execution_facts->>'task_id')::uuid FOR SHARE; + SELECT task_id,project_id INTO assignment_task,assignment_project + FROM public.task_assignments + WHERE id=(NEW.execution_facts->>'assignment_id')::uuid FOR SHARE; + SELECT task_id INTO submission_task FROM public.submissions + WHERE id=(NEW.execution_facts->>'submission_id')::uuid AND + version=(NEW.execution_facts->>'submission_version')::integer FOR SHARE; + IF task_project IS DISTINCT FROM NEW.project_id OR + assignment_task IS DISTINCT FROM (NEW.execution_facts->>'task_id')::uuid OR + assignment_project IS DISTINCT FROM NEW.project_id OR + submission_task IS DISTINCT FROM (NEW.execution_facts->>'task_id')::uuid THEN + RAISE EXCEPTION 'checker model invocation grant task ancestry is invalid' + USING ERRCODE='23514'; + END IF; + + SELECT * INTO run FROM public.checker_runs + WHERE id=NEW.checker_run_id FOR SHARE; + SELECT * INTO registry FROM public.external_checker_registry_entries + WHERE id=NEW.registry_entry_id FOR SHARE; + SELECT * INTO profile FROM public.model_usage_profiles + WHERE id=(NEW.paid_slot->>'profile_id')::uuid AND + profile_version=(NEW.paid_slot->>'profile_version')::integer AND + profile_digest=NEW.paid_slot->>'profile_digest' FOR SHARE; + NEW.issued_at:=clock_timestamp(); + IF NOT isfinite(NEW.expires_at) OR NEW.expires_at<=clock_timestamp() OR + (NEW.execution_facts->>'project_id')::uuid IS DISTINCT FROM NEW.project_id OR + (NEW.paid_slot->>'project_id')::uuid IS DISTINCT FROM NEW.project_id OR + (NEW.execution_facts->>'checker_run_id')::uuid IS DISTINCT FROM NEW.checker_run_id OR + (NEW.execution_facts->>'assignment_id')::uuid IS DISTINCT FROM + (NEW.paid_slot->>'assignment_id')::uuid OR + (NEW.execution_facts->>'submission_id')::uuid IS DISTINCT FROM + (NEW.paid_slot->>'submission_id')::uuid OR + (NEW.execution_facts->>'submission_version')::integer IS DISTINCT FROM + (NEW.paid_slot->>'submission_version')::integer OR + run.id IS NULL OR run.project_id IS DISTINCT FROM NEW.project_id OR + run.task_id IS DISTINCT FROM (NEW.execution_facts->>'task_id')::uuid OR + (run.request_json::jsonb->>'assignment_id')::uuid IS DISTINCT FROM + (NEW.execution_facts->>'assignment_id')::uuid OR + run.submission_id IS DISTINCT FROM (NEW.execution_facts->>'submission_id')::uuid OR + run.submission_version IS DISTINCT FROM + (NEW.execution_facts->>'submission_version')::integer OR + run.evaluation_request_id IS DISTINCT FROM + (NEW.execution_facts->>'evaluation_request_id')::uuid OR + run.evaluation_generation IS DISTINCT FROM + (NEW.execution_facts->>'evaluation_generation')::integer OR + run.worker_lease_id IS DISTINCT FROM + (NEW.execution_facts->>'worker_lease_id')::uuid OR + run.worker_lease_generation IS DISTINCT FROM + (NEW.execution_facts->>'worker_lease_generation')::integer OR + run.worker_lease_expires_at IS DISTINCT FROM + (NEW.execution_facts->>'worker_lease_expires_at')::timestamptz OR + run.worker_lease_expires_at IS DISTINCT FROM NEW.expires_at OR + run.status IS DISTINCT FROM 'running' OR run.phase IS DISTINCT FROM 'post_submission' OR + registry.id IS NULL OR registry.phase IS DISTINCT FROM 'post_submit' OR + registry.entry_digest IS DISTINCT FROM NEW.paid_slot->>'registry_entry_digest' OR + profile.id IS NULL OR profile.project_id IS DISTINCT FROM NEW.project_id THEN + RAISE EXCEPTION 'checker model invocation grant execution ancestry is invalid' + USING ERRCODE='23514'; + END IF; + + IF run.selected_post_submit_policy_sha256 IS NULL OR + run.selected_post_submit_policy_sha256 IS DISTINCT FROM + run.locked_post_submit_checker_policy_hash OR + jsonb_typeof(run.selected_post_submit_policy_body)<>'object' OR + run.selected_post_submit_policy_body->>'policy_sha256' IS DISTINCT FROM + run.selected_post_submit_policy_sha256 OR + jsonb_typeof(run.selected_post_submit_policy_body->'entries')<>'array' THEN + RAISE EXCEPTION 'checker model invocation grant locked policy is invalid' + USING ERRCODE='23514'; + END IF; + SELECT count(*) INTO binding_matches + FROM jsonb_array_elements(run.selected_post_submit_policy_body->'entries') binding + WHERE binding->>'requirement_id'=NEW.paid_slot->>'requirement_id' AND + binding->>'stage'='post_submit' AND + binding->>'configuration_sha256'=NEW.paid_slot->>'configuration_sha256' AND + binding->'registry'->>'registry_entry_id'=NEW.registry_entry_id::text AND + binding->'registry'->>'entry_digest'=NEW.paid_slot->>'registry_entry_digest' AND + binding->'configuration'->>'model_profile_id'=NEW.paid_slot->>'profile_id' AND + binding->'configuration'->>'model_profile_version'= + NEW.paid_slot->>'profile_version' AND + binding->'configuration'->>'model_profile_digest'=NEW.paid_slot->>'profile_digest'; + IF binding_matches<>1 THEN + RAISE EXCEPTION 'checker model invocation grant binding ancestry is invalid' + USING ERRCODE='23514'; + END IF; + RETURN NEW; +END $$ + """ + ) + op.execute( + r""" +CREATE TRIGGER checker_model_invocation_grants_guard + BEFORE INSERT OR UPDATE OR DELETE ON public.checker_model_invocation_grants + FOR EACH ROW EXECUTE FUNCTION public.guard_checker_model_invocation_grant() + """ + ) + op.execute( + r""" +CREATE TRIGGER checker_model_invocation_grants_no_truncate + BEFORE TRUNCATE ON public.checker_model_invocation_grants + FOR EACH STATEMENT EXECUTE FUNCTION public.guard_model_usage_no_truncate() + """ + ) + op.execute( + r""" +CREATE TABLE public.checker_model_invocation_grant_calls ( + grant_id uuid NOT NULL, + call_index integer NOT NULL, + attempt_id uuid NOT NULL, + request_sha256 varchar(71) NOT NULL, + bound_at timestamptz DEFAULT clock_timestamp() NOT NULL, + CONSTRAINT pk_checker_model_invocation_grant_calls PRIMARY KEY(grant_id,call_index), + CONSTRAINT ck_checker_model_invocation_grant_calls_facts CHECK ( + call_index BETWEEN 0 AND 99 AND request_sha256 ~ '^sha256:[0-9a-f]{64}$'), + CONSTRAINT fk_checker_model_invocation_grant_calls_grant FOREIGN KEY(grant_id) + REFERENCES public.checker_model_invocation_grants(id) ON DELETE RESTRICT, + CONSTRAINT fk_checker_model_invocation_grant_calls_attempt FOREIGN KEY(attempt_id) + REFERENCES public.model_usage_effects(id) ON DELETE RESTRICT +) + """ + ) + op.execute( + r""" +CREATE FUNCTION public.guard_checker_model_invocation_grant_call() RETURNS trigger +LANGUAGE plpgsql SET search_path=pg_catalog,public,pg_temp AS $$ +DECLARE grant_row public.checker_model_invocation_grants%ROWTYPE; +DECLARE attempt public.model_usage_effects%ROWTYPE; +DECLARE slot public.checker_model_usage_slots%ROWTYPE; +DECLARE profile public.model_usage_profiles%ROWTYPE; +DECLARE prior_calls bigint; +BEGIN + IF TG_OP<>'INSERT' THEN + RAISE EXCEPTION 'checker model invocation grant calls are immutable' + USING ERRCODE='55000'; + END IF; + SELECT * INTO grant_row FROM public.checker_model_invocation_grants + WHERE id=NEW.grant_id FOR UPDATE; + SELECT * INTO attempt FROM public.model_usage_effects + WHERE id=NEW.attempt_id FOR SHARE; + SELECT * INTO slot FROM public.checker_model_usage_slots + WHERE id=attempt.checker_slot_id FOR SHARE; + SELECT * INTO profile FROM public.model_usage_profiles + WHERE id=slot.profile_id AND profile_version=slot.profile_version AND + profile_digest=slot.profile_digest FOR SHARE; + SELECT count(*) INTO prior_calls FROM public.checker_model_invocation_grant_calls + WHERE grant_id=NEW.grant_id; + IF grant_row.id IS NULL OR grant_row.expires_at<=clock_timestamp() OR + attempt.id IS NULL OR slot.id IS NULL OR profile.id IS NULL OR + NEW.call_index<>prior_calls OR NEW.call_index>=profile.maximum_calls OR + attempt.request_sha256 IS DISTINCT FROM NEW.request_sha256 OR + slot.slot_index IS DISTINCT FROM NEW.call_index OR + slot.project_id IS DISTINCT FROM grant_row.project_id OR + slot.task_id IS DISTINCT FROM (grant_row.execution_facts->>'task_id')::uuid OR + slot.assignment_id IS DISTINCT FROM (grant_row.paid_slot->>'assignment_id')::uuid OR + slot.submission_id IS DISTINCT FROM (grant_row.paid_slot->>'submission_id')::uuid OR + slot.submission_version IS DISTINCT FROM + (grant_row.paid_slot->>'submission_version')::integer OR + slot.requirement_id IS DISTINCT FROM grant_row.paid_slot->>'requirement_id' OR + slot.registry_entry_digest IS DISTINCT FROM + grant_row.paid_slot->>'registry_entry_digest' OR + slot.configuration_sha256 IS DISTINCT FROM + grant_row.paid_slot->>'configuration_sha256' OR + slot.profile_id IS DISTINCT FROM (grant_row.paid_slot->>'profile_id')::uuid OR + slot.profile_version IS DISTINCT FROM + (grant_row.paid_slot->>'profile_version')::integer OR + slot.profile_digest IS DISTINCT FROM grant_row.paid_slot->>'profile_digest' THEN + RAISE EXCEPTION 'checker model invocation grant call ancestry is invalid' + USING ERRCODE='23514'; + END IF; + NEW.bound_at:=clock_timestamp(); + RETURN NEW; +END $$ + """ + ) + op.execute( + r""" +CREATE TRIGGER checker_model_invocation_grant_calls_guard + BEFORE INSERT OR UPDATE OR DELETE ON public.checker_model_invocation_grant_calls + FOR EACH ROW EXECUTE FUNCTION public.guard_checker_model_invocation_grant_call() + """ + ) + op.execute( + r""" +CREATE TRIGGER checker_model_invocation_grant_calls_no_truncate + BEFORE TRUNCATE ON public.checker_model_invocation_grant_calls + FOR EACH STATEMENT EXECUTE FUNCTION public.guard_model_usage_no_truncate() + """ + ) + + +def _upgrade_art_custody() -> None: + """Extend verified ART ancestry to call-stable evidence, preserving retained rows.""" + op.execute( + r""" +ALTER TABLE artifact_put_attempts ADD COLUMN model_call_attempt_id uuid REFERENCES model_usage_effects(id) ON DELETE RESTRICT + """ + ) + op.execute( + r""" +CREATE INDEX ix_artifact_put_attempts_model_call_attempt_id ON artifact_put_attempts (model_call_attempt_id) + """ + ) + op.execute( + r""" +ALTER TABLE artifact_operation_receipts ADD COLUMN model_call_attempt_id uuid REFERENCES model_usage_effects(id) ON DELETE RESTRICT + """ + ) + op.execute( + r""" +CREATE INDEX ix_artifact_operation_receipts_model_call_attempt_id ON artifact_operation_receipts (model_call_attempt_id) + """ + ) + op.execute( + r""" +ALTER TABLE artifact_put_attempts DROP CONSTRAINT ck_artifact_put_attempts_producer_request_type + """ + ) + op.execute( + r""" +ALTER TABLE artifact_put_attempts ADD CONSTRAINT ck_artifact_put_attempts_producer_request_type CHECK (producer_request_type IN ('guide','checker_output','setup_model_output','submission_bundle','task_import_source')) + """ + ) + op.execute( + r""" +ALTER TABLE artifact_put_attempts DROP CONSTRAINT ck_artifact_put_attempts_producer_identity + """ + ) + op.execute( + r""" +ALTER TABLE artifact_put_attempts ADD CONSTRAINT ck_artifact_put_attempts_producer_identity CHECK (((producer_request_type IN ('guide','task_import_source') AND producer_type='actor_profile' AND producer_ref ~ '^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$') OR (producer_request_type IN ('checker_output','setup_model_output') AND producer_type='service_identity' AND producer_ref='workstream.artifact.checker_output') OR (producer_request_type='submission_bundle' AND producer_type='actor_profile' AND producer_ref ~ '^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$'))) + """ + ) + op.execute( + r""" +ALTER TABLE artifact_put_attempts DROP CONSTRAINT ck_artifact_put_attempts_checker_request_digest + """ + ) + op.execute( + r""" +ALTER TABLE artifact_put_attempts ADD CONSTRAINT ck_artifact_put_attempts_checker_request_digest CHECK (((producer_request_type IN ('checker_output','setup_model_output') AND checker_request_digest IS NOT NULL AND checker_request_digest ~ '^sha256:[0-9a-f]{64}$') OR (producer_request_type NOT IN ('checker_output','setup_model_output') AND checker_request_digest IS NULL))) + """ + ) + op.execute( + r""" +ALTER TABLE artifact_put_attempts DROP CONSTRAINT ck_artifact_put_attempts_producer_reference + """ + ) + op.execute( + r""" +ALTER TABLE artifact_put_attempts ADD CONSTRAINT ck_artifact_put_attempts_producer_reference CHECK ((producer_request_type = 'guide' and task_import_source_id is null and guide_source_item_id is not null and checker_run_id is null and model_call_attempt_id is null and task_id is null and submission_id is null and submission_version is null and logical_role is null) or (producer_request_type = 'checker_output' and task_import_source_id is null and guide_source_item_id is null and ((checker_run_id is not null and model_call_attempt_id is null) or (checker_run_id is null and model_call_attempt_id is not null and logical_role in ('model_request','model_response'))) and task_id is not null and submission_id is not null and submission_version is not null and octet_length(logical_role) between 1 and 100) or (producer_request_type = 'setup_model_output' and task_import_source_id is null and guide_source_item_id is null and checker_run_id is null and model_call_attempt_id is not null and task_id is null and submission_id is null and submission_version is null and logical_role in ('model_request','model_response')) or (producer_request_type = 'submission_bundle' and task_import_source_id is null and guide_source_item_id is null and checker_run_id is null and model_call_attempt_id is null and task_id is not null and submission_id is null and submission_version is null and logical_role is null) or (producer_request_type = 'task_import_source' and task_import_source_id is not null and guide_source_item_id is null and checker_run_id is null and model_call_attempt_id is null and task_id is null and submission_id is null and submission_version is null and logical_role is not distinct from 'task_import_source')) + """ + ) + op.execute( + r""" +ALTER TABLE artifact_operation_receipts DROP CONSTRAINT ck_artifact_operation_receipts_contract_producer_reference + """ + ) + op.execute( + r""" +ALTER TABLE artifact_operation_receipts ADD CONSTRAINT ck_artifact_operation_receipts_contract_producer_reference CHECK (contract_version = 2 and put_attempt_id is not null and ((guide_source_item_id is not null and checker_run_id is null and model_call_attempt_id is null and logical_role is null) or (guide_source_item_id is null and checker_run_id is not null and model_call_attempt_id is null and octet_length(logical_role) between 1 and 100) or (guide_source_item_id is null and checker_run_id is null and model_call_attempt_id is null and (logical_role is null or logical_role='task_import_source')) or (guide_source_item_id is null and checker_run_id is null and model_call_attempt_id is not null and logical_role in ('model_request','model_response')))) + """ + ) + op.execute( + r""" +CREATE OR REPLACE FUNCTION public.guard_artifact_receipt_producer_reference() RETURNS trigger + LANGUAGE plpgsql SET search_path=pg_catalog,public,pg_temp AS $$ + DECLARE + attempt artifact_put_attempts%ROWTYPE; + BEGIN + SELECT value.* INTO attempt + FROM public.artifact_put_attempts value + WHERE value.id=NEW.put_attempt_id; + IF attempt.id IS NULL + OR (attempt.producer_request_type='guide' AND NOT ( + NEW.guide_source_item_id IS NOT NULL + AND NEW.checker_run_id IS NULL + AND NEW.model_call_attempt_id IS NULL + AND NEW.logical_role IS NULL)) + OR (attempt.producer_request_type IN ('checker_output','setup_model_output') AND NOT ( + NEW.guide_source_item_id IS NULL + AND ( + (attempt.model_call_attempt_id IS NULL + AND NEW.checker_run_id IS NOT NULL + AND NEW.model_call_attempt_id IS NULL + AND octet_length(NEW.logical_role) BETWEEN 1 AND 100) + OR + (attempt.model_call_attempt_id IS NOT NULL + AND NEW.checker_run_id IS NULL + AND NEW.model_call_attempt_id IS NOT DISTINCT FROM attempt.model_call_attempt_id + AND NEW.logical_role IS NOT DISTINCT FROM attempt.logical_role + AND NEW.logical_role IN ('model_request','model_response'))))) + OR (attempt.producer_request_type='submission_bundle' AND NOT ( + NEW.guide_source_item_id IS NULL + AND NEW.checker_run_id IS NULL + AND NEW.model_call_attempt_id IS NULL + AND NEW.logical_role IS NULL)) + OR (attempt.producer_request_type='task_import_source' AND ( + NEW.guide_source_item_id IS NOT NULL + OR NEW.checker_run_id IS NOT NULL + OR NEW.model_call_attempt_id IS NOT NULL + OR NEW.logical_role IS DISTINCT FROM 'task_import_source')) + OR attempt.producer_request_type NOT IN ( + 'guide','checker_output','setup_model_output','submission_bundle','task_import_source') THEN + RAISE EXCEPTION 'artifact receipt producer reference mismatch' + USING ERRCODE='23514'; + END IF; + RETURN NEW; + END $$ + """ + ) + op.execute( + r""" +ALTER TABLE artifact_bindings DROP CONSTRAINT ck_artifact_bindings_checker_output_lineage + """ + ) + op.execute( + r""" +ALTER TABLE artifact_bindings ADD CONSTRAINT ck_artifact_bindings_checker_output_lineage CHECK ((resource_type in ('checker_run','checker_model_call','setup_model_call') and scope_version = 1 and put_attempt_id is not null and verification_receipt_id is not null) or (resource_type not in ('checker_run','checker_model_call','setup_model_call') and put_attempt_id is null and verification_receipt_id is null)) + """ + ) + op.execute( + r""" +CREATE OR REPLACE FUNCTION public.guard_checker_output_put_attempt_custody() RETURNS trigger + LANGUAGE plpgsql SET search_path=pg_catalog,public,pg_temp AS $$ + BEGIN + IF TG_OP='DELETE' THEN + IF OLD.producer_request_type IN ('checker_output','setup_model_output') + OR OLD.checker_output_custody_sealed THEN + RAISE EXCEPTION 'checker output put attempt custody is immutable' + USING ERRCODE='55000'; + END IF; + RETURN OLD; + END IF; + IF OLD.checker_output_custody_sealed THEN + IF NOT NEW.checker_output_custody_sealed + OR (to_jsonb(NEW) - 'checker_output_custody_sealed') + IS DISTINCT FROM + (to_jsonb(OLD) - 'checker_output_custody_sealed') THEN + RAISE EXCEPTION 'checker output put attempt custody is immutable' + USING ERRCODE='55000'; + END IF; + RETURN NEW; + END IF; + IF NEW.checker_output_custody_sealed THEN + IF (to_jsonb(NEW) - 'checker_output_custody_sealed') + IS DISTINCT FROM + (to_jsonb(OLD) - 'checker_output_custody_sealed') + OR NOT EXISTS ( + SELECT 1 FROM artifact_bindings binding + WHERE binding.resource_type IN ('checker_run','checker_model_call','setup_model_call') + AND binding.put_attempt_id=OLD.id + ) THEN + RAISE EXCEPTION 'checker output put attempt seal requires binding' + USING ERRCODE='55000'; + END IF; + RETURN NEW; + END IF; + IF OLD.producer_request_type IN ('checker_output','setup_model_output') + OR NEW.producer_request_type IN ('checker_output','setup_model_output') THEN + IF ROW( + NEW.id, NEW.producer_request_type, NEW.producer_type, NEW.producer_ref, + NEW.project_id, NEW.task_id, NEW.guide_source_item_id, NEW.checker_run_id, NEW.model_call_attempt_id, + NEW.submission_id, NEW.submission_version, NEW.logical_role, + NEW.sha256, NEW.byte_count, NEW.media_type, NEW.storage_namespace_id, + NEW.namespace_fingerprint, NEW.canonical_target, NEW.operation_identity, + NEW.request_digest, NEW.checker_request_digest, NEW.maximum_observations, + NEW.prepared_at, NEW.created_at + ) IS DISTINCT FROM ROW( + OLD.id, OLD.producer_request_type, OLD.producer_type, OLD.producer_ref, + OLD.project_id, OLD.task_id, OLD.guide_source_item_id, OLD.checker_run_id, OLD.model_call_attempt_id, + OLD.submission_id, OLD.submission_version, OLD.logical_role, + OLD.sha256, OLD.byte_count, OLD.media_type, OLD.storage_namespace_id, + OLD.namespace_fingerprint, OLD.canonical_target, OLD.operation_identity, + OLD.request_digest, OLD.checker_request_digest, OLD.maximum_observations, + OLD.prepared_at, OLD.created_at + ) THEN + RAISE EXCEPTION 'checker output put attempt custody is immutable' + USING ERRCODE='55000'; + END IF; + END IF; + RETURN NEW; + END $$ + """ + ) + op.execute( + r""" +CREATE OR REPLACE FUNCTION public.guard_checker_output_verification_job_custody() RETURNS trigger + LANGUAGE plpgsql SET search_path=pg_catalog,public,pg_temp AS $$ + BEGIN + IF TG_OP='DELETE' THEN + IF OLD.checker_output_custody_sealed THEN + RAISE EXCEPTION 'checker output verification job custody is immutable' + USING ERRCODE='55000'; + END IF; + RETURN OLD; + END IF; + IF OLD.checker_output_custody_sealed THEN + IF NOT NEW.checker_output_custody_sealed + OR (to_jsonb(NEW) - 'checker_output_custody_sealed') + IS DISTINCT FROM + (to_jsonb(OLD) - 'checker_output_custody_sealed') THEN + RAISE EXCEPTION 'checker output verification job custody is immutable' + USING ERRCODE='55000'; + END IF; + RETURN NEW; + END IF; + IF NEW.checker_output_custody_sealed THEN + IF (to_jsonb(NEW) - 'checker_output_custody_sealed') + IS DISTINCT FROM + (to_jsonb(OLD) - 'checker_output_custody_sealed') + OR NOT EXISTS ( + SELECT 1 + FROM artifact_bindings binding + JOIN artifact_verification_receipts receipt + ON receipt.id=binding.verification_receipt_id + WHERE binding.resource_type IN ('checker_run','checker_model_call','setup_model_call') + AND receipt.verification_job_id=OLD.id + ) THEN + RAISE EXCEPTION 'checker output verification job seal requires binding' + USING ERRCODE='55000'; + END IF; + END IF; + RETURN NEW; + END $$ + """ + ) + op.execute( + r""" +CREATE OR REPLACE FUNCTION public.guard_checker_output_replica_custody() RETURNS trigger + LANGUAGE plpgsql SET search_path=pg_catalog,public,pg_temp AS $$ + BEGIN + IF TG_OP='DELETE' THEN + IF OLD.checker_output_custody_sealed THEN + RAISE EXCEPTION 'checker output replica custody is immutable' + USING ERRCODE='55000'; + END IF; + RETURN OLD; + END IF; + IF OLD.checker_output_custody_sealed THEN + IF NOT NEW.checker_output_custody_sealed + OR ROW( + NEW.id, NEW.content_id, NEW.storage_namespace_id, + NEW.namespace_fingerprint, NEW.adapter, + NEW.provider_profile, NEW.provider_object_ref + ) IS DISTINCT FROM ROW( + OLD.id, OLD.content_id, OLD.storage_namespace_id, + OLD.namespace_fingerprint, OLD.adapter, + OLD.provider_profile, OLD.provider_object_ref + ) THEN + RAISE EXCEPTION 'checker output replica custody is immutable' + USING ERRCODE='55000'; + END IF; + RETURN NEW; + END IF; + IF NEW.checker_output_custody_sealed THEN + IF (to_jsonb(NEW) - 'checker_output_custody_sealed') + IS DISTINCT FROM + (to_jsonb(OLD) - 'checker_output_custody_sealed') + OR NOT EXISTS ( + SELECT 1 + FROM artifact_bindings binding + JOIN artifact_verification_receipts receipt + ON receipt.id=binding.verification_receipt_id + JOIN artifact_verification_jobs job + ON job.id=receipt.verification_job_id + WHERE binding.resource_type IN ('checker_run','checker_model_call','setup_model_call') + AND job.replica_id=OLD.id + ) THEN + RAISE EXCEPTION 'checker output replica seal requires binding' + USING ERRCODE='55000'; + END IF; + END IF; + RETURN NEW; + END $$ + """ + ) + op.execute( + r""" +CREATE OR REPLACE FUNCTION public.guard_checker_output_binding_insert() RETURNS trigger + LANGUAGE plpgsql SET search_path=pg_catalog,public,pg_temp AS $$ + DECLARE + verification artifact_verification_receipts%ROWTYPE; + job artifact_verification_jobs%ROWTYPE; + replica artifact_replicas%ROWTYPE; + attempt artifact_put_attempts%ROWTYPE; + content artifact_contents%ROWTYPE; + write_receipt_matches integer; + BEGIN + IF NEW.resource_type NOT IN ('checker_run','checker_model_call','setup_model_call') THEN + RETURN NEW; + END IF; + + SELECT receipt.* INTO verification + FROM artifact_verification_receipts receipt + WHERE receipt.id=NEW.verification_receipt_id; + IF NOT FOUND THEN + RAISE EXCEPTION 'checker output binding verified ancestry mismatch' + USING ERRCODE='23514'; + END IF; + + SELECT value.* INTO job + FROM artifact_verification_jobs value + WHERE value.id=verification.verification_job_id + FOR UPDATE; + SELECT value.* INTO replica + FROM artifact_replicas value + WHERE value.id=job.replica_id + FOR UPDATE; + SELECT value.* INTO attempt + FROM artifact_put_attempts value + WHERE value.id=job.originating_put_attempt_id + FOR UPDATE; + SELECT value.* INTO content + FROM artifact_contents value + WHERE value.id=replica.content_id + FOR SHARE; + + SELECT count(*) INTO write_receipt_matches + FROM ( + SELECT receipt.id + FROM artifact_operation_receipts receipt + WHERE attempt.receipt_id IS NOT NULL + AND receipt.id=attempt.receipt_id + AND receipt.put_attempt_id=attempt.id + AND receipt.replica_id=replica.id + AND receipt.checker_run_id IS NOT DISTINCT FROM attempt.checker_run_id + AND receipt.model_call_attempt_id IS NOT DISTINCT FROM attempt.model_call_attempt_id + AND receipt.logical_role=attempt.logical_role + AND receipt.request_digest=attempt.request_digest + AND receipt.provider_object_ref=replica.provider_object_ref + AND receipt.outcome='stored_pending_verification' + UNION ALL + SELECT receipt.id + FROM artifact_put_observation_receipts receipt + WHERE attempt.receipt_id IS NULL + AND receipt.put_attempt_id=attempt.id + AND receipt.execution_generation=attempt.execution_generation + AND receipt.outcome='observed_confirmed' + AND receipt.expected_sha256=attempt.sha256 + AND receipt.observed_sha256=attempt.sha256 + AND receipt.expected_byte_count=attempt.byte_count + AND receipt.observed_byte_count=attempt.byte_count + ) successful_write; + + IF job.id IS NULL OR replica.id IS NULL OR attempt.id IS NULL OR content.id IS NULL + OR NEW.put_attempt_id IS DISTINCT FROM attempt.id + OR NEW.content_id IS DISTINCT FROM content.id + OR NEW.project_id IS DISTINCT FROM attempt.project_id + OR NEW.resource_id IS DISTINCT FROM ( + CASE WHEN NEW.resource_type IN ('checker_model_call','setup_model_call') + THEN attempt.model_call_attempt_id::text ELSE attempt.checker_run_id::text END + ) + OR NEW.logical_role IS DISTINCT FROM attempt.logical_role + OR NEW.actor_id IS DISTINCT FROM attempt.producer_ref + OR NEW.attribution_type IS DISTINCT FROM 'service_identity' + OR NEW.scope_version <> 1 + OR NEW.supersedes_binding_id IS NOT NULL + OR attempt.producer_request_type IS DISTINCT FROM ( + CASE WHEN NEW.resource_type='setup_model_call' + THEN 'setup_model_output' ELSE 'checker_output' END) + OR attempt.producer_type <> 'service_identity' + OR (NEW.resource_type <> 'setup_model_call' + AND (attempt.submission_id IS NULL OR attempt.submission_version IS NULL)) + OR attempt.checker_request_digest IS NULL + OR attempt.status <> 'object_confirmed' + OR attempt.replica_id IS DISTINCT FROM replica.id + OR attempt.storage_namespace_id IS DISTINCT FROM replica.storage_namespace_id + OR attempt.namespace_fingerprint IS DISTINCT FROM replica.namespace_fingerprint + OR attempt.canonical_target IS DISTINCT FROM replica.provider_object_ref + OR attempt.sha256 IS DISTINCT FROM content.sha256 + OR attempt.byte_count IS DISTINCT FROM content.byte_count + OR attempt.media_type IS DISTINCT FROM content.media_type + OR job.originating_put_attempt_id IS DISTINCT FROM attempt.id + OR job.replica_id IS DISTINCT FROM replica.id + OR job.status <> 'verified' + OR job.terminal_result_code IS DISTINCT FROM 'verified' + OR job.terminal_at IS NULL + OR verification.outcome <> 'verified' + OR verification.execution_generation IS DISTINCT FROM job.execution_generation + OR verification.observed_sha256 IS DISTINCT FROM attempt.sha256 + OR verification.observed_byte_count IS DISTINCT FROM attempt.byte_count + OR replica.verification_state <> 'verified' + OR replica.availability_state <> 'available' + OR replica.integrity_state <> 'valid' + OR write_receipt_matches <> 1 THEN + RAISE EXCEPTION 'checker output binding verified ancestry mismatch' + USING ERRCODE='23514'; + END IF; + RETURN NEW; + END $$ + """ + ) + op.execute( + r""" +CREATE OR REPLACE FUNCTION public.seal_checker_output_binding_ancestry() RETURNS trigger + LANGUAGE plpgsql SET search_path=pg_catalog,public,pg_temp AS $$ + DECLARE + verification artifact_verification_receipts%ROWTYPE; + job artifact_verification_jobs%ROWTYPE; + BEGIN + IF NEW.resource_type NOT IN ('checker_run','checker_model_call','setup_model_call') THEN + RETURN NEW; + END IF; + SELECT receipt.* INTO STRICT verification + FROM artifact_verification_receipts receipt + WHERE receipt.id=NEW.verification_receipt_id; + SELECT value.* INTO STRICT job + FROM artifact_verification_jobs value + WHERE value.id=verification.verification_job_id; + + UPDATE artifact_verification_jobs + SET checker_output_custody_sealed=true + WHERE id=job.id AND NOT checker_output_custody_sealed; + UPDATE artifact_replicas + SET checker_output_custody_sealed=true + WHERE id=job.replica_id AND NOT checker_output_custody_sealed; + UPDATE artifact_put_attempts + SET checker_output_custody_sealed=true + WHERE id=NEW.put_attempt_id AND NOT checker_output_custody_sealed; + RETURN NEW; + END $$ + """ + ) + op.execute( + r""" +CREATE FUNCTION public.guard_checker_model_artifact_ancestry() RETURNS trigger +LANGUAGE plpgsql SET search_path=pg_catalog,public,pg_temp AS $$ +DECLARE effect model_usage_effects%ROWTYPE; + checker_slot checker_model_usage_slots%ROWTYPE; + setup_slot setup_model_usage_slots%ROWTYPE; + profile model_usage_profiles%ROWTYPE; + expected_maximum integer; + expected_operation varchar; + expected_custody varchar; +BEGIN + IF NEW.model_call_attempt_id IS NULL THEN RETURN NEW; END IF; + SELECT * INTO STRICT effect FROM model_usage_effects WHERE id=NEW.model_call_attempt_id; + IF NEW.producer_request_type='setup_model_output' THEN + IF effect.setup_slot_id IS NULL OR effect.checker_slot_id IS NOT NULL THEN + RAISE EXCEPTION 'setup model artifact call ancestry mismatch' USING ERRCODE='23514'; + END IF; + SELECT * INTO STRICT setup_slot FROM setup_model_usage_slots + WHERE id=effect.setup_slot_id; + SELECT * INTO STRICT profile FROM model_usage_profiles + WHERE id=setup_slot.profile_id AND profile_version=setup_slot.profile_version + AND profile_digest=setup_slot.profile_digest; + expected_maximum := CASE WHEN NEW.logical_role='model_request' + THEN profile.maximum_request_bytes + ELSE profile.maximum_response_bytes END; + expected_operation := public.model_usage_json_digest(pg_catalog.jsonb_build_object( + 'request_type','setup_model_output', + 'model_call_attempt_id',effect.id, + 'logical_role',NEW.logical_role)); + expected_custody := public.model_usage_json_digest(pg_catalog.jsonb_build_object( + 'slot_key',pg_catalog.jsonb_build_object( + 'compilation_attempt_id',setup_slot.compilation_attempt_id, + 'project_id',setup_slot.project_id, + 'guide_id',setup_slot.guide_id, + 'source_snapshot_id',setup_slot.source_snapshot_id, + 'setup_run_id',setup_slot.setup_run_id, + 'setup_generation',setup_slot.setup_generation, + 'runtime_configuration_hash',setup_slot.runtime_configuration_hash, + 'profile_id',setup_slot.profile_id, + 'profile_version',setup_slot.profile_version, + 'profile_digest',setup_slot.profile_digest, + 'phase',setup_slot.phase, + 'call_index',setup_slot.call_index), + 'model_call_slot_id',setup_slot.id, + 'model_call_attempt_id',effect.id, + 'content_sha256',NEW.sha256, + 'role',NEW.logical_role, + 'media_type',NEW.media_type, + 'maximum_bytes',expected_maximum)); + IF NEW.project_id IS DISTINCT FROM setup_slot.project_id + OR NEW.task_id IS NOT NULL OR NEW.submission_id IS NOT NULL + OR NEW.submission_version IS NOT NULL OR NEW.checker_run_id IS NOT NULL + OR NEW.logical_role NOT IN ('model_request','model_response') + OR NEW.media_type IS DISTINCT FROM 'application/json' + OR NEW.byte_count < 1 OR NEW.byte_count > expected_maximum + OR effect.request_sha256 IS DISTINCT FROM setup_slot.request_sha256 + OR (NEW.logical_role='model_request' + AND NEW.sha256 IS DISTINCT FROM effect.request_sha256) + OR (NEW.logical_role='model_response' AND + (effect.state NOT IN ('dispatching','uncertain','completed','known_failed') + OR (effect.response_content_sha256 IS NOT NULL + AND NEW.sha256 IS DISTINCT FROM effect.response_content_sha256))) + OR NEW.operation_identity IS DISTINCT FROM expected_operation + OR NEW.checker_request_digest IS DISTINCT FROM expected_custody THEN + RAISE EXCEPTION 'setup model artifact call ancestry mismatch' USING ERRCODE='23514'; + END IF; + RETURN NEW; + END IF; + + SELECT * INTO STRICT checker_slot FROM checker_model_usage_slots + WHERE id=effect.checker_slot_id; + SELECT * INTO STRICT profile FROM model_usage_profiles + WHERE id=checker_slot.profile_id AND profile_version=checker_slot.profile_version + AND profile_digest=checker_slot.profile_digest; + IF ROW(NEW.project_id,NEW.task_id,NEW.submission_id,NEW.submission_version) + IS DISTINCT FROM ROW(checker_slot.project_id,checker_slot.task_id,checker_slot.submission_id,checker_slot.submission_version) + OR NEW.producer_request_type IS DISTINCT FROM 'checker_output' + OR NEW.checker_run_id IS NOT NULL + OR NEW.media_type IS DISTINCT FROM 'application/json' + OR NEW.byte_count < 1 + OR (NEW.logical_role='model_request' AND + (NEW.sha256 IS DISTINCT FROM effect.request_sha256 + OR NEW.byte_count > profile.maximum_request_bytes)) + OR (NEW.logical_role='model_response' AND + (effect.state NOT IN ('dispatching','uncertain','completed','known_failed') + OR NEW.byte_count > profile.maximum_response_bytes + OR (effect.response_content_sha256 IS NOT NULL + AND NEW.sha256 IS DISTINCT FROM effect.response_content_sha256))) + OR NEW.logical_role NOT IN ('model_request','model_response') THEN + RAISE EXCEPTION 'model artifact call ancestry mismatch' USING ERRCODE='23514'; + END IF; + RETURN NEW; +END $$ + """ + ) + op.execute( + r""" +CREATE TRIGGER checker_model_artifact_ancestry BEFORE INSERT ON artifact_put_attempts +FOR EACH ROW EXECUTE FUNCTION public.guard_checker_model_artifact_ancestry() + """ + ) + + +def _upgrade_authorization_vocabulary() -> None: + """Admit exactly the new model actions and permissions in retained audit custody.""" + op.execute("LOCK TABLE public.audit_events IN ACCESS EXCLUSIVE MODE") + name = "ck_audit_events_authorization_action_evidence" + definition = ( + op.get_bind() + .execute( + text( + "SELECT pg_catalog.pg_get_constraintdef(oid) FROM pg_catalog.pg_constraint " + "WHERE conrelid='public.audit_events'::regclass AND conname=:name" + ), + {"name": name}, + ) + .scalar_one() + ) + anchor = ( + "(((action_id)::text = 'contribution.compensation_unit.create'::text) AND " + "((permission_id)::text = 'compensation.policy.manage'::text))" + ) + actions = ( + ("checker.model_call.prepare", "checker.model_call.prepare"), + ("checker.model_call.dispatch", "checker.model_call.dispatch"), + ("checker.model_call.recover", "checker.model_call.recover"), + ("checker.model_call.observe", "checker.model_call.observe"), + ("checker.model_call.complete", "checker.model_call.complete"), + ("checker.model_call.grant", "checker.model_call.grant"), + ( + "project.guide_compilation.model.prepare", + "project.guide_compilation.model.prepare", + ), + ( + "project.guide_compilation.model.dispatch", + "project.guide_compilation.model.dispatch", + ), + ( + "project.guide_compilation.model.observe", + "project.guide_compilation.model.observe", + ), + ( + "project.guide_compilation.model.complete", + "project.guide_compilation.model.complete", + ), + ("project.model_profile.provision", "project.model_profile.manage"), + ("project.model_budget.configure", "project.model_budget.manage"), + ("project.model_usage.read", "project.model_budget.manage"), + ( + "project.model_call.recovery_child.authorize", + "project.model_call.recovery_child.authorize", + ), + ) + if definition.count(anchor) != 2 or any(action in definition for action, _ in actions): + raise RuntimeError("model-call audit action constraint shape changed") + addition = "".join( + f" OR (((action_id)::text = '{action}'::text) AND " + f"((permission_id)::text = '{permission}'::text))" + for action, permission in actions + ) + op.execute("ALTER TABLE public.audit_events DROP CONSTRAINT " + name) + op.execute( + "ALTER TABLE public.audit_events ADD CONSTRAINT " + + name + + " " + + definition.replace(anchor, anchor + addition) + ) + + name = "ck_audit_events_authority_registries" + definition = ( + op.get_bind() + .execute( + text( + "SELECT pg_catalog.pg_get_constraintdef(oid) FROM pg_catalog.pg_constraint " + "WHERE conrelid='public.audit_events'::regclass AND conname=:name" + ), + {"name": name}, + ) + .scalar_one() + ) + anchor = "('task.revision.recover'::character varying)::text" + permissions = tuple(dict.fromkeys(permission for _, permission in actions)) + if definition.count(anchor) != 1 or any( + f"('{permission}'::character varying)::text" in definition for permission in permissions + ): + raise RuntimeError("model-call audit permission registry shape changed") + addition = "".join(f", ('{permission}'::character varying)::text" for permission in permissions) + op.execute("ALTER TABLE public.audit_events DROP CONSTRAINT " + name) + op.execute( + "ALTER TABLE public.audit_events ADD CONSTRAINT " + + name + + " " + + definition.replace(anchor, anchor + addition) + ) + + +def downgrade() -> None: + """Refuse removal of retained paid-call and budget evidence.""" + raise RuntimeError("Workstream v0.1 migrations cannot be downgraded; recreate the database") + + +_TABLES = ( + r""" +CREATE TABLE public.model_usage_profiles ( + id uuid NOT NULL, + project_id uuid NOT NULL, + profile_version integer NOT NULL, + profile_digest varchar(71) NOT NULL, + provider varchar(160) NOT NULL, + api varchar(160) NOT NULL, + model_snapshot varchar(160) NOT NULL, + generation_settings jsonb NOT NULL, + maximum_request_bytes integer NOT NULL, + maximum_output_bytes integer NOT NULL, + maximum_response_bytes integer NOT NULL, + maximum_input_tokens integer NOT NULL, + maximum_output_tokens integer NOT NULL, + maximum_calls integer NOT NULL, + maximum_total_request_bytes integer NOT NULL, + maximum_total_response_bytes integer NOT NULL, + maximum_total_input_tokens integer NOT NULL, + maximum_total_output_tokens integer NOT NULL, + allowed_tools jsonb NOT NULL, + maximum_tool_calls integer NOT NULL, + maximum_tool_output_bytes integer NOT NULL, + deadline_ms integer NOT NULL, + pricing_policy_id uuid NOT NULL, + pricing_policy_version integer NOT NULL, + pricing_policy_digest varchar(71) NOT NULL, + uncached_input_rate bigint NOT NULL, + cached_input_rate bigint NOT NULL, + output_rate bigint NOT NULL, + operation_id uuid NOT NULL, + actor_profile_id uuid NOT NULL, + authorization_evidence_id uuid NOT NULL, + created_at timestamptz DEFAULT clock_timestamp() NOT NULL, + CONSTRAINT pk_model_usage_profiles PRIMARY KEY(id), + CONSTRAINT ck_model_usage_profiles_id_uuid7 CHECK ( + (get_byte(uuid_send(id),6)>>4)=7 AND (get_byte(uuid_send(id),8)&192)=128), + CONSTRAINT ck_model_usage_profiles_digests CHECK ( + profile_digest ~ '^sha256:[0-9a-f]{64}$' AND + pricing_policy_digest ~ '^sha256:[0-9a-f]{64}$'), + CONSTRAINT ck_model_usage_profiles_identifiers CHECK ( + provider ~ '^[A-Za-z0-9][A-Za-z0-9._:/-]{0,159}$' AND + api ~ '^[A-Za-z0-9][A-Za-z0-9._:/-]{0,159}$' AND + model_snapshot ~ '^[A-Za-z0-9][A-Za-z0-9._:/-]{0,159}$'), + CONSTRAINT ck_model_usage_profiles_settings CHECK ( + generation_settings = jsonb_build_object( + 'reasoning_effort',generation_settings->>'reasoning_effort', + 'text_verbosity',generation_settings->>'text_verbosity') AND + generation_settings->>'reasoning_effort' IN + ('none','minimal','low','medium','high','xhigh','max') AND + generation_settings->>'text_verbosity' IN ('low','medium','high')), + CONSTRAINT ck_model_usage_profiles_bounds CHECK ( + profile_version>0 AND pricing_policy_version>0 AND maximum_calls BETWEEN 1 AND 100 AND + maximum_request_bytes BETWEEN 1 AND 1048576 AND + maximum_output_bytes BETWEEN 1 AND 1048576 AND + maximum_response_bytes BETWEEN 1 AND 1048576 AND + maximum_response_bytes >= 6 * maximum_output_bytes + 4096 AND + maximum_input_tokens BETWEEN 1 AND 10000000 AND + maximum_output_tokens BETWEEN 1 AND 10000000 AND + maximum_total_request_bytes BETWEEN maximum_request_bytes AND 104857600 AND + maximum_total_response_bytes BETWEEN maximum_response_bytes AND 104857600 AND + maximum_total_input_tokens BETWEEN maximum_input_tokens AND 100000000 AND + maximum_total_output_tokens BETWEEN maximum_output_tokens AND 100000000 AND + maximum_total_request_bytes<=maximum_request_bytes*maximum_calls AND + maximum_total_response_bytes<=maximum_response_bytes*maximum_calls AND + maximum_total_input_tokens<=maximum_input_tokens*maximum_calls AND + maximum_total_output_tokens<=maximum_output_tokens*maximum_calls AND + maximum_tool_calls BETWEEN 0 AND 1000 AND + maximum_tool_output_bytes BETWEEN 0 AND 104857600 AND + jsonb_typeof(allowed_tools)='array' AND + ((jsonb_array_length(allowed_tools)=0 AND maximum_tool_calls=0 AND + maximum_tool_output_bytes=0) OR + (jsonb_array_length(allowed_tools)>0 AND maximum_tool_calls>0 AND + maximum_tool_output_bytes>0)) AND + deadline_ms BETWEEN 100 AND 3600000 AND + uncached_input_rate>=0 AND cached_input_rate>=0 AND output_rate>=0 AND + greatest(uncached_input_rate,cached_input_rate,output_rate)>0 AND + maximum_total_input_tokens::numeric * + greatest(uncached_input_rate,cached_input_rate)::numeric + + maximum_total_output_tokens::numeric * output_rate::numeric BETWEEN 1 AND 9000000000000000000), + CONSTRAINT uq_model_usage_profiles_version UNIQUE(project_id,profile_version), + CONSTRAINT uq_model_usage_profiles_identity UNIQUE(id,profile_version,profile_digest), + CONSTRAINT uq_model_usage_profiles_digest UNIQUE(profile_digest), + CONSTRAINT uq_model_usage_profiles_operation UNIQUE(operation_id), + CONSTRAINT uq_model_usage_profiles_authority UNIQUE(authorization_evidence_id), + CONSTRAINT fk_model_usage_profiles_project FOREIGN KEY(project_id) + REFERENCES public.projects(id) ON DELETE RESTRICT, + CONSTRAINT fk_model_usage_profiles_actor FOREIGN KEY(actor_profile_id) + REFERENCES public.actor_profiles(id) ON DELETE RESTRICT, + CONSTRAINT fk_model_usage_profiles_authority FOREIGN KEY(authorization_evidence_id) + REFERENCES public.audit_events(id) ON DELETE RESTRICT +) +""", + r""" +CREATE TABLE public.model_usage_budget_configurations ( + id uuid NOT NULL, + project_id uuid NOT NULL, + configuration_version integer NOT NULL, + configuration_digest varchar(71) NOT NULL, + authorized_total_nanodollars bigint NOT NULL, + externally_reconciled_prior_spend_nanodollars bigint NOT NULL, + lifetime_limit_nanodollars bigint NOT NULL, + operation_id uuid NOT NULL, + actor_profile_id uuid NOT NULL, + authorization_evidence_id uuid NOT NULL, + created_at timestamptz DEFAULT clock_timestamp() NOT NULL, + CONSTRAINT pk_model_usage_budget_configurations PRIMARY KEY(id), + CONSTRAINT ck_model_usage_budget_configurations_id_uuid7 CHECK ( + (get_byte(uuid_send(id),6)>>4)=7 AND (get_byte(uuid_send(id),8)&192)=128), + CONSTRAINT ck_model_usage_budget_configurations_values CHECK ( + configuration_version>0 AND authorized_total_nanodollars>0 AND + externally_reconciled_prior_spend_nanodollars>=0 AND + lifetime_limit_nanodollars>0 AND + externally_reconciled_prior_spend_nanodollars+lifetime_limit_nanodollars + <=authorized_total_nanodollars AND + configuration_digest ~ '^sha256:[0-9a-f]{64}$'), + CONSTRAINT uq_model_usage_budget_configurations_version + UNIQUE(project_id,configuration_version), + CONSTRAINT uq_model_usage_budget_configurations_identity + UNIQUE(id,project_id,configuration_version,configuration_digest), + CONSTRAINT uq_model_usage_budget_configurations_attempt_identity + UNIQUE(id,configuration_version,configuration_digest), + CONSTRAINT uq_model_usage_budget_configurations_id_project UNIQUE(id,project_id), + CONSTRAINT uq_model_usage_budget_configurations_digest UNIQUE(configuration_digest), + CONSTRAINT uq_model_usage_budget_configurations_operation UNIQUE(operation_id), + CONSTRAINT uq_model_usage_budget_configurations_authority + UNIQUE(authorization_evidence_id), + CONSTRAINT fk_model_usage_budget_configurations_project FOREIGN KEY(project_id) + REFERENCES public.projects(id) ON DELETE RESTRICT, + CONSTRAINT fk_model_usage_budget_configurations_actor FOREIGN KEY(actor_profile_id) + REFERENCES public.actor_profiles(id) ON DELETE RESTRICT, + CONSTRAINT fk_model_usage_budget_configurations_authority + FOREIGN KEY(authorization_evidence_id) + REFERENCES public.audit_events(id) ON DELETE RESTRICT +) +""", + r""" +CREATE TABLE public.model_usage_budget_accounts ( + project_id uuid NOT NULL, + current_configuration_id uuid NOT NULL, + lifetime_reserved_nanodollars bigint DEFAULT 0 NOT NULL, + lifetime_settled_nanodollars bigint DEFAULT 0 NOT NULL, + CONSTRAINT pk_model_usage_budget_accounts PRIMARY KEY(project_id), + CONSTRAINT uq_model_usage_budget_accounts_configuration + UNIQUE(current_configuration_id), + CONSTRAINT ck_model_usage_budget_accounts_values CHECK ( + lifetime_reserved_nanodollars>=0 AND lifetime_settled_nanodollars>=0 AND + lifetime_reserved_nanodollars::numeric+lifetime_settled_nanodollars::numeric + <=9000000000000000000), + CONSTRAINT fk_model_usage_budget_accounts_project FOREIGN KEY(project_id) + REFERENCES public.projects(id) ON DELETE RESTRICT, + CONSTRAINT fk_model_usage_budget_accounts_configuration + FOREIGN KEY(current_configuration_id,project_id) + REFERENCES public.model_usage_budget_configurations(id,project_id) + ON DELETE RESTRICT +) +""", + r""" +CREATE TABLE public.checker_model_usage_envelopes ( + id uuid NOT NULL, + project_id uuid NOT NULL, + task_id uuid NOT NULL, + assignment_id uuid NOT NULL, + submission_id uuid NOT NULL, + submission_version integer NOT NULL, + requirement_id varchar(100) NOT NULL, + registry_entry_digest varchar(71) NOT NULL, + configuration_sha256 varchar(71) NOT NULL, + profile_id uuid NOT NULL, + profile_version integer NOT NULL, + profile_digest varchar(71) NOT NULL, + created_at timestamptz DEFAULT clock_timestamp() NOT NULL, + CONSTRAINT pk_checker_model_usage_envelopes PRIMARY KEY(id), + CONSTRAINT ck_checker_model_usage_envelopes_id_uuid7 CHECK ( + (get_byte(uuid_send(id),6)>>4)=7 AND (get_byte(uuid_send(id),8)&192)=128), + CONSTRAINT ck_checker_model_usage_envelopes_values CHECK ( + submission_version>0 AND profile_version>0 AND + requirement_id ~ '^[a-z][a-z0-9_.-]{0,99}$' AND + registry_entry_digest ~ '^sha256:[0-9a-f]{64}$' AND + configuration_sha256 ~ '^sha256:[0-9a-f]{64}$' AND + profile_digest ~ '^sha256:[0-9a-f]{64}$'), + CONSTRAINT uq_checker_model_usage_envelopes_stable_identity UNIQUE( + project_id,assignment_id,submission_id,submission_version,requirement_id, + registry_entry_digest,configuration_sha256,profile_id,profile_version, + profile_digest), + CONSTRAINT fk_checker_model_usage_envelopes_project FOREIGN KEY(project_id) + REFERENCES public.projects(id) ON DELETE RESTRICT, + CONSTRAINT fk_checker_model_usage_envelopes_task_project FOREIGN KEY(task_id,project_id) + REFERENCES public.workstream_tasks(id,project_id) ON DELETE RESTRICT, + CONSTRAINT fk_checker_model_usage_envelopes_assignment FOREIGN KEY(assignment_id) + REFERENCES public.task_assignments(id) ON DELETE RESTRICT, + CONSTRAINT fk_checker_model_usage_envelopes_submission FOREIGN KEY( + submission_id,task_id,submission_version) + REFERENCES public.submissions(id,task_id,version) ON DELETE RESTRICT, + CONSTRAINT fk_checker_model_usage_envelopes_profile FOREIGN KEY( + profile_id,profile_version,profile_digest) + REFERENCES public.model_usage_profiles(id,profile_version,profile_digest) + ON DELETE RESTRICT +) +""", + r""" +CREATE TABLE public.checker_model_usage_slots ( + id uuid NOT NULL, + envelope_id uuid NOT NULL, + project_id uuid NOT NULL, + task_id uuid NOT NULL, + assignment_id uuid NOT NULL, + submission_id uuid NOT NULL, + submission_version integer NOT NULL, + requirement_id varchar(100) NOT NULL, + registry_entry_digest varchar(71) NOT NULL, + configuration_sha256 varchar(71) NOT NULL, + profile_id uuid NOT NULL, + profile_version integer NOT NULL, + profile_digest varchar(71) NOT NULL, + slot_index integer NOT NULL, + first_request_sha256 varchar(71) NOT NULL, + created_at timestamptz DEFAULT clock_timestamp() NOT NULL, + CONSTRAINT pk_checker_model_usage_slots PRIMARY KEY(id), + CONSTRAINT ck_checker_model_usage_slots_id_uuid7 CHECK ( + (get_byte(uuid_send(id),6)>>4)=7 AND (get_byte(uuid_send(id),8)&192)=128), + CONSTRAINT ck_checker_model_usage_slots_values CHECK ( + submission_version>0 AND profile_version>0 AND slot_index BETWEEN 0 AND 99 AND + requirement_id ~ '^[a-z][a-z0-9_.-]{0,99}$' AND + registry_entry_digest ~ '^sha256:[0-9a-f]{64}$' AND + configuration_sha256 ~ '^sha256:[0-9a-f]{64}$' AND + profile_digest ~ '^sha256:[0-9a-f]{64}$' AND + first_request_sha256 ~ '^sha256:[0-9a-f]{64}$'), + CONSTRAINT uq_checker_model_usage_slots_stable_identity UNIQUE( + project_id,assignment_id,submission_id,submission_version,requirement_id, + registry_entry_digest,configuration_sha256,profile_id,profile_version, + profile_digest,slot_index), + CONSTRAINT fk_checker_model_usage_slots_envelope FOREIGN KEY(envelope_id) + REFERENCES public.checker_model_usage_envelopes(id) ON DELETE RESTRICT, + CONSTRAINT fk_checker_model_usage_slots_project FOREIGN KEY(project_id) + REFERENCES public.projects(id) ON DELETE RESTRICT, + CONSTRAINT fk_checker_model_usage_slots_task_project FOREIGN KEY(task_id,project_id) + REFERENCES public.workstream_tasks(id,project_id) ON DELETE RESTRICT, + CONSTRAINT fk_checker_model_usage_slots_assignment FOREIGN KEY(assignment_id) + REFERENCES public.task_assignments(id) ON DELETE RESTRICT, + CONSTRAINT fk_checker_model_usage_slots_submission FOREIGN KEY( + submission_id,task_id,submission_version) + REFERENCES public.submissions(id,task_id,version) ON DELETE RESTRICT, + CONSTRAINT fk_checker_model_usage_slots_profile FOREIGN KEY( + profile_id,profile_version,profile_digest) + REFERENCES public.model_usage_profiles(id,profile_version,profile_digest) + ON DELETE RESTRICT +) +""", + r""" +ALTER TABLE public.project_guide_compilation_attempts + ADD CONSTRAINT uq_compilation_attempt_model_usage_owner UNIQUE( + id,project_id,guide_id,source_snapshot_id,setup_run_id,setup_generation, + runtime_configuration_hash) +""", + r""" +CREATE TABLE public.setup_model_usage_envelopes ( + id uuid NOT NULL, + compilation_attempt_id uuid NOT NULL, + project_id uuid NOT NULL, + guide_id uuid NOT NULL, + source_snapshot_id uuid NOT NULL, + setup_run_id uuid NOT NULL, + setup_generation bigint NOT NULL, + runtime_configuration_hash varchar(71) NOT NULL, + profile_id uuid NOT NULL, + profile_version integer NOT NULL, + profile_digest varchar(71) NOT NULL, + created_at timestamptz DEFAULT clock_timestamp() NOT NULL, + CONSTRAINT pk_setup_model_usage_envelopes PRIMARY KEY(id), + CONSTRAINT ck_setup_model_usage_envelopes_id_uuid7 CHECK ( + (get_byte(uuid_send(id),6)>>4)=7 AND (get_byte(uuid_send(id),8)&192)=128), + CONSTRAINT ck_setup_model_usage_envelopes_values CHECK ( + setup_generation>0 AND profile_version>0 AND + runtime_configuration_hash ~ '^sha256:[0-9a-f]{64}$' AND + profile_digest ~ '^sha256:[0-9a-f]{64}$'), + CONSTRAINT uq_setup_model_usage_envelopes_stable_identity UNIQUE( + compilation_attempt_id,runtime_configuration_hash,profile_id,profile_version, + profile_digest), + CONSTRAINT uq_setup_model_usage_envelopes_one_per_attempt UNIQUE( + compilation_attempt_id), + CONSTRAINT fk_setup_model_usage_envelopes_attempt FOREIGN KEY( + compilation_attempt_id,project_id,guide_id,source_snapshot_id,setup_run_id, + setup_generation,runtime_configuration_hash) + REFERENCES public.project_guide_compilation_attempts( + id,project_id,guide_id,source_snapshot_id,setup_run_id,setup_generation, + runtime_configuration_hash) ON DELETE RESTRICT, + CONSTRAINT fk_setup_model_usage_envelopes_profile FOREIGN KEY( + profile_id,profile_version,profile_digest) + REFERENCES public.model_usage_profiles(id,profile_version,profile_digest) + ON DELETE RESTRICT +) +""", + r""" +CREATE TABLE public.setup_model_usage_slots ( + id uuid NOT NULL, + envelope_id uuid NOT NULL, + compilation_attempt_id uuid NOT NULL, + project_id uuid NOT NULL, + guide_id uuid NOT NULL, + source_snapshot_id uuid NOT NULL, + setup_run_id uuid NOT NULL, + setup_generation bigint NOT NULL, + runtime_configuration_hash varchar(71) NOT NULL, + profile_id uuid NOT NULL, + profile_version integer NOT NULL, + profile_digest varchar(71) NOT NULL, + phase varchar(32) NOT NULL, + call_index integer NOT NULL, + request_sha256 varchar(71) NOT NULL, + created_at timestamptz DEFAULT clock_timestamp() NOT NULL, + CONSTRAINT pk_setup_model_usage_slots PRIMARY KEY(id), + CONSTRAINT ck_setup_model_usage_slots_id_uuid7 CHECK ( + (get_byte(uuid_send(id),6)>>4)=7 AND (get_byte(uuid_send(id),8)&192)=128), + CONSTRAINT ck_setup_model_usage_slots_values CHECK ( + setup_generation>0 AND profile_version>0 AND call_index BETWEEN 0 AND 99 AND + phase IN ('draft','review','correction') AND + runtime_configuration_hash ~ '^sha256:[0-9a-f]{64}$' AND + profile_digest ~ '^sha256:[0-9a-f]{64}$' AND + request_sha256 ~ '^sha256:[0-9a-f]{64}$'), + CONSTRAINT uq_setup_model_usage_slots_stable_identity UNIQUE( + compilation_attempt_id,runtime_configuration_hash,profile_id,profile_version, + profile_digest,phase,call_index), + CONSTRAINT fk_setup_model_usage_slots_envelope FOREIGN KEY(envelope_id) + REFERENCES public.setup_model_usage_envelopes(id) ON DELETE RESTRICT, + CONSTRAINT fk_setup_model_usage_slots_attempt FOREIGN KEY( + compilation_attempt_id,project_id,guide_id,source_snapshot_id,setup_run_id, + setup_generation,runtime_configuration_hash) + REFERENCES public.project_guide_compilation_attempts( + id,project_id,guide_id,source_snapshot_id,setup_run_id,setup_generation, + runtime_configuration_hash) ON DELETE RESTRICT, + CONSTRAINT fk_setup_model_usage_slots_profile FOREIGN KEY( + profile_id,profile_version,profile_digest) + REFERENCES public.model_usage_profiles(id,profile_version,profile_digest) + ON DELETE RESTRICT +) +""", + r""" +CREATE TABLE public.model_usage_effects ( + id uuid NOT NULL, + checker_slot_id uuid, + setup_slot_id uuid, + attempt_number integer NOT NULL, + internal_call_request_id uuid NOT NULL, + parent_attempt_id uuid, + request_sha256 varchar(71) NOT NULL, + state varchar(32) NOT NULL, + prepared_execution_facts jsonb, + prepared_authorization_evidence_id uuid NOT NULL, + request_binding_id uuid, + request_content_id uuid, + request_put_attempt_id uuid, + request_verification_receipt_id uuid, + request_content_sha256 varchar(71), + request_byte_count integer, + budget_configuration_id uuid, + budget_configuration_version integer, + budget_configuration_digest varchar(71), + reserved_nanodollars bigint, + dispatch_execution_facts jsonb, + dispatch_authorization_evidence_id uuid, + provider_response_id varchar(256), + response_binding_id uuid, + response_content_id uuid, + response_put_attempt_id uuid, + response_verification_receipt_id uuid, + response_content_sha256 varchar(71), + response_byte_count integer, + input_tokens integer, + cached_input_tokens integer, + output_tokens integer, + reasoning_tokens integer, + settled_nanodollars bigint, + settlement_authorization_evidence_id uuid, + settled_at timestamptz, + no_effect_proof_id uuid, + no_effect_proof_digest varchar(71), + no_effect_observed_at timestamptz, + terminal_authorization_evidence_id uuid, + uncertainty_digest varchar(71), + observation_authorization_evidence_id uuid, + prepared_at timestamptz DEFAULT clock_timestamp() NOT NULL, + dispatched_at timestamptz, + terminal_at timestamptz, + CONSTRAINT pk_model_usage_effects PRIMARY KEY(id), + CONSTRAINT ck_model_usage_effects_id_uuid7 CHECK ( + (get_byte(uuid_send(id),6)>>4)=7 AND (get_byte(uuid_send(id),8)&192)=128), + CONSTRAINT ck_model_usage_effects_internal_id_uuid7 CHECK ( + (get_byte(uuid_send(internal_call_request_id),6)>>4)=7 AND + (get_byte(uuid_send(internal_call_request_id),8)&192)=128), + CONSTRAINT ck_model_usage_effects_values CHECK ( + attempt_number>0 AND request_sha256 ~ '^sha256:[0-9a-f]{64}$' AND + state IN ('prepared','dispatching','uncertain','completed','known_failed','known_no_effect') AND + (provider_response_id IS NULL OR + provider_response_id ~ '^[A-Za-z0-9][A-Za-z0-9._:-]{0,255}$') AND + (reserved_nanodollars IS NULL OR reserved_nanodollars>0) AND + (settled_nanodollars IS NULL OR settled_nanodollars>=0)), + CONSTRAINT ck_model_usage_effects_exact_owner CHECK ( + num_nonnulls(checker_slot_id,setup_slot_id)=1), + CONSTRAINT uq_model_usage_effects_checker_number UNIQUE(checker_slot_id,attempt_number), + CONSTRAINT uq_model_usage_effects_setup_number UNIQUE(setup_slot_id,attempt_number), + CONSTRAINT uq_model_usage_effects_internal_request UNIQUE(internal_call_request_id), + CONSTRAINT uq_model_usage_effects_prepare_authority + UNIQUE(prepared_authorization_evidence_id), + CONSTRAINT uq_model_usage_effects_dispatch_authority + UNIQUE(dispatch_authorization_evidence_id), + CONSTRAINT uq_model_usage_effects_observation_authority + UNIQUE(observation_authorization_evidence_id), + CONSTRAINT uq_model_usage_effects_terminal_authority + UNIQUE(terminal_authorization_evidence_id), + CONSTRAINT uq_model_usage_effects_settlement_authority + UNIQUE(settlement_authorization_evidence_id), + CONSTRAINT fk_model_usage_effects_checker_slot FOREIGN KEY(checker_slot_id) + REFERENCES public.checker_model_usage_slots(id) ON DELETE RESTRICT, + CONSTRAINT fk_model_usage_effects_setup_slot FOREIGN KEY(setup_slot_id) + REFERENCES public.setup_model_usage_slots(id) ON DELETE RESTRICT, + CONSTRAINT fk_model_usage_effects_parent FOREIGN KEY(parent_attempt_id) + REFERENCES public.model_usage_effects(id) ON DELETE RESTRICT, + CONSTRAINT fk_model_usage_effects_budget FOREIGN KEY( + budget_configuration_id,budget_configuration_version,budget_configuration_digest) + REFERENCES public.model_usage_budget_configurations( + id,configuration_version,configuration_digest) ON DELETE RESTRICT, + CONSTRAINT fk_model_usage_effects_prepare_authority + FOREIGN KEY(prepared_authorization_evidence_id) + REFERENCES public.audit_events(id) ON DELETE RESTRICT, + CONSTRAINT fk_model_usage_effects_dispatch_authority + FOREIGN KEY(dispatch_authorization_evidence_id) + REFERENCES public.audit_events(id) ON DELETE RESTRICT, + CONSTRAINT fk_model_usage_effects_observation_authority + FOREIGN KEY(observation_authorization_evidence_id) + REFERENCES public.audit_events(id) ON DELETE RESTRICT, + CONSTRAINT fk_model_usage_effects_terminal_authority + FOREIGN KEY(terminal_authorization_evidence_id) + REFERENCES public.audit_events(id) ON DELETE RESTRICT, + CONSTRAINT fk_model_usage_effects_settlement_authority + FOREIGN KEY(settlement_authorization_evidence_id) + REFERENCES public.audit_events(id) ON DELETE RESTRICT, + CONSTRAINT fk_model_usage_effects_request_binding FOREIGN KEY(request_binding_id) + REFERENCES public.artifact_bindings(id) ON DELETE RESTRICT, + CONSTRAINT fk_model_usage_effects_request_content FOREIGN KEY(request_content_id) + REFERENCES public.artifact_contents(id) ON DELETE RESTRICT, + CONSTRAINT fk_model_usage_effects_request_put FOREIGN KEY(request_put_attempt_id) + REFERENCES public.artifact_put_attempts(id) ON DELETE RESTRICT, + CONSTRAINT fk_model_usage_effects_request_verification + FOREIGN KEY(request_verification_receipt_id) + REFERENCES public.artifact_verification_receipts(id) ON DELETE RESTRICT, + CONSTRAINT fk_model_usage_effects_response_binding FOREIGN KEY(response_binding_id) + REFERENCES public.artifact_bindings(id) ON DELETE RESTRICT, + CONSTRAINT fk_model_usage_effects_response_content FOREIGN KEY(response_content_id) + REFERENCES public.artifact_contents(id) ON DELETE RESTRICT, + CONSTRAINT fk_model_usage_effects_response_put FOREIGN KEY(response_put_attempt_id) + REFERENCES public.artifact_put_attempts(id) ON DELETE RESTRICT, + CONSTRAINT fk_model_usage_effects_response_verification + FOREIGN KEY(response_verification_receipt_id) + REFERENCES public.artifact_verification_receipts(id) ON DELETE RESTRICT +) +""", + r""" +CREATE TABLE public.checker_model_usage_recoveries ( + id uuid NOT NULL, + attempt_id uuid NOT NULL, + checker_run_id uuid NOT NULL, + worker_lease_id uuid NOT NULL, + worker_lease_generation integer NOT NULL, + execution_facts jsonb NOT NULL, + effect_facts jsonb NOT NULL, + authorization_evidence_id uuid NOT NULL, + created_at timestamptz DEFAULT clock_timestamp() NOT NULL, + CONSTRAINT pk_checker_model_usage_recoveries PRIMARY KEY(id), + CONSTRAINT ck_checker_model_usage_recoveries_id_uuid7 CHECK ( + (get_byte(uuid_send(id),6)>>4)=7 AND (get_byte(uuid_send(id),8)&192)=128), + CONSTRAINT ck_checker_model_usage_recoveries_generation CHECK(worker_lease_generation>0), + CONSTRAINT ck_checker_model_usage_recoveries_facts CHECK( + jsonb_typeof(execution_facts)='object' AND jsonb_typeof(effect_facts)='object'), + CONSTRAINT uq_checker_model_usage_recoveries_attachment UNIQUE( + attempt_id,checker_run_id,worker_lease_id,worker_lease_generation), + CONSTRAINT uq_checker_model_usage_recoveries_authority UNIQUE(authorization_evidence_id), + CONSTRAINT fk_checker_model_usage_recoveries_attempt FOREIGN KEY(attempt_id) + REFERENCES public.model_usage_effects(id) ON DELETE RESTRICT, + CONSTRAINT fk_checker_model_usage_recoveries_run FOREIGN KEY(checker_run_id) + REFERENCES public.checker_runs(id) ON DELETE RESTRICT, + CONSTRAINT fk_checker_model_usage_recoveries_authority FOREIGN KEY(authorization_evidence_id) + REFERENCES public.audit_events(id) ON DELETE RESTRICT +) +""", +) + + +_FUNCTIONS = ( + r""" +CREATE FUNCTION public.model_usage_canonical_json(value jsonb) RETURNS text +LANGUAGE plpgsql IMMUTABLE STRICT SET search_path=pg_catalog,public,pg_temp AS $$ +DECLARE encoded text; +BEGIN + CASE pg_catalog.jsonb_typeof(value) + WHEN 'object' THEN + SELECT '{'||coalesce(pg_catalog.string_agg( + pg_catalog.to_json(item_key)::text||':'||public.model_usage_canonical_json(item_value), + ',' ORDER BY item_key COLLATE "C"),'')||'}' INTO encoded + FROM pg_catalog.jsonb_each(value) AS items(item_key,item_value); + RETURN encoded; + WHEN 'array' THEN + SELECT '['||coalesce(pg_catalog.string_agg( + public.model_usage_canonical_json(item_value),',' ORDER BY item_order),'')||']' + INTO encoded FROM pg_catalog.jsonb_array_elements(value) WITH ORDINALITY + AS items(item_value,item_order); + RETURN encoded; + ELSE RETURN value::text; + END CASE; +END $$ +""", + r""" +CREATE FUNCTION public.model_usage_json_digest(value jsonb) RETURNS varchar +LANGUAGE sql IMMUTABLE STRICT SET search_path=pg_catalog,public,pg_temp AS $$ + SELECT 'sha256:'||pg_catalog.encode(pg_catalog.sha256(pg_catalog.convert_to( + public.model_usage_canonical_json(value),'UTF8')),'hex') +$$ +""", + r""" +CREATE FUNCTION public.model_usage_profile_authority_digest( + r public.model_usage_profiles +) RETURNS varchar LANGUAGE sql IMMUTABLE STRICT +SET search_path=pg_catalog,public,pg_temp AS $$ + WITH facts(value) AS (SELECT pg_catalog.jsonb_build_object( + 'operation_id',r.operation_id,'actor_profile_id',r.actor_profile_id, + 'spec',pg_catalog.jsonb_build_object( + 'project_id',r.project_id,'profile_version',r.profile_version, + 'provider',r.provider,'api',r.api,'model_snapshot',r.model_snapshot, + 'settings',r.generation_settings, + 'maximum_request_bytes',r.maximum_request_bytes, + 'maximum_output_bytes',r.maximum_output_bytes, + 'maximum_response_bytes',r.maximum_response_bytes, + 'maximum_input_tokens',r.maximum_input_tokens, + 'maximum_output_tokens',r.maximum_output_tokens, + 'maximum_calls',r.maximum_calls, + 'maximum_total_request_bytes',r.maximum_total_request_bytes, + 'maximum_total_response_bytes',r.maximum_total_response_bytes, + 'maximum_total_input_tokens',r.maximum_total_input_tokens, + 'maximum_total_output_tokens',r.maximum_total_output_tokens, + 'allowed_tools',r.allowed_tools, + 'maximum_tool_calls',r.maximum_tool_calls, + 'maximum_tool_output_bytes',r.maximum_tool_output_bytes, + 'deadline_ms',r.deadline_ms,'pricing',pg_catalog.jsonb_build_object( + 'pricing_policy_id',r.pricing_policy_id, + 'pricing_policy_version',r.pricing_policy_version, + 'pricing_policy_digest',r.pricing_policy_digest, + 'uncached_input_nanodollars_per_token',r.uncached_input_rate, + 'cached_input_nanodollars_per_token',r.cached_input_rate, + 'output_nanodollars_per_token',r.output_rate)), + 'profile_digest',r.profile_digest)) + SELECT public.model_usage_json_digest(pg_catalog.jsonb_build_object( + 'resource_context',pg_catalog.jsonb_build_object( + 'resource_type','project_model_administration','resource_id',r.operation_id, + 'project_id',r.project_id,'actor_profile_id',r.actor_profile_id, + 'action_id','project.model_profile.provision','facts',facts.value, + 'facts_digest',public.model_usage_json_digest(pg_catalog.jsonb_build_object( + 'action','project.model_profile.provision','facts',facts.value))))) FROM facts +$$ +""", + r""" +CREATE FUNCTION public.model_usage_budget_authority_digest( + r public.model_usage_budget_configurations +) RETURNS varchar LANGUAGE sql IMMUTABLE STRICT +SET search_path=pg_catalog,public,pg_temp AS $$ + WITH facts(value) AS (SELECT pg_catalog.jsonb_build_object( + 'operation_id',r.operation_id,'actor_profile_id',r.actor_profile_id, + 'spec',pg_catalog.jsonb_build_object('project_id',r.project_id, + 'configuration_version',r.configuration_version, + 'authorized_total_nanodollars',r.authorized_total_nanodollars, + 'externally_reconciled_prior_spend_nanodollars', + r.externally_reconciled_prior_spend_nanodollars, + 'lifetime_limit_nanodollars',r.lifetime_limit_nanodollars), + 'configuration_digest',r.configuration_digest)) + SELECT public.model_usage_json_digest(pg_catalog.jsonb_build_object( + 'resource_context',pg_catalog.jsonb_build_object( + 'resource_type','project_model_administration','resource_id',r.operation_id, + 'project_id',r.project_id,'actor_profile_id',r.actor_profile_id, + 'action_id','project.model_budget.configure','facts',facts.value, + 'facts_digest',public.model_usage_json_digest(pg_catalog.jsonb_build_object( + 'action','project.model_budget.configure','facts',facts.value))))) FROM facts +$$ +""", + r""" +CREATE FUNCTION public.guard_model_usage_profile() RETURNS trigger +LANGUAGE plpgsql SET search_path=pg_catalog,public,pg_temp AS $$ +DECLARE expected_version integer; +DECLARE authority public.audit_events%ROWTYPE; +BEGIN + IF TG_OP<>'INSERT' THEN + RAISE EXCEPTION 'checker model profiles are immutable' USING ERRCODE='55000'; + END IF; + IF NEW.profile_digest<>public.model_usage_json_digest( + pg_catalog.jsonb_build_object( + 'project_id',NEW.project_id,'profile_version',NEW.profile_version, + 'provider',NEW.provider,'api',NEW.api, + 'model_snapshot',NEW.model_snapshot,'settings',NEW.generation_settings, + 'maximum_request_bytes',NEW.maximum_request_bytes, + 'maximum_output_bytes',NEW.maximum_output_bytes, + 'maximum_response_bytes',NEW.maximum_response_bytes, + 'maximum_input_tokens',NEW.maximum_input_tokens, + 'maximum_output_tokens',NEW.maximum_output_tokens, + 'maximum_calls',NEW.maximum_calls, + 'maximum_total_request_bytes',NEW.maximum_total_request_bytes, + 'maximum_total_response_bytes',NEW.maximum_total_response_bytes, + 'maximum_total_input_tokens',NEW.maximum_total_input_tokens, + 'maximum_total_output_tokens',NEW.maximum_total_output_tokens, + 'allowed_tools',NEW.allowed_tools, + 'maximum_tool_calls',NEW.maximum_tool_calls, + 'maximum_tool_output_bytes',NEW.maximum_tool_output_bytes, + 'deadline_ms',NEW.deadline_ms, + 'pricing',pg_catalog.jsonb_build_object( + 'pricing_policy_id',NEW.pricing_policy_id, + 'pricing_policy_version',NEW.pricing_policy_version, + 'pricing_policy_digest',NEW.pricing_policy_digest, + 'uncached_input_nanodollars_per_token',NEW.uncached_input_rate, + 'cached_input_nanodollars_per_token',NEW.cached_input_rate, + 'output_nanodollars_per_token',NEW.output_rate))) THEN + RAISE EXCEPTION 'checker model profile digest is invalid' USING ERRCODE='23514'; + END IF; + SELECT * INTO authority FROM public.audit_events + WHERE id=NEW.authorization_evidence_id FOR SHARE; + IF authority.id IS NULL OR authority.event_domain<>'authority' OR + authority.event_type<>'SensitiveAuthorizationAllowed' OR + authority.actor_ref_kind<>'actor_profile' OR + authority.actor_id<>NEW.actor_profile_id::text OR + authority.action_id<>'project.model_profile.provision' OR + authority.permission_id<>'project.model_profile.manage' OR + authority.denial_code IS NOT NULL OR + authority.after_facts::jsonb<>pg_catalog.jsonb_build_object( + 'allowed',true,'resource_context_digest', + public.model_usage_profile_authority_digest(NEW)) THEN + RAISE EXCEPTION 'checker model profile authority is invalid' USING ERRCODE='23514'; + END IF; + PERFORM pg_advisory_xact_lock(hashtextextended('checker-model-profile:'||NEW.project_id,0)); + SELECT coalesce(max(profile_version),0)+1 INTO expected_version + FROM public.model_usage_profiles WHERE project_id=NEW.project_id; + IF NEW.profile_version<>expected_version THEN + RAISE EXCEPTION 'checker model profile version is not sequential' USING ERRCODE='23514'; + END IF; + RETURN NEW; +END $$ +""", + r""" +CREATE FUNCTION public.model_usage_checker_execution_matches( + facts jsonb, expected_project_id uuid, expected_task_id uuid, + expected_assignment_id uuid, expected_submission_id uuid, + expected_submission_version integer +) RETURNS boolean +LANGUAGE sql IMMUTABLE STRICT SET search_path=pg_catalog,public,pg_temp AS $$ + SELECT jsonb_typeof(facts)='object' AND + (SELECT count(*) FROM jsonb_object_keys(facts))=11 AND + facts ?& ARRAY[ + 'project_id','task_id','assignment_id','submission_id','submission_version', + 'evaluation_request_id','evaluation_generation','checker_run_id', + 'worker_lease_id','worker_lease_generation','worker_lease_expires_at'] AND + jsonb_typeof(facts->'submission_version')='number' AND + jsonb_typeof(facts->'evaluation_generation')='number' AND + jsonb_typeof(facts->'worker_lease_generation')='number' AND + facts->'submission_version'=to_jsonb((facts->>'submission_version')::integer) AND + facts->'evaluation_generation'=to_jsonb((facts->>'evaluation_generation')::integer) AND + facts->'worker_lease_generation'= + to_jsonb((facts->>'worker_lease_generation')::integer) AND + (facts->>'submission_version')::integer>0 AND + (facts->>'evaluation_generation')::integer>0 AND + (facts->>'worker_lease_generation')::integer>0 AND + (facts->>'project_id')::uuid::text=facts->>'project_id' AND + (facts->>'task_id')::uuid::text=facts->>'task_id' AND + (facts->>'assignment_id')::uuid::text=facts->>'assignment_id' AND + (facts->>'submission_id')::uuid::text=facts->>'submission_id' AND + (facts->>'evaluation_request_id')::uuid::text=facts->>'evaluation_request_id' AND + (facts->>'checker_run_id')::uuid::text=facts->>'checker_run_id' AND + (facts->>'worker_lease_id')::uuid::text=facts->>'worker_lease_id' AND + jsonb_typeof(facts->'worker_lease_expires_at')='string' AND + facts->>'worker_lease_expires_at' ~ '(Z|[+-][0-9]{2}:[0-9]{2})$' AND + isfinite((facts->>'worker_lease_expires_at')::timestamptz) AND + (facts->>'project_id')::uuid=expected_project_id AND + (facts->>'task_id')::uuid=expected_task_id AND + (facts->>'assignment_id')::uuid=expected_assignment_id AND + (facts->>'submission_id')::uuid=expected_submission_id AND + (facts->>'submission_version')::integer=expected_submission_version +$$ +""", + r""" +CREATE FUNCTION public.guard_model_usage_budget_configuration() RETURNS trigger +LANGUAGE plpgsql SET search_path=pg_catalog,public,pg_temp AS $$ +DECLARE account public.model_usage_budget_accounts%ROWTYPE; +DECLARE account_configuration public.model_usage_budget_configurations%ROWTYPE; +DECLARE authority public.audit_events%ROWTYPE; +BEGIN + IF TG_OP<>'INSERT' THEN + RAISE EXCEPTION 'checker model budget configurations are immutable' USING ERRCODE='55000'; + END IF; + IF NEW.configuration_digest<>public.model_usage_json_digest( + pg_catalog.jsonb_build_object( + 'project_id',NEW.project_id, + 'configuration_version',NEW.configuration_version, + 'authorized_total_nanodollars',NEW.authorized_total_nanodollars, + 'externally_reconciled_prior_spend_nanodollars', + NEW.externally_reconciled_prior_spend_nanodollars, + 'lifetime_limit_nanodollars',NEW.lifetime_limit_nanodollars)) THEN + RAISE EXCEPTION 'checker model budget configuration digest is invalid' + USING ERRCODE='23514'; + END IF; + SELECT * INTO authority FROM public.audit_events + WHERE id=NEW.authorization_evidence_id FOR SHARE; + IF authority.id IS NULL OR authority.event_domain<>'authority' OR + authority.event_type<>'SensitiveAuthorizationAllowed' OR + authority.actor_ref_kind<>'actor_profile' OR + authority.actor_id<>NEW.actor_profile_id::text OR + authority.action_id<>'project.model_budget.configure' OR + authority.permission_id<>'project.model_budget.manage' OR + authority.denial_code IS NOT NULL OR + authority.after_facts::jsonb<>pg_catalog.jsonb_build_object( + 'allowed',true,'resource_context_digest', + public.model_usage_budget_authority_digest(NEW)) THEN + RAISE EXCEPTION 'checker model budget authority is invalid' USING ERRCODE='23514'; + END IF; + PERFORM pg_advisory_xact_lock(hashtextextended('checker-model-budget:'||NEW.project_id,0)); + SELECT * INTO account FROM public.model_usage_budget_accounts + WHERE project_id=NEW.project_id FOR UPDATE; + IF account.project_id IS NULL THEN + IF NEW.configuration_version<>1 THEN + RAISE EXCEPTION 'initial checker model budget version must be one' USING ERRCODE='23514'; + END IF; + ELSE + SELECT * INTO account_configuration + FROM public.model_usage_budget_configurations + WHERE id=account.current_configuration_id; + IF NEW.configuration_version<>account_configuration.configuration_version+1 OR + NEW.authorized_total_nanodollars + chosen.lifetime_limit_nanodollars THEN + RAISE EXCEPTION 'checker model budget ceiling exceeded' USING ERRCODE='23514'; + END IF; + IF TG_OP='INSERT' THEN + IF chosen.configuration_version<>1 OR NEW.lifetime_reserved_nanodollars<>0 OR + NEW.lifetime_settled_nanodollars<>0 THEN + RAISE EXCEPTION 'invalid initial checker model budget account' USING ERRCODE='23514'; + END IF; + RETURN NEW; + END IF; + IF NEW.project_id<>OLD.project_id THEN + RAISE EXCEPTION 'checker model budget project is immutable' USING ERRCODE='55000'; + END IF; + IF NEW.current_configuration_id<>OLD.current_configuration_id THEN + SELECT * INTO prior FROM public.model_usage_budget_configurations + WHERE id=OLD.current_configuration_id FOR SHARE; + IF ROW(NEW.lifetime_reserved_nanodollars,NEW.lifetime_settled_nanodollars) + IS DISTINCT FROM + ROW(OLD.lifetime_reserved_nanodollars,OLD.lifetime_settled_nanodollars) OR + chosen.configuration_version<>prior.configuration_version+1 THEN + RAISE EXCEPTION 'invalid checker model budget pointer transition' USING ERRCODE='23514'; + END IF; + ELSIF NOT ( + (NEW.lifetime_reserved_nanodollars>OLD.lifetime_reserved_nanodollars AND + NEW.lifetime_settled_nanodollars=OLD.lifetime_settled_nanodollars) OR + (NEW.lifetime_reserved_nanodollars=OLD.lifetime_settled_nanodollars AND + NEW.lifetime_settled_nanodollars-OLD.lifetime_settled_nanodollars<= + OLD.lifetime_reserved_nanodollars-NEW.lifetime_reserved_nanodollars) + ) THEN + RAISE EXCEPTION 'invalid checker model budget counter transition' USING ERRCODE='23514'; + END IF; + RETURN NEW; +END $$ +""", + r""" +CREATE FUNCTION public.guard_checker_model_usage_envelope() RETURNS trigger +LANGUAGE plpgsql SET search_path=pg_catalog,public,pg_temp AS $$ +DECLARE assignment_task uuid; +DECLARE assignment_project uuid; +DECLARE profile public.model_usage_profiles%ROWTYPE; +BEGIN + IF TG_OP<>'INSERT' THEN + RAISE EXCEPTION 'checker model usage envelopes are immutable' USING ERRCODE='55000'; + END IF; + SELECT task_id,project_id INTO assignment_task,assignment_project + FROM public.task_assignments WHERE id=NEW.assignment_id FOR SHARE; + SELECT * INTO profile FROM public.model_usage_profiles WHERE + id=NEW.profile_id AND profile_version=NEW.profile_version AND + profile_digest=NEW.profile_digest FOR SHARE; + IF assignment_task IS DISTINCT FROM NEW.task_id OR + assignment_project IS DISTINCT FROM NEW.project_id OR profile.id IS NULL OR + profile.project_id<>NEW.project_id THEN + RAISE EXCEPTION 'invalid checker model usage envelope ownership' USING ERRCODE='23514'; + END IF; + RETURN NEW; +END $$ +""", + r""" +CREATE FUNCTION public.guard_checker_model_call_slot() RETURNS trigger +LANGUAGE plpgsql SET search_path=pg_catalog,public,pg_temp AS $$ +DECLARE envelope public.checker_model_usage_envelopes%ROWTYPE; +DECLARE profile public.model_usage_profiles%ROWTYPE; +BEGIN + IF TG_OP<>'INSERT' THEN + RAISE EXCEPTION 'checker model call slots are immutable' USING ERRCODE='55000'; + END IF; + SELECT * INTO envelope FROM public.checker_model_usage_envelopes + WHERE id=NEW.envelope_id FOR SHARE; + SELECT * INTO profile FROM public.model_usage_profiles + WHERE id=NEW.profile_id AND profile_version=NEW.profile_version AND + profile_digest=NEW.profile_digest FOR SHARE; + IF envelope.id IS NULL OR profile.id IS NULL OR + ROW(envelope.project_id,envelope.task_id,envelope.assignment_id, + envelope.submission_id,envelope.submission_version,envelope.requirement_id, + envelope.registry_entry_digest,envelope.configuration_sha256, + envelope.profile_id,envelope.profile_version,envelope.profile_digest) + IS DISTINCT FROM + ROW(NEW.project_id,NEW.task_id,NEW.assignment_id,NEW.submission_id, + NEW.submission_version,NEW.requirement_id,NEW.registry_entry_digest, + NEW.configuration_sha256,NEW.profile_id,NEW.profile_version, + NEW.profile_digest) OR NEW.slot_index>=profile.maximum_calls THEN + RAISE EXCEPTION 'invalid checker model call slot ownership' USING ERRCODE='23514'; + END IF; + RETURN NEW; +END $$ +""", + r""" +CREATE FUNCTION public.guard_setup_model_usage_envelope() RETURNS trigger +LANGUAGE plpgsql SET search_path=pg_catalog,public,pg_temp AS $$ +DECLARE attempt public.project_guide_compilation_attempts%ROWTYPE; +DECLARE profile public.model_usage_profiles%ROWTYPE; +BEGIN + IF TG_OP<>'INSERT' THEN + RAISE EXCEPTION 'setup model usage envelopes are immutable' USING ERRCODE='55000'; + END IF; + SELECT * INTO attempt FROM public.project_guide_compilation_attempts + WHERE id=NEW.compilation_attempt_id FOR SHARE; + SELECT * INTO profile FROM public.model_usage_profiles WHERE + id=NEW.profile_id AND profile_version=NEW.profile_version AND + profile_digest=NEW.profile_digest FOR SHARE; + IF attempt.id IS NULL OR profile.id IS NULL OR + attempt.project_id IS DISTINCT FROM NEW.project_id OR + attempt.guide_id IS DISTINCT FROM NEW.guide_id OR + attempt.source_snapshot_id IS DISTINCT FROM NEW.source_snapshot_id OR + attempt.setup_run_id IS DISTINCT FROM NEW.setup_run_id OR + attempt.setup_generation IS DISTINCT FROM NEW.setup_generation OR + attempt.runtime_configuration_hash IS DISTINCT FROM NEW.runtime_configuration_hash OR + jsonb_typeof(attempt.runtime_configuration::jsonb) IS DISTINCT FROM 'object' OR + attempt.runtime_configuration->>'model_profile_id' IS DISTINCT FROM + NEW.profile_id::text OR + (attempt.runtime_configuration->>'model_profile_version')::integer IS DISTINCT FROM + NEW.profile_version OR + attempt.runtime_configuration->>'model_profile_digest' IS DISTINCT FROM + NEW.profile_digest OR + attempt.runtime_configuration->>'model_provider' IS DISTINCT FROM profile.provider OR + attempt.runtime_configuration->>'model_api' IS DISTINCT FROM profile.api OR + attempt.runtime_configuration->>'model' IS DISTINCT FROM profile.model_snapshot OR + profile.project_id::text IS DISTINCT FROM NEW.project_id::text THEN + RAISE EXCEPTION 'invalid setup model usage envelope ownership' USING ERRCODE='23514'; + END IF; + RETURN NEW; +END $$ +""", + r""" +CREATE FUNCTION public.guard_setup_model_usage_slot() RETURNS trigger +LANGUAGE plpgsql SET search_path=pg_catalog,public,pg_temp AS $$ +DECLARE attempt public.project_guide_compilation_attempts%ROWTYPE; +DECLARE profile public.model_usage_profiles%ROWTYPE; +DECLARE envelope public.setup_model_usage_envelopes%ROWTYPE; +BEGIN + IF TG_OP<>'INSERT' THEN + RAISE EXCEPTION 'setup model usage slots are immutable' USING ERRCODE='55000'; + END IF; + SELECT * INTO attempt FROM public.project_guide_compilation_attempts + WHERE id=NEW.compilation_attempt_id FOR SHARE; + SELECT * INTO profile FROM public.model_usage_profiles WHERE + id=NEW.profile_id AND profile_version=NEW.profile_version AND + profile_digest=NEW.profile_digest FOR SHARE; + SELECT * INTO envelope FROM public.setup_model_usage_envelopes + WHERE id=NEW.envelope_id FOR SHARE; + IF attempt.id IS NULL OR profile.id IS NULL OR envelope.id IS NULL OR + attempt.project_id IS DISTINCT FROM NEW.project_id OR + attempt.guide_id IS DISTINCT FROM NEW.guide_id OR + attempt.source_snapshot_id IS DISTINCT FROM NEW.source_snapshot_id OR + attempt.setup_run_id IS DISTINCT FROM NEW.setup_run_id OR + attempt.setup_generation IS DISTINCT FROM NEW.setup_generation OR + attempt.runtime_configuration_hash IS DISTINCT FROM NEW.runtime_configuration_hash OR + jsonb_typeof(attempt.runtime_configuration::jsonb) IS DISTINCT FROM 'object' OR + attempt.runtime_configuration->>'model_profile_id' IS DISTINCT FROM + NEW.profile_id::text OR + (attempt.runtime_configuration->>'model_profile_version')::integer IS DISTINCT FROM + NEW.profile_version OR + attempt.runtime_configuration->>'model_profile_digest' IS DISTINCT FROM + NEW.profile_digest OR + attempt.runtime_configuration->>'model_provider' IS DISTINCT FROM profile.provider OR + attempt.runtime_configuration->>'model_api' IS DISTINCT FROM profile.api OR + attempt.runtime_configuration->>'model' IS DISTINCT FROM profile.model_snapshot OR + ROW(envelope.compilation_attempt_id,envelope.project_id,envelope.guide_id, + envelope.source_snapshot_id,envelope.setup_run_id,envelope.setup_generation, + envelope.runtime_configuration_hash,envelope.profile_id, + envelope.profile_version,envelope.profile_digest) + IS DISTINCT FROM + ROW(NEW.compilation_attempt_id,NEW.project_id,NEW.guide_id, + NEW.source_snapshot_id,NEW.setup_run_id,NEW.setup_generation, + NEW.runtime_configuration_hash,NEW.profile_id,NEW.profile_version, + NEW.profile_digest) OR + profile.project_id::text IS DISTINCT FROM NEW.project_id::text OR + NEW.call_index>=profile.maximum_calls THEN + RAISE EXCEPTION 'invalid setup model usage slot ownership' USING ERRCODE='23514'; + END IF; + RETURN NEW; +END $$ +""", + r""" +CREATE FUNCTION public.guard_model_usage_effect() RETURNS trigger +LANGUAGE plpgsql SET search_path=pg_catalog,public,pg_temp AS $$ +DECLARE slot public.checker_model_usage_slots%ROWTYPE; +DECLARE setup_slot public.setup_model_usage_slots%ROWTYPE; +DECLARE checker_envelope public.checker_model_usage_envelopes%ROWTYPE; +DECLARE setup_envelope public.setup_model_usage_envelopes%ROWTYPE; +DECLARE profile public.model_usage_profiles%ROWTYPE; +DECLARE budget public.model_usage_budget_configurations%ROWTYPE; +DECLARE account public.model_usage_budget_accounts%ROWTYPE; +DECLARE parent public.model_usage_effects%ROWTYPE; +DECLARE maximum_dispatch_reservation numeric; +DECLARE maximum_run_reservation numeric; +DECLARE exact_cost numeric; +DECLARE run_dispatches bigint; +DECLARE run_pending_dispatches bigint; +DECLARE run_reserved numeric; +DECLARE run_settled numeric; +DECLARE run_request_bytes bigint; +DECLARE run_response_bytes bigint; +DECLARE run_input_tokens bigint; +DECLARE run_output_tokens bigint; +DECLARE request_complete boolean; +DECLARE response_complete boolean; +DECLARE usage_complete boolean; +DECLARE prepare_authority public.audit_events%ROWTYPE; +DECLARE dispatch_authority public.audit_events%ROWTYPE; +DECLARE observation_authority public.audit_events%ROWTYPE; +DECLARE terminal_authority public.audit_events%ROWTYPE; +DECLARE terminal_actor public.actor_profiles%ROWTYPE; +DECLARE prepare_action text; +DECLARE dispatch_action text; +DECLARE observation_action text; +DECLARE terminal_action text; +DECLARE terminal_service text; +DECLARE terminal_resource_type text; +DECLARE terminal_slot jsonb; +DECLARE terminal_effect jsonb; +DECLARE terminal_evidence jsonb; +DECLARE terminal_usage jsonb; +DECLARE terminal_digest varchar; +DECLARE terminal_facts jsonb; +DECLARE terminal_resource_facts jsonb; +DECLARE terminal_resource_digest varchar; +DECLARE phase_actor public.actor_profiles%ROWTYPE; +DECLARE phase_execution jsonb; +DECLARE phase_facts jsonb; +DECLARE phase_resource jsonb; +DECLARE phase_resource_digest varchar; +DECLARE phase_request_evidence jsonb; +DECLARE phase_attempt jsonb; +DECLARE retry_terminal_digest varchar; +BEGIN + IF TG_OP='DELETE' THEN + RAISE EXCEPTION 'checker model call attempts cannot be deleted' USING ERRCODE='55000'; + END IF; + IF NEW.checker_slot_id IS NOT NULL THEN + prepare_action:=CASE WHEN NEW.attempt_number=1 + THEN 'checker.model_call.prepare' + ELSE 'project.model_call.recovery_child.authorize' END; + dispatch_action:='checker.model_call.dispatch'; + observation_action:='checker.model_call.observe'; + terminal_action:='checker.model_call.complete'; + SELECT * INTO slot FROM public.checker_model_usage_slots + WHERE id=NEW.checker_slot_id FOR SHARE; + SELECT * INTO profile FROM public.model_usage_profiles WHERE id=slot.profile_id FOR SHARE; + IF slot.id IS NULL OR profile.id IS NULL OR + NEW.request_sha256<>slot.first_request_sha256 THEN + RAISE EXCEPTION 'invalid checker model call attempt ownership' USING ERRCODE='23514'; + END IF; + SELECT * INTO checker_envelope FROM public.checker_model_usage_envelopes + WHERE id=slot.envelope_id FOR UPDATE; + terminal_service:='workstream.checker.post_submit'; + terminal_resource_type:='checker_model_call'; + terminal_slot:=pg_catalog.jsonb_build_object( + 'project_id',slot.project_id,'assignment_id',slot.assignment_id, + 'submission_id',slot.submission_id,'submission_version',slot.submission_version, + 'requirement_id',slot.requirement_id, + 'registry_entry_digest',slot.registry_entry_digest, + 'configuration_sha256',slot.configuration_sha256, + 'profile_id',slot.profile_id,'profile_version',slot.profile_version, + 'profile_digest',slot.profile_digest,'slot_index',slot.slot_index); + terminal_effect:=pg_catalog.jsonb_build_object( + 'project_id',slot.project_id,'task_id',slot.task_id,'slot_id',slot.id, + 'slot_key',terminal_slot,'attempt_id',NEW.id, + 'internal_call_request_id',NEW.internal_call_request_id); + ELSE + prepare_action:='project.guide_compilation.model.prepare'; + dispatch_action:='project.guide_compilation.model.dispatch'; + observation_action:='project.guide_compilation.model.observe'; + terminal_action:='project.guide_compilation.model.complete'; + SELECT * INTO setup_slot FROM public.setup_model_usage_slots + WHERE id=NEW.setup_slot_id FOR SHARE; + SELECT * INTO profile FROM public.model_usage_profiles + WHERE id=setup_slot.profile_id FOR SHARE; + IF setup_slot.id IS NULL OR profile.id IS NULL OR + NEW.request_sha256<>setup_slot.request_sha256 THEN + RAISE EXCEPTION 'invalid setup model call attempt ownership' USING ERRCODE='23514'; + END IF; + SELECT * INTO setup_envelope FROM public.setup_model_usage_envelopes + WHERE id=setup_slot.envelope_id FOR UPDATE; + terminal_service:='workstream.project.setup'; + terminal_resource_type:='setup_model_usage'; + terminal_slot:=pg_catalog.jsonb_build_object( + 'compilation_attempt_id',setup_slot.compilation_attempt_id, + 'project_id',setup_slot.project_id,'guide_id',setup_slot.guide_id, + 'source_snapshot_id',setup_slot.source_snapshot_id, + 'setup_run_id',setup_slot.setup_run_id, + 'setup_generation',setup_slot.setup_generation, + 'runtime_configuration_hash',setup_slot.runtime_configuration_hash, + 'profile_id',setup_slot.profile_id,'profile_version',setup_slot.profile_version, + 'profile_digest',setup_slot.profile_digest,'phase',setup_slot.phase, + 'call_index',setup_slot.call_index); + terminal_effect:=pg_catalog.jsonb_build_object( + 'project_id',setup_slot.project_id,'slot_id',setup_slot.id, + 'slot_key',terminal_slot,'effect_id',NEW.id, + 'internal_call_request_id',NEW.internal_call_request_id); + END IF; + IF profile.id IS NULL THEN + RAISE EXCEPTION 'invalid checker model call attempt ownership' USING ERRCODE='23514'; + END IF; + SELECT * INTO prepare_authority FROM public.audit_events + WHERE id=NEW.prepared_authorization_evidence_id FOR SHARE; + SELECT * INTO phase_actor FROM public.actor_profiles + WHERE id=prepare_authority.actor_id::uuid FOR SHARE; + IF NEW.checker_slot_id IS NULL THEN + phase_facts:=pg_catalog.jsonb_build_object( + 'slot',terminal_slot,'request_sha256',NEW.request_sha256); + phase_resource:=pg_catalog.jsonb_build_object( + 'resource_type','setup_model_usage', + 'resource_id',setup_slot.compilation_attempt_id, + 'scope_project_id',setup_slot.project_id, + 'action_id',prepare_action,'facts',phase_facts, + 'facts_digest',public.model_usage_json_digest(pg_catalog.jsonb_build_object( + 'action',prepare_action,'facts',phase_facts))); + ELSIF NEW.attempt_number=1 THEN + phase_execution:=NEW.prepared_execution_facts; + phase_facts:=pg_catalog.jsonb_build_object( + 'execution',phase_execution,'slot',terminal_slot, + 'request_sha256',NEW.request_sha256); + phase_resource:=pg_catalog.jsonb_build_object( + 'resource_type','checker_model_call', + 'resource_id',(phase_execution->>'evaluation_request_id')::uuid, + 'scope_project_id',slot.project_id, + 'action_id',prepare_action,'facts',phase_facts, + 'facts_digest',public.model_usage_json_digest(pg_catalog.jsonb_build_object( + 'action',prepare_action,'facts',phase_facts))); + ELSE + SELECT * INTO parent FROM public.model_usage_effects + WHERE id=NEW.parent_attempt_id FOR SHARE; + retry_terminal_digest:=CASE parent.state + WHEN 'known_no_effect' THEN public.model_usage_json_digest( + pg_catalog.jsonb_build_object( + 'state','known_no_effect','proof_id',parent.no_effect_proof_id, + 'proof_digest',parent.no_effect_proof_digest, + 'observed_at', + to_char(parent.no_effect_observed_at AT TIME ZONE 'UTC', + 'YYYY-MM-DD"T"HH24:MI:SS') || + CASE WHEN rtrim(to_char(parent.no_effect_observed_at AT TIME ZONE 'UTC','US'),'0')='' + THEN '' ELSE '.' || + rtrim(to_char(parent.no_effect_observed_at AT TIME ZONE 'UTC','US'),'0') END || + '+00:00')) + WHEN 'known_failed' THEN public.model_usage_json_digest( + pg_catalog.jsonb_build_object( + 'state','known_failed','provider_response_id',parent.provider_response_id, + 'response_content_sha256',parent.response_content_sha256, + 'usage',pg_catalog.jsonb_build_object( + 'input_tokens',parent.input_tokens, + 'cached_input_tokens',parent.cached_input_tokens, + 'output_tokens',parent.output_tokens, + 'reasoning_tokens',parent.reasoning_tokens), + 'settled_nanodollars',parent.settled_nanodollars)) + ELSE NULL END; + phase_facts:=pg_catalog.jsonb_build_object( + 'project_id',slot.project_id,'parent_attempt_id',parent.id, + 'terminal_digest',retry_terminal_digest); + phase_resource:=pg_catalog.jsonb_build_object( + 'resource_type','project_model_administration', + 'resource_id',parent.id,'project_id',slot.project_id, + 'actor_profile_id',prepare_authority.actor_id::uuid, + 'action_id',prepare_action,'facts',phase_facts, + 'facts_digest',public.model_usage_json_digest(pg_catalog.jsonb_build_object( + 'action',prepare_action,'facts',phase_facts))); + END IF; + phase_resource_digest:=public.model_usage_json_digest( + pg_catalog.jsonb_build_object('resource_context',phase_resource)); + IF prepare_authority.id IS NULL OR + phase_actor.id IS NULL OR phase_facts IS NULL OR + (NEW.checker_slot_id IS NOT NULL AND NEW.attempt_number=1 AND + phase_execution IS NULL) OR + (NEW.checker_slot_id IS NOT NULL AND NEW.attempt_number=1 AND + public.model_usage_checker_execution_matches( + phase_execution,slot.project_id,slot.task_id,slot.assignment_id, + slot.submission_id,slot.submission_version) IS NOT TRUE) OR + (NEW.checker_slot_id IS NOT NULL AND NEW.attempt_number>1 AND + (parent.id IS NULL OR retry_terminal_digest IS NULL)) OR + prepare_authority.event_domain IS DISTINCT FROM 'authority' OR + prepare_authority.event_type IS DISTINCT FROM 'SensitiveAuthorizationAllowed' OR + prepare_authority.actor_ref_kind IS DISTINCT FROM 'actor_profile' OR + prepare_authority.action_id IS DISTINCT FROM prepare_action OR + prepare_authority.permission_id IS DISTINCT FROM prepare_action OR + (NEW.attempt_number=1 AND + (prepare_authority.project_id IS NOT NULL OR + prepare_authority.resource_type IS NOT NULL OR + prepare_authority.resource_id IS NOT NULL)) OR + (NEW.attempt_number>1 AND + (prepare_authority.project_id IS DISTINCT FROM slot.project_id OR + prepare_authority.resource_type IS DISTINCT FROM 'project' OR + prepare_authority.resource_id IS DISTINCT FROM slot.project_id::text)) OR + prepare_authority.denial_code IS NOT NULL OR + prepare_authority.after_facts::jsonb IS DISTINCT FROM + pg_catalog.jsonb_build_object( + 'allowed',true,'resource_context_digest',phase_resource_digest) OR + (TG_OP='INSERT' AND NEW.attempt_number=1 AND + (phase_actor.actor_kind IS DISTINCT FROM 'service' OR + phase_actor.status IS DISTINCT FROM 'active' OR + phase_actor.service_identity IS DISTINCT FROM terminal_service)) OR + (TG_OP='INSERT' AND NEW.attempt_number>1 AND + (phase_actor.actor_kind IS DISTINCT FROM 'human' OR + phase_actor.status IS DISTINCT FROM 'active' OR + prepare_authority.matched_grant_id IS NULL)) THEN + RAISE EXCEPTION 'model usage prepare authority is invalid' USING ERRCODE='23514'; + END IF; + maximum_dispatch_reservation := profile.maximum_input_tokens::numeric * + greatest(profile.uncached_input_rate,profile.cached_input_rate)::numeric + + profile.maximum_output_tokens::numeric * profile.output_rate::numeric; + maximum_run_reservation := profile.maximum_total_input_tokens::numeric * + greatest(profile.uncached_input_rate,profile.cached_input_rate)::numeric + + profile.maximum_total_output_tokens::numeric * profile.output_rate::numeric; + request_complete := NEW.request_binding_id IS NOT NULL AND NEW.request_content_id IS NOT NULL AND + NEW.request_put_attempt_id IS NOT NULL AND NEW.request_verification_receipt_id IS NOT NULL AND + NEW.request_content_sha256=NEW.request_sha256 AND + NEW.request_byte_count BETWEEN 1 AND profile.maximum_request_bytes; + response_complete := NEW.response_binding_id IS NOT NULL AND NEW.response_content_id IS NOT NULL AND + NEW.response_put_attempt_id IS NOT NULL AND NEW.response_verification_receipt_id IS NOT NULL AND + NEW.response_content_sha256 ~ '^sha256:[0-9a-f]{64}$' AND + NEW.response_byte_count BETWEEN 1 AND profile.maximum_response_bytes; + usage_complete := NEW.input_tokens IS NOT NULL AND NEW.cached_input_tokens IS NOT NULL AND + NEW.output_tokens IS NOT NULL AND NEW.reasoning_tokens IS NOT NULL AND + NEW.input_tokens BETWEEN 0 AND profile.maximum_input_tokens AND + NEW.cached_input_tokens BETWEEN 0 AND NEW.input_tokens AND + NEW.output_tokens BETWEEN 0 AND profile.maximum_output_tokens AND + NEW.reasoning_tokens BETWEEN 0 AND NEW.output_tokens; + IF usage_complete THEN + exact_cost := (NEW.input_tokens-NEW.cached_input_tokens)::numeric * + profile.uncached_input_rate::numeric + NEW.cached_input_tokens::numeric * + profile.cached_input_rate::numeric + NEW.output_tokens::numeric * + profile.output_rate::numeric; + END IF; + IF NEW.state IN ('completed','known_failed','known_no_effect') THEN + terminal_evidence:=CASE WHEN NEW.state IN ('completed','known_failed') THEN + pg_catalog.jsonb_build_object( + 'binding_id',NEW.response_binding_id,'content_id',NEW.response_content_id, + 'put_attempt_id',NEW.response_put_attempt_id, + 'verification_receipt_id',NEW.response_verification_receipt_id, + 'content_sha256',NEW.response_content_sha256,'byte_count',NEW.response_byte_count, + 'media_type','application/json') ELSE NULL END; + terminal_usage:=CASE WHEN NEW.state IN ('completed','known_failed') THEN + pg_catalog.jsonb_build_object( + 'input_tokens',NEW.input_tokens,'cached_input_tokens',NEW.cached_input_tokens, + 'output_tokens',NEW.output_tokens,'reasoning_tokens',NEW.reasoning_tokens) + ELSE NULL END; + IF NEW.checker_slot_id IS NOT NULL THEN + IF NEW.state IN ('completed','known_failed') THEN + terminal_digest:=public.model_usage_json_digest(pg_catalog.jsonb_build_object( + 'status',NEW.state,'provider_response_id',NEW.provider_response_id, + 'provider_model_snapshot',profile.model_snapshot, + 'response_evidence',terminal_evidence,'usage',terminal_usage)); + END IF; + ELSE + IF NEW.state IN ('completed','known_failed') THEN + terminal_digest:=public.model_usage_json_digest(pg_catalog.jsonb_build_object( + 'status','completed','provider_response_id',NEW.provider_response_id, + 'provider_model_snapshot',profile.model_snapshot, + 'response_evidence',terminal_evidence,'usage',terminal_usage, + 'usable',NEW.state='completed')); + END IF; + END IF; + IF NEW.state='known_no_effect' THEN + terminal_digest:=public.model_usage_json_digest(pg_catalog.jsonb_build_object( + 'state','known_no_effect','proof_id',NEW.no_effect_proof_id, + 'proof_digest',NEW.no_effect_proof_digest, + 'observed_at', + to_char(NEW.no_effect_observed_at AT TIME ZONE 'UTC','YYYY-MM-DD"T"HH24:MI:SS') || + CASE WHEN rtrim(to_char(NEW.no_effect_observed_at AT TIME ZONE 'UTC','US'),'0')='' + THEN '' ELSE '.' || + rtrim(to_char(NEW.no_effect_observed_at AT TIME ZONE 'UTC','US'),'0') END || '+00:00')); + END IF; + terminal_facts:=pg_catalog.jsonb_build_object( + 'effect',terminal_effect,'response_evidence',terminal_evidence, + 'terminal_digest',terminal_digest); + terminal_resource_facts:=CASE WHEN terminal_evidence IS NULL THEN + terminal_facts-'response_evidence' ELSE terminal_facts END; + terminal_resource_digest:=public.model_usage_json_digest( + pg_catalog.jsonb_build_object('resource_context',pg_catalog.jsonb_build_object( + 'resource_type',terminal_resource_type,'resource_id',NEW.id, + 'scope_project_id',coalesce(slot.project_id,setup_slot.project_id), + 'action_id',terminal_action,'facts',terminal_resource_facts, + 'facts_digest',public.model_usage_json_digest(pg_catalog.jsonb_build_object( + 'action',terminal_action,'facts',terminal_facts))))); + END IF; + IF TG_OP='INSERT' THEN + IF NEW.attempt_number=1 THEN + IF NEW.parent_attempt_id IS NOT NULL OR + (NEW.checker_slot_id IS NOT NULL AND NEW.prepared_execution_facts IS NULL) OR + (NEW.setup_slot_id IS NOT NULL AND NEW.prepared_execution_facts IS NOT NULL) THEN + RAISE EXCEPTION 'initial checker model attempt cannot have a parent' USING ERRCODE='23514'; + END IF; + ELSE + SELECT * INTO parent FROM public.model_usage_effects + WHERE id=NEW.parent_attempt_id FOR SHARE; + IF parent.id IS NULL OR + parent.checker_slot_id IS DISTINCT FROM NEW.checker_slot_id OR + parent.setup_slot_id IS DISTINCT FROM NEW.setup_slot_id OR + parent.attempt_number+1<>NEW.attempt_number OR + parent.attempt_number<>(SELECT max(attempt_number) + FROM public.model_usage_effects WHERE + checker_slot_id IS NOT DISTINCT FROM NEW.checker_slot_id AND + setup_slot_id IS NOT DISTINCT FROM NEW.setup_slot_id) OR + NEW.prepared_execution_facts IS NOT NULL OR + NOT (parent.state='known_no_effect' OR + (parent.state='known_failed' AND parent.settled_nanodollars IS NOT NULL AND + parent.settlement_authorization_evidence_id IS NOT NULL)) THEN + RAISE EXCEPTION 'invalid checker model child attempt' USING ERRCODE='23514'; + END IF; + END IF; + ELSIF ROW(NEW.checker_slot_id,NEW.setup_slot_id,NEW.attempt_number, + NEW.internal_call_request_id, + NEW.parent_attempt_id,NEW.request_sha256, + NEW.prepared_execution_facts, + NEW.prepared_authorization_evidence_id,NEW.prepared_at) + IS DISTINCT FROM + ROW(OLD.checker_slot_id,OLD.setup_slot_id,OLD.attempt_number, + OLD.internal_call_request_id, + OLD.parent_attempt_id,OLD.request_sha256, + OLD.prepared_execution_facts, + OLD.prepared_authorization_evidence_id,OLD.prepared_at) THEN + RAISE EXCEPTION 'checker model call attempt identity is immutable' USING ERRCODE='55000'; + END IF; + IF TG_OP='UPDATE' AND OLD.state<>'prepared' AND + ROW(NEW.request_binding_id,NEW.request_content_id,NEW.request_put_attempt_id, + NEW.request_verification_receipt_id,NEW.request_content_sha256, + NEW.request_byte_count,NEW.budget_configuration_id, + NEW.budget_configuration_version,NEW.budget_configuration_digest, + NEW.reserved_nanodollars,NEW.dispatch_execution_facts, + NEW.dispatch_authorization_evidence_id, + NEW.dispatched_at) + IS DISTINCT FROM + ROW(OLD.request_binding_id,OLD.request_content_id,OLD.request_put_attempt_id, + OLD.request_verification_receipt_id,OLD.request_content_sha256, + OLD.request_byte_count,OLD.budget_configuration_id, + OLD.budget_configuration_version,OLD.budget_configuration_digest, + OLD.reserved_nanodollars,OLD.dispatch_execution_facts, + OLD.dispatch_authorization_evidence_id, + OLD.dispatched_at) THEN + RAISE EXCEPTION 'checker model dispatch facts are immutable' USING ERRCODE='55000'; + END IF; + IF NEW.state='prepared' THEN + IF TG_OP='UPDATE' OR NEW.request_binding_id IS NOT NULL OR + NEW.request_content_id IS NOT NULL OR NEW.request_put_attempt_id IS NOT NULL OR + NEW.request_verification_receipt_id IS NOT NULL OR + NEW.request_content_sha256 IS NOT NULL OR NEW.request_byte_count IS NOT NULL OR + NEW.budget_configuration_id IS NOT NULL OR + NEW.budget_configuration_version IS NOT NULL OR + NEW.budget_configuration_digest IS NOT NULL OR + NEW.reserved_nanodollars IS NOT NULL OR + NEW.dispatch_execution_facts IS NOT NULL OR + NEW.dispatch_authorization_evidence_id IS NOT NULL OR + NEW.dispatched_at IS NOT NULL OR NEW.provider_response_id IS NOT NULL OR + NEW.response_binding_id IS NOT NULL OR NEW.response_content_id IS NOT NULL OR + NEW.response_put_attempt_id IS NOT NULL OR + NEW.response_verification_receipt_id IS NOT NULL OR + NEW.response_content_sha256 IS NOT NULL OR NEW.response_byte_count IS NOT NULL OR + NEW.input_tokens IS NOT NULL OR NEW.cached_input_tokens IS NOT NULL OR + NEW.output_tokens IS NOT NULL OR NEW.reasoning_tokens IS NOT NULL OR + NEW.settled_nanodollars IS NOT NULL OR + NEW.settlement_authorization_evidence_id IS NOT NULL OR NEW.settled_at IS NOT NULL OR + NEW.no_effect_proof_id IS NOT NULL OR NEW.no_effect_proof_digest IS NOT NULL OR + NEW.no_effect_observed_at IS NOT NULL OR + NEW.terminal_authorization_evidence_id IS NOT NULL OR + NEW.uncertainty_digest IS NOT NULL OR + NEW.observation_authorization_evidence_id IS NOT NULL OR NEW.terminal_at IS NOT NULL THEN + RAISE EXCEPTION 'invalid prepared checker model call' USING ERRCODE='23514'; + END IF; + ELSE + SELECT * INTO dispatch_authority FROM public.audit_events + WHERE id=NEW.dispatch_authorization_evidence_id FOR SHARE; + SELECT * INTO budget FROM public.model_usage_budget_configurations WHERE + id=NEW.budget_configuration_id AND + configuration_version=NEW.budget_configuration_version AND + configuration_digest=NEW.budget_configuration_digest FOR SHARE; + SELECT * INTO account FROM public.model_usage_budget_accounts + WHERE project_id=coalesce(slot.project_id,setup_slot.project_id) FOR SHARE; + SELECT * INTO phase_actor FROM public.actor_profiles + WHERE id=dispatch_authority.actor_id::uuid FOR SHARE; + phase_request_evidence:=pg_catalog.jsonb_build_object( + 'binding_id',NEW.request_binding_id,'content_id',NEW.request_content_id, + 'put_attempt_id',NEW.request_put_attempt_id, + 'verification_receipt_id',NEW.request_verification_receipt_id, + 'content_sha256',NEW.request_content_sha256, + 'byte_count',NEW.request_byte_count,'media_type','application/json'); + IF NEW.checker_slot_id IS NOT NULL THEN + phase_attempt:=pg_catalog.jsonb_build_object( + 'slot_id',slot.id,'slot_key',terminal_slot,'attempt_id',NEW.id, + 'attempt_number',NEW.attempt_number, + 'internal_call_request_id',NEW.internal_call_request_id, + 'request_sha256',NEW.request_sha256,'state','prepared','replayed',false); + phase_execution:=NEW.dispatch_execution_facts; + phase_facts:=pg_catalog.jsonb_build_object( + 'execution',phase_execution,'attempt',phase_attempt, + 'request_evidence',phase_request_evidence); + ELSE + phase_attempt:=pg_catalog.jsonb_build_object( + 'envelope_id',setup_envelope.id,'slot_id',setup_slot.id, + 'slot_key',terminal_slot,'effect_id',NEW.id, + 'attempt_number',NEW.attempt_number, + 'internal_call_request_id',NEW.internal_call_request_id, + 'request_sha256',NEW.request_sha256,'state','prepared','replayed',false); + phase_facts:=pg_catalog.jsonb_build_object( + 'prepared',phase_attempt,'request_evidence',phase_request_evidence); + END IF; + phase_resource:=pg_catalog.jsonb_build_object( + 'resource_type',terminal_resource_type,'resource_id',NEW.id, + 'scope_project_id',coalesce(slot.project_id,setup_slot.project_id), + 'action_id',dispatch_action,'facts',phase_facts, + 'facts_digest',public.model_usage_json_digest(pg_catalog.jsonb_build_object( + 'action',dispatch_action,'facts',phase_facts))); + phase_resource_digest:=public.model_usage_json_digest( + pg_catalog.jsonb_build_object('resource_context',phase_resource)); + IF dispatch_authority.id IS NULL OR + phase_actor.id IS NULL OR phase_facts IS NULL OR + (NEW.checker_slot_id IS NOT NULL AND phase_execution IS NULL) OR + (NEW.checker_slot_id IS NOT NULL AND + public.model_usage_checker_execution_matches( + phase_execution,slot.project_id,slot.task_id,slot.assignment_id, + slot.submission_id,slot.submission_version) IS NOT TRUE) OR + (NEW.setup_slot_id IS NOT NULL AND NEW.dispatch_execution_facts IS NOT NULL) OR + dispatch_authority.event_domain IS DISTINCT FROM 'authority' OR + dispatch_authority.event_type IS DISTINCT FROM 'SensitiveAuthorizationAllowed' OR + dispatch_authority.actor_ref_kind IS DISTINCT FROM 'actor_profile' OR + dispatch_authority.action_id IS DISTINCT FROM dispatch_action OR + dispatch_authority.permission_id IS DISTINCT FROM dispatch_action OR + dispatch_authority.project_id IS NOT NULL OR + dispatch_authority.resource_type IS NOT NULL OR + dispatch_authority.resource_id IS NOT NULL OR + dispatch_authority.denial_code IS NOT NULL OR + dispatch_authority.after_facts::jsonb IS DISTINCT FROM + pg_catalog.jsonb_build_object( + 'allowed',true,'resource_context_digest',phase_resource_digest) OR + (TG_OP='UPDATE' AND OLD.state='prepared' AND + (phase_actor.actor_kind IS DISTINCT FROM 'service' OR + phase_actor.status IS DISTINCT FROM 'active' OR + phase_actor.service_identity IS DISTINCT FROM terminal_service)) OR + NOT request_complete OR budget.id IS NULL OR + budget.project_id<>coalesce(slot.project_id,setup_slot.project_id) OR + (TG_OP='UPDATE' AND OLD.state='prepared' AND + account.current_configuration_id IS DISTINCT FROM budget.id) OR + NEW.reserved_nanodollars IS NULL OR + NEW.reserved_nanodollars::numeric<>maximum_dispatch_reservation OR + NEW.dispatch_authorization_evidence_id IS NULL OR NEW.dispatched_at IS NULL THEN + RAISE EXCEPTION 'invalid dispatched checker model call' USING ERRCODE='23514'; + END IF; + END IF; + SELECT count(*) FILTER (WHERE effect.state<>'prepared'), + count(*) FILTER (WHERE effect.state IN ('dispatching','uncertain')), + coalesce(sum(CASE WHEN effect.state IN ('dispatching','uncertain') + THEN effect.reserved_nanodollars ELSE 0 END),0), + coalesce(sum(effect.settled_nanodollars),0), + coalesce(sum(effect.request_byte_count),0), + coalesce(sum(effect.response_byte_count),0), + coalesce(sum(effect.input_tokens),0), + coalesce(sum(effect.output_tokens),0) + INTO run_dispatches,run_pending_dispatches,run_reserved,run_settled,run_request_bytes, + run_response_bytes,run_input_tokens,run_output_tokens + FROM public.model_usage_effects effect + LEFT JOIN public.checker_model_usage_slots checker_child + ON checker_child.id=effect.checker_slot_id + LEFT JOIN public.setup_model_usage_slots setup_child + ON setup_child.id=effect.setup_slot_id + WHERE effect.id<>NEW.id AND ( + (checker_envelope.id IS NOT NULL AND + checker_child.envelope_id=checker_envelope.id) OR + (setup_envelope.id IS NOT NULL AND setup_child.envelope_id=setup_envelope.id)); + IF NEW.state<>'prepared' THEN + run_dispatches:=run_dispatches+1; + run_request_bytes:=run_request_bytes+coalesce(NEW.request_byte_count,0); + run_response_bytes:=run_response_bytes+coalesce(NEW.response_byte_count,0); + run_input_tokens:=run_input_tokens+coalesce(NEW.input_tokens,0); + run_output_tokens:=run_output_tokens+coalesce(NEW.output_tokens,0); + IF NEW.state IN ('dispatching','uncertain') THEN + run_pending_dispatches:=run_pending_dispatches+1; + run_reserved:=run_reserved+NEW.reserved_nanodollars; + END IF; + run_settled:=run_settled+coalesce(NEW.settled_nanodollars,0); + END IF; + IF run_dispatches>profile.maximum_calls OR + run_reserved+run_settled>maximum_run_reservation OR + run_request_bytes>profile.maximum_total_request_bytes OR + run_response_bytes+run_pending_dispatches*profile.maximum_response_bytes> + profile.maximum_total_response_bytes OR + run_input_tokens+run_pending_dispatches*profile.maximum_input_tokens> + profile.maximum_total_input_tokens OR + run_output_tokens+run_pending_dispatches*profile.maximum_output_tokens> + profile.maximum_total_output_tokens THEN + RAISE EXCEPTION 'model usage run envelope exceeded' USING ERRCODE='23514'; + END IF; + IF NEW.state='dispatching' THEN + IF NEW.provider_response_id IS NOT NULL OR NEW.uncertainty_digest IS NOT NULL OR + NEW.observation_authorization_evidence_id IS NOT NULL OR + NEW.response_binding_id IS NOT NULL OR NEW.response_content_id IS NOT NULL OR + NEW.response_put_attempt_id IS NOT NULL OR + NEW.response_verification_receipt_id IS NOT NULL OR + NEW.response_content_sha256 IS NOT NULL OR NEW.response_byte_count IS NOT NULL OR + NEW.input_tokens IS NOT NULL OR NEW.cached_input_tokens IS NOT NULL OR + NEW.output_tokens IS NOT NULL OR NEW.reasoning_tokens IS NOT NULL OR + NEW.settled_nanodollars IS NOT NULL OR + NEW.settlement_authorization_evidence_id IS NOT NULL OR NEW.settled_at IS NOT NULL OR + NEW.no_effect_proof_id IS NOT NULL OR NEW.no_effect_proof_digest IS NOT NULL OR + NEW.no_effect_observed_at IS NOT NULL OR + NEW.terminal_authorization_evidence_id IS NOT NULL OR NEW.terminal_at IS NOT NULL THEN + RAISE EXCEPTION 'invalid dispatching checker model call' USING ERRCODE='23514'; + END IF; + ELSIF NEW.state='uncertain' THEN + SELECT * INTO observation_authority FROM public.audit_events + WHERE id=NEW.observation_authorization_evidence_id FOR SHARE; + SELECT * INTO phase_actor FROM public.actor_profiles + WHERE id=observation_authority.actor_id::uuid FOR SHARE; + phase_facts:=pg_catalog.jsonb_build_object( + 'effect',terminal_effect,'provider_response_id',NEW.provider_response_id, + 'observation_digest',NEW.uncertainty_digest); + phase_resource:=pg_catalog.jsonb_build_object( + 'resource_type',terminal_resource_type,'resource_id',NEW.id, + 'scope_project_id',coalesce(slot.project_id,setup_slot.project_id), + 'action_id',observation_action, + 'facts',pg_catalog.jsonb_strip_nulls(phase_facts), + 'facts_digest',public.model_usage_json_digest(pg_catalog.jsonb_build_object( + 'action',observation_action,'facts',phase_facts))); + phase_resource_digest:=public.model_usage_json_digest( + pg_catalog.jsonb_build_object('resource_context',phase_resource)); + IF observation_authority.id IS NULL OR + phase_actor.id IS NULL OR + observation_authority.event_domain IS DISTINCT FROM 'authority' OR + observation_authority.event_type IS DISTINCT FROM 'SensitiveAuthorizationAllowed' OR + observation_authority.actor_ref_kind IS DISTINCT FROM 'actor_profile' OR + observation_authority.action_id IS DISTINCT FROM observation_action OR + observation_authority.permission_id IS DISTINCT FROM observation_action OR + observation_authority.project_id IS NOT NULL OR + observation_authority.resource_type IS NOT NULL OR + observation_authority.resource_id IS NOT NULL OR + observation_authority.denial_code IS NOT NULL OR + observation_authority.after_facts::jsonb IS DISTINCT FROM + pg_catalog.jsonb_build_object( + 'allowed',true,'resource_context_digest',phase_resource_digest) OR + (TG_OP='UPDATE' AND OLD.state='dispatching' AND + (phase_actor.actor_kind IS DISTINCT FROM 'service' OR + phase_actor.status IS DISTINCT FROM 'active' OR + phase_actor.service_identity IS DISTINCT FROM terminal_service)) OR + NEW.uncertainty_digest !~ '^sha256:[0-9a-f]{64}$' OR + NEW.observation_authorization_evidence_id IS NULL OR + NEW.response_binding_id IS NOT NULL OR NEW.response_content_id IS NOT NULL OR + NEW.response_put_attempt_id IS NOT NULL OR + NEW.response_verification_receipt_id IS NOT NULL OR + NEW.response_content_sha256 IS NOT NULL OR NEW.response_byte_count IS NOT NULL OR + NEW.input_tokens IS NOT NULL OR NEW.cached_input_tokens IS NOT NULL OR + NEW.output_tokens IS NOT NULL OR NEW.reasoning_tokens IS NOT NULL OR + NEW.settled_nanodollars IS NOT NULL OR + NEW.settlement_authorization_evidence_id IS NOT NULL OR NEW.settled_at IS NOT NULL OR + NEW.no_effect_proof_id IS NOT NULL OR NEW.no_effect_proof_digest IS NOT NULL OR + NEW.no_effect_observed_at IS NOT NULL OR + NEW.terminal_authorization_evidence_id IS NOT NULL OR NEW.terminal_at IS NOT NULL THEN + RAISE EXCEPTION 'invalid uncertain checker model call' USING ERRCODE='23514'; + END IF; + ELSIF NEW.state='completed' THEN + SELECT * INTO terminal_authority FROM public.audit_events + WHERE id=NEW.terminal_authorization_evidence_id FOR SHARE; + SELECT * INTO terminal_actor FROM public.actor_profiles + WHERE id=terminal_authority.actor_id::uuid FOR SHARE; + IF terminal_authority.id IS NULL OR terminal_actor.id IS NULL OR + terminal_authority.event_domain IS DISTINCT FROM 'authority' OR + terminal_authority.event_type IS DISTINCT FROM 'SensitiveAuthorizationAllowed' OR + terminal_authority.actor_ref_kind IS DISTINCT FROM 'actor_profile' OR + terminal_authority.action_id IS DISTINCT FROM terminal_action OR + terminal_authority.permission_id IS DISTINCT FROM terminal_action OR + terminal_authority.project_id IS NOT NULL OR + terminal_authority.resource_type IS NOT NULL OR + terminal_authority.resource_id IS NOT NULL OR + terminal_authority.denial_code IS NOT NULL OR + terminal_authority.after_facts::jsonb IS DISTINCT FROM + pg_catalog.jsonb_build_object( + 'allowed',true,'resource_context_digest',terminal_resource_digest) OR + terminal_actor.actor_kind IS DISTINCT FROM 'service' OR + terminal_actor.status IS DISTINCT FROM 'active' OR + terminal_actor.service_identity IS DISTINCT FROM terminal_service OR + NEW.provider_response_id IS NULL OR NOT response_complete OR NOT usage_complete OR + NEW.settled_nanodollars IS NULL OR NEW.settled_nanodollars::numeric<>exact_cost OR + NEW.terminal_authorization_evidence_id IS NULL OR NEW.terminal_at IS NULL OR + NEW.no_effect_proof_id IS NOT NULL OR NEW.no_effect_proof_digest IS NOT NULL OR + NEW.no_effect_observed_at IS NOT NULL OR + NEW.settlement_authorization_evidence_id IS NOT NULL OR NEW.settled_at IS NOT NULL THEN + RAISE EXCEPTION 'invalid completed checker model call' USING ERRCODE='23514'; + END IF; + ELSIF NEW.state='known_failed' THEN + SELECT * INTO terminal_authority FROM public.audit_events + WHERE id=NEW.terminal_authorization_evidence_id FOR SHARE; + SELECT * INTO terminal_actor FROM public.actor_profiles + WHERE id=terminal_authority.actor_id::uuid FOR SHARE; + IF terminal_authority.id IS NULL OR terminal_actor.id IS NULL OR + terminal_authority.event_domain IS DISTINCT FROM 'authority' OR + terminal_authority.event_type IS DISTINCT FROM 'SensitiveAuthorizationAllowed' OR + terminal_authority.actor_ref_kind IS DISTINCT FROM 'actor_profile' OR + terminal_authority.action_id IS DISTINCT FROM terminal_action OR + terminal_authority.permission_id IS DISTINCT FROM terminal_action OR + terminal_authority.project_id IS NOT NULL OR + terminal_authority.resource_type IS NOT NULL OR + terminal_authority.resource_id IS NOT NULL OR + terminal_authority.denial_code IS NOT NULL OR + terminal_authority.after_facts::jsonb IS DISTINCT FROM + pg_catalog.jsonb_build_object( + 'allowed',true,'resource_context_digest',terminal_resource_digest) OR + terminal_actor.actor_kind IS DISTINCT FROM 'service' OR + terminal_actor.status IS DISTINCT FROM 'active' OR + terminal_actor.service_identity IS DISTINCT FROM terminal_service OR + NEW.provider_response_id IS NULL OR NOT response_complete OR + NEW.terminal_authorization_evidence_id IS NULL OR NEW.terminal_at IS NULL OR + NEW.no_effect_proof_id IS NOT NULL OR + NOT usage_complete OR NEW.settled_nanodollars IS NULL OR + NEW.settled_nanodollars::numeric<>exact_cost OR + NEW.settlement_authorization_evidence_id IS DISTINCT FROM + NEW.terminal_authorization_evidence_id OR NEW.settled_at IS NULL THEN + RAISE EXCEPTION 'invalid known-failed checker model call' USING ERRCODE='23514'; + END IF; + ELSIF NEW.state='known_no_effect' THEN + SELECT * INTO terminal_authority FROM public.audit_events + WHERE id=NEW.terminal_authorization_evidence_id FOR SHARE; + SELECT * INTO terminal_actor FROM public.actor_profiles + WHERE id=terminal_authority.actor_id::uuid FOR SHARE; + IF terminal_authority.id IS NULL OR terminal_actor.id IS NULL OR + terminal_authority.event_domain IS DISTINCT FROM 'authority' OR + terminal_authority.event_type IS DISTINCT FROM 'SensitiveAuthorizationAllowed' OR + terminal_authority.actor_ref_kind IS DISTINCT FROM 'actor_profile' OR + terminal_authority.action_id IS DISTINCT FROM terminal_action OR + terminal_authority.permission_id IS DISTINCT FROM terminal_action OR + terminal_authority.project_id IS NOT NULL OR + terminal_authority.resource_type IS NOT NULL OR + terminal_authority.resource_id IS NOT NULL OR + terminal_authority.denial_code IS NOT NULL OR + terminal_authority.after_facts::jsonb IS DISTINCT FROM + pg_catalog.jsonb_build_object( + 'allowed',true,'resource_context_digest',terminal_resource_digest) OR + terminal_actor.actor_kind IS DISTINCT FROM 'service' OR + terminal_actor.status IS DISTINCT FROM 'active' OR + terminal_actor.service_identity IS DISTINCT FROM terminal_service OR + NEW.provider_response_id IS NOT NULL OR NEW.response_binding_id IS NOT NULL OR + NEW.response_content_id IS NOT NULL OR NEW.response_put_attempt_id IS NOT NULL OR + NEW.response_verification_receipt_id IS NOT NULL OR + NEW.response_content_sha256 IS NOT NULL OR NEW.response_byte_count IS NOT NULL OR + NEW.input_tokens IS NOT NULL OR NEW.cached_input_tokens IS NOT NULL OR + NEW.output_tokens IS NOT NULL OR NEW.reasoning_tokens IS NOT NULL OR + NEW.settled_nanodollars IS NOT NULL OR + NEW.settlement_authorization_evidence_id IS NOT NULL OR NEW.settled_at IS NOT NULL OR + NEW.no_effect_proof_id IS NULL OR + NEW.no_effect_proof_digest !~ '^sha256:[0-9a-f]{64}$' OR + NEW.no_effect_observed_at IS NULL OR + NEW.terminal_authorization_evidence_id IS NULL OR NEW.terminal_at IS NULL THEN + RAISE EXCEPTION 'invalid no-effect checker model call' USING ERRCODE='23514'; + END IF; + END IF; + IF TG_OP='UPDATE' AND OLD.state='uncertain' AND + (NEW.uncertainty_digest IS DISTINCT FROM OLD.uncertainty_digest OR + NEW.observation_authorization_evidence_id IS DISTINCT FROM + OLD.observation_authorization_evidence_id OR + (OLD.provider_response_id IS NOT NULL AND + NEW.provider_response_id IS DISTINCT FROM OLD.provider_response_id)) THEN + RAISE EXCEPTION 'checker model uncertainty facts are immutable' USING ERRCODE='55000'; + END IF; + IF TG_OP='UPDATE' AND OLD.state='known_failed' AND + ROW(NEW.provider_response_id,NEW.response_binding_id,NEW.response_content_id, + NEW.response_put_attempt_id,NEW.response_verification_receipt_id, + NEW.response_content_sha256,NEW.response_byte_count, + NEW.no_effect_proof_id,NEW.no_effect_proof_digest,NEW.no_effect_observed_at, + NEW.terminal_authorization_evidence_id,NEW.terminal_at, + NEW.uncertainty_digest,NEW.observation_authorization_evidence_id) + IS DISTINCT FROM + ROW(OLD.provider_response_id,OLD.response_binding_id,OLD.response_content_id, + OLD.response_put_attempt_id,OLD.response_verification_receipt_id, + OLD.response_content_sha256,OLD.response_byte_count, + OLD.no_effect_proof_id,OLD.no_effect_proof_digest,OLD.no_effect_observed_at, + OLD.terminal_authorization_evidence_id,OLD.terminal_at, + OLD.uncertainty_digest,OLD.observation_authorization_evidence_id) THEN + RAISE EXCEPTION 'checker model terminal facts are immutable' USING ERRCODE='55000'; + END IF; + IF TG_OP='UPDATE' AND NOT ( + (OLD.state='prepared' AND NEW.state='dispatching') OR + (OLD.state='dispatching' AND NEW.state IN + ('uncertain','completed','known_failed','known_no_effect')) OR + (OLD.state='uncertain' AND NEW.state IN ('completed','known_failed','known_no_effect')) + ) THEN + RAISE EXCEPTION 'invalid checker model call state transition' USING ERRCODE='55000'; + END IF; + RETURN NEW; +END $$ +""", + r""" +CREATE FUNCTION public.guard_checker_model_recovery() RETURNS trigger +LANGUAGE plpgsql SET search_path=pg_catalog,public,pg_temp AS $$ +DECLARE effect public.model_usage_effects%ROWTYPE; +DECLARE slot public.checker_model_usage_slots%ROWTYPE; +DECLARE run public.checker_runs%ROWTYPE; +DECLARE authority public.audit_events%ROWTYPE; +DECLARE actor public.actor_profiles%ROWTYPE; +DECLARE task_project uuid; +DECLARE assignment_task uuid; +DECLARE assignment_project uuid; +DECLARE submission_task uuid; +DECLARE expected_slot jsonb; +DECLARE expected_effect jsonb; +DECLARE expected_digest varchar; +BEGIN + IF TG_OP<>'INSERT' THEN + RAISE EXCEPTION 'checker model recovery attachments are immutable' USING ERRCODE='55000'; + END IF; + SELECT * INTO effect FROM public.model_usage_effects + WHERE id=NEW.attempt_id FOR SHARE; + SELECT * INTO slot FROM public.checker_model_usage_slots + WHERE id=effect.checker_slot_id FOR SHARE; + SELECT * INTO run FROM public.checker_runs + WHERE id=NEW.checker_run_id FOR SHARE; + SELECT * INTO authority FROM public.audit_events + WHERE id=NEW.authorization_evidence_id FOR SHARE; + SELECT * INTO actor FROM public.actor_profiles + WHERE id=authority.actor_id::uuid FOR SHARE; + SELECT project_id INTO task_project FROM public.workstream_tasks + WHERE id=run.task_id FOR SHARE; + SELECT task_id,project_id INTO assignment_task,assignment_project + FROM public.task_assignments WHERE id=slot.assignment_id FOR SHARE; + SELECT task_id INTO submission_task FROM public.submissions + WHERE id=slot.submission_id AND version=slot.submission_version FOR SHARE; + + expected_slot:=jsonb_build_object( + 'project_id',slot.project_id,'assignment_id',slot.assignment_id, + 'submission_id',slot.submission_id,'submission_version',slot.submission_version, + 'requirement_id',slot.requirement_id, + 'registry_entry_digest',slot.registry_entry_digest, + 'configuration_sha256',slot.configuration_sha256, + 'profile_id',slot.profile_id,'profile_version',slot.profile_version, + 'profile_digest',slot.profile_digest,'slot_index',slot.slot_index); + expected_effect:=jsonb_build_object( + 'project_id',slot.project_id,'task_id',run.task_id,'slot_id',slot.id, + 'slot_key',expected_slot,'attempt_id',effect.id, + 'internal_call_request_id',effect.internal_call_request_id); + expected_digest:=public.model_usage_json_digest(jsonb_build_object( + 'resource_context',jsonb_build_object( + 'resource_type','checker_model_call','resource_id',effect.id, + 'scope_project_id',slot.project_id,'action_id','checker.model_call.recover', + 'facts',jsonb_build_object( + 'execution',NEW.execution_facts,'effect',NEW.effect_facts), + 'facts_digest',public.model_usage_json_digest(jsonb_build_object( + 'action','checker.model_call.recover','facts',jsonb_build_object( + 'execution',NEW.execution_facts,'effect',NEW.effect_facts)))))); + + IF jsonb_typeof(NEW.execution_facts)<>'object' OR + (SELECT count(*) FROM jsonb_object_keys(NEW.execution_facts))<>11 OR + NOT NEW.execution_facts ?& ARRAY[ + 'project_id','task_id','assignment_id','submission_id','submission_version', + 'evaluation_request_id','evaluation_generation','checker_run_id', + 'worker_lease_id','worker_lease_generation','worker_lease_expires_at'] OR + effect.id IS NULL OR slot.id IS NULL OR run.id IS NULL OR + effect.setup_slot_id IS NOT NULL OR + effect.state NOT IN ('dispatching','uncertain','completed') OR + effect.provider_response_id IS NULL OR + NEW.effect_facts IS DISTINCT FROM expected_effect OR + NEW.attempt_id IS DISTINCT FROM (NEW.effect_facts->>'attempt_id')::uuid OR + NEW.checker_run_id IS DISTINCT FROM (NEW.execution_facts->>'checker_run_id')::uuid OR + NEW.worker_lease_id IS DISTINCT FROM (NEW.execution_facts->>'worker_lease_id')::uuid OR + NEW.worker_lease_generation IS DISTINCT FROM + (NEW.execution_facts->>'worker_lease_generation')::integer OR + (NEW.execution_facts->>'project_id')::uuid IS DISTINCT FROM slot.project_id OR + (NEW.execution_facts->>'task_id')::uuid IS DISTINCT FROM run.task_id OR + (NEW.execution_facts->>'assignment_id')::uuid IS DISTINCT FROM slot.assignment_id OR + (NEW.execution_facts->>'submission_id')::uuid IS DISTINCT FROM slot.submission_id OR + (NEW.execution_facts->>'submission_version')::integer IS DISTINCT FROM + slot.submission_version OR + (NEW.execution_facts->>'evaluation_request_id')::uuid IS DISTINCT FROM + run.evaluation_request_id OR + (NEW.execution_facts->>'evaluation_generation')::integer IS DISTINCT FROM + run.evaluation_generation OR + (NEW.execution_facts->>'worker_lease_expires_at')::timestamptz IS DISTINCT FROM + run.worker_lease_expires_at OR + run.project_id IS DISTINCT FROM slot.project_id OR + task_project IS DISTINCT FROM slot.project_id OR + assignment_task IS DISTINCT FROM run.task_id OR + assignment_project IS DISTINCT FROM slot.project_id OR + submission_task IS DISTINCT FROM run.task_id OR + (run.request_json::jsonb->>'assignment_id')::uuid IS DISTINCT FROM slot.assignment_id OR + run.submission_id IS DISTINCT FROM slot.submission_id OR + run.submission_version IS DISTINCT FROM slot.submission_version OR + run.worker_lease_id IS DISTINCT FROM NEW.worker_lease_id OR + run.worker_lease_generation IS DISTINCT FROM NEW.worker_lease_generation OR + run.status IS DISTINCT FROM 'running' OR run.phase IS DISTINCT FROM 'post_submission' THEN + RAISE EXCEPTION 'checker model recovery ancestry is invalid' USING ERRCODE='23514'; + END IF; + IF authority.id IS NULL OR actor.id IS NULL OR + authority.event_domain IS DISTINCT FROM 'authority' OR + authority.event_type IS DISTINCT FROM 'SensitiveAuthorizationAllowed' OR + authority.actor_ref_kind IS DISTINCT FROM 'actor_profile' OR + authority.action_id IS DISTINCT FROM 'checker.model_call.recover' OR + authority.permission_id IS DISTINCT FROM 'checker.model_call.recover' OR + authority.project_id IS NOT NULL OR + authority.resource_type IS NOT NULL OR authority.resource_id IS NOT NULL OR + authority.denial_code IS NOT NULL OR + authority.after_facts::jsonb IS DISTINCT FROM jsonb_build_object( + 'allowed',true,'resource_context_digest',expected_digest) OR + actor.actor_kind IS DISTINCT FROM 'service' OR actor.status IS DISTINCT FROM 'active' OR + actor.service_identity IS DISTINCT FROM 'workstream.checker.post_submit' THEN + RAISE EXCEPTION 'checker model recovery authority is invalid' USING ERRCODE='23514'; + END IF; + RETURN NEW; +END $$ +""", + r""" +CREATE FUNCTION public.reconcile_model_usage_budget() RETURNS trigger +LANGUAGE plpgsql SET search_path=pg_catalog,public,pg_temp AS $$ +DECLARE target_project uuid; +DECLARE account public.model_usage_budget_accounts%ROWTYPE; +DECLARE calculated_reserved numeric; +DECLARE calculated_settled numeric; +BEGIN + IF TG_TABLE_NAME='model_usage_budget_accounts' THEN + target_project:=coalesce(NEW.project_id,OLD.project_id); + ELSE + IF coalesce(NEW.checker_slot_id,OLD.checker_slot_id) IS NOT NULL THEN + SELECT project_id INTO target_project FROM public.checker_model_usage_slots + WHERE id=coalesce(NEW.checker_slot_id,OLD.checker_slot_id); + ELSE + SELECT project_id INTO target_project FROM public.setup_model_usage_slots + WHERE id=coalesce(NEW.setup_slot_id,OLD.setup_slot_id); + END IF; + END IF; + SELECT * INTO account FROM public.model_usage_budget_accounts + WHERE project_id=target_project; + SELECT coalesce(sum(CASE WHEN + a.state IN ('dispatching','uncertain') + THEN a.reserved_nanodollars ELSE 0 END),0), + coalesce(sum(CASE WHEN a.state='completed' OR + (a.state='known_failed' AND a.settled_nanodollars IS NOT NULL) + THEN a.settled_nanodollars ELSE 0 END),0) + INTO calculated_reserved,calculated_settled + FROM public.model_usage_effects a + LEFT JOIN public.checker_model_usage_slots checker_slot + ON checker_slot.id=a.checker_slot_id + LEFT JOIN public.setup_model_usage_slots setup_slot + ON setup_slot.id=a.setup_slot_id + WHERE coalesce(checker_slot.project_id,setup_slot.project_id)=target_project; + IF account.project_id IS NULL OR + account.lifetime_reserved_nanodollars::numeric<>calculated_reserved OR + account.lifetime_settled_nanodollars::numeric<>calculated_settled THEN + RAISE EXCEPTION 'checker model budget conservation failed' USING ERRCODE='23514'; + END IF; + RETURN NULL; +END $$ +""", + r""" +CREATE FUNCTION public.guard_model_usage_no_truncate() RETURNS trigger +LANGUAGE plpgsql AS $$ BEGIN + RAISE EXCEPTION 'checker model ledger cannot be truncated' USING ERRCODE='55000'; +END $$ +""", +) + + +_TRIGGERS = ( + "CREATE TRIGGER model_usage_profiles_guard BEFORE INSERT OR UPDATE OR DELETE " + "ON public.model_usage_profiles FOR EACH ROW " + "EXECUTE FUNCTION public.guard_model_usage_profile()", + "CREATE TRIGGER model_usage_budget_configurations_guard BEFORE INSERT OR UPDATE OR DELETE " + "ON public.model_usage_budget_configurations FOR EACH ROW " + "EXECUTE FUNCTION public.guard_model_usage_budget_configuration()", + "CREATE TRIGGER model_usage_budget_accounts_guard BEFORE INSERT OR UPDATE OR DELETE " + "ON public.model_usage_budget_accounts FOR EACH ROW " + "EXECUTE FUNCTION public.guard_model_usage_budget_account()", + "CREATE TRIGGER checker_model_usage_slots_guard BEFORE INSERT OR UPDATE OR DELETE " + "ON public.checker_model_usage_slots FOR EACH ROW " + "EXECUTE FUNCTION public.guard_checker_model_call_slot()", + "CREATE TRIGGER checker_model_usage_envelopes_guard BEFORE INSERT OR UPDATE OR DELETE " + "ON public.checker_model_usage_envelopes FOR EACH ROW " + "EXECUTE FUNCTION public.guard_checker_model_usage_envelope()", + "CREATE TRIGGER setup_model_usage_envelopes_guard BEFORE INSERT OR UPDATE OR DELETE " + "ON public.setup_model_usage_envelopes FOR EACH ROW " + "EXECUTE FUNCTION public.guard_setup_model_usage_envelope()", + "CREATE TRIGGER setup_model_usage_slots_guard BEFORE INSERT OR UPDATE OR DELETE " + "ON public.setup_model_usage_slots FOR EACH ROW " + "EXECUTE FUNCTION public.guard_setup_model_usage_slot()", + "CREATE TRIGGER model_usage_effects_guard BEFORE INSERT OR UPDATE OR DELETE " + "ON public.model_usage_effects FOR EACH ROW " + "EXECUTE FUNCTION public.guard_model_usage_effect()", + "CREATE TRIGGER checker_model_usage_recoveries_guard BEFORE INSERT OR UPDATE OR DELETE " + "ON public.checker_model_usage_recoveries FOR EACH ROW " + "EXECUTE FUNCTION public.guard_checker_model_recovery()", + "CREATE CONSTRAINT TRIGGER checker_model_budget_account_reconcile " + "AFTER INSERT OR UPDATE OR DELETE ON public.model_usage_budget_accounts " + "DEFERRABLE INITIALLY DEFERRED FOR EACH ROW " + "EXECUTE FUNCTION public.reconcile_model_usage_budget()", + "CREATE CONSTRAINT TRIGGER checker_model_call_attempt_reconcile " + "AFTER INSERT OR UPDATE OR DELETE ON public.model_usage_effects " + "DEFERRABLE INITIALLY DEFERRED FOR EACH ROW " + "EXECUTE FUNCTION public.reconcile_model_usage_budget()", + *( + "CREATE TRIGGER " + + table + + "_no_truncate BEFORE TRUNCATE ON public." + + table + + " FOR EACH STATEMENT EXECUTE FUNCTION public.guard_model_usage_no_truncate()" + for table in ( + "model_usage_profiles", + "model_usage_budget_configurations", + "model_usage_budget_accounts", + "checker_model_usage_slots", + "checker_model_usage_envelopes", + "setup_model_usage_envelopes", + "setup_model_usage_slots", + "model_usage_effects", + "checker_model_usage_recoveries", + ) + ), +) diff --git a/backend/app/adapters/artifacts/__init__.py b/backend/app/adapters/artifacts/__init__.py index 20c4be342..966771f8a 100644 --- a/backend/app/adapters/artifacts/__init__.py +++ b/backend/app/adapters/artifacts/__init__.py @@ -3,7 +3,8 @@ from __future__ import annotations from contextlib import asynccontextmanager -from typing import Annotated +from collections.abc import Callable +from typing import Annotated, cast from uuid import UUID from fastapi import Depends, Request @@ -18,6 +19,11 @@ from app.db.session import get_db_session, get_session_factory from app.interfaces.artifact_operations import GuideArtifactIngestCommand from app.modules.projects.api.guide_documents import GuideDocumentUploadTargetPort +from app.modules.model_usage.api import ( + SetupModelArtifactOutputPort, + SetupModelOutputBindingPort, + SetupModelUsageOwnerPort, +) from app.modules.artifacts.api import SubmissionBundlePreparationCommand from app.modules.artifacts.api.task_import_source import TaskImportSourceCommandPort from app.modules.checkers.api.materialization import PostSubmissionMaterializationPort @@ -189,6 +195,25 @@ def create_artifact_scratch_manager(settings: Settings) -> ArtifactScratchManage ) +def artifact_preparation_service(manager: ArtifactScratchManager) -> ArtifactPreparationService: + """Construct the public composition root's canonical stream preparation owner.""" + return ArtifactPreparationService(manager) + + +def configured_artifact_storage_namespace(settings: Settings, bootstrap): + """Derive the immutable namespace through the existing ART composition root.""" + from app.modules.artifacts.service import artifact_storage_namespace_spec + + return artifact_storage_namespace_spec(settings, bootstrap) + + +async def validate_configured_artifact_storage_namespace(bootstrap, settings, sessions): + """Validate startup namespace custody without exposing the private ART service.""" + from app.modules.artifacts.service import validate_artifact_storage_namespace_at_startup + + return await validate_artifact_storage_namespace_at_startup(bootstrap, settings, sessions) + + def _guide_original_runtime(settings: Settings): """Open the provider, namespace and scratch only when a verified original read begins.""" from app.modules.artifacts.service import artifact_storage_namespace_spec @@ -203,10 +228,13 @@ async def runtime(): try: manager = create_artifact_scratch_manager(settings) namespace = artifact_storage_namespace_spec(settings, bootstrap) - store = bootstrap.initialize_after_namespace_claim(ArtifactStoreNamespaceClaim( - adapter_identity=bootstrap.identity, namespace_identity=bootstrap.namespace_identity, - namespace_fingerprint=namespace.namespace_fingerprint, - )) + store = bootstrap.initialize_after_namespace_claim( + ArtifactStoreNamespaceClaim( + adapter_identity=bootstrap.identity, + namespace_identity=bootstrap.namespace_identity, + namespace_fingerprint=namespace.namespace_fingerprint, + ) + ) yield store, namespace, ArtifactPreparationService(manager) finally: if manager is not None: @@ -224,19 +252,26 @@ def task_guide_documents_port(session: AsyncSession, settings: Settings) -> Task scope = project_guide_document_scope_port(session) return ArtifactTaskGuideDocuments( - session, scope=scope, manifest=SqlAlchemyGuideDocumentManifest(session, scope), + session, + scope=scope, + manifest=SqlAlchemyGuideDocumentManifest(session, scope), runtime=_guide_original_runtime(settings), ) -def project_guide_documents_port(session: AsyncSession, settings: Settings) -> ProjectGuideDocumentContentPort: +def project_guide_documents_port( + session: AsyncSession, settings: Settings +) -> ProjectGuideDocumentContentPort: """Compose a Project Manager's verified original read; the provider opens only on ``open``.""" from app.adapters.projects import project_guide_document_scope_port from app.modules.artifacts.guide_documents import SqlAlchemyGuideDocumentManifest from app.modules.artifacts.task_guide_documents import ArtifactProjectGuideDocuments return ArtifactProjectGuideDocuments( - session, manifest=SqlAlchemyGuideDocumentManifest(session, project_guide_document_scope_port(session)), + session, + manifest=SqlAlchemyGuideDocumentManifest( + session, project_guide_document_scope_port(session) + ), runtime=_guide_original_runtime(settings), ) @@ -316,10 +351,17 @@ async def runtime(): bootstrap.close() from app.adapters.artifacts.internal_workers import continue_guide_setup_after_stored_document - service = GuideArtifactIngestService(runtime, authority, continue_guide_setup_after_stored_document) + + service = GuideArtifactIngestService( + runtime, authority, continue_guide_setup_after_stored_document + ) return PreparedGuideArtifactIngestCommand( - service, authority, targets, - maximum_document_bytes=min(settings.artifact_maximum_bytes, settings.project_agent_max_document_bytes), + service, + authority, + targets, + maximum_document_bytes=min( + settings.artifact_maximum_bytes, settings.project_agent_max_document_bytes + ), maximum_total_bytes=settings.project_agent_max_total_document_bytes, ) @@ -389,6 +431,7 @@ def get_submission_bundle_preparation_command( PreparedSubmissionBundlePreparationCommand, SubmissionBundlePreparationRuntime, ) + settings = request.app.state.settings request_id, correlation_id = (UUID(value) for value in request_ids(request)) task_contexts = task_submission_context_port(session) @@ -510,6 +553,7 @@ def guide_document_manifest_port(session: AsyncSession): """Compose ART's metadata-only current document manifest capability.""" from app.modules.artifacts.guide_documents import SqlAlchemyGuideDocumentManifest from app.adapters.projects import project_guide_document_scope_port + return SqlAlchemyGuideDocumentManifest(session, project_guide_document_scope_port(session)) @@ -532,15 +576,25 @@ async def guide_document_access_runtime(session_factory, attempt_id, manifest, c manager = create_artifact_scratch_manager(get_settings()) try: with _artifact_internal_runtime() as (store, namespace): + def authority(session): request_id = uuid4() return PreparedGuideSourceReadAuthorization( - session, request_id=request_id, correlation_id=request_id, + session, + request_id=request_id, + correlation_id=request_id, ) + grant = ScopedGuideDocumentGrant( - session_factory, store, namespace, ArtifactPreparationService(manager), authority, - scope_factory=project_guide_document_scope_port, manifest_factory=guide_document_manifest_port, - attempt_id=attempt_id, manifest=manifest, + session_factory, + store, + namespace, + ArtifactPreparationService(manager), + authority, + scope_factory=project_guide_document_scope_port, + manifest_factory=guide_document_manifest_port, + attempt_id=attempt_id, + manifest=manifest, lifetime_seconds=configuration.timeout_seconds, maximum_document_bytes=configuration.maximum_document_bytes, ) @@ -552,15 +606,22 @@ def authority(session): manager.close() -def post_submission_materialization(*, sessions, store, namespace, preparation, inspector) -> PostSubmissionMaterializationPort: +def post_submission_materialization( + *, sessions, store, namespace, preparation, inspector +) -> PostSubmissionMaterializationPort: """Compose exact input with fresh materializer authority and CHECKERS lease custody.""" from app.adapters.tasks import submitted_bundle_port from app.modules.artifacts.post_submit_materialization import PostSubmissionMaterializer from app.adapters.auth import post_submit_materialization_authority from app.adapters.checkers import current_post_submit_execution + return PostSubmissionMaterializer( - sessions=sessions, tasks=submitted_bundle_port, store=store, namespace=namespace, - preparation=preparation, inspector=inspector, + sessions=sessions, + tasks=submitted_bundle_port, + store=store, + namespace=namespace, + preparation=preparation, + inspector=inspector, authority=post_submit_materialization_authority, current_execution=current_post_submit_execution, ) @@ -573,7 +634,9 @@ def __init__(self, sessions) -> None: self._sessions = sessions async def materialize( - self, facts: ExecuteFacts, consumer: PostSubmissionMaterialConsumer, + self, + facts: ExecuteFacts, + consumer: PostSubmissionMaterialConsumer, ) -> PostSubmissionMaterializationResult: from app.core.config import get_settings from app.adapters.artifacts.internal_workers import ( @@ -624,24 +687,113 @@ def worker_post_submission_materialization(*, sessions) -> PostSubmissionMateria def checker_output_storage( - *, sessions, store, namespace, preparation, settings + *, sessions, store, namespace, preparation, settings, + reservations=checker_output_reservations, ) -> CheckerArtifactOutputPort: - """Compose hidden output custody; live producer and write authority remain absent.""" - from app.modules.artifacts.checker_outputs import CheckerArtifactOutputService, DenyCheckerOutputWriteAuthority - from app.modules.artifacts.schemas import DenyArtifactInternalAuthority + """Compose private call evidence through canonical admission and verification.""" + from app.adapters.auth import checker_output_write_authorization + from app.modules.artifacts.checker_outputs import CheckerArtifactOutputService + from uuid import uuid4 + + def authority(session, identity): + request_id = uuid4() + return PreparedArtifactInternalAuthority( + session, + service_identity=identity, + request_id=request_id, + correlation_id=request_id, + ) + return CheckerArtifactOutputService( - sessions=sessions, store=store, namespace=namespace, preparation=preparation, settings=settings, - reservations=checker_output_reservations, - authority=lambda session: DenyCheckerOutputWriteAuthority(), - internal_authority=lambda session: DenyArtifactInternalAuthority(), + sessions=sessions, + store=store, + namespace=namespace, + preparation=preparation, + settings=settings, + reservations=reservations, + authority=checker_output_write_authorization, + internal_authority=lambda session: authority( + session, ServiceIdentity.ARTIFACT_PUT_RESOLVER + ), + verification_authority=lambda session: authority( + session, ServiceIdentity.ARTIFACT_VERIFIER + ), ) -def checker_output_binding(session, *, namespace) -> CheckerOutputBindingPort: - """Compose a deny-only caller-transaction binding participant.""" - from app.modules.artifacts.checker_output_bindings import CheckerOutputBindingService, DenyCheckerOutputBindingAuthority - return CheckerOutputBindingService(session, namespace_fingerprint=namespace.namespace_fingerprint, - reservations=checker_output_reservations(session), authority=DenyCheckerOutputBindingAuthority()) +def checker_output_binding( + session, *, namespace, reservations=None +) -> CheckerOutputBindingPort: + """Bind one exact private call effect to independently verified ART bytes.""" + from app.adapters.auth import checker_output_binding_authorization + from app.modules.artifacts.checker_output_bindings import CheckerOutputBindingService + + return CheckerOutputBindingService( + session, + namespace_fingerprint=namespace.namespace_fingerprint, + reservations=reservations or checker_output_reservations(session), + authority=checker_output_binding_authorization(session), + ) + + +def setup_model_output_storage( + *, + sessions, + store, + namespace, + preparation, + settings, + owner: Callable[[AsyncSession], SetupModelUsageOwnerPort], +) -> SetupModelArtifactOutputPort: + """Compose setup request/response custody without importing PROJECTS into MODEL-USAGE.""" + from app.adapters.model_usage import setup_model_output_reservations + + return cast( + SetupModelArtifactOutputPort, + checker_output_storage( + sessions=sessions, + store=store, + namespace=namespace, + preparation=preparation, + settings=settings, + reservations=lambda session: setup_model_output_reservations( + session, owner=owner(session) + ), + ), + ) + + +def setup_model_output_binding( + session, + *, + namespace, + owner: SetupModelUsageOwnerPort, +) -> SetupModelOutputBindingPort: + """Compose exact setup-effect publication through the shared ART binding path.""" + from app.adapters.model_usage import setup_model_output_reservations + + return cast( + SetupModelOutputBindingPort, + checker_output_binding( + session, + namespace=namespace, + reservations=setup_model_output_reservations(session, owner=owner), + ), + ) + + +def checker_model_evidence(session): + """Expose verified call binding facts to CHECKERS without transcript bytes.""" + from app.modules.artifacts.checker_model_evidence import CheckerModelEvidence + + return CheckerModelEvidence(session) + + +def setup_model_evidence(session): + """Expose verified setup-effect bindings to MODEL-USAGE without transcript bytes.""" + from app.modules.artifacts.checker_model_evidence import SetupModelEvidence + + return SetupModelEvidence(session) async def get_task_import_source_commands( @@ -651,16 +803,23 @@ async def get_task_import_source_commands( ) -> TaskImportSourceCommandPort: """Capture actor facts, then compose the same source owner for public commands.""" await session.rollback() - return task_import_source_commands(session, settings=request.app.state.settings, context=context) + return task_import_source_commands( + session, settings=request.app.state.settings, context=context + ) def task_import_source_commands(session: AsyncSession, *, settings, context): """Compose source reads/bindings and lazy provider runtime through one owner path.""" from app.adapters.auth import task_import_source_authorization from app.modules.artifacts.service import ( - ArtifactAdmissionService, ArtifactStorageOrchestrator, artifact_storage_namespace_spec, + ArtifactAdmissionService, + ArtifactStorageOrchestrator, + artifact_storage_namespace_spec, + ) + from app.modules.artifacts.task_import_sources import ( + ArtifactTaskImportSourceCommands, + TaskImportSourceRuntime, ) - from app.modules.artifacts.task_import_sources import ArtifactTaskImportSourceCommands, TaskImportSourceRuntime request_id, correlation_id = context.request_id, context.correlation_id @@ -675,19 +834,34 @@ async def runtime(): try: manager = create_artifact_scratch_manager(settings) namespace = artifact_storage_namespace_spec(settings, bootstrap) - store = bootstrap.initialize_after_namespace_claim(ArtifactStoreNamespaceClaim( - adapter_identity=bootstrap.identity, namespace_identity=bootstrap.namespace_identity, - namespace_fingerprint=namespace.namespace_fingerprint, - )) - for identity in (ServiceIdentity.ARTIFACT_PUT_RESOLVER, ServiceIdentity.ARTIFACT_VERIFIER): - authorities.append(PreparedArtifactInternalAuthority( - session, service_identity=identity, request_id=request_id, correlation_id=correlation_id, - )) + store = bootstrap.initialize_after_namespace_claim( + ArtifactStoreNamespaceClaim( + adapter_identity=bootstrap.identity, + namespace_identity=bootstrap.namespace_identity, + namespace_fingerprint=namespace.namespace_fingerprint, + ) + ) + for identity in ( + ServiceIdentity.ARTIFACT_PUT_RESOLVER, + ServiceIdentity.ARTIFACT_VERIFIER, + ): + authorities.append( + PreparedArtifactInternalAuthority( + session, + service_identity=identity, + request_id=request_id, + correlation_id=correlation_id, + ) + ) yield TaskImportSourceRuntime( - store, namespace, ArtifactPreparationService(manager), ArtifactAdmissionService(session, settings, namespace), + store, + namespace, + ArtifactPreparationService(manager), + ArtifactAdmissionService(session, settings, namespace), ArtifactStorageOrchestrator(session, store, namespace, settings, authorities[0]), ArtifactStorageOrchestrator(session, store, namespace, settings, authorities[1]), - authorities[0].denial_boundary, authorities[1].denial_boundary, + authorities[0].denial_boundary, + authorities[1].denial_boundary, ) finally: for authority in authorities: @@ -697,9 +871,11 @@ async def runtime(): bootstrap.close() return ArtifactTaskImportSourceCommands( - session, actor_profile_id=context.actor_profile_id, + session, + actor_profile_id=context.actor_profile_id, identity_link_id=context.identity_link_id, - authorization=task_import_source_authorization(session, context), runtime=runtime, + authorization=task_import_source_authorization(session, context), + runtime=runtime, ) diff --git a/backend/app/adapters/artifacts/s3_compatible.py b/backend/app/adapters/artifacts/s3_compatible.py index ef128e989..318d34132 100644 --- a/backend/app/adapters/artifacts/s3_compatible.py +++ b/backend/app/adapters/artifacts/s3_compatible.py @@ -49,6 +49,7 @@ from app.adapters.artifacts.aws_roles_anywhere import ( create_roles_anywhere_credential_provider, ) +from app.core.cancellation import await_completion_preserving_cancellation from app.core.config import Settings from app.core.hashing import canonical_json_hash from app.core.s3_validation import ( @@ -573,19 +574,25 @@ async def put( if commitment.byte_count == 0: await body.validate_empty() request_body: object = b"" if commitment.byte_count == 0 else body - async with asyncio.timeout(self._operation_total_timeout_seconds): - async with asyncio.timeout(self._write_timeout_seconds): - await self._require_activation_for_sdk_call(evidence) - async with self._client() as client: + try: + async with asyncio.timeout(self._operation_total_timeout_seconds): + async with asyncio.timeout(self._write_timeout_seconds): await self._require_activation_for_sdk_call(evidence) - await client.put_object( - Bucket=self._bucket, - Key=self._object_key(provider_object_ref), - Body=request_body, - ContentLength=commitment.byte_count, - ContentType=commitment.media_type, - IfNoneMatch="*", - ) + async with self._client() as client: + await self._require_activation_for_sdk_call(evidence) + await client.put_object( + Bucket=self._bucket, + Key=self._object_key(provider_object_ref), + Body=request_body, + ContentLength=commitment.byte_count, + ContentType=commitment.media_type, + IfNoneMatch="*", + ) + finally: + # aiohttp streams async bodies in a separate writer task. Its + # cancellation is not joined by the request timeout path, so + # retain source ownership until that exact consumer is done. + await body.wait_for_consumer() if not body.complete: raise ArtifactInputMismatchError( "S3 provider did not consume the sealed artifact source" @@ -809,6 +816,11 @@ async def _validate_replay_source(self, body: _CommittedSourceBody) -> None: """Finish validating the sealed source before accepting any replay.""" try: async with asyncio.timeout(self._operation_total_timeout_seconds): + await body.wait_for_consumer() + if body.consumer_started and not body.complete: + raise ArtifactStoreUnavailableError( + "S3 artifact operation failed" + ) await body.validate_complete() except TimeoutError: raise ArtifactStoreUnavailableError("S3 artifact operation failed") from None @@ -880,6 +892,10 @@ def __init__(self, source: CommittedArtifactSource, buffer_bytes: int) -> None: self._pending = bytearray() self._digest = hashlib.sha256() self._byte_count = 0 + self._consumer_task: asyncio.Task[Any] | None = None + self._consumer_started = False + self._consumer_finished = False + self._consumer_error: ArtifactStoreError | None = None def __aiter__(self) -> AsyncIterator[bytes]: """Yield bounded bytes while checking the exact digest and count.""" @@ -889,8 +905,23 @@ def __aiter__(self) -> AsyncIterator[bytes]: async def iterate() -> AsyncIterator[bytes]: """Yield the source until its commitment and EOF are proven.""" - while chunk := await self.read(self._buffer_bytes): - yield chunk + consumer = asyncio.current_task() + if consumer is None or ( + self._consumer_task is not None and self._consumer_task is not consumer + ): + raise ArtifactInputMismatchError( + "artifact source stream has multiple consumers" + ) + self._consumer_task = consumer + self._consumer_started = True + try: + while chunk := await self.read(self._buffer_bytes): + yield chunk + except ArtifactStoreError as error: + self._consumer_error = error + raise + finally: + self._consumer_finished = True return iterate() @@ -899,6 +930,40 @@ def complete(self) -> bool: """Return whether the sealed second-pass stream was fully validated.""" return self._complete + @property + def consumer_started(self) -> bool: + """Return whether the SDK scheduled a task to consume this source.""" + return self._consumer_started + + async def wait_for_consumer(self) -> None: + """Join the finite sealed-source consumer before releasing ownership. + + The public put boundary accepts only ``CommittedArtifactSource``. Its + owner applies the scratch deadline to the complete stream and finishes + each regular-file read cancellation-resistently. An independent shorter + timeout here would abandon a live reader and violate scratch custody. + """ + consumer = self._consumer_task + if consumer is asyncio.current_task() and not self._consumer_finished: + raise ArtifactInputMismatchError( + "artifact source consumer cannot join itself" + ) + if consumer is not None and consumer is not asyncio.current_task(): + + async def join() -> None: + try: + await consumer + except BaseException: + # Cancellation and transport failure are classified by the + # request owner; a source failure is retained separately. + pass + + await await_completion_preserving_cancellation(join()) + if self._consumer_finished: + self._consumer_task = None + if self._consumer_error is not None: + raise self._consumer_error + async def read(self, amount: int | None = -1) -> bytes: """Return one bounded async file-like chunk for the pinned SDK.""" if not self._consumed: diff --git a/backend/app/adapters/auth/__init__.py b/backend/app/adapters/auth/__init__.py index 130377837..654da2d23 100644 --- a/backend/app/adapters/auth/__init__.py +++ b/backend/app/adapters/auth/__init__.py @@ -1,60 +1,68 @@ """Authorization application adapters and same-owner composition.""" import json - -from app.modules.authorization.domain.post_submit_routing import ( - AutomatedAcceptanceConsequence, - CheckerRemediationConsequence, CheckerExhaustionConsequence, - HumanAdmissionConsequence, - PostSubmitRoutingResourceContext, -) -from app.modules.authorization.post_submit_routing_authorization import ( - PostSubmitRoutingAuthorization, -) -from app.modules.tasks.api.routing_outcome import RoutingAuthorityFacts -from app.modules.authorization.runtime import PreparedAuthorizationHandleInvalid - -from app.adapters.auth.assignment_invalidation_publication import assignment_invalidation_publication -from app.modules.projects.api.guide_activation import GuideActivationAuthorizationPort from contextlib import asynccontextmanager from uuid import UUID, uuid5 -from app.modules.actors.api import ServiceIdentity -from app.modules.authorization.api import ( - ActorIdentityFacts, ActorKind, AuthorizationDenied, ProjectGuideCompilationAuthorizationPort, -) -from app.modules.authorization.api.guide_proposal_review import GuideProposalAuthorizationPort -from app.modules.authorization.api.post_policy import PostPolicyAuthorizationPort -from app.modules.authorization.api.outbox_dispatch import OutboxDispatchAuthorizationPort -from app.modules.authorization.guide_compilation import ProjectGuideCompilationAuthorizationAdapter -from app.modules.authorization.prepared import fixed_service_prepared_authorization -from app.modules.authorization.runtime import PreparedAuthorizationUnsupported from sqlalchemy.ext.asyncio import AsyncSession -from app.adapters.auth.contribution_policies import ContributionPolicyAuthorization -from app.modules.authorization.contribution_policy_authorization import ( - ContributionPolicyAuthorizationAdapter, -) - from app.adapters.auth.adapter_bindings import CompensationAdapterBindingAuthorization +from app.adapters.auth.assignment_invalidation_publication import ( + assignment_invalidation_publication, +) +from app.adapters.auth.contribution_policies import ContributionPolicyAuthorization +from app.modules.actors.api import ServiceIdentity from app.modules.authorization.adapter_binding_authorization import ( AdapterBindingAuthorizationAdapter, ) -from app.modules.authorization.project_setup_finalization import SetupFinalizationAuthorization -from app.modules.authorization.kernel import AuthorizationService +from app.modules.authorization.api import ( + ActorIdentityFacts, + ActorKind, + AuthorizationDenied, + ProjectGuideCompilationAuthorizationPort, +) +from app.modules.authorization.api.guide_proposal_review import GuideProposalAuthorizationPort +from app.modules.authorization.api.outbox_dispatch import OutboxDispatchAuthorizationPort +from app.modules.authorization.api.post_policy import PostPolicyAuthorizationPort from app.modules.authorization.artifact_operator_authorization import ( ArtifactOperatorReadAuthorization, ArtifactOperatorRetryAuthorization, ) -from app.modules.authorization.prepared import PreparedAuthorizationService -from app.modules.authorization.repository import AdminAuthorizationRepository -from app.modules.authorization.runtime import AuthorizationContext -from app.modules.authorization.task_authorization import PreparedTaskAuthorization -from app.modules.authorization.task_import_sources import PreparedTaskImportSourceAuthorization +from app.modules.authorization.contribution_policy_authorization import ( + ContributionPolicyAuthorizationAdapter, +) +from app.modules.authorization.domain.post_submit_routing import ( + AutomatedAcceptanceConsequence, + CheckerExhaustionConsequence, + CheckerRemediationConsequence, + HumanAdmissionConsequence, + PostSubmitRoutingResourceContext, +) +from app.modules.authorization.guide_compilation import ProjectGuideCompilationAuthorizationAdapter from app.modules.authorization.guide_compilation_projections import ( ArtifactPolicyProjectionAuthorization, GuideSufficiencyProjectionAuthorization, ) +from app.modules.authorization.kernel import AuthorizationService +from app.modules.authorization.post_submit_routing_authorization import ( + PostSubmitRoutingAuthorization, +) +from app.modules.authorization.prepared import ( + PreparedAuthorizationService, + fixed_service_prepared_authorization, +) +from app.modules.authorization.project_setup_finalization import SetupFinalizationAuthorization +from app.modules.authorization.repository import AdminAuthorizationRepository +from app.modules.authorization.runtime import ( + AuthorizationContext, + PreparedAuthorizationHandleInvalid, + PreparedAuthorizationUnsupported, + ServiceAuthorizationContext, +) +from app.modules.authorization.task_authorization import PreparedTaskAuthorization +from app.modules.authorization.task_import_sources import PreparedTaskImportSourceAuthorization +from app.modules.projects.api.guide_activation import GuideActivationAuthorizationPort +from app.modules.tasks.api.routing_outcome import RoutingAuthorityFacts def artifact_operator_read_authorization( @@ -96,7 +104,9 @@ def task_import_source_authorization( def checker_retry_authorization(session: AsyncSession, context: AuthorizationContext): """Compose one hidden retry through canonical prepared Operator authority.""" - from app.modules.authorization.checker_retry_authorization import PreparedCheckerRetryAuthorization + from app.modules.authorization.checker_retry_authorization import ( + PreparedCheckerRetryAuthorization, + ) return PreparedCheckerRetryAuthorization(session, context) @@ -136,6 +146,69 @@ def checker_registry_authorization( return ExternalCheckerRegistryAuthorizationAdapter(kernel, prepared) +def checker_model_authorization(session: AsyncSession, context: AuthorizationContext): + """Compose exact project-model authority using the existing AUTH/PREP graph.""" + from app.modules.authorization.model_usage_authorization import CheckerModelAuthorization + + repository = AdminAuthorizationRepository(session) + revalidate = None + if isinstance(context, ServiceAuthorizationContext): + from app.modules.authorization.prepared import fixed_service_context_revalidator + + if context.service_identity not in { + ServiceIdentity.CHECKER_POST_SUBMIT, + ServiceIdentity.PROJECT_SETUP, + }: + raise TypeError("model usage service identity is unsupported") + revalidate = fixed_service_context_revalidator(repository, context.service_identity) + kernel = AuthorizationService( + session, context, admin_repository=repository, revalidate_service=revalidate, + ) + prepared = PreparedAuthorizationService(session, context, kernel, repository) + return CheckerModelAuthorization(kernel, prepared) + + +async def checker_model_service_authorization(session: AsyncSession, request_id: UUID): + """Resolve the fixed POST checker principal within the caller's transaction.""" + from app.modules.authorization.prepared import fixed_service_authorization_context + + context = await fixed_service_authorization_context( + session, ServiceIdentity.CHECKER_POST_SUBMIT, request_id, request_id, + ) + return checker_model_authorization(session, context) + + +async def setup_model_service_authorization(session: AsyncSession, request_id: UUID): + """Resolve the fixed PROJECT_SETUP principal for one setup model effect.""" + from app.modules.authorization.prepared import fixed_service_authorization_context + + context = await fixed_service_authorization_context( + session, + ServiceIdentity.PROJECT_SETUP, + request_id, + uuid5(request_id, "setup-model-authority"), + ) + return checker_model_authorization(session, context) + + +def checker_output_write_authorization(session: AsyncSession): + """Compose private model-evidence admission and recovery authority.""" + from app.modules.authorization.checker_output_authorization import ( + PreparedCheckerOutputWriteAuthorization, + ) + + return PreparedCheckerOutputWriteAuthorization(session) + + +def checker_output_binding_authorization(session: AsyncSession): + """Compose verified call-evidence publication authority.""" + from app.modules.authorization.checker_output_authorization import ( + PreparedCheckerOutputBindingAuthorization, + ) + + return PreparedCheckerOutputBindingAuthorization(session) + + def guide_sufficiency_projection_authorization( session: AsyncSession, ) -> GuideSufficiencyProjectionAuthorization: @@ -183,19 +256,19 @@ def contribution_read_authorization(session: AsyncSession, context: Authorizatio __all__ = ( + "CompensationAdapterBindingAuthorization", + "ContributionPolicyAuthorization", + "artifact_policy_projection_authorization", "assignment_invalidation_publication", - "task_authorization", - "task_import_source_authorization", "checker_registry_authorization", - "guide_compilation_request_authority", - "guide_compilation_execution_authority", - "ContributionPolicyAuthorization", - "contribution_policy_authorization", - "CompensationAdapterBindingAuthorization", "compensation_adapter_binding_authorization", - "artifact_policy_projection_authorization", + "contribution_policy_authorization", + "guide_compilation_execution_authority", + "guide_compilation_request_authority", "guide_sufficiency_projection_authorization", "setup_finalization_authorization", + "task_authorization", + "task_import_source_authorization", ) @@ -238,7 +311,9 @@ def guide_proposal_authorization( session: AsyncSession, context: AuthorizationContext, ) -> GuideProposalAuthorizationPort: """Bind the public proposal authority port to the canonical request adapter.""" - from app.modules.authorization.guide_proposal_authorization import GuideProposalAuthorizationAdapter + from app.modules.authorization.guide_proposal_authorization import ( + GuideProposalAuthorizationAdapter, + ) return GuideProposalAuthorizationAdapter(session, context) @@ -284,35 +359,45 @@ def guide_activation_authorization( session: AsyncSession, context: AuthorizationContext, ) -> GuideActivationAuthorizationPort: """Compose live manager authority for the sole complete-guide operation.""" - from app.modules.authorization.guide_activation_authorization import GuideActivationAuthorizationAdapter + from app.modules.authorization.guide_activation_authorization import ( + GuideActivationAuthorizationAdapter, + ) return GuideActivationAuthorizationAdapter(session, context) def outbox_dispatch_authorization(session: AsyncSession) -> "OutboxDispatchAuthorizationPort": """Compose the one fixed dispatcher through canonical AUTH/PREP.""" - from app.modules.authorization.outbox_dispatch_authorization import OutboxDispatchAuthorizationAdapter + from app.modules.authorization.outbox_dispatch_authorization import ( + OutboxDispatchAuthorizationAdapter, + ) return OutboxDispatchAuthorizationAdapter(session) def assignment_invalidation_authorization(session: AsyncSession): """Compose the exact reconciler through canonical fixed-service PREP.""" - from app.modules.authorization.assignment_invalidation_authorization import AssignmentInvalidationAuthorizationAdapter + from app.modules.authorization.assignment_invalidation_authorization import ( + AssignmentInvalidationAuthorizationAdapter, + ) return AssignmentInvalidationAuthorizationAdapter(session) def revision_expiry_authorization(session: AsyncSession): """Bind TASK's deadline effect to canonical fixed-service AUTH.""" - from app.modules.authorization.revision_expiry_authorization import RevisionExpiryAuthorizationAdapter + from app.modules.authorization.revision_expiry_authorization import ( + RevisionExpiryAuthorizationAdapter, + ) return RevisionExpiryAuthorizationAdapter(session) def revision_recovery_authorization(session: AsyncSession): """Bind retained-window recovery to canonical fixed-service AUTH.""" - from app.modules.authorization.revision_recovery_authorization import RevisionRecoveryAuthorizationAdapter + from app.modules.authorization.revision_recovery_authorization import ( + RevisionRecoveryAuthorizationAdapter, + ) return RevisionRecoveryAuthorizationAdapter(session) @@ -411,7 +496,9 @@ def selected_pre_submit_authority(session): def post_submit_materialization_authority(session): """Compose the separate fixed ART materializer principal.""" - from app.modules.authorization.post_submit_authorization import PostSubmitMaterializationAuthorization + from app.modules.authorization.post_submit_authorization import ( + PostSubmitMaterializationAuthorization, + ) return PostSubmitMaterializationAuthorization(session) diff --git a/backend/app/adapters/checkers/__init__.py b/backend/app/adapters/checkers/__init__.py index e8305b080..04d31ae70 100644 --- a/backend/app/adapters/checkers/__init__.py +++ b/backend/app/adapters/checkers/__init__.py @@ -9,6 +9,7 @@ ExternalCheckerExecutionAdapter, ExternalCheckerExecutionResponse, ExternalCheckerMaterialGrant, + ExternalCheckerModelGrant, ExternalCheckerServiceHealth, ) from app.interfaces.external_services import ( @@ -16,6 +17,10 @@ ExternalServiceAdapterIdentity, ExternalServiceUnavailableError, ) +from app.interfaces.checker_model_provider import ( + CHECKER_MODEL_CAPABILITY_KEY, + CheckerModelProvider, +) from app.modules.tasks.api import TaskSubmissionContextFacts from app.modules.checkers.api.external_policy import LockedExternalPostSubmitPolicy @@ -63,6 +68,30 @@ def external_checker_execution_factory( return factory +def create_checker_model_provider(settings): + """Construct the sole provider registered for one locked model profile.""" + from openai import AsyncOpenAI + + from app.adapters.checkers.model_provider import ( + OPENAI_RESPONSES_PROVIDER_KEY, + OpenAIResponsesCheckerModelProvider, + ) + + factory = ExternalServiceAdapterFactory[CheckerModelProvider](CHECKER_MODEL_CAPABILITY_KEY) + factory.register( + OPENAI_RESPONSES_PROVIDER_KEY, + lambda: OpenAIResponsesCheckerModelProvider( + settings, + AsyncOpenAI( + api_key=settings.api_key, + max_retries=0, + timeout=settings.request_timeout_seconds, + ), + ), + ) + return factory.create(OPENAI_RESPONSES_PROVIDER_KEY) + + def configured_external_checker_execution_factory(settings): """Map configured image-execution settings into the sole adapter factory.""" if ( @@ -106,9 +135,11 @@ async def execute( self, request: ExternalCheckerExecutionRequest, grant: ExternalCheckerMaterialGrant, + *, + model_grant: ExternalCheckerModelGrant | None = None, ) -> ExternalCheckerExecutionResponse: """Resolve and invoke the sole provider for one selected binding.""" - return await self._resolve().execute(request, grant) + return await self._resolve().execute(request, grant, model_grant=model_grant) async def health(self) -> ExternalCheckerServiceHealth: """Resolve the sole provider before checking runtime readiness.""" @@ -120,6 +151,18 @@ def configured_external_checker_execution(settings) -> ExternalCheckerExecutionA return _ConfiguredExternalCheckerExecution(settings) +def configured_post_submit_external_execution( + settings, *, sessions +) -> ExternalCheckerExecutionAdapter: + """Add durable project model custody to the existing Rust image transport.""" + from app.adapters.checkers.model_execution import ModelAwareExternalExecution + + return ModelAwareExternalExecution( + execution=configured_external_checker_execution(settings), + sessions=sessions, + ) + + def selected_pre_submit_checker_execution(*, settings, archive_inspector): """Compose default/PRE checking with deferred project-image transport.""" return SelectedPreSubmitCheckerExecutionAdapter( @@ -135,6 +178,13 @@ def external_checker_registry_reader(session): return ExternalCheckerRegistryReader(session) +def model_profile_reader(session): + """Resolve exact immutable project model profiles without mutation authority.""" + from app.adapters.model_usage import model_profile_reader as shared_reader + + return shared_reader(session) + + def external_checker_registry_service(session, authority): """Compose CHECKERS registration and readback with request-scoped AUTH.""" from app.modules.checkers.external_registry import ExternalCheckerRegistryService @@ -232,9 +282,7 @@ def build(self, request: _ExecutionRequest) -> SelectedPreSubmitProcessor: or attempt_request_digest is None or grants is None ): - raise SelectedPreSubmitInfrastructureUnavailable( - "material_unavailable" - ) + raise SelectedPreSubmitInfrastructureUnavailable("material_unavailable") identity = PreSubmitExternalCheckerIdentity( project_id=request.project_id, task_id=request.task_id, @@ -285,27 +333,39 @@ def checker_retry_service(session, context, *, cursor_secret=None): """Compose supported Operator recovery from existing owner operations.""" from app.adapters.audit import checker_retry_audit from app.adapters.auth import ( - checker_retry_authorization, checker_retry_discovery_authorization, + checker_retry_authorization, + checker_retry_discovery_authorization, ) from app.adapters.outbox import outbox_append from app.adapters.tasks import evaluation_task_guard + from app.adapters.model_usage import model_recovery_sources from app.modules.checkers.checker_retry import CheckerRetryService from app.modules.checkers.execution_coordination import EvaluationCoordinator tasks = evaluation_task_guard(session) return CheckerRetryService( - session, actor_profile_id=context.actor_profile_id, - tasks=tasks, evaluations=EvaluationCoordinator(session, tasks=tasks), + session, + actor_profile_id=context.actor_profile_id, + tasks=tasks, + evaluations=EvaluationCoordinator(session, tasks=tasks), authorization=checker_retry_authorization(session, context), discovery=checker_retry_discovery_authorization( - session, context, cursor_secret, + session, + context, + cursor_secret, ), - events=outbox_append(session), audit=checker_retry_audit(session), + events=outbox_append(session), + audit=checker_retry_audit(session), + model_recovery=model_recovery_sources(session), ) def post_submit_delivery_service( - session, sessions, context, *, cursor_secret=None, + session, + sessions, + context, + *, + cursor_secret=None, ): """Compose exact Operator inspection and one-child reconciliation.""" from app.adapters.auth import ( @@ -328,9 +388,7 @@ async def detached_classify(delivery): return await classify_post_submit_delivery( delivery, evaluations=evaluations, - requests=evaluation_delivery_request_reader( - source_session, evaluations - ), + requests=evaluation_delivery_request_reader(source_session, evaluations), outcomes=post_submit_delivery_outcome(source_session, sessions), ) @@ -340,16 +398,18 @@ async def detached_classify(delivery): deliveries=finalized_unknown_deliveries(session), events=outbox_append(session), evaluations=evaluation_coordinator(session), - requests=evaluation_delivery_request_reader( - session, evaluation_coordinator(session) - ), + requests=evaluation_delivery_request_reader(session, evaluation_coordinator(session)), outcomes=post_submit_delivery_outcome(session, sessions), read_authority=post_submit_delivery_read_authorization( - session, context, cursor_secret, + session, + context, + cursor_secret, ), authorization=post_submit_delivery_authorization(session, context), detached_classify=detached_classify, ) + + def post_submission_executor(*, sessions, materialization): """Compose the sole executor with exact fixed-service AUTH at both phases.""" from app.adapters.outbox import outbox_append @@ -369,10 +429,72 @@ def post_submission_executor(*, sessions, materialization): def checker_output_reservations(session): - """Resolve exact active run/lease custody for artifact output reservations.""" - from app.modules.checkers.execution_coordination import CheckerOutputReservations + """Resolve exact call custody with TASK-before-CHECKERS request locking.""" + from app.adapters.tasks import evaluation_task_guard + from app.adapters.model_usage import model_evidence_custody, model_recovery_sources + from app.modules.checkers.model_usage_custody import ( + CheckerOutputReservations, + ModelCallCurrentExecution, + ) + + tasks = evaluation_task_guard(session) + return CheckerOutputReservations( + session, + current=ModelCallCurrentExecution( + session, tasks=tasks, recovery=model_recovery_sources(session) + ), + tasks=tasks, + model=model_evidence_custody(session), + ) + - return CheckerOutputReservations(session) +def model_call_service(session, authority): + """Compose durable model spending with exact TASK and ART owner ports.""" + from app.adapters.tasks import evaluation_task_guard + from app.adapters.artifacts import checker_model_evidence + from app.adapters.model_usage import ( + model_call_service as shared_model_call_service, + model_recovery_sources, + ) + from app.modules.checkers.model_usage_custody import ModelCallCurrentExecution + + return shared_model_call_service( + session, + authority=authority, + current_execution=ModelCallCurrentExecution( + session, + tasks=evaluation_task_guard(session), + recovery=model_recovery_sources(session), + ), + evidence=checker_model_evidence(session), + ) + + +def model_invocation_grants(session, authority): + """Compose short-lived image capabilities without creating provider effects.""" + from app.adapters.tasks import evaluation_task_guard + from app.adapters.model_usage import ( + model_invocation_grants as shared_model_invocation_grants, + model_recovery_sources, + ) + from app.modules.checkers.model_usage_custody import ModelCallCurrentExecution + + return shared_model_invocation_grants( + session, + authority=authority, + current_execution=ModelCallCurrentExecution( + session, + tasks=evaluation_task_guard(session), + recovery=model_recovery_sources(session), + ), + ) + + +def checker_model_proxy(**ports): + """Compose provider orchestration from typed CHECKERS, AUTH and ART ports.""" + from app.adapters.checkers.model_proxy import CheckerModelProxy + + return CheckerModelProxy(**ports) def current_post_submit_execution(session): @@ -527,7 +649,9 @@ def executor(child, original, payload, request): original=original, payload=payload, request=request, - selected_execution=configured_external_checker_execution(get_settings()), + selected_execution=configured_post_submit_external_execution( + get_settings(), sessions=sessions + ), ) return PostSubmitDeliveryReconciliationHandler( diff --git a/backend/app/adapters/checkers/external_service.py b/backend/app/adapters/checkers/external_service.py index b0f793fba..5b7d37264 100644 --- a/backend/app/adapters/checkers/external_service.py +++ b/backend/app/adapters/checkers/external_service.py @@ -13,6 +13,7 @@ ExternalCheckerExecutionResponse, ExternalCheckerIsolationReceipt, ExternalCheckerMaterialGrant, + ExternalCheckerModelGrant, ExternalCheckerServiceHealth, ) from app.interfaces.external_services import ( @@ -63,12 +64,20 @@ async def execute( self, request: ExternalCheckerExecutionRequest, grant: ExternalCheckerMaterialGrant, + *, + model_grant: ExternalCheckerModelGrant | None = None, ) -> ExternalCheckerExecutionResponse: """Exchange one bounded request and revalidate every returned fact.""" try: checked = ExternalCheckerExecutionRequest.model_validate(request) if type(grant) is not ExternalCheckerMaterialGrant: raise ValueError("external checker material grant is invalid") + if model_grant is not None and ( + type(model_grant) is not ExternalCheckerModelGrant + or checked.identity.phase != "post_submit" + or model_grant.binding_digest != checked.request_digest + ): + raise ValueError("external checker model grant is invalid") payload = json.dumps( { "protocol_version": _PROTOCOL, @@ -78,6 +87,14 @@ async def execute( "grant_id": grant.grant_id, "binding_digest": grant.binding_digest, }, + "model_grant": ( + None + if model_grant is None + else { + "grant_id": model_grant.grant_id, + "binding_digest": model_grant.binding_digest, + } + ), }, ensure_ascii=False, allow_nan=False, diff --git a/backend/app/adapters/checkers/model_execution.py b/backend/app/adapters/checkers/model_execution.py new file mode 100644 index 000000000..db1f1fd4d --- /dev/null +++ b/backend/app/adapters/checkers/model_execution.py @@ -0,0 +1,100 @@ +"""Compose optional project-image model callbacks into the sole POST execution path.""" + +from app.interfaces.external_services import ExternalServiceUnavailableError +from app.modules.model_usage.api import ( + ModelCallExecutionFacts, + ModelCallUnavailable, + ModelPaidSlotKey, +) +from app.modules.checkers.api.model_profiles import configured_model_profile_selector + + +def model_execution_facts(request) -> ModelCallExecutionFacts: + """Translate the existing typed POST identity without inventing new lineage.""" + identity = request.identity + if identity.phase != "post_submit": + raise ModelCallUnavailable("model_call_execution_unavailable") + return ModelCallExecutionFacts( + project_id=identity.project_id, + task_id=identity.task_id, + assignment_id=identity.assignment_id, + submission_id=identity.submission_id, + submission_version=identity.submission_version, + evaluation_request_id=identity.evaluation_request_id, + evaluation_generation=identity.evaluation_generation, + checker_run_id=identity.attempt_id, + worker_lease_id=identity.lease_id, + worker_lease_generation=identity.lease_generation, + worker_lease_expires_at=identity.lease_expires_at, + ) + + +class ModelAwareExternalExecution: + """Grant one private model callback and verify its completion before a verdict.""" + + def __init__(self, *, execution, sessions): + self._execution, self._sessions = execution, sessions + + @property + def identity(self): + """Keep the same fixed Rust provider identity.""" + return self._execution.identity + + async def health(self): + """Use the existing external execution health contract.""" + return await self._execution.health() + + async def execute(self, request, grant, *, model_grant=None): + """Ordinary images run directly; model images must complete their exact call.""" + from app.adapters.auth import checker_model_service_authorization + from app.adapters.checkers import model_call_service, model_invocation_grants + + if model_grant is not None: + raise ExternalServiceUnavailableError(self.identity) + selector = configured_model_profile_selector( + request.configuration, + stage=request.identity.phase, + ) + if selector is None: + return await self._execution.execute(request, grant) + facts = model_execution_facts(request) + slot = ModelPaidSlotKey( + project_id=facts.project_id, + assignment_id=facts.assignment_id, + submission_id=facts.submission_id, + submission_version=facts.submission_version, + requirement_id=request.input.get("requirement_id"), + registry_entry_digest=request.registry.entry_digest, + configuration_sha256=request.configuration_sha256, + profile_id=selector.profile_id, + profile_version=selector.profile_version, + profile_digest=selector.profile_digest, + ) + try: + async with self._sessions() as session, session.begin(): + authority = await checker_model_service_authorization( + session, facts.evaluation_request_id + ) + callback = await model_invocation_grants(session, authority).issue( + facts, + slot, + request.request_digest, + registry_entry_id=request.registry.registry_entry_id, + ) + response = await self._execution.execute(request, grant, model_grant=callback) + if response.result.outcome == "completed": + async with self._sessions() as session, session.begin(): + authority = await checker_model_service_authorization( + session, facts.evaluation_request_id + ) + effects = await model_invocation_grants(session, authority).completed_effects( + callback.grant_id, + request.request_digest, + facts, + ) + calls = model_call_service(session, authority) + for effect in effects: + await calls.attach_completed(facts, effect) + return response + except ModelCallUnavailable as error: + raise ExternalServiceUnavailableError(self.identity) from error diff --git a/backend/app/adapters/checkers/model_provider.py b/backend/app/adapters/checkers/model_provider.py new file mode 100644 index 000000000..ed0e450d6 --- /dev/null +++ b/backend/app/adapters/checkers/model_provider.py @@ -0,0 +1,287 @@ +"""OpenAI Responses transport for policy-bound checker model calls.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +import math +import re +from typing import Any, Literal, Protocol + +from app.interfaces.checker_model_provider import ( + CHECKER_MODEL_CAPABILITY_KEY, + MAX_CHECKER_MODEL_NAME_LENGTH, + MAX_CHECKER_MODEL_OUTPUT_BYTES, + MAX_CHECKER_MODEL_PROMPT_BYTES, + MAX_CHECKER_MODEL_TOKEN_COUNT, + CheckerModelCreateFailure, + CheckerModelCreateOutcome, + CheckerModelCreateResult, + CheckerModelObservation, + CheckerModelProviderStatus, + CheckerModelRecoveryOutcome, + CheckerModelRecoveryResult, + CheckerModelRequest, + CheckerModelResponseReference, + CheckerModelUsage, +) +from app.interfaces.external_services import ExternalServiceAdapterIdentity + + +OPENAI_RESPONSES_PROVIDER_KEY = "openai_responses" +_IDENTITY = ExternalServiceAdapterIdentity( + CHECKER_MODEL_CAPABILITY_KEY, + OPENAI_RESPONSES_PROVIDER_KEY, +) + +ReasoningEffort = Literal["none", "minimal", "low", "medium", "high", "xhigh", "max"] +TextVerbosity = Literal["low", "medium", "high"] +_REASONING_EFFORTS = {"none", "minimal", "low", "medium", "high", "xhigh", "max"} +_TEXT_VERBOSITIES = {"low", "medium", "high"} +_MODEL_IDENTIFIER = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]*$") + + +@dataclass(frozen=True, slots=True) +class OpenAIResponsesSettings: + """Trusted profile settings that an image cannot select or override.""" + + api_key: str = field(repr=False, compare=False) + model: str + maximum_request_bytes: int + maximum_input_tokens: int + maximum_output_tokens: int + maximum_output_bytes: int + request_timeout_seconds: float + reasoning_effort: ReasoningEffort + text_verbosity: TextVerbosity + + def __post_init__(self) -> None: + """Reject credentials, model identity, or limits outside the closed profile bounds.""" + if ( + not isinstance(self.api_key, str) + or not self.api_key + or len(self.api_key) > 8_192 + or not isinstance(self.model, str) + or not self.model + or len(self.model) > MAX_CHECKER_MODEL_NAME_LENGTH + or _MODEL_IDENTIFIER.fullmatch(self.model) is None + or type(self.maximum_request_bytes) is not int + or not 1 <= self.maximum_request_bytes <= MAX_CHECKER_MODEL_PROMPT_BYTES + or type(self.maximum_input_tokens) is not int + or not 1 <= self.maximum_input_tokens <= MAX_CHECKER_MODEL_TOKEN_COUNT + or type(self.maximum_output_tokens) is not int + or not 1 <= self.maximum_output_tokens <= MAX_CHECKER_MODEL_TOKEN_COUNT + or type(self.maximum_output_bytes) is not int + or not 1 <= self.maximum_output_bytes <= MAX_CHECKER_MODEL_OUTPUT_BYTES + or type(self.request_timeout_seconds) is not float + or not math.isfinite(self.request_timeout_seconds) + or not 0.1 <= self.request_timeout_seconds <= 3_600.0 + or self.reasoning_effort not in _REASONING_EFFORTS + or self.text_verbosity not in _TEXT_VERBOSITIES + ): + raise ValueError("OpenAI checker model settings are invalid") + + +class _InputTokenResource(Protocol): + """Describe the provider endpoint that counts input without creating an effect.""" + + async def count(self, **kwargs: Any) -> object: + """Count the exact request input without creating a response.""" + + +class _ResponsesResource(Protocol): + """Describe the exact stored-response operations used by this adapter.""" + + input_tokens: _InputTokenResource + + async def create(self, **kwargs: Any) -> object: + """Create one stored background response.""" + + async def retrieve(self, response_id: str, **kwargs: Any) -> object: + """Retrieve one already known response.""" + + +class _OpenAIClient(Protocol): + """Describe the bounded client surface owned by one profile adapter.""" + + responses: _ResponsesResource + + async def close(self) -> None: + """Close client transport resources.""" + + +class OpenAIResponsesCheckerModelProvider: + """Translate one fixed profile to stored OpenAI background Responses calls.""" + + __slots__ = ("_client", "_settings") + + def __init__(self, settings: OpenAIResponsesSettings, client: _OpenAIClient) -> None: + """Bind validated trusted settings to one structurally valid SDK client.""" + if type(settings) is not OpenAIResponsesSettings: + raise ValueError("OpenAI checker model settings are invalid") + if not hasattr(client, "responses") or not callable(getattr(client, "close", None)): + raise ValueError("OpenAI checker model client is invalid") + self._settings = settings + self._client = client + + @property + def identity(self) -> ExternalServiceAdapterIdentity: + """Return the fixed identity checked by the shared typed factory.""" + return _IDENTITY + + async def create(self, request: CheckerModelRequest) -> CheckerModelCreateResult: + """Count first, then dispatch one create without any adapter retry.""" + if type(request) is not CheckerModelRequest: + raise ValueError("checker model request is invalid") + if len(request.prompt.encode("utf-8")) > self._settings.maximum_request_bytes: + return CheckerModelCreateResult( + CheckerModelCreateOutcome.NO_EFFECT, + failure=CheckerModelCreateFailure.REQUEST_BYTES_EXCEEDED, + ) + try: + counted = await self._client.responses.input_tokens.count( + **self._request_parameters(request.prompt) + ) + input_tokens = getattr(counted, "input_tokens", None) + if ( + type(input_tokens) is not int + or not 0 <= input_tokens <= MAX_CHECKER_MODEL_TOKEN_COUNT + ): + raise ValueError("provider input count is invalid") + except Exception: + return CheckerModelCreateResult( + CheckerModelCreateOutcome.NO_EFFECT, + failure=CheckerModelCreateFailure.INPUT_COUNT_UNAVAILABLE, + ) + if input_tokens > self._settings.maximum_input_tokens: + return CheckerModelCreateResult( + CheckerModelCreateOutcome.NO_EFFECT, + failure=CheckerModelCreateFailure.INPUT_LIMIT_EXCEEDED, + ) + + try: + response = await self._client.responses.create( + **self._request_parameters(request.prompt), + background=True, + store=True, + service_tier="default", + max_output_tokens=self._settings.maximum_output_tokens, + ) + except Exception: + return CheckerModelCreateResult( + CheckerModelCreateOutcome.UNCERTAIN, + failure=CheckerModelCreateFailure.DELIVERY_UNCERTAIN, + ) + + known_response = _known_response(response) + try: + observation = self._observation(response) + except (TypeError, ValueError, UnicodeError): + return CheckerModelCreateResult( + CheckerModelCreateOutcome.UNCERTAIN, + known_response=known_response, + failure=CheckerModelCreateFailure.RESPONSE_INVALID, + ) + return CheckerModelCreateResult( + CheckerModelCreateOutcome.ACCEPTED, + observation=observation, + known_response=observation.reference, + ) + + async def recover(self, reference: CheckerModelResponseReference) -> CheckerModelRecoveryResult: + """Retrieve the known provider ID once and never issue create.""" + if type(reference) is not CheckerModelResponseReference: + raise ValueError("checker model response identity is invalid") + try: + response = await self._client.responses.retrieve( + reference.provider_response_id, + ) + except Exception: + return CheckerModelRecoveryResult( + CheckerModelRecoveryOutcome.UNAVAILABLE, + reference, + ) + try: + observation = self._observation(response) + if observation.reference != reference: + raise ValueError("provider response identity changed") + except (TypeError, ValueError, UnicodeError): + return CheckerModelRecoveryResult( + CheckerModelRecoveryOutcome.INVALID, + reference, + ) + return CheckerModelRecoveryResult( + CheckerModelRecoveryOutcome.OBSERVED, + reference, + observation, + ) + + async def aclose(self) -> None: + """Close the exact SDK client created for this profile adapter.""" + await self._client.close() + + def _request_parameters(self, prompt: str) -> dict[str, object]: + """Build provider parameters solely from the locked profile and supplied prompt.""" + return { + "model": self._settings.model, + "input": prompt, + "reasoning": {"effort": self._settings.reasoning_effort}, + "text": {"verbosity": self._settings.text_verbosity}, + "parallel_tool_calls": False, + "tools": [], + "truncation": "disabled", + "timeout": self._settings.request_timeout_seconds, + } + + def _observation(self, response: object) -> CheckerModelObservation: + """Validate provider identity, bounds, status, output, and usage as one observation.""" + reference = CheckerModelResponseReference(getattr(response, "id", None)) + if getattr(response, "service_tier", None) != "default": + raise ValueError("provider pricing tier differs from the locked standard rates") + provider_model = getattr(response, "model", None) + if provider_model != self._settings.model: + raise ValueError("provider model identity changed") + status = CheckerModelProviderStatus(getattr(response, "status", None)) + output = getattr(response, "output", None) + if type(output) is not list: + raise ValueError("provider output is invalid") + output_text = None if not output else getattr(response, "output_text", None) + if output_text is not None: + if not isinstance(output_text, str): + raise ValueError("provider output is invalid") + if len(output_text.encode("utf-8")) > self._settings.maximum_output_bytes: + raise ValueError("provider output exceeds profile bound") + usage_value = getattr(response, "usage", None) + usage = None if usage_value is None else _usage(usage_value) + if usage is not None and ( + usage.input_tokens > self._settings.maximum_input_tokens + or usage.output_tokens > self._settings.maximum_output_tokens + ): + raise ValueError("provider usage exceeds profile bound") + return CheckerModelObservation( + reference=reference, + provider_model=provider_model, + status=status, + output_text=output_text, + usage=usage, + ) + + +def _known_response(response: object) -> CheckerModelResponseReference | None: + """Recover a valid provider response identity without trusting other response fields.""" + try: + return CheckerModelResponseReference(getattr(response, "id", None)) + except (TypeError, ValueError): + return None + + +def _usage(value: object) -> CheckerModelUsage: + """Normalize the provider's nested token counters through the strict usage contract.""" + input_details = getattr(value, "input_tokens_details", None) + output_details = getattr(value, "output_tokens_details", None) + return CheckerModelUsage( + input_tokens=getattr(value, "input_tokens", None), + cached_input_tokens=getattr(input_details, "cached_tokens", None), + output_tokens=getattr(value, "output_tokens", None), + reasoning_tokens=getattr(output_details, "reasoning_tokens", None), + total_tokens=getattr(value, "total_tokens", None), + ) diff --git a/backend/app/adapters/checkers/model_proxy.py b/backend/app/adapters/checkers/model_proxy.py new file mode 100644 index 000000000..6d00b783d --- /dev/null +++ b/backend/app/adapters/checkers/model_proxy.py @@ -0,0 +1,399 @@ +"""Durable model-call orchestration for project-owned checker images.""" + +import asyncio +import hashlib +import json +from collections.abc import Callable +from contextlib import AbstractAsyncContextManager +from dataclasses import asdict +from uuid import uuid4 + +from sqlalchemy import func, select + +from app.core.hashing import canonical_json_hash +from app.core.identifiers import new_record_id +from app.interfaces.checker_model_provider import ( + CheckerModelCreateOutcome, + CheckerModelProvider, + CheckerModelProviderStatus, + CheckerModelRecoveryOutcome, + CheckerModelRequest, + CheckerModelResponseReference, +) +from app.interfaces.checker_model_proxy import ModelProxyReply, ModelProxyRequest +from app.modules.checkers.api.output_custody import ( + CheckerArtifactOutputPort, + CheckerOutputArtifactRequest, + CheckerOutputBindingRequest, + CheckerOutputSelector, + CheckerOutputUnavailable, +) +from app.modules.model_usage.api import ( + KnownNoEffectProof, + ModelCallBudgetUnavailable, + ModelCallEffectAuthorityFacts, + ModelCallEvidence, + ModelCallUnavailable, + ModelCallUsage, + ModelProfileSelector, + PreparedModelCallAttempt, + ProjectModelProfile, +) + + +def _canonical_bytes(value: object) -> bytes: + """Serialize exact bounded private evidence without logging its contents.""" + return json.dumps( + value, sort_keys=True, ensure_ascii=False, separators=(",", ":"), allow_nan=False + ).encode("utf-8") + + +def _sha(value: bytes) -> str: + return "sha256:" + hashlib.sha256(value).hexdigest() + + +def _unavailable( + code: str, + call_index: int, + *, + session_exhausted: bool = True, +) -> ModelProxyReply: + return ModelProxyReply( + outcome="unavailable", + output=None, + receipt_digest=None, + failure_code=code, + call_index=call_index, + session_exhausted=session_exhausted, + ) + + +class CheckerModelProxy: + """Persist intent before provider I/O and recover the original effect across runs.""" + + def __init__( + self, + *, + sessions, + authorization, + grants, + calls, + profiles, + outputs: CheckerArtifactOutputPort, + bindings, + provider: Callable[ + [ProjectModelProfile], AbstractAsyncContextManager[CheckerModelProvider] + ], + ): + self._sessions, self._authorization = sessions, authorization + self._grants, self._calls, self._profiles = grants, calls, profiles + self._outputs, self._bindings, self._provider = outputs, bindings, provider + + async def handle(self, request: ModelProxyRequest) -> ModelProxyReply: + """Consume one invocation capability; failed infrastructure never becomes a verdict.""" + try: + return await self._handle(request) + except ModelCallBudgetUnavailable: + return _unavailable("model_budget_unavailable", request.call_index) + except (ModelCallUnavailable, CheckerOutputUnavailable): + return _unavailable("model_call_unavailable", request.call_index) + + async def _handle(self, request: ModelProxyRequest) -> ModelProxyReply: + resolved_receipts = [ + { + "receipt_sha256": receipt.receipt_sha256, + "receipt": json.loads(receipt.canonical_receipt_utf8), + } + for receipt in request.resolved_receipts + ] + data = _canonical_bytes( + { + "schema_version": "workstream.model_session_call.v2", + "call_index": request.call_index, + "prompt": request.prompt, + "resolved_receipts": resolved_receipts, + } + ) + # The trusted relay resolves opaque handles; providers receive the exact retained + # facts, never image-authored receipt claims. Keep the ordinary prompt unchanged + # for calls without tools while using one closed envelope when receipts are present. + provider_prompt = request.prompt + if resolved_receipts: + provider_prompt = _canonical_bytes( + { + "schema_version": "workstream.model_prompt_with_receipts.v1", + "prompt": request.prompt, + "trusted_receipts": resolved_receipts, + } + ).decode("utf-8") + try: + provider_request = CheckerModelRequest(provider_prompt) + except ValueError: + return _unavailable("model_request_invalid", request.call_index) + request_sha = _sha(data) + async with self._sessions() as session, session.begin(): + authority = await self._authorization(session, uuid4()) + grants = self._grants(session, authority) + facts = await grants.resolve(request.grant_id, request.request_digest) + paid_slot = facts.slot.model_copy(update={"slot_index": request.call_index}) + profile = await self._profiles(session).read_exact( + ModelProfileSelector( + profile_id=facts.slot.profile_id, + profile_version=facts.slot.profile_version, + profile_digest=facts.slot.profile_digest, + ), + project_id=facts.slot.project_id, + ) + receipt_bytes = sum( + len(receipt.canonical_receipt_utf8.encode("utf-8")) + for receipt in request.resolved_receipts + ) + if ( + request.call_index >= profile.spec.maximum_calls + or len(request.resolved_receipts) > profile.spec.maximum_tool_calls + or receipt_bytes > profile.spec.maximum_tool_output_bytes + ): + return _unavailable("model_session_exhausted", request.call_index) + if max(len(data), len(provider_prompt.encode("utf-8"))) > ( + profile.spec.maximum_request_bytes + ): + return _unavailable("model_request_too_large", request.call_index) + attached = await self._calls(session, authority).prepare_attempt( + facts.execution, + paid_slot, + registry_entry_id=facts.registry_entry_id, + request_sha256=request_sha, + ) + await grants.consume_prepared( + facts, + call_index=request.call_index, + request_sha256=request_sha, + prepared_attempt_id=attached.attempt_id, + ) + effect = ModelCallEffectAuthorityFacts( + project_id=facts.execution.project_id, + task_id=facts.execution.task_id, + slot_id=attached.slot_id, + slot_key=attached.slot_key, + attempt_id=attached.attempt_id, + internal_call_request_id=attached.internal_call_request_id, + ) + if attached.state == "completed": + return await self._cached(facts, effect, profile, request.call_index) + if attached.state in {"known_failed", "known_no_effect"}: + return _unavailable("model_call_operator_recovery_required", request.call_index) + async with self._provider(profile) as provider: + dispatch = None + if attached.state == "prepared": + prepared = PreparedModelCallAttempt.model_validate(attached.model_dump()) + request_evidence = await self._store(facts, effect, "model_request", data) + async with self._sessions() as session, session.begin(): + authority = await self._authorization( + session, facts.execution.evaluation_request_id + ) + dispatch = await self._calls(session, authority).reserve_dispatch( + facts.execution, + prepared, + request_evidence, + ) + if dispatch.state == "completed": + return await self._cached(facts, effect, profile, request.call_index) + deadline = asyncio.get_running_loop().time() + profile.spec.deadline_ms / 1000 + if dispatch is not None and not dispatch.replayed: + try: + created = await asyncio.wait_for( + provider.create(provider_request), + timeout=max(0.001, deadline - asyncio.get_running_loop().time()), + ) + except TimeoutError: + await self._record_reference(effect, None) + return _unavailable("model_provider_delivery_uncertain", request.call_index) + if created.outcome is CheckerModelCreateOutcome.NO_EFFECT: + async with self._sessions() as session, session.begin(): + authority = await self._authorization( + session, effect.internal_call_request_id + ) + proof = KnownNoEffectProof( + proof_id=new_record_id(), + proof_digest=canonical_json_hash( + { + "attempt_id": str(effect.attempt_id), + "provider_outcome": created.outcome.value, + "failure_code": created.failure.value, + } + ), + observed_at=await session.scalar(select(func.clock_timestamp())), + ) + await self._calls(session, authority).mark_known_no_effect(effect, proof) + return _unavailable("model_provider_no_effect", request.call_index) + reference = created.known_response + await self._record_reference(effect, reference) + if created.outcome is CheckerModelCreateOutcome.UNCERTAIN: + return _unavailable("model_provider_delivery_uncertain", request.call_index) + observation = created.observation + else: + async with self._sessions() as session, session.begin(): + authority = await self._authorization( + session, facts.execution.evaluation_request_id + ) + recovery = await self._calls(session, authority).attach_recovery( + facts.execution, effect + ) + reference = CheckerModelResponseReference(recovery.provider_response_id) + observation = None + while True: + if observation is not None and observation.status not in { + CheckerModelProviderStatus.QUEUED, + CheckerModelProviderStatus.IN_PROGRESS, + }: + if observation.usage is None: + return _unavailable("model_provider_usage_unavailable", request.call_index) + return await self._complete( + facts, effect, profile, observation, request.call_index + ) + remaining = deadline - asyncio.get_running_loop().time() + if remaining <= 0: + return _unavailable("model_provider_pending", request.call_index) + await asyncio.sleep(min(1, remaining)) + try: + recovered = await asyncio.wait_for( + provider.recover(reference), + timeout=max(0.001, deadline - asyncio.get_running_loop().time()), + ) + except TimeoutError: + return _unavailable("model_provider_pending", request.call_index) + if recovered.outcome is not CheckerModelRecoveryOutcome.OBSERVED: + return _unavailable( + "model_provider_recovery_unavailable", request.call_index + ) + observation = recovered.observation + + async def _record_reference(self, effect, reference) -> None: + """Persist the provider identity before response storage or further polling.""" + provider_id = reference.provider_response_id if reference is not None else None + async with self._sessions() as session, session.begin(): + authority = await self._authorization(session, effect.internal_call_request_id) + await self._calls(session, authority).mark_uncertain( + effect, + canonical_json_hash({"provider_response_id": provider_id}), + provider_response_id=provider_id, + ) + + async def _store(self, facts, effect, role: str, data: bytes) -> ModelCallEvidence: + """Use ART's existing prepare, immutable put, verification and binding pipeline.""" + selector = CheckerOutputSelector( + paid_slot=effect.slot_key, + task_id=facts.execution.task_id, + model_call_slot_id=effect.slot_id, + model_call_attempt_id=effect.attempt_id, + slot_key=role, + content_sha256=_sha(data), + execution=facts.execution if role == "model_request" else None, + ) + + async def source(): + yield data + + stored = await self._outputs.store(CheckerOutputArtifactRequest(selector, source())) + if stored.status != "verified" or stored.verification_receipt_id is None: + raise ModelCallUnavailable("model_call_evidence_unavailable") + async with self._sessions() as session, session.begin(): + binding = await self._bindings(session).bind_checker_output( + CheckerOutputBindingRequest( + selector, + stored.put_attempt_id, + stored.verification_receipt_id, + ) + ) + return ModelCallEvidence( + binding_id=binding.binding_id, + content_id=binding.content_id, + put_attempt_id=binding.put_attempt_id, + verification_receipt_id=binding.verification_receipt_id, + content_sha256=selector.content_sha256, + byte_count=len(data), + ) + + async def _complete( + self, facts, effect, profile, observation, call_index: int + ) -> ModelProxyReply: + """Record authenticated terminal evidence before settling once, including late results.""" + data = _canonical_bytes( + { + "schema_version": "workstream.checker_model_response.v1", + "observation": asdict(observation), + } + ) + output = observation.output_text + oversized_output = output is not None and ( + len(output.encode("utf-8")) > profile.spec.maximum_output_bytes + ) + if len(data) > profile.spec.maximum_response_bytes: + return _unavailable("model_response_too_large", call_index) + evidence = await self._store(facts, effect, "model_response", data) + raw_usage = observation.usage + usage = ModelCallUsage( + input_tokens=raw_usage.input_tokens, + cached_input_tokens=raw_usage.cached_input_tokens, + output_tokens=raw_usage.output_tokens, + reasoning_tokens=raw_usage.reasoning_tokens, + ) + usable = ( + not oversized_output + and observation.status is CheckerModelProviderStatus.COMPLETED + and bool(observation.output_text) + ) + async with self._sessions() as session, session.begin(): + authority = await self._authorization(session, effect.internal_call_request_id) + calls = self._calls(session, authority) + operation = calls.complete if usable else calls.complete_known_failure + await operation( + effect, + provider_response_id=observation.reference.provider_response_id, + provider_model_snapshot=observation.provider_model, + response_evidence=evidence, + usage=usage, + ) + if oversized_output: + return _unavailable("model_response_too_large", call_index) + if not usable: + return _unavailable("model_provider_failed", call_index) + # Fresh current-run attachment gates image access; late completion itself remains retained. + return await self._cached(facts, effect, profile, call_index) + + async def _cached(self, facts, effect, profile, call_index: int) -> ModelProxyReply: + """Reauthorize current invocation before releasing the original verified response.""" + async with self._sessions() as session, session.begin(): + authority = await self._authorization(session, facts.execution.evaluation_request_id) + completed = await self._calls(session, authority).attach_completed( + facts.execution, effect + ) + selector = CheckerOutputSelector( + paid_slot=effect.slot_key, + task_id=facts.execution.task_id, + model_call_slot_id=effect.slot_id, + model_call_attempt_id=effect.attempt_id, + slot_key="model_response", + content_sha256=completed.response_evidence.content_sha256, + ) + data = await self._outputs.read_response(selector, completed.response_evidence) + async with self._sessions() as session, session.begin(): + authority = await self._authorization(session, facts.execution.evaluation_request_id) + await self._calls(session, authority).attach_completed(facts.execution, effect) + body = json.loads(data) + if body.get("schema_version") != "workstream.checker_model_response.v1": + raise ModelCallUnavailable("model_call_response_unavailable") + output = body["observation"]["output_text"] + if not isinstance(output, str) or ( + len(output.encode("utf-8")) > profile.spec.maximum_output_bytes + ): + return _unavailable("model_response_too_large", call_index) + receipt = canonical_json_hash(completed.model_dump(mode="json", exclude={"replayed"})) + return ModelProxyReply( + outcome="completed", + output=output, + receipt_digest=receipt, + failure_code=None, + call_index=call_index, + session_exhausted=call_index + 1 >= profile.spec.maximum_calls, + ) diff --git a/backend/app/adapters/checkers/model_proxy_transport.py b/backend/app/adapters/checkers/model_proxy_transport.py new file mode 100644 index 000000000..3283484bd --- /dev/null +++ b/backend/app/adapters/checkers/model_proxy_transport.py @@ -0,0 +1,400 @@ +"""Private bounded Unix transport for request-bound checker model calls.""" + +from __future__ import annotations + +import asyncio +import json +import os +import stat +import struct +from pathlib import Path +from typing import Any + +from app.interfaces.checker_model_proxy import ( + MODEL_PROXY_MAXIMUM_FRAME_BYTES, + MODEL_PROXY_PROTOCOL, + ModelProxyHandler, + ModelProxyReply, + ModelProxyRequest, + ModelProxyResolvedReceipt, +) +from app.interfaces.setup_model_proxy import ( + SETUP_MODEL_PROXY_PROTOCOL, + SetupModelProxyHandler, + SetupModelProxyReply, + SetupModelProxyRequest, + setup_model_proxy_request_from_wire, +) + + +class ModelProxyTransportError(RuntimeError): + """Base error for model-proxy transport ownership and lifecycle failures.""" + + +class ModelProxyCustodyError(ModelProxyTransportError): + """The configured private socket path is not safe to own.""" + + +class ModelProxyCleanupError(ModelProxyTransportError): + """The transport cannot prove that the remaining socket path is its own.""" + + +class ModelProxyTransport: + """Own one private AF_UNIX listener and retain every dispatched handler.""" + + def __init__( + self, + *, + socket_path: Path, + handler: ModelProxyHandler, + setup_handler: SetupModelProxyHandler | None = None, + maximum_connections: int = 16, + read_timeout_seconds: float = 2.0, + write_timeout_seconds: float = 2.0, + shutdown_timeout_seconds: float = 30.0, + ) -> None: + if ( + not isinstance(socket_path, Path) + or not callable(handler) + or (setup_handler is not None and not callable(setup_handler)) + or type(maximum_connections) is not int + or not 1 <= maximum_connections <= 256 + or type(read_timeout_seconds) is not float + or not 0.01 <= read_timeout_seconds <= 60.0 + or type(write_timeout_seconds) is not float + or not 0.01 <= write_timeout_seconds <= 60.0 + or type(shutdown_timeout_seconds) is not float + or not 0.01 <= shutdown_timeout_seconds <= 7_200.0 + ): + raise ValueError("model proxy transport configuration is invalid") + self._socket_path = socket_path + self._handler = handler + self._setup_handler = setup_handler + self._maximum_connections = maximum_connections + self._read_timeout_seconds = read_timeout_seconds + self._write_timeout_seconds = write_timeout_seconds + self._shutdown_timeout_seconds = shutdown_timeout_seconds + self._server: asyncio.AbstractServer | None = None + self._connection_tasks: set[asyncio.Task[None]] = set() + self._handler_tasks: set[asyncio.Task[ModelProxyReply | SetupModelProxyReply]] = set() + self._parent_identity: tuple[int, int] | None = None + self._socket_identity: tuple[int, int] | None = None + self._closing = False + + @property + def socket_path(self) -> Path: + return self._socket_path + + async def start(self) -> None: + """Bind only an absent path beneath its canonical same-UID private parent.""" + if self._server is not None or self._closing: + raise ModelProxyTransportError("model proxy transport already started") + parent = self._validated_parent() + try: + os.lstat(self._socket_path) + except FileNotFoundError: + pass + else: + raise ModelProxyCustodyError("model proxy socket path is occupied") + + server: asyncio.AbstractServer | None = None + created_identity: tuple[int, int] | None = None + try: + server = await asyncio.start_unix_server( + self._admit_connection, + path=self._socket_path, + backlog=self._maximum_connections, + limit=MODEL_PROXY_MAXIMUM_FRAME_BYTES + 4, + ) + metadata = os.lstat(self._socket_path) + created_identity = (metadata.st_dev, metadata.st_ino) + if not stat.S_ISSOCK(metadata.st_mode) or stat.S_ISLNK(metadata.st_mode): + raise ModelProxyCustodyError("model proxy socket identity is invalid") + os.chmod(self._socket_path, 0o600, follow_symlinks=False) + metadata = os.lstat(self._socket_path) + if not _private_socket(metadata): + raise ModelProxyCustodyError("model proxy socket custody is invalid") + if created_identity != (metadata.st_dev, metadata.st_ino): + raise ModelProxyCustodyError("model proxy socket identity changed") + except BaseException: + if server is not None: + server.close() + await server.wait_closed() + if created_identity is not None: + self._unlink_owned(created_identity, parent) + raise + self._parent_identity = parent + self._socket_identity = created_identity + self._server = server + + async def close(self) -> None: + """Stop admission, drain retained work, then unlink only the owned inode.""" + self._closing = True + if self._server is not None: + self._server.close() + deadline = asyncio.get_running_loop().time() + self._shutdown_timeout_seconds + while self._connection_tasks or self._handler_tasks: + remaining = deadline - asyncio.get_running_loop().time() + if remaining <= 0: + raise TimeoutError("model proxy handler drain timed out") + tasks: set[asyncio.Task[Any]] = { + *self._connection_tasks, + *self._handler_tasks, + } + done, pending = await asyncio.wait(tasks, timeout=remaining) + self._connection_tasks.difference_update(done) + self._handler_tasks.difference_update(done) + if pending: + raise TimeoutError("model proxy handler drain timed out") + if self._server is not None: + remaining = deadline - asyncio.get_running_loop().time() + if remaining <= 0: + raise TimeoutError("model proxy listener drain timed out") + async with asyncio.timeout(remaining): + await self._server.wait_closed() + self._server = None + if self._socket_identity is not None and self._parent_identity is not None: + self._unlink_owned(self._socket_identity, self._parent_identity) + self._socket_identity = None + self._parent_identity = None + + async def __aenter__(self) -> ModelProxyTransport: + await self.start() + return self + + async def __aexit__(self, *_: object) -> None: + await self.close() + + def _validated_parent(self) -> tuple[int, int]: + path = self._socket_path + encoded = os.fsencode(path) + if ( + not path.is_absolute() + or not encoded + or b"\0" in encoded + or len(encoded) >= 108 + or path.name in {"", ".", ".."} + ): + raise ModelProxyCustodyError("model proxy socket path is invalid") + parent = path.parent + try: + metadata = os.lstat(parent) + canonical = parent.resolve(strict=True) + except OSError as exc: + raise ModelProxyCustodyError("model proxy parent is unavailable") from exc + if canonical != parent or not _private_directory(metadata): + raise ModelProxyCustodyError("model proxy parent custody is invalid") + return (metadata.st_dev, metadata.st_ino) + + def _admit_connection( + self, + reader: asyncio.StreamReader, + writer: asyncio.StreamWriter, + ) -> None: + if ( + self._closing + or len(self._connection_tasks) >= self._maximum_connections + or len(self._handler_tasks) >= self._maximum_connections + ): + writer.close() + return + task = asyncio.create_task( + self._serve_connection(reader, writer), + name="checker-model-proxy-connection", + ) + self._connection_tasks.add(task) + task.add_done_callback(self._connection_finished) + + def _connection_finished(self, task: asyncio.Task[None]) -> None: + self._connection_tasks.discard(task) + if not task.cancelled(): + task.exception() + + def _handler_finished( + self, task: asyncio.Task[ModelProxyReply | SetupModelProxyReply] + ) -> None: + self._handler_tasks.discard(task) + if not task.cancelled(): + task.exception() + + async def _serve_connection( + self, + reader: asyncio.StreamReader, + writer: asyncio.StreamWriter, + ) -> None: + try: + async with asyncio.timeout(self._read_timeout_seconds): + header = await reader.readexactly(4) + size = struct.unpack(">I", header)[0] + if size == 0 or size > MODEL_PROXY_MAXIMUM_FRAME_BYTES: + return + payload = await reader.readexactly(size) + request = _decode_wire_request(payload) + handler = self._handler + if type(request) is SetupModelProxyRequest: + if self._setup_handler is None: + return + handler = self._setup_handler + try: + awaitable = handler(request) + except BaseException: + return + handler_task = asyncio.create_task( + awaitable, + name="checker-model-proxy-handler", + ) + self._handler_tasks.add(handler_task) + handler_task.add_done_callback(self._handler_finished) + try: + reply = await asyncio.shield(handler_task) + except BaseException: + return + if type(reply) not in {ModelProxyReply, SetupModelProxyReply}: + return + body = json.dumps( + reply.wire_value(), + ensure_ascii=False, + allow_nan=False, + separators=(",", ":"), + ).encode("utf-8") + if not body or len(body) > MODEL_PROXY_MAXIMUM_FRAME_BYTES: + return + frame = struct.pack(">I", len(body)) + body + writer.write(frame) + async with asyncio.timeout(self._write_timeout_seconds): + await writer.drain() + except ( + TimeoutError, + OSError, + asyncio.IncompleteReadError, + UnicodeError, + ValueError, + json.JSONDecodeError, + ): + return + finally: + writer.close() + try: + async with asyncio.timeout(self._write_timeout_seconds): + await writer.wait_closed() + except (TimeoutError, OSError): + pass + + def _unlink_owned( + self, + socket_identity: tuple[int, int], + parent_identity: tuple[int, int], + ) -> None: + try: + parent = os.lstat(self._socket_path.parent) + except FileNotFoundError: + raise ModelProxyCleanupError("model proxy parent disappeared") from None + if (parent.st_dev, parent.st_ino) != parent_identity or not _private_directory(parent): + raise ModelProxyCleanupError("model proxy parent identity changed") + try: + current = os.lstat(self._socket_path) + except FileNotFoundError: + return + if (current.st_dev, current.st_ino) != socket_identity or not _private_socket(current): + raise ModelProxyCleanupError("model proxy socket identity changed") + try: + self._socket_path.unlink() + except OSError as exc: + raise ModelProxyCleanupError("model proxy socket cleanup failed") from exc + + +def _decode_request(payload: bytes) -> ModelProxyRequest: + value = json.loads( + payload.decode("utf-8"), + parse_constant=_reject_json_constant, + object_pairs_hook=_closed_json_object, + ) + expected = { + "protocol_version", + "grant_id", + "request_digest", + "call_index", + "prompt", + "resolved_receipts", + } + if type(value) is not dict or set(value) != expected: + raise ValueError("model proxy request root is invalid") + if value["protocol_version"] != MODEL_PROXY_PROTOCOL: + raise ValueError("model proxy protocol version is invalid") + return ModelProxyRequest( + grant_id=value["grant_id"], + request_digest=value["request_digest"], + call_index=value["call_index"], + prompt=value["prompt"], + resolved_receipts=tuple( + ModelProxyResolvedReceipt( + receipt_sha256=item["receipt_sha256"], + canonical_receipt_utf8=item["canonical_receipt_utf8"], + ) + for item in _resolved_receipt_values(value["resolved_receipts"]) + ), + ) + + +def _decode_wire_request(payload: bytes) -> ModelProxyRequest | SetupModelProxyRequest: + value = json.loads( + payload.decode("utf-8"), + parse_constant=_reject_json_constant, + object_pairs_hook=_closed_json_object, + ) + if type(value) is not dict: + raise ValueError("model proxy request root is invalid") + if value.get("protocol_version") == SETUP_MODEL_PROXY_PROTOCOL: + return setup_model_proxy_request_from_wire(value) + return _decode_request(payload) + + +def _resolved_receipt_values(value: object) -> list[dict[str, object]]: + """Reject open or duplicate receipt objects before constructing trusted values.""" + if type(value) is not list: + raise ValueError("model proxy resolved receipts are invalid") + expected = {"receipt_sha256", "canonical_receipt_utf8"} + if any(type(item) is not dict or set(item) != expected for item in value): + raise ValueError("model proxy resolved receipts are invalid") + return value + + +def _closed_json_object(pairs: list[tuple[str, Any]]) -> dict[str, Any]: + value: dict[str, Any] = {} + for key, item in pairs: + if key in value: + raise ValueError("duplicate model proxy field") + value[key] = item + return value + + +def _reject_json_constant(_: str) -> None: + raise ValueError("non-finite model proxy number") + + +def _private_directory(metadata: os.stat_result) -> bool: + return ( + stat.S_ISDIR(metadata.st_mode) + and not stat.S_ISLNK(metadata.st_mode) + and stat.S_IMODE(metadata.st_mode) == 0o700 + and metadata.st_uid == os.geteuid() + and metadata.st_gid == os.getegid() + ) + + +def _private_socket(metadata: os.stat_result) -> bool: + return ( + stat.S_ISSOCK(metadata.st_mode) + and not stat.S_ISLNK(metadata.st_mode) + and stat.S_IMODE(metadata.st_mode) == 0o600 + and metadata.st_uid == os.geteuid() + and metadata.st_gid == os.getegid() + ) + + +__all__ = ( + "ModelProxyCleanupError", + "ModelProxyCustodyError", + "ModelProxyTransport", + "ModelProxyTransportError", +) diff --git a/backend/app/adapters/checkers/model_runtime.py b/backend/app/adapters/checkers/model_runtime.py new file mode 100644 index 000000000..84c9e9790 --- /dev/null +++ b/backend/app/adapters/checkers/model_runtime.py @@ -0,0 +1,144 @@ +"""Compose the private model listener with the existing AUTH, CHECKERS and ART owners.""" + +import os +from contextlib import asynccontextmanager + +from app.adapters.artifacts import ( + artifact_preparation_service, + artifact_provider_activation_guard, + checker_output_binding, + checker_output_storage, + configured_artifact_storage_namespace, + create_artifact_scratch_manager, + create_artifact_store_bootstrap, + setup_model_evidence, + setup_model_output_binding, + setup_model_output_storage, + validate_configured_artifact_storage_namespace, +) +from app.adapters.auth import ( + checker_model_service_authorization, + setup_model_service_authorization, +) +from app.adapters.checkers import ( + checker_model_proxy, + create_checker_model_provider, + model_call_service, + model_invocation_grants, + model_profile_reader, +) +from app.adapters.checkers.model_provider import ( + OpenAIResponsesSettings, +) +from app.adapters.checkers.model_proxy_transport import ModelProxyTransport +from app.adapters.model_usage import setup_model_usage_service +from app.adapters.project_agents.setup_model_handler import ( + SetupModelProxyHandler, + configured_setup_provider, +) +from app.adapters.projects import setup_model_usage_owner +from app.modules.model_usage.api import ModelCallUnavailable, ProjectModelProfile + + +@asynccontextmanager +async def configured_model_provider(profile: ProjectModelProfile): + """Construct exactly the provider selected by a trusted immutable profile.""" + spec = profile.spec + if (spec.provider, spec.api) != ("openai_responses", "responses.v1"): + raise ModelCallUnavailable("model_profile_provider_unavailable") + key = os.environ.get("OPENAI_API_KEY", "") + if not key: + raise ModelCallUnavailable("model_provider_credentials_unavailable") + provider = create_checker_model_provider( + OpenAIResponsesSettings( + api_key=key, + model=spec.model_snapshot, + maximum_request_bytes=spec.maximum_request_bytes, + maximum_input_tokens=spec.maximum_input_tokens, + maximum_output_tokens=spec.maximum_output_tokens, + maximum_output_bytes=spec.maximum_output_bytes, + request_timeout_seconds=float(spec.deadline_ms / 1000), + reasoning_effort=spec.settings.reasoning_effort, + text_verbosity=spec.settings.text_verbosity, + ) + ) + try: + yield provider + finally: + await provider.aclose() + + +@asynccontextmanager +async def configured_model_proxy_runtime(settings, sessions): + """Own one optional listener and retain its storage resources until handlers drain.""" + if settings.checker_model_proxy_socket is None: + yield + return + bootstrap = create_artifact_store_bootstrap( + settings, + activation_guard=artifact_provider_activation_guard(settings, sessions), + ) + manager = None + try: + claim = await validate_configured_artifact_storage_namespace( + bootstrap, + settings, + sessions, + ) + store = bootstrap.initialize_after_namespace_claim(claim) + namespace = configured_artifact_storage_namespace(settings, bootstrap) + manager = create_artifact_scratch_manager(settings) + outputs = checker_output_storage( + sessions=sessions, + store=store, + namespace=namespace, + preparation=artifact_preparation_service(manager), + settings=settings, + ) + setup_outputs = setup_model_output_storage( + sessions=sessions, + store=store, + namespace=namespace, + preparation=artifact_preparation_service(manager), + settings=settings, + owner=setup_model_usage_owner, + ) + proxy = checker_model_proxy( + sessions=sessions, + authorization=checker_model_service_authorization, + grants=model_invocation_grants, + calls=model_call_service, + profiles=model_profile_reader, + outputs=outputs, + bindings=lambda session: checker_output_binding(session, namespace=namespace), + provider=configured_model_provider, + ) + setup_proxy = SetupModelProxyHandler( + sessions=sessions, + authorization=setup_model_service_authorization, + calls=lambda session, authority: setup_model_usage_service( + session, + authority=authority, + evidence=setup_model_evidence(session), + owner=setup_model_usage_owner(session), + ), + profiles=model_profile_reader, + outputs=setup_outputs, + bindings=lambda session: setup_model_output_binding( + session, + namespace=namespace, + owner=setup_model_usage_owner(session), + ), + provider=configured_setup_provider, + ) + async with ModelProxyTransport( + socket_path=settings.checker_model_proxy_socket, + handler=proxy.handle, + setup_handler=setup_proxy.handle, + shutdown_timeout_seconds=7200.0, + ): + yield + finally: + if manager is not None: + manager.close() + bootstrap.close() diff --git a/backend/app/adapters/model_usage/__init__.py b/backend/app/adapters/model_usage/__init__.py new file mode 100644 index 000000000..606936cca --- /dev/null +++ b/backend/app/adapters/model_usage/__init__.py @@ -0,0 +1,83 @@ +"""MODEL-USAGE composition root for shared durable ledger implementations.""" + + +def model_profile_reader(session): + """Compose the immutable project-profile reader for an active transaction.""" + from app.modules.model_usage.profiles import ModelProfileReader + + return ModelProfileReader(session) + + +def model_usage_inspection(session, *, authority): + """Compose the exact-effect inspection service with its AUTH port.""" + from app.modules.model_usage.inspection import ModelUsageInspectionService + + return ModelUsageInspectionService(session, authority) + + +def model_call_service(session, *, authority, current_execution, evidence): + """Compose checker-owned paid-call operations over the shared ledger.""" + from app.modules.model_usage.service import ModelCallService + + return ModelCallService( + session, + authority=authority, + current_execution=current_execution, + evidence=evidence, + ) + + +def model_invocation_grants(session, *, authority, current_execution): + """Compose invocation-grant custody for a current checker execution.""" + from app.modules.model_usage.grants import ModelInvocationGrants + + return ModelInvocationGrants( + session, + authority=authority, + current=current_execution, + ) + + +def model_evidence_custody(session): + """Compose the checker evidence resolver over stable paid effects.""" + from app.modules.model_usage.owner_ports import ModelEvidenceCustody + + return ModelEvidenceCustody(session) + + +def model_recovery_sources(session): + """Compose retained checker recovery-source projection.""" + from app.modules.model_usage.owner_ports import ModelRecoverySources + + return ModelRecoverySources(session) + + +def setup_model_usage_service(session, *, authority, evidence, owner): + """Compose setup-owned paid-call operations with injected owner custody.""" + from app.modules.model_usage.setup import SetupModelUsageService + + return SetupModelUsageService( + session, + authority=authority, + evidence=evidence, + owner=owner, + ) + + +def setup_model_output_reservations(session, *, owner): + """Compose MODEL-USAGE-owned setup ART reservations behind its public port.""" + from app.modules.model_usage.output_custody import SetupModelOutputReservations + + return SetupModelOutputReservations(session, owner=owner) + + +__all__ = ( + "model_call_service", + "model_evidence_custody", + "model_invocation_grants", + "model_profile_reader", + "model_usage_inspection", + "model_recovery_sources", + "setup_model_usage_service", + "setup_model_output_reservations", +) diff --git a/backend/app/adapters/project_agents/omnicore_agent.py b/backend/app/adapters/project_agents/omnicore_agent.py index 72b5f36ee..a1eb455f0 100644 --- a/backend/app/adapters/project_agents/omnicore_agent.py +++ b/backend/app/adapters/project_agents/omnicore_agent.py @@ -34,6 +34,7 @@ ) from app.adapters.project_agents.provider_resilience import ModelCircuitAdmission from app.adapters.project_agents.result_validation import validate_project_guide_runtime_result +from app.adapters.project_agents.setup_model_proxy import SetupModelProxyUnavailable from app.interfaces.external_services import ExternalServiceAdapterIdentity from app.interfaces.project_agents import ( ProjectAgentRuntimeConfigurationError, @@ -47,7 +48,6 @@ from app.interfaces.project_guide_runtime import ProjectGuideRuntimeConfiguration from app.modules.projects.api.guide_documents import GuideRuntimeCapabilities - _OPENROUTER_CHAT_COMPLETIONS_URL = "https://openrouter.ai/api/v1/chat/completions" _READ_DESCRIPTION = "Read bounded text from an assigned original using its exact opaque handle." _SEARCH_DESCRIPTION = "Search bounded text in an assigned original using its exact opaque handle." @@ -136,6 +136,7 @@ "messages", "temperature", "max_tokens", + "reasoning_effort", "top_p", "stream", "tools", @@ -184,17 +185,16 @@ def __init__( raise ProjectAgentRuntimeConfigurationError( "project guide runtime requires draft and review turns" ) - credential = os.environ.get("OPENROUTER_API_KEY") - if not credential: - raise ProjectAgentRuntimeConfigurationError( - "project guide model credentials are unavailable" - ) + test_upstream = _validated_test_upstream(_test_upstream, _test_authority) + credential = ( + os.environ.get("OPENROUTER_API_KEY") if test_upstream is not None else None + ) or "setup-model-proxy-" + secrets.token_hex(16) self._profile = require_omnicore_agent_adapter_profile( configuration.adapter_profile_values() ) self._configuration = configuration self._credential = credential - self._test_upstream = _validated_test_upstream(_test_upstream, _test_authority) + self._test_upstream = test_upstream self._admission: ModelCircuitAdmission | None = None self._closed = False self._close_task: asyncio.Task[None] | None = None @@ -318,6 +318,8 @@ async def _run( admission = self._admission if admission is None: raise ProjectAgentRuntimeError("project guide runtime was not admitted") + if capabilities.model is None: + raise ProjectAgentRuntimeError("project guide model capability is unavailable") maximum_requests = configuration.maximum_turns * OMNICORE_PROVIDER_ATTEMPTS guard = _LoopbackOpenRouterGuard( credential=self._credential, @@ -328,6 +330,9 @@ async def _run( maximum_logical_turns=configuration.maximum_turns, io_timeout_seconds=max(1, min(300, configuration.timeout_seconds)), upstream=self._test_upstream, + dispatch=None if self._test_upstream is not None else capabilities.model, + reasoning_effort=capabilities.model.reasoning_effort, + text_verbosity=capabilities.model.text_verbosity, ) agents: list[Any] = [] await guard.start() @@ -375,6 +380,7 @@ async def search_document( async def run_phase(phase: str, phase_prompt: str, turn_ceiling: int) -> dict: nonlocal corrections + guard.phase = "draft" if phase == "checker_specification" else "review" guard.phase_turn_ceiling = turn_ceiling while True: remaining = turn_ceiling - guard.logical_turns @@ -389,6 +395,9 @@ async def run_phase(phase: str, phase_prompt: str, turn_ceiling: int) -> dict: base_url=guard.base_url, registry=registry, maximum_steps=remaining, + reasoning_effort=capabilities.model.reasoning_effort, + text_verbosity=capabilities.model.text_verbosity, + maximum_output_tokens=capabilities.model.maximum_output_tokens, phase_instruction=( "Return only one complete JSON object matching this exact " "checker-specification schema: " @@ -419,6 +428,7 @@ async def run_phase(phase: str, phase_prompt: str, turn_ceiling: int) -> dict: if corrections >= 2 or turn_ceiling - guard.logical_turns < 2: raise ProjectGuideCompilationInvalidOutputError("schema_invalid") corrections += 1 + guard.phase = "correction" phase_prompt = json.dumps( { "phase": phase, @@ -475,7 +485,9 @@ async def run_phase(phase: str, phase_prompt: str, turn_ceiling: int) -> dict: raise cleanup_error def _require_credential_unchanged(self) -> None: - """Reject SDK lifecycle drift without rewriting process-global state.""" + """Keep fake-upstream tests pinned without reading provider secrets in production.""" + if self._test_upstream is None: + return current = os.environ.get("OPENROUTER_API_KEY") if current is None or not hmac.compare_digest(current, self._credential): raise ProjectAgentRuntimeError("project guide model credential changed") @@ -490,6 +502,9 @@ def _build_agent( base_url: str, registry: Any, maximum_steps: int, + reasoning_effort: str, + text_verbosity: str, + maximum_output_tokens: int, phase_instruction: str, ) -> Any: """Construct one phase with a fresh private in-memory session and no extras.""" @@ -533,7 +548,9 @@ def _build_agent( "api_key": credential, "base_url": base_url, "temperature": 0, - "max_tokens": OMNICORE_MAX_TOKENS, + "max_tokens": min(OMNICORE_MAX_TOKENS, maximum_output_tokens), + "reasoning_effort": reasoning_effort, + "verbosity": text_verbosity, }, mcp_tools=[], local_tools=registry, @@ -656,6 +673,9 @@ def __init__( maximum_logical_turns: int, io_timeout_seconds: int, upstream: _UpstreamTarget | None, + dispatch=None, + reasoning_effort: str = "medium", + text_verbosity: str = "low", ) -> None: """Bind one attempt's credential, model, limits, and fixed upstream.""" self._credential = credential @@ -665,8 +685,12 @@ def __init__( self._maximum_requests = maximum_requests self._maximum_logical_turns = maximum_logical_turns self.phase_turn_ceiling = maximum_logical_turns + self.phase = "draft" self._io_timeout_seconds = io_timeout_seconds self._upstream_url = (upstream or _PRODUCTION_UPSTREAM).url + self._dispatch = dispatch + self._reasoning_effort = reasoning_effort + self._text_verbosity = text_verbosity self._path_token = secrets.token_urlsafe(32) self._server: asyncio.AbstractServer | None = None self._tasks: set[asyncio.Task[None]] = set() @@ -699,15 +723,16 @@ def logical_turns(self) -> int: return self._logical_turns async def start(self) -> None: - """Open the bounded loopback listener and proxy-free upstream client.""" - import httpx - - self._client = httpx.AsyncClient( - trust_env=False, - follow_redirects=False, - timeout=self._io_timeout_seconds, - ) - self._client.headers.clear() + """Open the bounded loopback listener and an optional fake-test upstream.""" + if self._dispatch is None: + import httpx + + self._client = httpx.AsyncClient( + trust_env=False, + follow_redirects=False, + timeout=self._io_timeout_seconds, + ) + self._client.headers.clear() try: self._server = await asyncio.start_server( self._accepted, @@ -717,13 +742,15 @@ async def start(self) -> None: start_serving=True, ) except BaseException: - await self._client.aclose() + if self._client is not None: + await self._client.aclose() raise sockets = self._server.sockets or [] if len(sockets) != 1: self._server.close() await self._server.wait_closed() - await self._client.aclose() + if self._client is not None: + await self._client.aclose() raise RuntimeError("OpenRouter guard listener is invalid") self._port = int(sockets[0].getsockname()[1]) @@ -882,7 +909,11 @@ async def _read_request(self, reader: asyncio.StreamReader) -> _ParsedRequest: raise _GuardRejection(400) from None if not isinstance(body, dict): raise _GuardRejection(400) - _validate_request_body(body, expected_model=self._model) + _validate_request_body( + body, + expected_model=self._model, + expected_reasoning_effort=self._reasoning_effort, + ) return _ParsedRequest(body=body) async def _forward(self, request: _ParsedRequest) -> tuple[int, Mapping[str, str], bytes]: @@ -896,13 +927,50 @@ async def _forward(self, request: _ParsedRequest) -> tuple[int, Mapping[str, str self._logical_request_active = True self._logical_turns += 1 self._last_upstream_transient_failure = False - outbound = _rebuild_openrouter_body(request.body, model=self._model) + outbound = _rebuild_openrouter_body( + request.body, + model=self._model, + reasoning_effort=self._reasoning_effort, + text_verbosity=self._text_verbosity, + ) request_body = json.dumps( outbound, ensure_ascii=False, allow_nan=False, separators=(",", ":"), ).encode("utf-8") + if self._dispatch is not None: + try: + reply = await self._dispatch.dispatch(self.phase, outbound) + except SetupModelProxyUnavailable: + self._last_upstream_transient_failure = True + raise _GuardRejection(502) from None + assert reply.status_code is not None and reply.response_json is not None + response_body = reply.response_json.encode("utf-8") + self._last_upstream_transient_failure = ( + reply.status_code in {408, 409, 429} or reply.status_code >= 500 + ) + if len(response_body) > self._profile.maximum_response_bytes: + self._last_upstream_transient_failure = False + return 422, {}, _SANITIZED_PROVIDER_ERROR_BODY + if not 200 <= reply.status_code < 300: + return ( + reply.status_code, + dict(reply.response_headers), + _SANITIZED_PROVIDER_ERROR_BODY, + ) + try: + response_body = _rebuild_provider_success_body( + response_body, + expected_model=self._model, + ) + except (UnicodeDecodeError, ValueError, RecursionError, _GuardRejection): + self._last_upstream_transient_failure = False + return 422, {}, _SANITIZED_PROVIDER_ERROR_BODY + self._admission.request_succeeded() + self._logical_request_active = False + return reply.status_code, dict(reply.response_headers), response_body + assert self._client is not None self._client.cookies.clear() try: async with self._client.stream( @@ -1003,7 +1071,12 @@ def __init__(self, status: int) -> None: self.status = status -def _validate_request_body(body: dict[str, Any], *, expected_model: str) -> None: +def _validate_request_body( + body: dict[str, Any], + *, + expected_model: str, + expected_reasoning_effort: str, +) -> None: """Accept only the pinned LiteLLM request fields and configured model.""" if set(body) - _ALLOWED_BODY_FIELDS: raise _GuardRejection(400) @@ -1015,6 +1088,8 @@ def _validate_request_body(body: dict[str, Any], *, expected_model: str) -> None raise _GuardRejection(400) if body.get("stream", False) is not False: raise _GuardRejection(400) + if body.get("reasoning_effort") != expected_reasoning_effort: + raise _GuardRejection(400) if "tool_choice" in body and body["tool_choice"] != "auto": raise _GuardRejection(400) temperature = body.get("temperature") @@ -1096,7 +1171,13 @@ def _validate_tool_call(call: object) -> None: raise _GuardRejection(400) -def _rebuild_openrouter_body(body: dict[str, Any], *, model: str) -> dict[str, Any]: +def _rebuild_openrouter_body( + body: dict[str, Any], + *, + model: str, + reasoning_effort: str, + text_verbosity: str, +) -> dict[str, Any]: """Build trusted OpenRouter state instead of forwarding caller objects.""" rebuilt = { key: body[key] @@ -1115,6 +1196,8 @@ def _rebuild_openrouter_body(body: dict[str, Any], *, model: str) -> dict[str, A "model": model, "tools": _MODEL_TOOLS, "usage": {"include": True}, + "reasoning_effort": reasoning_effort, + "verbosity": text_verbosity, **openrouter_request_options(), } ) diff --git a/backend/app/adapters/project_agents/openai_agent_sdk.py b/backend/app/adapters/project_agents/openai_agent_sdk.py index 3e072be5f..d98f5672d 100644 --- a/backend/app/adapters/project_agents/openai_agent_sdk.py +++ b/backend/app/adapters/project_agents/openai_agent_sdk.py @@ -4,12 +4,11 @@ import asyncio import json -import os from app.adapters.project_agents.compilation_draft import ( CompilationDraftParser, - generated_compilation_schema, contextual_output_issue, + generated_compilation_schema, ) from app.adapters.project_agents.openai_profile import ( require_openai_agents_sdk_adapter_profile, @@ -23,6 +22,7 @@ require_result_sources_opened, validate_project_guide_runtime_result, ) +from app.adapters.project_agents.setup_model_proxy import create_setup_httpx_client from app.interfaces.external_services import ExternalServiceAdapterIdentity from app.interfaces.project_agents import ( ProjectAgentRuntimeConfigurationError, @@ -34,13 +34,12 @@ project_guide_compilation_prompt_bytes, ) from app.interfaces.project_guide_runtime import ProjectGuideRuntimeConfiguration -from .openrouter_policy import openrouter_request_options, require_direct_openrouter_model from app.modules.projects.api.guide_documents import GuideRuntimeCapabilities +from .openrouter_policy import openrouter_request_options, require_direct_openrouter_model _INVALID_SCHEMA_OUTPUT = object() -_OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1" -_OPENAI_BASE_URL = "https://api.openai.com/v1" +_PROXY_BASE_URL = "https://model-proxy.invalid/v1" @@ -57,7 +56,7 @@ def __init__(self, configuration: ProjectGuideRuntimeConfiguration) -> None: raise ProjectAgentRuntimeConfigurationError( "project guide runtime requires draft and review turns" ) - self._provider = _provider_configuration(configuration) + _require_supported_provider(configuration) self._profile = require_openai_agents_sdk_adapter_profile( configuration.adapter_profile_values() ) @@ -157,12 +156,15 @@ async def _run( function_tool, ) from agents.exceptions import ModelBehaviorError - from openai import AsyncOpenAI, DefaultAsyncHttpx2Client + from openai import AsyncOpenAI configuration = self._configuration profile = self._profile admission = self._admission assert admission is not None + if capabilities.model is None: + raise ProjectAgentRuntimeError("project guide model capability is unavailable") + active_phase = "draft" phase_opened: set[str] = set() @@ -259,13 +261,16 @@ async def get_response( admission.request_succeeded() return response - http_client = DefaultAsyncHttpx2Client( - trust_env=False, - follow_redirects=False, + http_client = create_setup_httpx_client( + capabilities.model, + model=configuration.model, + api=configuration.model_api, + phase=lambda: active_phase, + timeout_seconds=float(profile.request_timeout_seconds), ) client = AsyncOpenAI( - api_key=self._provider.api_key, - base_url=self._provider.base_url, + api_key="setup-model-proxy", + base_url=_PROXY_BASE_URL, http_client=http_client, max_retries=0, timeout=profile.request_timeout_seconds, @@ -314,6 +319,9 @@ async def search_document( model_settings = ModelSettings( parallel_tool_calls=False, store=False, + max_tokens=capabilities.model.maximum_output_tokens, + reasoning={"effort": capabilities.model.reasoning_effort}, + verbosity=capabilities.model.text_verbosity, timeout=profile.request_timeout_seconds, retry=model_retry_settings(profile), extra_body=extra_body, @@ -335,7 +343,8 @@ async def search_document( async def run_phase(phase, phase_prompt, turn_limit): """Correct known invalid responses without replaying uncertain requests.""" - nonlocal corrections + nonlocal active_phase, corrections + active_phase = "draft" if phase == "checker_specification" else "review" while True: remaining = turn_limit - agent.model.model_turns if remaining <= 0: @@ -356,6 +365,7 @@ async def run_phase(phase, phase_prompt, turn_limit): if corrections >= 2: raise ProjectGuideCompilationInvalidOutputError("schema_invalid") corrections += 1 + active_phase = "correction" phase_prompt = json.dumps( { "phase": phase, @@ -410,37 +420,21 @@ async def run_phase(phase, phase_prompt, turn_limit): await client.close() -class _ProviderConfiguration: - __slots__ = ("api_key", "base_url") - - def __init__(self, api_key: str, base_url: str) -> None: - self.api_key = api_key - self.base_url = base_url - - -def _provider_configuration( +def _require_supported_provider( configuration: ProjectGuideRuntimeConfiguration, -) -> _ProviderConfiguration: +) -> None: if ( configuration.model_provider == "openrouter" and configuration.model_api == "chat_completions" ): require_direct_openrouter_model(configuration.model) - credential_name = "OPENROUTER_API_KEY" - base_url = _OPENROUTER_BASE_URL + return elif configuration.model_provider == "openai" and configuration.model_api in { "responses", "chat_completions", }: - credential_name = "OPENAI_API_KEY" - base_url = _OPENAI_BASE_URL + return else: raise ProjectAgentRuntimeConfigurationError( "project guide model provider or protocol is unsupported" ) - api_key = os.environ.get(credential_name) - if not api_key: - raise ProjectAgentRuntimeConfigurationError( - "project guide model credentials are unavailable" - ) - return _ProviderConfiguration(api_key, base_url) diff --git a/backend/app/adapters/project_agents/setup_model_handler.py b/backend/app/adapters/project_agents/setup_model_handler.py new file mode 100644 index 000000000..d279e1318 --- /dev/null +++ b/backend/app/adapters/project_agents/setup_model_handler.py @@ -0,0 +1,990 @@ +"""Trusted setup-agent model handler with durable budget and ART custody.""" + +from __future__ import annotations + +import hashlib +import json +import os +from collections.abc import Callable +from contextlib import AbstractAsyncContextManager, asynccontextmanager +from dataclasses import dataclass +from decimal import ROUND_CEILING, Decimal, InvalidOperation +from typing import Protocol + +from app.adapters.project_agents.openrouter_policy import openrouter_request_options +from app.core.hashing import canonical_json_hash +from app.interfaces.setup_model_proxy import ( + SetupModelProxyReply, + SetupModelProxyRequest, +) +from app.modules.model_usage.api import ( + CompletedSetupModelUsageEffect, + ModelCallBudgetUnavailable, + ModelCallEvidence, + ModelCallUnavailable, + ModelCallUsage, + ModelProfileSelector, + ObservedSetupModelUsageEffect, + PreparedSetupModelUsageEffect, + ProjectModelProfile, + SetupModelOutputArtifactRequest, + SetupModelOutputBindingRequest, + SetupModelOutputSelector, + SetupModelUsageSlotKey, +) + +_OPENAI_RESPONSES_URL = "https://api.openai.com/v1/responses" +_OPENROUTER_CHAT_URL = "https://openrouter.ai/api/v1/chat/completions" +_OPENROUTER_MODEL_URL = "https://openrouter.ai/api/v1/model/{author}/{slug}" +_OPENROUTER_REQUIRED_PARAMETERS = frozenset( + {"max_tokens", "reasoning_effort", "tool_choice", "tools", "verbosity"} +) +_OPENAI_RESPONSE_FIELDS = frozenset( + { + "background", + "include", + "input", + "instructions", + "max_output_tokens", + "model", + "parallel_tool_calls", + "previous_response_id", + "reasoning", + "store", + "stream", + "text", + "tool_choice", + "tools", + "truncation", + } +) +_COUNT_FIELDS = frozenset( + { + "input", + "instructions", + "model", + "parallel_tool_calls", + "reasoning", + "text", + "tool_choice", + "tools", + "truncation", + } +) +_OPENROUTER_CHAT_FIELDS = frozenset( + { + "max_tokens", + "messages", + "model", + "parallel_tool_calls", + "plugins", + "provider", + "reasoning_effort", + "response_format", + "store", + "stream", + "temperature", + "tool_choice", + "tools", + "top_p", + "usage", + "verbosity", + } +) + + +@dataclass(frozen=True, slots=True) +class SetupProviderResponse: + """One fully observed provider response with exact raw JSON and usage.""" + + status_code: int + response_json: str + provider_response_id: str + provider_model: str + usage: ModelCallUsage + usable: bool = True + + +class SetupProviderUncertain(RuntimeError): + """A provider dispatch may have created a paid effect.""" + + def __init__(self, digest: str, provider_response_id: str | None = None) -> None: + """Retain a safe uncertainty digest and any authenticated provider ID.""" + super().__init__("setup provider outcome is uncertain") + self.digest = digest + self.provider_response_id = provider_response_id + + +class SetupProvider(Protocol): + """Closed provider boundary used by the trusted setup model handler.""" + + async def count_input(self, body: dict[str, object]) -> int: + """Return a conservative provider-qualified input-token count.""" + ... + + async def dispatch(self, body_json: str) -> SetupProviderResponse: + """Send one bounded request without an automatic paid retry.""" + ... + + +class OpenAISetupProvider: + """No-retry OpenAI Responses adapter owned only by the trusted model process.""" + + def __init__(self, *, api_key: str, profile: ProjectModelProfile) -> None: + """Bind no-retry clients to one approved OpenAI profile.""" + import httpx + from openai import AsyncOpenAI + + self._profile = profile + self._counter = AsyncOpenAI( + api_key=api_key, + max_retries=0, + timeout=profile.spec.deadline_ms / 1000.0, + ) + self._client = httpx.AsyncClient( + trust_env=False, + follow_redirects=False, + timeout=profile.spec.deadline_ms / 1000.0, + headers={ + "Authorization": "Bearer " + api_key, + "Content-Type": "application/json", + "Accept": "application/json", + }, + ) + + async def count_input(self, body: dict[str, object]) -> int: + """Use the provider's non-creating endpoint on the exact semantic input.""" + arguments = {key: value for key, value in body.items() if key in _COUNT_FIELDS} + counted = await self._counter.responses.input_tokens.count(**arguments) + value = getattr(counted, "input_tokens", None) + if type(value) is not int or value < 0: + raise ModelCallUnavailable("setup_model_input_count_unavailable") + return value + + async def dispatch(self, body_json: str) -> SetupProviderResponse: + """Send exactly once; every exception or non-success remains uncertain.""" + raw = b"" + try: + async with self._client.stream( + "POST", _OPENAI_RESPONSES_URL, content=body_json + ) as response: + raw = await _bounded_response_bytes( + response, self._profile.spec.maximum_response_bytes + ) + except BaseException as exc: + raise SetupProviderUncertain( + canonical_json_hash({"outcome": "transport_error"}) + ) from exc + try: + if not raw: + raise ValueError("provider response exceeds profile") + value = json.loads(raw, parse_constant=_reject_constant) + if not isinstance(value, dict): + raise TypeError("provider response is invalid") + provider_id = value.get("id") + if not 200 <= response.status_code < 300: + raise SetupProviderUncertain( + canonical_json_hash( + {"outcome": "provider_status", "status_code": response.status_code} + ), + provider_id if isinstance(provider_id, str) else None, + ) + return _openai_response(self._profile, response.status_code, value) + except SetupProviderUncertain: + raise + except BaseException as exc: + provider_id = None + try: + candidate = json.loads(raw).get("id") + if isinstance(candidate, str): + provider_id = candidate + except (AttributeError, TypeError, ValueError, UnicodeError): + provider_id = None + raise SetupProviderUncertain( + canonical_json_hash( + {"outcome": "invalid_response", "status_code": response.status_code} + ), + provider_id, + ) from exc + + async def aclose(self) -> None: + """Close both provider clients and their connection pools.""" + await self._counter.close() + await self._client.aclose() + + +class OpenRouterSetupProvider: + """No-retry OpenRouter adapter reserving its full pinned model context.""" + + def __init__(self, *, api_key: str, profile: ProjectModelProfile) -> None: + """Bind one no-retry client to an exact direct OpenRouter model.""" + import httpx + + model_parts = profile.spec.model_snapshot.split("/") + if len(model_parts) != 2 or not all(model_parts): + raise ModelCallUnavailable("model_profile_provider_unavailable") + self._profile = profile + self._model_url = _OPENROUTER_MODEL_URL.format(author=model_parts[0], slug=model_parts[1]) + self._client = httpx.AsyncClient( + trust_env=False, + follow_redirects=False, + timeout=profile.spec.deadline_ms / 1000.0, + headers={ + "Authorization": "Bearer " + api_key, + "Content-Type": "application/json", + "Accept": "application/json", + }, + ) + + async def count_input(self, _body: dict[str, object]) -> int: + """Refresh exact model metadata, then reserve the full approved context.""" + try: + async with self._client.stream("GET", self._model_url) as response: + raw = await _bounded_response_bytes(response, 1_048_576) + if not 200 <= response.status_code < 300 or not raw: + raise ValueError("provider metadata unavailable") + value = json.loads(raw, parse_constant=_reject_constant) + _validate_openrouter_metadata(self._profile, value) + except ModelCallUnavailable: + raise + except BaseException as exc: + raise ModelCallUnavailable("model_profile_provider_unavailable") from exc + return self._profile.spec.maximum_input_tokens + + async def dispatch(self, body_json: str) -> SetupProviderResponse: + """Send one bounded Chat Completions request and classify uncertainty.""" + raw = b"" + try: + async with self._client.stream( + "POST", _OPENROUTER_CHAT_URL, content=body_json + ) as response: + raw = await _bounded_response_bytes( + response, self._profile.spec.maximum_response_bytes + ) + except BaseException as exc: + raise SetupProviderUncertain( + canonical_json_hash({"outcome": "transport_error"}) + ) from exc + try: + if not raw: + raise ValueError("provider response exceeds profile") + value = json.loads(raw, parse_constant=_reject_constant) + if not isinstance(value, dict): + raise TypeError("provider response is invalid") + provider_id = value.get("id") + if not 200 <= response.status_code < 300: + raise SetupProviderUncertain( + canonical_json_hash( + {"outcome": "provider_status", "status_code": response.status_code} + ), + provider_id if isinstance(provider_id, str) else None, + ) + return _openrouter_response(self._profile, response.status_code, value) + except SetupProviderUncertain: + raise + except BaseException as exc: + provider_id = None + try: + candidate = json.loads(raw).get("id") + if isinstance(candidate, str): + provider_id = candidate + except (AttributeError, TypeError, ValueError, UnicodeError): + provider_id = None + raise SetupProviderUncertain( + canonical_json_hash( + {"outcome": "invalid_response", "status_code": response.status_code} + ), + provider_id, + ) from exc + + async def aclose(self) -> None: + """Close the provider metadata and dispatch connection pool.""" + await self._client.aclose() + + +class SetupModelProxyHandler: + """Persist request and reservation before exactly one trusted provider send.""" + + def __init__( + self, + *, + sessions, + authorization, + calls, + profiles, + outputs, + bindings, + provider: Callable[[ProjectModelProfile], AbstractAsyncContextManager[SetupProvider]], + ) -> None: + """Inject transaction, authority, ledger, ART and provider factories.""" + self._sessions = sessions + self._authorization = authorization + self._calls = calls + self._profiles = profiles + self._outputs = outputs + self._bindings = bindings + self._provider = provider + + async def handle(self, request: SetupModelProxyRequest) -> SetupModelProxyReply: + """Map owner, budget and request failures to the closed wire outcome.""" + try: + return await self._handle(request) + except ModelCallBudgetUnavailable: + return _unavailable(request, "model_budget_unavailable") + except (ModelCallUnavailable, ValueError, TypeError): + return _unavailable(request, "model_call_unavailable") + + async def _handle(self, request: SetupModelProxyRequest) -> SetupModelProxyReply: + """Retain, reserve, dispatch and settle one stable setup call slot.""" + subject = request.subject + key = SetupModelUsageSlotKey( + compilation_attempt_id=subject.compilation_attempt_id, + project_id=subject.project_id, + guide_id=subject.guide_id, + source_snapshot_id=subject.source_snapshot_id, + setup_run_id=subject.setup_run_id, + setup_generation=subject.setup_generation, + runtime_configuration_hash=subject.runtime_configuration_hash, + profile_id=subject.profile_id, + profile_version=subject.profile_version, + profile_digest=subject.profile_digest, + phase=request.phase, + call_index=request.call_index, + ) + body = json.loads(request.provider_request_json) + async with self._sessions() as session, session.begin(): + profile = await self._profiles(session).read_exact( + ModelProfileSelector( + profile_id=subject.profile_id, + profile_version=subject.profile_version, + profile_digest=subject.profile_digest, + ), + project_id=subject.project_id, + ) + _validate_request(profile, request, body) + data = request.provider_request_json.encode("utf-8") + request_sha = _sha(data) + + async with self._sessions() as session, session.begin(): + authority = await self._authorization(session, subject.compilation_attempt_id) + calls = self._calls(session, authority) + existing = await calls.recover_existing(key, request_sha) + if isinstance(existing, ObservedSetupModelUsageEffect): + return await self._recover_observed(request, existing, profile) + if existing is not None: + if existing.state != "completed": + return _unavailable(request, "model_call_unavailable") + return await self._cached(request, existing, profile) + + async with self._provider(profile) as provider: + input_tokens = await provider.count_input(body) + if input_tokens > profile.spec.maximum_input_tokens: + raise ModelCallUnavailable("setup_model_input_limit_exceeded") + async with self._sessions() as session, session.begin(): + authority = await self._authorization(session, subject.compilation_attempt_id) + prepared = await self._calls(session, authority).prepare(key, request_sha) + request_evidence = await self._store(prepared, "model_request", data) + async with self._sessions() as session, session.begin(): + authority = await self._authorization(session, prepared.internal_call_request_id) + dispatch = await self._calls(session, authority).reserve_dispatch( + prepared, request_evidence + ) + if dispatch.replayed: + return _unavailable(request, "model_provider_delivery_uncertain") + try: + response = await provider.dispatch(request.provider_request_json) + except SetupProviderUncertain as uncertain: + async with self._sessions() as session, session.begin(): + authority = await self._authorization( + session, dispatch.internal_call_request_id + ) + await self._calls(session, authority).mark_uncertain( + dispatch.effect, + uncertain.digest, + provider_response_id=uncertain.provider_response_id, + ) + return _unavailable(request, "model_provider_delivery_uncertain") + response_data = response.response_json.encode("utf-8") + async with self._sessions() as session, session.begin(): + authority = await self._authorization(session, dispatch.internal_call_request_id) + await self._calls(session, authority).mark_uncertain( + dispatch.effect, + _sha(response_data), + provider_response_id=response.provider_response_id, + ) + response_evidence = await self._store(prepared, "model_response", response_data) + async with self._sessions() as session, session.begin(): + authority = await self._authorization(session, dispatch.internal_call_request_id) + await self._calls(session, authority).complete( + dispatch.effect, + provider_response_id=response.provider_response_id, + provider_model_snapshot=response.provider_model, + response_evidence=response_evidence, + usage=response.usage, + usable=response.usable, + ) + if not response.usable: + return _unavailable(request, "model_response_too_large") + return _completed_reply( + request, + profile, + status_code=response.status_code, + response_json=response.response_json, + ) + + async def _recover_observed( + self, + request: SetupModelProxyRequest, + observed: ObservedSetupModelUsageEffect, + profile: ProjectModelProfile, + ) -> SetupModelProxyReply: + """Complete only exact response bytes already verified by ART.""" + if observed.provider_response_id is None: + return _unavailable(request, "model_provider_delivery_uncertain") + effect = observed.effect + selector = SetupModelOutputSelector( + slot_key=effect.slot_key, + model_call_slot_id=effect.slot_id, + model_call_attempt_id=effect.effect_id, + role="model_response", + content_sha256=observed.uncertainty_digest, + ) + recovered = await self._outputs.recover_setup_output(selector) + if ( + recovered is None + or recovered.status != "verified" + or recovered.content_id is None + or recovered.verification_receipt_id is None + or recovered.content_sha256 != selector.content_sha256 + ): + return _unavailable(request, "model_provider_delivery_uncertain") + async with self._sessions() as session, session.begin(): + binding = await self._bindings(session).bind_setup_output( + SetupModelOutputBindingRequest( + selector, + recovered.put_attempt_id, + recovered.verification_receipt_id, + ) + ) + evidence = ModelCallEvidence( + binding_id=binding.binding_id, + content_id=binding.content_id, + put_attempt_id=binding.put_attempt_id, + verification_receipt_id=binding.verification_receipt_id, + content_sha256=recovered.content_sha256, + byte_count=recovered.byte_count, + ) + data = await self._outputs.read_setup_response(selector, evidence) + response = _retained_provider_response(profile, data) + if response.provider_response_id != observed.provider_response_id: + raise ModelCallUnavailable("setup_model_recovery_identity_mismatch") + async with self._sessions() as session, session.begin(): + authority = await self._authorization(session, effect.internal_call_request_id) + completed = await self._calls(session, authority).complete( + effect, + provider_response_id=response.provider_response_id, + provider_model_snapshot=response.provider_model, + response_evidence=evidence, + usage=response.usage, + usable=response.usable, + ) + if completed.state != "completed" or not response.usable: + return _unavailable(request, "model_response_too_large") + return _completed_reply( + request, + profile, + status_code=response.status_code, + response_json=response.response_json, + ) + + async def _store( + self, prepared: PreparedSetupModelUsageEffect, role: str, data: bytes + ) -> ModelCallEvidence: + """Store, verify and bind one exact setup request or response artifact.""" + selector = SetupModelOutputSelector( + slot_key=prepared.slot_key, + model_call_slot_id=prepared.slot_id, + model_call_attempt_id=prepared.effect_id, + role=role, + content_sha256=_sha(data), + ) + + async def source(): + """Yield the one canonical bounded evidence payload.""" + yield data + + stored = await self._outputs.store_setup_output( + SetupModelOutputArtifactRequest(selector, source()) + ) + if stored.status != "verified" or stored.verification_receipt_id is None: + raise ModelCallUnavailable("setup_model_evidence_unavailable") + async with self._sessions() as session, session.begin(): + binding = await self._bindings(session).bind_setup_output( + SetupModelOutputBindingRequest( + selector, + stored.put_attempt_id, + stored.verification_receipt_id, + ) + ) + return ModelCallEvidence( + binding_id=binding.binding_id, + content_id=binding.content_id, + put_attempt_id=binding.put_attempt_id, + verification_receipt_id=binding.verification_receipt_id, + content_sha256=selector.content_sha256, + byte_count=len(data), + ) + + async def _cached( + self, + request: SetupModelProxyRequest, + completed: CompletedSetupModelUsageEffect, + profile: ProjectModelProfile, + ) -> SetupModelProxyReply: + """Read and revalidate the retained response for a completed replay.""" + selector = SetupModelOutputSelector( + slot_key=completed.effect.slot_key, + model_call_slot_id=completed.effect.slot_id, + model_call_attempt_id=completed.effect.effect_id, + role="model_response", + content_sha256=completed.response_evidence.content_sha256, + ) + data = await self._outputs.read_setup_response(selector, completed.response_evidence) + body = json.loads(data) + response_json = _canonical_bytes(body).decode("utf-8") + return _completed_reply( + request, + profile, + status_code=200, + response_json=response_json, + ) + + +@asynccontextmanager +async def configured_setup_provider(profile: ProjectModelProfile): + """Construct only the closed provider/API pair supported with exact pre-counting.""" + identity = profile.spec.provider, profile.spec.api + if identity == ("openai", "responses"): + key_name = "OPENAI_API_KEY" + provider_type = OpenAISetupProvider + elif identity == ("openrouter", "chat_completions"): + key_name = "OPENROUTER_API_KEY" + provider_type = OpenRouterSetupProvider + else: + raise ModelCallUnavailable("model_profile_provider_unavailable") + key = os.environ.get(key_name, "") + if not key: + raise ModelCallUnavailable("model_provider_credentials_unavailable") + provider = provider_type(api_key=key, profile=profile) + try: + yield provider + finally: + await provider.aclose() + + +def _validate_request( + profile: ProjectModelProfile, + request: SetupModelProxyRequest, + body: object, +) -> None: + """Validate the complete SDK request against the locked profile.""" + subject = request.subject + if ( + not isinstance(body, dict) + or (profile.spec.provider, profile.spec.api, profile.spec.model_snapshot) + != (subject.provider, subject.api, subject.model) + or profile.spec.allowed_tools != ("read_document", "search_document") + or body.get("model") != subject.model + or body.get("stream", False) is not False + or len(request.provider_request_json.encode("utf-8")) > profile.spec.maximum_request_bytes + or not _safe_tools(body.get("tools")) + ): + raise ModelCallUnavailable("setup_model_request_invalid") + identity = subject.provider, subject.api + if identity == ("openai", "responses"): + if ( + set(body) - _OPENAI_RESPONSE_FIELDS + or body.get("background", False) is not False + or body.get("store", False) is not False + or body.get("previous_response_id") not in {None, ""} + or body.get("parallel_tool_calls") is not False + or body.get("truncation") not in {None, "disabled"} + or body.get("include", []) != [] + or body.get("reasoning") != {"effort": profile.spec.settings.reasoning_effort} + or not _safe_response_text(body.get("text"), profile.spec.settings.text_verbosity) + or type(body.get("max_output_tokens")) is not int + or not 1 <= body["max_output_tokens"] <= profile.spec.maximum_output_tokens + ): + raise ModelCallUnavailable("setup_model_request_invalid") + elif identity == ("openrouter", "chat_completions"): + if ( + set(body) - _OPENROUTER_CHAT_FIELDS + or type(body.get("max_tokens")) is not int + or not 1 <= body["max_tokens"] <= profile.spec.maximum_output_tokens + or body.get("usage") != {"include": True} + or body.get("provider") != openrouter_request_options()["provider"] + or body.get("plugins") != openrouter_request_options()["plugins"] + or body.get("reasoning_effort") != profile.spec.settings.reasoning_effort + or body.get("verbosity") != profile.spec.settings.text_verbosity + or body.get("temperature", 0) != 0 + or body.get("top_p", 1) != 1 + or body.get("store", False) is not False + or body.get("parallel_tool_calls", False) is not False + or not _safe_chat_response_format(body.get("response_format")) + ): + raise ModelCallUnavailable("setup_model_request_invalid") + else: + raise ModelCallUnavailable("setup_model_request_invalid") + + +def _safe_tools(value: object) -> bool: + """Accept only the two neutral setup document tools.""" + if not isinstance(value, list) or len(value) != 2: + return False + names: list[str] = [] + for item in value: + if not isinstance(item, dict) or item.get("type") != "function": + return False + name = item.get("name") + if name is None and isinstance(item.get("function"), dict): + name = item["function"].get("name") + if not isinstance(name, str): + return False + names.append(name) + return sorted(names) == ["read_document", "search_document"] + + +def _safe_response_text(value: object, verbosity: str) -> bool: + """Validate strict Responses output shape and locked verbosity.""" + if not isinstance(value, dict) or set(value) - {"format", "verbosity"}: + return False + output_format = value.get("format") + return ( + value.get("verbosity") == verbosity + and isinstance(output_format, dict) + and output_format.get("type") == "json_schema" + and output_format.get("strict") is True + and isinstance(output_format.get("name"), str) + and isinstance(output_format.get("schema"), dict) + ) + + +def _safe_chat_response_format(value: object) -> bool: + """Validate the optional strict Chat Completions JSON schema.""" + if value is None: + return True + if not isinstance(value, dict) or set(value) != {"type", "json_schema"}: + return False + schema = value.get("json_schema") + return ( + value.get("type") == "json_schema" + and isinstance(schema, dict) + and schema.get("strict") is True + and isinstance(schema.get("name"), str) + and isinstance(schema.get("schema"), dict) + and not set(schema) - {"name", "strict", "schema", "description"} + ) + + +def _validate_openrouter_metadata(profile: ProjectModelProfile, value: object) -> None: + """Fail before spend unless current provider metadata fits the pinned profile.""" + if not isinstance(value, dict) or set(value) != {"data"}: + raise ModelCallUnavailable("model_profile_provider_unavailable") + data = value["data"] + if not isinstance(data, dict): + raise ModelCallUnavailable("model_profile_provider_unavailable") + top_provider = data.get("top_provider") + supported = data.get("supported_parameters") + context_length = data.get("context_length") + if ( + data.get("id") != profile.spec.model_snapshot + or not isinstance(data.get("canonical_slug"), str) + or not data["canonical_slug"] + or type(context_length) is not int + or context_length < 1 + or context_length > profile.spec.maximum_input_tokens + or not isinstance(top_provider, dict) + or type(top_provider.get("context_length")) is not int + or top_provider["context_length"] != context_length + or type(top_provider.get("max_completion_tokens")) is not int + or not 1 <= profile.spec.maximum_output_tokens <= top_provider["max_completion_tokens"] + or not isinstance(supported, list) + or any(type(item) is not str for item in supported) + or not _OPENROUTER_REQUIRED_PARAMETERS.issubset(supported) + ): + raise ModelCallUnavailable("model_profile_provider_unavailable") + _validate_openrouter_pricing(profile, data.get("pricing")) + + +def _validate_openrouter_pricing(profile: ProjectModelProfile, value: object) -> None: + """Require pinned rates to cover every advertised tier and cache class.""" + if not isinstance(value, dict): + raise ModelCallUnavailable("model_profile_provider_unavailable") + pricing_rows = [value] + overrides = value.get("overrides", []) + if not isinstance(overrides, list) or any(not isinstance(row, dict) for row in overrides): + raise ModelCallUnavailable("model_profile_provider_unavailable") + pricing_rows.extend(overrides) + input_ceiling = 0 + cached_ceiling = 0 + output_ceiling = 0 + for row in pricing_rows: + _require_zero_unsupported_fees(row) + input_ceiling = max( + input_ceiling, + _nanodollars(row.get("prompt")), + _nanodollars(row.get("input_cache_write", row.get("prompt"))), + ) + cached_ceiling = max( + cached_ceiling, + _nanodollars(row.get("input_cache_read", row.get("prompt"))), + ) + output_ceiling = max(output_ceiling, _nanodollars(row.get("completion"))) + pinned = profile.spec.pricing + if ( + input_ceiling > pinned.uncached_input_nanodollars_per_token + or cached_ceiling > pinned.cached_input_nanodollars_per_token + or output_ceiling > pinned.output_nanodollars_per_token + ): + raise ModelCallUnavailable("model_profile_provider_unavailable") + + +def _require_zero_unsupported_fees(row: dict) -> None: + """Reject advertised charges the token-only ledger cannot reserve.""" + supported = { + "prompt", + "completion", + "input_cache_read", + "input_cache_write", + "overrides", + "min_prompt_tokens", + } + for key, value in row.items(): + if key not in supported and _nanodollars(value) != 0: + raise ModelCallUnavailable("model_profile_provider_unavailable") + + +def _nanodollars(value: object) -> int: + """Round one nonnegative per-token USD string up to nanodollars.""" + if type(value) is not str: + raise ModelCallUnavailable("model_profile_provider_unavailable") + try: + amount = Decimal(value) + except InvalidOperation: + raise ModelCallUnavailable("model_profile_provider_unavailable") from None + if not amount.is_finite() or amount < 0: + raise ModelCallUnavailable("model_profile_provider_unavailable") + converted = (amount * Decimal(1_000_000_000)).to_integral_value(rounding=ROUND_CEILING) + if converted > 9_000_000_000_000_000_000: + raise ModelCallUnavailable("model_profile_provider_unavailable") + return int(converted) + + +def _openai_response( + profile: ProjectModelProfile, status_code: int, value: dict[str, object] +) -> SetupProviderResponse: + """Normalize an exact OpenAI response, usage and output-byte decision.""" + provider_id = value.get("id") + provider_model = value.get("model") + if ( + not isinstance(provider_id, str) + or not provider_id + or provider_model != profile.spec.model_snapshot + ): + raise ValueError("provider response identity is invalid") + return SetupProviderResponse( + status_code=status_code, + response_json=_canonical_bytes(value).decode("utf-8"), + provider_response_id=provider_id, + provider_model=provider_model, + usage=_usage(value.get("usage")), + usable=_model_output_bytes(value) <= profile.spec.maximum_output_bytes, + ) + + +def _openrouter_response( + profile: ProjectModelProfile, status_code: int, value: dict[str, object] +) -> SetupProviderResponse: + """Normalize an exact OpenRouter response, usage and output-byte decision.""" + provider_id = value.get("id") + provider_model = value.get("model") + if ( + not isinstance(provider_id, str) + or not provider_id + or provider_model != profile.spec.model_snapshot + ): + raise ValueError("provider response identity is invalid") + return SetupProviderResponse( + status_code=status_code, + response_json=_canonical_bytes(value).decode("utf-8"), + provider_response_id=provider_id, + provider_model=provider_model, + usage=_chat_usage(value.get("usage")), + usable=_chat_output_bytes(value) <= profile.spec.maximum_output_bytes, + ) + + +def _retained_provider_response(profile: ProjectModelProfile, data: bytes) -> SetupProviderResponse: + """Re-validate exact retained ART bytes without another provider request.""" + try: + value = json.loads(data, parse_constant=_reject_constant) + if not isinstance(value, dict) or _canonical_bytes(value) != data: + raise ValueError("retained response is not canonical") + identity = profile.spec.provider, profile.spec.api + if identity == ("openai", "responses"): + return _openai_response(profile, 200, value) + if identity == ("openrouter", "chat_completions"): + return _openrouter_response(profile, 200, value) + except (TypeError, ValueError, UnicodeError, RecursionError) as exc: + raise ModelCallUnavailable("setup_model_recovery_response_invalid") from exc + raise ModelCallUnavailable("model_profile_provider_unavailable") + + +def _usage(value: object) -> ModelCallUsage: + """Decode complete Responses usage including cached and reasoning tokens.""" + if not isinstance(value, dict): + raise TypeError("provider usage is unavailable") + input_details = value.get("input_tokens_details") or {} + output_details = value.get("output_tokens_details") or {} + return ModelCallUsage( + input_tokens=value.get("input_tokens"), + cached_input_tokens=input_details.get("cached_tokens", 0), + output_tokens=value.get("output_tokens"), + reasoning_tokens=output_details.get("reasoning_tokens", 0), + ) + + +def _chat_usage(value: object) -> ModelCallUsage: + """Decode complete Chat Completions usage into the shared ledger shape.""" + if not isinstance(value, dict): + raise TypeError("provider usage is unavailable") + input_details = value.get("prompt_tokens_details") or {} + output_details = value.get("completion_tokens_details") or {} + return ModelCallUsage( + input_tokens=value.get("prompt_tokens"), + cached_input_tokens=input_details.get("cached_tokens", 0), + output_tokens=value.get("completion_tokens"), + reasoning_tokens=output_details.get("reasoning_tokens", 0), + ) + + +def _model_output_bytes(value: dict[str, object]) -> int: + """Count UTF-8 model-authored text and tool arguments without response metadata.""" + output = value.get("output") + if not isinstance(output, list): + raise TypeError("provider output is invalid") + total = 0 + stack: list[object] = list(output) + while stack: + current = stack.pop() + if isinstance(current, list): + stack.extend(current) + elif isinstance(current, dict): + for key, item in current.items(): + if key in {"text", "refusal", "arguments"} and isinstance(item, str): + total += len(item.encode("utf-8")) + elif isinstance(item, (dict, list)): + stack.append(item) + return total + + +def _chat_output_bytes(value: dict[str, object]) -> int: + """Count model-authored UTF-8 bytes across all completion choices.""" + choices = value.get("choices") + if not isinstance(choices, list): + raise TypeError("provider output is invalid") + return _generated_string_bytes(choices) + + +def _generated_string_bytes(root: object) -> int: + """Count bounded content, refusal and tool-argument strings recursively.""" + total = 0 + stack = [root] + while stack: + current = stack.pop() + if isinstance(current, list): + stack.extend(current) + elif isinstance(current, dict): + for key, item in current.items(): + if key in {"content", "refusal", "arguments"} and isinstance(item, str): + total += len(item.encode("utf-8")) + elif isinstance(item, (dict, list)): + stack.append(item) + return total + + +def _canonical_bytes(value: object) -> bytes: + """Serialize provider evidence to the canonical retained JSON form.""" + return json.dumps( + value, + sort_keys=True, + ensure_ascii=False, + allow_nan=False, + separators=(",", ":"), + ).encode("utf-8") + + +async def _bounded_response_bytes(response, maximum_bytes: int) -> bytes: + """Read a provider body without ever accumulating beyond its locked cap.""" + body = bytearray() + async for chunk in response.aiter_bytes(): + if not chunk: + continue + if len(chunk) > maximum_bytes - len(body): + raise ValueError("provider response exceeds profile") + body.extend(chunk) + return bytes(body) + + +def _sha(data: bytes) -> str: + """Return the canonical prefixed digest for retained bytes.""" + return "sha256:" + hashlib.sha256(data).hexdigest() + + +def _reject_constant(_: str) -> None: + """Reject non-finite JSON numeric constants.""" + raise ValueError("non-finite provider number") + + +def _unavailable(request: SetupModelProxyRequest, code: str) -> SetupModelProxyReply: + """Build the closed bounded unavailable reply for one call index.""" + return SetupModelProxyReply( + outcome="unavailable", + call_index=request.call_index, + status_code=None, + response_json=None, + response_headers=(), + failure_code=code, + session_exhausted=True, + ) + + +def _completed_reply( + request: SetupModelProxyRequest, + profile: ProjectModelProfile, + *, + status_code: int, + response_json: str, +) -> SetupModelProxyReply: + """Return a bounded SDK reply after the caller has retained and settled it.""" + try: + return SetupModelProxyReply( + outcome="completed", + call_index=request.call_index, + status_code=status_code, + response_json=response_json, + response_headers=(), + failure_code=None, + session_exhausted=request.call_index + 1 >= profile.spec.maximum_calls, + ) + except ValueError: + return _unavailable(request, "model_response_too_large") + + +__all__ = ( + "OpenAISetupProvider", + "SetupModelProxyHandler", + "SetupProviderResponse", + "SetupProviderUncertain", + "configured_setup_provider", +) diff --git a/backend/app/adapters/project_agents/setup_model_proxy.py b/backend/app/adapters/project_agents/setup_model_proxy.py new file mode 100644 index 000000000..502cbe14f --- /dev/null +++ b/backend/app/adapters/project_agents/setup_model_proxy.py @@ -0,0 +1,387 @@ +"""Attempt-bound client for the trusted setup model dispatch listener.""" + +from __future__ import annotations + +import asyncio +import json +import os +import stat +import struct +from collections.abc import Callable, Mapping +from contextlib import asynccontextmanager +from pathlib import Path +from typing import Any + +from app.adapters.model_usage import model_profile_reader +from app.interfaces.project_guide_runtime import ProjectGuideRuntimeConfiguration +from app.interfaces.setup_model_proxy import ( + SETUP_MODEL_PROXY_MAXIMUM_FRAME_BYTES, + SETUP_MODEL_PROXY_PROTOCOL, + SetupModelPhase, + SetupModelProxyReply, + SetupModelProxyRequest, + SetupModelProxySubject, + canonical_provider_json, +) +from app.modules.model_usage.api import ModelProfileSelector + + +class SetupModelProxyUnavailable(RuntimeError): + """The trusted dispatch did not produce a safely replayable provider response.""" + + +class SetupModelProxySession: + """Allocate monotonically increasing paid slots for one immutable setup attempt.""" + + def __init__( + self, + *, + socket_path: Path, + subject: SetupModelProxySubject, + maximum_calls: int, + maximum_output_tokens: int, + reasoning_effort: str, + text_verbosity: str, + timeout_seconds: float, + ) -> None: + """Validate and bind one attempt's immutable subject and session limits.""" + if ( + not isinstance(socket_path, Path) + or type(subject) is not SetupModelProxySubject + or type(maximum_calls) is not int + or not 1 <= maximum_calls <= 100 + or type(maximum_output_tokens) is not int + or not 1 <= maximum_output_tokens <= 10_000_000 + or reasoning_effort not in {"none", "minimal", "low", "medium", "high", "xhigh", "max"} + or text_verbosity not in {"low", "medium", "high"} + or type(timeout_seconds) is not float + or not 0.1 <= timeout_seconds <= 7_200.0 + ): + raise ValueError("setup model proxy session configuration is invalid") + self._socket_path = socket_path + self._subject = subject + self._maximum_calls = maximum_calls + self._maximum_output_tokens = maximum_output_tokens + self._reasoning_effort = reasoning_effort + self._text_verbosity = text_verbosity + self._timeout_seconds = timeout_seconds + self._call_index = 0 + self._blocked = False + self._active = False + + @property + def maximum_output_tokens(self) -> int: + """Return the locked per-dispatch completion-token ceiling.""" + return self._maximum_output_tokens + + @property + def reasoning_effort(self) -> str: + """Return the locked provider reasoning setting.""" + return self._reasoning_effort + + @property + def text_verbosity(self) -> str: + """Return the locked provider text-verbosity setting.""" + return self._text_verbosity + + async def dispatch( + self, phase: SetupModelPhase, provider_request: Mapping[str, object] + ) -> SetupModelProxyReply: + """Send one exact dispatch; any uncertain outcome permanently closes this session.""" + if self._blocked or self._call_index >= self._maximum_calls: + raise SetupModelProxyUnavailable("setup model session is unavailable") + if self._active: + self._blocked = True + raise SetupModelProxyUnavailable("setup model session is unavailable") + request = SetupModelProxyRequest( + subject=self._subject, + phase=phase, + call_index=self._call_index, + provider_request_json=canonical_provider_json(provider_request), + ) + self._active = True + try: + reply = await self._round_trip(request) + except BaseException: + self._blocked = True + raise + finally: + self._active = False + if reply.call_index != self._call_index: + self._blocked = True + raise SetupModelProxyUnavailable("setup model reply identity changed") + self._call_index += 1 + if reply.session_exhausted: + self._blocked = True + if reply.outcome != "completed": + self._blocked = True + raise SetupModelProxyUnavailable("setup model dispatch is unavailable") + return reply + + async def _round_trip(self, request: SetupModelProxyRequest) -> SetupModelProxyReply: + """Exchange one bounded frame over the exact private Unix socket.""" + identity = _validated_socket(self._socket_path) + reader: asyncio.StreamReader | None = None + writer: asyncio.StreamWriter | None = None + try: + async with asyncio.timeout(self._timeout_seconds): + reader, writer = await asyncio.open_unix_connection(self._socket_path) + if _validated_socket(self._socket_path) != identity: + raise SetupModelProxyUnavailable("setup model proxy identity changed") + body = json.dumps( + request.wire_value(), + ensure_ascii=False, + allow_nan=False, + separators=(",", ":"), + ).encode("utf-8") + if not body or len(body) > SETUP_MODEL_PROXY_MAXIMUM_FRAME_BYTES: + raise SetupModelProxyUnavailable("setup model request exceeds transport bound") + writer.write(struct.pack(">I", len(body)) + body) + await writer.drain() + header = await reader.readexactly(4) + size = struct.unpack(">I", header)[0] + if not 0 < size <= SETUP_MODEL_PROXY_MAXIMUM_FRAME_BYTES: + raise SetupModelProxyUnavailable("setup model reply exceeds transport bound") + payload = await reader.readexactly(size) + if await reader.read(1): + raise SetupModelProxyUnavailable("setup model reply has trailing bytes") + return _decode_reply(payload) + except ( + OSError, + TimeoutError, + asyncio.IncompleteReadError, + ValueError, + UnicodeError, + ) as exc: + raise SetupModelProxyUnavailable("setup model proxy is unavailable") from exc + finally: + if writer is not None: + writer.close() + try: + await asyncio.wait_for(writer.wait_closed(), timeout=1.0) + except (OSError, TimeoutError): + pass + + +def setup_model_session_factory(*, sessions, socket_path: Path | None): + """Bind exact attempt/profile facts before exposing a credential-free capability.""" + + @asynccontextmanager + async def open_session( + subject: SetupModelProxySubject | None, + configuration: ProjectGuideRuntimeConfiguration, + ): + """Yield an exact profile-bound model session or no capability.""" + if not configuration.uses_inference: + if subject is not None: + raise SetupModelProxyUnavailable("setup model subject is unexpected") + yield None + return + if socket_path is None or subject is None: + raise SetupModelProxyUnavailable("setup model proxy is unavailable") + if ( + configuration.model_profile_id is None + or configuration.model_profile_version is None + or configuration.model_profile_digest is None + ): + raise SetupModelProxyUnavailable("setup model profile is unavailable") + selector = ModelProfileSelector( + profile_id=configuration.model_profile_id, + profile_version=configuration.model_profile_version, + profile_digest=configuration.model_profile_digest, + ) + async with sessions() as database, database.begin(): + profile = await model_profile_reader(database).read_exact( + selector, + project_id=subject.project_id, + ) + if ( + profile.spec.provider, + profile.spec.api, + profile.spec.model_snapshot, + ) != ( + configuration.model_provider, + configuration.model_api, + configuration.model, + ): + raise SetupModelProxyUnavailable("setup model profile is unavailable") + if not _profile_covers_tool_runtime(profile, configuration): + raise SetupModelProxyUnavailable("setup model profile is unavailable") + if subject != SetupModelProxySubject( + compilation_attempt_id=subject.compilation_attempt_id, + project_id=subject.project_id, + guide_id=subject.guide_id, + source_snapshot_id=subject.source_snapshot_id, + setup_run_id=subject.setup_run_id, + setup_generation=subject.setup_generation, + runtime_configuration_hash=configuration.sha256, + profile_id=profile.profile_id, + profile_version=profile.spec.profile_version, + profile_digest=profile.profile_digest, + provider=profile.spec.provider, + api=profile.spec.api, + model=profile.spec.model_snapshot, + ): + raise SetupModelProxyUnavailable("setup model profile is unavailable") + yield SetupModelProxySession( + socket_path=socket_path, + subject=subject, + maximum_calls=profile.spec.maximum_calls, + maximum_output_tokens=profile.spec.maximum_output_tokens, + reasoning_effort=profile.spec.settings.reasoning_effort, + text_verbosity=profile.spec.settings.text_verbosity, + timeout_seconds=profile.spec.deadline_ms / 1000.0, + ) + + return open_session + + +def _profile_covers_tool_runtime(profile, configuration) -> bool: + """Require the selected profile to cover the fixed setup tool capability.""" + return ( + profile.spec.allowed_tools == ("read_document", "search_document") + and configuration.maximum_tool_calls <= profile.spec.maximum_tool_calls + and configuration.maximum_tool_output_characters * 4 + <= profile.spec.maximum_tool_output_bytes + ) + + +def _decode_reply(payload: bytes) -> SetupModelProxyReply: + """Decode the closed trusted-listener response frame.""" + value = json.loads( + payload.decode("utf-8"), + parse_constant=_reject_constant, + object_pairs_hook=_closed_object, + ) + expected = { + "protocol_version", + "operation", + "outcome", + "call_index", + "status_code", + "response_json", + "response_headers", + "failure_code", + "session_exhausted", + } + if type(value) is not dict or set(value) != expected: + raise ValueError("setup model reply root is invalid") + if ( + value["protocol_version"] != SETUP_MODEL_PROXY_PROTOCOL + or value["operation"] != "dispatch" + or type(value["response_headers"]) is not dict + ): + raise ValueError("setup model reply protocol is invalid") + return SetupModelProxyReply( + outcome=value["outcome"], + call_index=value["call_index"], + status_code=value["status_code"], + response_json=value["response_json"], + response_headers=tuple(sorted(value["response_headers"].items())), + failure_code=value["failure_code"], + session_exhausted=value["session_exhausted"], + ) + + +def _validated_socket(path: Path) -> tuple[int, int]: + """Verify exact same-identity socket custody and return its stable inode.""" + encoded = os.fsencode(path) + if not path.is_absolute() or not encoded or b"\0" in encoded or len(encoded) >= 108: + raise SetupModelProxyUnavailable("setup model proxy path is invalid") + metadata = os.lstat(path) + if ( + not stat.S_ISSOCK(metadata.st_mode) + or stat.S_ISLNK(metadata.st_mode) + or stat.S_IMODE(metadata.st_mode) != 0o600 + or metadata.st_uid != os.geteuid() + or metadata.st_gid != os.getegid() + ): + raise SetupModelProxyUnavailable("setup model proxy custody is invalid") + return metadata.st_dev, metadata.st_ino + + +def _closed_object(pairs: list[tuple[str, Any]]) -> dict[str, Any]: + """Decode one JSON object while rejecting duplicate members.""" + value: dict[str, Any] = {} + for key, item in pairs: + if key in value: + raise ValueError("duplicate setup model proxy field") + value[key] = item + return value + + +def _reject_constant(_: str) -> None: + """Reject non-finite numbers in private transport JSON.""" + raise ValueError("non-finite setup model proxy number") + + +__all__ = ( + "SetupModelProxySession", + "SetupModelProxyUnavailable", + "create_setup_httpx_client", + "setup_model_session_factory", +) + + +def create_setup_httpx_client( + session: SetupModelProxySession, + *, + model: str, + api: str, + phase: Callable[[], SetupModelPhase], + timeout_seconds: float, +): + """Build a no-network httpx2 client that preserves native SDK request parsing.""" + import httpx2 + + expected_path = { + "responses": "/v1/responses", + "chat_completions": "/v1/chat/completions", + }.get(api) + if expected_path is None: + raise ValueError("setup model API is unsupported") + + class DispatchTransport(httpx2.AsyncBaseTransport): + """Translate one closed native SDK request into the setup session.""" + + async def handle_async_request(self, request): + """Validate the SDK endpoint and return the trusted proxy reply.""" + if ( + request.method != "POST" + or request.url.host != "model-proxy.invalid" + or request.url.path != expected_path + ): + raise httpx2.TransportError("setup model SDK request is outside policy") + body = await request.aread() + try: + value = json.loads( + body, + parse_constant=_reject_constant, + object_pairs_hook=_closed_object, + ) + except (ValueError, UnicodeError) as exc: + raise httpx2.TransportError("setup model SDK request is invalid") from exc + if ( + not isinstance(value, dict) + or value.get("model") != model + or value.get("stream", False) is not False + ): + raise httpx2.TransportError("setup model SDK request is outside policy") + try: + reply = await session.dispatch(phase(), value) + except SetupModelProxyUnavailable as exc: + raise httpx2.TransportError("setup model dispatch is unavailable") from exc + assert reply.status_code is not None and reply.response_json is not None + return httpx2.Response( + status_code=reply.status_code, + headers=dict(reply.response_headers), + content=reply.response_json.encode("utf-8"), + request=request, + ) + + return httpx2.AsyncClient( + transport=DispatchTransport(), + trust_env=False, + follow_redirects=False, + timeout=timeout_seconds, + ) diff --git a/backend/app/adapters/projects/__init__.py b/backend/app/adapters/projects/__init__.py index ff8d73470..faba1d794 100644 --- a/backend/app/adapters/projects/__init__.py +++ b/backend/app/adapters/projects/__init__.py @@ -1,58 +1,59 @@ """PROJECT-owned composition adapters.""" +from collections.abc import Callable +from uuid import UUID + +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker + +from app.interfaces.external_services import ExternalServiceAdapterFactory +from app.interfaces.project_agents import ProjectGuideAgentRuntime +from app.interfaces.project_guide_runtime import ProjectGuideRuntimeConfiguration +from app.modules.authorization.api import ( + ActorIdentityFacts, + ArtifactPolicyProjectionAuthorizationPort, + GuideSufficiencyProjectionAuthorizationPort, + ProjectGuideCompilationAuthorizationPort, + SetupFinalizationAuthorizationPort, +) +from app.modules.authorization.api.guide_proposal_review import GuideProposalAuthorizationPort +from app.modules.authorization.api.post_policy import PostPolicyAuthorizationPort +from app.modules.checkers.api.external import ExternalCheckerCapabilityProjection +from app.modules.projects.api import ( + ProjectContributionPolicyEligibilityPort, + ProjectLockedPolicyContextPort, +) from app.modules.projects.api.guide_activation import ( GuideActivationAuthorizationPort, GuideActivationPort, GuideActivationReadinessPort, GuideContributionPolicyPort, ) -from app.modules.projects.api.guide_reads import ProjectGuideReadPort - - -from app.modules.projects.api.guide_documents import ProjectGuideDocumentScopePort -from app.modules.projects.api.guide_proposals import ( - GuideProposalOperationsPort, - GuideCorrectionDispatchPort, -) -from app.modules.authorization.api.guide_proposal_review import GuideProposalAuthorizationPort -from app.modules.authorization.api import ProjectGuideCompilationAuthorizationPort -from uuid import UUID -from collections.abc import Callable -from app.modules.authorization.api.post_policy import PostPolicyAuthorizationPort -from app.modules.projects.api.post_policy import PostPolicyOperationsPort, PostPolicyDeliveryPort -from app.modules.projects.post_policy.delivery import PostPolicyServiceAuthority -from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker +from app.modules.projects.api.guide_compilation import ProjectGuideCompilationDeliveryPort from app.modules.projects.api.guide_documents import ( + GuideDocumentAccessFactory, GuideDocumentManifestPort, GuideDocumentToolsFactory, - GuideDocumentAccessFactory, GuideRuntimeResourceCleanupAdapter, + ProjectGuideDocumentScopePort, ) -from app.interfaces.project_agents import ProjectGuideAgentRuntime -from app.interfaces.external_services import ExternalServiceAdapterFactory -from app.interfaces.project_guide_runtime import ProjectGuideRuntimeConfiguration -from app.modules.checkers.api.external import ExternalCheckerCapabilityProjection -from app.modules.authorization.api import ( - ActorIdentityFacts, - GuideSufficiencyProjectionAuthorizationPort, - ArtifactPolicyProjectionAuthorizationPort, - SetupFinalizationAuthorizationPort, +from app.modules.projects.api.guide_proposals import ( + GuideCorrectionDispatchPort, + GuideProposalOperationsPort, +) +from app.modules.projects.api.guide_reads import ProjectGuideReadPort +from app.modules.projects.api.post_policy import PostPolicyDeliveryPort, PostPolicyOperationsPort +from app.modules.projects.contribution_policy import ProjectContributionPolicyEligibility +from app.modules.projects.guide_compilation.live import ( + LiveGuideCompilationCoordinator, + RequestAuthority, ) -from app.modules.projects.guide_compilation.live import RequestAuthority -from app.modules.projects.guide_compilation.orchestrator import GuideCompilationAuthorizationContext -from app.modules.projects.api.guide_compilation import ProjectGuideCompilationDeliveryPort -from app.modules.projects.guide_compilation.live import LiveGuideCompilationCoordinator from app.modules.projects.guide_compilation.orchestrator import ( + GuideCompilationAuthorizationContext, project_guide_compilation_execution_port, ) from app.modules.projects.guide_compilation.projections import GuideCompilationProjectionService - -from app.modules.projects.api import ( - ProjectContributionPolicyEligibilityPort, - ProjectLockedPolicyContextPort, -) -from app.modules.projects.contribution_policy import ProjectContributionPolicyEligibility from app.modules.projects.locked_policy_repository import ProjectLockedPolicyRepository +from app.modules.projects.post_policy.delivery import PostPolicyServiceAuthority def project_locked_policy_context_port( @@ -69,15 +70,24 @@ def project_contribution_policy_eligibility_port( return ProjectContributionPolicyEligibility(session) +def setup_model_usage_owner(session: AsyncSession): + """Expose PROJECTS setup currentness only through the shared typed owner port.""" + from app.modules.projects.guide_compilation.model_usage import SetupModelUsageCurrent + + return SetupModelUsageCurrent(session) + + def project_guide_compilation_delivery_port( session_factory: async_sessionmaker[AsyncSession], *, material_factory: Callable[[AsyncSession], GuideDocumentManifestPort], document_tools_factory: GuideDocumentToolsFactory, + model_session_factory, checker_capabilities: ExternalCheckerCapabilityProjection, request_authority: RequestAuthority, execution_authority: GuideCompilationAuthorizationContext, configuration_factory: Callable[[], ProjectGuideRuntimeConfiguration], + model_profiles, runtime_factory: Callable[[ProjectGuideRuntimeConfiguration], ProjectGuideAgentRuntime], sufficiency_authorization_factory: Callable[ [AsyncSession], GuideSufficiencyProjectionAuthorizationPort @@ -96,6 +106,7 @@ def project_guide_compilation_delivery_port( checker_capabilities=checker_capabilities, request_authority=request_authority, configuration_factory=configuration_factory, + model_profiles=model_profiles, execution=project_guide_compilation_execution_port( session_factory, material_factory=material_factory, @@ -103,6 +114,7 @@ def project_guide_compilation_delivery_port( authorization_context=execution_authority, runtime_factory=runtime_factory, document_tools_factory=document_tools_factory, + model_session_factory=model_session_factory, ), projections=GuideCompilationProjectionService( session_factory, @@ -195,10 +207,15 @@ def project_guide_proposal_service( ExternalCheckerCapabilityProjection, ]: """Compose the sole proposal owner with explicit authority.""" - from app.adapters.checkers import external_checker_registry_reader + from app.adapters.checkers import external_checker_registry_reader, model_profile_reader from app.modules.projects.guide_compilation.proposal_service import GuideProposalService - return GuideProposalService(session, authorization, external_checker_registry_reader(session)) + return GuideProposalService( + session, + authorization, + external_checker_registry_reader(session), + model_profile_reader(session), + ) def project_guide_correction_dispatch( @@ -215,10 +232,13 @@ def project_guide_correction_dispatch( ) from app.modules.projects.guide_compilation.request_inputs import CompilationRequestInputs + from app.adapters.model_usage import model_profile_reader + return GuideCorrectionDispatchService( session, authorization, CompilationRequestInputs(material, capabilities, configuration), + profiles=model_profile_reader(session), ) @@ -270,7 +290,7 @@ def project_guide_activation_port( authorization: GuideActivationAuthorizationPort | None = None, ) -> GuideActivationPort[ActorIdentityFacts]: """Compose canonical activation with explicit CON validation and authority.""" - from app.adapters.checkers import external_checker_registry_reader + from app.adapters.checkers import external_checker_registry_reader, model_profile_reader from app.modules.projects.guide_activation.service import GuideActivationService return GuideActivationService( @@ -278,6 +298,7 @@ def project_guide_activation_port( contribution=contribution, authorization=authorization, registry=external_checker_registry_reader(session), + profiles=model_profile_reader(session), ) @@ -287,13 +308,14 @@ def project_guide_activation_readiness_port( contribution: GuideContributionPolicyPort, ) -> GuideActivationReadinessPort: """Compose activation's own conditions for a dry run; no activation authority is bound.""" - from app.adapters.checkers import external_checker_registry_reader + from app.adapters.checkers import external_checker_registry_reader, model_profile_reader from app.modules.projects.guide_activation.service import GuideActivationService return GuideActivationService( session, contribution=contribution, registry=external_checker_registry_reader(session), + profiles=model_profile_reader(session), ) diff --git a/backend/app/adapters/tasks/__init__.py b/backend/app/adapters/tasks/__init__.py index 16650488a..d8b68129b 100644 --- a/backend/app/adapters/tasks/__init__.py +++ b/backend/app/adapters/tasks/__init__.py @@ -510,7 +510,7 @@ def validate_submission_policy_context( def evaluation_request_handler(*, sessions, materialization): """Compose one hidden TASK request handler; production registration is separate.""" from app.adapters.checkers import ( - configured_external_checker_execution, + configured_post_submit_external_execution, delivery_bound_post_submission_executor, evaluation_coordinator, ) @@ -519,7 +519,7 @@ def evaluation_request_handler(*, sessions, materialization): from app.modules.tasks.evaluation_delivery import EvaluationRequestHandler def executor(envelope, request): - selected_execution = configured_external_checker_execution(get_settings()) + selected_execution = configured_post_submit_external_execution(get_settings(), sessions=sessions) return delivery_bound_post_submission_executor( sessions=sessions, materialization=materialization, diff --git a/backend/app/api/deps/checker_models.py b/backend/app/api/deps/checker_models.py new file mode 100644 index 000000000..fd1ae184b --- /dev/null +++ b/backend/app/api/deps/checker_models.py @@ -0,0 +1,261 @@ +"""Authenticated public composition for Operator-owned checker model controls.""" + +from __future__ import annotations + +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager +from dataclasses import dataclass +from typing import Annotated +from uuid import UUID + +from fastapi import Depends, Request +from sqlalchemy.exc import SQLAlchemyError +from sqlalchemy.ext.asyncio import AsyncSession + +from app.adapters.auth import checker_model_authorization +from app.adapters.checkers import model_call_service +from app.adapters.model_usage import model_usage_inspection +from app.api.deps.auth import get_auth_verification_result +from app.api.deps.authorization import ( + ResolvedActor, + _authorization_context, + get_authorization_actor, +) +from app.core.api_controls import StructuredHTTPException, parse_idempotency_key, request_ids +from app.core.hashing import canonical_json_hash +from app.db.session import get_db_session +from app.modules.model_usage.api import ( + ModelBudgetProvisioningFacts, + ModelCallBudgetUnavailable, + ModelCallAdministrationPort, + ModelCallConflict, + ModelCallUnavailable, + ModelProfileProvisioningFacts, + ModelUsageEffectInspection, + ModelUsageInspectionFacts, + ModelUsageInspectionPort, + ModelRecoveryChildRequest, + PreparedModelCallAttempt, + ProjectModelBudgetConfiguration, + ProjectModelBudgetConfigurationSpec, + ProjectModelProfile, + ProjectModelProfileSpec, +) +from app.schemas.auth import AuthVerificationResult + + +MODEL_ADMIN_IDEMPOTENCY_PARAMETER = { + "name": "Idempotency-Key", + "in": "header", + "required": True, + "schema": {"type": "string", "format": "uuid"}, +} + + +def require_model_admin_key(request: Request) -> UUID: + """Reject an absent, duplicate or noncanonical Operator mutation key.""" + values = request.headers.getlist("Idempotency-Key") + return parse_idempotency_key(values[0] if len(values) == 1 else "") + + +def _error(status_code: int) -> StructuredHTTPException: + code, message = { + 404: ("checker_model_control_not_found", "Checker model control not found"), + 409: ("checker_model_control_conflict", "Checker model control conflicts"), + 503: ("checker_model_control_unavailable", "Checker model control unavailable"), + }[status_code] + return StructuredHTTPException( + status_code=status_code, + detail=code, + error_code=code, + error_message=message, + retryable=status_code == 503, + ) + + +async def require_model_admin_human( + operation_id: Annotated[UUID, Depends(require_model_admin_key)], + result: Annotated[AuthVerificationResult, Depends(get_auth_verification_result)], +) -> None: + """Consume the key first and conceal every nonhuman public mutation caller.""" + del operation_id + if result.token.subject_kind != "human": + raise _error(404) + + +async def require_model_recovery_human( + result: Annotated[AuthVerificationResult, Depends(get_auth_verification_result)], +) -> None: + """Conceal recovery controls from every nonhuman public caller.""" + if result.token.subject_kind != "human": + raise _error(404) + + +@dataclass(frozen=True) +class CheckerModelAdminCommands: + """Bind path, actor and caller key to the existing atomic CHECKERS owner.""" + + service: ModelCallAdministrationPort + actor_profile_id: UUID + operation_id: UUID + inspection: ModelUsageInspectionPort | None = None + + async def provision_profile( + self, project_id: UUID, spec: ProjectModelProfileSpec + ) -> ProjectModelProfile: + if spec.project_id != project_id: + raise ModelCallConflict("model_profile_project_conflict") + return await self.service.provision_profile( + ModelProfileProvisioningFacts( + operation_id=self.operation_id, + actor_profile_id=self.actor_profile_id, + spec=spec, + profile_digest=canonical_json_hash(spec.model_dump(mode="json")), + ) + ) + + async def configure_budget( + self, project_id: UUID, spec: ProjectModelBudgetConfigurationSpec + ) -> ProjectModelBudgetConfiguration: + if spec.project_id != project_id: + raise ModelCallConflict("model_budget_project_conflict") + return await self.service.provision_budget( + ModelBudgetProvisioningFacts( + operation_id=self.operation_id, + actor_profile_id=self.actor_profile_id, + spec=spec, + configuration_digest=canonical_json_hash(spec.model_dump(mode="json")), + ) + ) + + async def inspect_effect(self, project_id: UUID, effect_id: UUID) -> ModelUsageEffectInspection: + if self.inspection is None: + raise ModelCallUnavailable("model_usage_inspection_unavailable") + return await self.inspection.inspect_effect( + ModelUsageInspectionFacts( + project_id=project_id, + effect_id=effect_id, + actor_profile_id=self.actor_profile_id, + request_id=self.operation_id, + ) + ) + + async def prepare_recovery_child( + self, + project_id: UUID, + parent_attempt_id: UUID, + request: ModelRecoveryChildRequest, + ) -> PreparedModelCallAttempt: + if request.slot.project_id != project_id: + raise ModelCallConflict("model_recovery_project_conflict") + return await self.service.prepare_recovery_child( + request.slot, + registry_entry_id=request.registry_entry_id, + parent_attempt_id=parent_attempt_id, + terminal_digest=request.terminal_digest, + request_sha256=request.request_sha256, + ) + + +@asynccontextmanager +async def _model_admin_operation( + request: Request, + operation_id: UUID, + resolved: ResolvedActor, + session: AsyncSession, + *, + correlation_id: UUID | None = None, +) -> AsyncIterator[CheckerModelAdminCommands]: + request_id, _ = (UUID(value) for value in request_ids(request)) + context = _authorization_context(resolved, request_id, correlation_id or operation_id) + await session.rollback() + try: + async with session.begin(): + authority = checker_model_authorization(session, context) + try: + yield CheckerModelAdminCommands( + service=model_call_service(session, authority), + inspection=model_usage_inspection(session, authority=authority), + actor_profile_id=context.actor_profile_id, + operation_id=operation_id, + ) + except ModelCallUnavailable as exc: + # AUTH has already staged its canonical denial. Preserve that evidence + # before the public boundary conceals the private authority reason. + if str(exc) == "model_call_authority_unavailable" and session.is_active: + await session.commit() + raise + except ModelCallBudgetUnavailable as exc: + raise _error(409) from exc + except ModelCallConflict as exc: + raise _error(409) from exc + except ModelCallUnavailable as exc: + status_code = ( + 404 + if str(exc) + in {"model_call_authority_unavailable", "model_usage_inspection_unavailable"} + else 503 + ) + raise _error(status_code) from exc + except SQLAlchemyError as exc: + raise _error(503) from exc + finally: + if session.in_transaction(): + await session.rollback() + + +async def get_checker_model_admin_commands( + request: Request, + operation_id: Annotated[UUID, Depends(require_model_admin_key)], + resolved: Annotated[ResolvedActor, Depends(get_authorization_actor)], + session: Annotated[AsyncSession, Depends(get_db_session)], +) -> AsyncIterator[CheckerModelAdminCommands]: + """Compose one human Operator mutation and its root transaction.""" + async with _model_admin_operation(request, operation_id, resolved, session) as commands: + yield commands + + +async def get_checker_model_recovery_commands( + request: Request, + resolved: Annotated[ResolvedActor, Depends(get_authorization_actor)], + session: Annotated[AsyncSession, Depends(get_db_session)], +) -> AsyncIterator[CheckerModelAdminCommands]: + """Compose recovery under fresh request IDs; the parent/candidate own replay.""" + request_id, correlation_id = (UUID(value) for value in request_ids(request)) + async with _model_admin_operation( + request, + request_id, + resolved, + session, + correlation_id=correlation_id, + ) as commands: + yield commands + + +async def get_checker_model_read_commands( + request: Request, + resolved: Annotated[ResolvedActor, Depends(get_authorization_actor)], + session: Annotated[AsyncSession, Depends(get_db_session)], +) -> AsyncIterator[CheckerModelAdminCommands]: + """Compose one exact human Operator model-usage read.""" + request_id, correlation_id = (UUID(value) for value in request_ids(request)) + async with _model_admin_operation( + request, + request_id, + resolved, + session, + correlation_id=correlation_id, + ) as commands: + yield commands + + +__all__ = ( + "MODEL_ADMIN_IDEMPOTENCY_PARAMETER", + "CheckerModelAdminCommands", + "get_checker_model_admin_commands", + "get_checker_model_recovery_commands", + "get_checker_model_read_commands", + "require_model_admin_human", + "require_model_admin_key", + "require_model_recovery_human", +) diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 9ca7a9a0b..5e832c24c 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -282,12 +282,19 @@ class Settings(BaseSettings): ) external_checker_service_socket: Path | None = None external_checker_material_root: Path | None = None + checker_model_proxy_socket: Path | None = None external_checker_service_timeout_seconds: float = Field( default=3660.0, gt=0.0, le=3700.0, ) + @field_validator("checker_model_proxy_socket", mode="before") + @classmethod + def empty_model_proxy_socket(cls, value: object) -> object: + """Allow the local compose overlay to leave model execution disabled.""" + return None if value == "" else value + artifact_pending_work_scan_interval_seconds: int = Field(default=60, gt=0, le=3600) artifact_pending_work_scan_page_size: int = Field(default=100, gt=0, le=1000) guide_setup_continuation_scan_page_size: int = Field(default=100, gt=0, le=1000) @@ -669,6 +676,11 @@ def validate_artifact_storage(self) -> Settings: raise ValueError( "external checker material root must equal the artifact scratch root" ) + if self.checker_model_proxy_socket is not None: + if not self.checker_model_proxy_socket.is_absolute(): + raise ValueError("checker model proxy socket must be absolute") + if self.artifact_store_backend == "disabled": + raise ValueError("checker model proxy requires enabled artifact storage") if self.artifact_store_backend == "disabled": return self if any( diff --git a/backend/app/core/project_guide_instructions.py b/backend/app/core/project_guide_instructions.py index 3104284a2..cd4fc243f 100644 --- a/backend/app/core/project_guide_instructions.py +++ b/backend/app/core/project_guide_instructions.py @@ -158,7 +158,7 @@ { "dependency_id": "reasoning_model", "kind": "model_service", - "provisioning": "runtime_gap", + "provisioning": "model_proxy", "purpose": "Apply the qualitative evidence rubric.", "unavailable_code": "implementation_unavailable", } @@ -317,9 +317,11 @@ capacity, deadline, provider failure and invalid output cannot consume revision rounds. DEPENDENCIES -Specifications grant no access. Current images receive verified package material, -manifest, approved configuration and normalized request metadata, not task/guide -prose, hosted models or nested environments. Declare unavailable needs explicitly; +Specs give no access. Images get verified material, manifest, config and +request, not guide prose, provider keys or nested environments. POST +model_evaluation requires model_service/model_proxy, implementation_unavailable, +a registered project image and its exact approved profile; else stays unbound. +Declare unavailable needs explicitly; a supported binding cannot depend on runtime_gap. current_runner isolates the checker image only; it supplies no container engine, second sandbox or host socket. Follow the schema's exact dependency kind/provisioning/unavailable_code pairs. diff --git a/backend/app/db/models.py b/backend/app/db/models.py index af769812e..e2bb7a027 100644 --- a/backend/app/db/models.py +++ b/backend/app/db/models.py @@ -123,3 +123,16 @@ from app.modules.reviews.lifecycle.models import JointLifecycleReleaseControl # noqa: F401 from app.modules.tasks.revision.models import TaskRevisionPreparation, TaskRevisionExpiration, TaskRevisionExhaustion # noqa: F401 + +from app.modules.model_usage.models import ( # noqa: F401 + ModelUsageProfile, + ModelUsageBudgetAccount, + ModelUsageBudgetConfiguration, + CheckerModelUsageEnvelope, + CheckerModelUsageSlot, + SetupModelUsageEnvelope, + SetupModelUsageSlot, + ModelUsageEffect, + CheckerModelUsageRecovery, + CheckerModelInvocationGrant, +) diff --git a/backend/app/interfaces/checker_model_provider.py b/backend/app/interfaces/checker_model_provider.py new file mode 100644 index 000000000..e6a442d46 --- /dev/null +++ b/backend/app/interfaces/checker_model_provider.py @@ -0,0 +1,243 @@ +"""Typed provider boundary for one policy-authorized checker model call.""" + +from __future__ import annotations + +from dataclasses import dataclass, field as dataclass_field +from enum import StrEnum +import re +from typing import Protocol + +from app.interfaces.external_services import ExternalServiceAdapter + + +CHECKER_MODEL_CAPABILITY_KEY = "checker_model_generation" +MAX_CHECKER_MODEL_PROMPT_BYTES = 1 * 1024 * 1024 +MAX_CHECKER_MODEL_OUTPUT_BYTES = 1 * 1024 * 1024 +MAX_CHECKER_MODEL_PROVIDER_ID_LENGTH = 256 +MAX_CHECKER_MODEL_NAME_LENGTH = 128 +MAX_CHECKER_MODEL_TOKEN_COUNT = 2**63 - 1 + +_PROVIDER_IDENTIFIER = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]*$") + + +class CheckerModelProviderStatus(StrEnum): + """Provider-owned state for one accepted response effect.""" + + QUEUED = "queued" + IN_PROGRESS = "in_progress" + COMPLETED = "completed" + FAILED = "failed" + CANCELLED = "cancelled" + INCOMPLETE = "incomplete" + + +class CheckerModelCreateOutcome(StrEnum): + """Effect classification for exactly one authorized create dispatch.""" + + ACCEPTED = "accepted" + NO_EFFECT = "no_effect" + UNCERTAIN = "uncertain" + + +class CheckerModelCreateFailure(StrEnum): + """Secret-safe reason for a create operation without an accepted observation.""" + + REQUEST_BYTES_EXCEEDED = "request_bytes_exceeded" + INPUT_COUNT_UNAVAILABLE = "input_count_unavailable" + INPUT_LIMIT_EXCEEDED = "input_limit_exceeded" + DELIVERY_UNCERTAIN = "delivery_uncertain" + RESPONSE_INVALID = "response_invalid" + + +class CheckerModelRecoveryOutcome(StrEnum): + """Observation classification for retrieval of one known provider response.""" + + OBSERVED = "observed" + UNAVAILABLE = "unavailable" + INVALID = "invalid" + + +@dataclass(frozen=True, slots=True) +class CheckerModelRequest: + """Bounded image-authored prompt without provider-controlled settings.""" + + prompt: str = dataclass_field(repr=False) + + def __post_init__(self) -> None: + if not isinstance(self.prompt, str) or not self.prompt: + raise ValueError("checker model prompt is invalid") + try: + encoded = self.prompt.encode("utf-8") + except UnicodeError: + raise ValueError("checker model prompt is invalid") from None + if len(encoded) > MAX_CHECKER_MODEL_PROMPT_BYTES: + raise ValueError("checker model prompt is invalid") + + +@dataclass(frozen=True, slots=True) +class CheckerModelResponseReference: + """One bounded provider response identity already known to Workstream.""" + + provider_response_id: str + + def __post_init__(self) -> None: + _require_provider_identifier( + self.provider_response_id, + maximum_length=MAX_CHECKER_MODEL_PROVIDER_ID_LENGTH, + label="checker model response identity", + ) + + +@dataclass(frozen=True, slots=True) +class CheckerModelUsage: + """Authenticated token facts; reasoning tokens remain part of output tokens.""" + + input_tokens: int + cached_input_tokens: int + output_tokens: int + reasoning_tokens: int + total_tokens: int + + def __post_init__(self) -> None: + values = ( + self.input_tokens, + self.cached_input_tokens, + self.output_tokens, + self.reasoning_tokens, + self.total_tokens, + ) + if any( + type(value) is not int or not 0 <= value <= MAX_CHECKER_MODEL_TOKEN_COUNT + for value in values + ): + raise ValueError("checker model usage is invalid") + if ( + self.cached_input_tokens > self.input_tokens + or self.reasoning_tokens > self.output_tokens + or self.total_tokens != self.input_tokens + self.output_tokens + ): + raise ValueError("checker model usage is invalid") + + +@dataclass(frozen=True, slots=True) +class CheckerModelObservation: + """Bounded provider response facts suitable for private ART evidence.""" + + reference: CheckerModelResponseReference + provider_model: str + status: CheckerModelProviderStatus + output_text: str | None = dataclass_field(default=None, repr=False) + usage: CheckerModelUsage | None = None + + def __post_init__(self) -> None: + if type(self.reference) is not CheckerModelResponseReference: + raise ValueError("checker model observation is invalid") + _require_provider_identifier( + self.provider_model, + maximum_length=MAX_CHECKER_MODEL_NAME_LENGTH, + label="checker model provider identity", + ) + if type(self.status) is not CheckerModelProviderStatus: + raise ValueError("checker model observation is invalid") + if self.output_text is not None: + if not isinstance(self.output_text, str): + raise ValueError("checker model observation is invalid") + try: + output_bytes = self.output_text.encode("utf-8") + except UnicodeError: + raise ValueError("checker model observation is invalid") from None + if len(output_bytes) > MAX_CHECKER_MODEL_OUTPUT_BYTES: + raise ValueError("checker model observation is invalid") + if self.usage is not None and type(self.usage) is not CheckerModelUsage: + raise ValueError("checker model observation is invalid") + if self.status in { + CheckerModelProviderStatus.QUEUED, + CheckerModelProviderStatus.IN_PROGRESS, + } and (self.output_text is not None or self.usage is not None): + raise ValueError("checker model observation is invalid") + if self.status is CheckerModelProviderStatus.COMPLETED and ( + self.output_text is None or self.usage is None + ): + raise ValueError("checker model observation is invalid") + + +@dataclass(frozen=True, slots=True) +class CheckerModelCreateResult: + """Sanitized result of one create attempt, including exact effect certainty.""" + + outcome: CheckerModelCreateOutcome + observation: CheckerModelObservation | None = None + known_response: CheckerModelResponseReference | None = None + failure: CheckerModelCreateFailure | None = None + + def __post_init__(self) -> None: + if type(self.outcome) is not CheckerModelCreateOutcome: + raise ValueError("checker model create result is invalid") + if self.outcome is CheckerModelCreateOutcome.ACCEPTED: + if ( + type(self.observation) is not CheckerModelObservation + or self.known_response != self.observation.reference + or self.failure is not None + ): + raise ValueError("checker model create result is invalid") + return + if self.observation is not None or type(self.failure) is not CheckerModelCreateFailure: + raise ValueError("checker model create result is invalid") + if self.outcome is CheckerModelCreateOutcome.NO_EFFECT: + if self.known_response is not None or self.failure not in { + CheckerModelCreateFailure.REQUEST_BYTES_EXCEEDED, + CheckerModelCreateFailure.INPUT_COUNT_UNAVAILABLE, + CheckerModelCreateFailure.INPUT_LIMIT_EXCEEDED, + }: + raise ValueError("checker model create result is invalid") + elif self.failure not in { + CheckerModelCreateFailure.DELIVERY_UNCERTAIN, + CheckerModelCreateFailure.RESPONSE_INVALID, + }: + raise ValueError("checker model create result is invalid") + + +@dataclass(frozen=True, slots=True) +class CheckerModelRecoveryResult: + """Sanitized result of retrieving one previously accepted provider response.""" + + outcome: CheckerModelRecoveryOutcome + reference: CheckerModelResponseReference + observation: CheckerModelObservation | None = None + + def __post_init__(self) -> None: + if ( + type(self.outcome) is not CheckerModelRecoveryOutcome + or type(self.reference) is not CheckerModelResponseReference + ): + raise ValueError("checker model recovery result is invalid") + if self.outcome is CheckerModelRecoveryOutcome.OBSERVED: + if ( + type(self.observation) is not CheckerModelObservation + or self.observation.reference != self.reference + ): + raise ValueError("checker model recovery result is invalid") + elif self.observation is not None: + raise ValueError("checker model recovery result is invalid") + + +class CheckerModelProvider(ExternalServiceAdapter, Protocol): + """Provider-specific transport behind durable Workstream call custody.""" + + async def create(self, request: CheckerModelRequest) -> CheckerModelCreateResult: + """Dispatch one authorized effect exactly once.""" + + async def recover(self, reference: CheckerModelResponseReference) -> CheckerModelRecoveryResult: + """Inspect one known effect without creating another.""" + + async def aclose(self) -> None: + """Close provider transport resources owned by this adapter.""" + + +def _require_provider_identifier(value: object, *, maximum_length: int, label: str) -> None: + if ( + not isinstance(value, str) + or len(value) > maximum_length + or _PROVIDER_IDENTIFIER.fullmatch(value) is None + ): + raise ValueError(f"{label} is invalid") diff --git a/backend/app/interfaces/checker_model_proxy.py b/backend/app/interfaces/checker_model_proxy.py new file mode 100644 index 000000000..14c5b7ad0 --- /dev/null +++ b/backend/app/interfaces/checker_model_proxy.py @@ -0,0 +1,168 @@ +"""Typed private protocol for bounded invocation-local model sessions.""" + +from collections.abc import Awaitable, Callable +from dataclasses import dataclass +import hashlib +import json + + +MODEL_PROXY_PROTOCOL = "workstream.model_session.v2" +MODEL_PROXY_MAXIMUM_FRAME_BYTES = 1024 * 1024 +MODEL_PROXY_MAXIMUM_RECEIPTS = 1000 +_MODEL_GRANT_PREFIX = "model_" +_SHA256_PREFIX = "sha256:" + + +@dataclass(frozen=True, slots=True, repr=False) +class ModelProxyResolvedReceipt: + """Exact retained tool receipt resolved by trusted invocation code.""" + + receipt_sha256: str + canonical_receipt_utf8: str + + def __post_init__(self) -> None: + if not _valid_sha256(self.receipt_sha256) or type(self.canonical_receipt_utf8) is not str: + raise ValueError("model proxy receipt is invalid") + try: + encoded = self.canonical_receipt_utf8.encode("utf-8") + value = json.loads(self.canonical_receipt_utf8) + except (UnicodeError, ValueError, TypeError): + raise ValueError("model proxy receipt is invalid") from None + if ( + not encoded + or len(encoded) > MODEL_PROXY_MAXIMUM_FRAME_BYTES + or "sha256:" + hashlib.sha256(encoded).hexdigest() != self.receipt_sha256 + or json.dumps( + value, + sort_keys=True, + ensure_ascii=False, + separators=(",", ":"), + allow_nan=False, + ) + != self.canonical_receipt_utf8 + ): + raise ValueError("model proxy receipt is invalid") + + def __repr__(self) -> str: + return "ModelProxyResolvedReceipt()" + + +@dataclass(frozen=True, slots=True, repr=False) +class ModelProxyRequest: + """One strict session call; repr never exposes its grant, prompt or receipts.""" + + grant_id: str + request_digest: str + call_index: int + prompt: str + resolved_receipts: tuple[ModelProxyResolvedReceipt, ...] = () + + def __post_init__(self) -> None: + if ( + not _valid_model_grant(self.grant_id) + or not _valid_sha256(self.request_digest) + or type(self.call_index) is not int + or not 0 <= self.call_index <= 99 + or type(self.prompt) is not str + or not self.prompt + or type(self.resolved_receipts) is not tuple + or len(self.resolved_receipts) > MODEL_PROXY_MAXIMUM_RECEIPTS + or any(type(item) is not ModelProxyResolvedReceipt for item in self.resolved_receipts) + or len({item.receipt_sha256 for item in self.resolved_receipts}) + != len(self.resolved_receipts) + ): + raise ValueError("model proxy request is invalid") + + def __repr__(self) -> str: + return "ModelProxyRequest()" + + +@dataclass(frozen=True, slots=True, repr=False) +class ModelProxyReply: + """Provider-neutral bounded reply returned to the checker image.""" + + outcome: str + output: str | None + receipt_digest: str | None + failure_code: str | None + call_index: int + session_exhausted: bool + + def __post_init__(self) -> None: + completed = ( + self.outcome == "completed" + and type(self.call_index) is int + and 0 <= self.call_index <= 99 + and type(self.output) is str + and bool(self.output) + and _valid_sha256(self.receipt_digest) + and self.failure_code is None + ) + unavailable = ( + self.outcome == "unavailable" + and type(self.call_index) is int + and 0 <= self.call_index <= 99 + and self.output is None + and self.receipt_digest is None + and _valid_failure_code(self.failure_code) + ) + if type(self.session_exhausted) is not bool or (not completed and not unavailable): + raise ValueError("model proxy reply is invalid") + + def __repr__(self) -> str: + return f"ModelProxyReply(outcome={self.outcome!r}, )" + + def wire_value(self) -> dict[str, str | None]: + """Return the exact private protocol root without provider coordinates.""" + return { + "protocol_version": MODEL_PROXY_PROTOCOL, + "outcome": self.outcome, + "output": self.output, + "receipt_digest": self.receipt_digest, + "failure_code": self.failure_code, + "call_index": self.call_index, + "session_exhausted": self.session_exhausted, + } + + +ModelProxyHandler = Callable[[ModelProxyRequest], Awaitable[ModelProxyReply]] + + +def _valid_model_grant(value: object) -> bool: + return ( + type(value) is str + and len(value) == len(_MODEL_GRANT_PREFIX) + 32 + and value.startswith(_MODEL_GRANT_PREFIX) + and all(item in "0123456789abcdef" for item in value[len(_MODEL_GRANT_PREFIX) :]) + ) + + +def _valid_sha256(value: object) -> bool: + return ( + type(value) is str + and len(value) == len(_SHA256_PREFIX) + 64 + and value.startswith(_SHA256_PREFIX) + and all(item in "0123456789abcdef" for item in value[len(_SHA256_PREFIX) :]) + ) + + +def _valid_failure_code(value: object) -> bool: + return ( + type(value) is str + and 1 <= len(value) <= 64 + and value[0].islower() + and value[0].isascii() + and all( + item.isascii() and (item.islower() or item.isdigit() or item == "_") for item in value + ) + ) + + +__all__ = ( + "MODEL_PROXY_MAXIMUM_FRAME_BYTES", + "MODEL_PROXY_PROTOCOL", + "ModelProxyHandler", + "ModelProxyReply", + "ModelProxyRequest", + "ModelProxyResolvedReceipt", +) diff --git a/backend/app/interfaces/external_checker_execution.py b/backend/app/interfaces/external_checker_execution.py index a627038d2..ece0a9674 100644 --- a/backend/app/interfaces/external_checker_execution.py +++ b/backend/app/interfaces/external_checker_execution.py @@ -2,7 +2,7 @@ from __future__ import annotations -from dataclasses import dataclass +from dataclasses import dataclass, field import re from typing import Protocol @@ -37,6 +37,24 @@ def __post_init__(self) -> None: raise ValueError("external checker material grant is invalid") +@dataclass(frozen=True, slots=True) +class ExternalCheckerModelGrant: + """Opaque invocation relay selector, never sufficient authority to spend.""" + + grant_id: str = field(repr=False) + binding_digest: Sha256 + + def __post_init__(self) -> None: + """Keep the private relay identity closed and bound to one request.""" + if ( + type(self.grant_id) is not str + or re.fullmatch(r"model_[0-9a-f]{32}", self.grant_id) is None + or type(self.binding_digest) is not str + or re.fullmatch(r"sha256:[0-9a-f]{64}", self.binding_digest) is None + ): + raise ValueError("external checker model grant is invalid") + + @dataclass(frozen=True, slots=True) class ExternalCheckerIsolationReceipt: """Observed service-owned image and sandbox identity for one execution.""" @@ -125,6 +143,8 @@ async def execute( self, request: ExternalCheckerExecutionRequest, grant: ExternalCheckerMaterialGrant, + *, + model_grant: ExternalCheckerModelGrant | None = None, ) -> ExternalCheckerExecutionResponse: """Return a request-bound result or a sanitized transport failure.""" @@ -137,5 +157,6 @@ async def health(self) -> ExternalCheckerServiceHealth: "ExternalCheckerExecutionResponse", "ExternalCheckerIsolationReceipt", "ExternalCheckerMaterialGrant", + "ExternalCheckerModelGrant", "ExternalCheckerServiceHealth", ) diff --git a/backend/app/interfaces/project_agents.py b/backend/app/interfaces/project_agents.py index 9c2a229d7..1056f7871 100644 --- a/backend/app/interfaces/project_agents.py +++ b/backend/app/interfaces/project_agents.py @@ -336,7 +336,9 @@ class PlatformCoverageRef(BaseModel): "execution_capacity", "prepared_material", ] -DependencyProvisioning = Literal["current_runner", "checker_implementation", "runtime_gap"] +DependencyProvisioning = Literal[ + "current_runner", "checker_implementation", "model_proxy", "runtime_gap" +] InfrastructureFailureCode = Literal[ "capacity_exceeded", "deadline_exceeded", @@ -450,10 +452,24 @@ def validate_provisioning(self) -> CheckRuntimeDependency: "task_context": ("runtime_gap", "material_unavailable"), "guide_context": ("runtime_gap", "material_unavailable"), "prepared_material": ("runtime_gap", "material_unavailable"), - "model_service": ("runtime_gap", "implementation_unavailable"), + # runtime_gap remains readable for immutable retained compilations. New + # generation admission below requires the precise model_proxy owner. + "model_service": ( + {"model_proxy", "runtime_gap"}, + "implementation_unavailable", + ), "execution_capacity": ("runtime_gap", "capacity_exceeded"), }[self.kind] - if (self.provisioning, self.unavailable_code) != expected: + expected_provisioning, expected_code = expected + if ( + self.provisioning + not in ( + expected_provisioning + if isinstance(expected_provisioning, set) + else {expected_provisioning} + ) + or self.unavailable_code != expected_code + ): raise ValueError("check dependency availability is invalid") return self @@ -947,8 +963,11 @@ def require_complete_generated_specifications( if specification is None: raise ValueError("check requirement lacks a check specification") if expected_stage is CompilationStage.PRE_SUBMIT: - if specification.evaluation_method == "model_evaluation": - raise ValueError("model evaluation requires post-submit background work") + if specification.evaluation_method == "model_evaluation" or any( + dependency.kind == "model_service" + for dependency in specification.runtime_dependencies + ): + raise ValueError("model service requires post-submit background work") if ( specification.execution_mode != "bounded_pre_submit" or specification.maximum_duration_seconds > 60 @@ -961,6 +980,12 @@ def require_complete_generated_specifications( for dependency in specification.runtime_dependencies ): raise ValueError("supported check depends on unavailable runtime support") + if any( + dependency.kind == "model_service" + and dependency.provisioning != "model_proxy" + for dependency in specification.runtime_dependencies + ): + raise ValueError("model service requires model proxy provisioning") definitions = { (definition.phase, definition.capability_id, definition.capability_version): definition diff --git a/backend/app/interfaces/project_guide_runtime.py b/backend/app/interfaces/project_guide_runtime.py index b1db8672d..c213451f7 100644 --- a/backend/app/interfaces/project_guide_runtime.py +++ b/backend/app/interfaces/project_guide_runtime.py @@ -7,6 +7,7 @@ import re from collections.abc import Mapping from typing import TypeAlias +from uuid import UUID from pydantic import BaseModel, ConfigDict, Field, StrictInt, model_validator @@ -87,6 +88,11 @@ class ProjectGuideRuntimeConfiguration(BaseModel): model_provider: str = Field(pattern=r"^[a-z][a-z0-9_.-]{0,63}$") model: str = Field(min_length=1, max_length=200, pattern=r"^[a-zA-Z0-9][a-zA-Z0-9._:/-]*$") model_api: str = Field(pattern=r"^[a-z][a-z0-9_]{0,63}$") + model_profile_id: UUID | None = None + model_profile_version: StrictInt | None = Field(default=None, ge=1) + model_profile_digest: str | None = Field( + default=None, pattern=r"^sha256:[0-9a-f]{64}$" + ) adapter_profile: str = Field(min_length=2, max_length=MAXIMUM_ADAPTER_PROFILE_BYTES) instruction_id: str = Field( default="project_guide_compilation", pattern=r"^[a-z][a-z0-9_]{0,63}$" @@ -115,7 +121,20 @@ def validate_snapshot(self) -> ProjectGuideRuntimeConfiguration: raise ValueError("project guide instruction hash mismatch") if self.maximum_document_bytes > self.maximum_total_document_bytes: raise ValueError("document byte limit exceeds the run total") - require_project_guide_inference_identity(self.model_provider, self.model_api, self.model) + uses_inference = require_project_guide_inference_identity( + self.model_provider, self.model_api, self.model + ) + selector = ( + self.model_profile_id, + self.model_profile_version, + self.model_profile_digest, + ) + if any(value is not None for value in selector) and not all( + value is not None for value in selector + ): + raise ValueError("guide runtime model profile selector is incomplete") + if not uses_inference and any(value is not None for value in selector): + raise ValueError("provider-free guide runtime cannot select a model profile") try: profile = json.loads(self.adapter_profile) except (TypeError, ValueError): diff --git a/backend/app/interfaces/setup_model_proxy.py b/backend/app/interfaces/setup_model_proxy.py new file mode 100644 index 000000000..3ce772a4d --- /dev/null +++ b/backend/app/interfaces/setup_model_proxy.py @@ -0,0 +1,351 @@ +"""Closed private protocol for setup-agent provider dispatches.""" + +from __future__ import annotations + +import hashlib +import json +from collections.abc import Awaitable, Callable, Mapping +from dataclasses import asdict, dataclass +from typing import Literal, Protocol +from uuid import UUID + +SETUP_MODEL_PROXY_PROTOCOL = "workstream.setup_model_proxy.v1" +SETUP_MODEL_PROXY_MAXIMUM_FRAME_BYTES = 1024 * 1024 +SetupModelPhase = Literal["draft", "review", "correction"] + + +def canonical_provider_json(value: Mapping[str, object]) -> str: + """Encode one provider request/response without duplicate or non-finite values.""" + if not isinstance(value, Mapping): + raise TypeError("setup model provider JSON is invalid") + encoded = json.dumps( + dict(value), + sort_keys=True, + ensure_ascii=False, + separators=(",", ":"), + allow_nan=False, + ) + if not encoded or len(encoded.encode("utf-8")) > SETUP_MODEL_PROXY_MAXIMUM_FRAME_BYTES: + raise ValueError("setup model provider JSON is invalid") + return encoded + + +@dataclass(frozen=True, slots=True) +class SetupModelProxySubject: + """Exact setup attempt and locked model profile bound by trusted composition.""" + + compilation_attempt_id: UUID + project_id: UUID + guide_id: UUID + source_snapshot_id: UUID + setup_run_id: UUID + setup_generation: int + runtime_configuration_hash: str + profile_id: UUID + profile_version: int + profile_digest: str + provider: str + api: str + model: str + + def __post_init__(self) -> None: + """Reject incomplete, substituted or non-canonical setup lineage.""" + if ( + any( + type(value) is not UUID + for value in ( + self.compilation_attempt_id, + self.project_id, + self.guide_id, + self.source_snapshot_id, + self.setup_run_id, + self.profile_id, + ) + ) + or type(self.setup_generation) is not int + or self.setup_generation < 1 + or type(self.profile_version) is not int + or self.profile_version < 1 + or not _valid_sha256(self.runtime_configuration_hash) + or not _valid_sha256(self.profile_digest) + or any(not _valid_identifier(value) for value in (self.provider, self.api)) + or not _valid_model(self.model) + ): + raise ValueError("setup model proxy subject is invalid") + + +@dataclass(frozen=True, slots=True, repr=False) +class SetupModelProxyRequest: + """One actual provider dispatch, with no caller-controlled endpoint or credential.""" + + subject: SetupModelProxySubject + phase: SetupModelPhase + call_index: int + provider_request_json: str + + def __post_init__(self) -> None: + """Require a canonical provider request and bounded stable call index.""" + if ( + type(self.subject) is not SetupModelProxySubject + or self.phase not in {"draft", "review", "correction"} + or type(self.call_index) is not int + or not 0 <= self.call_index <= 99 + or type(self.provider_request_json) is not str + ): + raise ValueError("setup model proxy request is invalid") + try: + value = json.loads(self.provider_request_json) + except (TypeError, ValueError, UnicodeError): + raise ValueError("setup model proxy request is invalid") from None + if ( + not isinstance(value, dict) + or canonical_provider_json(value) != self.provider_request_json + ): + raise ValueError("setup model proxy request is invalid") + + @property + def request_sha256(self) -> str: + """Hash exact provider bytes independently of transport framing.""" + return "sha256:" + hashlib.sha256(self.provider_request_json.encode("utf-8")).hexdigest() + + def __repr__(self) -> str: + """Hide prompt and provider payload content from diagnostic output.""" + return "SetupModelProxyRequest()" + + def wire_value(self) -> dict[str, object]: + """Return the exact closed private wire root.""" + return { + "protocol_version": SETUP_MODEL_PROXY_PROTOCOL, + "operation": "dispatch", + **{ + key: str(value) if isinstance(value, UUID) else value + for key, value in asdict(self.subject).items() + }, + "phase": self.phase, + "call_index": self.call_index, + "provider_request_json": self.provider_request_json, + } + + +@dataclass(frozen=True, slots=True, repr=False) +class SetupModelProxyReply: + """Exact provider HTTP result or one closed infrastructure failure.""" + + outcome: Literal["completed", "unavailable"] + call_index: int + status_code: int | None + response_json: str | None + response_headers: tuple[tuple[str, str], ...] + failure_code: str | None + session_exhausted: bool + + def __post_init__(self) -> None: + """Validate completed or unavailable wire state and its frame bound.""" + completed = ( + self.outcome == "completed" + and type(self.status_code) is int + and 100 <= self.status_code <= 599 + and type(self.response_json) is str + and self.failure_code is None + ) + unavailable = ( + self.outcome == "unavailable" + and self.status_code is None + and self.response_json is None + and _valid_identifier(self.failure_code) + ) + if ( + type(self.call_index) is not int + or not 0 <= self.call_index <= 99 + or type(self.session_exhausted) is not bool + or type(self.response_headers) is not tuple + or not _valid_headers(self.response_headers) + or (not completed and not unavailable) + ): + raise ValueError("setup model proxy reply is invalid") + if completed: + try: + value = json.loads(self.response_json) + except (TypeError, ValueError, UnicodeError): + raise ValueError("setup model proxy reply is invalid") from None + if not isinstance(value, dict) or canonical_provider_json(value) != self.response_json: + raise ValueError("setup model proxy reply is invalid") + encoded = json.dumps( + self.wire_value(), + ensure_ascii=False, + allow_nan=False, + separators=(",", ":"), + ).encode("utf-8") + if not encoded or len(encoded) > SETUP_MODEL_PROXY_MAXIMUM_FRAME_BYTES: + raise ValueError("setup model proxy reply exceeds transport bound") + + def __repr__(self) -> str: + """Expose only the outcome while redacting retained provider content.""" + return f"SetupModelProxyReply(outcome={self.outcome!r}, )" + + def wire_value(self) -> dict[str, object]: + """Return the closed response root used by private framing.""" + return { + "protocol_version": SETUP_MODEL_PROXY_PROTOCOL, + "operation": "dispatch", + "outcome": self.outcome, + "call_index": self.call_index, + "status_code": self.status_code, + "response_json": self.response_json, + "response_headers": dict(self.response_headers), + "failure_code": self.failure_code, + "session_exhausted": self.session_exhausted, + } + + +class SetupModelDispatchPort(Protocol): + """Attempt-bound capability used at each native SDK HTTP boundary.""" + + @property + def maximum_output_tokens(self) -> int: + """Return the immutable completion-token ceiling.""" + ... + + @property + def reasoning_effort(self) -> str: + """Return the immutable reasoning setting.""" + ... + + @property + def text_verbosity(self) -> str: + """Return the immutable text-verbosity setting.""" + ... + + async def dispatch( + self, phase: SetupModelPhase, provider_request: Mapping[str, object] + ) -> SetupModelProxyReply: + """Dispatch one phase call through the trusted metered boundary.""" + ... + + +SetupModelProxyHandler = Callable[[SetupModelProxyRequest], Awaitable[SetupModelProxyReply]] + + +def setup_model_proxy_request_from_wire(value: object) -> SetupModelProxyRequest: + """Decode one already duplicate-checked JSON root through the closed contract.""" + expected = { + "protocol_version", + "operation", + "compilation_attempt_id", + "project_id", + "guide_id", + "source_snapshot_id", + "setup_run_id", + "setup_generation", + "runtime_configuration_hash", + "profile_id", + "profile_version", + "profile_digest", + "provider", + "api", + "model", + "phase", + "call_index", + "provider_request_json", + } + if ( + type(value) is not dict + or set(value) != expected + or value["protocol_version"] != SETUP_MODEL_PROXY_PROTOCOL + or value["operation"] != "dispatch" + ): + raise ValueError("setup model proxy request root is invalid") + try: + subject = SetupModelProxySubject( + compilation_attempt_id=UUID(value["compilation_attempt_id"]), + project_id=UUID(value["project_id"]), + guide_id=UUID(value["guide_id"]), + source_snapshot_id=UUID(value["source_snapshot_id"]), + setup_run_id=UUID(value["setup_run_id"]), + setup_generation=value["setup_generation"], + runtime_configuration_hash=value["runtime_configuration_hash"], + profile_id=UUID(value["profile_id"]), + profile_version=value["profile_version"], + profile_digest=value["profile_digest"], + provider=value["provider"], + api=value["api"], + model=value["model"], + ) + except (TypeError, ValueError, AttributeError): + raise ValueError("setup model proxy request root is invalid") from None + return SetupModelProxyRequest( + subject=subject, + phase=value["phase"], + call_index=value["call_index"], + provider_request_json=value["provider_request_json"], + ) + + +def _valid_sha256(value: object) -> bool: + """Recognize one exact lowercase prefixed SHA-256 digest.""" + return ( + type(value) is str + and len(value) == 71 + and value.startswith("sha256:") + and all(character in "0123456789abcdef" for character in value[7:]) + ) + + +def _valid_identifier(value: object) -> bool: + """Recognize one bounded lowercase protocol identifier.""" + return ( + type(value) is str + and 1 <= len(value) <= 64 + and value[0].isascii() + and value[0].islower() + and all( + character.isascii() + and (character.islower() or character.isdigit() or character in {"_", ".", "-"}) + for character in value + ) + ) + + +def _valid_model(value: object) -> bool: + """Recognize one bounded direct provider model identifier.""" + return ( + type(value) is str + and 1 <= len(value) <= 200 + and value[0].isalnum() + and value[0].isascii() + and all( + character.isascii() and (character.isalnum() or character in "._:/-") + for character in value + ) + ) + + +def _valid_headers(value: tuple[tuple[str, str], ...]) -> bool: + """Accept only sorted bounded retry guidance headers.""" + allowed = {"retry-after", "x-ratelimit-reset-requests", "x-ratelimit-reset-tokens"} + names = [name for name, _ in value] + return ( + names == sorted(names) + and len(names) == len(set(names)) + and all( + name in allowed + and type(item) is str + and len(item.encode("ascii", errors="ignore")) <= 256 + and all(32 <= ord(character) <= 126 for character in item) + for name, item in value + ) + ) + + +__all__ = ( + "SETUP_MODEL_PROXY_MAXIMUM_FRAME_BYTES", + "SETUP_MODEL_PROXY_PROTOCOL", + "SetupModelDispatchPort", + "SetupModelPhase", + "SetupModelProxyHandler", + "SetupModelProxyReply", + "SetupModelProxyRequest", + "SetupModelProxySubject", + "canonical_provider_json", + "setup_model_proxy_request_from_wire", +) diff --git a/backend/app/modules/artifacts/api/checker_output_authority.py b/backend/app/modules/artifacts/api/checker_output_authority.py new file mode 100644 index 000000000..8ee9dd0c6 --- /dev/null +++ b/backend/app/modules/artifacts/api/checker_output_authority.py @@ -0,0 +1,76 @@ +"""Public ART authority facts for call-stable checker output custody.""" + +from dataclasses import dataclass +from typing import Protocol, final +from uuid import UUID + +from app.modules.checkers.api.output_custody import ( + GeneratedOutputReservation, + GeneratedOutputSelector, +) + + +@final +@dataclass(frozen=True, slots=True) +class CheckerOutputCommitmentAuthorityFacts: + """Server-computed commitment without source bytes or scratch identity.""" + + reservation: GeneratedOutputReservation + slot_key: str + sha256: str + byte_count: int + media_type: str + + +@final +@dataclass(frozen=True, slots=True) +class CheckerOutputStoredFacts: + """Exact storage facts selected by ART under the prepared call selector.""" + + put_attempt_id: UUID + checker_request_digest: str + sha256: str + byte_count: int + media_type: str + status: str + content_id: UUID | None + replica_id: UUID | None + verification_receipt_id: UUID | None + + +class CheckerOutputWriteAuthority(Protocol): + """Fresh write authority; no prepared handle survives storage I/O.""" + + async def preflight(self, selector: GeneratedOutputSelector) -> None: ... + + async def prepare(self, selector: GeneratedOutputSelector) -> None: ... + + async def consume(self, facts: CheckerOutputCommitmentAuthorityFacts) -> None: ... + + async def consume_existing( + self, reservation: GeneratedOutputReservation, facts: CheckerOutputStoredFacts + ) -> None: ... + + def discard(self) -> None: ... + + +class CheckerOutputBindingAuthority(Protocol): + """Fresh publication authority for independently verified call evidence.""" + + async def preflight(self, selector: GeneratedOutputSelector) -> None: ... + + async def prepare(self, selector: GeneratedOutputSelector) -> None: ... + + async def consume( + self, reservation: GeneratedOutputReservation, facts: CheckerOutputStoredFacts + ) -> None: ... + + def discard(self) -> None: ... + + +__all__ = ( + "CheckerOutputBindingAuthority", + "CheckerOutputCommitmentAuthorityFacts", + "CheckerOutputStoredFacts", + "CheckerOutputWriteAuthority", +) diff --git a/backend/app/modules/artifacts/checker_model_evidence.py b/backend/app/modules/artifacts/checker_model_evidence.py new file mode 100644 index 000000000..28e67ff14 --- /dev/null +++ b/backend/app/modules/artifacts/checker_model_evidence.py @@ -0,0 +1,152 @@ +"""Verified ART metadata consumed by the model-call ledger, without transcript bytes.""" + +from uuid import UUID + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.modules.artifacts.models import ( + ArtifactBinding, + ArtifactContent, + ArtifactPutAttempt, + ArtifactReplica, + ArtifactVerificationJob, + ArtifactVerificationReceipt, +) +from app.modules.model_usage.api import ( + ModelCallEvidence, + ModelCallUnavailable, + PreparedModelCallAttempt, + PreparedSetupModelUsageEffect, +) + + +class CheckerModelEvidence: + """Require immutable verified binding ancestry in the caller's transaction.""" + + def __init__(self, session: AsyncSession): + self._session = session + + async def require_request( + self, + attempt: PreparedModelCallAttempt, + evidence: ModelCallEvidence, + ) -> None: + """Verify the immutable request under the owner's exact prepared attempt.""" + if evidence.content_sha256 != attempt.request_sha256: + raise ModelCallUnavailable("model_call_evidence_unavailable") + await self._require(attempt.attempt_id, "model_request", evidence) + + async def require_response(self, attempt_id: UUID, evidence: ModelCallEvidence) -> None: + """Verify response custody for the effect already selected by CHECKERS.""" + await self._require(attempt_id, "model_response", evidence) + + async def _require( + self, + attempt_id: UUID, + role: str, + evidence: ModelCallEvidence, + *, + resource_type: str = "checker_model_call", + producer_request_type: str = "checker_output", + ) -> None: + """Match all identities and integrity states without acquiring another owner lock.""" + if not self._session.in_transaction() or self._session.in_nested_transaction(): + raise ModelCallUnavailable("model_call_evidence_unavailable") + evidence = ModelCallEvidence.model_validate(evidence) + binding = await self._session.scalar( + select(ArtifactBinding.id) + .join(ArtifactContent, ArtifactContent.id == ArtifactBinding.content_id) + .join(ArtifactPutAttempt, ArtifactPutAttempt.id == ArtifactBinding.put_attempt_id) + .join( + ArtifactVerificationReceipt, + ArtifactVerificationReceipt.id == ArtifactBinding.verification_receipt_id, + ) + .join( + ArtifactVerificationJob, + ArtifactVerificationJob.id == ArtifactVerificationReceipt.verification_job_id, + ) + .join(ArtifactReplica, ArtifactReplica.id == ArtifactVerificationJob.replica_id) + .where( + ArtifactBinding.id == str(evidence.binding_id), + ArtifactBinding.resource_type == resource_type, + ArtifactBinding.resource_id == str(attempt_id), + ArtifactBinding.logical_role == role, + ArtifactBinding.scope_version == 1, + ArtifactBinding.attribution_type == "service_identity", + ArtifactBinding.actor_id == "workstream.artifact.checker_output", + ArtifactBinding.project_id == ArtifactPutAttempt.project_id, + ArtifactBinding.content_id == str(evidence.content_id), + ArtifactBinding.put_attempt_id == str(evidence.put_attempt_id), + ArtifactBinding.verification_receipt_id == str(evidence.verification_receipt_id), + ArtifactBinding.supersedes_binding_id.is_(None), + ArtifactContent.sha256 == evidence.content_sha256, + ArtifactContent.byte_count == evidence.byte_count, + ArtifactContent.media_type == evidence.media_type, + ArtifactPutAttempt.model_call_attempt_id == str(attempt_id), + ArtifactPutAttempt.checker_run_id.is_(None), + ArtifactPutAttempt.producer_request_type == producer_request_type, + ArtifactPutAttempt.logical_role == role, + ArtifactPutAttempt.sha256 == evidence.content_sha256, + ArtifactPutAttempt.byte_count == evidence.byte_count, + ArtifactPutAttempt.media_type == evidence.media_type, + ArtifactPutAttempt.status == "object_confirmed", + ArtifactPutAttempt.checker_output_custody_sealed.is_(True), + ArtifactPutAttempt.replica_id == ArtifactReplica.id, + ArtifactPutAttempt.storage_namespace_id == ArtifactReplica.storage_namespace_id, + ArtifactPutAttempt.namespace_fingerprint == ArtifactReplica.namespace_fingerprint, + ArtifactPutAttempt.canonical_target == ArtifactReplica.provider_object_ref, + ArtifactVerificationJob.originating_put_attempt_id == ArtifactPutAttempt.id, + ArtifactVerificationJob.status == "verified", + ArtifactVerificationJob.terminal_result_code == "verified", + ArtifactVerificationJob.terminal_at.is_not(None), + ArtifactVerificationJob.checker_output_custody_sealed.is_(True), + ArtifactVerificationReceipt.outcome == "verified", + ArtifactVerificationReceipt.execution_generation + == ArtifactVerificationJob.execution_generation, + ArtifactVerificationReceipt.observed_sha256 == evidence.content_sha256, + ArtifactVerificationReceipt.observed_byte_count == evidence.byte_count, + ArtifactReplica.content_id == ArtifactContent.id, + ArtifactReplica.checker_output_custody_sealed.is_(True), + ArtifactReplica.verification_state == "verified", + ArtifactReplica.availability_state == "available", + ArtifactReplica.integrity_state == "valid", + ) + ) + if binding is None: + raise ModelCallUnavailable("model_call_evidence_unavailable") + + +class SetupModelEvidence(CheckerModelEvidence): + """Require sealed setup request/response bindings without PROJECTS callbacks.""" + + async def require_setup_request( + self, + prepared: PreparedSetupModelUsageEffect, + evidence: ModelCallEvidence, + ) -> None: + if evidence.content_sha256 != prepared.request_sha256: + raise ModelCallUnavailable("setup_model_evidence_unavailable") + await self._require_setup(prepared.effect_id, "model_request", evidence) + + async def require_setup_response( + self, effect_id: UUID, evidence: ModelCallEvidence + ) -> None: + await self._require_setup(effect_id, "model_response", evidence) + + async def _require_setup( + self, effect_id: UUID, role: str, evidence: ModelCallEvidence + ) -> None: + try: + await self._require( + effect_id, + role, + evidence, + resource_type="setup_model_call", + producer_request_type="setup_model_output", + ) + except ModelCallUnavailable as exc: + raise ModelCallUnavailable("setup_model_evidence_unavailable") from exc + + +__all__ = ("CheckerModelEvidence", "SetupModelEvidence") diff --git a/backend/app/modules/artifacts/checker_output_bindings.py b/backend/app/modules/artifacts/checker_output_bindings.py index 23285287b..3a02aed1f 100644 --- a/backend/app/modules/artifacts/checker_output_bindings.py +++ b/backend/app/modules/artifacts/checker_output_bindings.py @@ -1,67 +1,32 @@ """Verified checker-output bindings in the caller's final-result transaction.""" -from typing import Protocol from uuid import UUID from sqlalchemy import select, text from sqlalchemy.ext.asyncio import AsyncSession from app.core.identifiers import new_record_id +from app.modules.artifacts.api.checker_output_authority import ( + CheckerOutputBindingAuthority, +) from app.modules.checkers.api.output_custody import ( CheckerOutputBindingRequest, CheckerOutputBindingResult, - CheckerOutputReservation, CheckerOutputReservationPort, - CheckerOutputSelector, + GENERATED_OUTPUT_SELECTOR, + generated_output_subject, CheckerOutputUnavailable, ) -from app.modules.actors.api import ServiceIdentity -from app.modules.artifacts.checker_output_custody import ( - CheckerOutputStoredFacts, - select_checker_output, +from app.modules.model_usage.api import ( + SetupModelOutputBindingRequest, + SetupModelOutputBindingResult, + SetupModelOutputReservationPort, ) +from app.modules.actors.api import ServiceIdentity +from app.modules.artifacts.checker_output_custody import select_checker_output from app.modules.artifacts.models import ArtifactBinding -class CheckerOutputBindingAuthority(Protocol): - """Prepare authority before CHECKERS locks, consume exact ART custody afterward.""" - - async def preflight(self, selector: CheckerOutputSelector) -> None: - """Deny before any owner or artifact lookup.""" - - async def prepare(self, selector: CheckerOutputSelector) -> None: - """Stabilize exact service authority before feature row locks.""" - - async def consume( - self, reservation: CheckerOutputReservation, facts: CheckerOutputStoredFacts - ) -> None: - """Consume binding action authority in this caller-owned transaction.""" - - def discard(self) -> None: - """Release process-local authority without committing or rolling back.""" - - -class DenyCheckerOutputBindingAuthority: - """No production binding until exact AUTH and CHECKERS custody are implemented.""" - - async def preflight(self, selector: CheckerOutputSelector) -> None: - """Reject before protected reads.""" - raise CheckerOutputUnavailable("checker_output_binding_unavailable") - - async def prepare(self, selector: CheckerOutputSelector) -> None: - """Reject binding authority preparation.""" - raise CheckerOutputUnavailable("checker_output_binding_unavailable") - - async def consume( - self, reservation: CheckerOutputReservation, facts: CheckerOutputStoredFacts - ) -> None: - """Reject publication authority.""" - raise CheckerOutputUnavailable("checker_output_binding_unavailable") - - def discard(self) -> None: - """No prepared authority exists.""" - - class CheckerOutputBindingService: """Flush one immutable binding; never commit the caller's result transaction.""" @@ -69,7 +34,7 @@ def __init__( self, session: AsyncSession, *, - reservations: CheckerOutputReservationPort, + reservations: CheckerOutputReservationPort | SetupModelOutputReservationPort, authority: CheckerOutputBindingAuthority, namespace_fingerprint: str, ) -> None: @@ -79,25 +44,47 @@ def __init__( async def bind_checker_output( self, request: CheckerOutputBindingRequest ) -> CheckerOutputBindingResult: - """Fence current worker facts before acquiring ART binding/verification locks.""" + """Bind exact checker evidence through the shared publication operation.""" + if type(request) is not CheckerOutputBindingRequest: + raise TypeError("invalid checker output binding request") + return await self._bind(request) + + async def bind_setup_output( + self, request: SetupModelOutputBindingRequest + ) -> SetupModelOutputBindingResult: + if type(request) is not SetupModelOutputBindingRequest: + raise TypeError("invalid setup model output binding request") + result = await self._bind(request) + return SetupModelOutputBindingResult( + result.binding_id, + result.content_id, + result.put_attempt_id, + result.verification_receipt_id, + result.replayed, + ) + + async def _bind( + self, request: CheckerOutputBindingRequest | SetupModelOutputBindingRequest + ) -> CheckerOutputBindingResult: + """Fence owner facts before acquiring ART binding/verification locks.""" if ( - type(request) is not CheckerOutputBindingRequest - or not self._session.in_transaction() + not self._session.in_transaction() or self._session.in_nested_transaction() or type(request.put_attempt_id) is not UUID or type(request.verification_receipt_id) is not UUID ): raise CheckerOutputUnavailable("checker_output_binding_unavailable") - selector = CheckerOutputSelector.model_validate(request.selector) + selector = GENERATED_OUTPUT_SELECTOR.validate_python(request.selector) try: await self._authority.preflight(selector) await self._authority.prepare(selector) reservation = await self._reservations.resolve(selector) reservation.select(selector) + owner = generated_output_subject(reservation) await self._session.execute( text("select pg_advisory_xact_lock(hashtextextended(:key, 0))"), { - "key": f"checker-output:{selector.evaluation.project_id}:{selector.checker_run_id}:{selector.slot_key}", + "key": f"generated-output:{owner.project_id}:{owner.resource_id}:{owner.logical_role}", }, ) facts = await select_checker_output( @@ -119,10 +106,10 @@ async def bind_checker_output( binding = await self._session.scalar( select(ArtifactBinding) .where( - ArtifactBinding.project_id == str(selector.evaluation.project_id), - ArtifactBinding.resource_type == "checker_run", - ArtifactBinding.resource_id == str(selector.checker_run_id), - ArtifactBinding.logical_role == selector.slot_key, + ArtifactBinding.project_id == str(owner.project_id), + ArtifactBinding.resource_type == owner.resource_type, + ArtifactBinding.resource_id == str(owner.resource_id), + ArtifactBinding.logical_role == owner.logical_role, ArtifactBinding.scope_version == 1, ) .with_for_update() @@ -148,10 +135,10 @@ async def bind_checker_output( binding = ArtifactBinding( id=str(new_record_id()), content_id=str(facts.content_id), - project_id=str(selector.evaluation.project_id), - resource_type="checker_run", - resource_id=str(selector.checker_run_id), - logical_role=selector.slot_key, + project_id=str(owner.project_id), + resource_type=owner.resource_type, + resource_id=str(owner.resource_id), + logical_role=owner.logical_role, scope_version=1, actor_id=ServiceIdentity.ARTIFACT_CHECKER_OUTPUT.value, attribution_type="service_identity", diff --git a/backend/app/modules/artifacts/checker_output_custody.py b/backend/app/modules/artifacts/checker_output_custody.py index beff8fdb2..568c8b0e3 100644 --- a/backend/app/modules/artifacts/checker_output_custody.py +++ b/backend/app/modules/artifacts/checker_output_custody.py @@ -1,12 +1,12 @@ """Exact ART-owned output selection shared by storage recovery and binding.""" -from dataclasses import dataclass from uuid import UUID from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.core.hashing import canonical_json_hash +from app.modules.artifacts.api.checker_output_authority import CheckerOutputStoredFacts from app.modules.artifacts.models import ( ArtifactContent, ArtifactPutAttempt, @@ -15,38 +15,19 @@ ArtifactVerificationJob, ArtifactVerificationReceipt, ) -from app.modules.artifacts.schemas import ( - checker_output_operation_identity, - checker_output_request_digest_facts, -) from app.modules.checkers.api.output_custody import ( - CheckerOutputReservation, - CheckerOutputSelector, + GeneratedOutputReservation, + GeneratedOutputSelector, + generated_output_subject, CheckerOutputUnavailable, ) -@dataclass(frozen=True, slots=True) -class CheckerOutputStoredFacts: - """Exact storage facts consumed by fresh output action authority.""" - - put_attempt_id: UUID - checker_request_digest: str - sha256: str - byte_count: int - media_type: str - status: str - content_id: UUID | None - replica_id: UUID | None - verification_receipt_id: UUID | None - - - async def select_checker_output( session: AsyncSession, *, - selector: CheckerOutputSelector, - reservation: CheckerOutputReservation, + selector: GeneratedOutputSelector, + reservation: GeneratedOutputReservation, namespace_fingerprint: str, put_attempt_id: UUID | None = None, verification_receipt_id: UUID | None = None, @@ -54,24 +35,17 @@ async def select_checker_output( ) -> CheckerOutputStoredFacts | None: """Match immutable request and verified ancestry; never read foreign owners.""" slot = reservation.select(selector) - expected_digest = canonical_json_hash( - checker_output_request_digest_facts( - reservation=reservation, - slot=slot, - ) - ) + owner = generated_output_subject(reservation) + expected_digest = canonical_json_hash(owner.request_digest_facts) attempt = await session.scalar( select(ArtifactPutAttempt) .where( - ArtifactPutAttempt.operation_identity == checker_output_operation_identity( - checker_run_id=selector.checker_run_id, slot_key=selector.slot_key - ), + ArtifactPutAttempt.operation_identity == owner.operation_identity, ) .execution_options(populate_existing=True) ) if attempt is None: return None - evaluation = selector.evaluation if ( ( attempt.producer_request_type, @@ -79,25 +53,27 @@ async def select_checker_output( attempt.task_id, attempt.submission_id, attempt.submission_version, - attempt.checker_run_id, + attempt.model_call_attempt_id, attempt.logical_role, attempt.checker_request_digest, attempt.media_type, attempt.namespace_fingerprint, ) != ( - "checker_output", - str(evaluation.project_id), - str(evaluation.task_id), - str(evaluation.submission_id), - evaluation.submission_version, - str(selector.checker_run_id), + owner.producer_request_type, + str(owner.project_id), + str(owner.task_id) if owner.task_id else None, + str(owner.submission_id) if owner.submission_id else None, + owner.submission_version, + str(owner.resource_id), slot.key, expected_digest, slot.media_type, namespace_fingerprint, ) or attempt.byte_count > slot.maximum_bytes + or attempt.checker_run_id is not None + or attempt.sha256 != selector.content_sha256 or (put_attempt_id is not None and attempt.id != str(put_attempt_id)) ): raise CheckerOutputUnavailable("checker_output_identity_mismatch") diff --git a/backend/app/modules/artifacts/checker_outputs.py b/backend/app/modules/artifacts/checker_outputs.py index c2a9ce0d7..10721fbfa 100644 --- a/backend/app/modules/artifacts/checker_outputs.py +++ b/backend/app/modules/artifacts/checker_outputs.py @@ -1,27 +1,19 @@ """Bounded checker bytes through the existing durable ART storage workflow.""" +import hashlib from collections.abc import Callable -from typing import Protocol from uuid import UUID from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker from app.core.config import Settings -from app.modules.checkers.api.output_custody import ( - CheckerOutputArtifactRequest, - CheckerOutputArtifactResult, - CheckerOutputReservation, - CheckerOutputReservationPort, - CheckerOutputSelector, - CheckerOutputUnavailable, +from app.interfaces.artifacts import ArtifactByteRange, ArtifactIntegrityError, ArtifactStore +from app.modules.artifacts.api.checker_output_authority import ( + CheckerOutputWriteAuthority, ) -from app.interfaces.artifacts import ArtifactStore -from app.modules.artifacts.checker_output_custody import ( - CheckerOutputStoredFacts, - select_checker_output, -) -from app.modules.artifacts.models import ArtifactVerificationJob +from app.modules.artifacts.checker_output_custody import select_checker_output +from app.modules.artifacts.models import ArtifactReplica, ArtifactVerificationJob from app.modules.artifacts.preparation import ArtifactPreparationService from app.modules.artifacts.schemas import ( ArtifactInternalAuthority, @@ -29,55 +21,25 @@ ) from app.modules.artifacts.service import ( ArtifactAdmissionService, - ArtifactStorageOrchestrator, ArtifactStorageNamespaceSpec, + ArtifactStorageOrchestrator, +) +from app.modules.checkers.api.output_custody import ( + GENERATED_OUTPUT_SELECTOR, + CheckerOutputArtifactRequest, + CheckerOutputArtifactResult, + CheckerOutputReservationPort, + CheckerOutputSelector, + CheckerOutputUnavailable, + GeneratedOutputSelector, +) +from app.modules.model_usage.api import ( + ModelCallEvidence, + SetupModelOutputArtifactRequest, + SetupModelOutputArtifactResult, + SetupModelOutputReservationPort, + SetupModelOutputSelector, ) - - -class CheckerOutputWriteAuthority(Protocol): - """Fresh write authority; no prepared handle survives a storage operation.""" - - async def preflight(self, selector: CheckerOutputSelector) -> None: - """Deny before owner facts, source iteration or scratch access.""" - - async def prepare(self, selector: CheckerOutputSelector) -> None: - """Stabilize service authority before CHECKERS and ART transaction locks.""" - - async def consume(self, request: CheckerOutputArtifactAdmissionRequest) -> None: - """Bind the exact active worker, slot and byte commitment before admission.""" - - async def consume_existing( - self, reservation: CheckerOutputReservation, facts: CheckerOutputStoredFacts - ) -> None: - """Authorize exact stored output recovery with the current worker lease.""" - - def discard(self) -> None: - """Dispose transaction-local authority after each phase.""" - - -class DenyCheckerOutputWriteAuthority: - """Keep production output storage unavailable until ARCH-04D activation.""" - - async def preflight(self, selector: CheckerOutputSelector) -> None: - """Reject before any protected access.""" - raise CheckerOutputUnavailable("checker_output_write_unavailable") - - async def prepare(self, selector: CheckerOutputSelector) -> None: - """Reject transaction authority preparation.""" - raise CheckerOutputUnavailable("checker_output_write_unavailable") - - async def consume(self, request: CheckerOutputArtifactAdmissionRequest) -> None: - """Reject durable output admission.""" - raise CheckerOutputUnavailable("checker_output_write_unavailable") - - async def consume_existing( - self, reservation: CheckerOutputReservation, facts: CheckerOutputStoredFacts - ) -> None: - """Reject recovery of protected output facts.""" - raise CheckerOutputUnavailable("checker_output_write_unavailable") - - def discard(self) -> None: - """No authority was retained.""" class CheckerArtifactOutputService: @@ -87,9 +49,12 @@ def __init__( self, *, sessions: async_sessionmaker[AsyncSession], - reservations: Callable[[AsyncSession], CheckerOutputReservationPort], + reservations: Callable[ + [AsyncSession], CheckerOutputReservationPort | SetupModelOutputReservationPort + ], authority: Callable[[AsyncSession], CheckerOutputWriteAuthority], internal_authority: Callable[[AsyncSession], ArtifactInternalAuthority], + verification_authority: Callable[[AsyncSession], ArtifactInternalAuthority], preparation: ArtifactPreparationService, store: ArtifactStore, namespace: ArtifactStorageNamespaceSpec, @@ -97,13 +62,37 @@ def __init__( ) -> None: self._sessions, self._reservations, self._authority = sessions, reservations, authority self._internal_authority, self._preparation = internal_authority, preparation + self._verification_authority = verification_authority self._store, self._namespace, self._settings = store, namespace, settings async def store(self, request: CheckerOutputArtifactRequest) -> CheckerOutputArtifactResult: """Commit checked output bytes before I/O, never a CHECKERS completion.""" if type(request) is not CheckerOutputArtifactRequest: raise TypeError("invalid checker output request") - selector = CheckerOutputSelector.model_validate(request.selector) + return await self._store_output(request) + + async def store_setup_output( + self, request: SetupModelOutputArtifactRequest + ) -> SetupModelOutputArtifactResult: + """Store setup request/response bytes through the same verified path.""" + if type(request) is not SetupModelOutputArtifactRequest: + raise TypeError("invalid setup model output request") + result = await self._store_output(request) + return SetupModelOutputArtifactResult( + result.put_attempt_id, + result.status, + result.content_id, + result.verification_receipt_id, + result.content_sha256, + result.byte_count, + result.replayed, + ) + + async def _store_output( + self, + request: CheckerOutputArtifactRequest | SetupModelOutputArtifactRequest, + ) -> CheckerOutputArtifactResult: + selector = GENERATED_OUTPUT_SELECTOR.validate_python(request.selector) prepared = None async with self._sessions() as session: authority = self._authority(session) @@ -158,7 +147,29 @@ async def store(self, request: CheckerOutputArtifactRequest) -> CheckerOutputArt async def recover(self, selector: CheckerOutputSelector) -> CheckerOutputArtifactResult | None: """Recover a lost response by logical identity without regenerating bytes.""" - selector = CheckerOutputSelector.model_validate(selector) + return await self._recover(CheckerOutputSelector.model_validate(selector)) + + async def recover_setup_output( + self, selector: SetupModelOutputSelector + ) -> SetupModelOutputArtifactResult | None: + result = await self._recover(SetupModelOutputSelector.model_validate(selector)) + return ( + None + if result is None + else SetupModelOutputArtifactResult( + result.put_attempt_id, + result.status, + result.content_id, + result.verification_receipt_id, + result.content_sha256, + result.byte_count, + result.replayed, + ) + ) + + async def _recover( + self, selector: GeneratedOutputSelector + ) -> CheckerOutputArtifactResult | None: async with self._sessions() as session: authority = self._authority(session) try: @@ -186,6 +197,77 @@ async def recover(self, selector: CheckerOutputSelector) -> CheckerOutputArtifac finally: authority.discard() + async def read_response( + self, selector: CheckerOutputSelector, evidence: ModelCallEvidence, + ) -> bytes: + """Read only exact private response evidence, bounded and independently hashed.""" + selector = CheckerOutputSelector.model_validate(selector) + evidence = ModelCallEvidence.model_validate(evidence) + if selector.slot_key != "model_response" or selector.content_sha256 != evidence.content_sha256: + raise CheckerOutputUnavailable("checker_output_response_unavailable") + return await self._read(selector, evidence) + + async def read_setup_response( + self, selector: SetupModelOutputSelector, evidence: ModelCallEvidence + ) -> bytes: + selector = SetupModelOutputSelector.model_validate(selector) + evidence = ModelCallEvidence.model_validate(evidence) + if selector.role != "model_response" or selector.content_sha256 != evidence.content_sha256: + raise CheckerOutputUnavailable("checker_output_response_unavailable") + return await self._read(selector, evidence) + + async def _read( + self, selector: GeneratedOutputSelector, evidence: ModelCallEvidence + ) -> bytes: + async with self._sessions() as session: + authority = self._authority(session) + try: + await authority.preflight(selector) + async with session.begin(): + await authority.prepare(selector) + reservation = await self._reservations(session).resolve(selector) + facts = await select_checker_output( + session, selector=selector, reservation=reservation, + namespace_fingerprint=self._namespace.namespace_fingerprint, + put_attempt_id=evidence.put_attempt_id, + verification_receipt_id=evidence.verification_receipt_id, + ) + if facts is None or facts.status != "verified" or ( + facts.content_id, facts.sha256, facts.byte_count, facts.media_type, + ) != ( + evidence.content_id, evidence.content_sha256, + evidence.byte_count, evidence.media_type, + ): + raise CheckerOutputUnavailable("checker_output_response_unavailable") + await authority.consume_existing(reservation, facts) + provider_ref = await session.scalar(select(ArtifactReplica.provider_object_ref).where( + ArtifactReplica.id == str(facts.replica_id), + ArtifactReplica.namespace_fingerprint == self._namespace.namespace_fingerprint, + )) + if provider_ref is None: + raise CheckerOutputUnavailable("checker_output_response_unavailable") + authority.discard() + data = bytearray() + async for chunk in self._store.open( + provider_ref, byte_range=ArtifactByteRange(length=evidence.byte_count + 1), + ): + data.extend(chunk) + if len(data) > evidence.byte_count: + raise ArtifactIntegrityError("model response evidence length mismatch") + if len(data) != evidence.byte_count or ( + "sha256:" + hashlib.sha256(data).hexdigest() != evidence.content_sha256 + ): + raise ArtifactIntegrityError("model response evidence digest mismatch") + # Revocation and owner substitution during provider I/O still deny release. + current = await self._result(session, authority, selector, replayed=True) + if (current.content_id, current.verification_receipt_id) != ( + evidence.content_id, evidence.verification_receipt_id, + ): + raise CheckerOutputUnavailable("checker_output_response_unavailable") + return bytes(data) + finally: + authority.discard() + def _storage(self, session: AsyncSession) -> ArtifactStorageOrchestrator: """Use the shared put, observation and verification implementation.""" return ArtifactStorageOrchestrator( @@ -218,13 +300,17 @@ async def _verify( .limit(1) ) if job_id is not None: - await storage.verify_object(UUID(job_id)) + verifier = ArtifactStorageOrchestrator( + session, self._store, self._namespace, self._settings, + self._verification_authority(session), + ) + await verifier.verify_object(UUID(job_id)) async def _result( self, session: AsyncSession, authority: CheckerOutputWriteAuthority, - selector: CheckerOutputSelector, + selector: GeneratedOutputSelector, *, replayed: bool, ) -> CheckerOutputArtifactResult: @@ -247,5 +333,7 @@ async def _result( facts.status, facts.content_id, facts.verification_receipt_id, + facts.sha256, + facts.byte_count, replayed, ) diff --git a/backend/app/modules/artifacts/models.py b/backend/app/modules/artifacts/models.py index b625c355c..a37aa02e7 100644 --- a/backend/app/modules/artifacts/models.py +++ b/backend/app/modules/artifacts/models.py @@ -424,9 +424,9 @@ class ArtifactBinding(Base): name="scope_version_predecessor", ), CheckConstraint( - "(resource_type = 'checker_run' and scope_version = 1 " + "(resource_type in ('checker_run','checker_model_call','setup_model_call') and scope_version = 1 " "and put_attempt_id is not null and verification_receipt_id is not null) or " - "(resource_type <> 'checker_run' and put_attempt_id is null " + "(resource_type not in ('checker_run','checker_model_call','setup_model_call') and put_attempt_id is null " "and verification_receipt_id is null)", name="checker_output_lineage", ), @@ -1324,7 +1324,8 @@ class ArtifactPutAttempt(Base): ondelete="RESTRICT", name="fk_artifact_put_attempt_import_source_custody", ), CheckConstraint( - "producer_request_type in ('guide', 'checker_output', 'submission_bundle', 'task_import_source')", + "producer_request_type in ('guide', 'checker_output', 'setup_model_output', " + "'submission_bundle', 'task_import_source')", name="producer_request_type", ), CheckConstraint( @@ -1335,7 +1336,7 @@ class ArtifactPutAttempt(Base): "((producer_request_type in ('guide', 'task_import_source') " "and producer_type = 'actor_profile' and " + ACTOR_UUID7_CHECK.format(column="producer_ref") - + ") or (producer_request_type = 'checker_output' " + + ") or (producer_request_type in ('checker_output', 'setup_model_output') " "and producer_type = 'service_identity' " "and producer_ref = 'workstream.artifact.checker_output') or " "(producer_request_type = 'submission_bundle' " @@ -1359,9 +1360,11 @@ class ArtifactPutAttempt(Base): name="request_digest_shape", ), CheckConstraint( - "(producer_request_type = 'checker_output' and checker_request_digest is not null " + "(producer_request_type in ('checker_output','setup_model_output') " + "and checker_request_digest is not null " "and checker_request_digest ~ '^sha256:[0-9a-f]{64}$') or " - "(producer_request_type <> 'checker_output' and checker_request_digest is null)", + "(producer_request_type not in ('checker_output','setup_model_output') " + "and checker_request_digest is null)", name="checker_request_digest", ), CheckConstraint( @@ -1405,19 +1408,25 @@ class ArtifactPutAttempt(Base): ), CheckConstraint( "(producer_request_type = 'guide' and task_import_source_id is null and guide_source_item_id is not null " - "and checker_run_id is null and task_id is null and submission_id is null " + "and checker_run_id is null and model_call_attempt_id is null and task_id is null and submission_id is null " "and submission_version is null " "and logical_role is null) or " "(producer_request_type = 'checker_output' and task_import_source_id is null and guide_source_item_id is null " - "and checker_run_id is not null and task_id is not null " + "and ((checker_run_id is not null and model_call_attempt_id is null) or " + "(checker_run_id is null and model_call_attempt_id is not null " + "and logical_role in ('model_request','model_response'))) and task_id is not null " "and submission_id is not null and submission_version is not null " "and octet_length(logical_role) between 1 and 100) or " - "(producer_request_type = 'submission_bundle' and task_import_source_id is null " + "(producer_request_type = 'setup_model_output' and task_import_source_id is null " "and guide_source_item_id is null and checker_run_id is null " + "and model_call_attempt_id is not null and task_id is null and submission_id is null " + "and submission_version is null and logical_role in ('model_request','model_response')) or " + "(producer_request_type = 'submission_bundle' and task_import_source_id is null " + "and guide_source_item_id is null and checker_run_id is null and model_call_attempt_id is null " "and task_id is not null and submission_id is null " "and submission_version is null and logical_role is null) or " "(producer_request_type = 'task_import_source' and task_import_source_id is not null " - "and guide_source_item_id is null and checker_run_id is null and task_id is null " + "and guide_source_item_id is null and checker_run_id is null and model_call_attempt_id is null and task_id is null " "and submission_id is null and submission_version is null " "and logical_role is not distinct from 'task_import_source')", name="producer_reference", @@ -1441,6 +1450,9 @@ class ArtifactPutAttempt(Base): checker_run_id: Mapped[str | None] = mapped_column( ForeignKey("checker_runs.id", ondelete="RESTRICT"), index=True ) + model_call_attempt_id: Mapped[str | None] = mapped_column( + Uuid(as_uuid=False), ForeignKey("model_usage_effects.id", ondelete="RESTRICT"), index=True + ) submission_id: Mapped[str | None] = mapped_column(Uuid(as_uuid=False), index=True) submission_version: Mapped[int | None] = mapped_column(Integer) logical_role: Mapped[str | None] = mapped_column(String(100)) @@ -1849,12 +1861,14 @@ class ArtifactOperationReceipt(Base): CheckConstraint("attempt_number > 0", name="attempt_positive"), CheckConstraint( "contract_version = 2 and put_attempt_id is not null and " - "((guide_source_item_id is not null and checker_run_id is null " + "((guide_source_item_id is not null and checker_run_id is null and model_call_attempt_id is null " "and logical_role is null) or " - "(guide_source_item_id is null and checker_run_id is not null " + "(guide_source_item_id is null and checker_run_id is not null and model_call_attempt_id is null " "and octet_length(logical_role) between 1 and 100) or " - "(guide_source_item_id is null and checker_run_id is null " - "and (logical_role is null or logical_role='task_import_source')))", + "(guide_source_item_id is null and checker_run_id is null and model_call_attempt_id is null " + "and (logical_role is null or logical_role='task_import_source')) or " + "(guide_source_item_id is null and checker_run_id is null and model_call_attempt_id is not null " + "and logical_role in ('model_request','model_response')))", name="contract_producer_reference", ), ) @@ -1870,6 +1884,9 @@ class ArtifactOperationReceipt(Base): checker_run_id: Mapped[str | None] = mapped_column( ForeignKey("checker_runs.id", ondelete="RESTRICT"), index=True ) + model_call_attempt_id: Mapped[str | None] = mapped_column( + Uuid(as_uuid=False), ForeignKey("model_usage_effects.id", ondelete="RESTRICT"), index=True + ) logical_role: Mapped[str | None] = mapped_column(String(100)) replica_id: Mapped[str] = mapped_column( ForeignKey("artifact_replicas.id", ondelete="RESTRICT"), diff --git a/backend/app/modules/artifacts/schemas.py b/backend/app/modules/artifacts/schemas.py index 54f821287..2694ef50c 100644 --- a/backend/app/modules/artifacts/schemas.py +++ b/backend/app/modules/artifacts/schemas.py @@ -7,11 +7,9 @@ from typing import Protocol, TypeAlias, final from uuid import UUID -from app.core.hashing import canonical_json_hash from app.modules.artifacts.sources import CommittedArtifactSource from app.modules.checkers.api.output_custody import ( - CheckerOutputReservation, - CheckerOutputSlot, + GeneratedOutputReservation, ) from app.modules.authorization.catalogue import ActionId from app.modules.actors.api import ServiceIdentity @@ -47,52 +45,13 @@ class GuideArtifactAdmissionRequest: @final @dataclass(frozen=True, slots=True) class CheckerOutputArtifactAdmissionRequest: - """One prepared checker output admitted under its exact checker run.""" + """One prepared model transcript admitted under its exact durable call effect.""" - reservation: CheckerOutputReservation + reservation: GeneratedOutputReservation slot_key: str source: CommittedArtifactSource -def checker_output_operation_identity(*, checker_run_id: UUID, slot_key: str) -> str: - """Keep one logical output identity across worker-lease replacement.""" - return canonical_json_hash( - { - "request_type": "checker_output", - "checker_run_id": str(checker_run_id), - "logical_role": slot_key, - } - ) - - -def checker_output_request_digest_facts( - *, - reservation: CheckerOutputReservation, - slot: CheckerOutputSlot, -) -> dict[str, object]: - """Return stable owner facts shared by checker admission and binding.""" - evaluation = reservation.evaluation - return { - "evaluation_request_id": str(evaluation.evaluation_request_id), - "evaluation_request_sha256": evaluation.request_sha256, - "evaluation_generation": evaluation.evaluation_generation, - "checker_run_id": str(reservation.checker_run_id), - "project_id": str(evaluation.project_id), - "task_id": str(evaluation.task_id), - "submission_id": str(evaluation.submission_id), - "submission_version": evaluation.submission_version, - "slot_key": slot.key, - "media_type": slot.media_type, - "maximum_bytes": slot.maximum_bytes, - } - - -class CheckerOutputAdmissionAuthority(Protocol): - """Consume caller-prepared authority for one exact output reservation.""" - - async def consume(self, request: CheckerOutputArtifactAdmissionRequest) -> None: ... - - @final @dataclass(frozen=True, slots=True) class SubmissionBundleArtifactAdmissionRequest: @@ -374,4 +333,9 @@ class ArtifactRecoveryNotFoundError(ArtifactRecoveryError): # Only these current producers use post-put full-object verification. -VERIFICATION_PRODUCERS = ("checker_output", "submission_bundle", "task_import_source") +VERIFICATION_PRODUCERS = ( + "checker_output", + "setup_model_output", + "submission_bundle", + "task_import_source", +) diff --git a/backend/app/modules/artifacts/service.py b/backend/app/modules/artifacts/service.py index 21291181c..ad18f70d2 100644 --- a/backend/app/modules/artifacts/service.py +++ b/backend/app/modules/artifacts/service.py @@ -77,6 +77,10 @@ ArtifactRecoveryAuthority, ArtifactRecoveryAuthorityFacts, ) +from app.modules.artifacts.api.checker_output_authority import ( + CheckerOutputCommitmentAuthorityFacts, + CheckerOutputWriteAuthority, +) from app.modules.artifacts.provider_activation import require_provider_activation_for_terminal from app.modules.artifacts.authorization import ( GuideArtifactPreparedAuthorization, @@ -100,24 +104,22 @@ ArtifactRecoveryIneligibleError, ArtifactRecoveryNotFoundError, DenyArtifactInternalAuthority, - CheckerOutputAdmissionAuthority, CheckerOutputArtifactAdmissionRequest, GuideArtifactAdmissionRequest, GuideArtifactIngestAuthorityFacts, SubmissionBundleArtifactAdmissionRequest, SubmissionBundleDurableIntentAuthorityFacts, - checker_output_operation_identity, - checker_output_request_digest_facts, ) from app.modules.artifacts.submission_authorization import ( SubmissionBundlePreparedAuthorization, ) from app.modules.artifacts.sources import CommittedArtifactSource, PreparedArtifact from app.modules.checkers.api.output_custody import ( - CheckerOutputReservation, - CheckerOutputSelector, + GENERATED_OUTPUT_RESERVATION, + generated_output_subject, CheckerOutputUnavailable, ) +from app.modules.model_usage.api import ModelCallUnavailable from app.modules.authorization.runtime import ( AuthorizationContext, HumanAuthorizationContext, @@ -201,6 +203,7 @@ class _AdmissionFacts: operation_identity: str task_import_source_id: str | None = None selected_pre_submit_run_id: str | None = None + model_call_attempt_id: str | None = None def artifact_storage_namespace_spec( @@ -1068,6 +1071,7 @@ async def _complete_transaction_b( put_attempt_id=attempt.id, guide_source_item_id=attempt.guide_source_item_id, checker_run_id=attempt.checker_run_id, + model_call_attempt_id=attempt.model_call_attempt_id, logical_role=attempt.logical_role, replica_id=replica.id, operation="put", @@ -2238,7 +2242,7 @@ async def admit( submission_prepared_authorization: SubmissionBundlePreparedAuthorization | None = None, submission_task_contexts: TaskSubmissionContextPort | None = None, submission_project_contexts: ProjectLockedPolicyContextPort | None = None, - checker_output_authority: CheckerOutputAdmissionAuthority | None = None, + checker_output_authority: CheckerOutputWriteAuthority | None = None, task_import_source_authority: TaskImportSourceAdmissionAuthority | None = None, prepared_authorization: PreparedAuthorizationHandle | None = None, existing_transaction: bool = False, @@ -2259,7 +2263,15 @@ async def admit( ): if type(request) is CheckerOutputArtifactAdmissionRequest: assert checker_output_authority is not None - await checker_output_authority.consume(request) + await checker_output_authority.consume( + CheckerOutputCommitmentAuthorityFacts( + reservation=request.reservation, + slot_key=request.slot_key, + sha256=commitment.sha256, + byte_count=commitment.byte_count, + media_type=commitment.media_type, + ) + ) import_facts = None if type(request) is TaskImportSourceAdmissionRequest: if task_import_source_authority is None: @@ -2395,8 +2407,9 @@ async def admit( "submission_id": facts.submission_id, "submission_version": facts.submission_version, "checker_output_request": facts.checker_request_digest_facts, + "model_call_attempt_id": facts.model_call_attempt_id, } - if facts.request_type == "checker_output" + if facts.request_type in {"checker_output", "setup_model_output"} else {} ), "pre_submit_evidence_set_id": facts.pre_submit_evidence_set_id, @@ -2463,6 +2476,7 @@ async def admit( task_import_source_id=facts.task_import_source_id, guide_source_item_id=facts.guide_source_item_id, checker_run_id=facts.checker_run_id, + model_call_attempt_id=facts.model_call_attempt_id, logical_role=facts.logical_role, submission_id=facts.submission_id, submission_version=facts.submission_version, @@ -2544,19 +2558,14 @@ def _validate_request_boundary(request: ArtifactAdmissionRequest) -> None: if type(request.source) is not CommittedArtifactSource: raise TypeError("invalid artifact admission source") if type(request) is CheckerOutputArtifactAdmissionRequest: - if type(request.reservation) is not CheckerOutputReservation: - raise TypeError("invalid checker output reservation") try: - request.reservation.select( - CheckerOutputSelector( - evaluation=request.reservation.evaluation, - checker_run_id=request.reservation.checker_run_id, - worker_lease_id=request.reservation.worker_lease_id, - worker_lease_generation=request.reservation.worker_lease_generation, - slot_key=request.slot_key, - ) + reservation = GENERATED_OUTPUT_RESERVATION.validate_python( + request.reservation ) - except (CheckerOutputUnavailable, ValueError) as exc: + reservation.select(reservation.selector) + if request.slot_key != reservation.slot.key: + raise CheckerOutputUnavailable("checker_output_slot_unavailable") + except (CheckerOutputUnavailable, ModelCallUnavailable, ValueError) as exc: raise ArtifactAdmissionRelationshipError( "checker output reservation is unavailable" ) from exc @@ -2622,19 +2631,14 @@ async def _guide_facts(self, request: GuideArtifactAdmissionRequest) -> _Admissi async def _checker_output_facts( self, request: CheckerOutputArtifactAdmissionRequest ) -> _AdmissionFacts: - """Bind committed bytes to one owner-issued run slot and fixed service.""" - reservation = CheckerOutputReservation.model_validate(request.reservation) + """Bind committed transcript bytes to one durable call effect and role.""" + reservation = GENERATED_OUTPUT_RESERVATION.validate_python(request.reservation) + selector = reservation.selector try: - slot = reservation.select( - CheckerOutputSelector( - evaluation=reservation.evaluation, - checker_run_id=reservation.checker_run_id, - worker_lease_id=reservation.worker_lease_id, - worker_lease_generation=reservation.worker_lease_generation, - slot_key=request.slot_key, - ) - ) - except (CheckerOutputUnavailable, ValueError) as exc: + slot = reservation.select(selector) + if slot.key != request.slot_key: + raise CheckerOutputUnavailable("checker_output_slot_unavailable") + except (CheckerOutputUnavailable, ModelCallUnavailable, ValueError) as exc: raise ArtifactAdmissionRelationshipError( "checker output reservation is unavailable" ) from exc @@ -2642,33 +2646,29 @@ async def _checker_output_facts( if ( commitment.media_type != slot.media_type or commitment.byte_count > slot.maximum_bytes + or commitment.sha256 != selector.content_sha256 ): raise ArtifactAdmissionRelationshipError( "checker output does not match reserved slot" ) - evaluation = reservation.evaluation - checker_run_id = str(reservation.checker_run_id) logical_role = slot.key - operation_identity = checker_output_operation_identity( - checker_run_id=reservation.checker_run_id, slot_key=logical_role - ) - digest_facts = checker_output_request_digest_facts( - reservation=reservation, - slot=slot, - ) + owner = generated_output_subject(reservation) + operation_identity = owner.operation_identity + digest_facts = owner.request_digest_facts return _AdmissionFacts( - request_type="checker_output", + request_type=owner.producer_request_type, producer_type="service_identity", producer_ref=ServiceIdentity.ARTIFACT_CHECKER_OUTPUT.value, - project_id=str(evaluation.project_id), + project_id=str(owner.project_id), guide_id=None, - task_id=str(evaluation.task_id), + task_id=str(owner.task_id) if owner.task_id else None, guide_source_item_id=None, guide_source_snapshot_id=None, - checker_run_id=checker_run_id, + checker_run_id=None, + model_call_attempt_id=str(owner.resource_id), logical_role=logical_role, - submission_id=str(evaluation.submission_id), - submission_version=evaluation.submission_version, + submission_id=str(owner.submission_id) if owner.submission_id else None, + submission_version=owner.submission_version, checker_request_digest=canonical_json_hash(digest_facts), checker_request_digest_facts=digest_facts, pre_submit_evidence_set_id=None, diff --git a/backend/app/modules/authorization/action_definitions.py b/backend/app/modules/authorization/action_definitions.py new file mode 100644 index 000000000..4f19877d6 --- /dev/null +++ b/backend/app/modules/authorization/action_definitions.py @@ -0,0 +1,733 @@ +"""Closed immutable authorization action definition rows.""" + +from app.modules.authorization import catalogue as _catalogue + +ActionId = _catalogue.ActionId +PermissionId = _catalogue.PermissionId +ActionOwner = _catalogue.ActionOwner +CONTRIBUTOR_WORK_READ_ACTIONS = _catalogue.CONTRIBUTOR_WORK_READ_ACTIONS +PROJECT_GUIDE_READ_ACTIONS = _catalogue.PROJECT_GUIDE_READ_ACTIONS +PROJECT_WORK_READ_ACTIONS = _catalogue.PROJECT_WORK_READ_ACTIONS +_CONTRIBUTION_POLICY_ACTION_IDS = _catalogue._CONTRIBUTION_POLICY_ACTION_IDS +_active = _catalogue._active +_planned = _catalogue._planned + + +ACTION_DEFINITIONS = ( + _active( + ActionId.ACTOR_PROJECT_LIST, PermissionId.ACTOR_PROFILE_READ_SELF, ActionOwner.PILOT_READ_2 + ), + *( + _active(action, PermissionId.SUBMISSION_READ_OWN, ActionOwner.PILOT_READ_2) + for action in sorted(CONTRIBUTOR_WORK_READ_ACTIONS) + ), + *( + _active(a, PermissionId.PROJECT_GUIDE_MANAGE, ActionOwner.PILOT_READ_3) + for a in sorted(PROJECT_GUIDE_READ_ACTIONS) + ), + *( + _active(a, PermissionId.PROJECT_TASK_MANAGE, ActionOwner.PILOT_READ_3) + for a in sorted(PROJECT_WORK_READ_ACTIONS) + ), + _active( + ActionId.CONTRIBUTION_POLICY_LIST, + PermissionId.COMPENSATION_POLICY_MANAGE, + ActionOwner.PILOT_READ_3, + ), + _active( + ActionId.CONTRIBUTION_READ_SELF, PermissionId.CONTRIBUTION_READ_SELF, ActionOwner.PILOT_10 + ), + _active( + ActionId.CONTRIBUTION_READ_PROJECT, + PermissionId.CONTRIBUTION_READ_PROJECT, + ActionOwner.PILOT_10, + ), + _active( + ActionId.TASK_POST_SUBMIT_ROUTE, PermissionId.TASK_POST_SUBMIT_ROUTE, ActionOwner.ARCH_04E2 + ), + _active( + ActionId.TASK_ASSIGNMENT_AUTHORITY_RECONCILE, + PermissionId.TASK_ASSIGNMENT_AUTHORITY_RECONCILE, + ActionOwner.ARCH_03C1, + ), + _active(ActionId.TASK_REVISION_EXPIRE, PermissionId.TASK_REVISION_EXPIRE, ActionOwner.PILOT_08), + _active( + ActionId.TASK_REVISION_RECOVER, PermissionId.TASK_REVISION_RECOVER, ActionOwner.PILOT_08 + ), + _active(ActionId.OUTBOX_DISPATCH, PermissionId.OUTBOX_DISPATCH, ActionOwner.AUTH_OUTBOX_01), + _active( + ActionId.ACTOR_PROFILE_READ_SELF, PermissionId.ACTOR_PROFILE_READ_SELF, ActionOwner.AUTH_07B + ), + _active( + ActionId.ACTOR_PROFILE_UPDATE_SELF, + PermissionId.ACTOR_PROFILE_UPDATE_SELF, + ActionOwner.AUTH_07B, + ), + _active( + ActionId.AUTHORIZATION_PERMISSION_CATALOGUE_READ, + PermissionId.ADMIN_ROLE_READ, + ActionOwner.AUTH_08, + ), + _active( + ActionId.AUTHORIZATION_ADMIN_ROLE_DEFINITIONS_READ, + PermissionId.ADMIN_ROLE_READ, + ActionOwner.AUTH_08, + ), + _active(ActionId.ADMIN_ROLE_GRANT_LIST, PermissionId.ADMIN_ROLE_READ, ActionOwner.AUTH_08), + _active( + ActionId.ACTOR_ADMIN_ROLE_GRANT_HISTORY_READ, + PermissionId.ADMIN_ROLE_READ, + ActionOwner.AUTH_08, + ), + _active(ActionId.ADMIN_ROLE_GRANT_ISSUE, PermissionId.ADMIN_ROLE_GRANT, ActionOwner.AUTH_08), + _active(ActionId.ADMIN_ROLE_GRANT_REVOKE, PermissionId.ADMIN_ROLE_REVOKE, ActionOwner.AUTH_08), + _active( + ActionId.ADMIN_ROLE_GRANT_BOOTSTRAP, PermissionId.ADMIN_ROLE_GRANT, ActionOwner.AUTH_08 + ), + _active(ActionId.ACTOR_PROFILE_READ, PermissionId.ACTOR_PROFILE_READ_ANY, ActionOwner.AUTH_09C), + _active( + ActionId.ACTOR_PROFILE_SUSPEND, PermissionId.ACTOR_PROFILE_SUSPEND, ActionOwner.AUTH_09D_A + ), + _active( + ActionId.ACTOR_PROFILE_REACTIVATE, + PermissionId.ACTOR_PROFILE_REACTIVATE, + ActionOwner.AUTH_09D_A, + ), + _active( + ActionId.ACTOR_PROFILE_DEACTIVATE, + PermissionId.ACTOR_PROFILE_DEACTIVATE, + ActionOwner.AUTH_09D_A, + ), + _active( + ActionId.ACTOR_IDENTITY_LINK_READ, + PermissionId.ACTOR_IDENTITY_LINK_READ, + ActionOwner.AUTH_09C, + ), + _active( + ActionId.ACTOR_IDENTITY_LINK_REVOKE, + PermissionId.ACTOR_IDENTITY_LINK_REVOKE, + ActionOwner.AUTH_09D_B, + ), + _active( + ActionId.ACTOR_IDENTITY_LINK_REACTIVATE, + PermissionId.ACTOR_IDENTITY_LINK_REACTIVATE, + ActionOwner.AUTH_09D_B, + ), + _active( + ActionId.ACTOR_SERVICE_PROVISION, PermissionId.ACTOR_SERVICE_PROVISION, ActionOwner.AUTH_09B + ), + _active( + ActionId.PROJECT_CONTRIBUTOR_CANDIDATE_LIST, + PermissionId.PROJECT_ROLE_GRANT_MANAGE, + ActionOwner.AUTH_10B, + ), + _active( + ActionId.PROJECT_ROLE_GRANT_LIST, PermissionId.PROJECT_ROLE_GRANT_READ, ActionOwner.AUTH_10B + ), + _active( + ActionId.PROJECT_ROLE_GRANT_READ, PermissionId.PROJECT_ROLE_GRANT_READ, ActionOwner.AUTH_10B + ), + _active( + ActionId.PROJECT_ROLE_GRANT_ISSUE, + PermissionId.PROJECT_ROLE_GRANT_MANAGE, + ActionOwner.AUTH_10C, + ), + _active( + ActionId.PROJECT_ROLE_GRANT_REVOKE, + PermissionId.PROJECT_ROLE_GRANT_MANAGE, + ActionOwner.AUTH_10C, + ), + _active(ActionId.PROJECT_READ, PermissionId.PROJECT_READ, ActionOwner.AUTH_11B), + _active(ActionId.PROJECT_CATALOGUE_LIST, PermissionId.PROJECT_READ, ActionOwner.CLI_46), + _active( + ActionId.ACTOR_AUTHORIZATION_CONTEXT_READ, + PermissionId.ACTOR_PROFILE_READ_SELF, + ActionOwner.AUTH_11B, + ), + _active( + ActionId.PROJECT_SETUP_RUN_READ, + PermissionId.PROJECT_SETUP_DIAGNOSTIC_READ, + ActionOwner.AUTH_11C1, + ), + _active( + ActionId.PROJECT_GUIDE_SUFFICIENCY_REPORT_LIST, + PermissionId.PROJECT_SETUP_DIAGNOSTIC_READ, + ActionOwner.AUTH_11C1, + ), + _active( + ActionId.PROJECT_GUIDE_SUFFICIENCY_REPORT_READ, + PermissionId.PROJECT_SETUP_DIAGNOSTIC_READ, + ActionOwner.AUTH_11C1, + ), + _active( + ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_LIST, + PermissionId.PROJECT_EFFECTIVE_POLICY_READ, + ActionOwner.AUTH_11C1, + ), + _active( + ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_READ, + PermissionId.PROJECT_EFFECTIVE_POLICY_READ, + ActionOwner.AUTH_11C1, + ), + _active( + ActionId.PROJECT_EFFECTIVE_SUBMISSION_ARTIFACT_POLICY_READ, + PermissionId.PROJECT_EFFECTIVE_POLICY_READ, + ActionOwner.AUTH_11C2, + ), + _active( + ActionId.PROJECT_PRE_SUBMIT_CHECKER_POLICY_READ, + PermissionId.PROJECT_EFFECTIVE_POLICY_READ, + ActionOwner.AUTH_11C2, + ), + _active(ActionId.PROJECT_ACTIVE_GUIDE_READ, PermissionId.PROJECT_READ, ActionOwner.AUTH_11C2), + _active(ActionId.PROJECT_CREATE, PermissionId.PROJECT_CREATE, ActionOwner.AUTH_12C), + _active(ActionId.PROJECT_GUIDE_CREATE, PermissionId.PROJECT_GUIDE_MANAGE, ActionOwner.AUTH_12D), + _active(ActionId.PROJECT_GUIDE_UPDATE, PermissionId.PROJECT_GUIDE_MANAGE, ActionOwner.AUTH_12D), + _active( + ActionId.PROJECT_GUIDE_SOURCE_SNAPSHOT_CREATE, + PermissionId.PROJECT_GUIDE_MANAGE, + ActionOwner.AUTH_12D, + ), + _active( + ActionId.PROJECT_GUIDE_COMPILATION_REQUEST, + PermissionId.PROJECT_GUIDE_COMPILATION_REQUEST, + ActionOwner.AUTH_12I, + ), + _active( + ActionId.PROJECT_GUIDE_COMPILATION_REQUEST_AUTOMATIC, + PermissionId.PROJECT_GUIDE_COMPILATION_EXECUTE, + ActionOwner.AUTH_12I, + ), + _active( + ActionId.PROJECT_GUIDE_COMPILATION_EXECUTE, + PermissionId.PROJECT_GUIDE_COMPILATION_EXECUTE, + ActionOwner.AUTH_12I, + ), + _active( + ActionId.PROJECT_REVIEW_POLICY_UPDATE, + PermissionId.PROJECT_REVIEW_POLICY_MANAGE, + ActionOwner.XINT_003_02B, + ), + _active( + ActionId.PROJECT_REVISION_POLICY_UPDATE, + PermissionId.PROJECT_REVIEW_POLICY_MANAGE, + ActionOwner.XINT_003_02B, + ), + _active( + ActionId.PROJECT_GUIDE_SUFFICIENCY_REPORT_CREATE, + PermissionId.PROJECT_GUIDE_MANAGE, + ActionOwner.AUTH_12E, + ), + _active( + ActionId.PROJECT_GUIDE_SUFFICIENCY_RUN, + PermissionId.PROJECT_GUIDE_MANAGE, + ActionOwner.AUTH_12E, + ), + _active( + ActionId.PROJECT_GUIDE_SUFFICIENCY_WARNINGS_ACKNOWLEDGE, + PermissionId.PROJECT_GUIDE_MANAGE, + ActionOwner.AUTH_12E, + ), + _active( + ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_CREATE, + PermissionId.PROJECT_EFFECTIVE_POLICY_MANAGE, + ActionOwner.AUTH_12F2, + ), + _active( + ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_DERIVE, + PermissionId.PROJECT_EFFECTIVE_POLICY_MANAGE, + ActionOwner.AUTH_12F3, + ), + _active( + ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_UPDATE, + PermissionId.PROJECT_EFFECTIVE_POLICY_MANAGE, + ActionOwner.AUTH_12F2, + ), + _active( + ActionId.PROJECT_GUIDE_COMPILATION_REVIEW_PACKAGE_READ, + PermissionId.PROJECT_GUIDE_MANAGE, + ActionOwner.AUTH_12F4, + ), + _active( + ActionId.PROJECT_GUIDE_COMPILATION_CORRECTION_REQUEST, + PermissionId.PROJECT_GUIDE_COMPILATION_REQUEST, + ActionOwner.AUTH_12F4, + ), + _active( + ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_APPROVE, + PermissionId.PROJECT_EFFECTIVE_POLICY_MANAGE, + ActionOwner.AUTH_12F4, + ), + _active( + ActionId.PROJECT_POST_SUBMIT_CHECKER_POLICY_APPROVE, + PermissionId.PROJECT_EFFECTIVE_POLICY_MANAGE, + ActionOwner.AUTH_12G, + ), + _active( + ActionId.PROJECT_POST_SUBMIT_CHECKER_POLICY_CORRECTION_REQUEST, + PermissionId.PROJECT_EFFECTIVE_POLICY_MANAGE, + ActionOwner.AUTH_12G, + ), + _active( + ActionId.PROJECT_POST_SUBMIT_CHECKER_POLICY_DERIVE, + PermissionId.PROJECT_EFFECTIVE_POLICY_MANAGE, + ActionOwner.AUTH_12G, + ), + _active( + ActionId.PROJECT_SETUP_RUN_UPDATE, PermissionId.PROJECT_GUIDE_MANAGE, ActionOwner.AUTH_12B2 + ), + _active( + ActionId.PROJECT_GUIDE_ACTIVATE, PermissionId.PROJECT_GUIDE_MANAGE, ActionOwner.AUTH_12H + ), + _active( + ActionId.TASK_SUBMISSION_LIST, + PermissionId.SUBMISSION_READ_OWN, + ActionOwner.TASK_CHECKER_CLEANUP, + ), + _active( + ActionId.SUBMISSION_READ, PermissionId.SUBMISSION_READ_OWN, ActionOwner.TASK_CHECKER_CLEANUP + ), + _active( + ActionId.SUBMISSION_CHECKER_RUN_LIST, + PermissionId.SUBMISSION_READ_OWN, + ActionOwner.TASK_CHECKER_CLEANUP, + ), + _active( + ActionId.CHECKER_RUN_READ, + PermissionId.SUBMISSION_READ_OWN, + ActionOwner.TASK_CHECKER_CLEANUP, + ), + _active( + ActionId.PROJECT_TASK_SUBMISSION_LIST, + PermissionId.PROJECT_TASK_MANAGE, + ActionOwner.TASK_CHECKER_CLEANUP, + ), + _active( + ActionId.PROJECT_SUBMISSION_READ, + PermissionId.PROJECT_TASK_MANAGE, + ActionOwner.TASK_CHECKER_CLEANUP, + ), + _active( + ActionId.PROJECT_SUBMISSION_CHECKER_RUN_LIST, + PermissionId.PROJECT_TASK_MANAGE, + ActionOwner.TASK_CHECKER_CLEANUP, + ), + _active( + ActionId.PROJECT_CHECKER_RUN_READ, + PermissionId.PROJECT_TASK_MANAGE, + ActionOwner.TASK_CHECKER_CLEANUP, + ), + _active(ActionId.TASK_CLAIM, PermissionId.TASK_CLAIM, ActionOwner.TASK_PROJECT_GRANT), + _active(ActionId.TASK_START, PermissionId.TASK_CLAIM, ActionOwner.TASK_PROJECT_GRANT), + _active( + ActionId.TASK_WORK_CONTEXT_READ, + PermissionId.TASK_QUEUE_READ, + ActionOwner.TASK_PROJECT_GRANT, + ), + _active(ActionId.TASK_GUIDE_READ, PermissionId.TASK_QUEUE_READ, ActionOwner.PILOT_13), + _active(ActionId.TASK_READ, PermissionId.TASK_QUEUE_READ, ActionOwner.ARCH_03C5), + _active( + ActionId.TASK_SUBMISSION_REQUIREMENTS_READ, + PermissionId.TASK_QUEUE_READ, + ActionOwner.ARCH_03C5, + ), + _active(ActionId.PROJECT_TASK_READ, PermissionId.PROJECT_TASK_MANAGE, ActionOwner.ARCH_03C5), + _active( + ActionId.PROJECT_TASK_LOCKED_CONTEXT_READ, + PermissionId.PROJECT_TASK_MANAGE, + ActionOwner.ARCH_03C6, + ), + _active( + ActionId.OPERATIONS_TASK_LOCKED_CONTEXT_READ, + PermissionId.OPERATIONS_STATUS_READ, + ActionOwner.ARCH_03C6, + ), + _active( + ActionId.AUDIT_TASK_LOCKED_CONTEXT_READ, PermissionId.AUDIT_READ, ActionOwner.ARCH_03C6 + ), + _active(ActionId.AUDIT_TASK_EVIDENCE_READ, PermissionId.AUDIT_READ, ActionOwner.ARCH_03C7), + _active( + ActionId.PROJECT_TASK_SUBMISSION_REQUIREMENTS_READ, + PermissionId.PROJECT_TASK_MANAGE, + ActionOwner.ARCH_03C5, + ), + _active(ActionId.TASK_QUEUE_READ, PermissionId.TASK_QUEUE_READ, ActionOwner.ARCH_03C4), + _active( + ActionId.PROJECT_TASK_QUEUE_READ, PermissionId.PROJECT_TASK_MANAGE, ActionOwner.ARCH_03C4 + ), + _active( + ActionId.OPERATIONS_TASK_QUEUE_READ, + PermissionId.OPERATIONS_STATUS_READ, + ActionOwner.ARCH_03C4, + ), + _active(ActionId.PROJECT_TASK_CREATE, PermissionId.PROJECT_TASK_MANAGE, ActionOwner.ARCH_03C3), + _active(ActionId.PROJECT_TASK_IMPORT, PermissionId.PROJECT_TASK_MANAGE, ActionOwner.PILOT_02B), + _active( + ActionId.PROJECT_TASK_IMPORT_BATCH_READ, + PermissionId.PROJECT_TASK_MANAGE, + ActionOwner.PILOT_02B, + ), + _active( + ActionId.ARTIFACT_TASK_IMPORT_SOURCE_DECLARE, + PermissionId.PROJECT_TASK_MANAGE, + ActionOwner.PILOT_02A, + ), + _active( + ActionId.ARTIFACT_TASK_IMPORT_SOURCE_UPLOAD, + PermissionId.PROJECT_TASK_MANAGE, + ActionOwner.PILOT_02A, + ), + _active( + ActionId.ARTIFACT_TASK_IMPORT_SOURCE_READ, + PermissionId.PROJECT_TASK_MANAGE, + ActionOwner.PILOT_02A, + ), + _active(ActionId.PROJECT_TASK_SCREEN, PermissionId.PROJECT_TASK_MANAGE, ActionOwner.ARCH_03C3), + _active(ActionId.PROJECT_TASK_RELEASE, PermissionId.PROJECT_TASK_MANAGE, ActionOwner.ARCH_03C3), + _active( + ActionId.PROJECT_TASK_REVISION_REPAIR, + PermissionId.PROJECT_TASK_MANAGE, + ActionOwner.PILOT_08, + ), + _active( + ActionId.PROJECT_TASK_WORK_CONTEXT_READ, + PermissionId.PROJECT_TASK_MANAGE, + ActionOwner.TASK_PROJECT_GRANT, + ), + _active( + ActionId.OPERATIONS_TASK_START_OVERRIDE, + PermissionId.OPERATIONS_TASK_START_OVERRIDE, + ActionOwner.TASK_PROJECT_GRANT, + ), + _planned( + ActionId.OPERATIONS_SUBMISSION_GATE_REPAIR, + PermissionId.OPERATIONS_SUBMISSION_GATE_REPAIR, + ActionOwner.AUTH_14, + ), + _active( + ActionId.OPERATIONS_CHECKER_RETRY, + PermissionId.OPERATIONS_CHECKER_RETRY, + ActionOwner.AUTH_14, + ), + _active( + ActionId.OPERATIONS_CHECKER_RETRY_SOURCE_LIST, + PermissionId.OPERATIONS_STATUS_READ, + ActionOwner.AUTH_14, + ), + _active( + ActionId.OPERATIONS_POST_SUBMIT_DELIVERY_LIST, + PermissionId.OPERATIONS_STATUS_READ, + ActionOwner.AUTH_14, + ), + _active( + ActionId.OPERATIONS_POST_SUBMIT_DELIVERY_RECONCILE, + PermissionId.OPERATIONS_RECONCILE_RUN, + ActionOwner.AUTH_14, + ), + _active( + ActionId.CHECKER_REGISTRY_READ, PermissionId.OPERATIONS_STATUS_READ, ActionOwner.PILOT_04 + ), + _active( + ActionId.CHECKER_REGISTRY_REGISTER, + PermissionId.OPERATIONS_RECONCILE_RUN, + ActionOwner.PILOT_04, + ), + _active(ActionId.SUBMISSION_CREATE, PermissionId.SUBMISSION_CREATE, ActionOwner.AUTH_14), + _planned(ActionId.REVIEW_QUEUE_READ, PermissionId.REVIEW_QUEUE_READ, ActionOwner.AUTH_REV_05), + _planned( + ActionId.REVIEW_QUEUE_INSPECT, PermissionId.REVIEW_QUEUE_INSPECT, ActionOwner.AUTH_REV_05 + ), + _planned(ActionId.REVIEW_CLAIM, PermissionId.REVIEW_CLAIM, ActionOwner.AUTH_REV_06), + _planned(ActionId.REVIEW_RELEASE, PermissionId.REVIEW_RELEASE, ActionOwner.AUTH_REV_06), + _planned( + ActionId.REVIEW_DECLINE_PREFERENCE, + PermissionId.REVIEW_DECLINE_PREFERENCE, + ActionOwner.AUTH_REV_06, + ), + _planned( + ActionId.REVIEW_PREFERENCE_EXPIRY_RUN, + PermissionId.OPERATIONS_TIMER_RUN, + ActionOwner.AUTH_REV_06, + ), + _planned( + ActionId.REVIEW_LEASE_EXPIRY_RUN, PermissionId.OPERATIONS_TIMER_RUN, ActionOwner.AUTH_REV_06 + ), + _planned( + ActionId.REVIEW_CONTEXT_READ, + PermissionId.SUBMISSION_READ_FOR_REVIEW, + ActionOwner.AUTH_REV_07, + ), + _planned(ActionId.REVIEW_CHAIN_READ, PermissionId.REVIEW_CHAIN_READ, ActionOwner.AUTH_REV_07), + _planned( + ActionId.REVIEW_FINDING_EVIDENCE_INGEST, + PermissionId.REVIEW_DECISION, + ActionOwner.AUTH_REV_07, + ), + _planned(ActionId.REVIEW_DECISION, PermissionId.REVIEW_DECISION, ActionOwner.AUTH_REV_08), + _planned( + ActionId.REVIEW_FINDING_RESPONSE_EVIDENCE_INGEST, + PermissionId.SUBMISSION_CREATE, + ActionOwner.AUTH_REV_09A, + ), + _planned( + ActionId.REVIEW_LEASE_FORCE_RELEASE, + PermissionId.REVIEW_LEASE_FORCE_RELEASE, + ActionOwner.AUTH_REV_11, + ), + _planned( + ActionId.REVIEW_QUEUE_ROUTING_OVERRIDE, + PermissionId.REVIEW_QUEUE_OVERRIDE, + ActionOwner.AUTH_REV_11, + ), + _planned( + ActionId.REVIEW_QUEUE_ROUTING_CORRECT, + PermissionId.REVIEW_QUEUE_OVERRIDE, + ActionOwner.AUTH_REV_11, + ), + _planned( + ActionId.REVIEW_QUEUE_CLOSE, PermissionId.REVIEW_QUEUE_OVERRIDE, ActionOwner.AUTH_REV_11 + ), + _planned( + ActionId.REVIEW_RECONCILE_RUN, + PermissionId.OPERATIONS_RECONCILE_RUN, + ActionOwner.AUTH_REV_11, + ), + _planned( + ActionId.REVIEW_ARTIFACT_REFERENCE_RECONCILE, + PermissionId.OPERATIONS_RECONCILE_RUN, + ActionOwner.AUTH_REV_12, + ), + _planned( + ActionId.REVIEW_PROJECTION_REBUILD, + PermissionId.OPERATIONS_PROJECTION_REBUILD, + ActionOwner.AUTH_REV_12, + ), + _planned( + ActionId.REVIEW_REVISION_CONTEXT_REPAIR, + PermissionId.PROJECT_TASK_MANAGE, + ActionOwner.XINT_003_08A, + ), + _planned( + ActionId.REVIEW_REVISION_CONTEXT_LEGACY_CLOSE, + PermissionId.OPERATIONS_RECONCILE_RUN, + ActionOwner.XINT_003_08A, + ), + _active( + ActionId.REVIEW_LIFECYCLE_ACTIVATION_MANAGE, + PermissionId.OPERATIONS_RECONCILE_RUN, + ActionOwner.XINT_003_08B, + ), + _active( + ActionId.ARTIFACT_BINDING_READ, + PermissionId.ARTIFACT_BINDING_READ, + ActionOwner.AUTH_ART_02D_OPERATOR, + ), + _active( + ActionId.ARTIFACT_REPLICA_READ, + PermissionId.ARTIFACT_REPLICA_READ, + ActionOwner.AUTH_ART_02D_OPERATOR, + ), + _active( + ActionId.ARTIFACT_RECEIPT_READ, + PermissionId.ARTIFACT_RECEIPT_READ, + ActionOwner.AUTH_ART_02D_OPERATOR, + ), + _active( + ActionId.ARTIFACT_VERIFICATION_JOB_READ, + PermissionId.ARTIFACT_VERIFICATION_JOB_READ, + ActionOwner.AUTH_ART_02D_OPERATOR, + ), + _active( + ActionId.ARTIFACT_VERIFICATION_JOB_RETRY, + PermissionId.ARTIFACT_VERIFICATION_JOB_RETRY, + ActionOwner.AUTH_ART_02D_OPERATOR, + ), + _active( + ActionId.ARTIFACT_RECOVERY_ATTEMPT_READ, + PermissionId.ARTIFACT_RECOVERY_ATTEMPT_READ, + ActionOwner.AUTH_ART_02D_OPERATOR, + ), + _active( + ActionId.ARTIFACT_AUDIT_READ, + PermissionId.ARTIFACT_AUDIT_READ, + ActionOwner.AUTH_ART_02D_OPERATOR, + ), + _active( + ActionId.OPERATIONS_ARTIFACT_STORAGE_ADMISSION_READ, + PermissionId.OPERATIONS_STATUS_READ, + ActionOwner.AUTH_ART_02D_OPERATOR, + ), + _active( + ActionId.ARTIFACT_GUIDE_SOURCE_INGEST, + PermissionId.ARTIFACT_GUIDE_SOURCE_INGEST, + ActionOwner.XINT_002_04A, + ), + _active( + ActionId.ARTIFACT_GUIDE_SOURCE_READ, + PermissionId.ARTIFACT_GUIDE_SOURCE_READ, + ActionOwner.XINT_002_04B, + ), + _active( + ActionId.ARTIFACT_SUBMISSION_BUNDLE_PREPARE, + PermissionId.SUBMISSION_CREATE, + ActionOwner.XINT_002_05A, + ), + _active( + ActionId.ARTIFACT_SUBMISSION_BINDING_CREATE, + PermissionId.ARTIFACT_BINDING_CREATE, + ActionOwner.AUTH_ART_05, + ), + _active( + ActionId.ARTIFACT_CHECKER_OUTPUT_BINDING_CREATE, + PermissionId.ARTIFACT_BINDING_CREATE, + ActionOwner.AUTH_ART_06B, + ), + _active( + ActionId.ARTIFACT_VERIFICATION_EXECUTE, + PermissionId.ARTIFACT_VERIFICATION_EXECUTE, + ActionOwner.AUTH_ART_02D_INTERNAL, + ), + _active( + ActionId.ARTIFACT_PENDING_WORK_SCAN, + PermissionId.ARTIFACT_PENDING_WORK_SCAN, + ActionOwner.AUTH_ART_02D_INTERNAL, + ), + _active( + ActionId.ARTIFACT_PUT_ATTEMPT_RESOLVE, + PermissionId.ARTIFACT_PUT_ATTEMPT_RESOLVE, + ActionOwner.AUTH_ART_02D_INTERNAL, + ), + _active( + ActionId.CHECKER_PRE_SUBMIT_EXECUTE, + PermissionId.CHECKER_PRE_SUBMIT_EXECUTE, + ActionOwner.PILOT_04, + ), + _active( + ActionId.CHECKER_PRE_SUBMIT_FINALIZE, + PermissionId.CHECKER_PRE_SUBMIT_FINALIZE, + ActionOwner.PILOT_04, + ), + _active( + ActionId.CHECKER_POST_SUBMIT_EXECUTE, + PermissionId.CHECKER_POST_SUBMIT_EXECUTE, + ActionOwner.ARCH_04D2, + ), + _active( + ActionId.CHECKER_POST_SUBMIT_FINALIZE, + PermissionId.CHECKER_POST_SUBMIT_FINALIZE, + ActionOwner.ARCH_04D2, + ), + _active( + ActionId.CHECKER_MODEL_CALL_PREPARE, + PermissionId.CHECKER_MODEL_CALL_PREPARE, + ActionOwner.PILOT_05, + ), + _active( + ActionId.CHECKER_MODEL_CALL_DISPATCH, + PermissionId.CHECKER_MODEL_CALL_DISPATCH, + ActionOwner.PILOT_05, + ), + _active( + ActionId.CHECKER_MODEL_CALL_RECOVER, + PermissionId.CHECKER_MODEL_CALL_RECOVER, + ActionOwner.PILOT_05, + ), + _active( + ActionId.CHECKER_MODEL_CALL_OBSERVE, + PermissionId.CHECKER_MODEL_CALL_OBSERVE, + ActionOwner.PILOT_05, + ), + _active( + ActionId.CHECKER_MODEL_CALL_COMPLETE, + PermissionId.CHECKER_MODEL_CALL_COMPLETE, + ActionOwner.PILOT_05, + ), + _active( + ActionId.CHECKER_MODEL_CALL_GRANT, + PermissionId.CHECKER_MODEL_CALL_GRANT, + ActionOwner.PILOT_05, + ), + _active( + ActionId.PROJECT_GUIDE_COMPILATION_MODEL_PREPARE, + PermissionId.PROJECT_GUIDE_COMPILATION_MODEL_PREPARE, + ActionOwner.PILOT_05, + ), + _active( + ActionId.PROJECT_GUIDE_COMPILATION_MODEL_DISPATCH, + PermissionId.PROJECT_GUIDE_COMPILATION_MODEL_DISPATCH, + ActionOwner.PILOT_05, + ), + _active( + ActionId.PROJECT_GUIDE_COMPILATION_MODEL_OBSERVE, + PermissionId.PROJECT_GUIDE_COMPILATION_MODEL_OBSERVE, + ActionOwner.PILOT_05, + ), + _active( + ActionId.PROJECT_GUIDE_COMPILATION_MODEL_COMPLETE, + PermissionId.PROJECT_GUIDE_COMPILATION_MODEL_COMPLETE, + ActionOwner.PILOT_05, + ), + _active( + ActionId.PROJECT_MODEL_PROFILE_PROVISION, + PermissionId.PROJECT_MODEL_PROFILE_MANAGE, + ActionOwner.PILOT_05, + ), + _active( + ActionId.PROJECT_MODEL_BUDGET_CONFIGURE, + PermissionId.PROJECT_MODEL_BUDGET_MANAGE, + ActionOwner.PILOT_05, + ), + _active( + ActionId.PROJECT_MODEL_USAGE_READ, + PermissionId.PROJECT_MODEL_BUDGET_MANAGE, + ActionOwner.PILOT_05, + ), + _active( + ActionId.PROJECT_MODEL_CALL_RECOVERY_CHILD_AUTHORIZE, + PermissionId.PROJECT_MODEL_CALL_RECOVERY_CHILD_AUTHORIZE, + ActionOwner.PILOT_05, + ), + _active( + ActionId.ARTIFACT_PRE_SUBMIT_CHECKER_INPUT_MATERIALIZE, + PermissionId.ARTIFACT_CHECKER_INPUT_MATERIALIZE, + ActionOwner.XINT_002_06A, + ), + _active( + ActionId.ARTIFACT_POST_SUBMIT_CHECKER_INPUT_MATERIALIZE, + PermissionId.ARTIFACT_CHECKER_INPUT_MATERIALIZE, + ActionOwner.AUTH_ART_06A, + ), + _active( + ActionId.ARTIFACT_CHECKER_OUTPUT_WRITE, + PermissionId.ARTIFACT_CHECKER_OUTPUT_WRITE, + ActionOwner.AUTH_ART_06B, + ), + _planned( + ActionId.ARTIFACT_REVIEW_PACKET_MATERIALIZE, + PermissionId.ARTIFACT_REVIEW_PACKET_MATERIALIZE, + ActionOwner.XINT_002_07, + ), + _planned( + ActionId.ARTIFACT_REVIEW_EVIDENCE_BINDING_CREATE, + PermissionId.ARTIFACT_BINDING_CREATE, + ActionOwner.XINT_002_07, + ), + *( + _active(action, PermissionId.COMPENSATION_ADAPTER_BINDING_MANAGE, ActionOwner.ARCH_CP01A) + for action in ( + ActionId.COMPENSATION_ADAPTER_BINDING_READ, + ActionId.COMPENSATION_ADAPTER_BINDING_CREATE, + ActionId.COMPENSATION_ADAPTER_BINDING_SUSPEND, + ActionId.COMPENSATION_ADAPTER_BINDING_RESUME, + ) + ), + *( + _active(action, PermissionId.COMPENSATION_POLICY_MANAGE, ActionOwner.ARCH_CP01B) + for action in _CONTRIBUTION_POLICY_ACTION_IDS + ), + _active( + ActionId.CONTRIBUTION_COMPENSATION_UNIT_CREATE, + PermissionId.COMPENSATION_POLICY_MANAGE, + ActionOwner.PROJECT_POINTS_UNIT_CREATION, + ), +) diff --git a/backend/app/modules/authorization/admin_schemas.py b/backend/app/modules/authorization/admin_schemas.py index 19033d461..6d526baf9 100644 --- a/backend/app/modules/authorization/admin_schemas.py +++ b/backend/app/modules/authorization/admin_schemas.py @@ -35,7 +35,7 @@ class PermissionDefinitionsResponse(BaseModel): model_config = _STRICT items: tuple[PermissionDefinitionResponse, ...] - total: Literal[82] + total: Literal[95] class AdminRoleDefinitionResponse(BaseModel): diff --git a/backend/app/modules/authorization/catalogue.py b/backend/app/modules/authorization/catalogue.py index 8db392126..b306985eb 100644 --- a/backend/app/modules/authorization/catalogue.py +++ b/backend/app/modules/authorization/catalogue.py @@ -30,6 +30,19 @@ class PermissionId(StrEnum): CHECKER_PRE_SUBMIT_FINALIZE = "checker.pre_submit.finalize" CHECKER_POST_SUBMIT_EXECUTE = "checker.post_submit.execute" CHECKER_POST_SUBMIT_FINALIZE = "checker.post_submit.finalize" + CHECKER_MODEL_CALL_PREPARE = "checker.model_call.prepare" + CHECKER_MODEL_CALL_DISPATCH = "checker.model_call.dispatch" + CHECKER_MODEL_CALL_RECOVER = "checker.model_call.recover" + CHECKER_MODEL_CALL_OBSERVE = "checker.model_call.observe" + CHECKER_MODEL_CALL_COMPLETE = "checker.model_call.complete" + CHECKER_MODEL_CALL_GRANT = "checker.model_call.grant" + PROJECT_GUIDE_COMPILATION_MODEL_PREPARE = "project.guide_compilation.model.prepare" + PROJECT_GUIDE_COMPILATION_MODEL_DISPATCH = "project.guide_compilation.model.dispatch" + PROJECT_GUIDE_COMPILATION_MODEL_OBSERVE = "project.guide_compilation.model.observe" + PROJECT_GUIDE_COMPILATION_MODEL_COMPLETE = "project.guide_compilation.model.complete" + PROJECT_MODEL_PROFILE_MANAGE = "project.model_profile.manage" + PROJECT_MODEL_BUDGET_MANAGE = "project.model_budget.manage" + PROJECT_MODEL_CALL_RECOVERY_CHILD_AUTHORIZE = "project.model_call.recovery_child.authorize" OUTBOX_DISPATCH = "outbox.dispatch" ACTOR_SERVICE_PROVISION = "actor.service.provision" ADMIN_ROLE_READ = "admin_role.read" @@ -156,6 +169,20 @@ class ActionId(StrEnum): CHECKER_PRE_SUBMIT_FINALIZE = "checker.pre_submit.finalize" CHECKER_POST_SUBMIT_EXECUTE = "checker.post_submit.execute" CHECKER_POST_SUBMIT_FINALIZE = "checker.post_submit.finalize" + CHECKER_MODEL_CALL_PREPARE = "checker.model_call.prepare" + CHECKER_MODEL_CALL_DISPATCH = "checker.model_call.dispatch" + CHECKER_MODEL_CALL_RECOVER = "checker.model_call.recover" + CHECKER_MODEL_CALL_OBSERVE = "checker.model_call.observe" + CHECKER_MODEL_CALL_COMPLETE = "checker.model_call.complete" + CHECKER_MODEL_CALL_GRANT = "checker.model_call.grant" + PROJECT_GUIDE_COMPILATION_MODEL_PREPARE = "project.guide_compilation.model.prepare" + PROJECT_GUIDE_COMPILATION_MODEL_DISPATCH = "project.guide_compilation.model.dispatch" + PROJECT_GUIDE_COMPILATION_MODEL_OBSERVE = "project.guide_compilation.model.observe" + PROJECT_GUIDE_COMPILATION_MODEL_COMPLETE = "project.guide_compilation.model.complete" + PROJECT_MODEL_PROFILE_PROVISION = "project.model_profile.provision" + PROJECT_MODEL_BUDGET_CONFIGURE = "project.model_budget.configure" + PROJECT_MODEL_USAGE_READ = "project.model_usage.read" + PROJECT_MODEL_CALL_RECOVERY_CHILD_AUTHORIZE = "project.model_call.recovery_child.authorize" OUTBOX_DISPATCH = "outbox.dispatch" ACTOR_SERVICE_PROVISION = "actor.service.provision" PROJECT_CONTRIBUTOR_CANDIDATE_LIST = "project.contributor_candidate.list" @@ -164,6 +191,7 @@ class ActionId(StrEnum): PROJECT_ROLE_GRANT_ISSUE = "project_role_grant.issue" PROJECT_ROLE_GRANT_REVOKE = "project_role_grant.revoke" PROJECT_READ = "project.read" + PROJECT_CATALOGUE_LIST = "project.catalogue.list" ACTOR_AUTHORIZATION_CONTEXT_READ = "actor.authorization_context.read" PROJECT_SETUP_RUN_READ = "project.setup_run.read" PROJECT_GUIDE_SUFFICIENCY_REPORT_LIST = "project.guide_sufficiency_report.list" @@ -272,7 +300,6 @@ class ActionId(StrEnum): CONTRIBUTION_READ_SELF = "contribution.read_self" CONTRIBUTION_READ_PROJECT = "contribution.read_project" ACTOR_PROJECT_LIST = "actor.project.list" - PROJECT_CATALOGUE_LIST = "project.catalogue.list" ACTOR_ASSIGNMENT_LIST = "actor.assignment.list" ACTOR_SUBMISSION_LIST = "actor.submission.list" ACTOR_WORK_SUMMARY_READ = "actor.work_summary.read" @@ -289,46 +316,75 @@ class ActionId(StrEnum): PROJECT_SUBMISSION_BUNDLE_PREPARATION_LIST = "project.submission_bundle_preparation.list" CONTRIBUTION_POLICY_LIST = "contribution.policy.list" -CONTRIBUTION_READ_ACTIONS = frozenset({ActionId.CONTRIBUTION_READ_SELF, ActionId.CONTRIBUTION_READ_PROJECT}) + +CONTRIBUTION_READ_ACTIONS = frozenset( + {ActionId.CONTRIBUTION_READ_SELF, ActionId.CONTRIBUTION_READ_PROJECT} +) # Own-work reads under a current exact-project Submitter grant. -CONTRIBUTOR_WORK_READ_ACTIONS = frozenset({ - ActionId.ACTOR_ASSIGNMENT_LIST, - ActionId.ACTOR_SUBMISSION_LIST, - ActionId.ACTOR_WORK_SUMMARY_READ, - ActionId.TASK_SUBMISSION_BUNDLE_PREPARATION_LIST, - ActionId.TASK_SUBMISSION_BUNDLE_PREPARATION_READ, -}) +CONTRIBUTOR_WORK_READ_ACTIONS = frozenset( + { + ActionId.ACTOR_ASSIGNMENT_LIST, + ActionId.ACTOR_SUBMISSION_LIST, + ActionId.ACTOR_WORK_SUMMARY_READ, + ActionId.TASK_SUBMISSION_BUNDLE_PREPARATION_LIST, + ActionId.TASK_SUBMISSION_BUNDLE_PREPARATION_READ, + } +) CONTRIBUTOR_READ_ACTIONS = CONTRIBUTOR_WORK_READ_ACTIONS | {ActionId.ACTOR_PROJECT_LIST} # Project-wide Project Manager reads; the Finance policy version list shares their evaluator. -PROJECT_GUIDE_READ_ACTIONS = frozenset({ActionId.PROJECT_GUIDE_LIST, ActionId.PROJECT_GUIDE_READ, - ActionId.PROJECT_GUIDE_ACTIVATION_READINESS_READ, ActionId.PROJECT_GUIDE_DOCUMENT_READ}) -PROJECT_WORK_READ_ACTIONS = frozenset({ActionId.PROJECT_SUBMISSION_LIST, ActionId.PROJECT_ASSIGNMENT_LIST, - ActionId.PROJECT_SUMMARY_READ, ActionId.PROJECT_TASK_IMPORT_BATCH_LIST, - ActionId.PROJECT_SUBMISSION_BUNDLE_PREPARATION_LIST}) -PROJECT_MANAGEMENT_READ_ACTIONS = PROJECT_GUIDE_READ_ACTIONS | PROJECT_WORK_READ_ACTIONS | {ActionId.CONTRIBUTION_POLICY_LIST} +PROJECT_GUIDE_READ_ACTIONS = frozenset( + { + ActionId.PROJECT_GUIDE_LIST, + ActionId.PROJECT_GUIDE_READ, + ActionId.PROJECT_GUIDE_ACTIVATION_READINESS_READ, + ActionId.PROJECT_GUIDE_DOCUMENT_READ, + } +) +PROJECT_WORK_READ_ACTIONS = frozenset( + { + ActionId.PROJECT_SUBMISSION_LIST, + ActionId.PROJECT_ASSIGNMENT_LIST, + ActionId.PROJECT_SUMMARY_READ, + ActionId.PROJECT_TASK_IMPORT_BATCH_LIST, + ActionId.PROJECT_SUBMISSION_BUNDLE_PREPARATION_LIST, + } +) +PROJECT_MANAGEMENT_READ_ACTIONS = ( + PROJECT_GUIDE_READ_ACTIONS | PROJECT_WORK_READ_ACTIONS | {ActionId.CONTRIBUTION_POLICY_LIST} +) -HISTORY_READ_ACTIONS = frozenset({ - ActionId.TASK_SUBMISSION_LIST, - ActionId.SUBMISSION_READ, - ActionId.SUBMISSION_CHECKER_RUN_LIST, - ActionId.CHECKER_RUN_READ, - ActionId.PROJECT_TASK_SUBMISSION_LIST, - ActionId.PROJECT_SUBMISSION_READ, - ActionId.PROJECT_SUBMISSION_CHECKER_RUN_LIST, - ActionId.PROJECT_CHECKER_RUN_READ, -}) +HISTORY_READ_ACTIONS = frozenset( + { + ActionId.TASK_SUBMISSION_LIST, + ActionId.SUBMISSION_READ, + ActionId.SUBMISSION_CHECKER_RUN_LIST, + ActionId.CHECKER_RUN_READ, + ActionId.PROJECT_TASK_SUBMISSION_LIST, + ActionId.PROJECT_SUBMISSION_READ, + ActionId.PROJECT_SUBMISSION_CHECKER_RUN_LIST, + ActionId.PROJECT_CHECKER_RUN_READ, + } +) + +TASK_LOCKED_CONTEXT_READ_ACTIONS = frozenset( + { + ActionId.PROJECT_TASK_LOCKED_CONTEXT_READ, + ActionId.OPERATIONS_TASK_LOCKED_CONTEXT_READ, + ActionId.AUDIT_TASK_LOCKED_CONTEXT_READ, + } +) +TASK_CONCEALED_READ_ACTIONS = TASK_LOCKED_CONTEXT_READ_ACTIONS | frozenset( + { + ActionId.TASK_GUIDE_READ, + ActionId.TASK_WORK_CONTEXT_READ, + ActionId.AUDIT_TASK_EVIDENCE_READ, + ActionId.TASK_READ, + ActionId.TASK_SUBMISSION_REQUIREMENTS_READ, + ActionId.PROJECT_TASK_READ, + ActionId.PROJECT_TASK_SUBMISSION_REQUIREMENTS_READ, + } +) -TASK_LOCKED_CONTEXT_READ_ACTIONS = frozenset({ - ActionId.PROJECT_TASK_LOCKED_CONTEXT_READ, ActionId.OPERATIONS_TASK_LOCKED_CONTEXT_READ, - ActionId.AUDIT_TASK_LOCKED_CONTEXT_READ, -}) -TASK_CONCEALED_READ_ACTIONS = TASK_LOCKED_CONTEXT_READ_ACTIONS | frozenset({ - ActionId.TASK_GUIDE_READ, - ActionId.TASK_WORK_CONTEXT_READ, - ActionId.AUDIT_TASK_EVIDENCE_READ, - ActionId.TASK_READ, ActionId.TASK_SUBMISSION_REQUIREMENTS_READ, - ActionId.PROJECT_TASK_READ, ActionId.PROJECT_TASK_SUBMISSION_REQUIREMENTS_READ, -}) @unique class ActionOwner(StrEnum): @@ -340,11 +396,12 @@ class ActionOwner(StrEnum): PILOT_02A = "PILOT-02A" PILOT_02B = "PILOT-02B" PILOT_04 = "PILOT-04" + PILOT_05 = "PILOT-05" PILOT_08 = "PILOT-08" PILOT_10 = "PILOT-10" PILOT_READ_2 = "PILOT-READ-2" - PILOT_READ_3 = "PILOT-READ-3" CLI_46 = "WS-CLI-001-46" + PILOT_READ_3 = "PILOT-READ-3" AUTH_07B = "WS-AUTH-001-07B" AUTH_08 = "WS-AUTH-001-08" @@ -445,332 +502,7 @@ def _active( ) -ACTION_DEFINITIONS = ( - _active(ActionId.ACTOR_PROJECT_LIST, PermissionId.ACTOR_PROFILE_READ_SELF, ActionOwner.PILOT_READ_2), - _active(ActionId.PROJECT_CATALOGUE_LIST, PermissionId.PROJECT_READ, ActionOwner.CLI_46), - *( - _active(action, PermissionId.SUBMISSION_READ_OWN, ActionOwner.PILOT_READ_2) - for action in sorted(CONTRIBUTOR_WORK_READ_ACTIONS) - ), - *(_active(a, PermissionId.PROJECT_GUIDE_MANAGE, ActionOwner.PILOT_READ_3) for a in sorted(PROJECT_GUIDE_READ_ACTIONS)), - *(_active(a, PermissionId.PROJECT_TASK_MANAGE, ActionOwner.PILOT_READ_3) for a in sorted(PROJECT_WORK_READ_ACTIONS)), - _active(ActionId.CONTRIBUTION_POLICY_LIST, PermissionId.COMPENSATION_POLICY_MANAGE, ActionOwner.PILOT_READ_3), - _active(ActionId.CONTRIBUTION_READ_SELF, PermissionId.CONTRIBUTION_READ_SELF, ActionOwner.PILOT_10), - _active(ActionId.CONTRIBUTION_READ_PROJECT, PermissionId.CONTRIBUTION_READ_PROJECT, ActionOwner.PILOT_10), - _active(ActionId.TASK_POST_SUBMIT_ROUTE, PermissionId.TASK_POST_SUBMIT_ROUTE, ActionOwner.ARCH_04E2), - _active(ActionId.TASK_ASSIGNMENT_AUTHORITY_RECONCILE, PermissionId.TASK_ASSIGNMENT_AUTHORITY_RECONCILE, ActionOwner.ARCH_03C1), - _active(ActionId.TASK_REVISION_EXPIRE, PermissionId.TASK_REVISION_EXPIRE, ActionOwner.PILOT_08), - _active(ActionId.TASK_REVISION_RECOVER, PermissionId.TASK_REVISION_RECOVER, ActionOwner.PILOT_08), - _active(ActionId.OUTBOX_DISPATCH, PermissionId.OUTBOX_DISPATCH, ActionOwner.AUTH_OUTBOX_01), - _active( - ActionId.ACTOR_PROFILE_READ_SELF, PermissionId.ACTOR_PROFILE_READ_SELF, ActionOwner.AUTH_07B - ), - _active(ActionId.ACTOR_PROFILE_UPDATE_SELF, PermissionId.ACTOR_PROFILE_UPDATE_SELF, ActionOwner.AUTH_07B), - _active(ActionId.AUTHORIZATION_PERMISSION_CATALOGUE_READ, PermissionId.ADMIN_ROLE_READ, ActionOwner.AUTH_08), - _active(ActionId.AUTHORIZATION_ADMIN_ROLE_DEFINITIONS_READ, PermissionId.ADMIN_ROLE_READ, ActionOwner.AUTH_08), - _active(ActionId.ADMIN_ROLE_GRANT_LIST, PermissionId.ADMIN_ROLE_READ, ActionOwner.AUTH_08), - _active(ActionId.ACTOR_ADMIN_ROLE_GRANT_HISTORY_READ, PermissionId.ADMIN_ROLE_READ, ActionOwner.AUTH_08), - _active(ActionId.ADMIN_ROLE_GRANT_ISSUE, PermissionId.ADMIN_ROLE_GRANT, ActionOwner.AUTH_08), - _active(ActionId.ADMIN_ROLE_GRANT_REVOKE, PermissionId.ADMIN_ROLE_REVOKE, ActionOwner.AUTH_08), - _active(ActionId.ADMIN_ROLE_GRANT_BOOTSTRAP, PermissionId.ADMIN_ROLE_GRANT, ActionOwner.AUTH_08), - _active(ActionId.ACTOR_PROFILE_READ, PermissionId.ACTOR_PROFILE_READ_ANY, ActionOwner.AUTH_09C), - _active(ActionId.ACTOR_PROFILE_SUSPEND, PermissionId.ACTOR_PROFILE_SUSPEND, ActionOwner.AUTH_09D_A), - _active(ActionId.ACTOR_PROFILE_REACTIVATE, PermissionId.ACTOR_PROFILE_REACTIVATE, ActionOwner.AUTH_09D_A), - _active(ActionId.ACTOR_PROFILE_DEACTIVATE, PermissionId.ACTOR_PROFILE_DEACTIVATE, ActionOwner.AUTH_09D_A), - _active(ActionId.ACTOR_IDENTITY_LINK_READ, PermissionId.ACTOR_IDENTITY_LINK_READ, ActionOwner.AUTH_09C), - _active(ActionId.ACTOR_IDENTITY_LINK_REVOKE, PermissionId.ACTOR_IDENTITY_LINK_REVOKE, ActionOwner.AUTH_09D_B), - _active( - ActionId.ACTOR_IDENTITY_LINK_REACTIVATE, - PermissionId.ACTOR_IDENTITY_LINK_REACTIVATE, - ActionOwner.AUTH_09D_B, - ), - _active(ActionId.ACTOR_SERVICE_PROVISION, PermissionId.ACTOR_SERVICE_PROVISION, ActionOwner.AUTH_09B), - _active(ActionId.PROJECT_CONTRIBUTOR_CANDIDATE_LIST, PermissionId.PROJECT_ROLE_GRANT_MANAGE, ActionOwner.AUTH_10B), - _active(ActionId.PROJECT_ROLE_GRANT_LIST, PermissionId.PROJECT_ROLE_GRANT_READ, ActionOwner.AUTH_10B), - _active(ActionId.PROJECT_ROLE_GRANT_READ, PermissionId.PROJECT_ROLE_GRANT_READ, ActionOwner.AUTH_10B), - _active(ActionId.PROJECT_ROLE_GRANT_ISSUE, PermissionId.PROJECT_ROLE_GRANT_MANAGE, ActionOwner.AUTH_10C), - _active(ActionId.PROJECT_ROLE_GRANT_REVOKE, PermissionId.PROJECT_ROLE_GRANT_MANAGE, ActionOwner.AUTH_10C), - _active(ActionId.PROJECT_READ, PermissionId.PROJECT_READ, ActionOwner.AUTH_11B), - _active(ActionId.ACTOR_AUTHORIZATION_CONTEXT_READ, PermissionId.ACTOR_PROFILE_READ_SELF, ActionOwner.AUTH_11B), - _active(ActionId.PROJECT_SETUP_RUN_READ, PermissionId.PROJECT_SETUP_DIAGNOSTIC_READ, ActionOwner.AUTH_11C1), - _active( - ActionId.PROJECT_GUIDE_SUFFICIENCY_REPORT_LIST, - PermissionId.PROJECT_SETUP_DIAGNOSTIC_READ, - ActionOwner.AUTH_11C1, - ), - _active( - ActionId.PROJECT_GUIDE_SUFFICIENCY_REPORT_READ, - PermissionId.PROJECT_SETUP_DIAGNOSTIC_READ, - ActionOwner.AUTH_11C1, - ), - _active( - ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_LIST, - PermissionId.PROJECT_EFFECTIVE_POLICY_READ, - ActionOwner.AUTH_11C1, - ), - _active( - ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_READ, - PermissionId.PROJECT_EFFECTIVE_POLICY_READ, - ActionOwner.AUTH_11C1, - ), - _active( - ActionId.PROJECT_EFFECTIVE_SUBMISSION_ARTIFACT_POLICY_READ, - PermissionId.PROJECT_EFFECTIVE_POLICY_READ, - ActionOwner.AUTH_11C2, - ), - _active( - ActionId.PROJECT_PRE_SUBMIT_CHECKER_POLICY_READ, - PermissionId.PROJECT_EFFECTIVE_POLICY_READ, - ActionOwner.AUTH_11C2, - ), - _active(ActionId.PROJECT_ACTIVE_GUIDE_READ, PermissionId.PROJECT_READ, ActionOwner.AUTH_11C2), - _active(ActionId.PROJECT_CREATE, PermissionId.PROJECT_CREATE, ActionOwner.AUTH_12C), - _active(ActionId.PROJECT_GUIDE_CREATE, PermissionId.PROJECT_GUIDE_MANAGE, ActionOwner.AUTH_12D), - _active(ActionId.PROJECT_GUIDE_UPDATE, PermissionId.PROJECT_GUIDE_MANAGE, ActionOwner.AUTH_12D), - _active(ActionId.PROJECT_GUIDE_SOURCE_SNAPSHOT_CREATE, PermissionId.PROJECT_GUIDE_MANAGE, ActionOwner.AUTH_12D), - _active( - ActionId.PROJECT_GUIDE_COMPILATION_REQUEST, - PermissionId.PROJECT_GUIDE_COMPILATION_REQUEST, - ActionOwner.AUTH_12I, - ), - _active( - ActionId.PROJECT_GUIDE_COMPILATION_REQUEST_AUTOMATIC, - PermissionId.PROJECT_GUIDE_COMPILATION_EXECUTE, - ActionOwner.AUTH_12I, - ), - _active( - ActionId.PROJECT_GUIDE_COMPILATION_EXECUTE, - PermissionId.PROJECT_GUIDE_COMPILATION_EXECUTE, - ActionOwner.AUTH_12I, - ), - _active(ActionId.PROJECT_REVIEW_POLICY_UPDATE, PermissionId.PROJECT_REVIEW_POLICY_MANAGE, ActionOwner.XINT_003_02B), - _active( - ActionId.PROJECT_REVISION_POLICY_UPDATE, - PermissionId.PROJECT_REVIEW_POLICY_MANAGE, - ActionOwner.XINT_003_02B, - ), - _active(ActionId.PROJECT_GUIDE_SUFFICIENCY_REPORT_CREATE, PermissionId.PROJECT_GUIDE_MANAGE, ActionOwner.AUTH_12E), - _active(ActionId.PROJECT_GUIDE_SUFFICIENCY_RUN, PermissionId.PROJECT_GUIDE_MANAGE, ActionOwner.AUTH_12E), - _active( - ActionId.PROJECT_GUIDE_SUFFICIENCY_WARNINGS_ACKNOWLEDGE, - PermissionId.PROJECT_GUIDE_MANAGE, - ActionOwner.AUTH_12E, - ), - _active( - ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_CREATE, - PermissionId.PROJECT_EFFECTIVE_POLICY_MANAGE, - ActionOwner.AUTH_12F2, - ), - _active( - ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_DERIVE, - PermissionId.PROJECT_EFFECTIVE_POLICY_MANAGE, - ActionOwner.AUTH_12F3, - ), - _active( - ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_UPDATE, - PermissionId.PROJECT_EFFECTIVE_POLICY_MANAGE, - ActionOwner.AUTH_12F2, - ), - _active( - ActionId.PROJECT_GUIDE_COMPILATION_REVIEW_PACKAGE_READ, - PermissionId.PROJECT_GUIDE_MANAGE, - ActionOwner.AUTH_12F4, - ), - _active( - ActionId.PROJECT_GUIDE_COMPILATION_CORRECTION_REQUEST, - PermissionId.PROJECT_GUIDE_COMPILATION_REQUEST, - ActionOwner.AUTH_12F4, - ), - _active( - ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_APPROVE, - PermissionId.PROJECT_EFFECTIVE_POLICY_MANAGE, - ActionOwner.AUTH_12F4, - ), - _active( - ActionId.PROJECT_POST_SUBMIT_CHECKER_POLICY_APPROVE, - PermissionId.PROJECT_EFFECTIVE_POLICY_MANAGE, - ActionOwner.AUTH_12G, - ), - _active( - ActionId.PROJECT_POST_SUBMIT_CHECKER_POLICY_CORRECTION_REQUEST, - PermissionId.PROJECT_EFFECTIVE_POLICY_MANAGE, - ActionOwner.AUTH_12G, - ), - _active( - ActionId.PROJECT_POST_SUBMIT_CHECKER_POLICY_DERIVE, - PermissionId.PROJECT_EFFECTIVE_POLICY_MANAGE, - ActionOwner.AUTH_12G, - ), - _active(ActionId.PROJECT_SETUP_RUN_UPDATE, PermissionId.PROJECT_GUIDE_MANAGE, ActionOwner.AUTH_12B2), - _active(ActionId.PROJECT_GUIDE_ACTIVATE, PermissionId.PROJECT_GUIDE_MANAGE, ActionOwner.AUTH_12H), - _active(ActionId.TASK_SUBMISSION_LIST, PermissionId.SUBMISSION_READ_OWN, ActionOwner.TASK_CHECKER_CLEANUP), - _active(ActionId.SUBMISSION_READ, PermissionId.SUBMISSION_READ_OWN, ActionOwner.TASK_CHECKER_CLEANUP), - _active(ActionId.SUBMISSION_CHECKER_RUN_LIST, PermissionId.SUBMISSION_READ_OWN, ActionOwner.TASK_CHECKER_CLEANUP), - _active(ActionId.CHECKER_RUN_READ, PermissionId.SUBMISSION_READ_OWN, ActionOwner.TASK_CHECKER_CLEANUP), - _active(ActionId.PROJECT_TASK_SUBMISSION_LIST, PermissionId.PROJECT_TASK_MANAGE, ActionOwner.TASK_CHECKER_CLEANUP), - _active(ActionId.PROJECT_SUBMISSION_READ, PermissionId.PROJECT_TASK_MANAGE, ActionOwner.TASK_CHECKER_CLEANUP), - _active(ActionId.PROJECT_SUBMISSION_CHECKER_RUN_LIST, PermissionId.PROJECT_TASK_MANAGE, ActionOwner.TASK_CHECKER_CLEANUP), - _active(ActionId.PROJECT_CHECKER_RUN_READ, PermissionId.PROJECT_TASK_MANAGE, ActionOwner.TASK_CHECKER_CLEANUP), - _active(ActionId.TASK_CLAIM, PermissionId.TASK_CLAIM, ActionOwner.TASK_PROJECT_GRANT), - _active(ActionId.TASK_START, PermissionId.TASK_CLAIM, ActionOwner.TASK_PROJECT_GRANT), - _active(ActionId.TASK_WORK_CONTEXT_READ, PermissionId.TASK_QUEUE_READ, ActionOwner.TASK_PROJECT_GRANT), - _active(ActionId.TASK_GUIDE_READ, PermissionId.TASK_QUEUE_READ, ActionOwner.PILOT_13), - _active(ActionId.TASK_READ, PermissionId.TASK_QUEUE_READ, ActionOwner.ARCH_03C5), - _active(ActionId.TASK_SUBMISSION_REQUIREMENTS_READ, PermissionId.TASK_QUEUE_READ, ActionOwner.ARCH_03C5), - _active(ActionId.PROJECT_TASK_READ, PermissionId.PROJECT_TASK_MANAGE, ActionOwner.ARCH_03C5), - _active(ActionId.PROJECT_TASK_LOCKED_CONTEXT_READ, PermissionId.PROJECT_TASK_MANAGE, ActionOwner.ARCH_03C6), - _active(ActionId.OPERATIONS_TASK_LOCKED_CONTEXT_READ, PermissionId.OPERATIONS_STATUS_READ, ActionOwner.ARCH_03C6), - _active(ActionId.AUDIT_TASK_LOCKED_CONTEXT_READ, PermissionId.AUDIT_READ, ActionOwner.ARCH_03C6), - _active(ActionId.AUDIT_TASK_EVIDENCE_READ, PermissionId.AUDIT_READ, ActionOwner.ARCH_03C7), - _active(ActionId.PROJECT_TASK_SUBMISSION_REQUIREMENTS_READ, PermissionId.PROJECT_TASK_MANAGE, ActionOwner.ARCH_03C5), - _active(ActionId.TASK_QUEUE_READ, PermissionId.TASK_QUEUE_READ, ActionOwner.ARCH_03C4), - _active(ActionId.PROJECT_TASK_QUEUE_READ, PermissionId.PROJECT_TASK_MANAGE, ActionOwner.ARCH_03C4), - _active(ActionId.OPERATIONS_TASK_QUEUE_READ, PermissionId.OPERATIONS_STATUS_READ, ActionOwner.ARCH_03C4), - _active(ActionId.PROJECT_TASK_CREATE, PermissionId.PROJECT_TASK_MANAGE, ActionOwner.ARCH_03C3), - _active(ActionId.PROJECT_TASK_IMPORT, PermissionId.PROJECT_TASK_MANAGE, ActionOwner.PILOT_02B), - _active(ActionId.PROJECT_TASK_IMPORT_BATCH_READ, PermissionId.PROJECT_TASK_MANAGE, ActionOwner.PILOT_02B), - _active(ActionId.ARTIFACT_TASK_IMPORT_SOURCE_DECLARE, PermissionId.PROJECT_TASK_MANAGE, ActionOwner.PILOT_02A), - _active(ActionId.ARTIFACT_TASK_IMPORT_SOURCE_UPLOAD, PermissionId.PROJECT_TASK_MANAGE, ActionOwner.PILOT_02A), - _active(ActionId.ARTIFACT_TASK_IMPORT_SOURCE_READ, PermissionId.PROJECT_TASK_MANAGE, ActionOwner.PILOT_02A), - _active(ActionId.PROJECT_TASK_SCREEN, PermissionId.PROJECT_TASK_MANAGE, ActionOwner.ARCH_03C3), - _active(ActionId.PROJECT_TASK_RELEASE, PermissionId.PROJECT_TASK_MANAGE, ActionOwner.ARCH_03C3), - _active(ActionId.PROJECT_TASK_REVISION_REPAIR, PermissionId.PROJECT_TASK_MANAGE, ActionOwner.PILOT_08), - _active(ActionId.PROJECT_TASK_WORK_CONTEXT_READ, PermissionId.PROJECT_TASK_MANAGE, ActionOwner.TASK_PROJECT_GRANT), - _active( - ActionId.OPERATIONS_TASK_START_OVERRIDE, - PermissionId.OPERATIONS_TASK_START_OVERRIDE, - ActionOwner.TASK_PROJECT_GRANT, - ), - _planned( - ActionId.OPERATIONS_SUBMISSION_GATE_REPAIR, - PermissionId.OPERATIONS_SUBMISSION_GATE_REPAIR, - ActionOwner.AUTH_14, - ), - _active(ActionId.OPERATIONS_CHECKER_RETRY, PermissionId.OPERATIONS_CHECKER_RETRY, ActionOwner.AUTH_14), - _active(ActionId.OPERATIONS_CHECKER_RETRY_SOURCE_LIST, PermissionId.OPERATIONS_STATUS_READ, ActionOwner.AUTH_14), - _active(ActionId.OPERATIONS_POST_SUBMIT_DELIVERY_LIST, PermissionId.OPERATIONS_STATUS_READ, ActionOwner.AUTH_14), - _active(ActionId.OPERATIONS_POST_SUBMIT_DELIVERY_RECONCILE, PermissionId.OPERATIONS_RECONCILE_RUN, ActionOwner.AUTH_14), - _active(ActionId.CHECKER_REGISTRY_READ, PermissionId.OPERATIONS_STATUS_READ, ActionOwner.PILOT_04), - _active(ActionId.CHECKER_REGISTRY_REGISTER, PermissionId.OPERATIONS_RECONCILE_RUN, ActionOwner.PILOT_04), - _active(ActionId.SUBMISSION_CREATE, PermissionId.SUBMISSION_CREATE, ActionOwner.AUTH_14), - _planned(ActionId.REVIEW_QUEUE_READ, PermissionId.REVIEW_QUEUE_READ, ActionOwner.AUTH_REV_05), - _planned(ActionId.REVIEW_QUEUE_INSPECT, PermissionId.REVIEW_QUEUE_INSPECT, ActionOwner.AUTH_REV_05), - _planned(ActionId.REVIEW_CLAIM, PermissionId.REVIEW_CLAIM, ActionOwner.AUTH_REV_06), - _planned(ActionId.REVIEW_RELEASE, PermissionId.REVIEW_RELEASE, ActionOwner.AUTH_REV_06), - _planned(ActionId.REVIEW_DECLINE_PREFERENCE, PermissionId.REVIEW_DECLINE_PREFERENCE, ActionOwner.AUTH_REV_06), - _planned(ActionId.REVIEW_PREFERENCE_EXPIRY_RUN, PermissionId.OPERATIONS_TIMER_RUN, ActionOwner.AUTH_REV_06), - _planned(ActionId.REVIEW_LEASE_EXPIRY_RUN, PermissionId.OPERATIONS_TIMER_RUN, ActionOwner.AUTH_REV_06), - _planned(ActionId.REVIEW_CONTEXT_READ, PermissionId.SUBMISSION_READ_FOR_REVIEW, ActionOwner.AUTH_REV_07), - _planned(ActionId.REVIEW_CHAIN_READ, PermissionId.REVIEW_CHAIN_READ, ActionOwner.AUTH_REV_07), - _planned(ActionId.REVIEW_FINDING_EVIDENCE_INGEST, PermissionId.REVIEW_DECISION, ActionOwner.AUTH_REV_07), - _planned(ActionId.REVIEW_DECISION, PermissionId.REVIEW_DECISION, ActionOwner.AUTH_REV_08), - _planned( - ActionId.REVIEW_FINDING_RESPONSE_EVIDENCE_INGEST, - PermissionId.SUBMISSION_CREATE, - ActionOwner.AUTH_REV_09A, - ), - _planned(ActionId.REVIEW_LEASE_FORCE_RELEASE, PermissionId.REVIEW_LEASE_FORCE_RELEASE, ActionOwner.AUTH_REV_11), - _planned(ActionId.REVIEW_QUEUE_ROUTING_OVERRIDE, PermissionId.REVIEW_QUEUE_OVERRIDE, ActionOwner.AUTH_REV_11), - _planned(ActionId.REVIEW_QUEUE_ROUTING_CORRECT, PermissionId.REVIEW_QUEUE_OVERRIDE, ActionOwner.AUTH_REV_11), - _planned(ActionId.REVIEW_QUEUE_CLOSE, PermissionId.REVIEW_QUEUE_OVERRIDE, ActionOwner.AUTH_REV_11), - _planned(ActionId.REVIEW_RECONCILE_RUN, PermissionId.OPERATIONS_RECONCILE_RUN, ActionOwner.AUTH_REV_11), - _planned( - ActionId.REVIEW_ARTIFACT_REFERENCE_RECONCILE, - PermissionId.OPERATIONS_RECONCILE_RUN, - ActionOwner.AUTH_REV_12, - ), - _planned(ActionId.REVIEW_PROJECTION_REBUILD, PermissionId.OPERATIONS_PROJECTION_REBUILD, ActionOwner.AUTH_REV_12), - _planned(ActionId.REVIEW_REVISION_CONTEXT_REPAIR, PermissionId.PROJECT_TASK_MANAGE, ActionOwner.XINT_003_08A), - _planned( - ActionId.REVIEW_REVISION_CONTEXT_LEGACY_CLOSE, - PermissionId.OPERATIONS_RECONCILE_RUN, - ActionOwner.XINT_003_08A, - ), - _active(ActionId.REVIEW_LIFECYCLE_ACTIVATION_MANAGE, PermissionId.OPERATIONS_RECONCILE_RUN, ActionOwner.XINT_003_08B), - _active(ActionId.ARTIFACT_BINDING_READ, PermissionId.ARTIFACT_BINDING_READ, ActionOwner.AUTH_ART_02D_OPERATOR), - _active(ActionId.ARTIFACT_REPLICA_READ, PermissionId.ARTIFACT_REPLICA_READ, ActionOwner.AUTH_ART_02D_OPERATOR), - _active(ActionId.ARTIFACT_RECEIPT_READ, PermissionId.ARTIFACT_RECEIPT_READ, ActionOwner.AUTH_ART_02D_OPERATOR), - _active(ActionId.ARTIFACT_VERIFICATION_JOB_READ, - PermissionId.ARTIFACT_VERIFICATION_JOB_READ, ActionOwner.AUTH_ART_02D_OPERATOR), - _active(ActionId.ARTIFACT_VERIFICATION_JOB_RETRY, - PermissionId.ARTIFACT_VERIFICATION_JOB_RETRY, ActionOwner.AUTH_ART_02D_OPERATOR), - _active(ActionId.ARTIFACT_RECOVERY_ATTEMPT_READ, - PermissionId.ARTIFACT_RECOVERY_ATTEMPT_READ, ActionOwner.AUTH_ART_02D_OPERATOR), - _active(ActionId.ARTIFACT_AUDIT_READ, PermissionId.ARTIFACT_AUDIT_READ, ActionOwner.AUTH_ART_02D_OPERATOR), - _active(ActionId.OPERATIONS_ARTIFACT_STORAGE_ADMISSION_READ, - PermissionId.OPERATIONS_STATUS_READ, ActionOwner.AUTH_ART_02D_OPERATOR), - _active(ActionId.ARTIFACT_GUIDE_SOURCE_INGEST, PermissionId.ARTIFACT_GUIDE_SOURCE_INGEST, ActionOwner.XINT_002_04A), - _active(ActionId.ARTIFACT_GUIDE_SOURCE_READ, PermissionId.ARTIFACT_GUIDE_SOURCE_READ, ActionOwner.XINT_002_04B), - _active(ActionId.ARTIFACT_SUBMISSION_BUNDLE_PREPARE, PermissionId.SUBMISSION_CREATE, ActionOwner.XINT_002_05A), - _active(ActionId.ARTIFACT_SUBMISSION_BINDING_CREATE, PermissionId.ARTIFACT_BINDING_CREATE, ActionOwner.AUTH_ART_05), - _planned( - ActionId.ARTIFACT_CHECKER_OUTPUT_BINDING_CREATE, - PermissionId.ARTIFACT_BINDING_CREATE, - ActionOwner.AUTH_ART_06B, - ), - _active( - ActionId.ARTIFACT_VERIFICATION_EXECUTE, - PermissionId.ARTIFACT_VERIFICATION_EXECUTE, - ActionOwner.AUTH_ART_02D_INTERNAL, - ), - _active( - ActionId.ARTIFACT_PENDING_WORK_SCAN, - PermissionId.ARTIFACT_PENDING_WORK_SCAN, - ActionOwner.AUTH_ART_02D_INTERNAL, - ), - _active( - ActionId.ARTIFACT_PUT_ATTEMPT_RESOLVE, - PermissionId.ARTIFACT_PUT_ATTEMPT_RESOLVE, - ActionOwner.AUTH_ART_02D_INTERNAL, - ), - _active(ActionId.CHECKER_PRE_SUBMIT_EXECUTE, PermissionId.CHECKER_PRE_SUBMIT_EXECUTE, ActionOwner.PILOT_04), - _active(ActionId.CHECKER_PRE_SUBMIT_FINALIZE, PermissionId.CHECKER_PRE_SUBMIT_FINALIZE, ActionOwner.PILOT_04), - _active(ActionId.CHECKER_POST_SUBMIT_EXECUTE, PermissionId.CHECKER_POST_SUBMIT_EXECUTE, ActionOwner.ARCH_04D2), - _active(ActionId.CHECKER_POST_SUBMIT_FINALIZE, PermissionId.CHECKER_POST_SUBMIT_FINALIZE, ActionOwner.ARCH_04D2), - _active( - ActionId.ARTIFACT_PRE_SUBMIT_CHECKER_INPUT_MATERIALIZE, - PermissionId.ARTIFACT_CHECKER_INPUT_MATERIALIZE, - ActionOwner.XINT_002_06A, - ), - _active( - ActionId.ARTIFACT_POST_SUBMIT_CHECKER_INPUT_MATERIALIZE, - PermissionId.ARTIFACT_CHECKER_INPUT_MATERIALIZE, - ActionOwner.AUTH_ART_06A, - ), - _planned( - ActionId.ARTIFACT_CHECKER_OUTPUT_WRITE, - PermissionId.ARTIFACT_CHECKER_OUTPUT_WRITE, - ActionOwner.AUTH_ART_06B, - ), - _planned( - ActionId.ARTIFACT_REVIEW_PACKET_MATERIALIZE, - PermissionId.ARTIFACT_REVIEW_PACKET_MATERIALIZE, - ActionOwner.XINT_002_07, - ), - _planned( - ActionId.ARTIFACT_REVIEW_EVIDENCE_BINDING_CREATE, - PermissionId.ARTIFACT_BINDING_CREATE, - ActionOwner.XINT_002_07, - ), - *( - _active(action, PermissionId.COMPENSATION_ADAPTER_BINDING_MANAGE, ActionOwner.ARCH_CP01A) - for action in ( - ActionId.COMPENSATION_ADAPTER_BINDING_READ, - ActionId.COMPENSATION_ADAPTER_BINDING_CREATE, - ActionId.COMPENSATION_ADAPTER_BINDING_SUSPEND, - ActionId.COMPENSATION_ADAPTER_BINDING_RESUME, - ) - ), - *( - _active(action, PermissionId.COMPENSATION_POLICY_MANAGE, ActionOwner.ARCH_CP01B) - for action in _CONTRIBUTION_POLICY_ACTION_IDS - ), - _active( - ActionId.CONTRIBUTION_COMPENSATION_UNIT_CREATE, - PermissionId.COMPENSATION_POLICY_MANAGE, - ActionOwner.PROJECT_POINTS_UNIT_CREATION, - ), -) +from app.modules.authorization.action_definitions import ACTION_DEFINITIONS # noqa: E402 PERMISSION_IDS = frozenset(PermissionId) ACTION_IDS = frozenset(ActionId) @@ -784,6 +516,19 @@ def _active( { PermissionId.CHECKER_POST_SUBMIT_EXECUTE, PermissionId.CHECKER_POST_SUBMIT_FINALIZE, + PermissionId.CHECKER_MODEL_CALL_PREPARE, + PermissionId.CHECKER_MODEL_CALL_DISPATCH, + PermissionId.CHECKER_MODEL_CALL_RECOVER, + PermissionId.CHECKER_MODEL_CALL_OBSERVE, + PermissionId.CHECKER_MODEL_CALL_COMPLETE, + PermissionId.CHECKER_MODEL_CALL_GRANT, + PermissionId.PROJECT_GUIDE_COMPILATION_MODEL_PREPARE, + PermissionId.PROJECT_GUIDE_COMPILATION_MODEL_DISPATCH, + PermissionId.PROJECT_GUIDE_COMPILATION_MODEL_OBSERVE, + PermissionId.PROJECT_GUIDE_COMPILATION_MODEL_COMPLETE, + PermissionId.PROJECT_MODEL_PROFILE_MANAGE, + PermissionId.PROJECT_MODEL_BUDGET_MANAGE, + PermissionId.PROJECT_MODEL_CALL_RECOVERY_CHILD_AUTHORIZE, PermissionId.CHECKER_PRE_SUBMIT_EXECUTE, PermissionId.CHECKER_PRE_SUBMIT_FINALIZE, PermissionId.TASK_ASSIGNMENT_AUTHORITY_RECONCILE, @@ -819,28 +564,37 @@ def _active( ) HISTORICAL_PERMISSION_IDS = PERMISSION_IDS - NEW_PERMISSION_IDS + def _require_catalogue_counts() -> None: """Keep the closed action inventory and permission boundary exact.""" - if len(PERMISSION_IDS) != 82 or len(ACTION_IDS) != 178: + if len(PERMISSION_IDS) != 95 or len(ACTION_IDS) != 192: raise RuntimeError("authorization catalogue count mismatch") - if len(HISTORICAL_PERMISSION_IDS) != 49 or len(NEW_PERMISSION_IDS) != 33: + if len(HISTORICAL_PERMISSION_IDS) != 49 or len(NEW_PERMISSION_IDS) != 46: raise RuntimeError("authorization permission boundary mismatch") -POST_POLICY_MUTATION_ACTION_IDS = frozenset({ - ActionId.PROJECT_POST_SUBMIT_CHECKER_POLICY_APPROVE, - ActionId.PROJECT_POST_SUBMIT_CHECKER_POLICY_CORRECTION_REQUEST, - ActionId.PROJECT_POST_SUBMIT_CHECKER_POLICY_DERIVE, -}) -POST_POLICY_ACTION_IDS = POST_POLICY_MUTATION_ACTION_IDS | {ActionId.PROJECT_GUIDE_COMPILATION_REVIEW_PACKAGE_READ} -POST_POLICY_HUMAN_ACTION_IDS = POST_POLICY_ACTION_IDS - {ActionId.PROJECT_POST_SUBMIT_CHECKER_POLICY_DERIVE} +POST_POLICY_MUTATION_ACTION_IDS = frozenset( + { + ActionId.PROJECT_POST_SUBMIT_CHECKER_POLICY_APPROVE, + ActionId.PROJECT_POST_SUBMIT_CHECKER_POLICY_CORRECTION_REQUEST, + ActionId.PROJECT_POST_SUBMIT_CHECKER_POLICY_DERIVE, + } +) +POST_POLICY_ACTION_IDS = POST_POLICY_MUTATION_ACTION_IDS | { + ActionId.PROJECT_GUIDE_COMPILATION_REVIEW_PACKAGE_READ +} +POST_POLICY_HUMAN_ACTION_IDS = POST_POLICY_ACTION_IDS - { + ActionId.PROJECT_POST_SUBMIT_CHECKER_POLICY_DERIVE +} -GUIDE_PROPOSAL_ACTION_IDS = frozenset({ - ActionId.PROJECT_GUIDE_COMPILATION_REVIEW_PACKAGE_READ, - ActionId.PROJECT_GUIDE_COMPILATION_CORRECTION_REQUEST, - ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_APPROVE, -}) +GUIDE_PROPOSAL_ACTION_IDS = frozenset( + { + ActionId.PROJECT_GUIDE_COMPILATION_REVIEW_PACKAGE_READ, + ActionId.PROJECT_GUIDE_COMPILATION_CORRECTION_REQUEST, + ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_APPROVE, + } +) def _validate_action_definition_rows(definitions: tuple[ActionDefinition, ...]) -> None: @@ -870,96 +624,119 @@ def _validate_action_definitions(definitions: tuple[ActionDefinition, ...]) -> N raise RuntimeError("authorization action owner catalogue is incomplete") -_ACTIVE_ACTION_BOUNDARY = frozenset({ - ActionId.TASK_POST_SUBMIT_ROUTE, - ActionId.TASK_ASSIGNMENT_AUTHORITY_RECONCILE, - ActionId.TASK_REVISION_EXPIRE, - ActionId.TASK_REVISION_RECOVER, - ActionId.OUTBOX_DISPATCH, - ActionId.PROJECT_GUIDE_ACTIVATE, - *_CONTRIBUTION_POLICY_ACTION_IDS, - ActionId.CONTRIBUTION_COMPENSATION_UNIT_CREATE, - ActionId.REVIEW_LIFECYCLE_ACTIVATION_MANAGE, - *GUIDE_PROPOSAL_ACTION_IDS, - *POST_POLICY_MUTATION_ACTION_IDS, - ActionId.ACTOR_PROFILE_READ_SELF, - ActionId.ACTOR_PROFILE_UPDATE_SELF, - ActionId.AUTHORIZATION_PERMISSION_CATALOGUE_READ, - ActionId.AUTHORIZATION_ADMIN_ROLE_DEFINITIONS_READ, - ActionId.ADMIN_ROLE_GRANT_LIST, - ActionId.ACTOR_ADMIN_ROLE_GRANT_HISTORY_READ, - ActionId.ADMIN_ROLE_GRANT_ISSUE, - ActionId.ADMIN_ROLE_GRANT_REVOKE, - ActionId.ADMIN_ROLE_GRANT_BOOTSTRAP, - ActionId.ACTOR_PROFILE_READ, - ActionId.ACTOR_IDENTITY_LINK_READ, - ActionId.ACTOR_SERVICE_PROVISION, - ActionId.ACTOR_PROFILE_SUSPEND, - ActionId.ACTOR_PROFILE_REACTIVATE, - ActionId.ACTOR_PROFILE_DEACTIVATE, - ActionId.ACTOR_IDENTITY_LINK_REVOKE, - ActionId.ACTOR_IDENTITY_LINK_REACTIVATE, - ActionId.CHECKER_REGISTRY_READ, - ActionId.CHECKER_REGISTRY_REGISTER, - ActionId.PROJECT_CONTRIBUTOR_CANDIDATE_LIST, - ActionId.PROJECT_ROLE_GRANT_LIST, - ActionId.PROJECT_ROLE_GRANT_READ, - ActionId.PROJECT_ROLE_GRANT_ISSUE, - ActionId.PROJECT_ROLE_GRANT_REVOKE, - ActionId.PROJECT_CREATE, ActionId.PROJECT_GUIDE_CREATE, - ActionId.PROJECT_GUIDE_UPDATE, - ActionId.PROJECT_GUIDE_SOURCE_SNAPSHOT_CREATE, - ActionId.PROJECT_GUIDE_COMPILATION_REQUEST, - ActionId.PROJECT_GUIDE_COMPILATION_REQUEST_AUTOMATIC, - ActionId.PROJECT_GUIDE_COMPILATION_EXECUTE, - ActionId.PROJECT_SETUP_RUN_UPDATE, - ActionId.PROJECT_REVIEW_POLICY_UPDATE, - ActionId.PROJECT_REVISION_POLICY_UPDATE, - ActionId.PROJECT_GUIDE_SUFFICIENCY_REPORT_CREATE, - ActionId.PROJECT_GUIDE_SUFFICIENCY_RUN, - ActionId.PROJECT_GUIDE_SUFFICIENCY_WARNINGS_ACKNOWLEDGE, - ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_CREATE, - ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_DERIVE, - ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_UPDATE, - ActionId.PROJECT_READ, - ActionId.PROJECT_CATALOGUE_LIST, - ActionId.ACTOR_AUTHORIZATION_CONTEXT_READ, - ActionId.PROJECT_SETUP_RUN_READ, - ActionId.PROJECT_GUIDE_SUFFICIENCY_REPORT_LIST, - ActionId.PROJECT_GUIDE_SUFFICIENCY_REPORT_READ, - ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_LIST, - ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_READ, - ActionId.PROJECT_EFFECTIVE_SUBMISSION_ARTIFACT_POLICY_READ, - ActionId.PROJECT_PRE_SUBMIT_CHECKER_POLICY_READ, - ActionId.PROJECT_ACTIVE_GUIDE_READ, - ActionId.ARTIFACT_BINDING_READ, - ActionId.ARTIFACT_REPLICA_READ, - ActionId.ARTIFACT_RECEIPT_READ, - ActionId.ARTIFACT_VERIFICATION_JOB_READ, - ActionId.ARTIFACT_VERIFICATION_JOB_RETRY, - ActionId.ARTIFACT_RECOVERY_ATTEMPT_READ, - ActionId.ARTIFACT_AUDIT_READ, - ActionId.OPERATIONS_ARTIFACT_STORAGE_ADMISSION_READ, - ActionId.ARTIFACT_GUIDE_SOURCE_INGEST, - ActionId.ARTIFACT_GUIDE_SOURCE_READ, - ActionId.ARTIFACT_SUBMISSION_BUNDLE_PREPARE, - ActionId.SUBMISSION_CREATE, - ActionId.ARTIFACT_SUBMISSION_BINDING_CREATE, - ActionId.ARTIFACT_VERIFICATION_EXECUTE, - ActionId.ARTIFACT_PENDING_WORK_SCAN, - ActionId.ARTIFACT_PUT_ATTEMPT_RESOLVE, - ActionId.ARTIFACT_PRE_SUBMIT_CHECKER_INPUT_MATERIALIZE, - ActionId.ARTIFACT_POST_SUBMIT_CHECKER_INPUT_MATERIALIZE, - ActionId.CHECKER_POST_SUBMIT_EXECUTE, ActionId.CHECKER_POST_SUBMIT_FINALIZE, - ActionId.CHECKER_PRE_SUBMIT_EXECUTE, ActionId.CHECKER_PRE_SUBMIT_FINALIZE, - ActionId.COMPENSATION_ADAPTER_BINDING_READ, - ActionId.COMPENSATION_ADAPTER_BINDING_CREATE, - ActionId.COMPENSATION_ADAPTER_BINDING_SUSPEND, - ActionId.COMPENSATION_ADAPTER_BINDING_RESUME, -}) +_ACTIVE_ACTION_BOUNDARY = frozenset( + { + ActionId.TASK_POST_SUBMIT_ROUTE, + ActionId.TASK_ASSIGNMENT_AUTHORITY_RECONCILE, + ActionId.TASK_REVISION_EXPIRE, + ActionId.TASK_REVISION_RECOVER, + ActionId.OUTBOX_DISPATCH, + ActionId.PROJECT_GUIDE_ACTIVATE, + *_CONTRIBUTION_POLICY_ACTION_IDS, + ActionId.CONTRIBUTION_COMPENSATION_UNIT_CREATE, + ActionId.REVIEW_LIFECYCLE_ACTIVATION_MANAGE, + *GUIDE_PROPOSAL_ACTION_IDS, + *POST_POLICY_MUTATION_ACTION_IDS, + ActionId.ACTOR_PROFILE_READ_SELF, + ActionId.ACTOR_PROFILE_UPDATE_SELF, + ActionId.AUTHORIZATION_PERMISSION_CATALOGUE_READ, + ActionId.AUTHORIZATION_ADMIN_ROLE_DEFINITIONS_READ, + ActionId.ADMIN_ROLE_GRANT_LIST, + ActionId.ACTOR_ADMIN_ROLE_GRANT_HISTORY_READ, + ActionId.ADMIN_ROLE_GRANT_ISSUE, + ActionId.ADMIN_ROLE_GRANT_REVOKE, + ActionId.ADMIN_ROLE_GRANT_BOOTSTRAP, + ActionId.ACTOR_PROFILE_READ, + ActionId.ACTOR_IDENTITY_LINK_READ, + ActionId.ACTOR_SERVICE_PROVISION, + ActionId.ACTOR_PROFILE_SUSPEND, + ActionId.ACTOR_PROFILE_REACTIVATE, + ActionId.ACTOR_PROFILE_DEACTIVATE, + ActionId.ACTOR_IDENTITY_LINK_REVOKE, + ActionId.ACTOR_IDENTITY_LINK_REACTIVATE, + ActionId.CHECKER_REGISTRY_READ, + ActionId.CHECKER_REGISTRY_REGISTER, + ActionId.PROJECT_CONTRIBUTOR_CANDIDATE_LIST, + ActionId.PROJECT_ROLE_GRANT_LIST, + ActionId.PROJECT_ROLE_GRANT_READ, + ActionId.PROJECT_ROLE_GRANT_ISSUE, + ActionId.PROJECT_ROLE_GRANT_REVOKE, + ActionId.PROJECT_CREATE, + ActionId.PROJECT_GUIDE_CREATE, + ActionId.PROJECT_GUIDE_UPDATE, + ActionId.PROJECT_GUIDE_SOURCE_SNAPSHOT_CREATE, + ActionId.PROJECT_GUIDE_COMPILATION_REQUEST, + ActionId.PROJECT_GUIDE_COMPILATION_REQUEST_AUTOMATIC, + ActionId.PROJECT_GUIDE_COMPILATION_EXECUTE, + ActionId.PROJECT_SETUP_RUN_UPDATE, + ActionId.PROJECT_REVIEW_POLICY_UPDATE, + ActionId.PROJECT_REVISION_POLICY_UPDATE, + ActionId.PROJECT_GUIDE_SUFFICIENCY_REPORT_CREATE, + ActionId.PROJECT_GUIDE_SUFFICIENCY_RUN, + ActionId.PROJECT_GUIDE_SUFFICIENCY_WARNINGS_ACKNOWLEDGE, + ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_CREATE, + ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_DERIVE, + ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_UPDATE, + ActionId.PROJECT_READ, + ActionId.PROJECT_CATALOGUE_LIST, + ActionId.ACTOR_AUTHORIZATION_CONTEXT_READ, + ActionId.PROJECT_SETUP_RUN_READ, + ActionId.PROJECT_GUIDE_SUFFICIENCY_REPORT_LIST, + ActionId.PROJECT_GUIDE_SUFFICIENCY_REPORT_READ, + ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_LIST, + ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_READ, + ActionId.PROJECT_EFFECTIVE_SUBMISSION_ARTIFACT_POLICY_READ, + ActionId.PROJECT_PRE_SUBMIT_CHECKER_POLICY_READ, + ActionId.PROJECT_ACTIVE_GUIDE_READ, + ActionId.ARTIFACT_BINDING_READ, + ActionId.ARTIFACT_REPLICA_READ, + ActionId.ARTIFACT_RECEIPT_READ, + ActionId.ARTIFACT_VERIFICATION_JOB_READ, + ActionId.ARTIFACT_VERIFICATION_JOB_RETRY, + ActionId.ARTIFACT_RECOVERY_ATTEMPT_READ, + ActionId.ARTIFACT_AUDIT_READ, + ActionId.OPERATIONS_ARTIFACT_STORAGE_ADMISSION_READ, + ActionId.ARTIFACT_GUIDE_SOURCE_INGEST, + ActionId.ARTIFACT_GUIDE_SOURCE_READ, + ActionId.ARTIFACT_SUBMISSION_BUNDLE_PREPARE, + ActionId.SUBMISSION_CREATE, + ActionId.ARTIFACT_SUBMISSION_BINDING_CREATE, + ActionId.ARTIFACT_CHECKER_OUTPUT_BINDING_CREATE, + ActionId.ARTIFACT_VERIFICATION_EXECUTE, + ActionId.ARTIFACT_PENDING_WORK_SCAN, + ActionId.ARTIFACT_PUT_ATTEMPT_RESOLVE, + ActionId.ARTIFACT_PRE_SUBMIT_CHECKER_INPUT_MATERIALIZE, + ActionId.ARTIFACT_POST_SUBMIT_CHECKER_INPUT_MATERIALIZE, + ActionId.ARTIFACT_CHECKER_OUTPUT_WRITE, + ActionId.CHECKER_POST_SUBMIT_EXECUTE, + ActionId.CHECKER_POST_SUBMIT_FINALIZE, + ActionId.CHECKER_MODEL_CALL_PREPARE, + ActionId.CHECKER_MODEL_CALL_DISPATCH, + ActionId.CHECKER_MODEL_CALL_RECOVER, + ActionId.CHECKER_MODEL_CALL_OBSERVE, + ActionId.CHECKER_MODEL_CALL_COMPLETE, + ActionId.CHECKER_MODEL_CALL_GRANT, + ActionId.PROJECT_GUIDE_COMPILATION_MODEL_PREPARE, + ActionId.PROJECT_GUIDE_COMPILATION_MODEL_DISPATCH, + ActionId.PROJECT_GUIDE_COMPILATION_MODEL_OBSERVE, + ActionId.PROJECT_GUIDE_COMPILATION_MODEL_COMPLETE, + ActionId.PROJECT_MODEL_PROFILE_PROVISION, + ActionId.PROJECT_MODEL_BUDGET_CONFIGURE, + ActionId.PROJECT_MODEL_USAGE_READ, + ActionId.PROJECT_MODEL_CALL_RECOVERY_CHILD_AUTHORIZE, + ActionId.CHECKER_PRE_SUBMIT_EXECUTE, + ActionId.CHECKER_PRE_SUBMIT_FINALIZE, + ActionId.COMPENSATION_ADAPTER_BINDING_READ, + ActionId.COMPENSATION_ADAPTER_BINDING_CREATE, + ActionId.COMPENSATION_ADAPTER_BINDING_SUSPEND, + ActionId.COMPENSATION_ADAPTER_BINDING_RESUME, + } +) -def _index_actions(definitions: tuple[ActionDefinition, ...]) -> MappingProxyType[ActionId, ActionDefinition]: +def _index_actions( + definitions: tuple[ActionDefinition, ...], +) -> MappingProxyType[ActionId, ActionDefinition]: _validate_action_definition_rows(definitions) indexed = {definition.action_id: definition for definition in definitions} _require_catalogue_counts() @@ -970,17 +747,30 @@ def _index_actions(definitions: tuple[ActionDefinition, ...]) -> MappingProxyTyp for definition in definitions if definition.availability is ActionAvailability.ACTIVE } != _ACTIVE_ACTION_BOUNDARY | CONTRIBUTION_READ_ACTIONS | CONTRIBUTOR_READ_ACTIONS | { - ActionId.TASK_CLAIM, ActionId.TASK_START, ActionId.TASK_WORK_CONTEXT_READ, ActionId.TASK_GUIDE_READ, - ActionId.PROJECT_TASK_WORK_CONTEXT_READ, ActionId.OPERATIONS_TASK_START_OVERRIDE, - ActionId.PROJECT_TASK_CREATE, ActionId.PROJECT_TASK_SCREEN, ActionId.PROJECT_TASK_RELEASE, + ActionId.TASK_CLAIM, + ActionId.TASK_START, + ActionId.TASK_WORK_CONTEXT_READ, + ActionId.TASK_GUIDE_READ, + ActionId.PROJECT_TASK_WORK_CONTEXT_READ, + ActionId.OPERATIONS_TASK_START_OVERRIDE, + ActionId.PROJECT_TASK_CREATE, + ActionId.PROJECT_TASK_SCREEN, + ActionId.PROJECT_TASK_RELEASE, ActionId.PROJECT_TASK_REVISION_REPAIR, - ActionId.PROJECT_TASK_IMPORT, ActionId.PROJECT_TASK_IMPORT_BATCH_READ, - ActionId.ARTIFACT_TASK_IMPORT_SOURCE_DECLARE, ActionId.ARTIFACT_TASK_IMPORT_SOURCE_UPLOAD, + ActionId.PROJECT_TASK_IMPORT, + ActionId.PROJECT_TASK_IMPORT_BATCH_READ, + ActionId.ARTIFACT_TASK_IMPORT_SOURCE_DECLARE, + ActionId.ARTIFACT_TASK_IMPORT_SOURCE_UPLOAD, ActionId.ARTIFACT_TASK_IMPORT_SOURCE_READ, - ActionId.TASK_QUEUE_READ, ActionId.PROJECT_TASK_QUEUE_READ, ActionId.OPERATIONS_TASK_QUEUE_READ, - ActionId.TASK_READ, ActionId.TASK_SUBMISSION_REQUIREMENTS_READ, - ActionId.PROJECT_TASK_READ, ActionId.PROJECT_TASK_SUBMISSION_REQUIREMENTS_READ, - ActionId.AUDIT_TASK_EVIDENCE_READ, ActionId.OPERATIONS_CHECKER_RETRY, + ActionId.TASK_QUEUE_READ, + ActionId.PROJECT_TASK_QUEUE_READ, + ActionId.OPERATIONS_TASK_QUEUE_READ, + ActionId.TASK_READ, + ActionId.TASK_SUBMISSION_REQUIREMENTS_READ, + ActionId.PROJECT_TASK_READ, + ActionId.PROJECT_TASK_SUBMISSION_REQUIREMENTS_READ, + ActionId.AUDIT_TASK_EVIDENCE_READ, + ActionId.OPERATIONS_CHECKER_RETRY, ActionId.OPERATIONS_CHECKER_RETRY_SOURCE_LIST, ActionId.OPERATIONS_POST_SUBMIT_DELIVERY_LIST, ActionId.OPERATIONS_POST_SUBMIT_DELIVERY_RECONCILE, @@ -995,12 +785,30 @@ def _index_actions(definitions: tuple[ActionDefinition, ...]) -> MappingProxyTyp _SERVICE_ACTIONS = { ServiceIdentity.TASK_POST_SUBMIT_ROUTER: frozenset({ActionId.TASK_POST_SUBMIT_ROUTE}), - ServiceIdentity.CHECKER_PRE_SUBMIT: frozenset({ActionId.CHECKER_PRE_SUBMIT_EXECUTE, ActionId.CHECKER_PRE_SUBMIT_FINALIZE}), - ServiceIdentity.CHECKER_POST_SUBMIT: frozenset({ActionId.CHECKER_POST_SUBMIT_EXECUTE, ActionId.CHECKER_POST_SUBMIT_FINALIZE}), - ServiceIdentity.TASK_ASSIGNMENT_RECONCILER: frozenset({ActionId.TASK_ASSIGNMENT_AUTHORITY_RECONCILE}), - ServiceIdentity.TASK_REVISION_EXPIRER: frozenset({ - ActionId.TASK_REVISION_EXPIRE, ActionId.TASK_REVISION_RECOVER, - }), + ServiceIdentity.CHECKER_PRE_SUBMIT: frozenset( + {ActionId.CHECKER_PRE_SUBMIT_EXECUTE, ActionId.CHECKER_PRE_SUBMIT_FINALIZE} + ), + ServiceIdentity.CHECKER_POST_SUBMIT: frozenset( + { + ActionId.CHECKER_POST_SUBMIT_EXECUTE, + ActionId.CHECKER_POST_SUBMIT_FINALIZE, + ActionId.CHECKER_MODEL_CALL_PREPARE, + ActionId.CHECKER_MODEL_CALL_DISPATCH, + ActionId.CHECKER_MODEL_CALL_RECOVER, + ActionId.CHECKER_MODEL_CALL_OBSERVE, + ActionId.CHECKER_MODEL_CALL_COMPLETE, + ActionId.CHECKER_MODEL_CALL_GRANT, + } + ), + ServiceIdentity.TASK_ASSIGNMENT_RECONCILER: frozenset( + {ActionId.TASK_ASSIGNMENT_AUTHORITY_RECONCILE} + ), + ServiceIdentity.TASK_REVISION_EXPIRER: frozenset( + { + ActionId.TASK_REVISION_EXPIRE, + ActionId.TASK_REVISION_RECOVER, + } + ), ServiceIdentity.OUTBOX_DISPATCHER: frozenset({ActionId.OUTBOX_DISPATCH}), ServiceIdentity.ARTIFACT_VERIFIER: frozenset({ActionId.ARTIFACT_VERIFICATION_EXECUTE}), ServiceIdentity.ARTIFACT_PUT_RESOLVER: frozenset({ActionId.ARTIFACT_PUT_ATTEMPT_RESOLVE}), @@ -1029,6 +837,10 @@ def _index_actions(definitions: tuple[ActionDefinition, ...]) -> MappingProxyTyp ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_DERIVE, ActionId.PROJECT_POST_SUBMIT_CHECKER_POLICY_DERIVE, ActionId.PROJECT_SETUP_RUN_UPDATE, + ActionId.PROJECT_GUIDE_COMPILATION_MODEL_PREPARE, + ActionId.PROJECT_GUIDE_COMPILATION_MODEL_DISPATCH, + ActionId.PROJECT_GUIDE_COMPILATION_MODEL_OBSERVE, + ActionId.PROJECT_GUIDE_COMPILATION_MODEL_COMPLETE, } ), ServiceIdentity.REVIEW_PREFERENCE_EXPIRY: frozenset({ActionId.REVIEW_PREFERENCE_EXPIRY_RUN}), @@ -1043,9 +855,7 @@ def _index_actions(definitions: tuple[ActionDefinition, ...]) -> MappingProxyTyp ServiceIdentity.REVIEW_PROJECTION: frozenset({ActionId.REVIEW_PROJECTION_REBUILD}), } -ACTION_BEARING_SERVICE_IDENTITIES = SERVICE_IDENTITIES - { - ServiceIdentity.COMPENSATION_ADAPTER -} +ACTION_BEARING_SERVICE_IDENTITIES = SERVICE_IDENTITIES - {ServiceIdentity.COMPENSATION_ADAPTER} _EXPECTED_SERVICE_ACTION_MEMBERSHIPS = frozenset( (identity, action) @@ -1055,6 +865,12 @@ def _index_actions(definitions: tuple[ActionDefinition, ...]) -> MappingProxyTyp (ServiceIdentity.CHECKER_PRE_SUBMIT, ActionId.CHECKER_PRE_SUBMIT_FINALIZE), (ServiceIdentity.CHECKER_POST_SUBMIT, ActionId.CHECKER_POST_SUBMIT_EXECUTE), (ServiceIdentity.CHECKER_POST_SUBMIT, ActionId.CHECKER_POST_SUBMIT_FINALIZE), + (ServiceIdentity.CHECKER_POST_SUBMIT, ActionId.CHECKER_MODEL_CALL_PREPARE), + (ServiceIdentity.CHECKER_POST_SUBMIT, ActionId.CHECKER_MODEL_CALL_DISPATCH), + (ServiceIdentity.CHECKER_POST_SUBMIT, ActionId.CHECKER_MODEL_CALL_RECOVER), + (ServiceIdentity.CHECKER_POST_SUBMIT, ActionId.CHECKER_MODEL_CALL_OBSERVE), + (ServiceIdentity.CHECKER_POST_SUBMIT, ActionId.CHECKER_MODEL_CALL_COMPLETE), + (ServiceIdentity.CHECKER_POST_SUBMIT, ActionId.CHECKER_MODEL_CALL_GRANT), (ServiceIdentity.TASK_ASSIGNMENT_RECONCILER, ActionId.TASK_ASSIGNMENT_AUTHORITY_RECONCILE), (ServiceIdentity.TASK_REVISION_EXPIRER, ActionId.TASK_REVISION_EXPIRE), (ServiceIdentity.TASK_REVISION_EXPIRER, ActionId.TASK_REVISION_RECOVER), @@ -1082,6 +898,10 @@ def _index_actions(definitions: tuple[ActionDefinition, ...]) -> MappingProxyTyp (ServiceIdentity.PROJECT_SETUP, ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_DERIVE), (ServiceIdentity.PROJECT_SETUP, ActionId.PROJECT_POST_SUBMIT_CHECKER_POLICY_DERIVE), (ServiceIdentity.PROJECT_SETUP, ActionId.PROJECT_SETUP_RUN_UPDATE), + (ServiceIdentity.PROJECT_SETUP, ActionId.PROJECT_GUIDE_COMPILATION_MODEL_PREPARE), + (ServiceIdentity.PROJECT_SETUP, ActionId.PROJECT_GUIDE_COMPILATION_MODEL_DISPATCH), + (ServiceIdentity.PROJECT_SETUP, ActionId.PROJECT_GUIDE_COMPILATION_MODEL_OBSERVE), + (ServiceIdentity.PROJECT_SETUP, ActionId.PROJECT_GUIDE_COMPILATION_MODEL_COMPLETE), (ServiceIdentity.REVIEW_PREFERENCE_EXPIRY, ActionId.REVIEW_PREFERENCE_EXPIRY_RUN), (ServiceIdentity.REVIEW_LEASE_EXPIRY, ActionId.REVIEW_LEASE_EXPIRY_RUN), ( @@ -1104,6 +924,16 @@ def _index_actions(definitions: tuple[ActionDefinition, ...]) -> MappingProxyTyp ActionId.TASK_POST_SUBMIT_ROUTE, ActionId.CHECKER_POST_SUBMIT_EXECUTE, ActionId.CHECKER_POST_SUBMIT_FINALIZE, + ActionId.CHECKER_MODEL_CALL_PREPARE, + ActionId.CHECKER_MODEL_CALL_DISPATCH, + ActionId.CHECKER_MODEL_CALL_RECOVER, + ActionId.CHECKER_MODEL_CALL_OBSERVE, + ActionId.CHECKER_MODEL_CALL_COMPLETE, + ActionId.CHECKER_MODEL_CALL_GRANT, + ActionId.PROJECT_GUIDE_COMPILATION_MODEL_PREPARE, + ActionId.PROJECT_GUIDE_COMPILATION_MODEL_DISPATCH, + ActionId.PROJECT_GUIDE_COMPILATION_MODEL_OBSERVE, + ActionId.PROJECT_GUIDE_COMPILATION_MODEL_COMPLETE, ActionId.ARTIFACT_POST_SUBMIT_CHECKER_INPUT_MATERIALIZE, ActionId.TASK_ASSIGNMENT_AUTHORITY_RECONCILE, ActionId.TASK_REVISION_EXPIRE, @@ -1115,8 +945,10 @@ def _index_actions(definitions: tuple[ActionDefinition, ...]) -> MappingProxyTyp ActionId.ARTIFACT_PUT_ATTEMPT_RESOLVE, ActionId.ARTIFACT_PENDING_WORK_SCAN, ActionId.ARTIFACT_SUBMISSION_BINDING_CREATE, + ActionId.ARTIFACT_CHECKER_OUTPUT_BINDING_CREATE, ActionId.ARTIFACT_GUIDE_SOURCE_READ, ActionId.ARTIFACT_PRE_SUBMIT_CHECKER_INPUT_MATERIALIZE, + ActionId.ARTIFACT_CHECKER_OUTPUT_WRITE, ActionId.PROJECT_GUIDE_SUFFICIENCY_RUN, ActionId.PROJECT_GUIDE_COMPILATION_EXECUTE, ActionId.PROJECT_GUIDE_COMPILATION_REQUEST_AUTOMATIC, @@ -1124,53 +956,182 @@ def _index_actions(definitions: tuple[ActionDefinition, ...]) -> MappingProxyTyp } +_SERVICE_ACTION_METADATA = { + ActionId.TASK_POST_SUBMIT_ROUTE: ( + PermissionId.TASK_POST_SUBMIT_ROUTE, + ActionOwner.ARCH_04E2, + ), + ActionId.CHECKER_PRE_SUBMIT_EXECUTE: ( + PermissionId.CHECKER_PRE_SUBMIT_EXECUTE, + ActionOwner.PILOT_04, + ), + ActionId.CHECKER_PRE_SUBMIT_FINALIZE: ( + PermissionId.CHECKER_PRE_SUBMIT_FINALIZE, + ActionOwner.PILOT_04, + ), + ActionId.CHECKER_POST_SUBMIT_EXECUTE: ( + PermissionId.CHECKER_POST_SUBMIT_EXECUTE, + ActionOwner.ARCH_04D2, + ), + ActionId.CHECKER_POST_SUBMIT_FINALIZE: ( + PermissionId.CHECKER_POST_SUBMIT_FINALIZE, + ActionOwner.ARCH_04D2, + ), + ActionId.CHECKER_MODEL_CALL_PREPARE: ( + PermissionId.CHECKER_MODEL_CALL_PREPARE, + ActionOwner.PILOT_05, + ), + ActionId.CHECKER_MODEL_CALL_DISPATCH: ( + PermissionId.CHECKER_MODEL_CALL_DISPATCH, + ActionOwner.PILOT_05, + ), + ActionId.CHECKER_MODEL_CALL_RECOVER: ( + PermissionId.CHECKER_MODEL_CALL_RECOVER, + ActionOwner.PILOT_05, + ), + ActionId.CHECKER_MODEL_CALL_OBSERVE: ( + PermissionId.CHECKER_MODEL_CALL_OBSERVE, + ActionOwner.PILOT_05, + ), + ActionId.CHECKER_MODEL_CALL_COMPLETE: ( + PermissionId.CHECKER_MODEL_CALL_COMPLETE, + ActionOwner.PILOT_05, + ), + ActionId.CHECKER_MODEL_CALL_GRANT: ( + PermissionId.CHECKER_MODEL_CALL_GRANT, + ActionOwner.PILOT_05, + ), + ActionId.PROJECT_GUIDE_COMPILATION_MODEL_PREPARE: ( + PermissionId.PROJECT_GUIDE_COMPILATION_MODEL_PREPARE, + ActionOwner.PILOT_05, + ), + ActionId.PROJECT_GUIDE_COMPILATION_MODEL_DISPATCH: ( + PermissionId.PROJECT_GUIDE_COMPILATION_MODEL_DISPATCH, + ActionOwner.PILOT_05, + ), + ActionId.PROJECT_GUIDE_COMPILATION_MODEL_OBSERVE: ( + PermissionId.PROJECT_GUIDE_COMPILATION_MODEL_OBSERVE, + ActionOwner.PILOT_05, + ), + ActionId.PROJECT_GUIDE_COMPILATION_MODEL_COMPLETE: ( + PermissionId.PROJECT_GUIDE_COMPILATION_MODEL_COMPLETE, + ActionOwner.PILOT_05, + ), + ActionId.TASK_ASSIGNMENT_AUTHORITY_RECONCILE: ( + PermissionId.TASK_ASSIGNMENT_AUTHORITY_RECONCILE, + ActionOwner.ARCH_03C1, + ), + ActionId.TASK_REVISION_EXPIRE: (PermissionId.TASK_REVISION_EXPIRE, ActionOwner.PILOT_08), + ActionId.TASK_REVISION_RECOVER: (PermissionId.TASK_REVISION_RECOVER, ActionOwner.PILOT_08), + ActionId.OUTBOX_DISPATCH: (PermissionId.OUTBOX_DISPATCH, ActionOwner.AUTH_OUTBOX_01), + ActionId.ARTIFACT_VERIFICATION_EXECUTE: ( + PermissionId.ARTIFACT_VERIFICATION_EXECUTE, + ActionOwner.AUTH_ART_02D_INTERNAL, + ), + ActionId.ARTIFACT_PUT_ATTEMPT_RESOLVE: ( + PermissionId.ARTIFACT_PUT_ATTEMPT_RESOLVE, + ActionOwner.AUTH_ART_02D_INTERNAL, + ), + ActionId.ARTIFACT_PENDING_WORK_SCAN: ( + PermissionId.ARTIFACT_PENDING_WORK_SCAN, + ActionOwner.AUTH_ART_02D_INTERNAL, + ), + ActionId.ARTIFACT_SUBMISSION_BINDING_CREATE: ( + PermissionId.ARTIFACT_BINDING_CREATE, + ActionOwner.AUTH_ART_05, + ), + ActionId.ARTIFACT_CHECKER_OUTPUT_BINDING_CREATE: ( + PermissionId.ARTIFACT_BINDING_CREATE, + ActionOwner.AUTH_ART_06B, + ), + ActionId.ARTIFACT_GUIDE_SOURCE_READ: ( + PermissionId.ARTIFACT_GUIDE_SOURCE_READ, + ActionOwner.XINT_002_04B, + ), + ActionId.ARTIFACT_PRE_SUBMIT_CHECKER_INPUT_MATERIALIZE: ( + PermissionId.ARTIFACT_CHECKER_INPUT_MATERIALIZE, + ActionOwner.XINT_002_06A, + ), + ActionId.ARTIFACT_POST_SUBMIT_CHECKER_INPUT_MATERIALIZE: ( + PermissionId.ARTIFACT_CHECKER_INPUT_MATERIALIZE, + ActionOwner.AUTH_ART_06A, + ), + ActionId.ARTIFACT_CHECKER_OUTPUT_WRITE: ( + PermissionId.ARTIFACT_CHECKER_OUTPUT_WRITE, + ActionOwner.AUTH_ART_06B, + ), + ActionId.ARTIFACT_REVIEW_PACKET_MATERIALIZE: ( + PermissionId.ARTIFACT_REVIEW_PACKET_MATERIALIZE, + ActionOwner.XINT_002_07, + ), + ActionId.ARTIFACT_REVIEW_EVIDENCE_BINDING_CREATE: ( + PermissionId.ARTIFACT_BINDING_CREATE, + ActionOwner.XINT_002_07, + ), + ActionId.PROJECT_GUIDE_SUFFICIENCY_RUN: ( + PermissionId.PROJECT_GUIDE_MANAGE, + ActionOwner.AUTH_12E, + ), + ActionId.PROJECT_GUIDE_COMPILATION_REQUEST_AUTOMATIC: ( + PermissionId.PROJECT_GUIDE_COMPILATION_EXECUTE, + ActionOwner.AUTH_12I, + ), + ActionId.PROJECT_GUIDE_COMPILATION_EXECUTE: ( + PermissionId.PROJECT_GUIDE_COMPILATION_EXECUTE, + ActionOwner.AUTH_12I, + ), + ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_DERIVE: ( + PermissionId.PROJECT_EFFECTIVE_POLICY_MANAGE, + ActionOwner.AUTH_12F3, + ), + ActionId.PROJECT_POST_SUBMIT_CHECKER_POLICY_DERIVE: ( + PermissionId.PROJECT_EFFECTIVE_POLICY_MANAGE, + ActionOwner.AUTH_12G, + ), + ActionId.PROJECT_SETUP_RUN_UPDATE: ( + PermissionId.PROJECT_GUIDE_MANAGE, + ActionOwner.AUTH_12B2, + ), + ActionId.REVIEW_PREFERENCE_EXPIRY_RUN: ( + PermissionId.OPERATIONS_TIMER_RUN, + ActionOwner.AUTH_REV_06, + ), + ActionId.REVIEW_LEASE_EXPIRY_RUN: ( + PermissionId.OPERATIONS_TIMER_RUN, + ActionOwner.AUTH_REV_06, + ), + ActionId.REVIEW_RECONCILE_RUN: ( + PermissionId.OPERATIONS_RECONCILE_RUN, + ActionOwner.AUTH_REV_11, + ), + ActionId.REVIEW_ARTIFACT_REFERENCE_RECONCILE: ( + PermissionId.OPERATIONS_RECONCILE_RUN, + ActionOwner.AUTH_REV_12, + ), + ActionId.REVIEW_PROJECTION_REBUILD: ( + PermissionId.OPERATIONS_PROJECTION_REBUILD, + ActionOwner.AUTH_REV_12, + ), +} + + def _index_service_actions( rows: dict[ServiceIdentity, frozenset[ActionId]], ) -> MappingProxyType[ServiceIdentity, frozenset[ActionId]]: - expected_metadata = { - ActionId.TASK_POST_SUBMIT_ROUTE: (PermissionId.TASK_POST_SUBMIT_ROUTE, ActionOwner.ARCH_04E2), - ActionId.CHECKER_PRE_SUBMIT_EXECUTE: (PermissionId.CHECKER_PRE_SUBMIT_EXECUTE, ActionOwner.PILOT_04), - ActionId.CHECKER_PRE_SUBMIT_FINALIZE: (PermissionId.CHECKER_PRE_SUBMIT_FINALIZE, ActionOwner.PILOT_04), - ActionId.CHECKER_POST_SUBMIT_EXECUTE: (PermissionId.CHECKER_POST_SUBMIT_EXECUTE, ActionOwner.ARCH_04D2), - ActionId.CHECKER_POST_SUBMIT_FINALIZE: (PermissionId.CHECKER_POST_SUBMIT_FINALIZE, ActionOwner.ARCH_04D2), - ActionId.TASK_ASSIGNMENT_AUTHORITY_RECONCILE: (PermissionId.TASK_ASSIGNMENT_AUTHORITY_RECONCILE, ActionOwner.ARCH_03C1), - ActionId.TASK_REVISION_EXPIRE: (PermissionId.TASK_REVISION_EXPIRE, ActionOwner.PILOT_08), - ActionId.TASK_REVISION_RECOVER: (PermissionId.TASK_REVISION_RECOVER, ActionOwner.PILOT_08), - ActionId.OUTBOX_DISPATCH: (PermissionId.OUTBOX_DISPATCH, ActionOwner.AUTH_OUTBOX_01), - ActionId.ARTIFACT_VERIFICATION_EXECUTE: (PermissionId.ARTIFACT_VERIFICATION_EXECUTE, ActionOwner.AUTH_ART_02D_INTERNAL), - ActionId.ARTIFACT_PUT_ATTEMPT_RESOLVE: (PermissionId.ARTIFACT_PUT_ATTEMPT_RESOLVE, ActionOwner.AUTH_ART_02D_INTERNAL), - ActionId.ARTIFACT_PENDING_WORK_SCAN: (PermissionId.ARTIFACT_PENDING_WORK_SCAN, ActionOwner.AUTH_ART_02D_INTERNAL), - ActionId.ARTIFACT_SUBMISSION_BINDING_CREATE: (PermissionId.ARTIFACT_BINDING_CREATE, ActionOwner.AUTH_ART_05), - ActionId.ARTIFACT_CHECKER_OUTPUT_BINDING_CREATE: (PermissionId.ARTIFACT_BINDING_CREATE, ActionOwner.AUTH_ART_06B), - ActionId.ARTIFACT_GUIDE_SOURCE_READ: (PermissionId.ARTIFACT_GUIDE_SOURCE_READ, ActionOwner.XINT_002_04B), - ActionId.ARTIFACT_PRE_SUBMIT_CHECKER_INPUT_MATERIALIZE: (PermissionId.ARTIFACT_CHECKER_INPUT_MATERIALIZE, ActionOwner.XINT_002_06A), - ActionId.ARTIFACT_POST_SUBMIT_CHECKER_INPUT_MATERIALIZE: (PermissionId.ARTIFACT_CHECKER_INPUT_MATERIALIZE, ActionOwner.AUTH_ART_06A), - ActionId.ARTIFACT_CHECKER_OUTPUT_WRITE: (PermissionId.ARTIFACT_CHECKER_OUTPUT_WRITE, ActionOwner.AUTH_ART_06B), - ActionId.ARTIFACT_REVIEW_PACKET_MATERIALIZE: (PermissionId.ARTIFACT_REVIEW_PACKET_MATERIALIZE, ActionOwner.XINT_002_07), - ActionId.ARTIFACT_REVIEW_EVIDENCE_BINDING_CREATE: (PermissionId.ARTIFACT_BINDING_CREATE, ActionOwner.XINT_002_07), - ActionId.PROJECT_GUIDE_SUFFICIENCY_RUN: (PermissionId.PROJECT_GUIDE_MANAGE, ActionOwner.AUTH_12E), - ActionId.PROJECT_GUIDE_COMPILATION_REQUEST_AUTOMATIC: (PermissionId.PROJECT_GUIDE_COMPILATION_EXECUTE, ActionOwner.AUTH_12I), - ActionId.PROJECT_GUIDE_COMPILATION_EXECUTE: (PermissionId.PROJECT_GUIDE_COMPILATION_EXECUTE, ActionOwner.AUTH_12I), - ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_DERIVE: (PermissionId.PROJECT_EFFECTIVE_POLICY_MANAGE, ActionOwner.AUTH_12F3), - ActionId.PROJECT_POST_SUBMIT_CHECKER_POLICY_DERIVE: (PermissionId.PROJECT_EFFECTIVE_POLICY_MANAGE, ActionOwner.AUTH_12G), - ActionId.PROJECT_SETUP_RUN_UPDATE: (PermissionId.PROJECT_GUIDE_MANAGE, ActionOwner.AUTH_12B2), - ActionId.REVIEW_PREFERENCE_EXPIRY_RUN: (PermissionId.OPERATIONS_TIMER_RUN, ActionOwner.AUTH_REV_06), - ActionId.REVIEW_LEASE_EXPIRY_RUN: (PermissionId.OPERATIONS_TIMER_RUN, ActionOwner.AUTH_REV_06), - ActionId.REVIEW_RECONCILE_RUN: (PermissionId.OPERATIONS_RECONCILE_RUN, ActionOwner.AUTH_REV_11), - ActionId.REVIEW_ARTIFACT_REFERENCE_RECONCILE: (PermissionId.OPERATIONS_RECONCILE_RUN, ActionOwner.AUTH_REV_12), - ActionId.REVIEW_PROJECTION_REBUILD: (PermissionId.OPERATIONS_PROJECTION_REBUILD, ActionOwner.AUTH_REV_12), - } if set(rows) != ACTION_BEARING_SERVICE_IDENTITIES: raise RuntimeError("service action matrix identity mismatch") if any(not actions for actions in rows.values()): raise RuntimeError("service action matrix row mismatch") - if frozenset((i, a) for i, actions in rows.items() for a in actions) != _EXPECTED_SERVICE_ACTION_MEMBERSHIPS: + if ( + frozenset((i, a) for i, actions in rows.items() for a in actions) + != _EXPECTED_SERVICE_ACTION_MEMBERSHIPS + ): raise RuntimeError("service action matrix row mismatch") if not FUTURE_INTENT_REQUIRED_ACTIONS.isdisjoint( action for actions in rows.values() for action in actions ): raise RuntimeError("future-intent action cannot enter the service matrix") - for action, (permission, owner) in expected_metadata.items(): + for action, (permission, owner) in _SERVICE_ACTION_METADATA.items(): definition = ACTION_BY_ID[action] expected_availability = ( ActionAvailability.ACTIVE diff --git a/backend/app/modules/authorization/checker_output_authorization.py b/backend/app/modules/authorization/checker_output_authorization.py new file mode 100644 index 000000000..790618020 --- /dev/null +++ b/backend/app/modules/authorization/checker_output_authorization.py @@ -0,0 +1,263 @@ +"""Prepared AUTH adapters for private checker-model output custody.""" + +from __future__ import annotations + +from app.modules.actors.api import ServiceIdentity +from app.modules.artifacts.api.checker_output_authority import ( + CheckerOutputBindingAuthority, + CheckerOutputCommitmentAuthorityFacts, + CheckerOutputStoredFacts, + CheckerOutputWriteAuthority, +) +from app.modules.authorization.catalogue import ( + ACTION_BY_ID, + SERVICE_ACTIONS_BY_IDENTITY, + ActionAvailability, + ActionId, +) +from app.modules.authorization.domain.checker_outputs import ( + BIND, + WRITE, + checker_output_admission_resource, + checker_output_prepare_resource, + checker_output_stored_resource, +) +from app.modules.authorization.kernel import AuthorizationService +from app.modules.authorization.prepared import ( + PreparedAuthorizationHandle, + PreparedAuthorizationService, + fixed_service_authorization_context, + fixed_service_context_revalidator, +) +from app.modules.authorization.repository import AdminAuthorizationRepository +from app.modules.authorization.runtime import ( + AuthorizationDenied, + AuthorizationEvidenceUnavailable, + PreparedAuthorizationHandleInvalid, + PreparedAuthorizationInput, + PreparedAuthorizationUnsupported, + PreparedAuthorityScope, + PreparedAuthorityScopeKind, +) +from app.modules.checkers.api.output_custody import ( + GENERATED_OUTPUT_RESERVATION, + GENERATED_OUTPUT_SELECTOR, + GeneratedOutputReservation, + GeneratedOutputSelector, + CheckerOutputUnavailable, +) +from app.modules.model_usage.api import ModelCallUnavailable + + +_AUTH_ERRORS = ( + AuthorizationDenied, + AuthorizationEvidenceUnavailable, + PreparedAuthorizationHandleInvalid, + PreparedAuthorizationUnsupported, +) + + +class _PreparedCheckerOutputAuthorization: + """Own one transaction-local capability from selector through ART facts.""" + + __slots__ = ( + "_action", + "_handle", + "_input", + "_prepared", + "_selector", + "_service_identity", + "_session", + ) + + def __init__( + self, + session, + *, + action: ActionId, + service_identity: ServiceIdentity, + ) -> None: + self._session = session + self._action = action + self._service_identity = service_identity + self._prepared: PreparedAuthorizationService | None = None + self._handle: PreparedAuthorizationHandle | None = None + self._input: PreparedAuthorizationInput | None = None + self._selector: GeneratedOutputSelector | None = None + + async def preflight(self, selector: GeneratedOutputSelector) -> None: + """Reject malformed or unavailable action shape before protected reads.""" + try: + GENERATED_OUTPUT_SELECTOR.validate_python(selector) + action = ACTION_BY_ID[self._action] + if ( + action.availability is not ActionAvailability.ACTIVE + or self._action not in SERVICE_ACTIONS_BY_IDENTITY.get(self._service_identity, ()) + ): + raise ValueError("checker output action unavailable") + except (KeyError, TypeError, ValueError) as error: + raise CheckerOutputUnavailable("checker_output_authority_unavailable") from error + + async def prepare(self, selector: GeneratedOutputSelector) -> None: + """Lock one fixed-service grant before CHECKERS or ART feature rows.""" + self.discard() + if not self._session.in_transaction() or self._session.in_nested_transaction(): + raise CheckerOutputUnavailable("checker_output_authority_unavailable") + prepared: PreparedAuthorizationService | None = None + try: + resource = checker_output_prepare_resource(self._action, selector) + context = await fixed_service_authorization_context( + self._session, + self._service_identity, + resource.resource_id, + resource.resource_id, + ) + repository = AdminAuthorizationRepository(self._session) + authorization = AuthorizationService( + self._session, + context, + revalidate_service=fixed_service_context_revalidator( + repository, self._service_identity + ), + admin_repository=repository, + ) + prepared = PreparedAuthorizationService( + self._session, context, authorization, repository + ) + caller_input = PreparedAuthorizationInput( + idempotency_key=resource.resource_id, + request_value=resource.model_dump(mode="json"), + ) + handle = await prepared.prepare( + self._action, + caller_input, + PreparedAuthorityScope( + kind=PreparedAuthorityScopeKind.PROJECT, + project_id=resource.scope_project_id, + ), + ) + except _AUTH_ERRORS as error: + if prepared is not None: + prepared.close() + raise CheckerOutputUnavailable("checker_output_authority_unavailable") from error + except (TypeError, ValueError) as error: + if prepared is not None: + prepared.close() + raise CheckerOutputUnavailable("checker_output_authority_unavailable") from error + self._prepared = prepared + self._handle = handle + self._input = caller_input + self._selector = resource.selector + + async def _consume(self, resource) -> None: + prepared, handle, caller_input = self._prepared, self._handle, self._input + if ( + prepared is None + or handle is None + or caller_input is None + or self._selector is None + or resource.selector != self._selector + ): + raise CheckerOutputUnavailable("checker_output_authority_unavailable") + try: + await prepared.consume(handle, self._action, caller_input, resource) + except _AUTH_ERRORS as error: + raise CheckerOutputUnavailable("checker_output_authority_unavailable") from error + finally: + self.discard() + + def discard(self) -> None: + """Invalidate every unconsumed transaction-local capability.""" + if self._prepared is not None: + self._prepared.close() + self._prepared = None + self._handle = None + self._input = None + self._selector = None + + +class PreparedCheckerOutputWriteAuthorization( + _PreparedCheckerOutputAuthorization, CheckerOutputWriteAuthority +): + """Authorize exact private request/response evidence storage and recovery.""" + + def __init__(self, session) -> None: + super().__init__( + session, + action=WRITE, + service_identity=ServiceIdentity.ARTIFACT_CHECKER_OUTPUT, + ) + + async def consume(self, facts: CheckerOutputCommitmentAuthorityFacts) -> None: + """Consume server-computed commitment facts without receiving raw bytes.""" + if type(facts) is not CheckerOutputCommitmentAuthorityFacts: + raise CheckerOutputUnavailable("checker_output_authority_unavailable") + selector = self._require_selector() + try: + reservation = GENERATED_OUTPUT_RESERVATION.validate_python(facts.reservation) + slot = reservation.select(selector) + except (CheckerOutputUnavailable, ModelCallUnavailable, TypeError, ValueError) as error: + self.discard() + raise CheckerOutputUnavailable("checker_output_authority_unavailable") from error + if facts.slot_key != slot.key: + self.discard() + raise CheckerOutputUnavailable("checker_output_authority_unavailable") + try: + resource = checker_output_admission_resource(selector, facts) + except (TypeError, ValueError) as error: + self.discard() + raise CheckerOutputUnavailable("checker_output_authority_unavailable") from error + await self._consume(resource) + + async def consume_existing( + self, + reservation: GeneratedOutputReservation, + facts: CheckerOutputStoredFacts, + ) -> None: + """Consume exact stored ancestry selected under the prepared selector.""" + selector = self._require_selector() + try: + resource = checker_output_stored_resource(WRITE, selector, reservation, facts) + except (TypeError, ValueError) as error: + self.discard() + raise CheckerOutputUnavailable("checker_output_authority_unavailable") from error + await self._consume(resource) + + def _require_selector(self) -> GeneratedOutputSelector: + if self._selector is None: + raise CheckerOutputUnavailable("checker_output_authority_unavailable") + return self._selector + + +class PreparedCheckerOutputBindingAuthorization( + _PreparedCheckerOutputAuthorization, CheckerOutputBindingAuthority +): + """Authorize one binding from verified call-stable evidence.""" + + def __init__(self, session) -> None: + super().__init__( + session, + action=BIND, + service_identity=ServiceIdentity.ARTIFACT_BINDING, + ) + + async def consume( + self, + reservation: GeneratedOutputReservation, + facts: CheckerOutputStoredFacts, + ) -> None: + """Consume only complete verified ancestry for the prepared selector.""" + if self._selector is None: + raise CheckerOutputUnavailable("checker_output_authority_unavailable") + try: + resource = checker_output_stored_resource(BIND, self._selector, reservation, facts) + except (TypeError, ValueError) as error: + self.discard() + raise CheckerOutputUnavailable("checker_output_authority_unavailable") from error + await self._consume(resource) + + +__all__ = ( + "PreparedCheckerOutputBindingAuthorization", + "PreparedCheckerOutputWriteAuthorization", +) diff --git a/backend/app/modules/authorization/domain/action_groups.py b/backend/app/modules/authorization/domain/action_groups.py index 5bb04715e..6877c02c9 100644 --- a/backend/app/modules/authorization/domain/action_groups.py +++ b/backend/app/modules/authorization/domain/action_groups.py @@ -3,7 +3,13 @@ from app.modules.authorization.domain.task_authority import TASK_IMPORT_BATCH_ACTIONS, TaskImportBatchResourceContext from app.modules.authorization.catalogue import ActionId -from app.modules.authorization.domain import adapter_bindings, checker_registry, contribution_policies +from app.modules.authorization.domain import ( + adapter_bindings, + model_usage as checker_models, + checker_outputs, + checker_registry, + contribution_policies, +) from app.modules.authorization.domain.artifact_operator import ARTIFACT_OPERATOR_ACTIONS from app.modules.authorization.runtime import PROJECT_SUBMISSION_POLICY_TARGET_KIND_BY_ACTION @@ -23,6 +29,7 @@ ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_UPDATE, } ) +SYSTEM_OPERATOR_MODEL_MUTATIONS = checker_models.MODEL_ADMIN_ACTIONS GUIDE_BOUND_PROJECT_MANAGER_ACTIONS |= GUIDE_PROPOSAL_ACTION_IDS | POST_POLICY_HUMAN_ACTION_IDS EXACT_PROJECT_MANAGER_SCOPE_ACTIONS = frozenset({ ActionId.PROJECT_GUIDE_COMPILATION_REQUEST, ActionId.PROJECT_GUIDE_ACTIVATE, @@ -56,6 +63,8 @@ *POST_POLICY_MUTATION_ACTION_IDS, *adapter_bindings.ADAPTER_BINDING_ACTIONS, *contribution_policies.CONTRIBUTION_POLICY_ACTIONS, + *checker_models.CHECKER_MODEL_ACTIONS, + *checker_outputs.CHECKER_OUTPUT_ACTIONS, *ARTIFACT_OPERATOR_ACTIONS, } ) @@ -74,6 +83,17 @@ def supports_prepared_denial(action_id: ActionId, resource_context: object) -> b ProjectPolicyMutationPrepareDenialResourceContext, ProjectSubmissionArtifactPolicyMutationResourceContext, ) return ( + ( + action_id in checker_outputs.CHECKER_OUTPUT_ACTIONS + and type(resource_context) is checker_outputs.CheckerOutputResourceContext + ) + or + ( + action_id in checker_models.CHECKER_MODEL_ACTIONS + and type(resource_context) + is checker_models.CHECKER_MODEL_RESOURCE_BY_ACTION[action_id] + ) + or ( action_id in checker_registry.CHECKER_REGISTRY_MUTATION_ACTIONS and type(resource_context) diff --git a/backend/app/modules/authorization/domain/audit.py b/backend/app/modules/authorization/domain/audit.py index a13ddb074..9c93e3157 100644 --- a/backend/app/modules/authorization/domain/audit.py +++ b/backend/app/modules/authorization/domain/audit.py @@ -24,6 +24,11 @@ "task_submission_history", "submission_history", "checker_history", + "checker_model_call", + "checker_model_output", + "setup_model_usage", + "setup_model_call", + "project_model_administration", "artifact_put_attempt", "artifact_verification_job", "artifact_pending_work", @@ -83,6 +88,11 @@ "task_submission_history", "submission_history", "checker_history", + "checker_model_call", + "checker_model_output", + "setup_model_usage", + "setup_model_call", + "project_model_administration", "actor_profile", "actor_authorization_context", "project", diff --git a/backend/app/modules/authorization/domain/audit_targets.py b/backend/app/modules/authorization/domain/audit_targets.py index 5dc54eb34..624239a67 100644 --- a/backend/app/modules/authorization/domain/audit_targets.py +++ b/backend/app/modules/authorization/domain/audit_targets.py @@ -65,6 +65,18 @@ def _compensation_unit_target(resource): return project_id, "project", project_id, "project", project_id +def _model_administration_target(resource): + """Audit project model administration against its exact owning project.""" + from app.modules.authorization.domain.model_usage import ( + ProjectModelAdministrationResourceContext, + ) + + if type(resource) is not ProjectModelAdministrationResourceContext: + return None + project_id = str(resource.project_id) + return project_id, "project", project_id, "project", project_id + + def project_authority_audit_target( resource: object, action_id: ActionId, *, matched_scope_project_id: UUID | None = None, @@ -136,7 +148,7 @@ def project_authority_audit_target( return project_id, "checker_run", str(resource.resource_id), "project", project_id if type(resource) is CheckerRetrySourceReadResourceContext: return _checker_retry_read_target(resource) - if target := _post_submit_delivery_target(resource) or _compensation_unit_target(resource): + if target := _post_submit_delivery_target(resource) or _compensation_unit_target(resource) or _model_administration_target(resource): return target if action_id in SUBMISSION_POLICY_MUTATIONS and isinstance( resource, ProjectSubmissionArtifactPolicyMutationResourceContext, diff --git a/backend/app/modules/authorization/domain/checker_outputs.py b/backend/app/modules/authorization/domain/checker_outputs.py new file mode 100644 index 000000000..660bfa208 --- /dev/null +++ b/backend/app/modules/authorization/domain/checker_outputs.py @@ -0,0 +1,288 @@ +"""Exact prepared resources for private generated model-output custody.""" + +from __future__ import annotations + +import json +from typing import Literal, Self +from uuid import UUID + +from pydantic import BaseModel, ConfigDict, Field, ValidationError, model_validator + +from app.core.hashing import canonical_json_hash +from app.modules.authorization.catalogue import ActionId +from app.modules.checkers.api.output_custody import ( + GENERATED_OUTPUT_RESERVATION, + GENERATED_OUTPUT_SELECTOR, + GeneratedOutputReservation, + GeneratedOutputSelector, + CheckerOutputUnavailable, + generated_output_subject, +) +from app.modules.model_usage.api import ModelCallUnavailable, SetupModelOutputSelector + + +WRITE = ActionId.ARTIFACT_CHECKER_OUTPUT_WRITE +BIND = ActionId.ARTIFACT_CHECKER_OUTPUT_BINDING_CREATE +CHECKER_OUTPUT_ACTIONS = frozenset({WRITE, BIND}) + +_STRICT_FROZEN = ConfigDict(extra="forbid", frozen=True, strict=True, revalidate_instances="always") + + +class CheckerOutputCommitmentFacts(BaseModel): + """Server-computed content commitment without source bytes or scratch identity.""" + + model_config = _STRICT_FROZEN + sha256: str = Field(pattern=r"^sha256:[0-9a-f]{64}$") + byte_count: int = Field(strict=True, ge=0, le=1_048_576) + media_type: Literal["application/json"] + + +class CheckerOutputStoredAuthorityFacts(BaseModel): + """Verified ART ancestry selected under the exact model-call reservation.""" + + model_config = _STRICT_FROZEN + put_attempt_id: UUID + checker_request_digest: str = Field(pattern=r"^sha256:[0-9a-f]{64}$") + sha256: str = Field(pattern=r"^sha256:[0-9a-f]{64}$") + byte_count: int = Field(strict=True, ge=0, le=1_048_576) + media_type: Literal["application/json"] + status: Literal[ + "prepared", + "put_in_flight", + "acknowledgement_unknown", + "object_confirmed", + "absent_replay_required", + "integrity_mismatch", + "provider_unavailable", + "conflict", + "verified", + ] + content_id: UUID | None + replica_id: UUID | None + verification_receipt_id: UUID | None + + @model_validator(mode="after") + def exact_verification(self) -> Self: + present = ( + self.content_id is not None, + self.replica_id is not None, + self.verification_receipt_id is not None, + ) + if self.status == "verified" and not all(present): + raise ValueError("verified checker output ancestry is incomplete") + if self.status != "verified" and any(present): + raise ValueError("unverified checker output has verified ancestry") + return self + + +class CheckerOutputResourceContext(BaseModel): + """One exact selector prepared before owner locks and closed by stored facts.""" + + model_config = _STRICT_FROZEN + resource_type: Literal["checker_model_output", "setup_model_call"] + resource_id: UUID + scope_project_id: UUID + action_id: ActionId + selector: GeneratedOutputSelector + selector_digest: str = Field(pattern=r"^sha256:[0-9a-f]{64}$") + reservation: GeneratedOutputReservation | None = None + commitment: CheckerOutputCommitmentFacts | None = None + stored: CheckerOutputStoredAuthorityFacts | None = None + + @model_validator(mode="after") + def exact_selector(self) -> Self: + selector = GENERATED_OUTPUT_SELECTOR.validate_python(self.selector) + if self.action_id not in CHECKER_OUTPUT_ACTIONS: + raise ValueError("invalid checker output action") + resource_type, resource_id, project_id = _selector_resource(selector) + if (self.resource_type, self.resource_id, self.scope_project_id) != ( + resource_type, + resource_id, + project_id, + ) or self.selector_digest != checker_output_selector_digest(selector): + raise ValueError("invalid checker output selector resource") + if self.reservation is not None: + reservation = GENERATED_OUTPUT_RESERVATION.validate_python(self.reservation) + try: + slot = reservation.select(selector) + except (CheckerOutputUnavailable, ModelCallUnavailable, ValidationError) as error: + raise ValueError("model output reservation differs from selector") from error + subject = generated_output_subject(reservation) + if ( + subject.resource_id != self.resource_id + or subject.project_id != self.scope_project_id + or _subject_resource_type(subject.resource_type) != self.resource_type + ): + raise ValueError("model output reservation resource differs") + if self.commitment is not None and ( + self.commitment.byte_count > slot.maximum_bytes + or self.commitment.media_type != slot.media_type + ): + raise ValueError("checker output commitment exceeds its reservation") + if self.stored is not None and ( + self.stored.byte_count > slot.maximum_bytes + or self.stored.media_type != slot.media_type + or self.stored.checker_request_digest + != canonical_json_hash(subject.request_digest_facts) + ): + raise ValueError("stored checker output differs from its reservation") + if self.commitment is not None and self.action_id is not WRITE: + raise ValueError("checker output commitment requires write authority") + if self.stored is not None and self.stored.sha256 != self.selector.content_sha256: + raise ValueError("stored checker output differs from selector") + if ( + self.reservation is not None + and self.action_id is BIND + and (self.stored is None or self.stored.status != "verified") + ): + raise ValueError("checker output binding requires verified ancestry") + if self.commitment is not None and self.commitment.sha256 != self.selector.content_sha256: + raise ValueError("checker output commitment differs from selector") + if self.commitment is not None and self.stored is not None: + raise ValueError("checker output authority facts are ambiguous") + return self + + +def checker_output_selector_digest(selector: GeneratedOutputSelector) -> str: + """Hash the entire typed selector prepared before protected owner access.""" + checked = GENERATED_OUTPUT_SELECTOR.validate_python(selector) + return canonical_json_hash(checked.model_dump(mode="json")) + + +def checker_output_prepare_resource( + action: ActionId, selector: GeneratedOutputSelector +) -> CheckerOutputResourceContext: + """Build the preliminary resource bound only to the caller's exact selector.""" + checked = GENERATED_OUTPUT_SELECTOR.validate_python(selector) + resource_type, resource_id, project_id = _selector_resource(checked) + return CheckerOutputResourceContext( + resource_type=resource_type, + resource_id=resource_id, + scope_project_id=project_id, + action_id=action, + selector=checked, + selector_digest=checker_output_selector_digest(checked), + ) + + +def checker_output_admission_resource( + selector: GeneratedOutputSelector, + facts: object, +) -> CheckerOutputResourceContext: + """Bind prepared write authority to server-computed content facts.""" + prepared = checker_output_prepare_resource(WRITE, selector) + return CheckerOutputResourceContext( + resource_type=prepared.resource_type, + resource_id=prepared.resource_id, + scope_project_id=prepared.scope_project_id, + action_id=prepared.action_id, + selector=prepared.selector, + selector_digest=prepared.selector_digest, + reservation=facts.reservation, + commitment=CheckerOutputCommitmentFacts( + sha256=facts.sha256, + byte_count=facts.byte_count, + media_type=facts.media_type, + ), + ) + + +def checker_output_stored_resource( + action: ActionId, + selector: GeneratedOutputSelector, + reservation: GeneratedOutputReservation, + facts: object, +) -> CheckerOutputResourceContext: + """Bind prepared authority to storage facts selected by ART itself.""" + prepared = checker_output_prepare_resource(action, selector) + return CheckerOutputResourceContext( + resource_type=prepared.resource_type, + resource_id=prepared.resource_id, + scope_project_id=prepared.scope_project_id, + action_id=prepared.action_id, + selector=prepared.selector, + selector_digest=prepared.selector_digest, + reservation=reservation, + stored=CheckerOutputStoredAuthorityFacts( + put_attempt_id=facts.put_attempt_id, + checker_request_digest=facts.checker_request_digest, + sha256=facts.sha256, + byte_count=facts.byte_count, + media_type=facts.media_type, + status=facts.status, + content_id=facts.content_id, + replica_id=facts.replica_id, + verification_receipt_id=facts.verification_receipt_id, + ), + ) + + +def _selector_resource( + selector: GeneratedOutputSelector, +) -> tuple[Literal["checker_model_output", "setup_model_call"], UUID, UUID]: + if isinstance(selector, SetupModelOutputSelector): + return "setup_model_call", selector.model_call_attempt_id, selector.slot_key.project_id + return ( + "checker_model_output", + selector.model_call_attempt_id, + selector.paid_slot.project_id, + ) + + +def _subject_resource_type( + value: Literal["checker_model_call", "setup_model_call"], +) -> Literal["checker_model_output", "setup_model_call"]: + return "checker_model_output" if value == "checker_model_call" else value + + +def parse_checker_output_prepare( + action: ActionId, value: object, invalid_error: type[Exception] +) -> CheckerOutputResourceContext | None: + """Parse only preliminary selector resources at PREP issuance.""" + if action not in CHECKER_OUTPUT_ACTIONS: + return None + try: + resource = CheckerOutputResourceContext.model_validate_json(json.dumps(value)) + if ( + resource.action_id is not action + or resource.reservation is not None + or resource.commitment is not None + or resource.stored is not None + ): + raise ValueError("checker output prepare resource is not preliminary") + return resource + except (TypeError, ValueError) as error: + raise invalid_error("invalid prepared checker output authority") from error + + +def checker_output_prepare_matches( + prepared: CheckerOutputResourceContext | None, + resource: object, +) -> bool: + """Require final facts to retain the exact selector committed at preparation.""" + return ( + prepared is not None + and type(resource) is CheckerOutputResourceContext + and resource.resource_type == prepared.resource_type + and resource.action_id is prepared.action_id + and resource.resource_id == prepared.resource_id + and resource.scope_project_id == prepared.scope_project_id + and resource.selector == prepared.selector + and resource.selector_digest == prepared.selector_digest + and resource.reservation is not None + and ((resource.commitment is not None) ^ (resource.stored is not None)) + ) + + +__all__ = ( + "BIND", + "CHECKER_OUTPUT_ACTIONS", + "CheckerOutputResourceContext", + "WRITE", + "checker_output_admission_resource", + "checker_output_prepare_matches", + "checker_output_prepare_resource", + "checker_output_selector_digest", + "checker_output_stored_resource", + "parse_checker_output_prepare", +) diff --git a/backend/app/modules/authorization/domain/model_usage.py b/backend/app/modules/authorization/domain/model_usage.py new file mode 100644 index 000000000..93ad4d80f --- /dev/null +++ b/backend/app/modules/authorization/domain/model_usage.py @@ -0,0 +1,481 @@ +"""Exact AUTH resources for project-owned checker model effects.""" + +from __future__ import annotations + +import json +from typing import Literal, Self +from uuid import UUID + +from pydantic import BaseModel, ConfigDict, Field, model_validator + +from app.modules.authorization.catalogue import ActionId +from app.modules.model_usage.api import ( + ModelBudgetProvisioningFacts, + ModelCallEffectAuthorityFacts, + ModelCallEvidence, + ModelCallExecutionFacts, + ModelPaidSlotKey, + ModelProfileProvisioningFacts, + ModelUsageInspectionFacts, + PreparedModelCallAttempt, + PreparedSetupModelUsageEffect, + SetupModelEffectAuthorityFacts, + SetupModelUsageSlotKey, + model_action_digest, +) + + +PROFILE_PROVISION = ActionId.PROJECT_MODEL_PROFILE_PROVISION +BUDGET_CONFIGURE = ActionId.PROJECT_MODEL_BUDGET_CONFIGURE +USAGE_READ = ActionId.PROJECT_MODEL_USAGE_READ +RECOVERY_CHILD_AUTHORIZE = ActionId.PROJECT_MODEL_CALL_RECOVERY_CHILD_AUTHORIZE +PREPARE = ActionId.CHECKER_MODEL_CALL_PREPARE +DISPATCH = ActionId.CHECKER_MODEL_CALL_DISPATCH +RECOVER = ActionId.CHECKER_MODEL_CALL_RECOVER +OBSERVE = ActionId.CHECKER_MODEL_CALL_OBSERVE +COMPLETE = ActionId.CHECKER_MODEL_CALL_COMPLETE +GRANT = ActionId.CHECKER_MODEL_CALL_GRANT +SETUP_PREPARE = ActionId.PROJECT_GUIDE_COMPILATION_MODEL_PREPARE +SETUP_DISPATCH = ActionId.PROJECT_GUIDE_COMPILATION_MODEL_DISPATCH +SETUP_OBSERVE = ActionId.PROJECT_GUIDE_COMPILATION_MODEL_OBSERVE +SETUP_COMPLETE = ActionId.PROJECT_GUIDE_COMPILATION_MODEL_COMPLETE + +MODEL_ADMIN_ACTIONS = frozenset( + {PROFILE_PROVISION, BUDGET_CONFIGURE, USAGE_READ, RECOVERY_CHILD_AUTHORIZE} +) +CHECKER_MODEL_CALL_ACTIONS = frozenset({PREPARE, DISPATCH, RECOVER, OBSERVE, COMPLETE, GRANT}) +SETUP_MODEL_USAGE_ACTIONS = frozenset( + {SETUP_PREPARE, SETUP_DISPATCH, SETUP_OBSERVE, SETUP_COMPLETE} +) +CHECKER_MODEL_ACTIONS = MODEL_ADMIN_ACTIONS | CHECKER_MODEL_CALL_ACTIONS | SETUP_MODEL_USAGE_ACTIONS + +_STRICT_FROZEN = ConfigDict(extra="forbid", frozen=True, strict=True, revalidate_instances="always") +_Sha256 = str + + +class ModelCallPrepareAuthorityFacts(BaseModel): + """Exact current execution, stable slot and first request commitment.""" + + model_config = _STRICT_FROZEN + execution: ModelCallExecutionFacts + slot: ModelPaidSlotKey + request_sha256: _Sha256 = Field(pattern=r"^sha256:[0-9a-f]{64}$") + + +class ModelCallDispatchAuthorityFacts(BaseModel): + """Exact current execution, prepared attempt and verified request evidence.""" + + model_config = _STRICT_FROZEN + execution: ModelCallExecutionFacts + attempt: PreparedModelCallAttempt + request_evidence: ModelCallEvidence + + +class ModelCallRecoverAuthorityFacts(BaseModel): + """A current execution attaching to one existing provider effect.""" + + model_config = _STRICT_FROZEN + execution: ModelCallExecutionFacts + effect: ModelCallEffectAuthorityFacts + + +class ModelCallGrantAuthorityFacts(BaseModel): + """Current execution and opaque invocation-grant commitment.""" + + model_config = _STRICT_FROZEN + execution: ModelCallExecutionFacts + slot: ModelPaidSlotKey + external_request_digest: _Sha256 = Field(pattern=r"^sha256:[0-9a-f]{64}$") + token_hash: _Sha256 = Field(pattern=r"^sha256:[0-9a-f]{64}$") + + +class ModelCallObserveAuthorityFacts(BaseModel): + """Lease-independent uncertain-effect observation facts.""" + + model_config = _STRICT_FROZEN + effect: ModelCallEffectAuthorityFacts + provider_response_id: str | None = Field( + default=None, + min_length=1, + max_length=256, + pattern=r"^[A-Za-z0-9][A-Za-z0-9._:-]*$", + ) + observation_digest: _Sha256 = Field(pattern=r"^sha256:[0-9a-f]{64}$") + + +class ModelCallCompleteAuthorityFacts(BaseModel): + """Lease-independent terminal effect and optional response evidence.""" + + model_config = _STRICT_FROZEN + effect: ModelCallEffectAuthorityFacts + response_evidence: ModelCallEvidence | None + terminal_digest: _Sha256 = Field(pattern=r"^sha256:[0-9a-f]{64}$") + + +class SetupModelPrepareAuthorityFacts(BaseModel): + """Exact setup call slot and canonical first request commitment.""" + + model_config = _STRICT_FROZEN + slot: SetupModelUsageSlotKey + request_sha256: _Sha256 = Field(pattern=r"^sha256:[0-9a-f]{64}$") + + +class SetupModelDispatchAuthorityFacts(BaseModel): + """Exact prepared setup effect and verified request evidence.""" + + model_config = _STRICT_FROZEN + prepared: PreparedSetupModelUsageEffect + request_evidence: ModelCallEvidence + + +class SetupModelObserveAuthorityFacts(BaseModel): + """Lease-independent uncertain setup-effect observation.""" + + model_config = _STRICT_FROZEN + effect: SetupModelEffectAuthorityFacts + provider_response_id: str | None = Field( + default=None, + min_length=1, + max_length=256, + pattern=r"^[A-Za-z0-9][A-Za-z0-9._:-]*$", + ) + observation_digest: _Sha256 = Field(pattern=r"^sha256:[0-9a-f]{64}$") + + +class SetupModelCompleteAuthorityFacts(BaseModel): + """Lease-independent terminal setup effect and optional response evidence.""" + + model_config = _STRICT_FROZEN + effect: SetupModelEffectAuthorityFacts + response_evidence: ModelCallEvidence | None + terminal_digest: _Sha256 = Field(pattern=r"^sha256:[0-9a-f]{64}$") + + +class ModelCallRecoveryChildAuthorityFacts(BaseModel): + """Operator-authorized child after one exact terminal parent effect.""" + + model_config = _STRICT_FROZEN + project_id: UUID + parent_attempt_id: UUID + terminal_digest: _Sha256 = Field(pattern=r"^sha256:[0-9a-f]{64}$") + + +RuntimeFacts = ( + ModelCallPrepareAuthorityFacts + | ModelCallDispatchAuthorityFacts + | ModelCallRecoverAuthorityFacts + | ModelCallObserveAuthorityFacts + | ModelCallCompleteAuthorityFacts + | ModelCallGrantAuthorityFacts +) +SetupRuntimeFacts = ( + SetupModelPrepareAuthorityFacts + | SetupModelDispatchAuthorityFacts + | SetupModelObserveAuthorityFacts + | SetupModelCompleteAuthorityFacts +) +AdminFacts = ( + ModelProfileProvisioningFacts + | ModelBudgetProvisioningFacts + | ModelUsageInspectionFacts + | ModelCallRecoveryChildAuthorityFacts +) + + +class CheckerModelCallResourceContext(BaseModel): + """One exact fixed-service model-call phase in a project scope.""" + + model_config = _STRICT_FROZEN + resource_type: Literal["checker_model_call"] = "checker_model_call" + resource_id: UUID + scope_project_id: UUID + action_id: ActionId + facts: RuntimeFacts + facts_digest: _Sha256 = Field(pattern=r"^sha256:[0-9a-f]{64}$") + + @model_validator(mode="after") + def exact_phase(self) -> Self: + expected = { + PREPARE: ModelCallPrepareAuthorityFacts, + DISPATCH: ModelCallDispatchAuthorityFacts, + RECOVER: ModelCallRecoverAuthorityFacts, + OBSERVE: ModelCallObserveAuthorityFacts, + COMPLETE: ModelCallCompleteAuthorityFacts, + GRANT: ModelCallGrantAuthorityFacts, + }.get(self.action_id) + if expected is None or type(self.facts) is not expected: + raise ValueError("invalid checker model call authority phase") + project_id, resource_id = _runtime_identity(self.facts) + if ( + self.scope_project_id != project_id + or self.resource_id != resource_id + or self.facts_digest != model_action_digest(self.action_id.value, self.facts) + ): + raise ValueError("invalid checker model call authority resource") + return self + + +class ProjectModelAdministrationResourceContext(BaseModel): + """Exact project target governed only by a current system Operator grant.""" + + model_config = _STRICT_FROZEN + resource_type: Literal["project_model_administration"] = "project_model_administration" + resource_id: UUID + project_id: UUID + actor_profile_id: UUID + action_id: ActionId + facts: AdminFacts + facts_digest: _Sha256 = Field(pattern=r"^sha256:[0-9a-f]{64}$") + + @model_validator(mode="after") + def exact_operation(self) -> Self: + expected = { + PROFILE_PROVISION: ModelProfileProvisioningFacts, + BUDGET_CONFIGURE: ModelBudgetProvisioningFacts, + USAGE_READ: ModelUsageInspectionFacts, + RECOVERY_CHILD_AUTHORIZE: ModelCallRecoveryChildAuthorityFacts, + }.get(self.action_id) + if expected is None or type(self.facts) is not expected: + raise ValueError("invalid project model administration phase") + project_id, resource_id, actor_profile_id = _admin_identity( + self.facts, self.actor_profile_id + ) + if ( + self.project_id != project_id + or self.resource_id != resource_id + or self.actor_profile_id != actor_profile_id + or self.facts_digest != model_action_digest(self.action_id.value, self.facts) + ): + raise ValueError("invalid project model administration resource") + return self + + +class SetupModelUsageResourceContext(BaseModel): + """One exact PROJECT_SETUP model-use phase in a project scope.""" + + model_config = _STRICT_FROZEN + resource_type: Literal["setup_model_usage"] = "setup_model_usage" + resource_id: UUID + scope_project_id: UUID + action_id: ActionId + facts: SetupRuntimeFacts + facts_digest: _Sha256 = Field(pattern=r"^sha256:[0-9a-f]{64}$") + + @model_validator(mode="after") + def exact_phase(self) -> Self: + expected = { + SETUP_PREPARE: SetupModelPrepareAuthorityFacts, + SETUP_DISPATCH: SetupModelDispatchAuthorityFacts, + SETUP_OBSERVE: SetupModelObserveAuthorityFacts, + SETUP_COMPLETE: SetupModelCompleteAuthorityFacts, + }.get(self.action_id) + if expected is None or type(self.facts) is not expected: + raise ValueError("invalid setup model usage authority phase") + project_id, resource_id = _setup_runtime_identity(self.facts) + if ( + self.scope_project_id != project_id + or self.resource_id != resource_id + or self.facts_digest != model_action_digest(self.action_id.value, self.facts) + ): + raise ValueError("invalid setup model usage authority resource") + return self + + +CHECKER_MODEL_RESOURCE_BY_ACTION = { + **{action: CheckerModelCallResourceContext for action in CHECKER_MODEL_CALL_ACTIONS}, + **{action: SetupModelUsageResourceContext for action in SETUP_MODEL_USAGE_ACTIONS}, + **{action: ProjectModelAdministrationResourceContext for action in MODEL_ADMIN_ACTIONS}, +} +MODEL_ADMIN_RESOURCE_BY_ACTION = { + action: ProjectModelAdministrationResourceContext for action in MODEL_ADMIN_ACTIONS +} + + +def checker_model_call_resource( + action: ActionId, facts: RuntimeFacts +) -> CheckerModelCallResourceContext: + """Build the only valid fixed-service resource for one phase.""" + project_id, resource_id = _runtime_identity(facts) + return CheckerModelCallResourceContext( + resource_id=resource_id, + scope_project_id=project_id, + action_id=action, + facts=facts, + facts_digest=model_action_digest(action.value, facts), + ) + + +def project_model_admin_resource( + action: ActionId, + facts: AdminFacts, + *, + actor_profile_id: UUID, +) -> ProjectModelAdministrationResourceContext: + """Build one system-Operator resource retaining its exact project target.""" + project_id, resource_id, checked_actor = _admin_identity(facts, actor_profile_id) + return ProjectModelAdministrationResourceContext( + resource_id=resource_id, + project_id=project_id, + actor_profile_id=checked_actor, + action_id=action, + facts=facts, + facts_digest=model_action_digest(action.value, facts), + ) + + +def setup_model_usage_resource( + action: ActionId, facts: SetupRuntimeFacts +) -> SetupModelUsageResourceContext: + """Build the only valid PROJECT_SETUP resource for one model-use phase.""" + project_id, resource_id = _setup_runtime_identity(facts) + return SetupModelUsageResourceContext( + resource_id=resource_id, + scope_project_id=project_id, + action_id=action, + facts=facts, + facts_digest=model_action_digest(action.value, facts), + ) + + +def parse_checker_model_prepare( + action: ActionId, value: object, invalid_error: type[Exception] +) -> ( + CheckerModelCallResourceContext + | SetupModelUsageResourceContext + | ProjectModelAdministrationResourceContext + | None +): + """Parse the exact resource committed before AUTH locks product owners.""" + expected = CHECKER_MODEL_RESOURCE_BY_ACTION.get(action) + if expected is None: + return None + try: + # Prepared requests are retained as JSON-compatible values. Re-enter through + # JSON validation so strict UUID/datetime fields receive their wire types + # without weakening integer, boolean, enum or object validation. + resource = expected.model_validate_json(json.dumps(value)) + if resource.action_id is not action: + raise ValueError("checker model action mismatch") + return resource + except (TypeError, ValueError) as error: + raise invalid_error("invalid prepared checker model authority") from error + + +def checker_model_prepare_matches( + prepared: ( + CheckerModelCallResourceContext + | SetupModelUsageResourceContext + | ProjectModelAdministrationResourceContext + | None + ), + resource: object, +) -> bool: + """Require final facts to equal the resource committed at preparation.""" + return prepared is not None and type(resource) is type(prepared) and resource == prepared + + +def _runtime_identity(facts: RuntimeFacts) -> tuple[UUID, UUID]: + if type(facts) is ModelCallPrepareAuthorityFacts: + if not _execution_matches_slot(facts.execution, facts.slot): + raise ValueError("checker model project mismatch") + return facts.execution.project_id, facts.execution.evaluation_request_id + if type(facts) is ModelCallDispatchAuthorityFacts: + if not _execution_matches_slot(facts.execution, facts.attempt.slot_key): + raise ValueError("checker model project mismatch") + return facts.execution.project_id, facts.attempt.attempt_id + if type(facts) is ModelCallRecoverAuthorityFacts: + if ( + not _execution_matches_slot(facts.execution, facts.effect.slot_key) + or facts.execution.project_id != facts.effect.project_id + or facts.execution.task_id != facts.effect.task_id + ): + raise ValueError("checker model recovery effect mismatch") + return facts.execution.project_id, facts.effect.attempt_id + if type(facts) is ModelCallGrantAuthorityFacts: + if not _execution_matches_slot(facts.execution, facts.slot): + raise ValueError("checker model grant project mismatch") + return facts.execution.project_id, facts.execution.evaluation_request_id + if type(facts) in {ModelCallObserveAuthorityFacts, ModelCallCompleteAuthorityFacts}: + if facts.effect.project_id != facts.effect.slot_key.project_id: + raise ValueError("checker model project mismatch") + return facts.effect.project_id, facts.effect.attempt_id + raise ValueError("invalid checker model runtime facts") + + +def _execution_matches_slot(execution: ModelCallExecutionFacts, slot: ModelPaidSlotKey) -> bool: + return ( + execution.project_id, + execution.assignment_id, + execution.submission_id, + execution.submission_version, + ) == ( + slot.project_id, + slot.assignment_id, + slot.submission_id, + slot.submission_version, + ) + + +def _setup_runtime_identity(facts: SetupRuntimeFacts) -> tuple[UUID, UUID]: + if type(facts) is SetupModelPrepareAuthorityFacts: + return facts.slot.project_id, facts.slot.compilation_attempt_id + if type(facts) is SetupModelDispatchAuthorityFacts: + return facts.prepared.slot_key.project_id, facts.prepared.effect_id + if type(facts) in {SetupModelObserveAuthorityFacts, SetupModelCompleteAuthorityFacts}: + if facts.effect.project_id != facts.effect.slot_key.project_id: + raise ValueError("setup model project mismatch") + return facts.effect.project_id, facts.effect.effect_id + raise ValueError("invalid setup model runtime facts") + + +def _admin_identity(facts: AdminFacts, actor_profile_id: UUID) -> tuple[UUID, UUID, UUID]: + if type(facts) in {ModelProfileProvisioningFacts, ModelBudgetProvisioningFacts}: + return facts.spec.project_id, facts.operation_id, facts.actor_profile_id + if type(facts) is ModelCallRecoveryChildAuthorityFacts: + return facts.project_id, facts.parent_attempt_id, actor_profile_id + if type(facts) is ModelUsageInspectionFacts: + return facts.project_id, facts.effect_id, facts.actor_profile_id + raise ValueError("invalid project model administration facts") + + +__all__ = ( + "BUDGET_CONFIGURE", + "CHECKER_MODEL_ACTIONS", + "CHECKER_MODEL_CALL_ACTIONS", + "CHECKER_MODEL_RESOURCE_BY_ACTION", + "COMPLETE", + "CheckerModelCallResourceContext", + "DISPATCH", + "GRANT", + "MODEL_ADMIN_ACTIONS", + "MODEL_ADMIN_RESOURCE_BY_ACTION", + "ModelCallCompleteAuthorityFacts", + "ModelCallDispatchAuthorityFacts", + "ModelCallGrantAuthorityFacts", + "ModelCallObserveAuthorityFacts", + "ModelCallPrepareAuthorityFacts", + "ModelCallRecoverAuthorityFacts", + "ModelCallRecoveryChildAuthorityFacts", + "OBSERVE", + "PREPARE", + "PROFILE_PROVISION", + "ProjectModelAdministrationResourceContext", + "RECOVER", + "RECOVERY_CHILD_AUTHORIZE", + "SETUP_COMPLETE", + "SETUP_DISPATCH", + "SETUP_MODEL_USAGE_ACTIONS", + "SETUP_OBSERVE", + "SETUP_PREPARE", + "USAGE_READ", + "SetupModelCompleteAuthorityFacts", + "SetupModelDispatchAuthorityFacts", + "SetupModelObserveAuthorityFacts", + "SetupModelPrepareAuthorityFacts", + "SetupModelUsageResourceContext", + "checker_model_call_resource", + "checker_model_prepare_matches", + "parse_checker_model_prepare", + "project_model_admin_resource", + "setup_model_usage_resource", +) diff --git a/backend/app/modules/authorization/domain/prepared_checker_runtime.py b/backend/app/modules/authorization/domain/prepared_checker_runtime.py new file mode 100644 index 000000000..8ab8a30e3 --- /dev/null +++ b/backend/app/modules/authorization/domain/prepared_checker_runtime.py @@ -0,0 +1,68 @@ +"""Prepared-binding helpers for checker model calls and their ART outputs.""" + +from uuid import UUID + +from app.modules.authorization.catalogue import ActionId +from app.modules.authorization.domain.model_usage import ( + CHECKER_MODEL_ACTIONS, + CheckerModelCallResourceContext, + ProjectModelAdministrationResourceContext, + SetupModelUsageResourceContext, + checker_model_prepare_matches, + parse_checker_model_prepare, +) +from app.modules.authorization.domain.checker_outputs import ( + CHECKER_OUTPUT_ACTIONS, + CheckerOutputResourceContext, + checker_output_prepare_matches, + parse_checker_output_prepare, +) + + +def parse_checker_runtime_bindings( + action: ActionId, value: object, invalid_error: type[Exception] +) -> dict[str, object | None]: + """Parse the exact preliminary checker-runtime resource, when applicable.""" + return { + "checker_model_context": parse_checker_model_prepare(action, value, invalid_error), + "checker_output_context": parse_checker_output_prepare(action, value, invalid_error), + } + + +def checker_runtime_binding_matches( + action: ActionId, + model_context: object, + output_context: object, + resource: object, +) -> bool | None: + """Match a checker-runtime final resource or return None for another capability.""" + if action in CHECKER_MODEL_ACTIONS: + return checker_model_prepare_matches(model_context, resource) + if action in CHECKER_OUTPUT_ACTIONS: + return checker_output_prepare_matches(output_context, resource) + return None + + +def checker_runtime_project_id(action: ActionId, resource: object) -> UUID | None: + """Extract project scope only from the exact checker-runtime resource type.""" + if action in CHECKER_MODEL_ACTIONS and isinstance(resource, CheckerModelCallResourceContext): + return resource.scope_project_id + if action in CHECKER_MODEL_ACTIONS and isinstance(resource, SetupModelUsageResourceContext): + return resource.scope_project_id + # Project model administration deliberately consumes system-scoped Operator + # authority. The project identifier remains an exact guarded target inside + # the resource; it is not the authority scope for this action family. + if action in CHECKER_MODEL_ACTIONS and isinstance( + resource, ProjectModelAdministrationResourceContext + ): + return None + if action in CHECKER_OUTPUT_ACTIONS and isinstance(resource, CheckerOutputResourceContext): + return resource.scope_project_id + return None + + +__all__ = ( + "checker_runtime_binding_matches", + "checker_runtime_project_id", + "parse_checker_runtime_bindings", +) diff --git a/backend/app/modules/authorization/domain/prepared_service.py b/backend/app/modules/authorization/domain/prepared_service.py index 13de67002..11e71762b 100644 --- a/backend/app/modules/authorization/domain/prepared_service.py +++ b/backend/app/modules/authorization/domain/prepared_service.py @@ -6,19 +6,47 @@ from app.core.hashing import canonical_json_hash from app.modules.authorization.catalogue import ActionId -from app.modules.authorization.domain.post_submit_routing import ROUTE, PostSubmitRoutingResourceContext, parse_post_submit_routing_prepare +from app.modules.authorization.domain.model_usage import ( + CHECKER_MODEL_CALL_ACTIONS, + SETUP_MODEL_USAGE_ACTIONS, + CheckerModelCallResourceContext, + SetupModelUsageResourceContext, +) +from app.modules.authorization.domain.checker_outputs import ( + CHECKER_OUTPUT_ACTIONS, + CheckerOutputResourceContext, +) +from app.modules.authorization.domain.post_submit_routing import ( + ROUTE, + PostSubmitRoutingResourceContext, + parse_post_submit_routing_prepare, +) from app.modules.authorization.domain.post_submit import ( - POST_SUBMIT_ACTIONS, PostSubmitResourceContext, parse_post_submit_prepare, + POST_SUBMIT_ACTIONS, + PostSubmitResourceContext, + parse_post_submit_prepare, ) from app.modules.authorization.domain.pre_submit import ( PRE_SUBMIT_ACTIONS, PreSubmitResourceContext, parse_pre_submit_prepare, ) -from app.modules.authorization.domain.assignment_invalidation import AssignmentInvalidationResourceContext, parse_assignment_invalidation_binding -from app.modules.authorization.domain.revision_expiry import RevisionExpiryResourceContext, parse_revision_expiry_binding -from app.modules.authorization.domain.revision_recovery import RevisionRecoveryResourceContext, parse_revision_recovery_binding -from app.modules.authorization.domain.outbox_dispatch import OutboxDispatchResourceContext, prepared_outbox_digest +from app.modules.authorization.domain.assignment_invalidation import ( + AssignmentInvalidationResourceContext, + parse_assignment_invalidation_binding, +) +from app.modules.authorization.domain.revision_expiry import ( + RevisionExpiryResourceContext, + parse_revision_expiry_binding, +) +from app.modules.authorization.domain.revision_recovery import ( + RevisionRecoveryResourceContext, + parse_revision_recovery_binding, +) +from app.modules.authorization.domain.outbox_dispatch import ( + OutboxDispatchResourceContext, + prepared_outbox_digest, +) from app.modules.authorization.domain.post_policy import PostPolicyResourceContext, DERIVE from app.modules.authorization.domain.guide_compilation import ( ProjectGuideCompilationExecuteResourceContext, @@ -27,7 +55,9 @@ from app.modules.authorization.domain.guide_compilation_projections import ( ProjectGuideProjectionResourceContext, ) -from app.modules.authorization.domain.project_setup_finalization import ProjectSetupFinalizationResourceContext +from app.modules.authorization.domain.project_setup_finalization import ( + ProjectSetupFinalizationResourceContext, +) from app.modules.authorization.runtime import ( AuthorizationResourceContext, PreparedAuthorityScope, @@ -44,6 +74,7 @@ ActionId.PROJECT_GUIDE_COMPILATION_REQUEST_AUTOMATIC, ActionId.PROJECT_SETUP_RUN_UPDATE, DERIVE, + *SETUP_MODEL_USAGE_ACTIONS, } ) @@ -64,9 +95,11 @@ def project_setup_resource_matches( ) -> bool | None: """Validate setup-service facts, or return None for non-setup actions.""" if action_id == DERIVE: - return (type(resource) is PostPolicyResourceContext - and resource.facts.locator.action_id == DERIVE - and resource.scope_project_id == project_id) + return ( + type(resource) is PostPolicyResourceContext + and resource.facts.locator.action_id == DERIVE + and resource.scope_project_id == project_id + ) if action_id is ActionId.PROJECT_SETUP_RUN_UPDATE: return ( isinstance(resource, ProjectSetupFinalizationResourceContext) @@ -106,34 +139,84 @@ def project_setup_resource_matches( and resource.trigger == "automatic_source_ready" and resource.scope_project_id == project_id ) + if action_id in SETUP_MODEL_USAGE_ACTIONS: + return ( + type(resource) is SetupModelUsageResourceContext + and resource.action_id is action_id + and resource.scope_project_id == project_id + ) return None def fixed_service_scope_project(action_id, scope, artifact_resource): """Admit exact setup, dispatcher or artifact scopes without conflating owners.""" - outbox = (action_id in {ActionId.OUTBOX_DISPATCH, ActionId.TASK_ASSIGNMENT_AUTHORITY_RECONCILE, - ActionId.TASK_REVISION_EXPIRE, ActionId.TASK_REVISION_RECOVER} - and scope.kind is PreparedAuthorityScopeKind.PROJECT and scope.project_id is not None) - if is_project_setup_scope(action_id, scope) or outbox or ( - action_id in PRE_SUBMIT_ACTIONS | POST_SUBMIT_ACTIONS | {ROUTE} + outbox = ( + action_id + in { + ActionId.OUTBOX_DISPATCH, + ActionId.TASK_ASSIGNMENT_AUTHORITY_RECONCILE, + ActionId.TASK_REVISION_EXPIRE, + ActionId.TASK_REVISION_RECOVER, + } and scope.kind is PreparedAuthorityScopeKind.PROJECT and scope.project_id is not None + ) + if ( + is_project_setup_scope(action_id, scope) + or outbox + or ( + action_id + in PRE_SUBMIT_ACTIONS + | POST_SUBMIT_ACTIONS + | CHECKER_MODEL_CALL_ACTIONS + | CHECKER_OUTPUT_ACTIONS + | {ROUTE} + and scope.kind is PreparedAuthorityScopeKind.PROJECT + and scope.project_id is not None + ) ): return scope.project_id - if (artifact_resource is None or scope.kind is not PreparedAuthorityScopeKind.ARTIFACT_INTERNAL - or scope.artifact_resource_type != artifact_resource[0]): - from app.modules.authorization.runtime import PreparedAuthorizationUnsupported, AuthorizationDenialCode + if ( + artifact_resource is None + or scope.kind is not PreparedAuthorityScopeKind.ARTIFACT_INTERNAL + or scope.artifact_resource_type != artifact_resource[0] + ): + from app.modules.authorization.runtime import ( + PreparedAuthorizationUnsupported, + AuthorizationDenialCode, + ) + raise PreparedAuthorizationUnsupported(AuthorizationDenialCode.RESOURCE_GUARD_DENIED) return None -def fixed_service_resource_matches(action_id, resource, project_id, artifact_type, artifact_id, expected): +def fixed_service_resource_matches( + action_id, resource, project_id, artifact_type, artifact_id, expected +): """Check the final resource against the exact prepared service scope.""" if action_id is ROUTE: - return type(resource) is PostSubmitRoutingResourceContext and resource.scope_project_id == project_id + return ( + type(resource) is PostSubmitRoutingResourceContext + and resource.scope_project_id == project_id + ) if action_id in POST_SUBMIT_ACTIONS: - return (type(resource) is PostSubmitResourceContext and resource.action_id is action_id - and resource.scope_project_id == project_id) + return ( + type(resource) is PostSubmitResourceContext + and resource.action_id is action_id + and resource.scope_project_id == project_id + ) + if action_id in CHECKER_MODEL_CALL_ACTIONS: + return ( + type(resource) is CheckerModelCallResourceContext + and resource.action_id is action_id + and resource.scope_project_id == project_id + ) + if action_id in CHECKER_OUTPUT_ACTIONS: + return ( + type(resource) is CheckerOutputResourceContext + and resource.action_id is action_id + and resource.scope_project_id == project_id + ) if action_id in PRE_SUBMIT_ACTIONS: return ( type(resource) is PreSubmitResourceContext @@ -141,36 +224,58 @@ def fixed_service_resource_matches(action_id, resource, project_id, artifact_typ and resource.scope_project_id == project_id ) if action_id is ActionId.TASK_ASSIGNMENT_AUTHORITY_RECONCILE: - return type(resource) is AssignmentInvalidationResourceContext and resource.scope_project_id == project_id + return ( + type(resource) is AssignmentInvalidationResourceContext + and resource.scope_project_id == project_id + ) if action_id is ActionId.TASK_REVISION_EXPIRE: - return type(resource) is RevisionExpiryResourceContext and resource.scope_project_id == project_id + return ( + type(resource) is RevisionExpiryResourceContext + and resource.scope_project_id == project_id + ) if action_id is ActionId.TASK_REVISION_RECOVER: - return type(resource) is RevisionRecoveryResourceContext and resource.scope_project_id == project_id + return ( + type(resource) is RevisionRecoveryResourceContext + and resource.scope_project_id == project_id + ) if action_id is ActionId.OUTBOX_DISPATCH: - return type(resource) is OutboxDispatchResourceContext and resource.scope_project_id == project_id + return ( + type(resource) is OutboxDispatchResourceContext + and resource.scope_project_id == project_id + ) setup = project_setup_resource_matches(action_id, resource, project_id) if setup is not None: return setup - return (expected is not None and isinstance(resource, expected[1]) - and resource.resource_type == artifact_type and resource.resource_id == artifact_id) + return ( + expected is not None + and isinstance(resource, expected[1]) + and resource.resource_type == artifact_type + and resource.resource_id == artifact_id + ) def prepared_request_digest(value): """One canonical request commitment shared by PREP issuance and consumption.""" - return canonical_json_hash({"domain": "workstream.prepared_authorization.request.v1", "request": value}) + return canonical_json_hash( + {"domain": "workstream.prepared_authorization.request.v1", "request": value} + ) def prepared_fixed_service_bindings(action, request, invalid_error): """Keep exact dispatcher and assignment effect commitments with service guards.""" if action is ROUTE: - return {"routing_request": parse_post_submit_routing_prepare(action, request, invalid_error)} + return { + "routing_request": parse_post_submit_routing_prepare(action, request, invalid_error) + } return { - "pre_submit_prepare_context": parse_pre_submit_prepare( + "pre_submit_prepare_context": parse_pre_submit_prepare(action, request, invalid_error), + "post_submit_prepare_context": parse_post_submit_prepare(action, request, invalid_error), + "assignment_invalidation_context": parse_assignment_invalidation_binding( action, request, invalid_error ), - "post_submit_prepare_context": parse_post_submit_prepare(action, request, invalid_error), - "assignment_invalidation_context": parse_assignment_invalidation_binding(action, request, invalid_error), "revision_expiry_context": parse_revision_expiry_binding(action, request, invalid_error), - "revision_recovery_context": parse_revision_recovery_binding(action, request, invalid_error), + "revision_recovery_context": parse_revision_recovery_binding( + action, request, invalid_error + ), "outbox_dispatch_digest": prepared_outbox_digest(action, request, invalid_error), } diff --git a/backend/app/modules/authorization/kernel.py b/backend/app/modules/authorization/kernel.py index 638a6bcb8..98edb7adc 100644 --- a/backend/app/modules/authorization/kernel.py +++ b/backend/app/modules/authorization/kernel.py @@ -1,20 +1,20 @@ """Deny-by-default request-scoped authorization kernel.""" - from __future__ import annotations from app.modules.authorization.domain.guide_proposals import GUIDE_PROPOSAL_RESOURCE_BY_ACTION from app.modules.authorization.catalogue import POST_POLICY_ACTION_IDS from app.modules.authorization.domain.post_policy import PostPolicyResourceContext - from collections.abc import Awaitable, Callable from types import MappingProxyType from typing import NoReturn from uuid import UUID from app.core.identifiers import new_record_id - from sqlalchemy.exc import SQLAlchemyError from sqlalchemy.ext.asyncio import AsyncSession - -from app.modules.audit.schemas import ActorReferenceKind, AuthorityAuditEventInput, AuthorityEventType +from app.modules.audit.schemas import ( + ActorReferenceKind, + AuthorityAuditEventInput, + AuthorityEventType, +) from app.modules.audit.service import AuditService from app.modules.authorization.catalogue import ( ACTION_BY_ID, @@ -24,7 +24,16 @@ PermissionId, ) from app.modules.authorization.domain.guide_manager_resources import guide_manager_resource_denial -from app.modules.authorization.domain import adapter_bindings, artifact_operator, checker_registry, checker_retry, contribution_policies, guide_compilation as compilation, post_submit_deliveries +from app.modules.authorization.domain import ( + adapter_bindings, + artifact_operator, + model_usage as checker_models, + checker_registry, + checker_retry, + contribution_policies, + guide_compilation as compilation, + post_submit_deliveries, +) from app.modules.authorization.domain.action_groups import ( GUIDE_BOUND_PROJECT_MANAGER_ACTIONS as _GUIDE_BOUND_PROJECT_MANAGER_ACTIONS, CONTEXT_DIGEST_ACTIONS, @@ -35,12 +44,14 @@ from app.modules.authorization.domain.audit_targets import project_authority_audit_target from app.modules.authorization.domain.lifecycle import ReviewLifecycleActivationContract from app.modules.authorization.domain.prepared_service import ( - fixed_service_scope_project, fixed_service_resource_matches, + fixed_service_scope_project, + fixed_service_resource_matches, ) from app.modules.authorization.policy import ACTIVE_GUIDE_ADMIN_ROLES from app.modules.authorization.domain.read_dispatch import READ_ACTIONS, read_denial from app.modules.authorization.prepared_admin_authority import ( - lock_admin_mutation_authority, lock_project_create_authority, + lock_admin_mutation_authority, + lock_project_create_authority, lock_project_manager_authority, project_create_prelocked_denial, ) @@ -103,83 +114,99 @@ evaluate_project_authority, lock_project_authority, ) - ContextRevalidator = Callable[ [ HumanAuthorizationContext, - ActorSelfResourceContext - | ActorAuthorizationContextResourceContext - | ProjectReadResourceContext, + ActorSelfResourceContext | ActorAuthorizationContextResourceContext | ProjectReadResourceContext, ], Awaitable[HumanAuthorizationContext], ] - - ServiceContextRevalidator = Callable[ [ServiceAuthorizationContext, ActionId], Awaitable[ServiceAuthorizationContext | None], ] - -_ADMIN_ACTIONS = frozenset( - { - ActionId.REVIEW_LIFECYCLE_ACTIVATION_MANAGE, - ActionId.AUTHORIZATION_PERMISSION_CATALOGUE_READ, - ActionId.AUTHORIZATION_ADMIN_ROLE_DEFINITIONS_READ, - ActionId.ADMIN_ROLE_GRANT_LIST, - ActionId.ACTOR_ADMIN_ROLE_GRANT_HISTORY_READ, - ActionId.ADMIN_ROLE_GRANT_ISSUE, - ActionId.ADMIN_ROLE_GRANT_REVOKE, - ActionId.ADMIN_ROLE_GRANT_BOOTSTRAP, - ActionId.ACTOR_SERVICE_PROVISION, - ActionId.ACTOR_PROFILE_READ, - ActionId.ACTOR_IDENTITY_LINK_READ, - ActionId.ACTOR_PROFILE_SUSPEND, - ActionId.ACTOR_PROFILE_REACTIVATE, - ActionId.ACTOR_PROFILE_DEACTIVATE, - ActionId.ACTOR_IDENTITY_LINK_REVOKE, - ActionId.ACTOR_IDENTITY_LINK_REACTIVATE, - ActionId.PROJECT_ROLE_GRANT_ISSUE, - ActionId.PROJECT_ROLE_GRANT_REVOKE, - ActionId.PROJECT_CONTRIBUTOR_CANDIDATE_LIST, - ActionId.PROJECT_ROLE_GRANT_LIST, - ActionId.PROJECT_ROLE_GRANT_READ, - ActionId.PROJECT_SETUP_RUN_READ, - ActionId.PROJECT_GUIDE_SUFFICIENCY_REPORT_LIST, - ActionId.PROJECT_GUIDE_SUFFICIENCY_REPORT_READ, - ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_LIST, - ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_READ, - ActionId.PROJECT_EFFECTIVE_SUBMISSION_ARTIFACT_POLICY_READ, - ActionId.PROJECT_PRE_SUBMIT_CHECKER_POLICY_READ, - ActionId.PROJECT_ACTIVE_GUIDE_READ, - } -) | adapter_bindings.ADAPTER_BINDING_READ_ACTIONS | contribution_policies.CONTRIBUTION_POLICY_READ_ACTIONS | checker_registry.CHECKER_REGISTRY_ACTIONS | checker_retry.CHECKER_RETRY_ACTIONS | post_submit_deliveries.POST_SUBMIT_DELIVERY_MUTATION_ACTIONS | artifact_operator.ARTIFACT_OPERATOR_ACTIONS -_SERIALIZED_ADMIN_READS = frozenset( - { - ActionId.ACTOR_PROFILE_READ, - ActionId.ACTOR_IDENTITY_LINK_READ, - ActionId.PROJECT_SETUP_RUN_READ, - ActionId.PROJECT_GUIDE_SUFFICIENCY_REPORT_LIST, - ActionId.PROJECT_GUIDE_SUFFICIENCY_REPORT_READ, - ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_LIST, - ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_READ, - ActionId.PROJECT_EFFECTIVE_SUBMISSION_ARTIFACT_POLICY_READ, - ActionId.PROJECT_PRE_SUBMIT_CHECKER_POLICY_READ, - ActionId.PROJECT_ACTIVE_GUIDE_READ, - } -) | adapter_bindings.ADAPTER_BINDING_READ_ACTIONS | contribution_policies.CONTRIBUTION_POLICY_READ_ACTIONS | checker_registry.CHECKER_REGISTRY_READ_ACTIONS | artifact_operator.ARTIFACT_OPERATOR_READ_ACTIONS -_ADMIN_MUTATIONS = frozenset( - { - ActionId.REVIEW_LIFECYCLE_ACTIVATION_MANAGE, - ActionId.ADMIN_ROLE_GRANT_REVOKE, - ActionId.ACTOR_SERVICE_PROVISION, - ActionId.ACTOR_PROFILE_SUSPEND, - ActionId.ACTOR_PROFILE_REACTIVATE, - ActionId.ACTOR_PROFILE_DEACTIVATE, - ActionId.ACTOR_IDENTITY_LINK_REVOKE, - ActionId.ACTOR_IDENTITY_LINK_REACTIVATE, - } -) | PROJECT_SCOPED_ADMIN_MUTATIONS | checker_registry.CHECKER_REGISTRY_MUTATION_ACTIONS | checker_retry.CHECKER_RETRY_ACTIONS | post_submit_deliveries.POST_SUBMIT_DELIVERY_MUTATION_ACTIONS | {ActionId.ARTIFACT_VERIFICATION_JOB_RETRY} - +_ADMIN_ACTIONS = ( + frozenset( + { + ActionId.REVIEW_LIFECYCLE_ACTIVATION_MANAGE, + ActionId.AUTHORIZATION_PERMISSION_CATALOGUE_READ, + ActionId.AUTHORIZATION_ADMIN_ROLE_DEFINITIONS_READ, + ActionId.ADMIN_ROLE_GRANT_LIST, + ActionId.ACTOR_ADMIN_ROLE_GRANT_HISTORY_READ, + ActionId.ADMIN_ROLE_GRANT_ISSUE, + ActionId.ADMIN_ROLE_GRANT_REVOKE, + ActionId.ADMIN_ROLE_GRANT_BOOTSTRAP, + ActionId.ACTOR_SERVICE_PROVISION, + ActionId.ACTOR_PROFILE_READ, + ActionId.ACTOR_IDENTITY_LINK_READ, + ActionId.ACTOR_PROFILE_SUSPEND, + ActionId.ACTOR_PROFILE_REACTIVATE, + ActionId.ACTOR_PROFILE_DEACTIVATE, + ActionId.ACTOR_IDENTITY_LINK_REVOKE, + ActionId.ACTOR_IDENTITY_LINK_REACTIVATE, + ActionId.PROJECT_ROLE_GRANT_ISSUE, + ActionId.PROJECT_ROLE_GRANT_REVOKE, + ActionId.PROJECT_CONTRIBUTOR_CANDIDATE_LIST, + ActionId.PROJECT_ROLE_GRANT_LIST, + ActionId.PROJECT_ROLE_GRANT_READ, + ActionId.PROJECT_SETUP_RUN_READ, + ActionId.PROJECT_GUIDE_SUFFICIENCY_REPORT_LIST, + ActionId.PROJECT_GUIDE_SUFFICIENCY_REPORT_READ, + ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_LIST, + ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_READ, + ActionId.PROJECT_EFFECTIVE_SUBMISSION_ARTIFACT_POLICY_READ, + ActionId.PROJECT_PRE_SUBMIT_CHECKER_POLICY_READ, + ActionId.PROJECT_ACTIVE_GUIDE_READ, + } + ) + | adapter_bindings.ADAPTER_BINDING_READ_ACTIONS + | contribution_policies.CONTRIBUTION_POLICY_READ_ACTIONS + | checker_registry.CHECKER_REGISTRY_ACTIONS + | checker_models.MODEL_ADMIN_ACTIONS + | checker_retry.CHECKER_RETRY_ACTIONS + | post_submit_deliveries.POST_SUBMIT_DELIVERY_MUTATION_ACTIONS + | artifact_operator.ARTIFACT_OPERATOR_ACTIONS +) +_SERIALIZED_ADMIN_READS = ( + frozenset( + { + ActionId.ACTOR_PROFILE_READ, + ActionId.ACTOR_IDENTITY_LINK_READ, + ActionId.PROJECT_SETUP_RUN_READ, + ActionId.PROJECT_GUIDE_SUFFICIENCY_REPORT_LIST, + ActionId.PROJECT_GUIDE_SUFFICIENCY_REPORT_READ, + ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_LIST, + ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_READ, + ActionId.PROJECT_EFFECTIVE_SUBMISSION_ARTIFACT_POLICY_READ, + ActionId.PROJECT_PRE_SUBMIT_CHECKER_POLICY_READ, + ActionId.PROJECT_ACTIVE_GUIDE_READ, + } + ) + | adapter_bindings.ADAPTER_BINDING_READ_ACTIONS + | contribution_policies.CONTRIBUTION_POLICY_READ_ACTIONS + | checker_registry.CHECKER_REGISTRY_READ_ACTIONS + | artifact_operator.ARTIFACT_OPERATOR_READ_ACTIONS +) +_ADMIN_MUTATIONS = ( + frozenset( + { + ActionId.REVIEW_LIFECYCLE_ACTIVATION_MANAGE, + ActionId.ADMIN_ROLE_GRANT_REVOKE, + ActionId.ACTOR_SERVICE_PROVISION, + ActionId.ACTOR_PROFILE_SUSPEND, + ActionId.ACTOR_PROFILE_REACTIVATE, + ActionId.ACTOR_PROFILE_DEACTIVATE, + ActionId.ACTOR_IDENTITY_LINK_REVOKE, + ActionId.ACTOR_IDENTITY_LINK_REACTIVATE, + } + ) + | PROJECT_SCOPED_ADMIN_MUTATIONS + | checker_registry.CHECKER_REGISTRY_MUTATION_ACTIONS + | checker_models.MODEL_ADMIN_ACTIONS + | checker_retry.CHECKER_RETRY_ACTIONS + | post_submit_deliveries.POST_SUBMIT_DELIVERY_MUTATION_ACTIONS + | {ActionId.ARTIFACT_VERIFICATION_JOB_RETRY} +) def _prepared_admin_role_locker(action_id): """Select the only supported prepared admin-role authority locker.""" if action_id in _ADMIN_MUTATIONS: @@ -188,9 +215,7 @@ def _prepared_admin_role_locker(action_id): return lock_project_manager_authority raise ValueError("unsupported prepared admin-role action") -async def prelock_serialized_admin_read_actor( - authorization: AuthorizationService, action_id: ActionId -) -> None: +async def prelock_serialized_admin_read_actor(authorization: AuthorizationService, action_id: ActionId) -> None: """Order caller custody before product rows; final require still decides access.""" if action_id not in _SERIALIZED_ADMIN_READS: raise ValueError("unsupported serialized administrative read") @@ -201,15 +226,15 @@ async def prelock_serialized_admin_read_actor( ) _ARTIFACT_INTERNAL_RESOURCES = { - ActionId.ARTIFACT_GUIDE_SOURCE_READ: ( - "guide_source_read", - GuideSourceReadResourceContext, - ), + ActionId.ARTIFACT_GUIDE_SOURCE_READ: ("guide_source_read", GuideSourceReadResourceContext), ActionId.ARTIFACT_PRE_SUBMIT_CHECKER_INPUT_MATERIALIZE: ( "pre_submit_checker_input", PreSubmitCheckerInputResourceContext, ), - ActionId.ARTIFACT_SUBMISSION_BINDING_CREATE: ("submission_binding", SubmissionBindingResourceContext), + ActionId.ARTIFACT_SUBMISSION_BINDING_CREATE: ( + "submission_binding", + SubmissionBindingResourceContext, + ), ActionId.ARTIFACT_PUT_ATTEMPT_RESOLVE: ( "artifact_put_attempt", ArtifactPutAttemptResourceContext, @@ -241,9 +266,7 @@ async def prelock_serialized_admin_read_actor( ActionId.ACTOR_PROFILE_DEACTIVATE: ActorProfileLifecycleResourceContext, ActionId.ACTOR_IDENTITY_LINK_REVOKE: ActorIdentityLinkLifecycleResourceContext, ActionId.ACTOR_IDENTITY_LINK_REACTIVATE: ActorIdentityLinkLifecycleResourceContext, - ActionId.PROJECT_CONTRIBUTOR_CANDIDATE_LIST: ( - ProjectContributorCandidateCollectionResourceContext - ), + ActionId.PROJECT_CONTRIBUTOR_CANDIDATE_LIST: (ProjectContributorCandidateCollectionResourceContext), ActionId.PROJECT_ROLE_GRANT_LIST: ProjectRoleGrantCollectionResourceContext, ActionId.PROJECT_ROLE_GRANT_READ: ProjectRoleGrantReadResourceContext, ActionId.PROJECT_ROLE_GRANT_ISSUE: ProjectRoleGrantIssueResourceContext, @@ -253,18 +276,21 @@ async def prelock_serialized_admin_read_actor( ActionId.PROJECT_GUIDE_SUFFICIENCY_REPORT_READ: ProjectDiagnosticReadResourceContext, ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_LIST: ProjectDiagnosticReadResourceContext, ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_READ: ProjectDiagnosticReadResourceContext, - ActionId.PROJECT_EFFECTIVE_SUBMISSION_ARTIFACT_POLICY_READ: ( - ProjectPolicyReadResourceContext - ), + ActionId.PROJECT_EFFECTIVE_SUBMISSION_ARTIFACT_POLICY_READ: ProjectPolicyReadResourceContext, ActionId.PROJECT_PRE_SUBMIT_CHECKER_POLICY_READ: ProjectPolicyReadResourceContext, ActionId.PROJECT_ACTIVE_GUIDE_READ: ProjectActiveGuideReadResourceContext, **adapter_bindings.ADAPTER_BINDING_RESOURCE_BY_ACTION, - **contribution_policies.CONTRIBUTION_POLICY_RESOURCE_BY_ACTION, **checker_registry.CHECKER_REGISTRY_RESOURCE_BY_ACTION, - ActionId.OPERATIONS_CHECKER_RETRY: checker_retry.CheckerRetryResourceContext, **post_submit_deliveries.POST_SUBMIT_DELIVERY_RESOURCE_BY_ACTION, **artifact_operator.ARTIFACT_OPERATOR_RESOURCE_BY_ACTION, + **contribution_policies.CONTRIBUTION_POLICY_RESOURCE_BY_ACTION, + **checker_registry.CHECKER_REGISTRY_RESOURCE_BY_ACTION, + **checker_models.MODEL_ADMIN_RESOURCE_BY_ACTION, + ActionId.OPERATIONS_CHECKER_RETRY: checker_retry.CheckerRetryResourceContext, + **post_submit_deliveries.POST_SUBMIT_DELIVERY_RESOURCE_BY_ACTION, + **artifact_operator.ARTIFACT_OPERATOR_RESOURCE_BY_ACTION, **PROJECT_MUTATION_RESOURCE_BY_ACTION, **GUIDE_PROPOSAL_RESOURCE_BY_ACTION, } ) + def project_action_available_for_status(action_id: ActionId, project_status: str) -> bool: """Apply project-only lifecycle guards shared by decisions and projections.""" if action_id in { @@ -371,12 +397,7 @@ def _register_prepared_consumer( """Issue one opaque kernel token only to an exactly composed PREP service.""" from app.modules.authorization.prepared import PreparedAuthorizationService - if ( - type(owner) is not PreparedAuthorizationService - or session is not self._session - or repository is not self._admin - or context != self._context - ): + if type(owner) is not PreparedAuthorizationService or session is not self._session or repository is not self._admin or context != self._context: raise TypeError("invalid prepared authorization composition") token = object() self._prepared_consumers[token] = owner @@ -414,9 +435,7 @@ async def _prepare_prelocked( raise PreparedAuthorizationUnsupported(AuthorizationDenialCode.ACTION_UNAVAILABLE) expected_resource = _ARTIFACT_INTERNAL_RESOURCES.get(action_id) project_id = fixed_service_scope_project(action_id, scope, expected_resource) - locked = await self._admin.lock_request_actor( - context.identity_link_id, context.actor_profile_id - ) + locked = await self._admin.lock_request_actor(context.identity_link_id, context.actor_profile_id) context = self._locked_service_context(locked, context) lifecycle = self._lifecycle_denial(context) if lifecycle is not None: @@ -440,40 +459,39 @@ async def _prepare_prelocked( if action.availability is not ActionAvailability.ACTIVE: raise PreparedAuthorizationUnsupported(AuthorizationDenialCode.ACTION_UNAVAILABLE) if action_id is ActionId.ACTOR_PROFILE_UPDATE_SELF: - if ( - scope.kind is not PreparedAuthorityScopeKind.ACTOR_SELF - or scope.actor_profile_id != context.actor_profile_id - ): - raise PreparedAuthorizationUnsupported( - AuthorizationDenialCode.RESOURCE_GUARD_DENIED - ) - locked = await self._admin.lock_actor_self( - context.actor_profile_id, context.identity_link_id - ) + if scope.kind is not PreparedAuthorityScopeKind.ACTOR_SELF or scope.actor_profile_id != context.actor_profile_id: + raise PreparedAuthorizationUnsupported(AuthorizationDenialCode.RESOURCE_GUARD_DENIED) + locked = await self._admin.lock_actor_self(context.actor_profile_id, context.identity_link_id) context = self._locked_human_context(locked, context) elif action_id in _ADMIN_MUTATIONS | _GUIDE_BOUND_PROJECT_MANAGER_ACTIONS: context, grant = await _prepared_admin_role_locker(action_id)( - self._admin, context, scope, action_id, action.permission_id, self._locked_human_context, + self._admin, + context, + scope, + action_id, + action.permission_id, + self._locked_human_context, ) elif action_id is ActionId.PROJECT_CREATE: context, grant = await lock_project_create_authority( - self._admin, context, scope, self._locked_human_context, + self._admin, + context, + scope, + self._locked_human_context, ) elif action_id in PROJECT_AUTHORITY_ACTIONS: context, grant = await lock_project_authority( - self._admin, context, scope, action, self._locked_human_context, + self._admin, + context, + scope, + action, + self._locked_human_context, ) else: - raise PreparedAuthorizationUnsupported( - AuthorizationDenialCode.PERMISSION_NOT_GRANTED - if action_id in _ARTIFACT_INTERNAL_RESOURCES - else AuthorizationDenialCode.ACTION_UNAVAILABLE - ) + raise PreparedAuthorizationUnsupported(AuthorizationDenialCode.PERMISSION_NOT_GRANTED if action_id in _ARTIFACT_INTERNAL_RESOURCES else AuthorizationDenialCode.ACTION_UNAVAILABLE) lifecycle = self._lifecycle_denial(context) if lifecycle is not None or context.actor_kind is not ActorKind.HUMAN: - raise PreparedAuthorizationUnsupported( - lifecycle or AuthorizationDenialCode.PERMISSION_NOT_GRANTED - ) + raise PreparedAuthorizationUnsupported(lifecycle or AuthorizationDenialCode.PERMISSION_NOT_GRANTED) authority = _PrelockedAuthority( _PRELOCKED_CONSTRUCTOR_TOKEN, issuer=self, @@ -482,11 +500,7 @@ async def _prepare_prelocked( action_id=action_id, scope_project_id=scope.project_id, matched_grant_id=UUID(str(grant.id)) if grant is not None else None, - matched_grant_scope_project_id=( - UUID(str(grant.scope_project_id)) - if grant is not None and getattr(grant, "scope_project_id", None) is not None - else None - ), + matched_grant_scope_project_id=(UUID(str(grant.scope_project_id)) if grant is not None and getattr(grant, "scope_project_id", None) is not None else None), matched_grant_status=grant.status if grant is not None else None, permission_id=action.permission_id, ) @@ -498,11 +512,7 @@ def _locked_human_context(locked, original) -> HumanAuthorizationContext: if locked is None: raise PreparedAuthorizationUnsupported(AuthorizationDenialCode.IDENTITY_LINK_REVOKED) link, profile = locked - if ( - profile.id != str(original.actor_profile_id) - or link.id != str(original.identity_link_id) - or link.actor_profile_id != profile.id - ): + if profile.id != str(original.actor_profile_id) or link.id != str(original.identity_link_id) or link.actor_profile_id != profile.id: raise PreparedAuthorizationUnsupported(AuthorizationDenialCode.PERMISSION_NOT_GRANTED) return HumanAuthorizationContext( actor_profile_id=UUID(profile.id), @@ -519,13 +529,7 @@ def _locked_service_context(locked, original) -> ServiceAuthorizationContext: if locked is None: raise PreparedAuthorizationUnsupported(AuthorizationDenialCode.IDENTITY_LINK_REVOKED) link, profile = locked - if ( - profile.id != str(original.actor_profile_id) - or link.id != str(original.identity_link_id) - or link.actor_profile_id != profile.id - or profile.actor_kind != ActorKind.SERVICE.value - or profile.service_identity != original.service_identity.value - ): + if profile.id != str(original.actor_profile_id) or link.id != str(original.identity_link_id) or link.actor_profile_id != profile.id or profile.actor_kind != ActorKind.SERVICE.value or profile.service_identity != original.service_identity.value: raise PreparedAuthorizationUnsupported(AuthorizationDenialCode.PERMISSION_NOT_GRANTED) return ServiceAuthorizationContext( actor_profile_id=UUID(profile.id), @@ -596,16 +600,24 @@ async def require( if denial is None: matched_kind = MatchedAuthorityKind.ADMIN_ROLE_GRANT elif action is not None and action.action_id in READ_ACTIONS: - denial, context, matched_kind, matched_grant_id, matched_project_id, revalidated = await read_denial( - action, resource_context, context, self._admin, - self._locked_human_context, self._lifecycle_denial, self._revalidate_actor_self, + ( + denial, + context, + matched_kind, + matched_grant_id, + matched_project_id, + revalidated, + ) = await read_denial( + action, + resource_context, + context, + self._admin, + self._locked_human_context, + self._lifecycle_denial, + self._revalidate_actor_self, ) elif action is not None and action.action_id is ActionId.ACTOR_AUTHORIZATION_CONTEXT_READ: - if ( - isinstance(context, HumanAuthorizationContext) - and isinstance(resource_context, ActorAuthorizationContextResourceContext) - and self._revalidate_actor_self is not None - ): + if isinstance(context, HumanAuthorizationContext) and isinstance(resource_context, ActorAuthorizationContextResourceContext) and self._revalidate_actor_self is not None: context = await self._revalidate_actor_self(context, resource_context) revalidated = True ( @@ -613,9 +625,7 @@ async def require( matched_kind, matched_grant_id, matched_project_id, - ) = await self._authorization_context_denial( - action, resource_context, context, revalidated - ) + ) = await self._authorization_context_denial(action, resource_context, context, revalidated) else: if ( action is not None @@ -656,12 +666,7 @@ async def _authorization_context_denial( UUID | None, ]: """Authorize a caller-owned context projection for one exact project.""" - if ( - not isinstance(context, HumanAuthorizationContext) - or not isinstance(resource, ActorAuthorizationContextResourceContext) - or resource.resource_id != context.actor_profile_id - or not revalidated - ): + if not isinstance(context, HumanAuthorizationContext) or not isinstance(resource, ActorAuthorizationContextResourceContext) or resource.resource_id != context.actor_profile_id or not revalidated: return AuthorizationDenialCode.RESOURCE_GUARD_DENIED, None, None, None if action.availability is not ActionAvailability.ACTIVE: return AuthorizationDenialCode.ACTION_UNAVAILABLE, None, None, None @@ -692,13 +697,9 @@ async def _authorization_context_denial( for_update=True, ) if project_grant is None: - out_of_scope = await self._admin.has_effective_permission_any_scope( - context.actor_profile_id, PermissionId.PROJECT_READ - ) or await self._admin.has_active_project_role_any_project(context.actor_profile_id) + out_of_scope = await self._admin.has_effective_permission_any_scope(context.actor_profile_id, PermissionId.PROJECT_READ) or await self._admin.has_active_project_role_any_project(context.actor_profile_id) return ( - AuthorizationDenialCode.SCOPE_NOT_AUTHORIZED - if out_of_scope - else AuthorizationDenialCode.PERMISSION_NOT_GRANTED, + AuthorizationDenialCode.SCOPE_NOT_AUTHORIZED if out_of_scope else AuthorizationDenialCode.PERMISSION_NOT_GRANTED, None, None, None, @@ -720,15 +721,7 @@ async def _require_prelocked( """Evaluate final facts using exact authority already locked by AUTH.""" self._validate_prepared_consumer(consumer_token) transaction = self._session.sync_session.get_transaction() - if ( - type(authority) is not _PrelockedAuthority - or authority not in self._sealed_prelocked - or authority.issuer is not self - or transaction is None - or not transaction.is_active - or transaction is not authority.transaction - or self._session.in_nested_transaction() - ): + if type(authority) is not _PrelockedAuthority or authority not in self._sealed_prelocked or authority.issuer is not self or transaction is None or not transaction.is_active or transaction is not authority.transaction or self._session.in_nested_transaction(): raise TypeError("invalid prelocked authority") self._sealed_prelocked.remove(authority) self._pending_denial = None @@ -736,8 +729,7 @@ async def _require_prelocked( action = ACTION_BY_ID.get(action_id) if isinstance(action_id, ActionId) else None context = authority.context denial: AuthorizationDenialCode | None - matched_kind = None - matched_grant_id = None + matched_kind, matched_grant_id = None, None matched_project_id = None if action is None or authority.action_id is not action_id: denial = AuthorizationDenialCode.UNKNOWN_ACTION @@ -751,7 +743,12 @@ async def _require_prelocked( if denial is None and action_id not in SERVICE_ACTIONS_BY_IDENTITY.get(context.service_identity, ()): denial = AuthorizationDenialCode.PERMISSION_NOT_GRANTED if denial is None and not fixed_service_resource_matches( - action_id, resource_context, authority.scope_project_id, authority.artifact_resource_type, authority.artifact_resource_id, expected_resource, + action_id, + resource_context, + authority.scope_project_id, + authority.artifact_resource_type, + authority.artifact_resource_id, + expected_resource, ): denial = AuthorizationDenialCode.RESOURCE_GUARD_DENIED if denial is None: @@ -769,9 +766,7 @@ async def _require_prelocked( final_scope = self._resource_project_id(resource_context) if denial is None and final_scope != authority.scope_project_id: denial = AuthorizationDenialCode.SCOPE_NOT_AUTHORIZED - if denial is None and ( - authority.matched_grant_id is None or authority.matched_grant_status != "active" - ): + if denial is None and (authority.matched_grant_id is None or authority.matched_grant_status != "active"): denial = AuthorizationDenialCode.PERMISSION_NOT_GRANTED if denial is None: denial = await self._admin_guard(action_id, resource_context, context) @@ -785,9 +780,7 @@ async def _require_prelocked( denial = AuthorizationDenialCode.ACTION_UNAVAILABLE if denial is None: denial = guide_manager_resource_denial(action_id, resource_context, authority.scope_project_id) - if denial is None and ( - authority.matched_grant_id is None or authority.matched_grant_status != "active" - ): + if denial is None and (authority.matched_grant_id is None or authority.matched_grant_status != "active"): denial = AuthorizationDenialCode.PERMISSION_NOT_GRANTED if denial is None: matched_kind = MatchedAuthorityKind.ADMIN_ROLE_GRANT @@ -795,14 +788,21 @@ async def _require_prelocked( matched_project_id = authority.matched_grant_scope_project_id elif action_id is ActionId.PROJECT_CREATE: denial = project_create_prelocked_denial( - action, resource_context, authority, self._lifecycle_denial(context), + action, + resource_context, + authority, + self._lifecycle_denial(context), ) if denial is None: matched_kind = MatchedAuthorityKind.ADMIN_ROLE_GRANT matched_grant_id = authority.matched_grant_id elif action_id in PROJECT_AUTHORITY_ACTIONS: denial, matched_kind, matched_grant_id, matched_project_id = evaluate_project_authority( - action, context, authority, resource_context, self._lifecycle_denial(context), + action, + context, + authority, + resource_context, + self._lifecycle_denial(context), ) else: denial = AuthorizationDenialCode.ACTION_UNAVAILABLE @@ -888,11 +888,7 @@ async def _service_denial( lifecycle = self._lifecycle_denial(refreshed) if lifecycle is not None: return lifecycle, refreshed, True - if ( - refreshed.service_identity is not context.service_identity - or action.action_id not in SERVICE_ACTIONS_BY_IDENTITY.get(refreshed.service_identity, ()) - or ACTION_BY_ID[action.action_id].availability is not ActionAvailability.ACTIVE - ): + if refreshed.service_identity is not context.service_identity or action.action_id not in SERVICE_ACTIONS_BY_IDENTITY.get(refreshed.service_identity, ()) or ACTION_BY_ID[action.action_id].availability is not ActionAvailability.ACTIVE: return AuthorizationDenialCode.PERMISSION_NOT_GRANTED, refreshed, True return AuthorizationDenialCode.RESOURCE_GUARD_DENIED, refreshed, True @@ -952,11 +948,14 @@ async def _admin_denial( if action.action_id is ActionId.PROJECT_ACTIVE_GUIDE_READ: grant_filters["allowed_roles"] = ACTIVE_GUIDE_ADMIN_ROLES else: - grant_filters.update( - adapter_bindings.finance_authority_grant_filters(action.action_id) - ) - grant_filters.update({**contribution_policies.policy_finance_grant_filters(action.action_id), **checker_registry.checker_registry_grant_filters(action.action_id)}) - if action.action_id in checker_retry.CHECKER_RETRY_ACTIONS | post_submit_deliveries.POST_SUBMIT_DELIVERY_MUTATION_ACTIONS | artifact_operator.ARTIFACT_OPERATOR_ACTIONS: + grant_filters.update(adapter_bindings.finance_authority_grant_filters(action.action_id)) + grant_filters.update( + { + **contribution_policies.policy_finance_grant_filters(action.action_id), + **checker_registry.checker_registry_grant_filters(action.action_id), + } + ) + if action.action_id in checker_retry.CHECKER_RETRY_ACTIONS | post_submit_deliveries.POST_SUBMIT_DELIVERY_MUTATION_ACTIONS | checker_models.MODEL_ADMIN_ACTIONS | artifact_operator.ARTIFACT_OPERATOR_ACTIONS: grant_filters["allowed_roles"] = (AdminRole.OPERATOR,) matched = await self._admin.find_effective_grant( context.actor_profile_id, @@ -997,7 +996,14 @@ async def _admin_guard( context: AuthorizationContext, ) -> AuthorizationDenialCode | None: """Apply target existence and self-authority guards after permission match.""" - if type(resource) in (artifact_operator.ArtifactOperatorReadResourceContext, artifact_operator.ArtifactOperatorRetryResourceContext) and resource.identity_link_id != context.identity_link_id: + if ( + type(resource) + in ( + artifact_operator.ArtifactOperatorReadResourceContext, + artifact_operator.ArtifactOperatorRetryResourceContext, + ) + and resource.identity_link_id != context.identity_link_id + ): return AuthorizationDenialCode.RESOURCE_GUARD_DENIED if isinstance(resource, AdminRoleGrantIssueResourceContext): if resource.resource_id == context.actor_profile_id: @@ -1011,9 +1017,7 @@ async def _admin_guard( return AuthorizationDenialCode.RESOURCE_NOT_FOUND elif isinstance(resource, AdminRoleGrantResourceContext): grant = await self._admin.get_grant(resource.resource_id, for_update=True) - if grant is None or ( - grant.status != "active" and not resource.existing_idempotency_record - ): + if grant is None or (grant.status != "active" and not resource.existing_idempotency_record): return AuthorizationDenialCode.GRANT_NOT_FOUND if grant.target_actor_profile_id == str(context.actor_profile_id): return AuthorizationDenialCode.SELF_ROLE_REVOKE_FORBIDDEN @@ -1042,17 +1046,18 @@ async def _admin_guard( return AuthorizationDenialCode.RESOURCE_NOT_FOUND elif type(resource) is post_submit_deliveries.PostSubmitDeliveryResourceContext and not await self._admin.project_exists(resource.scope_project_id, for_update=True): return AuthorizationDenialCode.RESOURCE_NOT_FOUND + elif type(resource) is checker_models.ProjectModelAdministrationResourceContext: + if resource.actor_profile_id != context.actor_profile_id: + return AuthorizationDenialCode.RESOURCE_GUARD_DENIED + if not await self._admin.project_exists(resource.project_id, for_update=True): + return AuthorizationDenialCode.RESOURCE_NOT_FOUND elif isinstance( resource, (AdminRoleGrantCollectionResourceContext, ActorAdminRoleGrantHistoryResourceContext), ): - if resource.scope_project_id is not None and not await self._admin.project_exists( - resource.scope_project_id - ): + if resource.scope_project_id is not None and not await self._admin.project_exists(resource.scope_project_id): return AuthorizationDenialCode.RESOURCE_NOT_FOUND - if isinstance( - resource, ActorAdminRoleGrantHistoryResourceContext - ) and not await self._admin.actor_exists(resource.resource_id): + if isinstance(resource, ActorAdminRoleGrantHistoryResourceContext) and not await self._admin.actor_exists(resource.resource_id): return AuthorizationDenialCode.ACTOR_NOT_FOUND elif isinstance(resource, ProjectContributorCandidateCollectionResourceContext): if not project_action_available_for_status(action_id, resource.project_status): @@ -1119,12 +1124,7 @@ def _lifecycle_denial( async def restage_denial(self, decision: AuthorizationDecision) -> None: """Restage the exact pending denial after composition-root rollback.""" - if ( - decision.allowed - or decision is not self._pending_denial - or decision.request_id != self._context.request_id - or decision.correlation_id != self._context.correlation_id - ): + if decision.allowed or decision is not self._pending_denial or decision.request_id != self._context.request_id or decision.correlation_id != self._context.correlation_id: raise TypeError("invalid authorization denial evidence") resource_context = self._pending_denial_resource_context if resource_context is None: @@ -1209,18 +1209,23 @@ async def _stage_decision( audit_resource_id = None target_ref_kind = None target_ref_id = None - audit_project_id = ( - str(decision.matched_scope_project_id) - if decision.matched_scope_project_id is not None - else None - ) + audit_project_id = str(decision.matched_scope_project_id) if decision.matched_scope_project_id is not None else None if target_is_actor: audit_resource_type = "actor_profile" elif decision.resource_type in {"actor_identity_link", "admin_role_grant"}: audit_resource_type = decision.resource_type - if exact_project_target := project_authority_audit_target(resource_context, decision.action_id, matched_scope_project_id=decision.matched_scope_project_id): - (target_project_id, audit_resource_type, audit_resource_id, - target_ref_kind, target_ref_id) = exact_project_target + if exact_project_target := project_authority_audit_target( + resource_context, + decision.action_id, + matched_scope_project_id=decision.matched_scope_project_id, + ): + ( + target_project_id, + audit_resource_type, + audit_resource_id, + target_ref_kind, + target_ref_id, + ) = exact_project_target if target_project_id is not None: audit_project_id = target_project_id default_resource_id = str(decision.resource_id) if audit_resource_type else None @@ -1234,26 +1239,16 @@ async def _stage_decision( await self._audit.add_authority_event( AuthorityAuditEventInput( event_id=decision.decision_id, - event_type=( - AuthorityEventType.SENSITIVE_AUTHORIZATION_ALLOWED - if decision.allowed - else AuthorityEventType.SENSITIVE_AUTHORIZATION_DENIED - ), + event_type=(AuthorityEventType.SENSITIVE_AUTHORIZATION_ALLOWED if decision.allowed else AuthorityEventType.SENSITIVE_AUTHORIZATION_DENIED), entity_type="authorization_decision", entity_id=str(decision.decision_id), actor_ref_kind=ActorReferenceKind.ACTOR_PROFILE, actor_ref=str(actor_profile_id), request_id=decision.request_id, correlation_id=decision.correlation_id, - target_actor_ref_kind=( - ActorReferenceKind.ACTOR_PROFILE if target_is_actor else None - ), + target_actor_ref_kind=(ActorReferenceKind.ACTOR_PROFILE if target_is_actor else None), target_actor_ref=str(decision.resource_id) if target_is_actor else None, - matched_grant_id=( - str(decision.matched_grant_id) - if decision.matched_grant_id is not None - else None - ), + matched_grant_id=(str(decision.matched_grant_id) if decision.matched_grant_id is not None else None), permission_id=decision.permission_id, action_id=decision.action_id, project_id=audit_project_id, diff --git a/backend/app/modules/authorization/model_usage_authorization.py b/backend/app/modules/authorization/model_usage_authorization.py new file mode 100644 index 000000000..747204ae5 --- /dev/null +++ b/backend/app/modules/authorization/model_usage_authorization.py @@ -0,0 +1,488 @@ +"""Prepared AUTH adapter for project-owned checker model calls.""" + +from __future__ import annotations + +from uuid import NAMESPACE_URL, UUID, uuid5 + +from app.modules.actors.api import ServiceIdentity +from app.modules.authorization.catalogue import ActionId +from app.modules.authorization.domain.model_usage import ( + BUDGET_CONFIGURE, + COMPLETE, + DISPATCH, + GRANT, + OBSERVE, + PREPARE, + PROFILE_PROVISION, + USAGE_READ, + RECOVER, + RECOVERY_CHILD_AUTHORIZE, + SETUP_COMPLETE, + SETUP_DISPATCH, + SETUP_MODEL_USAGE_ACTIONS, + SETUP_OBSERVE, + SETUP_PREPARE, + CHECKER_MODEL_CALL_ACTIONS, + MODEL_ADMIN_ACTIONS, + ModelCallCompleteAuthorityFacts, + ModelCallDispatchAuthorityFacts, + ModelCallGrantAuthorityFacts, + ModelCallObserveAuthorityFacts, + ModelCallPrepareAuthorityFacts, + ModelCallRecoverAuthorityFacts, + ModelCallRecoveryChildAuthorityFacts, + SetupModelCompleteAuthorityFacts, + SetupModelDispatchAuthorityFacts, + SetupModelObserveAuthorityFacts, + SetupModelPrepareAuthorityFacts, + checker_model_call_resource, + project_model_admin_resource, + setup_model_usage_resource, +) +from app.modules.authorization.kernel import AuthorizationService +from app.modules.authorization.prepared import PreparedAuthorizationService +from app.modules.authorization.runtime import ( + AuthorizationDenied, + AuthorizationEvidenceUnavailable, + HumanAuthorizationContext, + PreparedAuthorizationHandleInvalid, + PreparedAuthorizationInput, + PreparedAuthorizationUnsupported, + PreparedAuthorityScope, + PreparedAuthorityScopeKind, + ServiceAuthorizationContext, +) +from app.modules.model_usage.api import ( + ModelActionEvidence, + ModelBudgetProvisioningFacts, + ModelCallEffectAuthorityFacts, + ModelCallEvidence, + ModelCallExecutionFacts, + ModelCallUnavailable, + ModelPaidSlotKey, + ModelProfileProvisioningFacts, + ModelUsageInspectionFacts, + PreparedModelCallAttempt, + PreparedSetupModelUsageEffect, + SetupModelEffectAuthorityFacts, + SetupModelUsageSlotKey, + model_action_digest, +) + + +class CheckerModelAuthorization: + """Bind exact model-call facts to current human or fixed-service authority.""" + + __slots__ = ("_authorization", "_prepared") + + def __init__( + self, + authorization: AuthorizationService, + prepared: PreparedAuthorizationService, + ) -> None: + if ( + prepared._authorization is not authorization + or prepared._context != authorization._context + ): + raise TypeError("checker model authority requires one composition") + self._authorization = authorization + self._prepared = prepared + + async def authorize_profile(self, facts: ModelProfileProvisioningFacts) -> ModelActionEvidence: + """Require a current system Operator for one exact project profile.""" + facts = ModelProfileProvisioningFacts.model_validate(facts) + self._require_human(facts.actor_profile_id) + resource = project_model_admin_resource( + PROFILE_PROVISION, + facts, + actor_profile_id=facts.actor_profile_id, + ) + return await self._authorize( + PROFILE_PROVISION, + resource, + facts.operation_id, + model_action_digest("profile", facts), + ) + + async def authorize_budget(self, facts: ModelBudgetProvisioningFacts) -> ModelActionEvidence: + """Require a current system Operator for one exact project budget.""" + facts = ModelBudgetProvisioningFacts.model_validate(facts) + self._require_human(facts.actor_profile_id) + resource = project_model_admin_resource( + BUDGET_CONFIGURE, + facts, + actor_profile_id=facts.actor_profile_id, + ) + return await self._authorize( + BUDGET_CONFIGURE, + resource, + facts.operation_id, + model_action_digest("budget", facts), + ) + + async def authorize_usage_read(self, facts: ModelUsageInspectionFacts) -> ModelActionEvidence: + """Require a current system Operator for one exact redacted effect read.""" + facts = ModelUsageInspectionFacts.model_validate(facts) + self._require_human(facts.actor_profile_id) + resource = project_model_admin_resource( + USAGE_READ, + facts, + actor_profile_id=facts.actor_profile_id, + ) + return await self._authorize( + USAGE_READ, + resource, + facts.request_id, + model_action_digest("usage_read", facts), + ) + + async def authorize_prepare( + self, + execution: ModelCallExecutionFacts, + slot: ModelPaidSlotKey, + request_sha256: str, + ) -> ModelActionEvidence: + """Authorize one exact stable-slot preparation before CHECKERS locks.""" + facts = ModelCallPrepareAuthorityFacts( + execution=execution, + slot=slot, + request_sha256=request_sha256, + ) + return await self._authorize_runtime( + PREPARE, + facts, + execution.evaluation_request_id, + "prepare", + ) + + async def authorize_dispatch( + self, + execution: ModelCallExecutionFacts, + attempt: PreparedModelCallAttempt, + request_evidence: ModelCallEvidence, + ) -> ModelActionEvidence: + """Authorize reservation and one dispatch for exact verified evidence.""" + facts = ModelCallDispatchAuthorityFacts( + execution=execution, + attempt=attempt, + request_evidence=request_evidence, + ) + return await self._authorize_runtime( + DISPATCH, + facts, + attempt.internal_call_request_id, + "dispatch", + ) + + async def authorize_recover( + self, + execution: ModelCallExecutionFacts, + effect: ModelCallEffectAuthorityFacts, + ) -> ModelActionEvidence: + """Authorize current-run attachment to one existing paid effect.""" + facts = ModelCallRecoverAuthorityFacts( + execution=execution, + effect=effect, + ) + return await self._authorize_runtime( + RECOVER, + facts, + execution.evaluation_request_id, + "recover", + ) + + async def authorize_grant( + self, + execution: ModelCallExecutionFacts, + slot: ModelPaidSlotKey, + external_request_digest: str, + token_hash: str, + ) -> ModelActionEvidence: + """Authorize one opaque invocation grant for the exact current call slot.""" + facts = ModelCallGrantAuthorityFacts( + execution=execution, + slot=slot, + external_request_digest=external_request_digest, + token_hash=token_hash, + ) + return await self._authorize_runtime( + GRANT, + facts, + execution.evaluation_request_id, + "grant", + ) + + async def authorize_observe( + self, + effect: ModelCallEffectAuthorityFacts, + provider_response_id: str | None, + observation_digest: str, + ) -> ModelActionEvidence: + """Authorize a lease-independent observation of one dispatched effect.""" + facts = ModelCallObserveAuthorityFacts( + effect=effect, + provider_response_id=provider_response_id, + observation_digest=observation_digest, + ) + return await self._authorize_runtime( + OBSERVE, + facts, + effect.internal_call_request_id, + "observe", + ) + + async def authorize_child( + self, + project_id: UUID, + parent_attempt_id: UUID, + terminal_digest: str, + ) -> ModelActionEvidence: + """Require explicit system Operator authority for one recovery child.""" + actor_profile_id = self._require_human() + facts = ModelCallRecoveryChildAuthorityFacts( + project_id=project_id, + parent_attempt_id=parent_attempt_id, + terminal_digest=terminal_digest, + ) + resource = project_model_admin_resource( + RECOVERY_CHILD_AUTHORIZE, + facts, + actor_profile_id=actor_profile_id, + ) + return await self._authorize( + RECOVERY_CHILD_AUTHORIZE, + resource, + parent_attempt_id, + model_action_digest("child", facts), + ) + + async def authorize_complete( + self, + effect: ModelCallEffectAuthorityFacts, + response_evidence: ModelCallEvidence | None, + terminal_digest: str, + ) -> ModelActionEvidence: + """Authorize terminalization of one exact lease-independent effect.""" + facts = ModelCallCompleteAuthorityFacts( + effect=effect, + response_evidence=response_evidence, + terminal_digest=terminal_digest, + ) + return await self._authorize_runtime( + COMPLETE, + facts, + effect.internal_call_request_id, + "complete", + ) + + async def authorize_setup_prepare( + self, slot: SetupModelUsageSlotKey, request_sha256: str + ) -> ModelActionEvidence: + """Authorize one stable setup phase/call slot before MODEL locks.""" + facts = SetupModelPrepareAuthorityFacts( + slot=slot, + request_sha256=request_sha256, + ) + return await self._authorize_setup( + SETUP_PREPARE, + facts, + uuid5(NAMESPACE_URL, "workstream:setup-model:" + slot.digest), + "setup_prepare", + ) + + async def authorize_setup_dispatch( + self, + prepared: PreparedSetupModelUsageEffect, + request_evidence: ModelCallEvidence, + ) -> ModelActionEvidence: + """Authorize one verified setup provider dispatch.""" + facts = SetupModelDispatchAuthorityFacts( + prepared=prepared, + request_evidence=request_evidence, + ) + return await self._authorize_setup( + SETUP_DISPATCH, + facts, + prepared.internal_call_request_id, + "setup_dispatch", + ) + + async def authorize_setup_observe( + self, + effect: SetupModelEffectAuthorityFacts, + provider_response_id: str | None, + observation_digest: str, + ) -> ModelActionEvidence: + """Authorize retention of an ambiguous setup provider effect.""" + facts = SetupModelObserveAuthorityFacts( + effect=effect, + provider_response_id=provider_response_id, + observation_digest=observation_digest, + ) + return await self._authorize_setup( + SETUP_OBSERVE, + facts, + effect.internal_call_request_id, + "setup_observe", + ) + + async def authorize_setup_complete( + self, + effect: SetupModelEffectAuthorityFacts, + response_evidence: ModelCallEvidence | None, + terminal_digest: str, + ) -> ModelActionEvidence: + """Authorize exact terminal setup accounting or proven no-effect release.""" + facts = SetupModelCompleteAuthorityFacts( + effect=effect, + response_evidence=response_evidence, + terminal_digest=terminal_digest, + ) + return await self._authorize_setup( + SETUP_COMPLETE, + facts, + effect.internal_call_request_id, + "setup_complete", + ) + + async def _authorize_setup( + self, + action: ActionId, + facts: ( + SetupModelPrepareAuthorityFacts + | SetupModelDispatchAuthorityFacts + | SetupModelObserveAuthorityFacts + | SetupModelCompleteAuthorityFacts + ), + idempotency_key: UUID, + service_action: str, + ) -> ModelActionEvidence: + self._require_setup_service() + return await self._authorize( + action, + setup_model_usage_resource(action, facts), + idempotency_key, + model_action_digest(service_action, facts), + ) + + async def _authorize_runtime( + self, + action: ActionId, + facts: ( + ModelCallPrepareAuthorityFacts + | ModelCallDispatchAuthorityFacts + | ModelCallRecoverAuthorityFacts + | ModelCallObserveAuthorityFacts + | ModelCallCompleteAuthorityFacts + | ModelCallGrantAuthorityFacts + ), + idempotency_key: UUID, + service_action: str, + ) -> ModelActionEvidence: + self._require_post_submit_service() + resource = checker_model_call_resource(action, facts) + return await self._authorize( + action, + resource, + idempotency_key, + model_action_digest(service_action, facts), + ) + + async def _authorize( + self, + action: ActionId, + resource, + idempotency_key: UUID, + service_facts_digest: str, + ) -> ModelActionEvidence: + if action in CHECKER_MODEL_CALL_ACTIONS: + scope = PreparedAuthorityScope( + kind=PreparedAuthorityScopeKind.PROJECT, + project_id=resource.scope_project_id, + ) + elif action in SETUP_MODEL_USAGE_ACTIONS: + scope = PreparedAuthorityScope( + kind=PreparedAuthorityScopeKind.PROJECT, + project_id=resource.scope_project_id, + ) + elif action in MODEL_ADMIN_ACTIONS: + scope = PreparedAuthorityScope(kind=PreparedAuthorityScopeKind.SYSTEM) + else: + raise ModelCallUnavailable("model_call_authority_unavailable") + caller_input = PreparedAuthorizationInput( + idempotency_key=idempotency_key, + request_value=resource.model_dump(mode="json"), + ) + handle = None + try: + handle = await self._prepared.prepare(action, caller_input, scope) + decision = await self._prepared.consume( + handle, + action, + caller_input, + resource, + ) + except PreparedAuthorizationUnsupported as error: + try: + await self._prepared.deny_unsupported( + action, + caller_input, + resource, + error, + ) + except AuthorizationEvidenceUnavailable as evidence_error: + raise ModelCallUnavailable( + "model_call_authority_evidence_unavailable" + ) from evidence_error + except ( + AuthorizationDenied, + PreparedAuthorizationHandleInvalid, + PreparedAuthorizationUnsupported, + ): + pass + raise ModelCallUnavailable("model_call_authority_unavailable") from error + except AuthorizationDenied as error: + if handle is not None: + try: + self._prepared.close_handle(handle) + except PreparedAuthorizationHandleInvalid: + pass + raise ModelCallUnavailable("model_call_authority_unavailable") from error + except (AuthorizationEvidenceUnavailable, PreparedAuthorizationHandleInvalid) as error: + if handle is not None: + try: + self._prepared.close_handle(handle) + except PreparedAuthorizationHandleInvalid: + pass + raise ModelCallUnavailable("model_call_authority_evidence_unavailable") from error + return ModelActionEvidence( + evidence_id=decision.decision_id, + facts_digest=service_facts_digest, + ) + + def _require_human(self, actor_profile_id: UUID | None = None) -> UUID: + context = self._authorization._context + if not isinstance(context, HumanAuthorizationContext) or ( + actor_profile_id is not None and context.actor_profile_id != actor_profile_id + ): + raise ModelCallUnavailable("model_call_authority_unavailable") + return context.actor_profile_id + + def _require_post_submit_service(self) -> None: + context = self._authorization._context + if ( + not isinstance(context, ServiceAuthorizationContext) + or context.service_identity is not ServiceIdentity.CHECKER_POST_SUBMIT + ): + raise ModelCallUnavailable("model_call_authority_unavailable") + + def _require_setup_service(self) -> None: + context = self._authorization._context + if ( + not isinstance(context, ServiceAuthorizationContext) + or context.service_identity is not ServiceIdentity.PROJECT_SETUP + ): + raise ModelCallUnavailable("model_call_authority_unavailable") + + +__all__ = ("CheckerModelAuthorization",) diff --git a/backend/app/modules/authorization/policy.py b/backend/app/modules/authorization/policy.py index 82a70910e..3b2f8afd0 100644 --- a/backend/app/modules/authorization/policy.py +++ b/backend/app/modules/authorization/policy.py @@ -45,6 +45,9 @@ PermissionId.OPERATIONS_TASK_START_OVERRIDE, PermissionId.OPERATIONS_SUBMISSION_GATE_REPAIR, PermissionId.OPERATIONS_CHECKER_RETRY, + PermissionId.PROJECT_MODEL_PROFILE_MANAGE, + PermissionId.PROJECT_MODEL_BUDGET_MANAGE, + PermissionId.PROJECT_MODEL_CALL_RECOVERY_CHILD_AUTHORIZE, PermissionId.ARTIFACT_BINDING_READ, PermissionId.ARTIFACT_REPLICA_READ, PermissionId.ARTIFACT_RECEIPT_READ, diff --git a/backend/app/modules/authorization/prepared.py b/backend/app/modules/authorization/prepared.py index 4f0ab9180..5801800bd 100644 --- a/backend/app/modules/authorization/prepared.py +++ b/backend/app/modules/authorization/prepared.py @@ -8,7 +8,6 @@ from app.modules.authorization.fixed_service_revalidation import fixed_service_context_revalidator from app.modules.authorization.prepared_checker_retry_replay import validate_checker_retry_replay from app.modules.reviews.api.lifecycle import LifecycleTransitionCommand - from copy import Error as CopyError from contextlib import asynccontextmanager from dataclasses import dataclass @@ -47,6 +46,7 @@ checker_retry_resource_matches, parse_checker_retry_prepare, ) +from app.modules.authorization.domain.prepared_checker_runtime import checker_runtime_binding_matches, checker_runtime_project_id, parse_checker_runtime_bindings from app.modules.checkers.api.execution import CheckerRetryPreparation from app.modules.authorization.domain.prepared_submission_policy import parse_submission_policy_prepare from app.modules.authorization.domain.prepared_compilation import prepared_compilation_matches @@ -272,6 +272,8 @@ class _PreparedAuthorizationBinding: adapter_binding_resource_digest: str | None = None guide_projection_prepare_context: dict | None = None setup_finalization_prepare_context: dict | None = None + checker_model_context: object | None = None + checker_output_context: object | None = None TASK_IMPORT_SOURCE_ACTIONS = frozenset({ @@ -662,6 +664,12 @@ def _validate_consumption( issuance.authority.context.identity_link_id, historical_checker_retry_link, ): raise PreparedAuthorizationHandleInvalid("invalid prepared checker retry authority") + checker_runtime_matches = checker_runtime_binding_matches( + expected_action_id, issuance.binding.checker_model_context, + issuance.binding.checker_output_context, final_resource_context, + ) + if checker_runtime_matches is False: + raise PreparedAuthorizationHandleInvalid("invalid prepared checker runtime authority") async def consume( self, @@ -966,6 +974,7 @@ def _binding( **parse_prepared_contribution_policy(action_id, caller_input.request_value), **parse_review_bindings(action_id, caller_input, scope, self._context), **setup_bindings, + **parse_checker_runtime_bindings(action_id, caller_input.request_value, PreparedAuthorizationHandleInvalid), ) @staticmethod @@ -975,16 +984,14 @@ def _scope_from_resource( ) -> PreparedAuthorityScope: artifact_resource = _EXACT_ARTIFACT_RESOURCE_BY_ACTION.get(action_id) if artifact_resource is not None and isinstance(resource, artifact_resource[1]): - return PreparedAuthorityScope( - kind=PreparedAuthorityScopeKind.ARTIFACT_INTERNAL, - artifact_resource_type=artifact_resource[0], - artifact_resource_id=resource.resource_id, - ) + return PreparedAuthorityScope(kind=PreparedAuthorityScopeKind.ARTIFACT_INTERNAL, artifact_resource_type=artifact_resource[0], artifact_resource_id=resource.resource_id) if isinstance(resource, (ProjectGuideProjectionResourceContext, PostPolicyResourceContext, OutboxDispatchResourceContext, AssignmentInvalidationResourceContext, RevisionExpiryResourceContext, RevisionRecoveryResourceContext, PreSubmitResourceContext, PostSubmitResourceContext, PostSubmitRoutingResourceContext)): return PreparedAuthorityScope( kind=PreparedAuthorityScopeKind.PROJECT, project_id=resource.scope_project_id, ) + if checker_runtime_project := checker_runtime_project_id(action_id, resource): + return PreparedAuthorityScope(kind=PreparedAuthorityScopeKind.PROJECT, project_id=checker_runtime_project) if action_id is ActionId.ACTOR_PROFILE_UPDATE_SELF and isinstance( resource, ActorSelfResourceContext ): @@ -1070,7 +1077,6 @@ def _scope_from_resource( ) raise PreparedAuthorizationHandleInvalid("invalid prepared authorization handle") - def _admin_prepared_scope(action_id, resource): """Return the exact administrative prepared scope when applicable.""" if ( diff --git a/backend/app/modules/authorization/prepared_admin_authority.py b/backend/app/modules/authorization/prepared_admin_authority.py index 4b6ff90bb..ac836df08 100644 --- a/backend/app/modules/authorization/prepared_admin_authority.py +++ b/backend/app/modules/authorization/prepared_admin_authority.py @@ -4,6 +4,7 @@ from app.modules.authorization.catalogue import ActionAvailability, ActionId, PermissionId from app.modules.authorization.domain import ( adapter_bindings, + model_usage as checker_models, checker_registry, contribution_policies, ) @@ -50,9 +51,16 @@ async def lock_admin_mutation_authority(repository, context, scope, action_id, p scope_project_id=scope.project_id, system_scope_only=scope.project_id is None, for_update=True, - **({"allowed_roles": (AdminRole.OPERATOR,)} if action_id in { - ActionId.REVIEW_LIFECYCLE_ACTIVATION_MANAGE, ActionId.OPERATIONS_CHECKER_RETRY, - } else {}), + **( + {"allowed_roles": (AdminRole.OPERATOR,)} + if action_id + in { + ActionId.REVIEW_LIFECYCLE_ACTIVATION_MANAGE, + ActionId.OPERATIONS_CHECKER_RETRY, + *checker_models.MODEL_ADMIN_ACTIONS, + } + else {} + ), **adapter_bindings.finance_authority_grant_filters(action_id), **checker_registry.checker_registry_grant_filters(action_id), **contribution_policies.policy_finance_grant_filters(action_id), diff --git a/backend/app/modules/authorization/runtime.py b/backend/app/modules/authorization/runtime.py index cb3dbf81e..aff1e43ca 100644 --- a/backend/app/modules/authorization/runtime.py +++ b/backend/app/modules/authorization/runtime.py @@ -27,7 +27,7 @@ from app.modules.authorization.domain.project_create import ProjectCreateResourceContext from app.modules.authorization.domain import ( checker_registry, checker_retry, checker_retry_reads, contribution_reads, - contributor_reads, management_reads, submission_history, task_authority, task_queues, + model_usage as checker_models, checker_outputs, contributor_reads, management_reads, submission_history, task_authority, task_queues, post_submit_deliveries, project_catalogue, ) @@ -1063,6 +1063,9 @@ class PreSubmitCheckerInputResourceContext(PreSubmitCheckerInputPreparationConte AuthorizationResourceContext = ( ReviewLifecycleActivationContract | contribution_reads.ContributionSelfReadResourceContext | contribution_reads.ContributionProjectReadResourceContext + | checker_models.CheckerModelCallResourceContext + | checker_models.ProjectModelAdministrationResourceContext + | checker_outputs.CheckerOutputResourceContext | contributor_reads.ActorProjectListResourceContext | contributor_reads.ContributorWorkReadResourceContext | project_catalogue.ProjectCatalogueResourceContext | management_reads.ProjectManagementReadResourceContext | diff --git a/backend/app/modules/checkers/api/execution.py b/backend/app/modules/checkers/api/execution.py index 6cb47b74e..205e93e7b 100644 --- a/backend/app/modules/checkers/api/execution.py +++ b/backend/app/modules/checkers/api/execution.py @@ -25,6 +25,7 @@ from app.modules.checkers.api.delivery_reconciliation import ( PostSubmitRequestDeliveryFacts, ) +from app.modules.model_usage.api import ModelRecoveryCandidate REQUEST_EVENT = "PostSubmissionEvaluationRequested" COMPLETION_EVENT = "PostSubmissionEvaluationCompleted" @@ -173,6 +174,7 @@ class CheckerRetrySourceItem(CheckerValue): completed_at: AwareDatetime retry_receipt_id: ResourceId | None = None successor: CheckerRetrySuccessor | None = None + model_recovery_candidates: tuple[ModelRecoveryCandidate, ...] = () @model_validator(mode="after") def exact_retry_state(self): diff --git a/backend/app/modules/checkers/api/external_policy.py b/backend/app/modules/checkers/api/external_policy.py index 651e51f9a..47b0bf859 100644 --- a/backend/app/modules/checkers/api/external_policy.py +++ b/backend/app/modules/checkers/api/external_policy.py @@ -4,6 +4,7 @@ import json from typing import Literal, Self +from uuid import UUID from pydantic import BaseModel, ConfigDict, Field, JsonValue, model_validator @@ -20,6 +21,12 @@ ExternalCheckerRegistryPort, external_checker_json_hash, ) +from app.modules.model_usage.api import ModelProfileReaderPort +from app.modules.checkers.api.model_profiles import ( + ModelProfileConfigurationError, + configured_model_profile_selector, + require_model_profile_configuration, +) WORKSTREAM_DEFAULT_EXECUTION_BUDGET_MS = 60_000 @@ -60,6 +67,10 @@ def validate_binding(self) -> Self: if self.registry.phase != self.stage: raise ExternalCheckerPolicyError("checker binding phase mismatch") self.registry.configuration_schema.validate_instance(self.configuration) + try: + configured_model_profile_selector(self.configuration, stage=self.stage) + except ModelProfileConfigurationError as exc: + raise ExternalCheckerPolicyError(str(exc)) from exc if self.configuration_sha256 != external_checker_json_hash(self.configuration): raise ExternalCheckerPolicyError("checker binding configuration mismatch") return self @@ -185,6 +196,9 @@ def _validate_entries( async def compile_external_checker_policies( result: object, registry: ExternalCheckerRegistryPort, + *, + project_id: UUID | None = None, + profiles: ModelProfileReaderPort | None = None, ) -> tuple[LockedExternalPreSubmitPolicy, LockedExternalPostSubmitPolicy]: """Resolve every supported binding to exact immutable registry custody.""" from app.interfaces.project_agents import ProjectGuideCompilationResult @@ -195,8 +209,22 @@ async def compile_external_checker_policies( requirements = {item.requirement_id: item for item in result.requirements} if len(requirements) != len(result.requirements): raise ExternalCheckerPolicyError("checker requirements are duplicated") - pre = await _resolve(result.pre_submit_bindings, requirements, registry, "pre_submit") - post = await _resolve(result.post_submit_bindings, requirements, registry, "post_submit") + pre = await _resolve( + result.pre_submit_bindings, + requirements, + registry, + "pre_submit", + project_id=project_id, + profiles=profiles, + ) + post = await _resolve( + result.post_submit_bindings, + requirements, + registry, + "post_submit", + project_id=project_id, + profiles=profiles, + ) return _make_policy(LockedExternalPreSubmitPolicy, pre), _make_policy( LockedExternalPostSubmitPolicy, post ) @@ -205,9 +233,17 @@ async def compile_external_checker_policies( async def compile_external_checker_policy_bundle( result: object, registry: ExternalCheckerRegistryPort, + *, + project_id: UUID | None = None, + profiles: ModelProfileReaderPort | None = None, ) -> LockedExternalCheckerPolicies: """Lock both execution phases into one immutable approval-time value.""" - pre, post = await compile_external_checker_policies(result, registry) + pre, post = await compile_external_checker_policies( + result, + registry, + project_id=project_id, + profiles=profiles, + ) body = { "schema_version": "external_checker_policies.v1", "pre_submit": pre.model_dump(mode="json"), @@ -220,7 +256,15 @@ async def compile_external_checker_policy_bundle( ) -async def _resolve(bindings, requirements, registry, phase): +async def _resolve( + bindings, + requirements, + registry, + phase, + *, + project_id: UUID | None, + profiles: ModelProfileReaderPort | None, +): from app.interfaces.project_agents import ( RequirementDisposition, ) @@ -235,9 +279,10 @@ async def _resolve(bindings, requirements, registry, phase): bindings, key=lambda item: (item.requirement_id, item.capability_id, item.capability_version), ): + requirement = requirements.get(binding.requirement_id) if ( - requirements.get(binding.requirement_id) is None - or requirements[binding.requirement_id].disposition is not expected + requirement is None + or requirement.disposition is not expected or binding.stage.value != phase ): raise ExternalCheckerPolicyError("checker binding does not cover requirement") @@ -256,6 +301,29 @@ async def _resolve(bindings, requirements, registry, phase): parameter.name: parameter.model_dump(mode="json")["value"] for parameter in binding.parameters } + try: + selector = configured_model_profile_selector(configuration, stage=phase) + specification = requirement.check_specification + model_required = specification is not None and any( + dependency.kind == "model_service" + for dependency in specification.runtime_dependencies + ) + if model_required != (selector is not None): + raise ModelProfileConfigurationError( + "model evaluation profile configuration is invalid" + ) + if selector is not None: + if project_id is None: + raise ModelProfileConfigurationError("model profile project is unavailable") + await require_model_profile_configuration( + configuration, + stage=phase, + project_id=project_id, + registry=entry, + profiles=profiles, + ) + except ModelProfileConfigurationError as exc: + raise ExternalCheckerPolicyError(str(exc)) from exc resolved.append( LockedExternalCheckerBinding( requirement_id=binding.requirement_id, @@ -273,6 +341,27 @@ async def _resolve(bindings, requirements, registry, phase): return tuple(resolved) +async def require_locked_model_profiles( + policies: LockedExternalCheckerPolicies, + *, + project_id: UUID, + profiles: ModelProfileReaderPort | None, +) -> None: + """Revalidate every exact selected profile before readiness or activation.""" + policies = LockedExternalCheckerPolicies.model_validate(policies) + for binding in (*policies.pre_submit.entries, *policies.post_submit.entries): + try: + await require_model_profile_configuration( + binding.configuration, + stage=binding.stage, + project_id=project_id, + registry=binding.registry, + profiles=profiles, + ) + except ModelProfileConfigurationError as exc: + raise ExternalCheckerPolicyError(str(exc)) from exc + + def _make_policy(model, entries): hash_body = { "schema_version": model.model_fields["schema_version"].default, diff --git a/backend/app/modules/checkers/api/model_profiles.py b/backend/app/modules/checkers/api/model_profiles.py new file mode 100644 index 000000000..1fc5f47d3 --- /dev/null +++ b/backend/app/modules/checkers/api/model_profiles.py @@ -0,0 +1,98 @@ +"""Reserved external-checker configuration for exact project model profiles.""" + +from __future__ import annotations + +from collections.abc import Mapping +from uuid import UUID + +from pydantic import JsonValue, ValidationError + +from app.modules.checkers.api.external import ( + ExternalCheckerPhase, + ExternalCheckerRegistryEntry, +) +from app.modules.model_usage.api import ( + ModelCallUnavailable, + ModelProfileReaderPort, + ModelProfileSelector, +) + + +MODEL_PROFILE_CONFIGURATION_KEYS = frozenset( + {"model_profile_id", "model_profile_version", "model_profile_digest"} +) + + +class ModelProfileConfigurationError(ValueError): + """Reject partial, phase-substituted or unavailable profile selection.""" + + +def configured_model_profile_selector( + configuration: Mapping[str, JsonValue], + *, + stage: ExternalCheckerPhase, +) -> ModelProfileSelector | None: + """Decode the reserved all-or-none selector without affecting ordinary images.""" + present = MODEL_PROFILE_CONFIGURATION_KEYS.intersection(configuration) + if not present: + return None + if present != MODEL_PROFILE_CONFIGURATION_KEYS or stage != "post_submit": + raise ModelProfileConfigurationError("model profile configuration is invalid") + profile_id = configuration["model_profile_id"] + profile_version = configuration["model_profile_version"] + profile_digest = configuration["model_profile_digest"] + if ( + type(profile_id) is not str + or type(profile_version) is not int + or type(profile_digest) is not str + ): + raise ModelProfileConfigurationError("model profile configuration is invalid") + try: + canonical_profile_id = UUID(profile_id) + if str(canonical_profile_id) != profile_id: + raise ValueError("model profile ID is not canonical") + return ModelProfileSelector( + profile_id=canonical_profile_id, + profile_version=profile_version, + profile_digest=profile_digest, + ) + except (ValueError, ValidationError) as exc: + raise ModelProfileConfigurationError("model profile configuration is invalid") from exc + + +async def require_model_profile_configuration( + configuration: Mapping[str, JsonValue], + *, + stage: ExternalCheckerPhase, + project_id: UUID, + registry: ExternalCheckerRegistryEntry, + profiles: ModelProfileReaderPort | None, +) -> ModelProfileSelector | None: + """Resolve a model-enabled binding to one exact immutable approved profile.""" + selector = configured_model_profile_selector(configuration, stage=stage) + if selector is None: + return None + if profiles is None: + raise ModelProfileConfigurationError("model profile reader is unavailable") + try: + profile = await profiles.read_exact( + selector, + project_id=project_id, + ) + except ModelCallUnavailable as exc: + # Registry/persistence details remain concealed at the policy boundary. + raise ModelProfileConfigurationError("model profile is unavailable") from exc + if ( + profile.selector != selector + or profile.spec.project_id != project_id + ): + raise ModelProfileConfigurationError("model profile is unavailable") + return selector + + +__all__ = ( + "MODEL_PROFILE_CONFIGURATION_KEYS", + "ModelProfileConfigurationError", + "configured_model_profile_selector", + "require_model_profile_configuration", +) diff --git a/backend/app/modules/checkers/api/output_custody.py b/backend/app/modules/checkers/api/output_custody.py index c0a8fcc9b..569a09d61 100644 --- a/backend/app/modules/checkers/api/output_custody.py +++ b/backend/app/modules/checkers/api/output_custody.py @@ -1,18 +1,23 @@ -"""CHECKERS-owned output custody contracts; production reservations are unavailable.""" +"""Call-stable private model evidence through CHECKERS and existing ART custody.""" from collections.abc import AsyncIterable from dataclasses import dataclass from typing import Annotated, Literal, Protocol, Self from uuid import UUID -from pydantic import Field, StrictInt, model_validator +from pydantic import Field, StrictInt, TypeAdapter, model_validator -from app.modules.checkers.api.evaluation_contract import PostSubmissionExecutionRequest +from app.core.hashing import canonical_json_hash from app.modules.checkers.api.canonical_values import ( CheckerValue, - Identifier, ResourceId, - VersionNumber, +) +from app.modules.model_usage.api import ( + ModelCallEvidence, + ModelCallExecutionFacts, + ModelPaidSlotKey, + SetupModelOutputReservation, + SetupModelOutputSelector, ) @@ -21,59 +26,69 @@ class CheckerOutputUnavailable(RuntimeError): class CheckerOutputSelector(CheckerValue): - """Select exact immutable evaluation and the caller's claimed execution lease.""" + """Select one call effect's bytes without tying late completion to an old run.""" - evaluation: PostSubmissionExecutionRequest - checker_run_id: ResourceId - worker_lease_id: ResourceId - worker_lease_generation: VersionNumber - slot_key: Identifier + paid_slot: ModelPaidSlotKey + task_id: ResourceId + model_call_slot_id: ResourceId + model_call_attempt_id: ResourceId + slot_key: Literal["model_request", "model_response"] + content_sha256: str = Field(pattern=r"^sha256:[0-9a-f]{64}$") + execution: ModelCallExecutionFacts | None = None + + @model_validator(mode="after") + def exact_execution_scope(self) -> Self: + """Request writes need a current invocation; effect responses do not.""" + if self.slot_key == "model_response": + if self.execution is not None: + raise ValueError("model response custody is call-effect scoped") + return self + execution = self.execution + if execution is None or ( + execution.project_id, + execution.task_id, + execution.assignment_id, + execution.submission_id, + execution.submission_version, + ) != ( + self.paid_slot.project_id, + self.task_id, + self.paid_slot.assignment_id, + self.paid_slot.submission_id, + self.paid_slot.submission_version, + ): + raise ValueError("model request custody requires exact execution facts") + return self class CheckerOutputSlot(CheckerValue): - """One globally unique run slot issued by CHECKERS, not by a contributor.""" + """One bounded canonical JSON role issued by the call-effect owner.""" - key: Identifier - media_type: Literal["application/json", "application/octet-stream", "text/plain"] - maximum_bytes: Annotated[StrictInt, Field(ge=1, le=536_870_912)] + key: Literal["model_request", "model_response"] + media_type: Literal["application/json"] = "application/json" + maximum_bytes: Annotated[StrictInt, Field(ge=1, le=1_048_576)] class CheckerOutputReservation(CheckerValue): """Detached owner facts, never a substitute for fresh action authority.""" - evaluation: PostSubmissionExecutionRequest - checker_run_id: ResourceId - worker_lease_id: ResourceId - worker_lease_generation: VersionNumber - slots: tuple[CheckerOutputSlot, ...] = Field(max_length=64) + selector: CheckerOutputSelector + slot: CheckerOutputSlot @model_validator(mode="after") - def unique_slots(self) -> Self: - """Keep generic ART binding's run/role scope unambiguous.""" - if len({slot.key for slot in self.slots}) != len(self.slots): - raise ValueError("checker output slots repeat") + def exact_role(self) -> Self: + """Reserve exactly the requested role; never grant another evidence slot.""" + if self.slot.key != self.selector.slot_key: + raise ValueError("checker output role differs from reservation") return self def select(self, selector: CheckerOutputSelector) -> CheckerOutputSlot: - """Match the caller's claimed lease as well as immutable evaluation facts.""" + """Match exact call custody and the authority phase selected by its owner.""" current = CheckerOutputReservation.model_validate(self) selector = CheckerOutputSelector.model_validate(selector) - if ( - current.evaluation, - current.checker_run_id, - current.worker_lease_id, - current.worker_lease_generation, - ) != ( - selector.evaluation, - selector.checker_run_id, - selector.worker_lease_id, - selector.worker_lease_generation, - ): + if current.selector != selector: raise CheckerOutputUnavailable("checker_output_reservation_unavailable") - for slot in current.slots: - if slot.key == selector.slot_key: - return slot - raise CheckerOutputUnavailable("checker_output_slot_unavailable") + return current.slot class CheckerOutputReservationPort(Protocol): @@ -83,6 +98,13 @@ async def resolve(self, selector: CheckerOutputSelector) -> CheckerOutputReserva """Return exact current facts under the caller's CHECKERS transaction locks.""" +class CheckerOutputTaskCustodyPort(Protocol): + """Retain historical TASK ancestry before call locks or ART foreign-key writes.""" + + async def lock_model_evidence_scope(self, selector: CheckerOutputSelector) -> None: + """Require exact owners without requiring the original run to remain active.""" + + @dataclass(frozen=True, slots=True) class CheckerOutputBindingRequest: """Bind one exact independently verified output in the caller transaction.""" @@ -108,6 +130,8 @@ class CheckerOutputArtifactResult: status: str content_id: UUID | None verification_receipt_id: UUID | None + content_sha256: str + byte_count: int replayed: bool @@ -134,12 +158,101 @@ async def bind_checker_output( class CheckerArtifactOutputPort(Protocol): """Store generated output for one fixed checker execution.""" - async def store( - self, request: CheckerOutputArtifactRequest - ) -> CheckerOutputArtifactResult: + async def store(self, request: CheckerOutputArtifactRequest) -> CheckerOutputArtifactResult: """Store one generated checker artifact.""" - async def recover( - self, selector: CheckerOutputSelector - ) -> CheckerOutputArtifactResult | None: + async def recover(self, selector: CheckerOutputSelector) -> CheckerOutputArtifactResult | None: """Recover a lost output response without a byte source or regeneration.""" + + async def read_response( + self, + selector: CheckerOutputSelector, + evidence: ModelCallEvidence, + ) -> bytes: + """Read one authorized private response with exact byte/hash verification.""" + + +GeneratedOutputSelector = CheckerOutputSelector | SetupModelOutputSelector +GeneratedOutputReservation = CheckerOutputReservation | SetupModelOutputReservation +GENERATED_OUTPUT_SELECTOR = TypeAdapter(GeneratedOutputSelector) +GENERATED_OUTPUT_RESERVATION = TypeAdapter(GeneratedOutputReservation) + + +@dataclass(frozen=True, slots=True) +class GeneratedOutputSubject: + """Closed native lineage for the shared ART generated-output pipeline.""" + + producer_request_type: Literal["checker_output", "setup_model_output"] + project_id: UUID + task_id: UUID | None + submission_id: UUID | None + submission_version: int | None + resource_type: Literal["checker_model_call", "setup_model_call"] + resource_id: UUID + logical_role: Literal["model_request", "model_response"] + operation_identity: str + request_digest_facts: dict[str, object] + + +def generated_output_subject( + reservation: GeneratedOutputReservation, +) -> GeneratedOutputSubject: + """Preserve checker identities and add one exact setup-effect subject.""" + reservation = GENERATED_OUTPUT_RESERVATION.validate_python(reservation) + selector, slot = reservation.selector, reservation.slot + if isinstance(reservation, SetupModelOutputReservation): + key = selector.slot_key + return GeneratedOutputSubject( + "setup_model_output", + key.project_id, + None, + None, + None, + "setup_model_call", + selector.model_call_attempt_id, + slot.key, + canonical_json_hash( + { + "request_type": "setup_model_output", + "model_call_attempt_id": str(selector.model_call_attempt_id), + "logical_role": slot.key, + } + ), + { + "slot_key": key.model_dump(mode="json"), + "model_call_slot_id": str(selector.model_call_slot_id), + "model_call_attempt_id": str(selector.model_call_attempt_id), + "content_sha256": selector.content_sha256, + "role": slot.key, + "media_type": slot.media_type, + "maximum_bytes": slot.maximum_bytes, + }, + ) + paid = selector.paid_slot + return GeneratedOutputSubject( + "checker_output", + paid.project_id, + selector.task_id, + paid.submission_id, + paid.submission_version, + "checker_model_call", + selector.model_call_attempt_id, + slot.key, + canonical_json_hash( + { + "request_type": "checker_output", + "model_call_attempt_id": str(selector.model_call_attempt_id), + "logical_role": slot.key, + } + ), + { + "model_call_slot_id": str(selector.model_call_slot_id), + "model_call_attempt_id": str(selector.model_call_attempt_id), + "paid_slot": paid.model_dump(mode="json"), + "task_id": str(selector.task_id), + "content_sha256": selector.content_sha256, + "slot_key": slot.key, + "media_type": slot.media_type, + "maximum_bytes": slot.maximum_bytes, + }, + ) diff --git a/backend/app/modules/checkers/checker_retry.py b/backend/app/modules/checkers/checker_retry.py index 1e76f99fd..44b345479 100644 --- a/backend/app/modules/checkers/checker_retry.py +++ b/backend/app/modules/checkers/checker_retry.py @@ -12,29 +12,49 @@ from app.core.hashing import canonical_json_hash from app.core.identifiers import new_record_id from app.modules.checkers.api.execution import ( - CheckerExecutionUnavailable, CheckerRequestConflict, CheckerRetryAuditPort, - CheckerRetryAuthorizationPort, CheckerRetryCommand, CheckerRetryDelivery, - CheckerRetryDiscoveryAuthorizationPort, CheckerRetryAuthorityDenied, + CheckerExecutionUnavailable, + CheckerRequestConflict, + CheckerRetryAuditPort, + CheckerRetryAuthorizationPort, + CheckerRetryCommand, + CheckerRetryDelivery, + CheckerRetryDiscoveryAuthorizationPort, + CheckerRetryAuthorityDenied, CheckerRetryDiscoverySource, CheckerRetryEventExpectation, CheckerRetryOperationUnavailable, - CheckerRetryPreparation, CheckerRetryResult, CheckerRetrySource, + CheckerRetryPreparation, + CheckerRetryResult, + CheckerRetrySource, CheckerRetrySourceUnavailable, - CheckerRetrySourceItem, CheckerRetrySourcePage, CheckerRetrySourcePosition, - CheckerRetrySourceQuery, CheckerRetrySuccessor, EvaluationTaskGuard, + CheckerRetrySourceItem, + CheckerRetrySourcePage, + CheckerRetrySourcePosition, + CheckerRetrySourceQuery, + CheckerRetrySuccessor, + EvaluationTaskGuard, ) from app.modules.checkers.api.evaluation_contract import PostSubmissionExecutionRequest from app.modules.checkers.api.selected_post_submit import make_selected_post_submit_request from app.modules.checkers.execution_coordination import EvaluationCoordinator from app.modules.checkers.execution_repository import ( - require_transaction, stored_request, stored_result, + require_transaction, + stored_request, + stored_result, ) from app.modules.checkers.models import ( - CheckerRetryReceipt, CheckerRun, CheckerSubmissionFence, + CheckerRetryReceipt, + CheckerRun, + CheckerSubmissionFence, ) +from app.modules.model_usage.api import ModelRecoverySourcePort from app.modules.outbox.api import ( - OutboxAppendInput, OutboxAppendPort, OutboxAppendResult, - OutboxIdempotencyConflict, OutboxInputError, OutboxPersistenceError, + OutboxAppendInput, + OutboxAppendPort, + OutboxAppendResult, + OutboxIdempotencyConflict, + OutboxInputError, + OutboxPersistenceError, ) @@ -43,18 +63,19 @@ def require_finalized_infrastructure_source( ) -> tuple[PostSubmissionExecutionRequest, CheckerRetrySource]: """Require a real terminal result/finalization before any new Operator effect.""" if ( - run.status != "infrastructure_failed" or run.finalize_evidence_id is None - or run.result_json is None or run.result_digest is None - or run.completion_event_id is not None or run.failure_code is None + run.status != "infrastructure_failed" + or run.finalize_evidence_id is None + or run.result_json is None + or run.result_digest is None + or run.completion_event_id is not None + or run.failure_code is None ): raise CheckerRetrySourceUnavailable("checker_retry_source_unavailable") try: request = stored_request(run) result = stored_result(run) except CheckerExecutionUnavailable as exc: - raise CheckerRetryOperationUnavailable( - "checker_retry_evidence_unavailable" - ) from exc + raise CheckerRetryOperationUnavailable("checker_retry_evidence_unavailable") from exc if result.outcome != "infrastructure_failed": raise CheckerRetrySourceUnavailable("checker_retry_source_unavailable") if ( @@ -64,26 +85,30 @@ def require_finalized_infrastructure_source( raise CheckerRetryOperationUnavailable("checker_retry_evidence_unavailable") try: source = CheckerRetrySource( - project_id=request.project_id, task_id=request.task_id, - assignment_id=request.assignment_id, submission_id=request.submission_id, + project_id=request.project_id, + task_id=request.task_id, + assignment_id=request.assignment_id, + submission_id=request.submission_id, submission_version=request.submission_version, - source_run_id=UUID(run.id), source_request_id=request.evaluation_request_id, + source_run_id=UUID(run.id), + source_request_id=request.evaluation_request_id, source_request_digest=request.request_sha256, - source_result_id=UUID(run.result_id), source_result_digest=run.result_digest, + source_result_id=UUID(run.result_id), + source_result_digest=run.result_digest, source_finalize_evidence_id=UUID(run.finalize_evidence_id), - source_generation=run.evaluation_generation, failure_code=run.failure_code, + source_generation=run.evaluation_generation, + failure_code=run.failure_code, ) except (TypeError, ValueError) as exc: - raise CheckerRetryOperationUnavailable( - "checker_retry_evidence_unavailable" - ) from exc + raise CheckerRetryOperationUnavailable("checker_retry_evidence_unavailable") from exc return request, source def _source_from_receipt(receipt: CheckerRetryReceipt) -> CheckerRetrySource: try: return CheckerRetrySource( - project_id=UUID(receipt.project_id), task_id=UUID(receipt.task_id), + project_id=UUID(receipt.project_id), + task_id=UUID(receipt.task_id), assignment_id=UUID(receipt.assignment_id), submission_id=UUID(receipt.submission_id), submission_version=receipt.submission_version, @@ -93,12 +118,11 @@ def _source_from_receipt(receipt: CheckerRetryReceipt) -> CheckerRetrySource: source_result_id=UUID(receipt.source_result_id), source_result_digest=receipt.source_result_digest, source_finalize_evidence_id=UUID(receipt.source_finalize_evidence_id), - source_generation=receipt.source_generation, failure_code=receipt.failure_code, + source_generation=receipt.source_generation, + failure_code=receipt.failure_code, ) except (TypeError, ValueError) as exc: - raise CheckerRetryOperationUnavailable( - "checker_retry_replay_unavailable" - ) from exc + raise CheckerRetryOperationUnavailable("checker_retry_replay_unavailable") from exc def _successor_from_receipt(receipt: CheckerRetryReceipt) -> CheckerRetrySuccessor: @@ -113,29 +137,37 @@ def _successor_from_receipt(receipt: CheckerRetryReceipt) -> CheckerRetrySuccess audit_event_id=UUID(receipt.audit_event_id), ) except (TypeError, ValueError) as exc: - raise CheckerRetryOperationUnavailable( - "checker_retry_replay_unavailable" - ) from exc + raise CheckerRetryOperationUnavailable("checker_retry_replay_unavailable") from exc def retry_request_event( - source: CheckerRetrySource, *, receipt_id: UUID, request_id: UUID, - request_digest: str, run_id: UUID, result_id: UUID, + source: CheckerRetrySource, + *, + receipt_id: UUID, + request_id: UUID, + request_digest: str, + run_id: UUID, + result_id: UUID, ) -> OutboxAppendInput: """One v1 request event schema for creation and later exact delivery.""" return OutboxAppendInput( - event_type="PostSubmissionEvaluationRequested", event_version=1, - aggregate_type="submission", aggregate_id=source.submission_id, + event_type="PostSubmissionEvaluationRequested", + event_version=1, + aggregate_type="submission", + aggregate_id=source.submission_id, project_id=source.project_id, correlation_id=str(source.source_run_id), idempotency_key="checker-retry:" + str(source.source_run_id), payload={ - "project_id": str(source.project_id), "task_id": str(source.task_id), + "project_id": str(source.project_id), + "task_id": str(source.task_id), "submission_id": str(source.submission_id), "submission_version": source.submission_version, - "request_id": str(request_id), "request_digest": request_digest, + "request_id": str(request_id), + "request_digest": request_digest, "evaluation_generation": source.source_generation + 1, - "attempt_id": str(run_id), "result_id": str(result_id), + "attempt_id": str(run_id), + "result_id": str(result_id), "source_run_id": str(source.source_run_id), "source_result_digest": source.source_result_digest, "retry_receipt_id": str(receipt_id), @@ -144,39 +176,56 @@ def retry_request_event( async def _append_retry_event( - events: OutboxAppendPort, source: CheckerRetrySource, *, receipt_id: UUID, - request_id: UUID, request_digest: str, run_id: UUID, result_id: UUID, + events: OutboxAppendPort, + source: CheckerRetrySource, + *, + receipt_id: UUID, + request_id: UUID, + request_digest: str, + run_id: UUID, + result_id: UUID, ) -> OutboxAppendResult: """Normalize exact OUTBOX contract failures into retry-owner unavailability.""" try: - return await events.append(retry_request_event( - source, receipt_id=receipt_id, request_id=request_id, - request_digest=request_digest, run_id=run_id, result_id=result_id, - )) + return await events.append( + retry_request_event( + source, + receipt_id=receipt_id, + request_id=request_id, + request_digest=request_digest, + run_id=run_id, + result_id=result_id, + ) + ) except ( OutboxIdempotencyConflict, OutboxInputError, OutboxPersistenceError, ) as exc: - raise CheckerRetryOperationUnavailable( - "checker_retry_outbox_unavailable" - ) from exc + raise CheckerRetryOperationUnavailable("checker_retry_outbox_unavailable") from exc class CheckerRetryService: """Compose existing TASK, AUTH, CHECKERS, OUTBOX and AUDIT owner operations.""" def __init__( - self, session: AsyncSession, *, actor_profile_id: UUID, - tasks: EvaluationTaskGuard, evaluations: EvaluationCoordinator, + self, + session: AsyncSession, + *, + actor_profile_id: UUID, + tasks: EvaluationTaskGuard, + evaluations: EvaluationCoordinator, authorization: CheckerRetryAuthorizationPort, discovery: CheckerRetryDiscoveryAuthorizationPort, - events: OutboxAppendPort, audit: CheckerRetryAuditPort, + events: OutboxAppendPort, + audit: CheckerRetryAuditPort, + model_recovery: ModelRecoverySourcePort, ) -> None: self._session, self._actor_profile_id = session, actor_profile_id self._tasks, self._evaluations = tasks, evaluations self._authorization, self._discovery = authorization, discovery self._events, self._audit = events, audit + self._model_recovery = model_recovery async def discover(self, query: CheckerRetrySourceQuery) -> CheckerRetrySourcePage: """List only retained finalized infrastructure failures under fresh authority.""" @@ -200,9 +249,7 @@ async def discover(self, query: CheckerRetrySourceQuery) -> CheckerRetrySourcePa CheckerRun.completed_at.label("completed_at"), CheckerRetryReceipt.id.label("retry_receipt_id"), CheckerRetryReceipt.successor_request_id.label("successor_request_id"), - CheckerRetryReceipt.successor_request_digest.label( - "successor_request_digest" - ), + CheckerRetryReceipt.successor_request_digest.label("successor_request_digest"), CheckerRetryReceipt.successor_run_id.label("successor_run_id"), CheckerRetryReceipt.successor_result_id.label("successor_result_id"), CheckerRetryReceipt.successor_generation.label("successor_generation"), @@ -236,10 +283,17 @@ async def discover(self, query: CheckerRetrySourceQuery) -> CheckerRetrySourcePa > tuple_(after.created_at, str(after.source_run_id)) ) rows = ( - await self._session.execute( - statement.order_by(CheckerRun.created_at, CheckerRun.id).limit(query.limit + 1) + ( + await self._session.execute( + statement.order_by(CheckerRun.created_at, CheckerRun.id).limit(query.limit + 1) + ) ) - ).mappings().all() + .mappings() + .all() + ) + recovery_candidates = await self._model_recovery.recovery_candidates( + tuple(UUID(row.source_run_id) for row in rows[: query.limit]), + ) items = [] for row in rows[: query.limit]: source = CheckerRetryDiscoverySource( @@ -272,20 +326,19 @@ async def discover(self, query: CheckerRetrySourceQuery) -> CheckerRetrySourcePa ), ) else: - state = ( - "eligible" - if row.current_run_id == row.source_run_id - else "not_current" - ) + state = "eligible" if row.current_run_id == row.source_run_id else "not_current" receipt_id, successor = None, None - items.append(CheckerRetrySourceItem( - source=source, - recovery_state=state, - created_at=row.created_at, - completed_at=row.completed_at, - retry_receipt_id=receipt_id, - successor=successor, - )) + items.append( + CheckerRetrySourceItem( + source=source, + recovery_state=state, + created_at=row.created_at, + completed_at=row.completed_at, + retry_receipt_id=receipt_id, + successor=successor, + model_recovery_candidates=recovery_candidates.get(UUID(row.source_run_id), ()), + ) + ) next_cursor = None if len(rows) > query.limit: last = items[-1] @@ -307,34 +360,38 @@ async def retry(self, command: CheckerRetryCommand) -> CheckerRetryResult: require_transaction(self._session) command = CheckerRetryCommand.model_validate(command) # Detached selectors only: no CHECKERS lock, AUTH custody or decision yet. - existing = await self._session.scalar(select(CheckerRetryReceipt).where( - CheckerRetryReceipt.actor_profile_id == str(self._actor_profile_id), - CheckerRetryReceipt.request_key == str(command.request_key), - ).execution_options(populate_existing=True)) + existing = await self._session.scalar( + select(CheckerRetryReceipt) + .where( + CheckerRetryReceipt.actor_profile_id == str(self._actor_profile_id), + CheckerRetryReceipt.request_key == str(command.request_key), + ) + .execution_options(populate_existing=True) + ) if existing is not None and existing.project_id != str(command.project_id): raise CheckerRetrySourceUnavailable("checker_retry_source_unavailable") if existing is not None: try: source_id = UUID(existing.source_run_id) except (TypeError, ValueError) as exc: - raise CheckerRetryOperationUnavailable( - "checker_retry_replay_unavailable" - ) from exc + raise CheckerRetryOperationUnavailable("checker_retry_replay_unavailable") from exc else: source_id = command.source_run_id - source_run = await self._session.scalar(select(CheckerRun).where( - CheckerRun.id == str(source_id), - CheckerRun.project_id == str(command.project_id), - CheckerRun.phase == "post_submission", - ).execution_options(populate_existing=True)) + source_run = await self._session.scalar( + select(CheckerRun) + .where( + CheckerRun.id == str(source_id), + CheckerRun.project_id == str(command.project_id), + CheckerRun.phase == "post_submission", + ) + .execution_options(populate_existing=True) + ) if source_run is None: raise CheckerRetrySourceUnavailable("checker_retry_source_unavailable") try: source_request = stored_request(source_run) except CheckerExecutionUnavailable as exc: - raise CheckerRetryOperationUnavailable( - "checker_retry_evidence_unavailable" - ) from exc + raise CheckerRetryOperationUnavailable("checker_retry_evidence_unavailable") from exc # Both branches first hold the same historical TASK owner rows. A # contender that saw no detached receipt must reread it after this # lock: the first transaction may have committed while it waited. @@ -347,10 +404,14 @@ async def retry(self, command: CheckerRetryCommand) -> CheckerRetryResult: else CheckerRetrySourceUnavailable ) raise unavailable("checker_retry_scope_unavailable") from exc - existing = await self._session.scalar(select(CheckerRetryReceipt).where( - CheckerRetryReceipt.actor_profile_id == str(self._actor_profile_id), - CheckerRetryReceipt.request_key == str(command.request_key), - ).execution_options(populate_existing=True)) + existing = await self._session.scalar( + select(CheckerRetryReceipt) + .where( + CheckerRetryReceipt.actor_profile_id == str(self._actor_profile_id), + CheckerRetryReceipt.request_key == str(command.request_key), + ) + .execution_options(populate_existing=True) + ) if existing is not None and ( existing.source_run_id != str(command.source_run_id) or existing.reason != command.reason @@ -360,14 +421,16 @@ async def retry(self, command: CheckerRetryCommand) -> CheckerRetryResult: try: await self._tasks.lock_terminal_retry_scope(source_request) except CheckerExecutionUnavailable as exc: - raise CheckerRetrySourceUnavailable( - "checker_retry_source_unavailable" - ) from exc - source_run = await self._session.scalar(select(CheckerRun).where( - CheckerRun.id == str(source_id), - CheckerRun.project_id == str(command.project_id), - CheckerRun.phase == "post_submission", - ).execution_options(populate_existing=True)) + raise CheckerRetrySourceUnavailable("checker_retry_source_unavailable") from exc + source_run = await self._session.scalar( + select(CheckerRun) + .where( + CheckerRun.id == str(source_id), + CheckerRun.project_id == str(command.project_id), + CheckerRun.phase == "post_submission", + ) + .execution_options(populate_existing=True) + ) if source_run is None: raise CheckerRetrySourceUnavailable("checker_retry_source_unavailable") locked_request, source = require_finalized_infrastructure_source(source_run) @@ -377,21 +440,28 @@ async def retry(self, command: CheckerRetryCommand) -> CheckerRetryResult: return await self._replay(command, existing, source) preparation = CheckerRetryPreparation( actor_profile_id=self._actor_profile_id, - request_key=command.request_key, reason=command.reason, source=source, + request_key=command.request_key, + reason=command.reason, + source=source, ) handle = await self._authorization.prepare(preparation) try: request_fields = { - **source_request.model_dump(exclude={ - "evaluation_request_id", "evaluation_generation", "request_sha256", - }), + **source_request.model_dump( + exclude={ + "evaluation_request_id", + "evaluation_generation", + "request_sha256", + } + ), "evaluation_request_id": new_record_id(), "evaluation_generation": source.source_generation + 1, } next_request = make_selected_post_submit_request(**request_fields) try: reserved = await self._evaluations._reserve_after_task_lock( - next_request, terminal_source_id=source.source_run_id, + next_request, + terminal_source_id=source.source_run_id, ) except CheckerRetrySourceUnavailable: raise @@ -404,29 +474,36 @@ async def retry(self, command: CheckerRetryCommand) -> CheckerRetryResult: or reserved.request_digest != next_request.request_sha256 or reserved.evaluation_generation != source.source_generation + 1 ): - raise CheckerRetryOperationUnavailable( - "checker_retry_reservation_unavailable" - ) + raise CheckerRetryOperationUnavailable("checker_retry_reservation_unavailable") receipt_id = new_record_id() event = await _append_retry_event( - self._events, source, receipt_id=receipt_id, + self._events, + source, + receipt_id=receipt_id, request_id=reserved.request_id, request_digest=reserved.request_digest, - run_id=reserved.attempt_id, result_id=reserved.result_id, + run_id=reserved.attempt_id, + result_id=reserved.result_id, ) successor = CheckerRetrySuccessor( - request_id=reserved.request_id, request_digest=reserved.request_digest, - run_id=reserved.attempt_id, result_id=reserved.result_id, + request_id=reserved.request_id, + request_digest=reserved.request_digest, + run_id=reserved.attempt_id, + result_id=reserved.result_id, generation=reserved.evaluation_generation, - event_id=event.event_id, audit_event_id=new_record_id(), + event_id=event.event_id, + audit_event_id=new_record_id(), ) authority = await self._authorization.consume(handle, preparation, successor) await self._audit.record(preparation, successor, authority) receipt = CheckerRetryReceipt( - id=str(receipt_id), actor_profile_id=str(self._actor_profile_id), + id=str(receipt_id), + actor_profile_id=str(self._actor_profile_id), identity_link_id=str(authority.identity_link_id), - request_key=str(command.request_key), reason=command.reason, - project_id=str(source.project_id), task_id=str(source.task_id), + request_key=str(command.request_key), + reason=command.reason, + project_id=str(source.project_id), + task_id=str(source.task_id), assignment_id=str(source.assignment_id), submission_id=str(source.submission_id), submission_version=source.submission_version, @@ -436,7 +513,8 @@ async def retry(self, command: CheckerRetryCommand) -> CheckerRetryResult: source_result_id=str(source.source_result_id), source_result_digest=source.source_result_digest, source_finalize_evidence_id=str(source.source_finalize_evidence_id), - source_generation=source.source_generation, failure_code=source.failure_code, + source_generation=source.source_generation, + failure_code=source.failure_code, successor_run_id=str(successor.run_id), successor_request_id=str(successor.request_id), successor_request_digest=successor.request_digest, @@ -451,7 +529,9 @@ async def retry(self, command: CheckerRetryCommand) -> CheckerRetryResult: self._session.add(receipt) await self._session.flush() return CheckerRetryResult( - receipt_id=receipt_id, source=source, successor=successor, + receipt_id=receipt_id, + source=source, + successor=successor, authorization_decision_id=authority.decision_id, ) finally: @@ -466,48 +546,63 @@ async def restage_discovery_denial(self, error: Exception) -> bool: return await self._discovery.restage_denial(error) async def _replay( - self, command: CheckerRetryCommand, detached: CheckerRetryReceipt, + self, + command: CheckerRetryCommand, + detached: CheckerRetryReceipt, source: CheckerRetrySource, ) -> CheckerRetryResult: preparation = CheckerRetryPreparation( - actor_profile_id=self._actor_profile_id, request_key=command.request_key, - reason=command.reason, source=source, + actor_profile_id=self._actor_profile_id, + request_key=command.request_key, + reason=command.reason, + source=source, ) handle = await self._authorization.prepare(preparation) try: - receipt = await self._session.scalar(select(CheckerRetryReceipt).where( - CheckerRetryReceipt.id == detached.id, - CheckerRetryReceipt.actor_profile_id == str(self._actor_profile_id), - CheckerRetryReceipt.request_key == str(command.request_key), - ).with_for_update().execution_options(populate_existing=True)) - if receipt is None or _source_from_receipt(receipt) != source or receipt.reason != command.reason: - raise CheckerRetryOperationUnavailable( - "checker_retry_replay_unavailable" + receipt = await self._session.scalar( + select(CheckerRetryReceipt) + .where( + CheckerRetryReceipt.id == detached.id, + CheckerRetryReceipt.actor_profile_id == str(self._actor_profile_id), + CheckerRetryReceipt.request_key == str(command.request_key), ) + .with_for_update() + .execution_options(populate_existing=True) + ) + if ( + receipt is None + or _source_from_receipt(receipt) != source + or receipt.reason != command.reason + ): + raise CheckerRetryOperationUnavailable("checker_retry_replay_unavailable") successor = _successor_from_receipt(receipt) delivery = await read_retry_delivery( - self._session, self._evaluations, - project_id=source.project_id, submission_id=source.submission_id, + self._session, + self._evaluations, + project_id=source.project_id, + submission_id=source.submission_id, event_id=successor.event_id, ) # The reader already binds the retained run/result/generation to this receipt. if delivery is None or delivery.request.evaluation_request_id != successor.request_id: - raise CheckerRetryOperationUnavailable( - "checker_retry_replay_unavailable" - ) + raise CheckerRetryOperationUnavailable("checker_retry_replay_unavailable") try: identity_link_id = UUID(receipt.identity_link_id) decision_id = UUID(receipt.authorization_decision_id) receipt_id = UUID(receipt.id) except (TypeError, ValueError) as exc: - raise CheckerRetryOperationUnavailable( - "checker_retry_replay_unavailable" - ) from exc + raise CheckerRetryOperationUnavailable("checker_retry_replay_unavailable") from exc await self._authorization.validate_replay( - handle, preparation, successor, identity_link_id, decision_id, + handle, + preparation, + successor, + identity_link_id, + decision_id, ) return CheckerRetryResult( - receipt_id=receipt_id, source=source, successor=successor, + receipt_id=receipt_id, + source=source, + successor=successor, authorization_decision_id=decision_id, ) finally: @@ -515,27 +610,35 @@ async def _replay( async def read_retry_delivery( - session: AsyncSession, evaluations: EvaluationCoordinator, *, - project_id: UUID, submission_id: UUID, event_id: UUID, + session: AsyncSession, + evaluations: EvaluationCoordinator, + *, + project_id: UUID, + submission_id: UUID, + event_id: UUID, ) -> CheckerRetryDelivery | None: """Expose only a committed exact receipt through CHECKERS's existing port.""" - receipt = await session.scalar(select(CheckerRetryReceipt).where( - CheckerRetryReceipt.project_id == str(project_id), - CheckerRetryReceipt.submission_id == str(submission_id), - CheckerRetryReceipt.event_id == event_id, - ).execution_options(populate_existing=True)) + receipt = await session.scalar( + select(CheckerRetryReceipt) + .where( + CheckerRetryReceipt.project_id == str(project_id), + CheckerRetryReceipt.submission_id == str(submission_id), + CheckerRetryReceipt.event_id == event_id, + ) + .execution_options(populate_existing=True) + ) if receipt is None: return None source, successor = _source_from_receipt(receipt), _successor_from_receipt(receipt) try: stored = await evaluations.read_reserved_evaluation( - project_id=source.project_id, task_id=source.task_id, - submission_id=source.submission_id, request_id=successor.request_id, + project_id=source.project_id, + task_id=source.task_id, + submission_id=source.submission_id, + request_id=successor.request_id, ) except CheckerExecutionUnavailable as exc: - raise CheckerRetryOperationUnavailable( - "checker_retry_delivery_unavailable" - ) from exc + raise CheckerRetryOperationUnavailable("checker_retry_delivery_unavailable") from exc if ( stored.reservation.request_digest != successor.request_digest or stored.reservation.attempt_id != successor.run_id @@ -548,25 +651,31 @@ async def read_retry_delivery( try: receipt_id = UUID(receipt.id) except (TypeError, ValueError) as exc: - raise CheckerRetryOperationUnavailable( - "checker_retry_delivery_unavailable" - ) from exc + raise CheckerRetryOperationUnavailable("checker_retry_delivery_unavailable") from exc expected = retry_request_event( - source, receipt_id=receipt_id, request_id=successor.request_id, + source, + receipt_id=receipt_id, + request_id=successor.request_id, request_digest=successor.request_digest, - run_id=successor.run_id, result_id=successor.result_id, + run_id=successor.run_id, + result_id=successor.result_id, ) return CheckerRetryDelivery( request=stored.request, expected_event=CheckerRetryEventExpectation( - event_type=expected.event_type, event_version=expected.event_version, - aggregate_type=expected.aggregate_type, aggregate_id=expected.aggregate_id, + event_type=expected.event_type, + event_version=expected.event_version, + aggregate_type=expected.aggregate_type, + aggregate_id=expected.aggregate_id, correlation_id=expected.correlation_id, causation_event_id=expected.causation_event_id, idempotency_key=expected.idempotency_key, payload_json=json.dumps( - expected.payload, sort_keys=True, separators=(",", ":"), - ensure_ascii=False, allow_nan=False, + expected.payload, + sort_keys=True, + separators=(",", ":"), + ensure_ascii=False, + allow_nan=False, ), payload_digest=canonical_json_hash(expected.payload), ), diff --git a/backend/app/modules/checkers/execution_coordination.py b/backend/app/modules/checkers/execution_coordination.py index f4b72693d..70355d108 100644 --- a/backend/app/modules/checkers/execution_coordination.py +++ b/backend/app/modules/checkers/execution_coordination.py @@ -31,11 +31,6 @@ from app.modules.checkers.api.selected_post_submit import ( SelectedPostSubmitCurrentResultReference, ) -from app.modules.checkers.api.output_custody import ( - CheckerOutputReservation, - CheckerOutputSelector, - CheckerOutputUnavailable, -) from app.modules.checkers.execution_repository import ( ExecutionRepository, require_transaction, @@ -411,36 +406,3 @@ async def require_current_execution(self, facts): raise CheckerExecutionUnavailable("checker_current_execution_unavailable") execution_authority_digest(facts) await ExecutionRepository(self._session).require_lease(facts.request, facts.lease) - - -class CheckerOutputReservations: - """Selected evidence-only checker executions have no artifact output slots.""" - - def __init__(self, session: AsyncSession): - """Resolve output custody within the supplied CHECKERS session.""" - self._session = session - - async def resolve(self, selector: CheckerOutputSelector) -> CheckerOutputReservation: - """Require exact current lease custody and return the supported zero-output contract.""" - from app.modules.checkers.api.execution import CheckerExecutionUnavailable - - try: - repo = ExecutionRepository(self._session) - run = await repo.lock_current(selector.evaluation) - if ( - run.status != "running" - or run.id != str(selector.checker_run_id) - or run.worker_lease_id != str(selector.worker_lease_id) - or run.worker_lease_generation != selector.worker_lease_generation - or run.worker_lease_expires_at <= await repo.now() - ): - raise CheckerOutputUnavailable("checker_output_reservation_unavailable") - return CheckerOutputReservation( - evaluation=selector.evaluation, - checker_run_id=UUID(run.id), - worker_lease_id=UUID(run.worker_lease_id), - worker_lease_generation=run.worker_lease_generation, - slots=(), - ) - except CheckerExecutionUnavailable: - raise CheckerOutputUnavailable("checker_output_reservation_unavailable") from None diff --git a/backend/app/modules/checkers/model_usage_custody.py b/backend/app/modules/checkers/model_usage_custody.py new file mode 100644 index 000000000..b8c1d8d7f --- /dev/null +++ b/backend/app/modules/checkers/model_usage_custody.py @@ -0,0 +1,193 @@ +"""Current invocation and call-stable private evidence custody.""" + +from uuid import UUID + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.modules.checkers.api.execution import ( + CheckerExecutionUnavailable, + EvaluationTaskGuard, + ExecutionLease, +) +from app.modules.model_usage.api import ( + ModelCallCurrentExecutionPort, + ModelCallExecutionFacts, + ModelCallUnavailable, + ModelEvidenceCustodyPort, + ModelEvidenceSelector, + ModelPaidSlotKey, + ModelRecoverySourcePort, +) +from app.modules.checkers.api.output_custody import ( + CheckerOutputReservation, + CheckerOutputSelector, + CheckerOutputSlot, + CheckerOutputTaskCustodyPort, + CheckerOutputUnavailable, +) +from app.modules.checkers.execution_repository import ( + ExecutionRepository, + require_transaction, + reservation, + stored_request, +) +from app.modules.checkers.models import CheckerRun + + +class ModelCallCurrentExecution: + """Check the exact locked project binding before any paid effect is reserved.""" + + def __init__( + self, + session: AsyncSession, + *, + tasks: EvaluationTaskGuard, + recovery: ModelRecoverySourcePort, + ): + self._session, self._tasks, self._recovery = session, tasks, recovery + + @staticmethod + def _require_binding(request, slot: ModelPaidSlotKey) -> None: + if request.assignment_id != slot.assignment_id or ( + slot.project_id, + slot.submission_id, + slot.submission_version, + ) != (request.project_id, request.submission_id, request.submission_version): + raise ModelCallUnavailable("model_call_execution_unavailable") + bindings = [ + binding + for binding in request.policy.entries + if binding.requirement_id == slot.requirement_id + and binding.registry.entry_digest == slot.registry_entry_digest + and binding.configuration_sha256 == slot.configuration_sha256 + ] + if len(bindings) != 1: + raise ModelCallUnavailable("model_call_binding_unavailable") + configuration = bindings[0].configuration + if ( + configuration.get("model_profile_id") != str(slot.profile_id) + or type(configuration.get("model_profile_version")) is not int + or configuration.get("model_profile_version") != slot.profile_version + or configuration.get("model_profile_digest") != slot.profile_digest + ): + raise ModelCallUnavailable("model_call_profile_unavailable") + + async def require_current( + self, + facts: ModelCallExecutionFacts, + slot: ModelPaidSlotKey, + ) -> None: + """Read immutable request facts, then lock TASK before the CHECKERS fence.""" + require_transaction(self._session) + facts = ModelCallExecutionFacts.model_validate(facts) + slot = ModelPaidSlotKey.model_validate(slot) + run = await self._session.scalar( + select(CheckerRun) + .where( + CheckerRun.id == str(facts.checker_run_id), + CheckerRun.project_id == str(facts.project_id), + CheckerRun.task_id == str(facts.task_id), + CheckerRun.submission_id == str(facts.submission_id), + CheckerRun.submission_version == facts.submission_version, + CheckerRun.evaluation_request_id == str(facts.evaluation_request_id), + CheckerRun.evaluation_generation == facts.evaluation_generation, + ) + .execution_options(populate_existing=True) + ) + if run is None: + raise ModelCallUnavailable("model_call_execution_unavailable") + request = stored_request(run) + if request.assignment_id != facts.assignment_id: + raise ModelCallUnavailable("model_call_execution_unavailable") + self._require_binding(request, slot) + try: + if not await self._tasks.lock_evaluation_scope(request): + raise ModelCallUnavailable("model_call_execution_unavailable") + await ExecutionRepository(self._session).require_lease( + request, + ExecutionLease( + reservation=reservation(run), + lease_id=facts.worker_lease_id, + lease_generation=facts.worker_lease_generation, + expires_at=facts.worker_lease_expires_at, + ), + ) + except CheckerExecutionUnavailable: + raise ModelCallUnavailable("model_call_execution_unavailable") from None + + async def require_recovery_current( + self, slot: ModelPaidSlotKey, parent_attempt_id: UUID + ) -> None: + """Lock current TASK/Submission custody for a terminal source, without a lease.""" + require_transaction(self._session) + slot = ModelPaidSlotKey.model_validate(slot) + try: + source = await self._recovery.recovery_owner(slot, parent_attempt_id) + except ModelCallUnavailable: + raise ModelCallUnavailable("model_call_recovery_unavailable") from None + run = await self._session.get(CheckerRun, str(source.checker_run_id)) + if ( + run is None + or run.task_id != str(source.task_id) + or run.status != "infrastructure_failed" + or run.failure_code is None + or run.completed_at is None + ): + raise ModelCallUnavailable("model_call_recovery_unavailable") + try: + request = stored_request(run) + except (CheckerExecutionUnavailable, ValueError): + raise ModelCallUnavailable("model_call_recovery_unavailable") from None + if ( + source.execution.checker_run_id != UUID(run.id) + or source.execution.task_id != source.task_id + ): + raise ModelCallUnavailable("model_call_recovery_unavailable") + self._require_binding(request, slot) + try: + if not await self._tasks.lock_evaluation_scope(request): + raise ModelCallUnavailable("model_call_recovery_unavailable") + except CheckerExecutionUnavailable: + raise ModelCallUnavailable("model_call_recovery_unavailable") from None + + +class CheckerOutputReservations: + """Resolve two bounded ART roles under one immutable paid call identity.""" + + def __init__( + self, + session: AsyncSession, + *, + current: ModelCallCurrentExecutionPort, + tasks: CheckerOutputTaskCustodyPort, + model: ModelEvidenceCustodyPort, + ): + self._session, self._current = session, current + self._tasks, self._model = tasks, model + + async def resolve(self, selector: CheckerOutputSelector) -> CheckerOutputReservation: + """Requests require current execution; late responses retain effect custody.""" + require_transaction(self._session) + selector = CheckerOutputSelector.model_validate(selector) + if selector.execution is not None: + await self._current.require_current(selector.execution, selector.paid_slot) + else: + await self._tasks.lock_model_evidence_scope(selector) + try: + reservation = await self._model.resolve_evidence( + ModelEvidenceSelector( + paid_slot=selector.paid_slot, + task_id=selector.task_id, + model_call_slot_id=selector.model_call_slot_id, + model_call_attempt_id=selector.model_call_attempt_id, + role=selector.slot_key, + content_sha256=selector.content_sha256, + ) + ) + except ModelCallUnavailable: + raise CheckerOutputUnavailable("checker_output_reservation_unavailable") from None + return CheckerOutputReservation( + selector=selector, + slot=CheckerOutputSlot(key=selector.slot_key, maximum_bytes=reservation.maximum_bytes), + ) diff --git a/backend/app/modules/checkers/router.py b/backend/app/modules/checkers/router.py index 6d22de255..a3c9e595d 100644 --- a/backend/app/modules/checkers/router.py +++ b/backend/app/modules/checkers/router.py @@ -18,13 +18,27 @@ ) from app.core.api_controls import ApiErrorResponse from app.api.deps.checker_recovery import ( - CheckerRetryCommands, CheckerRetryReads, - get_checker_retry_commands, get_checker_retry_reads, + CheckerRetryCommands, + CheckerRetryReads, + get_checker_retry_commands, + get_checker_retry_reads, require_checker_retry_human, - PostSubmitDeliveryCommands, PostSubmitDeliveryReads, - get_post_submit_delivery_commands, get_post_submit_delivery_reads, + PostSubmitDeliveryCommands, + PostSubmitDeliveryReads, + get_post_submit_delivery_commands, + get_post_submit_delivery_reads, require_post_submit_delivery_human, ) +from app.api.deps.checker_models import ( + MODEL_ADMIN_IDEMPOTENCY_PARAMETER, + CheckerModelAdminCommands, + get_checker_model_admin_commands, + get_checker_model_read_commands, + get_checker_model_recovery_commands, + require_model_admin_human, + require_model_admin_key, + require_model_recovery_human, +) from app.api.deps.history import HistoryReadOperation, get_history_reads @@ -41,18 +55,119 @@ ExternalCheckerRegistrySpec, ) from app.modules.checkers.api.execution import ( - CheckerRetryPublicResult, CheckerRetryRequest, CheckerRetrySourcePage, + CheckerRetryPublicResult, + CheckerRetryRequest, + CheckerRetrySourcePage, ) from app.modules.checkers.api.delivery_reconciliation import ( PostSubmitDeliveryPage, PostSubmitDeliveryRequest, PostSubmitDeliveryResult, ) +from app.modules.model_usage.api import ( + ModelRecoveryChildRequest, + ModelUsageEffectInspection, + PreparedModelCallAttempt, + ProjectModelBudgetConfiguration, + ProjectModelBudgetConfigurationSpec, + ProjectModelProfile, + ProjectModelProfileSpec, +) router = APIRouter(tags=["checkers"]) +@router.post( + "/operations/projects/{project_id}/model-profiles", + response_model=ProjectModelProfile, + status_code=status.HTTP_201_CREATED, + dependencies=[ + Depends(enforce_admin_mutation_rate_limit), + Depends(require_model_admin_human), + Depends(require_model_admin_key), + ], + responses={code: {"model": ApiErrorResponse} for code in (404, 409, 422, 503)}, + openapi_extra={ + "x-workstream-action-id": "project.model_profile.provision", + "parameters": [MODEL_ADMIN_IDEMPOTENCY_PARAMETER], + }, +) +async def provision_project_model_profile( + project_id: UUID, + payload: ProjectModelProfileSpec, + commands: Annotated[CheckerModelAdminCommands, Depends(get_checker_model_admin_commands)], +) -> ProjectModelProfile: + """Publish or exactly replay one immutable approved project model profile.""" + return await commands.provision_profile(project_id, payload) + + +@router.post( + "/operations/projects/{project_id}/model-budget-configurations", + response_model=ProjectModelBudgetConfiguration, + status_code=status.HTTP_201_CREATED, + dependencies=[ + Depends(enforce_admin_mutation_rate_limit), + Depends(require_model_admin_human), + Depends(require_model_admin_key), + ], + responses={code: {"model": ApiErrorResponse} for code in (404, 409, 422, 503)}, + openapi_extra={ + "x-workstream-action-id": "project.model_budget.configure", + "parameters": [MODEL_ADMIN_IDEMPOTENCY_PARAMETER], + }, +) +async def configure_project_model_budget( + project_id: UUID, + payload: ProjectModelBudgetConfigurationSpec, + commands: Annotated[CheckerModelAdminCommands, Depends(get_checker_model_admin_commands)], +) -> ProjectModelBudgetConfiguration: + """Create or replay one immutable lifetime project model budget version.""" + return await commands.configure_budget(project_id, payload) + + +@router.get( + "/operations/projects/{project_id}/model-usage/effects/{effect_id}", + response_model=ModelUsageEffectInspection, + dependencies=[ + Depends(enforce_authorization_read_rate_limit), + Depends(enforce_human_authorization_read), + ], + responses={code: {"model": ApiErrorResponse} for code in (404, 422, 503)}, + openapi_extra={"x-workstream-action-id": "project.model_usage.read"}, +) +async def read_project_model_usage_effect( + project_id: UUID, + effect_id: UUID, + commands: Annotated[CheckerModelAdminCommands, Depends(get_checker_model_read_commands)], +) -> ModelUsageEffectInspection: + """Read redacted accounting and evidence identities for one exact paid effect.""" + return await commands.inspect_effect(project_id, effect_id) + + +@router.post( + "/operations/projects/{project_id}/model-call-attempts/{parent_attempt_id}/recovery-children", + response_model=PreparedModelCallAttempt, + status_code=status.HTTP_201_CREATED, + dependencies=[ + Depends(enforce_admin_mutation_rate_limit), + Depends(require_model_recovery_human), + ], + responses={code: {"model": ApiErrorResponse} for code in (404, 409, 422, 503)}, + openapi_extra={ + "x-workstream-action-id": "project.model_call.recovery_child.authorize", + }, +) +async def prepare_project_model_recovery_child( + project_id: UUID, + parent_attempt_id: UUID, + payload: ModelRecoveryChildRequest, + commands: Annotated[CheckerModelAdminCommands, Depends(get_checker_model_recovery_commands)], +) -> PreparedModelCallAttempt: + """Create or replay a child only for one exact known terminal paid effect.""" + return await commands.prepare_recovery_child(project_id, parent_attempt_id, payload) + + @router.post( "/operations/external-checker-registry", response_model=ExternalCheckerRegistryEntry, @@ -67,9 +182,7 @@ async def register_external_checker( payload: ExternalCheckerRegistrySpec, operation_id: Annotated[UUID, Depends(require_registry_key)], - operation: Annotated[ - CheckerRegistryOperation, Depends(get_registry_registration) - ], + operation: Annotated[CheckerRegistryOperation, Depends(get_registry_registration)], ) -> ExternalCheckerRegistryEntry: """Publish or exactly replay one immutable digest-pinned checker entry.""" response = await operation.service.register( @@ -99,6 +212,7 @@ async def read_external_checker( ) return await commit_registry_response(operation, response) + CHECKER_RETRY_KEY_PARAMETER = { "name": "Idempotency-Key", "in": "header", @@ -124,9 +238,7 @@ async def read_external_checker( ) async def list_uncertain_post_submit_deliveries( project_id: UUID, - reads: Annotated[ - PostSubmitDeliveryReads, Depends(get_post_submit_delivery_reads) - ], + reads: Annotated[PostSubmitDeliveryReads, Depends(get_post_submit_delivery_reads)], limit: Annotated[int, Query(ge=1, le=100)] = 50, cursor: Annotated[str | None, Query(max_length=512)] = None, ) -> PostSubmitDeliveryPage: @@ -151,9 +263,7 @@ async def reconcile_uncertain_post_submit_delivery( project_id: UUID, event_id: UUID, payload: PostSubmitDeliveryRequest, - commands: Annotated[ - PostSubmitDeliveryCommands, Depends(get_post_submit_delivery_commands) - ], + commands: Annotated[PostSubmitDeliveryCommands, Depends(get_post_submit_delivery_commands)], ) -> PostSubmitDeliveryResult: """Append or exactly replay the sole authorized reconciliation child.""" return await commands.reconcile(project_id, event_id, payload) @@ -201,7 +311,9 @@ async def retry_checker_infrastructure_failure( return CheckerRetryPublicResult.from_result(result) -@router.get("/submissions/{submission_id}/checker-runs", response_model=ContributorCheckerHistoryPage, +@router.get( + "/submissions/{submission_id}/checker-runs", + response_model=ContributorCheckerHistoryPage, dependencies=[Depends(enforce_human_authorization_read)], openapi_extra={"x-workstream-action-id": "submission.checker_run.list"}, ) @@ -213,11 +325,16 @@ async def read_contributor_checker_runs( ) -> ContributorCheckerHistoryPage: """Read retained checker runs under fresh contributor authority.""" return await history.read( - "checker_runs", submission_id, limit=limit, cursor=cursor, + "checker_runs", + submission_id, + limit=limit, + cursor=cursor, ) -@router.get("/projects/{project_id}/submissions/{submission_id}/checker-runs", response_model=ManagementCheckerHistoryPage, +@router.get( + "/projects/{project_id}/submissions/{submission_id}/checker-runs", + response_model=ManagementCheckerHistoryPage, dependencies=[Depends(enforce_human_authorization_read)], openapi_extra={"x-workstream-action-id": "project.submission.checker_run.list"}, ) @@ -230,11 +347,17 @@ async def read_management_checker_runs( ) -> ManagementCheckerHistoryPage: """Read retained checker runs under fresh management authority.""" return await history.read( - "checker_runs", submission_id, project_id=project_id, limit=limit, cursor=cursor, + "checker_runs", + submission_id, + project_id=project_id, + limit=limit, + cursor=cursor, ) -@router.get("/submissions/{submission_id}/checker-runs/{checker_run_id}", response_model=ContributorCheckerHistory, +@router.get( + "/submissions/{submission_id}/checker-runs/{checker_run_id}", + response_model=ContributorCheckerHistory, dependencies=[Depends(enforce_human_authorization_read)], openapi_extra={"x-workstream-action-id": "checker_run.read"}, ) @@ -245,11 +368,15 @@ async def read_contributor_checker_run( ) -> ContributorCheckerHistory: """Read retained checker run under fresh contributor authority.""" return await history.read( - "checker_run", checker_run_id, submission_id=submission_id, + "checker_run", + checker_run_id, + submission_id=submission_id, ) -@router.get("/projects/{project_id}/submissions/{submission_id}/checker-runs/{checker_run_id}", response_model=ManagementCheckerHistory, +@router.get( + "/projects/{project_id}/submissions/{submission_id}/checker-runs/{checker_run_id}", + response_model=ManagementCheckerHistory, dependencies=[Depends(enforce_human_authorization_read)], openapi_extra={"x-workstream-action-id": "project.checker_run.read"}, ) @@ -261,5 +388,8 @@ async def read_management_checker_run( ) -> ManagementCheckerHistory: """Read retained checker run under fresh management authority.""" return await history.read( - "checker_run", checker_run_id, submission_id=submission_id, project_id=project_id, + "checker_run", + checker_run_id, + submission_id=submission_id, + project_id=project_id, ) diff --git a/backend/app/modules/model_usage/__init__.py b/backend/app/modules/model_usage/__init__.py new file mode 100644 index 000000000..c11c91761 --- /dev/null +++ b/backend/app/modules/model_usage/__init__.py @@ -0,0 +1 @@ +"""Shared durable model-use ownership for setup and checker workloads.""" diff --git a/backend/app/modules/model_usage/api.py b/backend/app/modules/model_usage/api.py new file mode 100644 index 000000000..ec55b85a9 --- /dev/null +++ b/backend/app/modules/model_usage/api.py @@ -0,0 +1,1289 @@ +"""Typed contracts for project-image model calls and lifetime project spend.""" + +from __future__ import annotations + +from collections.abc import AsyncIterable +from dataclasses import dataclass +from datetime import UTC, datetime +from typing import Annotated, Literal, Protocol, Self +from uuid import RFC_4122, UUID + +from pydantic import ( + AwareDatetime, + BaseModel, + ConfigDict, + Field, + StrictInt, + StrictStr, + field_validator, + model_validator, +) + +from app.core.hashing import canonical_json_hash + +Sha256 = Annotated[StrictStr, Field(pattern=r"^sha256:[0-9a-f]{64}$")] +Identifier = Annotated[ + StrictStr, Field(min_length=1, max_length=100, pattern=r"^[a-z][a-z0-9_.-]*$") +] +ProviderIdentifier = Annotated[ + StrictStr, + Field(min_length=1, max_length=160, pattern=r"^[A-Za-z0-9][A-Za-z0-9._:/-]*$"), +] +ProviderResponseIdentifier = Annotated[ + StrictStr, + Field(min_length=1, max_length=256, pattern=r"^[A-Za-z0-9][A-Za-z0-9._:-]*$"), +] +Nanodollars = Annotated[StrictInt, Field(ge=0, le=9_000_000_000_000_000_000)] + + +class ModelCallUnavailable(RuntimeError): + """Conceal unavailable, foreign, stale or unauthorized model-call state.""" + + +class ModelCallConflict(ModelCallUnavailable): + """Reject immutable identity or replay substitution.""" + + +class ModelCallBudgetUnavailable(ModelCallUnavailable): + """Report a finite project budget that cannot cover the next dispatch.""" + + +class ModelCallValue(BaseModel): + """Strict immutable values shared across model-call owner boundaries.""" + + model_config = ConfigDict( + extra="forbid", frozen=True, strict=True, revalidate_instances="always" + ) + + +def _require_uuid7(value: UUID, name: str) -> UUID: + if value.version != 7 or value.variant != RFC_4122: + raise ValueError(f"{name} must be UUIDv7") + return value + + +def _public_uuid(value: object) -> object: + """Decode only the canonical UUID spelling that a JSON request can carry.""" + if isinstance(value, UUID) or type(value) is not str: + return value + try: + parsed = UUID(value) + except ValueError: + return value + if str(parsed) != value: + raise ValueError("UUID must use its canonical lowercase spelling") + return parsed + + +def _public_datetime(value: object) -> object: + """Decode an RFC 3339-compatible timestamp before strict aware validation.""" + if isinstance(value, datetime) or type(value) is not str: + return value + try: + return datetime.fromisoformat(value) + except ValueError: + return value + + +class ModelProfileSelector(ModelCallValue): + """Exact immutable profile identity retained in approved checker configuration.""" + + profile_id: UUID + profile_version: StrictInt = Field(ge=1) + profile_digest: Sha256 + + @model_validator(mode="after") + def canonical_id(self) -> Self: + _require_uuid7(self.profile_id, "model profile ID") + return self + + +class ModelGenerationSettings(ModelCallValue): + """Closed provider-neutral settings; prompts never belong to a profile.""" + + reasoning_effort: Literal["none", "minimal", "low", "medium", "high", "xhigh", "max"] + text_verbosity: Literal["low", "medium", "high"] + + +class ModelPricing(ModelCallValue): + """Exact integer nano-USD rates with no floating-point rounding.""" + + pricing_policy_id: UUID + pricing_policy_version: StrictInt = Field(ge=1) + pricing_policy_digest: Sha256 + uncached_input_nanodollars_per_token: Nanodollars + cached_input_nanodollars_per_token: Nanodollars + output_nanodollars_per_token: Nanodollars + + _decode_public_uuid = field_validator("pricing_policy_id", mode="before")(_public_uuid) + + @model_validator(mode="after") + def nonzero_and_canonical(self) -> Self: + _require_uuid7(self.pricing_policy_id, "model pricing policy ID") + rates = ( + self.uncached_input_nanodollars_per_token, + self.cached_input_nanodollars_per_token, + self.output_nanodollars_per_token, + ) + if max(rates) <= 0: + raise ValueError("model pricing rates cannot all be zero") + return self + + +class ProjectModelProfileSpec(ModelCallValue): + """Operator-owned provider and pricing profile shared by model-use owners.""" + + project_id: UUID + profile_version: StrictInt = Field(ge=1) + provider: ProviderIdentifier + api: ProviderIdentifier + model_snapshot: ProviderIdentifier + settings: ModelGenerationSettings + maximum_request_bytes: StrictInt = Field(ge=1, le=1_048_576) + maximum_output_bytes: StrictInt = Field(ge=1, le=1_048_576) + maximum_response_bytes: StrictInt = Field(ge=1, le=1_048_576) + maximum_input_tokens: StrictInt = Field(ge=1, le=10_000_000) + maximum_output_tokens: StrictInt = Field(ge=1, le=10_000_000) + maximum_calls: StrictInt = Field(ge=1, le=100) + maximum_total_request_bytes: StrictInt = Field(ge=1, le=104_857_600) + maximum_total_response_bytes: StrictInt = Field(ge=1, le=104_857_600) + maximum_total_input_tokens: StrictInt = Field(ge=1, le=100_000_000) + maximum_total_output_tokens: StrictInt = Field(ge=1, le=100_000_000) + allowed_tools: tuple[Identifier, ...] = () + maximum_tool_calls: StrictInt = Field(ge=0, le=1_000) + maximum_tool_output_bytes: StrictInt = Field(ge=0, le=104_857_600) + deadline_ms: StrictInt = Field(ge=100, le=3_600_000) + pricing: ModelPricing + + _decode_public_uuid = field_validator("project_id", mode="before")(_public_uuid) + + @field_validator("allowed_tools", mode="before") + @classmethod + def decode_public_tools(cls, value): + """Decode the JSON array representation into the frozen canonical tuple.""" + if isinstance(value, list): + return tuple(value) + return value + + @model_validator(mode="after") + def coherent_bounds(self) -> Self: + if self.maximum_response_bytes < 6 * self.maximum_output_bytes + 4096: + raise ValueError("model response evidence cannot preserve the maximum output") + if len(set(self.allowed_tools)) != len(self.allowed_tools): + raise ValueError("model profile tools must be unique") + if tuple(sorted(self.allowed_tools)) != self.allowed_tools: + raise ValueError("model profile tools must be canonical") + if not self.allowed_tools and ( + self.maximum_tool_calls != 0 or self.maximum_tool_output_bytes != 0 + ): + raise ValueError("tool-free model profiles cannot reserve tool capacity") + if self.allowed_tools and ( + self.maximum_tool_calls == 0 or self.maximum_tool_output_bytes == 0 + ): + raise ValueError("tool-enabled model profiles require finite tool bounds") + bounded_totals = ( + (self.maximum_request_bytes, self.maximum_total_request_bytes), + (self.maximum_response_bytes, self.maximum_total_response_bytes), + (self.maximum_input_tokens, self.maximum_total_input_tokens), + (self.maximum_output_tokens, self.maximum_total_output_tokens), + ) + if any(per_call > total for per_call, total in bounded_totals): + raise ValueError("model run totals cannot be smaller than one call") + if any(total > per_call * self.maximum_calls for per_call, total in bounded_totals): + raise ValueError("model run totals exceed the finite call envelope") + return self + + @property + def maximum_reservation_nanodollars(self) -> int: + """Return the maximum settled plus outstanding amount for one whole run.""" + input_rate = max( + self.pricing.uncached_input_nanodollars_per_token, + self.pricing.cached_input_nanodollars_per_token, + ) + value = ( + self.maximum_total_input_tokens * input_rate + + self.maximum_total_output_tokens * self.pricing.output_nanodollars_per_token + ) + if value <= 0 or value > 9_000_000_000_000_000_000: + raise ValueError("model profile reservation is outside the supported range") + return value + + @property + def maximum_dispatch_reservation_nanodollars(self) -> int: + """Return the conservative reservation for one actual provider dispatch.""" + input_rate = max( + self.pricing.uncached_input_nanodollars_per_token, + self.pricing.cached_input_nanodollars_per_token, + ) + value = ( + self.maximum_input_tokens * input_rate + + self.maximum_output_tokens * self.pricing.output_nanodollars_per_token + ) + if value <= 0 or value > self.maximum_reservation_nanodollars: + raise ValueError("model dispatch reservation is outside the run envelope") + return value + + +class ModelProfileProvisioningFacts(ModelCallValue): + """Complete immutable profile publication facts consumed by Operator authority.""" + + operation_id: UUID + actor_profile_id: UUID + spec: ProjectModelProfileSpec + profile_digest: Sha256 + + @model_validator(mode="after") + def exact_digest(self) -> Self: + if self.profile_digest != canonical_json_hash(self.spec.model_dump(mode="json")): + raise ValueError("model profile digest mismatch") + return self + + +class ProjectModelProfile(ModelProfileProvisioningFacts): + """Published exact profile with its immutable authorization receipt.""" + + profile_id: UUID + authorization_evidence_id: UUID + created_at: AwareDatetime + + @model_validator(mode="after") + def canonical_record_id(self) -> Self: + _require_uuid7(self.profile_id, "model profile ID") + return self + + @property + def selector(self) -> ModelProfileSelector: + return ModelProfileSelector( + profile_id=self.profile_id, + profile_version=self.spec.profile_version, + profile_digest=self.profile_digest, + ) + + +class ProjectModelBudgetConfigurationSpec(ModelCallValue): + """One explicit authorization/reconciliation and metered-ceiling adjustment. + + Externally reconciled prior spend excludes usage retained by this ledger. Later + immutable versions may only increase that external fact; account counters remain + lifetime values and are never folded into or reset by this field. + """ + + project_id: UUID + configuration_version: StrictInt = Field(ge=1) + authorized_total_nanodollars: Nanodollars + externally_reconciled_prior_spend_nanodollars: Nanodollars + lifetime_limit_nanodollars: Nanodollars + + _decode_public_uuid = field_validator("project_id", mode="before")(_public_uuid) + + @model_validator(mode="after") + def positive_limit(self) -> Self: + if self.lifetime_limit_nanodollars <= 0: + raise ValueError("model budget limit must be positive") + if self.authorized_total_nanodollars <= 0: + raise ValueError("model authorization ceiling must be positive") + if ( + self.externally_reconciled_prior_spend_nanodollars + self.lifetime_limit_nanodollars + > self.authorized_total_nanodollars + ): + raise ValueError("model metered allowance exceeds the reconciled authorization") + return self + + +class ModelBudgetProvisioningFacts(ModelCallValue): + """Operator mutation facts for one immutable budget configuration version.""" + + operation_id: UUID + actor_profile_id: UUID + spec: ProjectModelBudgetConfigurationSpec + configuration_digest: Sha256 + + @model_validator(mode="after") + def exact_digest(self) -> Self: + if self.configuration_digest != canonical_json_hash(self.spec.model_dump(mode="json")): + raise ValueError("model budget configuration digest mismatch") + return self + + +class ProjectModelBudgetConfiguration(ModelBudgetProvisioningFacts): + """Published budget version plus lifetime counters retained on its project account.""" + + configuration_id: UUID + authorization_evidence_id: UUID + created_at: AwareDatetime + lifetime_reserved_nanodollars: Nanodollars + lifetime_settled_nanodollars: Nanodollars + + @model_validator(mode="after") + def canonical_record_id(self) -> Self: + _require_uuid7(self.configuration_id, "model budget configuration ID") + return self + + +class ModelUsageInspectionFacts(ModelCallValue): + """Exact Operator read target committed before model-ledger inspection.""" + + project_id: UUID + effect_id: UUID + actor_profile_id: UUID + request_id: UUID + + _decode_public_uuids = field_validator( + "project_id", "effect_id", "actor_profile_id", "request_id", mode="before" + )(_public_uuid) + + +class ModelPaidSlotKey(ModelCallValue): + """Paid-effect identity stable across checker leases and retry generations.""" + + project_id: UUID + assignment_id: UUID + submission_id: UUID + submission_version: StrictInt = Field(ge=1) + requirement_id: Identifier + registry_entry_digest: Sha256 + configuration_sha256: Sha256 + profile_id: UUID + profile_version: StrictInt = Field(ge=1) + profile_digest: Sha256 + slot_index: StrictInt = Field(default=0, ge=0, le=99) + + _decode_public_uuids = field_validator( + "project_id", "assignment_id", "submission_id", "profile_id", mode="before" + )(_public_uuid) + + @model_validator(mode="after") + def canonical_profile(self) -> Self: + _require_uuid7(self.profile_id, "model profile ID") + return self + + @property + def digest(self) -> str: + return canonical_json_hash(self.model_dump(mode="json")) + + @property + def envelope_digest(self) -> str: + """Commit the stable whole-run subject independently of a call index.""" + return canonical_json_hash(self.model_dump(mode="json", exclude={"slot_index"})) + + +class SetupModelUsageSlotKey(ModelCallValue): + """Exact setup-owned provider dispatch identity allocated before I/O.""" + + compilation_attempt_id: UUID + project_id: UUID + guide_id: UUID + source_snapshot_id: UUID + setup_run_id: UUID + setup_generation: StrictInt = Field(ge=1) + runtime_configuration_hash: Sha256 + profile_id: UUID + profile_version: StrictInt = Field(ge=1) + profile_digest: Sha256 + phase: Literal["draft", "review", "correction"] + call_index: StrictInt = Field(ge=0, le=99) + + _decode_public_uuids = field_validator( + "compilation_attempt_id", + "project_id", + "guide_id", + "source_snapshot_id", + "setup_run_id", + "profile_id", + mode="before", + )(_public_uuid) + + @model_validator(mode="after") + def canonical_records(self) -> Self: + _require_uuid7(self.compilation_attempt_id, "guide compilation attempt ID") + _require_uuid7(self.setup_run_id, "project setup run ID") + _require_uuid7(self.profile_id, "model profile ID") + return self + + @property + def digest(self) -> str: + """Commit the complete setup subject without provider request bytes.""" + return canonical_json_hash(self.model_dump(mode="json")) + + @property + def envelope_digest(self) -> str: + """Commit the setup attempt run independently of phase and call index.""" + return canonical_json_hash(self.model_dump(mode="json", exclude={"phase", "call_index"})) + + +class PreparedSetupModelUsageEffect(ModelCallValue): + """Prepared setup effect allocated under exact attempt custody before I/O.""" + + envelope_id: UUID + slot_id: UUID + slot_key: SetupModelUsageSlotKey + effect_id: UUID + attempt_number: StrictInt = Field(ge=1) + internal_call_request_id: UUID + request_sha256: Sha256 + state: Literal["prepared"] = "prepared" + replayed: bool + + +class SetupModelEffectAuthorityFacts(ModelCallValue): + """Exact setup-owned provider effect used by narrow observation authority.""" + + project_id: UUID + slot_id: UUID + slot_key: SetupModelUsageSlotKey + effect_id: UUID + internal_call_request_id: UUID + + +class SetupModelDispatchReservation(ModelCallValue): + """One setup provider dispatch reserved against shared project and run ceilings.""" + + envelope_id: UUID + slot_id: UUID + slot_key: SetupModelUsageSlotKey + effect_id: UUID + attempt_number: StrictInt = Field(ge=1) + internal_call_request_id: UUID + effect: SetupModelEffectAuthorityFacts + request_sha256: Sha256 + request_evidence: ModelCallEvidence + budget_configuration_id: UUID + budget_configuration_version: StrictInt = Field(ge=1) + budget_configuration_digest: Sha256 + reserved_nanodollars: Nanodollars + state: Literal["dispatching"] = "dispatching" + replayed: bool + + @model_validator(mode="after") + def exact_effect(self) -> Self: + if ( + self.effect.project_id != self.slot_key.project_id + or self.effect.slot_id != self.slot_id + or self.effect.slot_key != self.slot_key + or self.effect.effect_id != self.effect_id + or self.effect.internal_call_request_id != self.internal_call_request_id + ): + raise ValueError("setup dispatch effect identity mismatch") + return self + + +class ObservedSetupModelUsageEffect(ModelCallValue): + """Durable ambiguous provider effect that keeps its full reservation.""" + + effect: SetupModelEffectAuthorityFacts + uncertainty_digest: Sha256 + provider_response_id: ProviderResponseIdentifier | None = None + state: Literal["uncertain"] = "uncertain" + replayed: bool + + +class SetupModelUsageAuthorityPort(Protocol): + """PROJECTS-owned authorization for exact setup model-use mutations.""" + + async def authorize_setup_prepare( + self, slot: SetupModelUsageSlotKey, request_sha256: str + ) -> ModelActionEvidence: + """Authorize one preallocated setup call slot before owner locks.""" + ... + + async def authorize_setup_dispatch( + self, + prepared: PreparedSetupModelUsageEffect, + request_evidence: ModelCallEvidence, + ) -> ModelActionEvidence: + """Authorize one verified setup provider dispatch before owner locks.""" + ... + + async def authorize_setup_observe( + self, + effect: SetupModelEffectAuthorityFacts, + provider_response_id: str | None, + observation_digest: str, + ) -> ModelActionEvidence: + """Authorize retention of one ambiguous provider observation.""" + ... + + async def authorize_setup_complete( + self, + effect: SetupModelEffectAuthorityFacts, + response_evidence: ModelCallEvidence | None, + terminal_digest: str, + ) -> ModelActionEvidence: + """Authorize exact terminal accounting or authenticated no-effect release.""" + ... + + +class SetupModelUsageEvidencePort(Protocol): + """Verify private ART request evidence for an exact setup effect.""" + + async def require_setup_request( + self, + prepared: PreparedSetupModelUsageEffect, + evidence: ModelCallEvidence, + ) -> None: + """Require sealed bytes owned by the exact setup effect and request hash.""" + ... + + async def require_setup_response(self, effect_id: UUID, evidence: ModelCallEvidence) -> None: + """Require sealed response bytes owned by the exact setup effect.""" + ... + + +class SetupModelOwnerProfile(ModelCallValue): + """Exact profile and provider identity frozen by the PROJECTS attempt owner.""" + + selector: ModelProfileSelector + provider: ProviderIdentifier + api: ProviderIdentifier + model_snapshot: ProviderIdentifier + + +class SetupModelUsageOwnerPort(Protocol): + """Require the exact current PROJECTS compilation attempt before MODEL locks.""" + + async def require_setup_current(self, slot: SetupModelUsageSlotKey) -> SetupModelOwnerProfile: + """Lock and validate the complete setup attempt ancestry.""" + ... + + +class ModelEvidenceSelector(ModelCallValue): + """Owner-neutral selector for one checker request or response evidence slot.""" + + paid_slot: ModelPaidSlotKey + task_id: UUID + model_call_slot_id: UUID + model_call_attempt_id: UUID + role: Literal["model_request", "model_response"] + content_sha256: Sha256 + + +class ModelEvidenceReservation(ModelCallValue): + """Bound returned by the shared ledger after exact effect validation.""" + + maximum_bytes: StrictInt = Field(ge=1, le=1_048_576) + + +class ModelEvidenceCustodyPort(Protocol): + """Resolve shared ledger custody after the owner has taken its own locks.""" + + async def resolve_evidence(self, selector: ModelEvidenceSelector) -> ModelEvidenceReservation: + """Lock envelope, slot and effect and return the exact role bound.""" + ... + + +class SetupModelOutputSelector(ModelCallValue): + """Select one private setup request or response by its exact paid effect.""" + + slot_key: SetupModelUsageSlotKey + model_call_slot_id: UUID + model_call_attempt_id: UUID + role: Literal["model_request", "model_response"] + content_sha256: Sha256 + + _decode_public_uuids = field_validator( + "model_call_slot_id", "model_call_attempt_id", mode="before" + )(_public_uuid) + + +class SetupModelOutputSlot(ModelCallValue): + """One bounded canonical JSON role issued by the setup effect owner.""" + + key: Literal["model_request", "model_response"] + media_type: Literal["application/json"] = "application/json" + maximum_bytes: StrictInt = Field(ge=1, le=1_048_576) + + +class SetupModelOutputReservation(ModelCallValue): + """Detached setup owner facts, never fresh dispatch authority.""" + + selector: SetupModelOutputSelector + slot: SetupModelOutputSlot + + @model_validator(mode="after") + def exact_role(self) -> Self: + if self.slot.key != self.selector.role: + raise ValueError("setup model output role differs from reservation") + return self + + def select(self, selector: SetupModelOutputSelector) -> SetupModelOutputSlot: + current = SetupModelOutputReservation.model_validate(self) + if current.selector != SetupModelOutputSelector.model_validate(selector): + raise ModelCallUnavailable("setup_model_output_reservation_unavailable") + return current.slot + + +class SetupModelOutputReservationPort(Protocol): + """Resolve current request or retained paid-effect response ART custody.""" + + async def resolve(self, selector: SetupModelOutputSelector) -> SetupModelOutputReservation: + """Return exact bounds after the role-specific owner checks.""" + ... + + +@dataclass(frozen=True, slots=True) +class SetupModelOutputArtifactRequest: + """Bounded setup bytes admitted through the shared generated-output path.""" + + selector: SetupModelOutputSelector + byte_source: AsyncIterable[bytes] + + +@dataclass(frozen=True, slots=True) +class SetupModelOutputBindingRequest: + """Bind one independently verified setup output to its paid effect.""" + + selector: SetupModelOutputSelector + put_attempt_id: UUID + verification_receipt_id: UUID + + +@dataclass(frozen=True, slots=True) +class SetupModelOutputArtifactResult: + put_attempt_id: UUID + status: str + content_id: UUID | None + verification_receipt_id: UUID | None + content_sha256: Sha256 + byte_count: int + replayed: bool + + def __post_init__(self) -> None: + if type(self.byte_count) is not int or not 0 <= self.byte_count <= 1_048_576: + raise ValueError("setup model output byte count is invalid") + + +@dataclass(frozen=True, slots=True) +class SetupModelOutputBindingResult: + binding_id: UUID + content_id: UUID + put_attempt_id: UUID + verification_receipt_id: UUID + replayed: bool + + +class SetupModelOutputBindingPort(Protocol): + """Publish exact verified setup bytes under the paid effect owner.""" + + async def bind_setup_output( + self, request: SetupModelOutputBindingRequest + ) -> SetupModelOutputBindingResult: + """Bind one setup request or response in the caller transaction.""" + ... + + +class SetupModelArtifactOutputPort(Protocol): + """Store and recover bounded setup output through shared ART custody.""" + + async def store_setup_output( + self, request: SetupModelOutputArtifactRequest + ) -> SetupModelOutputArtifactResult: + """Store one setup request or response.""" + ... + + async def recover_setup_output( + self, selector: SetupModelOutputSelector + ) -> SetupModelOutputArtifactResult | None: + """Recover a committed setup output without regenerating bytes.""" + ... + + async def read_setup_response( + self, selector: SetupModelOutputSelector, evidence: ModelCallEvidence + ) -> bytes: + """Read exact independently verified response bytes.""" + ... + + +class ModelRecoverySourcePort(Protocol): + """Read retained recovery lineage without granting fresh owner authority.""" + + async def recovery_owner( + self, slot: ModelPaidSlotKey, parent_attempt_id: UUID + ) -> ModelRecoveryOwnerFacts: + """Resolve an exact terminal effect to its originating checker run.""" + ... + + async def recovery_candidates( + self, source_run_ids: tuple[UUID, ...] + ) -> dict[UUID, tuple[ModelRecoveryCandidate, ...]]: + """Project retryable terminal effects for the requested retained runs.""" + ... + + +class ModelInvocationGrantAuthorityPort(Protocol): + """Issue one bounded callback capability without authorizing provider spend.""" + + async def authorize_grant( + self, + execution: ModelCallExecutionFacts, + slot: ModelPaidSlotKey, + external_request_digest: str, + token_hash: str, + ) -> ModelActionEvidence: + """Authorize the exact invocation, request binding and opaque token hash.""" + ... + + +class ModelCallExecutionFacts(ModelCallValue): + """Current run/lease facts used only for authority and currentness checks.""" + + project_id: UUID + task_id: UUID + assignment_id: UUID + submission_id: UUID + submission_version: StrictInt = Field(ge=1) + evaluation_request_id: UUID + evaluation_generation: StrictInt = Field(ge=1) + checker_run_id: UUID + worker_lease_id: UUID + worker_lease_generation: StrictInt = Field(ge=1) + worker_lease_expires_at: AwareDatetime + + _decode_public_uuids = field_validator( + "project_id", + "task_id", + "assignment_id", + "submission_id", + "evaluation_request_id", + "checker_run_id", + "worker_lease_id", + mode="before", + )(_public_uuid) + _decode_public_expiry = field_validator("worker_lease_expires_at", mode="before")( + _public_datetime + ) + + +class ModelRecoveryChildRequest(ModelCallValue): + """Public Operator request for one exact known-terminal paid-call child.""" + + slot: ModelPaidSlotKey + registry_entry_id: UUID + terminal_digest: Sha256 + request_sha256: Sha256 + + _decode_public_uuid = field_validator("registry_entry_id", mode="before")(_public_uuid) + + @model_validator(mode="after") + def canonical_registry(self) -> Self: + _require_uuid7(self.registry_entry_id, "external checker registry entry ID") + return self + + +class ModelRecoveryCandidate(ModelRecoveryChildRequest): + """Exact terminal paid effect disclosed under existing Operator retry reads.""" + + parent_attempt_id: UUID + + _decode_public_parent = field_validator("parent_attempt_id", mode="before")(_public_uuid) + + +class ModelCallEvidence(ModelCallValue): + """Verified private ART identity; transcript bytes never cross this contract.""" + + binding_id: UUID + content_id: UUID + put_attempt_id: UUID + verification_receipt_id: UUID + content_sha256: Sha256 + byte_count: StrictInt = Field(ge=1, le=1_048_576) + media_type: Literal["application/json"] = "application/json" + + +class ModelCallEffectAuthorityFacts(ModelCallValue): + """Exact paid effect identity supplied to AUTH for lease-independent observations.""" + + project_id: UUID + task_id: UUID + slot_id: UUID + slot_key: ModelPaidSlotKey + attempt_id: UUID + internal_call_request_id: UUID + + +class AttachedModelCallAttempt(ModelCallValue): + """Existing stable effect attached to a fresh checker invocation.""" + + slot_id: UUID + slot_key: ModelPaidSlotKey + attempt_id: UUID + attempt_number: StrictInt = Field(ge=1) + internal_call_request_id: UUID + request_sha256: Sha256 + state: Literal[ + "prepared", "dispatching", "uncertain", "completed", "known_failed", "known_no_effect" + ] + replayed: bool + + +class PreparedModelCallAttempt(AttachedModelCallAttempt): + """Server-owned attempt identity created before ART request evidence exists.""" + + state: Literal["prepared"] = "prepared" + + +class ModelCallUsage(ModelCallValue): + """Provider-observed token classes used for exact integer settlement.""" + + input_tokens: StrictInt = Field(ge=0) + cached_input_tokens: StrictInt = Field(ge=0) + output_tokens: StrictInt = Field(ge=0) + reasoning_tokens: StrictInt = Field(ge=0) + + @model_validator(mode="after") + def nested_classes(self) -> Self: + if ( + self.cached_input_tokens > self.input_tokens + or self.reasoning_tokens > self.output_tokens + ): + raise ValueError("model usage token classes are inconsistent") + return self + + +class ModelDispatchReservation(ModelCallValue): + """Committed dispatch intent and lifetime project-budget reservation.""" + + slot_id: UUID + attempt_id: UUID + attempt_number: StrictInt = Field(ge=1) + internal_call_request_id: UUID + effect: ModelCallEffectAuthorityFacts + request_sha256: Sha256 + request_evidence: ModelCallEvidence + budget_configuration_id: UUID + budget_configuration_version: StrictInt = Field(ge=1) + budget_configuration_digest: Sha256 + reserved_nanodollars: Nanodollars + state: Literal["dispatching", "uncertain", "completed"] + provider_response_id: ProviderResponseIdentifier | None = None + response_evidence: ModelCallEvidence | None = None + replayed: bool + + +class ModelCallRecoveryAttachment(ModelCallValue): + """Fresh authorized recovery access to one known provider effect.""" + + slot_id: UUID + attempt_id: UUID + attempt_number: StrictInt = Field(ge=1) + internal_call_request_id: UUID + provider_response_id: ProviderResponseIdentifier + state: Literal["dispatching", "uncertain"] + replayed: bool + + +class CompletedModelCall(ModelCallValue): + """Exact immutable response evidence and once-settled project cost.""" + + slot_id: UUID + attempt_id: UUID + attempt_number: StrictInt = Field(ge=1) + internal_call_request_id: UUID + provider_response_id: ProviderResponseIdentifier + request_evidence: ModelCallEvidence + response_evidence: ModelCallEvidence + usage: ModelCallUsage + settled_nanodollars: Nanodollars + completed_at: AwareDatetime + replayed: bool + + +class KnownFailedModelCall(ModelCallValue): + """Authenticated terminal provider effect without a usable model response.""" + + slot_id: UUID + attempt_id: UUID + attempt_number: StrictInt = Field(ge=1) + internal_call_request_id: UUID + provider_response_id: ProviderResponseIdentifier + request_evidence: ModelCallEvidence + response_evidence: ModelCallEvidence + usage: ModelCallUsage + settled_nanodollars: Nanodollars + completed_at: AwareDatetime + replayed: bool + + +class KnownNoEffectProof(ModelCallValue): + """Exact provider proof that an attempted create caused no billable effect.""" + + proof_id: UUID + proof_digest: Sha256 + observed_at: AwareDatetime + + +class CompletedSetupModelUsageEffect(ModelCallValue): + """Exact retained setup response and once-settled project cost or unusable result.""" + + effect: SetupModelEffectAuthorityFacts + provider_response_id: ProviderResponseIdentifier + request_evidence: ModelCallEvidence + response_evidence: ModelCallEvidence + usage: ModelCallUsage + settled_nanodollars: Nanodollars + completed_at: AwareDatetime + state: Literal["completed", "known_failed"] = "completed" + replayed: bool + + +class KnownNoEffectSetupModelUsageEffect(ModelCallValue): + """Authenticated zero-effect terminal setup call with released reservation.""" + + effect: SetupModelEffectAuthorityFacts + proof: KnownNoEffectProof + state: Literal["known_no_effect"] = "known_no_effect" + replayed: bool + + +class ModelUsageEffectInspection(ModelCallValue): + """Redacted exact-effect accounting and evidence identities for an Operator.""" + + project_id: UUID + effect_id: UUID + owner_kind: Literal["checker", "setup"] + owner: ModelPaidSlotKey | SetupModelUsageSlotKey + attempt_number: StrictInt = Field(ge=1) + state: Literal[ + "prepared", "dispatching", "uncertain", "completed", "known_failed", "known_no_effect" + ] + provider_response_known: bool + request_evidence: ModelCallEvidence | None + response_evidence: ModelCallEvidence | None + usage: ModelCallUsage | None + budget_configuration_id: UUID | None + budget_configuration_version: StrictInt | None = Field(default=None, ge=1) + budget_configuration_digest: Sha256 | None + reserved_nanodollars: Nanodollars | None + settled_nanodollars: Nanodollars | None + uncertainty_digest: Sha256 | None + prepared_at: AwareDatetime + dispatched_at: AwareDatetime | None + terminal_at: AwareDatetime | None + + @model_validator(mode="after") + def exact_owner(self) -> Self: + expected = "checker" if isinstance(self.owner, ModelPaidSlotKey) else "setup" + if self.owner_kind != expected or self.owner.project_id != self.project_id: + raise ValueError("model usage inspection owner mismatch") + budget = ( + self.budget_configuration_id, + self.budget_configuration_version, + self.budget_configuration_digest, + self.reserved_nanodollars, + ) + if any(value is not None for value in budget) and not all( + value is not None for value in budget + ): + raise ValueError("model usage inspection budget identity is incomplete") + return self + + +def model_usage_instant(value: datetime) -> str: + """Match PostgreSQL's canonical UTC timestamptz JSON spelling exactly.""" + utc = value.astimezone(UTC) + rendered = utc.strftime("%Y-%m-%dT%H:%M:%S") + if utc.microsecond: + rendered += "." + f"{utc.microsecond:06d}".rstrip("0") + return rendered + "+00:00" + + +def model_retry_terminal_digest( + terminal: KnownFailedModelCall | KnownNoEffectProof, +) -> str: + """Commit the exact terminal facts that may authorize a paid child attempt.""" + if isinstance(terminal, KnownNoEffectProof): + facts = { + "state": "known_no_effect", + "proof_id": str(terminal.proof_id), + "proof_digest": terminal.proof_digest, + "observed_at": model_usage_instant(terminal.observed_at), + } + else: + facts = { + "state": "known_failed", + "provider_response_id": terminal.provider_response_id, + "response_content_sha256": terminal.response_evidence.content_sha256, + "usage": terminal.usage.model_dump(mode="json"), + "settled_nanodollars": terminal.settled_nanodollars, + } + return canonical_json_hash(facts) + + +class ModelActionEvidence(ModelCallValue): + """Opaque exact AUTH decision returned to the model-call owner.""" + + evidence_id: UUID + facts_digest: Sha256 + + +class ModelRecoveryOwnerFacts(ModelCallValue): + """Retained checker lineage selected from an exact paid effect and grant.""" + + checker_run_id: UUID + task_id: UUID + execution: ModelCallExecutionFacts + request_sha256: Sha256 + + +class ModelInvocationGrantFacts(ModelCallValue): + """Immutable owner facts selected privately by the proxy, never image input.""" + + token_hash: str = Field(repr=False) + external_request_digest: Sha256 + execution: ModelCallExecutionFacts + slot: ModelPaidSlotKey + registry_entry_id: UUID + + +class ModelProfileReaderPort(Protocol): + """Read immutable project profiles without mutation authority.""" + + async def read_exact( + self, + selector: ModelProfileSelector, + *, + project_id: UUID, + ) -> ProjectModelProfile: + """Resolve the complete selector only for its exact project.""" + ... + + async def read_latest_matching( + self, + *, + project_id: UUID, + provider: str, + api: str, + model_snapshot: str, + ) -> ProjectModelProfile: + """Select the highest published version matching one configured identity.""" + ... + + +class ModelCallAdministrationPort(Protocol): + """Own exact public profile, budget and recovery-child mutations.""" + + async def provision_profile(self, facts: ModelProfileProvisioningFacts) -> ProjectModelProfile: + """Authorize and retain immutable profile facts with exact operation replay.""" + ... + + async def provision_budget( + self, facts: ModelBudgetProvisioningFacts + ) -> ProjectModelBudgetConfiguration: + """Publish a finite lifetime limit without resetting reserved or settled spend.""" + ... + + async def prepare_recovery_child( + self, + slot: ModelPaidSlotKey, + *, + registry_entry_id: UUID, + parent_attempt_id: UUID, + terminal_digest: str, + request_sha256: str, + ) -> PreparedModelCallAttempt: + """Authorize a child of proven terminal failure without dispatching a provider call.""" + ... + + +class ModelUsageInspectionPort(Protocol): + """Read one exact redacted paid effect without mutation authority.""" + + async def inspect_effect(self, facts: ModelUsageInspectionFacts) -> ModelUsageEffectInspection: + """Return the authorized effect only when it belongs to the exact project.""" + ... + + +class ModelCallCurrentExecutionPort(Protocol): + """Require one current authorized checker execution without granting model effects.""" + + async def require_current(self, facts: ModelCallExecutionFacts, slot: ModelPaidSlotKey) -> None: + """Require current TASK and checker lease custody before attaching a paid slot.""" + ... + + async def require_recovery_current( + self, slot: ModelPaidSlotKey, parent_attempt_id: UUID + ) -> None: + """Lock current TASK/Submission custody without requiring a live worker lease.""" + ... + + +class ModelCallEvidencePort(Protocol): + """Verify ART evidence belongs to the exact call attempt and role.""" + + async def require_request( + self, attempt: PreparedModelCallAttempt, evidence: ModelCallEvidence + ) -> None: + """Verify the exact attempt owns the sealed request bytes before dispatch.""" + ... + + async def require_response(self, attempt_id: UUID, evidence: ModelCallEvidence) -> None: + """Verify sealed response evidence belongs to this attempt before settlement.""" + ... + + +class ModelCallAuthorityPort(Protocol): + """Action-specific authority; implementations must reject phase substitution.""" + + async def authorize_profile(self, facts: ModelProfileProvisioningFacts) -> ModelActionEvidence: + """Bind profile publication authority to the project and complete immutable facts.""" + ... + + async def authorize_budget(self, facts: ModelBudgetProvisioningFacts) -> ModelActionEvidence: + """Bind budget publication authority to the exact project and configuration.""" + ... + + async def authorize_usage_read(self, facts: ModelUsageInspectionFacts) -> ModelActionEvidence: + """Bind an Operator read to one exact project and paid effect.""" + ... + + async def authorize_prepare( + self, + execution: ModelCallExecutionFacts, + slot: ModelPaidSlotKey, + request_sha256: str, + ) -> ModelActionEvidence: + """Authorize request preparation for the current execution and stable paid slot.""" + ... + + async def authorize_dispatch( + self, + execution: ModelCallExecutionFacts, + attempt: PreparedModelCallAttempt, + request_evidence: ModelCallEvidence, + ) -> ModelActionEvidence: + """Authorize one provider effect using current execution and verified request evidence.""" + ... + + async def authorize_recover( + self, + execution: ModelCallExecutionFacts, + effect: ModelCallEffectAuthorityFacts, + ) -> ModelActionEvidence: + """Authorize inspection of an existing effect without permitting another create.""" + ... + + async def authorize_observe( + self, + effect: ModelCallEffectAuthorityFacts, + provider_response_id: str | None, + observation_digest: str, + ) -> ModelActionEvidence: + """Authorize retention of exact provider observations independently of lease expiry.""" + ... + + async def authorize_child( + self, + project_id: UUID, + parent_attempt_id: UUID, + terminal_digest: str, + ) -> ModelActionEvidence: + """Authorize a recovery child only against the exact retained terminal digest.""" + ... + + async def authorize_complete( + self, + effect: ModelCallEffectAuthorityFacts, + response_evidence: ModelCallEvidence | None, + terminal_digest: str, + ) -> ModelActionEvidence: + """Authorize terminal accounting against the effect and its response evidence.""" + ... + + +def model_usage_cost(profile: ProjectModelProfileSpec, usage: ModelCallUsage) -> int: + """Price disjoint token classes using exact integer nano-USD arithmetic.""" + usage = ModelCallUsage.model_validate(usage) + if ( + usage.input_tokens > profile.maximum_input_tokens + or usage.output_tokens > profile.maximum_output_tokens + ): + raise ModelCallConflict("model usage exceeds the approved profile") + uncached = usage.input_tokens - usage.cached_input_tokens + pricing = profile.pricing + value = ( + uncached * pricing.uncached_input_nanodollars_per_token + + usage.cached_input_tokens * pricing.cached_input_nanodollars_per_token + + usage.output_tokens * pricing.output_nanodollars_per_token + ) + if value < 0 or value > profile.maximum_reservation_nanodollars: + raise ModelCallConflict("model usage cost exceeds the approved reservation") + return value + + +def model_action_digest(action: str, facts: object) -> str: + """Commit one action name and its complete typed facts for AUTH evidence.""" + if isinstance(facts, BaseModel): + value = facts.model_dump(mode="json") + else: + value = facts + return canonical_json_hash({"action": action, "facts": value}) + + +__all__ = ( + "AttachedModelCallAttempt", + "CompletedModelCall", + "CompletedSetupModelUsageEffect", + "KnownFailedModelCall", + "KnownNoEffectProof", + "KnownNoEffectSetupModelUsageEffect", + "ModelActionEvidence", + "ModelBudgetProvisioningFacts", + "ModelCallAdministrationPort", + "ModelCallAuthorityPort", + "ModelCallBudgetUnavailable", + "ModelCallConflict", + "ModelCallCurrentExecutionPort", + "ModelCallEffectAuthorityFacts", + "ModelCallEvidence", + "ModelCallEvidencePort", + "ModelCallExecutionFacts", + "ModelCallRecoveryAttachment", + "ModelCallUnavailable", + "ModelCallUsage", + "ModelUsageEffectInspection", + "ModelUsageInspectionFacts", + "ModelUsageInspectionPort", + "ModelDispatchReservation", + "ModelEvidenceCustodyPort", + "ModelEvidenceReservation", + "ModelEvidenceSelector", + "ModelGenerationSettings", + "ModelInvocationGrantAuthorityPort", + "ModelInvocationGrantFacts", + "ModelPaidSlotKey", + "ModelPricing", + "ModelProfileProvisioningFacts", + "ModelProfileReaderPort", + "ModelProfileSelector", + "ModelRecoveryCandidate", + "ModelRecoveryChildRequest", + "ModelRecoveryOwnerFacts", + "ModelRecoverySourcePort", + "ObservedSetupModelUsageEffect", + "PreparedModelCallAttempt", + "PreparedSetupModelUsageEffect", + "ProjectModelBudgetConfiguration", + "ProjectModelBudgetConfigurationSpec", + "ProjectModelProfile", + "ProjectModelProfileSpec", + "SetupModelArtifactOutputPort", + "SetupModelDispatchReservation", + "SetupModelEffectAuthorityFacts", + "SetupModelOutputArtifactRequest", + "SetupModelOutputArtifactResult", + "SetupModelOutputBindingPort", + "SetupModelOutputBindingRequest", + "SetupModelOutputBindingResult", + "SetupModelOutputReservation", + "SetupModelOutputReservationPort", + "SetupModelOutputSelector", + "SetupModelOutputSlot", + "SetupModelOwnerProfile", + "SetupModelUsageAuthorityPort", + "SetupModelUsageEvidencePort", + "SetupModelUsageOwnerPort", + "SetupModelUsageSlotKey", + "model_action_digest", + "model_retry_terminal_digest", + "model_usage_instant", + "model_usage_cost", +) diff --git a/backend/app/modules/model_usage/grants.py b/backend/app/modules/model_usage/grants.py new file mode 100644 index 000000000..7fa3ee13b --- /dev/null +++ b/backend/app/modules/model_usage/grants.py @@ -0,0 +1,259 @@ +"""Bounded private session grants; each paid effect remains in the call ledger.""" + +import hashlib +import json +import re +import secrets +from uuid import UUID + +from sqlalchemy import select, func +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.identifiers import new_record_id +from app.interfaces.external_checker_execution import ExternalCheckerModelGrant +from app.modules.model_usage.api import ( + ModelCallCurrentExecutionPort, + ModelCallExecutionFacts, + ModelCallEffectAuthorityFacts, + ModelCallUnavailable, + ModelPaidSlotKey, + model_action_digest, +) +from app.modules.model_usage.api import ( + ModelInvocationGrantAuthorityPort, + ModelInvocationGrantFacts, +) +from app.modules.model_usage.repository import require_model_call_transaction +from app.modules.model_usage.models import ( + CheckerModelInvocationGrant, + CheckerModelInvocationGrantCall, +) + + +def model_grant_token_hash(token: str) -> str: + """Hash a closed short-lived capability with an explicit domain separator.""" + if type(token) is not str or re.fullmatch(r"model_[0-9a-f]{32}", token) is None: + raise ModelCallUnavailable("model_grant_unavailable") + return ( + "sha256:" + + hashlib.sha256(b"workstream.checker.model-grant.v1\0" + token.encode("ascii")).hexdigest() + ) + + +class ModelInvocationGrants: + """Issue invocation custody and bind each sequential call in caller transactions.""" + + def __init__( + self, + session: AsyncSession, + *, + authority: ModelInvocationGrantAuthorityPort, + current: ModelCallCurrentExecutionPort, + ): + self._session, self._authority, self._current = session, authority, current + + async def issue( + self, + execution: ModelCallExecutionFacts, + slot: ModelPaidSlotKey, + external_request_digest: str, + *, + registry_entry_id: UUID, + ) -> ExternalCheckerModelGrant: + """Authorize current binding before creating one private random capability.""" + require_model_call_transaction(self._session) + execution = ModelCallExecutionFacts.model_validate(execution) + slot = ModelPaidSlotKey.model_validate(slot) + token = "model_" + secrets.token_hex(16) + grant = ExternalCheckerModelGrant(token, external_request_digest) + token_hash = model_grant_token_hash(token) + evidence = await self._authority.authorize_grant( + execution, + slot, + external_request_digest, + token_hash, + ) + facts = { + "execution": execution.model_dump(mode="json"), + "slot": slot.model_dump(mode="json"), + "external_request_digest": external_request_digest, + "token_hash": token_hash, + } + if evidence.facts_digest != model_action_digest("grant", facts): + raise ModelCallUnavailable("model_grant_authority_unavailable") + await self._current.require_current(execution, slot) + self._session.add( + CheckerModelInvocationGrant( + id=str(new_record_id()), + token_hash=token_hash, + project_id=str(execution.project_id), + checker_run_id=str(execution.checker_run_id), + registry_entry_id=registry_entry_id, + execution_facts=execution.model_dump(mode="json"), + paid_slot=slot.model_dump(mode="json"), + external_request_digest=external_request_digest, + expires_at=execution.worker_lease_expires_at, + authorization_evidence_id=str(evidence.evidence_id), + ) + ) + await self._session.flush() + return grant + + async def resolve(self, token: str, request_digest: str) -> ModelInvocationGrantFacts: + """Select concealed capability facts; this lookup does not grant model authority.""" + require_model_call_transaction(self._session) + token_hash = model_grant_token_hash(token) + row = await self._session.scalar( + select(CheckerModelInvocationGrant).where( + CheckerModelInvocationGrant.token_hash == token_hash, + CheckerModelInvocationGrant.external_request_digest == request_digest, + CheckerModelInvocationGrant.expires_at > func.clock_timestamp(), + ) + ) + if row is None: + raise ModelCallUnavailable("model_grant_unavailable") + execution = ModelCallExecutionFacts.model_validate_json(json.dumps(row.execution_facts)) + slot = ModelPaidSlotKey.model_validate_json(json.dumps(row.paid_slot)) + if ( + str(execution.project_id), + str(execution.checker_run_id), + execution.worker_lease_expires_at, + ) != ( + row.project_id, + row.checker_run_id, + row.expires_at, + ): + raise ModelCallUnavailable("model_grant_unavailable") + return ModelInvocationGrantFacts( + token_hash=token_hash, + external_request_digest=request_digest, + execution=execution, + slot=slot, + registry_entry_id=UUID(str(row.registry_entry_id)), + ) + + async def consume_prepared( + self, + facts: ModelInvocationGrantFacts, + *, + call_index: int, + request_sha256: str, + prepared_attempt_id: UUID, + ) -> None: + """Bind one sequential call after preparation in the same owner transaction.""" + from app.modules.model_usage.repository import ModelCallRepository + + require_model_call_transaction(self._session) + if re.fullmatch(r"sha256:[0-9a-f]{64}", request_sha256) is None: + raise ModelCallUnavailable("model_grant_unavailable") + if type(call_index) is not int or not 0 <= call_index <= 99: + raise ModelCallUnavailable("model_grant_unavailable") + selected_slot = facts.slot.model_copy(update={"slot_index": call_index}) + repo = ModelCallRepository(self._session) + await repo.lock_envelope_scope(selected_slot) + if await repo.envelope(selected_slot, lock=True) is None: + raise ModelCallUnavailable("model_grant_unavailable") + await repo.lock_slot_scope(selected_slot) + slot = await repo.slot(selected_slot, lock=True) + attempt = await repo.attempt(prepared_attempt_id, lock=True) + if ( + slot is None + or attempt is None + or (attempt.checker_slot_id != slot.id or attempt.request_sha256 != request_sha256) + ): + raise ModelCallUnavailable("model_grant_unavailable") + row = await self._session.scalar( + select(CheckerModelInvocationGrant) + .where( + CheckerModelInvocationGrant.token_hash == facts.token_hash, + CheckerModelInvocationGrant.external_request_digest + == facts.external_request_digest, + ) + .with_for_update() + .execution_options(populate_existing=True) + ) + now = await self._session.scalar(select(func.clock_timestamp())) + if ( + row is None + or row.expires_at <= now + or ( + row.execution_facts != facts.execution.model_dump(mode="json") + or row.paid_slot != facts.slot.model_dump(mode="json") + ) + ): + raise ModelCallUnavailable("model_grant_unavailable") + existing = await self._session.get( + CheckerModelInvocationGrantCall, + {"grant_id": row.id, "call_index": call_index}, + ) + if existing is not None: + if ( + existing.attempt_id != str(prepared_attempt_id) + or existing.request_sha256 != request_sha256 + ): + raise ModelCallUnavailable("model_grant_unavailable") + return + self._session.add( + CheckerModelInvocationGrantCall( + grant_id=row.id, + call_index=call_index, + attempt_id=str(prepared_attempt_id), + request_sha256=request_sha256, + ) + ) + await self._session.flush() + + async def completed_effects( + self, + token: str, + request_digest: str, + execution: ModelCallExecutionFacts, + ) -> tuple[ModelCallEffectAuthorityFacts, ...]: + """Select all session effects; the ledger freshly authorizes each completed read.""" + from app.modules.model_usage.models import CheckerModelUsageSlot, ModelUsageEffect + + require_model_call_transaction(self._session) + row = await self._session.scalar( + select(CheckerModelInvocationGrant).where( + CheckerModelInvocationGrant.token_hash == model_grant_token_hash(token), + CheckerModelInvocationGrant.external_request_digest == request_digest, + CheckerModelInvocationGrant.expires_at > func.clock_timestamp(), + ) + ) + if row is None or row.execution_facts != execution.model_dump(mode="json"): + raise ModelCallUnavailable("model_grant_completion_unavailable") + calls = ( + await self._session.execute( + select(CheckerModelInvocationGrantCall, ModelUsageEffect, CheckerModelUsageSlot) + .join( + ModelUsageEffect, + ModelUsageEffect.id == CheckerModelInvocationGrantCall.attempt_id, + ) + .join( + CheckerModelUsageSlot, + CheckerModelUsageSlot.id == ModelUsageEffect.checker_slot_id, + ) + .where(CheckerModelInvocationGrantCall.grant_id == row.id) + .order_by(CheckerModelInvocationGrantCall.call_index) + ) + ).all() + if any( + call.call_index != index + or attempt.request_sha256 != call.request_sha256 + or attempt.state != "completed" + or slot.slot_index != index + for index, (call, attempt, slot) in enumerate(calls) + ): + raise ModelCallUnavailable("model_grant_completion_unavailable") + base_slot = ModelPaidSlotKey.model_validate_json(json.dumps(row.paid_slot)) + return tuple( + ModelCallEffectAuthorityFacts( + project_id=execution.project_id, + task_id=execution.task_id, + slot_id=UUID(attempt.checker_slot_id), + slot_key=base_slot.model_copy(update={"slot_index": call.call_index}), + attempt_id=UUID(attempt.id), + internal_call_request_id=UUID(attempt.internal_call_request_id), + ) + for call, attempt, _slot in calls + ) diff --git a/backend/app/modules/model_usage/inspection.py b/backend/app/modules/model_usage/inspection.py new file mode 100644 index 000000000..52126a2c0 --- /dev/null +++ b/backend/app/modules/model_usage/inspection.py @@ -0,0 +1,159 @@ +"""Authorized redacted reads for one exact durable model effect.""" + +from __future__ import annotations + +from uuid import UUID + +from sqlalchemy.ext.asyncio import AsyncSession + +from app.modules.model_usage.api import ( + ModelCallAuthorityPort, + ModelCallEvidence, + ModelCallUnavailable, + ModelCallUsage, + ModelPaidSlotKey, + ModelUsageEffectInspection, + ModelUsageInspectionFacts, + SetupModelUsageSlotKey, + model_action_digest, +) +from app.modules.model_usage.models import ( + CheckerModelUsageSlot, + ModelUsageEffect, + SetupModelUsageSlot, +) +from app.modules.model_usage.repository import require_model_call_transaction + + +def _uuid(value: str | UUID) -> UUID: + """Decode a stored canonical record identifier.""" + return UUID(str(value)) + + +def _checker_key(row: CheckerModelUsageSlot) -> ModelPaidSlotKey: + """Project one stored checker slot into its public stable owner key.""" + return ModelPaidSlotKey( + project_id=_uuid(row.project_id), + assignment_id=_uuid(row.assignment_id), + submission_id=_uuid(row.submission_id), + submission_version=row.submission_version, + requirement_id=row.requirement_id, + registry_entry_digest=row.registry_entry_digest, + configuration_sha256=row.configuration_sha256, + profile_id=_uuid(row.profile_id), + profile_version=row.profile_version, + profile_digest=row.profile_digest, + slot_index=row.slot_index, + ) + + +def _setup_key(row: SetupModelUsageSlot) -> SetupModelUsageSlotKey: + """Project one stored setup slot into its public stable owner key.""" + return SetupModelUsageSlotKey( + compilation_attempt_id=_uuid(row.compilation_attempt_id), + project_id=_uuid(row.project_id), + guide_id=_uuid(row.guide_id), + source_snapshot_id=_uuid(row.source_snapshot_id), + setup_run_id=_uuid(row.setup_run_id), + setup_generation=row.setup_generation, + runtime_configuration_hash=row.runtime_configuration_hash, + profile_id=_uuid(row.profile_id), + profile_version=row.profile_version, + profile_digest=row.profile_digest, + phase=row.phase, + call_index=row.call_index, + ) + + +def _optional_evidence(row: ModelUsageEffect, prefix: str) -> ModelCallEvidence | None: + """Return complete bounded ART facts or reject partial retained custody.""" + values = { + "binding_id": getattr(row, f"{prefix}_binding_id"), + "content_id": getattr(row, f"{prefix}_content_id"), + "put_attempt_id": getattr(row, f"{prefix}_put_attempt_id"), + "verification_receipt_id": getattr(row, f"{prefix}_verification_receipt_id"), + "content_sha256": getattr(row, f"{prefix}_content_sha256"), + "byte_count": getattr(row, f"{prefix}_byte_count"), + } + if all(value is None for value in values.values()): + return None + if any(value is None for value in values.values()): + raise ModelCallUnavailable("model_usage_inspection_unavailable") + for field in ("binding_id", "content_id", "put_attempt_id", "verification_receipt_id"): + values[field] = _uuid(values[field]) + return ModelCallEvidence.model_validate(values) + + +def _optional_usage(row: ModelUsageEffect) -> ModelCallUsage | None: + """Return complete provider usage or reject a partial usage tuple.""" + values = { + "input_tokens": row.input_tokens, + "cached_input_tokens": row.cached_input_tokens, + "output_tokens": row.output_tokens, + "reasoning_tokens": row.reasoning_tokens, + } + if all(value is None for value in values.values()): + return None + if any(value is None for value in values.values()): + raise ModelCallUnavailable("model_usage_inspection_unavailable") + return ModelCallUsage.model_validate(values) + + +class ModelUsageInspectionService: + """Authorize before reading MODEL-USAGE and conceal absent or foreign effects.""" + + def __init__(self, session: AsyncSession, authority: ModelCallAuthorityPort) -> None: + """Bind one database transaction to the exact-effect authority port.""" + self._session = session + self._authority = authority + + async def inspect_effect(self, facts: ModelUsageInspectionFacts) -> ModelUsageEffectInspection: + """Authorize and return one redacted same-project paid effect.""" + require_model_call_transaction(self._session) + facts = ModelUsageInspectionFacts.model_validate(facts) + evidence = await self._authority.authorize_usage_read(facts) + if evidence.facts_digest != model_action_digest("usage_read", facts): + raise ModelCallUnavailable("model_call_authority_unavailable") + + effect = await self._session.get(ModelUsageEffect, str(facts.effect_id)) + if effect is None: + raise ModelCallUnavailable("model_usage_inspection_unavailable") + if effect.checker_slot_id is not None: + slot = await self._session.get(CheckerModelUsageSlot, effect.checker_slot_id) + owner_kind = "checker" + owner = _checker_key(slot) if slot is not None else None + else: + slot = await self._session.get(SetupModelUsageSlot, effect.setup_slot_id) + owner_kind = "setup" + owner = _setup_key(slot) if slot is not None else None + if owner is None or owner.project_id != facts.project_id: + raise ModelCallUnavailable("model_usage_inspection_unavailable") + + return ModelUsageEffectInspection( + project_id=facts.project_id, + effect_id=facts.effect_id, + owner_kind=owner_kind, + owner=owner, + attempt_number=effect.attempt_number, + state=effect.state, + provider_response_known=effect.provider_response_id is not None, + request_evidence=_optional_evidence(effect, "request"), + response_evidence=_optional_evidence(effect, "response"), + usage=_optional_usage(effect), + budget_configuration_id=( + _uuid(effect.budget_configuration_id) + if effect.budget_configuration_id is not None + else None + ), + budget_configuration_version=effect.budget_configuration_version, + budget_configuration_digest=effect.budget_configuration_digest, + reserved_nanodollars=effect.reserved_nanodollars, + settled_nanodollars=effect.settled_nanodollars, + uncertainty_digest=effect.uncertainty_digest, + prepared_at=effect.prepared_at, + dispatched_at=effect.dispatched_at, + terminal_at=effect.terminal_at, + ) + + +__all__ = ("ModelUsageInspectionService",) diff --git a/backend/app/modules/model_usage/models.py b/backend/app/modules/model_usage/models.py new file mode 100644 index 000000000..a0a902a12 --- /dev/null +++ b/backend/app/modules/model_usage/models.py @@ -0,0 +1,813 @@ +"""SQLAlchemy rows for immutable profiles, lifetime budgets and paid call slots.""" + +from __future__ import annotations + +from datetime import datetime +from uuid import UUID + +from sqlalchemy import ( + BigInteger, + CheckConstraint, + DateTime, + ForeignKey, + ForeignKeyConstraint, + Integer, + String, + UniqueConstraint, + Uuid, + func, +) +from sqlalchemy.dialects.postgresql import JSONB +from sqlalchemy.orm import Mapped, mapped_column +from sqlalchemy.sql.expression import text + +from app.db.base import Base + + +_UUID7 = "(get_byte(uuid_send(id), 6) >> 4) = 7 and (get_byte(uuid_send(id), 8) & 192) = 128" +_SHA = "^sha256:[0-9a-f]{64}$" + + +class ModelUsageProfile(Base): + """One immutable Operator-authorized provider/pricing profile.""" + + __tablename__ = "model_usage_profiles" + __table_args__ = ( + CheckConstraint(_UUID7, name="id_uuid7"), + UniqueConstraint("project_id", "profile_version", name="model_usage_profiles_version"), + UniqueConstraint( + "id", + "profile_version", + "profile_digest", + name="model_usage_profiles_identity", + ), + UniqueConstraint("profile_digest", name="model_usage_profiles_digest"), + UniqueConstraint("operation_id", name="model_usage_profiles_operation"), + UniqueConstraint("authorization_evidence_id", name="model_usage_profiles_authority"), + CheckConstraint( + f"profile_digest ~ '{_SHA}' and pricing_policy_digest ~ '{_SHA}'", + name="digests", + ), + CheckConstraint( + "profile_version > 0 and pricing_policy_version > 0 and " + "maximum_calls between 1 and 100", + name="versions", + ), + CheckConstraint( + "maximum_request_bytes between 1 and 1048576 and " + "maximum_output_bytes between 1 and 1048576 and " + "maximum_response_bytes between 1 and 1048576 and " + "maximum_response_bytes >= 6 * maximum_output_bytes + 4096 and " + "maximum_input_tokens between 1 and 10000000 and " + "maximum_output_tokens between 1 and 10000000 and " + "deadline_ms between 100 and 3600000 and " + "maximum_total_request_bytes between maximum_request_bytes and 104857600 and " + "maximum_total_response_bytes between maximum_response_bytes and 104857600 and " + "maximum_total_input_tokens between maximum_input_tokens and 100000000 and " + "maximum_total_output_tokens between maximum_output_tokens and 100000000 and " + "maximum_total_request_bytes <= maximum_request_bytes * maximum_calls and " + "maximum_total_response_bytes <= maximum_response_bytes * maximum_calls and " + "maximum_total_input_tokens <= maximum_input_tokens * maximum_calls and " + "maximum_total_output_tokens <= maximum_output_tokens * maximum_calls and " + "maximum_tool_calls between 0 and 1000 and " + "maximum_tool_output_bytes between 0 and 104857600", + name="bounds", + ), + CheckConstraint( + "jsonb_typeof(allowed_tools) = 'array' and " + "((jsonb_array_length(allowed_tools) = 0 and maximum_tool_calls = 0 and " + "maximum_tool_output_bytes = 0) or " + "(jsonb_array_length(allowed_tools) > 0 and maximum_tool_calls > 0 and " + "maximum_tool_output_bytes > 0))", + name="tools", + ), + CheckConstraint( + "uncached_input_rate >= 0 and cached_input_rate >= 0 and " + "output_rate >= 0 and " + "greatest(uncached_input_rate,cached_input_rate,output_rate) > 0", + name="pricing", + ), + ) + + id: Mapped[str] = mapped_column(Uuid(as_uuid=False), primary_key=True) + project_id: Mapped[str] = mapped_column( + Uuid(as_uuid=False), ForeignKey("projects.id", ondelete="RESTRICT"), nullable=False + ) + profile_version: Mapped[int] = mapped_column(Integer, nullable=False) + profile_digest: Mapped[str] = mapped_column(String(71), nullable=False) + provider: Mapped[str] = mapped_column(String(160), nullable=False) + api: Mapped[str] = mapped_column(String(160), nullable=False) + model_snapshot: Mapped[str] = mapped_column(String(160), nullable=False) + generation_settings: Mapped[dict] = mapped_column(JSONB(), nullable=False) + maximum_request_bytes: Mapped[int] = mapped_column(Integer, nullable=False) + maximum_output_bytes: Mapped[int] = mapped_column(Integer, nullable=False) + maximum_response_bytes: Mapped[int] = mapped_column(Integer, nullable=False) + maximum_input_tokens: Mapped[int] = mapped_column(Integer, nullable=False) + maximum_output_tokens: Mapped[int] = mapped_column(Integer, nullable=False) + maximum_calls: Mapped[int] = mapped_column(Integer, nullable=False) + maximum_total_request_bytes: Mapped[int] = mapped_column(Integer, nullable=False) + maximum_total_response_bytes: Mapped[int] = mapped_column(Integer, nullable=False) + maximum_total_input_tokens: Mapped[int] = mapped_column(Integer, nullable=False) + maximum_total_output_tokens: Mapped[int] = mapped_column(Integer, nullable=False) + allowed_tools: Mapped[list[str]] = mapped_column(JSONB(), nullable=False) + maximum_tool_calls: Mapped[int] = mapped_column(Integer, nullable=False) + maximum_tool_output_bytes: Mapped[int] = mapped_column(Integer, nullable=False) + deadline_ms: Mapped[int] = mapped_column(Integer, nullable=False) + pricing_policy_id: Mapped[str] = mapped_column(Uuid(as_uuid=False), nullable=False) + pricing_policy_version: Mapped[int] = mapped_column(Integer, nullable=False) + pricing_policy_digest: Mapped[str] = mapped_column(String(71), nullable=False) + uncached_input_rate: Mapped[int] = mapped_column(BigInteger, nullable=False) + cached_input_rate: Mapped[int] = mapped_column(BigInteger, nullable=False) + output_rate: Mapped[int] = mapped_column(BigInteger, nullable=False) + operation_id: Mapped[str] = mapped_column(Uuid(as_uuid=False), nullable=False) + actor_profile_id: Mapped[str] = mapped_column( + Uuid(as_uuid=False), ForeignKey("actor_profiles.id", ondelete="RESTRICT"), nullable=False + ) + authorization_evidence_id: Mapped[str] = mapped_column( + Uuid(as_uuid=False), ForeignKey("audit_events.id", ondelete="RESTRICT"), nullable=False + ) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False, server_default=text("clock_timestamp()") + ) + + +class ModelUsageBudgetAccount(Base): + """One project lifetime account; configuration versions never reset counters.""" + + __tablename__ = "model_usage_budget_accounts" + __table_args__ = ( + ForeignKeyConstraint( + ["current_configuration_id", "project_id"], + [ + "model_usage_budget_configurations.id", + "model_usage_budget_configurations.project_id", + ], + ondelete="RESTRICT", + name="configuration_identity", + ), + CheckConstraint( + "lifetime_reserved_nanodollars >= 0 and lifetime_settled_nanodollars >= 0", + name="nonnegative_totals", + ), + ) + + project_id: Mapped[str] = mapped_column( + Uuid(as_uuid=False), ForeignKey("projects.id", ondelete="RESTRICT"), primary_key=True + ) + current_configuration_id: Mapped[str] = mapped_column( + Uuid(as_uuid=False), nullable=False, unique=True + ) + lifetime_reserved_nanodollars: Mapped[int] = mapped_column( + BigInteger, nullable=False, default=0 + ) + lifetime_settled_nanodollars: Mapped[int] = mapped_column(BigInteger, nullable=False, default=0) + + +class ModelUsageBudgetConfiguration(Base): + """Immutable Operator-authorized adjustment of one project's lifetime ceiling.""" + + __tablename__ = "model_usage_budget_configurations" + __table_args__ = ( + CheckConstraint(_UUID7, name="id_uuid7"), + UniqueConstraint( + "project_id", + "configuration_version", + name="model_usage_budget_configurations_version", + ), + UniqueConstraint( + "id", + "project_id", + "configuration_version", + "configuration_digest", + name="model_usage_budget_configurations_identity", + ), + UniqueConstraint( + "id", + "configuration_version", + "configuration_digest", + name="model_usage_budget_configurations_attempt_identity", + ), + UniqueConstraint( + "id", + "project_id", + name="model_usage_budget_configurations_id_project", + ), + UniqueConstraint("configuration_digest", name="model_usage_budget_configurations_digest"), + UniqueConstraint("operation_id", name="model_usage_budget_configurations_operation"), + UniqueConstraint( + "authorization_evidence_id", + name="model_usage_budget_configurations_authority", + ), + CheckConstraint(f"configuration_digest ~ '{_SHA}'", name="digest"), + CheckConstraint( + "configuration_version > 0 and authorized_total_nanodollars > 0 and " + "externally_reconciled_prior_spend_nanodollars >= 0 and " + "lifetime_limit_nanodollars > 0 and " + "externally_reconciled_prior_spend_nanodollars + " + "lifetime_limit_nanodollars <= authorized_total_nanodollars", + name="positive_values", + ), + ) + + id: Mapped[str] = mapped_column(Uuid(as_uuid=False), primary_key=True) + project_id: Mapped[str] = mapped_column( + Uuid(as_uuid=False), ForeignKey("projects.id", ondelete="RESTRICT"), nullable=False + ) + configuration_version: Mapped[int] = mapped_column(Integer, nullable=False) + configuration_digest: Mapped[str] = mapped_column(String(71), nullable=False) + authorized_total_nanodollars: Mapped[int] = mapped_column(BigInteger, nullable=False) + externally_reconciled_prior_spend_nanodollars: Mapped[int] = mapped_column( + BigInteger, nullable=False + ) + lifetime_limit_nanodollars: Mapped[int] = mapped_column(BigInteger, nullable=False) + operation_id: Mapped[str] = mapped_column(Uuid(as_uuid=False), nullable=False) + actor_profile_id: Mapped[str] = mapped_column( + Uuid(as_uuid=False), ForeignKey("actor_profiles.id", ondelete="RESTRICT"), nullable=False + ) + authorization_evidence_id: Mapped[str] = mapped_column( + Uuid(as_uuid=False), ForeignKey("audit_events.id", ondelete="RESTRICT"), nullable=False + ) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False, server_default=text("clock_timestamp()") + ) + + +class CheckerModelUsageEnvelope(Base): + """Submission-stable whole-run ceiling shared by checker call slots and retries.""" + + __tablename__ = "checker_model_usage_envelopes" + __table_args__ = ( + CheckConstraint(_UUID7, name="id_uuid7"), + UniqueConstraint( + "project_id", + "assignment_id", + "submission_id", + "submission_version", + "requirement_id", + "registry_entry_digest", + "configuration_sha256", + "profile_id", + "profile_version", + "profile_digest", + name="checker_model_usage_envelopes_stable_identity", + ), + ForeignKeyConstraint( + ["profile_id", "profile_version", "profile_digest"], + [ + "model_usage_profiles.id", + "model_usage_profiles.profile_version", + "model_usage_profiles.profile_digest", + ], + name="profile_identity", + ondelete="RESTRICT", + ), + CheckConstraint( + f"registry_entry_digest ~ '{_SHA}' and configuration_sha256 ~ '{_SHA}' and " + f"profile_digest ~ '{_SHA}'", + name="digests", + ), + CheckConstraint( + "submission_version > 0 and profile_version > 0", + name="versions", + ), + CheckConstraint( + "requirement_id ~ '^[a-z][a-z0-9_.-]{0,99}$'", + name="requirement_id", + ), + ) + + id: Mapped[str] = mapped_column(Uuid(as_uuid=False), primary_key=True) + project_id: Mapped[str] = mapped_column( + Uuid(as_uuid=False), ForeignKey("projects.id", ondelete="RESTRICT"), nullable=False + ) + task_id: Mapped[str] = mapped_column( + Uuid(as_uuid=False), + ForeignKey("workstream_tasks.id", ondelete="RESTRICT"), + nullable=False, + ) + assignment_id: Mapped[str] = mapped_column( + Uuid(as_uuid=False), ForeignKey("task_assignments.id", ondelete="RESTRICT"), nullable=False + ) + submission_id: Mapped[str] = mapped_column( + Uuid(as_uuid=False), ForeignKey("submissions.id", ondelete="RESTRICT"), nullable=False + ) + submission_version: Mapped[int] = mapped_column(Integer, nullable=False) + requirement_id: Mapped[str] = mapped_column(String(100), nullable=False) + registry_entry_digest: Mapped[str] = mapped_column(String(71), nullable=False) + configuration_sha256: Mapped[str] = mapped_column(String(71), nullable=False) + profile_id: Mapped[str] = mapped_column(Uuid(as_uuid=False), nullable=False) + profile_version: Mapped[int] = mapped_column(Integer, nullable=False) + profile_digest: Mapped[str] = mapped_column(String(71), nullable=False) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False, server_default=text("clock_timestamp()") + ) + + +class CheckerModelUsageSlot(Base): + """Submission-stable paid effect identity independent of run generation and lease.""" + + __tablename__ = "checker_model_usage_slots" + __table_args__ = ( + CheckConstraint(_UUID7, name="id_uuid7"), + UniqueConstraint( + "project_id", + "assignment_id", + "submission_id", + "submission_version", + "requirement_id", + "registry_entry_digest", + "configuration_sha256", + "profile_id", + "profile_version", + "profile_digest", + "slot_index", + name="checker_model_usage_slots_stable_identity", + ), + ForeignKeyConstraint( + ["profile_id", "profile_version", "profile_digest"], + [ + "model_usage_profiles.id", + "model_usage_profiles.profile_version", + "model_usage_profiles.profile_digest", + ], + name="profile_identity", + ondelete="RESTRICT", + ), + CheckConstraint( + f"registry_entry_digest ~ '{_SHA}' and configuration_sha256 ~ '{_SHA}' and " + f"profile_digest ~ '{_SHA}' and first_request_sha256 ~ '{_SHA}'", + name="digests", + ), + CheckConstraint( + "submission_version > 0 and profile_version > 0 and slot_index between 0 and 99", + name="versions", + ), + CheckConstraint( + "requirement_id ~ '^[a-z][a-z0-9_.-]{0,99}$'", + name="requirement_id", + ), + ) + + id: Mapped[str] = mapped_column(Uuid(as_uuid=False), primary_key=True) + envelope_id: Mapped[str] = mapped_column( + Uuid(as_uuid=False), + ForeignKey("checker_model_usage_envelopes.id", ondelete="RESTRICT"), + nullable=False, + ) + project_id: Mapped[str] = mapped_column( + Uuid(as_uuid=False), ForeignKey("projects.id", ondelete="RESTRICT"), nullable=False + ) + task_id: Mapped[str] = mapped_column( + Uuid(as_uuid=False), + ForeignKey("workstream_tasks.id", ondelete="RESTRICT"), + nullable=False, + ) + assignment_id: Mapped[str] = mapped_column( + Uuid(as_uuid=False), ForeignKey("task_assignments.id", ondelete="RESTRICT"), nullable=False + ) + submission_id: Mapped[str] = mapped_column( + Uuid(as_uuid=False), ForeignKey("submissions.id", ondelete="RESTRICT"), nullable=False + ) + submission_version: Mapped[int] = mapped_column(Integer, nullable=False) + requirement_id: Mapped[str] = mapped_column(String(100), nullable=False) + registry_entry_digest: Mapped[str] = mapped_column(String(71), nullable=False) + configuration_sha256: Mapped[str] = mapped_column(String(71), nullable=False) + profile_id: Mapped[str] = mapped_column(Uuid(as_uuid=False), nullable=False) + profile_version: Mapped[int] = mapped_column(Integer, nullable=False) + profile_digest: Mapped[str] = mapped_column(String(71), nullable=False) + slot_index: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + first_request_sha256: Mapped[str] = mapped_column(String(71), nullable=False) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False, server_default=text("clock_timestamp()") + ) + + +class SetupModelUsageEnvelope(Base): + """Whole-run ceiling strongly owned by one exact setup compilation attempt.""" + + __tablename__ = "setup_model_usage_envelopes" + __table_args__ = ( + CheckConstraint(_UUID7, name="id_uuid7"), + UniqueConstraint( + "compilation_attempt_id", + "runtime_configuration_hash", + "profile_id", + "profile_version", + "profile_digest", + name="setup_model_usage_envelopes_stable_identity", + ), + UniqueConstraint( + "compilation_attempt_id", + name="setup_model_usage_envelopes_one_per_attempt", + ), + ForeignKeyConstraint( + [ + "compilation_attempt_id", + "project_id", + "guide_id", + "source_snapshot_id", + "setup_run_id", + "setup_generation", + "runtime_configuration_hash", + ], + [ + "project_guide_compilation_attempts.id", + "project_guide_compilation_attempts.project_id", + "project_guide_compilation_attempts.guide_id", + "project_guide_compilation_attempts.source_snapshot_id", + "project_guide_compilation_attempts.setup_run_id", + "project_guide_compilation_attempts.setup_generation", + "project_guide_compilation_attempts.runtime_configuration_hash", + ], + name="setup_attempt_identity", + ondelete="RESTRICT", + ), + ForeignKeyConstraint( + ["profile_id", "profile_version", "profile_digest"], + [ + "model_usage_profiles.id", + "model_usage_profiles.profile_version", + "model_usage_profiles.profile_digest", + ], + name="profile_identity", + ondelete="RESTRICT", + ), + CheckConstraint( + f"runtime_configuration_hash ~ '{_SHA}' and profile_digest ~ '{_SHA}'", + name="digests", + ), + CheckConstraint( + "setup_generation > 0 and profile_version > 0", + name="versions", + ), + ) + + id: Mapped[str] = mapped_column(Uuid(as_uuid=False), primary_key=True) + compilation_attempt_id: Mapped[UUID] = mapped_column(Uuid(), nullable=False) + project_id: Mapped[str] = mapped_column(Uuid(as_uuid=False), nullable=False) + guide_id: Mapped[str] = mapped_column(Uuid(as_uuid=False), nullable=False) + source_snapshot_id: Mapped[str] = mapped_column(Uuid(as_uuid=False), nullable=False) + setup_run_id: Mapped[str] = mapped_column(Uuid(as_uuid=False), nullable=False) + setup_generation: Mapped[int] = mapped_column(BigInteger, nullable=False) + runtime_configuration_hash: Mapped[str] = mapped_column(String(71), nullable=False) + profile_id: Mapped[str] = mapped_column(Uuid(as_uuid=False), nullable=False) + profile_version: Mapped[int] = mapped_column(Integer, nullable=False) + profile_digest: Mapped[str] = mapped_column(String(71), nullable=False) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False, server_default=text("clock_timestamp()") + ) + + +class SetupModelUsageSlot(Base): + """One provider dispatch slot strongly owned by an exact setup attempt.""" + + __tablename__ = "setup_model_usage_slots" + __table_args__ = ( + CheckConstraint(_UUID7, name="id_uuid7"), + UniqueConstraint( + "compilation_attempt_id", + "runtime_configuration_hash", + "profile_id", + "profile_version", + "profile_digest", + "phase", + "call_index", + name="setup_model_usage_slots_stable_identity", + ), + ForeignKeyConstraint( + [ + "compilation_attempt_id", + "project_id", + "guide_id", + "source_snapshot_id", + "setup_run_id", + "setup_generation", + "runtime_configuration_hash", + ], + [ + "project_guide_compilation_attempts.id", + "project_guide_compilation_attempts.project_id", + "project_guide_compilation_attempts.guide_id", + "project_guide_compilation_attempts.source_snapshot_id", + "project_guide_compilation_attempts.setup_run_id", + "project_guide_compilation_attempts.setup_generation", + "project_guide_compilation_attempts.runtime_configuration_hash", + ], + name="setup_attempt_identity", + ondelete="RESTRICT", + ), + ForeignKeyConstraint( + ["profile_id", "profile_version", "profile_digest"], + [ + "model_usage_profiles.id", + "model_usage_profiles.profile_version", + "model_usage_profiles.profile_digest", + ], + name="profile_identity", + ondelete="RESTRICT", + ), + CheckConstraint( + f"runtime_configuration_hash ~ '{_SHA}' and profile_digest ~ '{_SHA}' and " + f"request_sha256 ~ '{_SHA}'", + name="digests", + ), + CheckConstraint( + "setup_generation > 0 and profile_version > 0 and call_index between 0 and 99", + name="versions", + ), + CheckConstraint( + "phase in ('draft','review','correction')", + name="phase", + ), + ) + + id: Mapped[str] = mapped_column(Uuid(as_uuid=False), primary_key=True) + envelope_id: Mapped[str] = mapped_column( + Uuid(as_uuid=False), + ForeignKey("setup_model_usage_envelopes.id", ondelete="RESTRICT"), + nullable=False, + ) + compilation_attempt_id: Mapped[UUID] = mapped_column(Uuid(), nullable=False) + project_id: Mapped[str] = mapped_column(Uuid(as_uuid=False), nullable=False) + guide_id: Mapped[str] = mapped_column(Uuid(as_uuid=False), nullable=False) + source_snapshot_id: Mapped[str] = mapped_column(Uuid(as_uuid=False), nullable=False) + setup_run_id: Mapped[str] = mapped_column(Uuid(as_uuid=False), nullable=False) + setup_generation: Mapped[int] = mapped_column(BigInteger, nullable=False) + runtime_configuration_hash: Mapped[str] = mapped_column(String(71), nullable=False) + profile_id: Mapped[str] = mapped_column(Uuid(as_uuid=False), nullable=False) + profile_version: Mapped[int] = mapped_column(Integer, nullable=False) + profile_digest: Mapped[str] = mapped_column(String(71), nullable=False) + phase: Mapped[str] = mapped_column(String(32), nullable=False) + call_index: Mapped[int] = mapped_column(Integer, nullable=False) + request_sha256: Mapped[str] = mapped_column(String(71), nullable=False) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False, server_default=text("clock_timestamp()") + ) + + +class ModelUsageEffect(Base): + """One server-numbered provider effect attempt beneath a stable paid slot.""" + + __tablename__ = "model_usage_effects" + __table_args__ = ( + CheckConstraint(_UUID7, name="id_uuid7"), + UniqueConstraint( + "checker_slot_id", "attempt_number", name="model_usage_effects_checker_number" + ), + UniqueConstraint( + "setup_slot_id", "attempt_number", name="model_usage_effects_setup_number" + ), + UniqueConstraint("internal_call_request_id", name="model_usage_effects_internal_request"), + UniqueConstraint( + "prepared_authorization_evidence_id", + name="model_usage_effects_prepare_authority", + ), + ForeignKeyConstraint( + [ + "budget_configuration_id", + "budget_configuration_version", + "budget_configuration_digest", + ], + [ + "model_usage_budget_configurations.id", + "model_usage_budget_configurations.configuration_version", + "model_usage_budget_configurations.configuration_digest", + ], + ondelete="RESTRICT", + name="budget_identity", + ), + CheckConstraint( + "state in ('prepared','dispatching','uncertain','completed','known_failed','known_no_effect')", + name="state", + ), + CheckConstraint(f"request_sha256 ~ '{_SHA}'", name="request_digest"), + CheckConstraint( + "attempt_number > 0 and (reserved_nanodollars is null or reserved_nanodollars > 0) " + "and (settled_nanodollars is null or settled_nanodollars >= 0)", + name="amounts", + ), + CheckConstraint( + "input_tokens is null or (input_tokens >= 0 and cached_input_tokens between 0 and input_tokens " + "and output_tokens >= 0 and reasoning_tokens between 0 and output_tokens)", + name="usage", + ), + CheckConstraint( + "num_nonnulls(checker_slot_id, setup_slot_id) = 1", + name="exact_owner", + ), + ) + + id: Mapped[str] = mapped_column(Uuid(as_uuid=False), primary_key=True) + checker_slot_id: Mapped[str | None] = mapped_column( + Uuid(as_uuid=False), + ForeignKey("checker_model_usage_slots.id", ondelete="RESTRICT"), + ) + setup_slot_id: Mapped[str | None] = mapped_column( + Uuid(as_uuid=False), + ForeignKey("setup_model_usage_slots.id", ondelete="RESTRICT"), + ) + attempt_number: Mapped[int] = mapped_column(Integer, nullable=False) + internal_call_request_id: Mapped[str] = mapped_column(Uuid(as_uuid=False), nullable=False) + parent_attempt_id: Mapped[str | None] = mapped_column( + Uuid(as_uuid=False), + ForeignKey("model_usage_effects.id", ondelete="RESTRICT"), + ) + request_sha256: Mapped[str] = mapped_column(String(71), nullable=False) + state: Mapped[str] = mapped_column(String(32), nullable=False) + prepared_execution_facts: Mapped[dict | None] = mapped_column(JSONB) + prepared_authorization_evidence_id: Mapped[str] = mapped_column( + Uuid(as_uuid=False), ForeignKey("audit_events.id", ondelete="RESTRICT"), nullable=False + ) + request_binding_id: Mapped[str | None] = mapped_column( + Uuid(as_uuid=False), ForeignKey("artifact_bindings.id", ondelete="RESTRICT") + ) + request_content_id: Mapped[str | None] = mapped_column( + Uuid(as_uuid=False), ForeignKey("artifact_contents.id", ondelete="RESTRICT") + ) + request_put_attempt_id: Mapped[str | None] = mapped_column( + Uuid(as_uuid=False), ForeignKey("artifact_put_attempts.id", ondelete="RESTRICT") + ) + request_verification_receipt_id: Mapped[str | None] = mapped_column( + Uuid(as_uuid=False), + ForeignKey("artifact_verification_receipts.id", ondelete="RESTRICT"), + ) + request_content_sha256: Mapped[str | None] = mapped_column(String(71)) + request_byte_count: Mapped[int | None] = mapped_column(Integer) + budget_configuration_id: Mapped[str | None] = mapped_column(Uuid(as_uuid=False)) + budget_configuration_version: Mapped[int | None] = mapped_column(Integer) + budget_configuration_digest: Mapped[str | None] = mapped_column(String(71)) + reserved_nanodollars: Mapped[int | None] = mapped_column(BigInteger) + dispatch_execution_facts: Mapped[dict | None] = mapped_column(JSONB) + dispatch_authorization_evidence_id: Mapped[str | None] = mapped_column( + Uuid(as_uuid=False), ForeignKey("audit_events.id", ondelete="RESTRICT"), unique=True + ) + provider_response_id: Mapped[str | None] = mapped_column(String(256)) + response_binding_id: Mapped[str | None] = mapped_column( + Uuid(as_uuid=False), ForeignKey("artifact_bindings.id", ondelete="RESTRICT") + ) + response_content_id: Mapped[str | None] = mapped_column( + Uuid(as_uuid=False), ForeignKey("artifact_contents.id", ondelete="RESTRICT") + ) + response_put_attempt_id: Mapped[str | None] = mapped_column( + Uuid(as_uuid=False), ForeignKey("artifact_put_attempts.id", ondelete="RESTRICT") + ) + response_verification_receipt_id: Mapped[str | None] = mapped_column( + Uuid(as_uuid=False), + ForeignKey("artifact_verification_receipts.id", ondelete="RESTRICT"), + ) + response_content_sha256: Mapped[str | None] = mapped_column(String(71)) + response_byte_count: Mapped[int | None] = mapped_column(Integer) + input_tokens: Mapped[int | None] = mapped_column(Integer) + cached_input_tokens: Mapped[int | None] = mapped_column(Integer) + output_tokens: Mapped[int | None] = mapped_column(Integer) + reasoning_tokens: Mapped[int | None] = mapped_column(Integer) + settled_nanodollars: Mapped[int | None] = mapped_column(BigInteger) + settlement_authorization_evidence_id: Mapped[str | None] = mapped_column( + Uuid(as_uuid=False), ForeignKey("audit_events.id", ondelete="RESTRICT"), unique=True + ) + settled_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + no_effect_proof_id: Mapped[str | None] = mapped_column(Uuid(as_uuid=False)) + no_effect_proof_digest: Mapped[str | None] = mapped_column(String(71)) + no_effect_observed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + terminal_authorization_evidence_id: Mapped[str | None] = mapped_column( + Uuid(as_uuid=False), ForeignKey("audit_events.id", ondelete="RESTRICT"), unique=True + ) + uncertainty_digest: Mapped[str | None] = mapped_column(String(71)) + observation_authorization_evidence_id: Mapped[str | None] = mapped_column( + Uuid(as_uuid=False), ForeignKey("audit_events.id", ondelete="RESTRICT"), unique=True + ) + prepared_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False, server_default=text("clock_timestamp()") + ) + dispatched_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + terminal_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + + +class CheckerModelUsageRecovery(Base): + """One fresh current-run recovery attachment to an existing paid effect.""" + + __tablename__ = "checker_model_usage_recoveries" + __table_args__ = ( + CheckConstraint(_UUID7, name="id_uuid7"), + UniqueConstraint( + "attempt_id", + "checker_run_id", + "worker_lease_id", + "worker_lease_generation", + name="checker_model_usage_recoveries_attachment", + ), + UniqueConstraint( + "authorization_evidence_id", name="checker_model_usage_recoveries_authority" + ), + CheckConstraint("worker_lease_generation > 0", name="lease_generation"), + CheckConstraint( + "jsonb_typeof(execution_facts) = 'object' and jsonb_typeof(effect_facts) = 'object'", + name="facts", + ), + ) + + id: Mapped[str] = mapped_column(Uuid(as_uuid=False), primary_key=True) + attempt_id: Mapped[str] = mapped_column( + Uuid(as_uuid=False), + ForeignKey("model_usage_effects.id", ondelete="RESTRICT"), + nullable=False, + ) + checker_run_id: Mapped[str] = mapped_column( + Uuid(as_uuid=False), ForeignKey("checker_runs.id", ondelete="RESTRICT"), nullable=False + ) + worker_lease_id: Mapped[str] = mapped_column(Uuid(as_uuid=False), nullable=False) + worker_lease_generation: Mapped[int] = mapped_column(Integer, nullable=False) + execution_facts: Mapped[dict] = mapped_column(JSONB, nullable=False) + effect_facts: Mapped[dict] = mapped_column(JSONB, nullable=False) + authorization_evidence_id: Mapped[str] = mapped_column( + Uuid(as_uuid=False), ForeignKey("audit_events.id", ondelete="RESTRICT"), nullable=False + ) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False, server_default=text("clock_timestamp()") + ) + + +class CheckerModelInvocationGrant(Base): + """No plaintext capability or transcript; bounded session custody only.""" + + __tablename__ = "checker_model_invocation_grants" + __table_args__ = ( + CheckConstraint( + _UUID7, + name="id_uuid7", + ), + CheckConstraint( + "token_hash ~ '^sha256:[0-9a-f]{64}$' and " + "external_request_digest ~ '^sha256:[0-9a-f]{64}$'", + name="digests", + ), + ) + id: Mapped[str] = mapped_column(Uuid(as_uuid=False), primary_key=True) + token_hash: Mapped[str] = mapped_column(String(71), nullable=False, unique=True) + project_id: Mapped[str] = mapped_column( + Uuid(as_uuid=False), ForeignKey("projects.id", ondelete="RESTRICT"), nullable=False + ) + checker_run_id: Mapped[str] = mapped_column( + Uuid(as_uuid=False), ForeignKey("checker_runs.id", ondelete="RESTRICT"), nullable=False + ) + registry_entry_id: Mapped[UUID] = mapped_column( + Uuid(), + ForeignKey("external_checker_registry_entries.id", ondelete="RESTRICT"), + nullable=False, + ) + execution_facts: Mapped[dict] = mapped_column(JSONB, nullable=False) + paid_slot: Mapped[dict] = mapped_column(JSONB, nullable=False) + external_request_digest: Mapped[str] = mapped_column(String(71), nullable=False) + expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + issued_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.clock_timestamp(), nullable=False + ) + authorization_evidence_id: Mapped[str] = mapped_column( + Uuid(as_uuid=False), + ForeignKey("audit_events.id", ondelete="RESTRICT"), + nullable=False, + unique=True, + ) + + +class CheckerModelInvocationGrantCall(Base): + """One canonical call index bound to one durable paid effect under a session grant.""" + + __tablename__ = "checker_model_invocation_grant_calls" + __table_args__ = ( + CheckConstraint( + "call_index between 0 and 99 and request_sha256 ~ '^sha256:[0-9a-f]{64}$'", + name="facts", + ), + UniqueConstraint("grant_id", "call_index", name="uq_checker_model_grant_call_index"), + ) + grant_id: Mapped[str] = mapped_column( + Uuid(as_uuid=False), + ForeignKey("checker_model_invocation_grants.id", ondelete="RESTRICT"), + primary_key=True, + ) + call_index: Mapped[int] = mapped_column(Integer, primary_key=True) + attempt_id: Mapped[str] = mapped_column( + Uuid(as_uuid=False), + ForeignKey("model_usage_effects.id", ondelete="RESTRICT"), + nullable=False, + ) + request_sha256: Mapped[str] = mapped_column(String(71), nullable=False) + bound_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.clock_timestamp(), nullable=False + ) + + +__all__ = ( + "ModelUsageBudgetAccount", + "ModelUsageBudgetConfiguration", + "ModelUsageEffect", + "CheckerModelUsageRecovery", + "CheckerModelUsageEnvelope", + "CheckerModelUsageSlot", + "SetupModelUsageEnvelope", + "SetupModelUsageSlot", + "CheckerModelInvocationGrant", + "CheckerModelInvocationGrantCall", + "ModelUsageProfile", +) diff --git a/backend/app/modules/model_usage/output_custody.py b/backend/app/modules/model_usage/output_custody.py new file mode 100644 index 000000000..4de841b91 --- /dev/null +++ b/backend/app/modules/model_usage/output_custody.py @@ -0,0 +1,109 @@ +"""MODEL-USAGE-owned setup output custody for the shared ART pipeline.""" + +from sqlalchemy.ext.asyncio import AsyncSession + +from app.modules.model_usage.api import ( + ModelProfileSelector, + ModelCallUnavailable, + SetupModelOutputReservation, + SetupModelOutputSelector, + SetupModelOutputSlot, + SetupModelUsageOwnerPort, +) +from app.modules.model_usage.repository import ( + ModelCallRepository, + require_model_call_transaction, +) + + +class SetupModelOutputReservations: + """Requests require current PROJECTS custody; responses retain paid effects.""" + + def __init__( + self, + session: AsyncSession, + *, + owner: SetupModelUsageOwnerPort, + ) -> None: + self._repo = ModelCallRepository(session) + self._owner = owner + self._session = session + + async def resolve(self, selector: SetupModelOutputSelector) -> SetupModelOutputReservation: + """Resolve one exact setup role without MODEL-to-PROJECTS lock backedges.""" + require_model_call_transaction(self._session) + selector = SetupModelOutputSelector.model_validate(selector) + key = selector.slot_key + if selector.role == "model_request": + owner_profile = await self._owner.require_setup_current(key) + if owner_profile.selector != ModelProfileSelector( + profile_id=key.profile_id, + profile_version=key.profile_version, + profile_digest=key.profile_digest, + ): + raise ModelCallUnavailable("setup_model_output_owner_unavailable") + + await self._repo.lock_setup_envelope_scope(key) + envelope = await self._repo.setup_envelope(key, lock=True) + await self._repo.lock_setup_slot_scope(key) + slot = await self._repo.setup_slot(key, lock=True) + effect = await self._repo.attempt(selector.model_call_attempt_id, lock=True) + profile = await self._repo.profile_exact( + key.profile_id, key.profile_version, key.profile_digest + ) + if ( + envelope is None + or slot is None + or effect is None + or profile is None + or slot.envelope_id != envelope.id + or slot.id != str(selector.model_call_slot_id) + or effect.setup_slot_id != slot.id + or profile.project_id != str(key.project_id) + or ( + str(slot.compilation_attempt_id), + slot.project_id, + slot.guide_id, + slot.source_snapshot_id, + slot.setup_run_id, + slot.setup_generation, + slot.runtime_configuration_hash, + slot.profile_id, + slot.profile_version, + slot.profile_digest, + slot.phase, + slot.call_index, + ) + != ( + str(key.compilation_attempt_id), + str(key.project_id), + str(key.guide_id), + str(key.source_snapshot_id), + str(key.setup_run_id), + key.setup_generation, + key.runtime_configuration_hash, + str(key.profile_id), + key.profile_version, + key.profile_digest, + key.phase, + key.call_index, + ) + ): + raise ModelCallUnavailable("setup_model_output_unavailable") + if selector.role == "model_request": + if effect.request_sha256 != selector.content_sha256: + raise ModelCallUnavailable("setup_model_output_request_conflict") + maximum = profile.maximum_request_bytes + else: + if effect.state not in {"dispatching", "uncertain", "completed", "known_failed"}: + raise ModelCallUnavailable("setup_model_output_effect_unavailable") + if effect.response_content_sha256 not in {None, selector.content_sha256}: + raise ModelCallUnavailable("setup_model_output_response_conflict") + maximum = profile.maximum_response_bytes + return SetupModelOutputReservation( + selector=selector, + slot=SetupModelOutputSlot(key=selector.role, maximum_bytes=maximum), + ) + + +__all__ = ("SetupModelOutputReservations",) diff --git a/backend/app/modules/model_usage/owner_ports.py b/backend/app/modules/model_usage/owner_ports.py new file mode 100644 index 000000000..e091d53b0 --- /dev/null +++ b/backend/app/modules/model_usage/owner_ports.py @@ -0,0 +1,182 @@ +"""Narrow shared-ledger views injected into checker owner adapters.""" + +from __future__ import annotations + +import json +from uuid import UUID + +from sqlalchemy import exists, select +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import aliased + +from app.modules.model_usage.api import ( + ModelCallExecutionFacts, + ModelCallUnavailable, + ModelEvidenceReservation, + ModelEvidenceSelector, + ModelPaidSlotKey, + ModelRecoveryCandidate, + ModelRecoveryOwnerFacts, +) +from app.modules.model_usage.models import ( + CheckerModelInvocationGrant, + CheckerModelInvocationGrantCall, + CheckerModelUsageSlot, + ModelUsageEffect, +) +from app.modules.model_usage.repository import ModelCallRepository +from app.modules.model_usage.service import ( + _slot_key, + _stored_retry_terminal_digest, + _uuid, +) + + +class ModelEvidenceCustody: + """Lock and validate exact shared effect custody after owner locks.""" + + def __init__(self, session: AsyncSession) -> None: + """Bind evidence resolution to one caller-owned transaction.""" + self._repo = ModelCallRepository(session) + + async def resolve_evidence(self, selector: ModelEvidenceSelector) -> ModelEvidenceReservation: + """Lock and verify the exact effect, role, digest and profile byte bound.""" + selector = ModelEvidenceSelector.model_validate(selector) + await self._repo.lock_envelope_scope(selector.paid_slot) + if await self._repo.envelope(selector.paid_slot, lock=True) is None: + raise ModelCallUnavailable("model_evidence_unavailable") + await self._repo.lock_slot_scope(selector.paid_slot) + slot = await self._repo.slot(selector.paid_slot, lock=True) + if slot is None or ( + slot.id != str(selector.model_call_slot_id) or slot.task_id != str(selector.task_id) + ): + raise ModelCallUnavailable("model_evidence_unavailable") + effect = await self._repo.attempt(selector.model_call_attempt_id, lock=True) + if effect is None or effect.checker_slot_id != slot.id: + raise ModelCallUnavailable("model_evidence_unavailable") + profile = await self._repo.profile_exact( + selector.paid_slot.profile_id, + selector.paid_slot.profile_version, + selector.paid_slot.profile_digest, + ) + if profile is None or profile.project_id != str(selector.paid_slot.project_id): + raise ModelCallUnavailable("model_evidence_profile_unavailable") + if selector.role == "model_request": + if effect.request_sha256 != selector.content_sha256: + raise ModelCallUnavailable("model_evidence_request_conflict") + maximum = profile.maximum_request_bytes + else: + if effect.state not in {"dispatching", "uncertain", "completed", "known_failed"}: + raise ModelCallUnavailable("model_evidence_effect_unavailable") + if effect.response_content_sha256 not in {None, selector.content_sha256}: + raise ModelCallUnavailable("model_evidence_response_conflict") + maximum = profile.maximum_response_bytes + return ModelEvidenceReservation(maximum_bytes=maximum) + + +class ModelRecoverySources: + """Project retained checker recovery lineage without owner authorization.""" + + def __init__(self, session: AsyncSession) -> None: + """Bind retained recovery reads to one caller-owned transaction.""" + self._session = session + self._repo = ModelCallRepository(session) + + async def recovery_owner( + self, slot: ModelPaidSlotKey, parent_attempt_id: UUID + ) -> ModelRecoveryOwnerFacts: + """Resolve the immutable checker owner for one eligible parent effect.""" + slot = ModelPaidSlotKey.model_validate(slot) + slot_row = await self._repo.slot(slot) + parent = await self._repo.attempt(parent_attempt_id) + grant = await self._session.scalar( + select(CheckerModelInvocationGrant) + .join( + CheckerModelInvocationGrantCall, + CheckerModelInvocationGrantCall.grant_id == CheckerModelInvocationGrant.id, + ) + .where(CheckerModelInvocationGrantCall.attempt_id == str(parent_attempt_id)) + .order_by( + CheckerModelInvocationGrant.issued_at.desc(), + CheckerModelInvocationGrant.id, + ) + .limit(1) + ) + if ( + slot_row is None + or parent is None + or parent.checker_slot_id != slot_row.id + or grant is None + or grant.project_id != str(slot.project_id) + or ModelPaidSlotKey.model_validate_json(json.dumps(grant.paid_slot)).envelope_digest + != slot.envelope_digest + ): + raise ModelCallUnavailable("model_recovery_source_unavailable") + try: + execution = ModelCallExecutionFacts.model_validate_json( + json.dumps(grant.execution_facts) + ) + except ValueError as exc: + raise ModelCallUnavailable("model_recovery_source_unavailable") from exc + return ModelRecoveryOwnerFacts( + checker_run_id=UUID(grant.checker_run_id), + task_id=UUID(slot_row.task_id), + execution=execution, + request_sha256=parent.request_sha256, + ) + + async def recovery_candidates( + self, source_run_ids: tuple[UUID, ...] + ) -> dict[UUID, tuple[ModelRecoveryCandidate, ...]]: + """Project terminal effects that may receive an authorized child.""" + if not source_run_ids: + return {} + child = aliased(ModelUsageEffect) + rows = ( + await self._session.execute( + select( + CheckerModelInvocationGrant.checker_run_id, + ModelUsageEffect, + CheckerModelUsageSlot, + CheckerModelInvocationGrant.registry_entry_id, + ) + .select_from(CheckerModelInvocationGrant) + .join( + CheckerModelInvocationGrantCall, + CheckerModelInvocationGrantCall.grant_id == CheckerModelInvocationGrant.id, + ) + .join( + ModelUsageEffect, + ModelUsageEffect.id == CheckerModelInvocationGrantCall.attempt_id, + ) + .join( + CheckerModelUsageSlot, + CheckerModelUsageSlot.id == ModelUsageEffect.checker_slot_id, + ) + .where( + CheckerModelInvocationGrant.checker_run_id.in_( + tuple(str(value) for value in source_run_ids) + ), + ModelUsageEffect.state.in_(("known_failed", "known_no_effect")), + ~exists(select(child.id).where(child.parent_attempt_id == ModelUsageEffect.id)), + ) + .order_by(CheckerModelInvocationGrant.checker_run_id, ModelUsageEffect.id) + ) + ).all() + projected: dict[UUID, dict[UUID, ModelRecoveryCandidate]] = {} + for run_id, attempt, slot_row, registry_entry_id in rows: + digest = _stored_retry_terminal_digest(attempt) + if digest is None: + continue + candidate = ModelRecoveryCandidate( + parent_attempt_id=_uuid(attempt.id), + slot=_slot_key(slot_row), + registry_entry_id=_uuid(registry_entry_id), + terminal_digest=digest, + request_sha256=attempt.request_sha256, + ) + projected.setdefault(_uuid(run_id), {})[candidate.parent_attempt_id] = candidate + return {run_id: tuple(candidates.values()) for run_id, candidates in projected.items()} + + +__all__ = ("ModelEvidenceCustody", "ModelRecoverySources") diff --git a/backend/app/modules/model_usage/profiles.py b/backend/app/modules/model_usage/profiles.py new file mode 100644 index 000000000..0c52a4260 --- /dev/null +++ b/backend/app/modules/model_usage/profiles.py @@ -0,0 +1,132 @@ +"""Authority-neutral exact reads of immutable project model profiles.""" + +from __future__ import annotations + +from uuid import UUID + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.modules.model_usage.api import ( + ModelCallUnavailable, + ModelGenerationSettings, + ModelPricing, + ModelProfileSelector, + ProjectModelProfile, + ProjectModelProfileSpec, +) +from app.modules.model_usage.models import ModelUsageProfile + + +def stored_model_profile(row: ModelUsageProfile) -> ProjectModelProfile: + """Decode one immutable row through the strict public profile contract.""" + pricing = ModelPricing( + pricing_policy_id=UUID(row.pricing_policy_id), + pricing_policy_version=row.pricing_policy_version, + pricing_policy_digest=row.pricing_policy_digest, + uncached_input_nanodollars_per_token=row.uncached_input_rate, + cached_input_nanodollars_per_token=row.cached_input_rate, + output_nanodollars_per_token=row.output_rate, + ) + spec = ProjectModelProfileSpec( + project_id=UUID(row.project_id), + profile_version=row.profile_version, + provider=row.provider, + api=row.api, + model_snapshot=row.model_snapshot, + settings=ModelGenerationSettings.model_validate(row.generation_settings), + maximum_request_bytes=row.maximum_request_bytes, + maximum_output_bytes=row.maximum_output_bytes, + maximum_response_bytes=row.maximum_response_bytes, + maximum_input_tokens=row.maximum_input_tokens, + maximum_output_tokens=row.maximum_output_tokens, + maximum_calls=row.maximum_calls, + maximum_total_request_bytes=row.maximum_total_request_bytes, + maximum_total_response_bytes=row.maximum_total_response_bytes, + maximum_total_input_tokens=row.maximum_total_input_tokens, + maximum_total_output_tokens=row.maximum_total_output_tokens, + allowed_tools=tuple(row.allowed_tools), + maximum_tool_calls=row.maximum_tool_calls, + maximum_tool_output_bytes=row.maximum_tool_output_bytes, + deadline_ms=row.deadline_ms, + pricing=pricing, + ) + return ProjectModelProfile( + profile_id=UUID(row.id), + operation_id=UUID(row.operation_id), + actor_profile_id=UUID(row.actor_profile_id), + spec=spec, + profile_digest=row.profile_digest, + authorization_evidence_id=UUID(row.authorization_evidence_id), + created_at=row.created_at, + ) + + +class ModelProfileReader: + """Read exact immutable metadata without profile-mutation authority.""" + + def __init__(self, session: AsyncSession) -> None: + """Bind exact profile reads to the caller-owned database session.""" + self._session = session + + def _require_root_transaction(self) -> None: + """Reject reads outside an active root transaction or inside a savepoint.""" + transaction = self._session.sync_session.get_transaction() + if ( + transaction is None + or not transaction.is_active + or self._session.in_nested_transaction() + ): + raise ModelCallUnavailable("model_profile_unavailable") + + async def read_exact( + self, + selector: ModelProfileSelector, + *, + project_id: UUID, + ) -> ProjectModelProfile: + """Require the complete selector plus exact project custody.""" + self._require_root_transaction() + try: + selector = ModelProfileSelector.model_validate(selector) + except ValueError as exc: + raise ModelCallUnavailable("model_profile_unavailable") from exc + row = await self._session.scalar( + select(ModelUsageProfile).where( + ModelUsageProfile.id == str(selector.profile_id), + ModelUsageProfile.profile_version == selector.profile_version, + ModelUsageProfile.profile_digest == selector.profile_digest, + ModelUsageProfile.project_id == str(project_id), + ) + ) + if row is None: + raise ModelCallUnavailable("model_profile_unavailable") + return stored_model_profile(row) + + async def read_latest_matching( + self, + *, + project_id: UUID, + provider: str, + api: str, + model_snapshot: str, + ) -> ProjectModelProfile: + """Select only the newest immutable version for this project and identity.""" + self._require_root_transaction() + row = await self._session.scalar( + select(ModelUsageProfile) + .where( + ModelUsageProfile.project_id == str(project_id), + ModelUsageProfile.provider == provider, + ModelUsageProfile.api == api, + ModelUsageProfile.model_snapshot == model_snapshot, + ) + .order_by(ModelUsageProfile.profile_version.desc()) + .limit(1) + ) + if row is None: + raise ModelCallUnavailable("model_profile_unavailable") + return stored_model_profile(row) + + +__all__ = ("ModelProfileReader", "stored_model_profile") diff --git a/backend/app/modules/model_usage/repository.py b/backend/app/modules/model_usage/repository.py new file mode 100644 index 000000000..8551734c6 --- /dev/null +++ b/backend/app/modules/model_usage/repository.py @@ -0,0 +1,383 @@ +"""Flush-only persistence and lock order for project model-call ownership.""" + +from __future__ import annotations + +from dataclasses import dataclass +from uuid import UUID + +from sqlalchemy import case, func, select, text +from sqlalchemy.ext.asyncio import AsyncSession + +from app.modules.model_usage.api import ( + ModelPaidSlotKey, + ModelCallUnavailable, + SetupModelUsageSlotKey, +) +from app.modules.model_usage.models import ( + ModelUsageBudgetAccount, + ModelUsageBudgetConfiguration, + ModelUsageEffect, + CheckerModelUsageEnvelope, + CheckerModelUsageRecovery, + CheckerModelUsageSlot, + SetupModelUsageEnvelope, + SetupModelUsageSlot, + ModelUsageProfile, +) + + +@dataclass(frozen=True) +class ModelUsageEnvelopeTotals: + """Current committed effects beneath one locked whole-run envelope.""" + + dispatches: int + pending_dispatches: int + reserved_nanodollars: int + settled_nanodollars: int + request_bytes: int + response_bytes: int + input_tokens: int + output_tokens: int + + +def require_model_call_transaction(session: AsyncSession) -> None: + """Require one caller-owned root transaction and reject nested ownership.""" + transaction = session.sync_session.get_transaction() + if transaction is None or not transaction.is_active or session.in_nested_transaction(): + raise ModelCallUnavailable("model_call_transaction_unavailable") + + +class ModelCallRepository: + """Own model-call row locks without committing the caller's transaction.""" + + def __init__(self, session: AsyncSession) -> None: + """Bind all reads, writes, and locks to the caller-owned session.""" + self._session = session + + async def now(self): + """Read the authoritative database clock inside the current transaction.""" + return await self._session.scalar(select(func.clock_timestamp())) + + async def advisory(self, key: str) -> None: + """Take a transaction-scoped advisory lock for one canonical owner key.""" + await self._session.execute( + text("select pg_advisory_xact_lock(hashtextextended(:key, 0))"), + {"key": key}, + ) + + async def lock_profile_scopes( + self, operation_id: UUID, project_id: UUID, profile_version: int + ) -> None: + """Serialize profile idempotency and project version allocation in order.""" + for key in ( + f"checker-model-profile-operation:{operation_id}", + f"checker-model-profile:{project_id}", + ): + await self.advisory(key) + + async def profile_by_operation(self, operation_id: UUID): + """Return the immutable profile previously written for an operation.""" + return await self._session.scalar( + select(ModelUsageProfile).where(ModelUsageProfile.operation_id == str(operation_id)) + ) + + async def profile_by_version(self, project_id: UUID, profile_version: int): + """Return the profile occupying one project-scoped version, if present.""" + return await self._session.scalar( + select(ModelUsageProfile).where( + ModelUsageProfile.project_id == str(project_id), + ModelUsageProfile.profile_version == profile_version, + ) + ) + + async def latest_profile(self, project_id: UUID): + """Return the highest immutable profile version currently stored for a project.""" + return await self._session.scalar( + select(ModelUsageProfile) + .where(ModelUsageProfile.project_id == str(project_id)) + .order_by(ModelUsageProfile.profile_version.desc()) + .limit(1) + ) + + async def profile_exact(self, profile_id: UUID, profile_version: int, profile_digest: str): + """Resolve a profile only when its ID, version, and digest all match.""" + return await self._session.scalar( + select(ModelUsageProfile).where( + ModelUsageProfile.id == str(profile_id), + ModelUsageProfile.profile_version == profile_version, + ModelUsageProfile.profile_digest == profile_digest, + ) + ) + + async def lock_budget_scope(self, project_id: UUID) -> None: + """Serialize lifetime budget mutations for one project.""" + await self.advisory(f"checker-model-budget:{project_id}") + + async def budget_account(self, project_id: UUID, *, lock: bool = False): + """Load the project lifetime account, optionally taking its row lock.""" + query = select(ModelUsageBudgetAccount).where( + ModelUsageBudgetAccount.project_id == str(project_id) + ) + if lock: + query = query.with_for_update() + return await self._session.scalar(query.execution_options(populate_existing=True)) + + async def budget_configuration_by_operation(self, operation_id: UUID): + """Return the immutable budget configuration written for an operation.""" + return await self._session.scalar( + select(ModelUsageBudgetConfiguration).where( + ModelUsageBudgetConfiguration.operation_id == str(operation_id) + ) + ) + + async def budget_configuration_by_version(self, project_id: UUID, version: int): + """Return one exact project budget version, if it exists.""" + return await self._session.scalar( + select(ModelUsageBudgetConfiguration).where( + ModelUsageBudgetConfiguration.project_id == str(project_id), + ModelUsageBudgetConfiguration.configuration_version == version, + ) + ) + + async def current_budget_configuration(self, account: ModelUsageBudgetAccount): + """Refresh the configuration selected by the locked lifetime account.""" + return await self._session.get( + ModelUsageBudgetConfiguration, + account.current_configuration_id, + populate_existing=True, + ) + + @staticmethod + def _envelope_predicates(key: ModelPaidSlotKey): + """Build the complete stable whole-run identity predicate.""" + return ( + CheckerModelUsageEnvelope.project_id == str(key.project_id), + CheckerModelUsageEnvelope.assignment_id == str(key.assignment_id), + CheckerModelUsageEnvelope.submission_id == str(key.submission_id), + CheckerModelUsageEnvelope.submission_version == key.submission_version, + CheckerModelUsageEnvelope.requirement_id == key.requirement_id, + CheckerModelUsageEnvelope.registry_entry_digest == key.registry_entry_digest, + CheckerModelUsageEnvelope.configuration_sha256 == key.configuration_sha256, + CheckerModelUsageEnvelope.profile_id == str(key.profile_id), + CheckerModelUsageEnvelope.profile_version == key.profile_version, + CheckerModelUsageEnvelope.profile_digest == key.profile_digest, + ) + + async def lock_envelope_scope(self, key: ModelPaidSlotKey) -> None: + """Serialize all slots and effects under one stable checker run envelope.""" + await self.advisory("checker-model-usage-envelope:" + key.envelope_digest) + + async def envelope(self, key: ModelPaidSlotKey, *, lock: bool = False): + """Load one exact checker whole-run envelope.""" + query = select(CheckerModelUsageEnvelope).where(*self._envelope_predicates(key)) + if lock: + query = query.with_for_update() + return await self._session.scalar(query.execution_options(populate_existing=True)) + + @staticmethod + def _slot_predicates(key: ModelPaidSlotKey): + """Build the complete paid-slot identity predicate without partial matching.""" + return ( + CheckerModelUsageSlot.project_id == str(key.project_id), + CheckerModelUsageSlot.assignment_id == str(key.assignment_id), + CheckerModelUsageSlot.submission_id == str(key.submission_id), + CheckerModelUsageSlot.submission_version == key.submission_version, + CheckerModelUsageSlot.requirement_id == key.requirement_id, + CheckerModelUsageSlot.registry_entry_digest == key.registry_entry_digest, + CheckerModelUsageSlot.configuration_sha256 == key.configuration_sha256, + CheckerModelUsageSlot.profile_id == str(key.profile_id), + CheckerModelUsageSlot.profile_version == key.profile_version, + CheckerModelUsageSlot.profile_digest == key.profile_digest, + CheckerModelUsageSlot.slot_index == key.slot_index, + ) + + async def lock_slot_scope(self, key: ModelPaidSlotKey) -> None: + """Serialize creation and state transitions for one exact paid slot.""" + await self.advisory("checker-model-call-slot:" + key.digest) + + async def slot(self, key: ModelPaidSlotKey, *, lock: bool = False): + """Load an exact paid slot and optionally lock it for mutation.""" + query = select(CheckerModelUsageSlot).where(*self._slot_predicates(key)) + if lock: + query = query.with_for_update() + return await self._session.scalar(query.execution_options(populate_existing=True)) + + async def latest_attempt(self, slot_id: str, *, lock: bool = False): + """Load the greatest attempt number for a slot, optionally for update.""" + query = ( + select(ModelUsageEffect) + .where(ModelUsageEffect.checker_slot_id == slot_id) + .order_by(ModelUsageEffect.attempt_number.desc()) + .limit(1) + ) + if lock: + query = query.with_for_update() + return await self._session.scalar(query.execution_options(populate_existing=True)) + + async def envelope_totals(self, envelope_id: str) -> ModelUsageEnvelopeTotals: + """Aggregate all effect-bearing checker attempts under a locked envelope.""" + row = ( + await self._session.execute( + select( + func.count(ModelUsageEffect.id).filter(ModelUsageEffect.state != "prepared"), + func.count(ModelUsageEffect.id).filter( + ModelUsageEffect.state.in_(("dispatching", "uncertain")) + ), + func.coalesce( + func.sum( + case( + ( + ModelUsageEffect.state.in_(("dispatching", "uncertain")), + ModelUsageEffect.reserved_nanodollars, + ), + else_=0, + ) + ), + 0, + ), + func.coalesce(func.sum(ModelUsageEffect.settled_nanodollars), 0), + func.coalesce(func.sum(ModelUsageEffect.request_byte_count), 0), + func.coalesce(func.sum(ModelUsageEffect.response_byte_count), 0), + func.coalesce(func.sum(ModelUsageEffect.input_tokens), 0), + func.coalesce(func.sum(ModelUsageEffect.output_tokens), 0), + ) + .join( + CheckerModelUsageSlot, + CheckerModelUsageSlot.id == ModelUsageEffect.checker_slot_id, + ) + .where(CheckerModelUsageSlot.envelope_id == envelope_id) + ) + ).one() + return ModelUsageEnvelopeTotals(*(int(value) for value in row)) + + @staticmethod + def _setup_envelope_predicates(key: SetupModelUsageSlotKey): + return ( + SetupModelUsageEnvelope.compilation_attempt_id == key.compilation_attempt_id, + SetupModelUsageEnvelope.project_id == str(key.project_id), + SetupModelUsageEnvelope.guide_id == str(key.guide_id), + SetupModelUsageEnvelope.source_snapshot_id == str(key.source_snapshot_id), + SetupModelUsageEnvelope.setup_run_id == str(key.setup_run_id), + SetupModelUsageEnvelope.setup_generation == key.setup_generation, + SetupModelUsageEnvelope.runtime_configuration_hash == key.runtime_configuration_hash, + SetupModelUsageEnvelope.profile_id == str(key.profile_id), + SetupModelUsageEnvelope.profile_version == key.profile_version, + SetupModelUsageEnvelope.profile_digest == key.profile_digest, + ) + + async def lock_setup_envelope_scope(self, key: SetupModelUsageSlotKey) -> None: + """Serialize all phases and calls under one exact setup attempt envelope.""" + await self.advisory("setup-model-usage-envelope:" + key.envelope_digest) + + async def setup_envelope(self, key: SetupModelUsageSlotKey, *, lock: bool = False): + query = select(SetupModelUsageEnvelope).where(*self._setup_envelope_predicates(key)) + if lock: + query = query.with_for_update() + return await self._session.scalar(query.execution_options(populate_existing=True)) + + @staticmethod + def _setup_slot_predicates(key: SetupModelUsageSlotKey): + return ( + SetupModelUsageSlot.compilation_attempt_id == key.compilation_attempt_id, + SetupModelUsageSlot.runtime_configuration_hash == key.runtime_configuration_hash, + SetupModelUsageSlot.profile_id == str(key.profile_id), + SetupModelUsageSlot.profile_version == key.profile_version, + SetupModelUsageSlot.profile_digest == key.profile_digest, + SetupModelUsageSlot.phase == key.phase, + SetupModelUsageSlot.call_index == key.call_index, + ) + + async def lock_setup_slot_scope(self, key: SetupModelUsageSlotKey) -> None: + await self.advisory("setup-model-usage-slot:" + key.digest) + + async def setup_slot(self, key: SetupModelUsageSlotKey, *, lock: bool = False): + query = select(SetupModelUsageSlot).where(*self._setup_slot_predicates(key)) + if lock: + query = query.with_for_update() + return await self._session.scalar(query.execution_options(populate_existing=True)) + + async def latest_setup_effect(self, slot_id: str, *, lock: bool = False): + query = ( + select(ModelUsageEffect) + .where(ModelUsageEffect.setup_slot_id == slot_id) + .order_by(ModelUsageEffect.attempt_number.desc()) + .limit(1) + ) + if lock: + query = query.with_for_update() + return await self._session.scalar(query.execution_options(populate_existing=True)) + + async def setup_envelope_totals(self, envelope_id: str) -> ModelUsageEnvelopeTotals: + row = ( + await self._session.execute( + select( + func.count(ModelUsageEffect.id).filter(ModelUsageEffect.state != "prepared"), + func.count(ModelUsageEffect.id).filter( + ModelUsageEffect.state.in_(("dispatching", "uncertain")) + ), + func.coalesce( + func.sum( + case( + ( + ModelUsageEffect.state.in_(("dispatching", "uncertain")), + ModelUsageEffect.reserved_nanodollars, + ), + else_=0, + ) + ), + 0, + ), + func.coalesce(func.sum(ModelUsageEffect.settled_nanodollars), 0), + func.coalesce(func.sum(ModelUsageEffect.request_byte_count), 0), + func.coalesce(func.sum(ModelUsageEffect.response_byte_count), 0), + func.coalesce(func.sum(ModelUsageEffect.input_tokens), 0), + func.coalesce(func.sum(ModelUsageEffect.output_tokens), 0), + ) + .join( + SetupModelUsageSlot, + SetupModelUsageSlot.id == ModelUsageEffect.setup_slot_id, + ) + .where(SetupModelUsageSlot.envelope_id == envelope_id) + ) + ).one() + return ModelUsageEnvelopeTotals(*(int(value) for value in row)) + + async def attempt(self, attempt_id: UUID, *, lock: bool = False): + """Load one server-owned attempt ID, optionally refreshing it under lock.""" + query = select(ModelUsageEffect).where(ModelUsageEffect.id == str(attempt_id)) + if lock: + query = query.with_for_update() + return await self._session.scalar(query.execution_options(populate_existing=True)) + + async def recovery( + self, + attempt_id: UUID, + checker_run_id: UUID, + worker_lease_id: UUID, + worker_lease_generation: int, + ): + """Resolve an attachment by its complete attempt, run, and worker lease identity.""" + return await self._session.scalar( + select(CheckerModelUsageRecovery).where( + CheckerModelUsageRecovery.attempt_id == str(attempt_id), + CheckerModelUsageRecovery.checker_run_id == str(checker_run_id), + CheckerModelUsageRecovery.worker_lease_id == str(worker_lease_id), + CheckerModelUsageRecovery.worker_lease_generation == worker_lease_generation, + ) + ) + + async def add(self, row) -> None: + """Stage and flush a new owned row without committing the root transaction.""" + self._session.add(row) + await self._session.flush() + + async def flush(self) -> None: + """Flush pending state transitions while leaving commit to the caller.""" + await self._session.flush() + + +__all__ = ( + "ModelCallRepository", + "ModelUsageEnvelopeTotals", + "require_model_call_transaction", +) diff --git a/backend/app/modules/model_usage/service.py b/backend/app/modules/model_usage/service.py new file mode 100644 index 000000000..075d8de4f --- /dev/null +++ b/backend/app/modules/model_usage/service.py @@ -0,0 +1,1277 @@ +"""Transactional owner for durable, project-budgeted checker model calls.""" + +from __future__ import annotations + +from uuid import UUID + +from pydantic import TypeAdapter +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.hashing import canonical_json_hash +from app.core.identifiers import new_record_id +from app.modules.model_usage.api import ( + AttachedModelCallAttempt, + CompletedModelCall, + KnownFailedModelCall, + KnownNoEffectProof, + ModelActionEvidence, + ModelBudgetProvisioningFacts, + ModelCallAuthorityPort, + ModelCallBudgetUnavailable, + ModelCallConflict, + ModelCallCurrentExecutionPort, + ModelCallEvidence, + ModelCallEffectAuthorityFacts, + ModelCallEvidencePort, + ModelCallExecutionFacts, + ModelCallRecoveryAttachment, + ModelCallUnavailable, + ModelCallUsage, + ModelDispatchReservation, + ModelPaidSlotKey, + ModelProfileProvisioningFacts, + ModelProfileSelector, + PreparedModelCallAttempt, + ProviderResponseIdentifier, + ProjectModelBudgetConfiguration, + ProjectModelProfile, + model_action_digest, + model_retry_terminal_digest, + model_usage_instant, + model_usage_cost, +) +from app.modules.model_usage.models import ( + ModelUsageBudgetAccount, + ModelUsageBudgetConfiguration, + ModelUsageEffect, + CheckerModelUsageRecovery, + CheckerModelUsageEnvelope, + CheckerModelUsageSlot, + ModelUsageProfile, +) +from app.modules.model_usage.repository import ( + ModelCallRepository, + require_model_call_transaction, +) +from app.modules.model_usage.profiles import stored_model_profile + + +def _uuid(value: object) -> UUID: + """Decode a persisted UUID string at the typed service boundary.""" + return UUID(str(value)) + + +def _same_uuid(value: str, expected: UUID) -> bool: + """Compare persisted string identity with a typed caller UUID exactly.""" + return value == str(expected) + + +_SHA_ADAPTER = TypeAdapter(str) +_PROVIDER_RESPONSE_ADAPTER = TypeAdapter(ProviderResponseIdentifier) + + +def _sha(value: str) -> str: + """Require one lowercase SHA-256 identifier for raw service arguments.""" + # Pydantic constrained aliases are validated by their containing public values; + # raw service arguments receive the equivalent closed check here. + if len(value) != 71 or not value.startswith("sha256:"): + raise ModelCallConflict("model_call_digest_invalid") + try: + int(value[7:], 16) + except ValueError as exc: + raise ModelCallConflict("model_call_digest_invalid") from exc + if value[7:] != value[7:].lower(): + raise ModelCallConflict("model_call_digest_invalid") + return _SHA_ADAPTER.validate_python(value, strict=True) + + +def _require_authority(action: str, facts: object, evidence: ModelActionEvidence) -> None: + """Require authority evidence to bind the exact action and canonical facts.""" + if evidence.facts_digest != model_action_digest(action, facts): + raise ModelCallUnavailable("model_call_authority_unavailable") + + +def _slot_key(row: CheckerModelUsageSlot) -> ModelPaidSlotKey: + """Project a stored slot through the complete immutable paid-slot identity.""" + return ModelPaidSlotKey( + project_id=_uuid(row.project_id), + assignment_id=_uuid(row.assignment_id), + submission_id=_uuid(row.submission_id), + submission_version=row.submission_version, + requirement_id=row.requirement_id, + registry_entry_digest=row.registry_entry_digest, + configuration_sha256=row.configuration_sha256, + profile_id=_uuid(row.profile_id), + profile_version=row.profile_version, + profile_digest=row.profile_digest, + slot_index=row.slot_index, + ) + + +def _evidence(row: ModelUsageEffect, prefix: str) -> ModelCallEvidence: + """Decode one complete request or response ART evidence set from an attempt.""" + values = { + "binding_id": getattr(row, f"{prefix}_binding_id"), + "content_id": getattr(row, f"{prefix}_content_id"), + "put_attempt_id": getattr(row, f"{prefix}_put_attempt_id"), + "verification_receipt_id": getattr(row, f"{prefix}_verification_receipt_id"), + "content_sha256": getattr(row, f"{prefix}_content_sha256"), + "byte_count": getattr(row, f"{prefix}_byte_count"), + } + if any(value is None for value in values.values()): + raise ModelCallUnavailable("model_call_evidence_unavailable") + for field in ( + "binding_id", + "content_id", + "put_attempt_id", + "verification_receipt_id", + ): + values[field] = _uuid(values[field]) + return ModelCallEvidence.model_validate(values) + + +def _attempt( + slot: CheckerModelUsageSlot, + row: ModelUsageEffect, + *, + replayed: bool, +) -> AttachedModelCallAttempt: + """Project a stored attempt with its exact slot identity and replay status.""" + values = dict( + slot_id=_uuid(slot.id), + slot_key=_slot_key(slot), + attempt_id=_uuid(row.id), + attempt_number=row.attempt_number, + internal_call_request_id=_uuid(row.internal_call_request_id), + request_sha256=row.request_sha256, + state=row.state, + replayed=replayed, + ) + if row.state == "prepared": + return PreparedModelCallAttempt.model_validate(values) + return AttachedModelCallAttempt.model_validate(values) + + +def _effect(slot: CheckerModelUsageSlot, row: ModelUsageEffect) -> ModelCallEffectAuthorityFacts: + """Build the authority facts identifying one durable provider effect.""" + return ModelCallEffectAuthorityFacts( + project_id=_uuid(slot.project_id), + task_id=_uuid(slot.task_id), + slot_id=_uuid(slot.id), + slot_key=_slot_key(slot), + attempt_id=_uuid(row.id), + internal_call_request_id=_uuid(row.internal_call_request_id), + ) + + +def _require_effect( + supplied: ModelCallEffectAuthorityFacts, + slot: CheckerModelUsageSlot, + row: ModelUsageEffect, +) -> None: + """Reject supplied effect authority facts that differ from stored custody.""" + if supplied != _effect(slot, row): + raise ModelCallUnavailable("model_call_effect_unavailable") + + +def _usage(row: ModelUsageEffect) -> ModelCallUsage | None: + """Return complete stored usage, distinguishing absent from partial counters.""" + values = ( + row.input_tokens, + row.cached_input_tokens, + row.output_tokens, + row.reasoning_tokens, + ) + if all(value is None for value in values): + return None + if any(value is None for value in values): + raise ModelCallUnavailable("model_call_usage_unavailable") + return ModelCallUsage( + input_tokens=row.input_tokens, + cached_input_tokens=row.cached_input_tokens, + output_tokens=row.output_tokens, + reasoning_tokens=row.reasoning_tokens, + ) + + +def _stored_retry_terminal_digest(row: ModelUsageEffect) -> str | None: + """Reconstruct a retry-eligible terminal digest only from complete stored facts.""" + if row.state == "known_no_effect": + if ( + row.no_effect_proof_id is None + or row.no_effect_proof_digest is None + or row.no_effect_observed_at is None + or row.reserved_nanodollars is None + ): + return None + return canonical_json_hash( + { + "state": row.state, + "proof_id": row.no_effect_proof_id, + "proof_digest": row.no_effect_proof_digest, + "observed_at": model_usage_instant(row.no_effect_observed_at), + } + ) + if row.state == "known_failed": + usage = _usage(row) + if ( + usage is None + or row.provider_response_id is None + or row.response_content_sha256 is None + or row.settled_nanodollars is None + or row.settlement_authorization_evidence_id is None + or row.settled_at is None + ): + return None + return canonical_json_hash( + { + "state": row.state, + "provider_response_id": row.provider_response_id, + "response_content_sha256": row.response_content_sha256, + "usage": usage.model_dump(mode="json"), + "settled_nanodollars": row.settled_nanodollars, + } + ) + return None + + +class ModelCallService: + """Flush-only state machine; the caller owns commit or rollback.""" + + def __init__( + self, + session: AsyncSession, + *, + authority: ModelCallAuthorityPort, + current_execution: ModelCallCurrentExecutionPort, + evidence: ModelCallEvidencePort, + ) -> None: + """Bind persistence and authority ports without taking transaction ownership.""" + self._session = session + self._repo = ModelCallRepository(session) + self._authority = authority + self._current = current_execution + self._evidence = evidence + + def _require_transaction(self) -> None: + """Require the caller's active root transaction before state-machine work.""" + require_model_call_transaction(self._session) + + async def provision_profile(self, facts: ModelProfileProvisioningFacts) -> ProjectModelProfile: + """Publish one immutable Operator-authorized exact model profile.""" + self._require_transaction() + facts = ModelProfileProvisioningFacts.model_validate(facts) + _ = facts.spec.maximum_reservation_nanodollars + authority = await self._authority.authorize_profile(facts) + _require_authority("profile", facts, authority) + await self._repo.lock_profile_scopes( + facts.operation_id, facts.spec.project_id, facts.spec.profile_version + ) + by_operation = await self._repo.profile_by_operation(facts.operation_id) + if by_operation is not None: + existing = stored_model_profile(by_operation) + stored_facts = ModelProfileProvisioningFacts( + operation_id=existing.operation_id, + actor_profile_id=existing.actor_profile_id, + spec=existing.spec, + profile_digest=existing.profile_digest, + ) + if stored_facts != facts: + raise ModelCallConflict("model_profile_operation_conflict") + return existing + if ( + await self._repo.profile_by_version(facts.spec.project_id, facts.spec.profile_version) + is not None + ): + raise ModelCallConflict("model_profile_version_conflict") + latest = await self._repo.latest_profile(facts.spec.project_id) + expected_version = 1 if latest is None else latest.profile_version + 1 + if facts.spec.profile_version != expected_version: + raise ModelCallConflict("model_profile_version_conflict") + + spec = facts.spec + pricing = spec.pricing + row = ModelUsageProfile( + id=str(new_record_id()), + project_id=str(spec.project_id), + profile_version=spec.profile_version, + profile_digest=facts.profile_digest, + provider=spec.provider, + api=spec.api, + model_snapshot=spec.model_snapshot, + generation_settings=spec.settings.model_dump(mode="json"), + maximum_request_bytes=spec.maximum_request_bytes, + maximum_output_bytes=spec.maximum_output_bytes, + maximum_response_bytes=spec.maximum_response_bytes, + maximum_input_tokens=spec.maximum_input_tokens, + maximum_output_tokens=spec.maximum_output_tokens, + maximum_calls=spec.maximum_calls, + maximum_total_request_bytes=spec.maximum_total_request_bytes, + maximum_total_response_bytes=spec.maximum_total_response_bytes, + maximum_total_input_tokens=spec.maximum_total_input_tokens, + maximum_total_output_tokens=spec.maximum_total_output_tokens, + allowed_tools=list(spec.allowed_tools), + maximum_tool_calls=spec.maximum_tool_calls, + maximum_tool_output_bytes=spec.maximum_tool_output_bytes, + deadline_ms=spec.deadline_ms, + pricing_policy_id=str(pricing.pricing_policy_id), + pricing_policy_version=pricing.pricing_policy_version, + pricing_policy_digest=pricing.pricing_policy_digest, + uncached_input_rate=pricing.uncached_input_nanodollars_per_token, + cached_input_rate=pricing.cached_input_nanodollars_per_token, + output_rate=pricing.output_nanodollars_per_token, + operation_id=str(facts.operation_id), + actor_profile_id=str(facts.actor_profile_id), + authorization_evidence_id=str(authority.evidence_id), + ) + await self._repo.add(row) + return stored_model_profile(row) + + async def provision_budget( + self, facts: ModelBudgetProvisioningFacts + ) -> ProjectModelBudgetConfiguration: + """Advance a ceiling version without resetting project lifetime counters.""" + self._require_transaction() + facts = ModelBudgetProvisioningFacts.model_validate(facts) + authority = await self._authority.authorize_budget(facts) + _require_authority("budget", facts, authority) + spec = facts.spec + + await self._repo.lock_budget_scope(spec.project_id) + by_operation = await self._repo.budget_configuration_by_operation(facts.operation_id) + if by_operation is not None: + account = await self._repo.budget_account(spec.project_id, lock=True) + if account is None: + raise ModelCallUnavailable("model_budget_account_unavailable") + result = self._budget_result(by_operation, account) + stored_facts = ModelBudgetProvisioningFacts( + operation_id=result.operation_id, + actor_profile_id=result.actor_profile_id, + spec=result.spec, + configuration_digest=result.configuration_digest, + ) + if stored_facts != facts: + raise ModelCallConflict("model_budget_operation_conflict") + return result + + account = await self._repo.budget_account(spec.project_id, lock=True) + current = ( + await self._repo.current_budget_configuration(account) if account is not None else None + ) + expected_version = 1 if current is None else current.configuration_version + 1 + if spec.configuration_version != expected_version: + raise ModelCallConflict("model_budget_version_conflict") + if current is not None and ( + spec.authorized_total_nanodollars < current.authorized_total_nanodollars + or spec.externally_reconciled_prior_spend_nanodollars + < current.externally_reconciled_prior_spend_nanodollars + ): + raise ModelCallConflict("model_budget_reconciliation_conflict") + if ( + await self._repo.budget_configuration_by_version( + spec.project_id, spec.configuration_version + ) + is not None + ): + raise ModelCallConflict("model_budget_version_conflict") + reserved = 0 if account is None else account.lifetime_reserved_nanodollars + settled = 0 if account is None else account.lifetime_settled_nanodollars + if spec.lifetime_limit_nanodollars < reserved + settled: + raise ModelCallBudgetUnavailable("model_budget_below_lifetime_usage") + + row = ModelUsageBudgetConfiguration( + id=str(new_record_id()), + project_id=str(spec.project_id), + configuration_version=spec.configuration_version, + configuration_digest=facts.configuration_digest, + authorized_total_nanodollars=spec.authorized_total_nanodollars, + externally_reconciled_prior_spend_nanodollars=( + spec.externally_reconciled_prior_spend_nanodollars + ), + lifetime_limit_nanodollars=spec.lifetime_limit_nanodollars, + operation_id=str(facts.operation_id), + actor_profile_id=str(facts.actor_profile_id), + authorization_evidence_id=str(authority.evidence_id), + ) + await self._repo.add(row) + if account is None: + account = ModelUsageBudgetAccount( + project_id=str(spec.project_id), + current_configuration_id=row.id, + lifetime_reserved_nanodollars=0, + lifetime_settled_nanodollars=0, + ) + await self._repo.add(account) + else: + account.current_configuration_id = row.id + await self._repo.flush() + return self._budget_result(row, account) + + @staticmethod + def _budget_result( + row: ModelUsageBudgetConfiguration, account: ModelUsageBudgetAccount + ) -> ProjectModelBudgetConfiguration: + """Project an immutable budget version with its current lifetime counters.""" + return ProjectModelBudgetConfiguration( + configuration_id=_uuid(row.id), + operation_id=_uuid(row.operation_id), + actor_profile_id=_uuid(row.actor_profile_id), + spec={ + "project_id": _uuid(row.project_id), + "configuration_version": row.configuration_version, + "authorized_total_nanodollars": row.authorized_total_nanodollars, + "externally_reconciled_prior_spend_nanodollars": ( + row.externally_reconciled_prior_spend_nanodollars + ), + "lifetime_limit_nanodollars": row.lifetime_limit_nanodollars, + }, + configuration_digest=row.configuration_digest, + authorization_evidence_id=_uuid(row.authorization_evidence_id), + created_at=row.created_at, + lifetime_reserved_nanodollars=account.lifetime_reserved_nanodollars, + lifetime_settled_nanodollars=account.lifetime_settled_nanodollars, + ) + + async def read_exact( + self, + selector: ModelProfileSelector, + *, + project_id: UUID, + ) -> ProjectModelProfile: + """Read an exact profile without treating a partial selector as authority.""" + self._require_transaction() + selector = ModelProfileSelector.model_validate(selector) + row = await self._repo.profile_exact( + selector.profile_id, selector.profile_version, selector.profile_digest + ) + if row is None: + raise ModelCallUnavailable("model_profile_unavailable") + result = stored_model_profile(row) + if result.spec.project_id != project_id: + raise ModelCallUnavailable("model_profile_unavailable") + return result + + @staticmethod + def _require_execution_slot(execution: ModelCallExecutionFacts, slot: ModelPaidSlotKey) -> None: + """Require the paid slot to belong to the exact authorized execution.""" + if ( + execution.project_id != slot.project_id + or execution.assignment_id != slot.assignment_id + or execution.submission_id != slot.submission_id + or execution.submission_version != slot.submission_version + ): + raise ModelCallUnavailable("model_call_execution_unavailable") + + async def _profile_for_slot( + self, slot: ModelPaidSlotKey, registry_entry_id: UUID + ) -> ProjectModelProfile: + """Resolve the exact stored profile selected by a checker-owned slot.""" + return await self.read_exact( + ModelProfileSelector( + profile_id=slot.profile_id, + profile_version=slot.profile_version, + profile_digest=slot.profile_digest, + ), + project_id=slot.project_id, + ) + + async def _profile_for_stored_slot(self, slot: ModelPaidSlotKey) -> ProjectModelProfile: + """Resolve a stored slot's immutable profile without accepting registry substitution.""" + row = await self._repo.profile_exact( + slot.profile_id, slot.profile_version, slot.profile_digest + ) + if row is None: + raise ModelCallUnavailable("model_profile_unavailable") + result = stored_model_profile(row) + if result.spec.project_id != slot.project_id: + raise ModelCallUnavailable("model_profile_unavailable") + return result + + async def _require_checker_envelope( + self, + execution: ModelCallExecutionFacts, + slot: ModelPaidSlotKey, + ) -> CheckerModelUsageEnvelope: + """Lock or create the immutable whole-run checker owner before slot custody.""" + await self._repo.lock_envelope_scope(slot) + row = await self._repo.envelope(slot, lock=True) + if row is None: + values = slot.model_dump(mode="json", exclude={"slot_index"}) + row = CheckerModelUsageEnvelope( + id=str(new_record_id()), + task_id=str(execution.task_id), + **values, + ) + await self._repo.add(row) + elif row.task_id != str(execution.task_id): + raise ModelCallConflict("model_call_envelope_conflict") + return row + + async def prepare_attempt( + self, + execution: ModelCallExecutionFacts, + slot: ModelPaidSlotKey, + *, + registry_entry_id: UUID, + request_sha256: str, + ) -> AttachedModelCallAttempt: + """Allocate a server-owned attempt before private request ART upload.""" + self._require_transaction() + execution = ModelCallExecutionFacts.model_validate(execution) + slot = ModelPaidSlotKey.model_validate(slot) + request_sha256 = _sha(request_sha256) + self._require_execution_slot(execution, slot) + authority = await self._authority.authorize_prepare(execution, slot, request_sha256) + _require_authority( + "prepare", + { + "execution": execution.model_dump(mode="json"), + "slot": slot.model_dump(mode="json"), + "request_sha256": request_sha256, + }, + authority, + ) + await self._current.require_current(execution, slot) + await self._profile_for_slot(slot, registry_entry_id) + + envelope = await self._require_checker_envelope(execution, slot) + await self._repo.lock_slot_scope(slot) + slot_row = await self._repo.slot(slot, lock=True) + if slot_row is None: + slot_row = CheckerModelUsageSlot( + id=str(new_record_id()), + envelope_id=envelope.id, + task_id=str(execution.task_id), + **slot.model_dump(mode="json"), + first_request_sha256=request_sha256, + ) + await self._repo.add(slot_row) + elif slot_row.first_request_sha256 != request_sha256 or slot_row.task_id != str( + execution.task_id + ): + raise ModelCallConflict("model_call_request_conflict") + attempt = await self._repo.latest_attempt(slot_row.id, lock=True) + if attempt is not None: + return _attempt(slot_row, attempt, replayed=True) + + attempt = ModelUsageEffect( + id=str(new_record_id()), + checker_slot_id=slot_row.id, + attempt_number=1, + internal_call_request_id=str(new_record_id()), + request_sha256=request_sha256, + state="prepared", + prepared_execution_facts=execution.model_dump(mode="json"), + prepared_authorization_evidence_id=str(authority.evidence_id), + ) + await self._repo.add(attempt) + return _attempt(slot_row, attempt, replayed=False) + + async def prepare_recovery_child( + self, + slot: ModelPaidSlotKey, + *, + registry_entry_id: UUID, + parent_attempt_id: UUID, + terminal_digest: str, + request_sha256: str, + ) -> PreparedModelCallAttempt: + """Allocate N+1 only after an authorized, fully-known terminal parent.""" + self._require_transaction() + slot = ModelPaidSlotKey.model_validate(slot) + terminal_digest = _sha(terminal_digest) + request_sha256 = _sha(request_sha256) + authority = await self._authority.authorize_child( + slot.project_id, parent_attempt_id, terminal_digest + ) + child_facts = { + "project_id": str(slot.project_id), + "parent_attempt_id": str(parent_attempt_id), + "terminal_digest": terminal_digest, + } + _require_authority("child", child_facts, authority) + await self._current.require_recovery_current(slot, parent_attempt_id) + await self._profile_for_slot(slot, registry_entry_id) + + envelope = await self._repo.envelope(slot) + if envelope is None: + raise ModelCallUnavailable("model_call_parent_unavailable") + await self._repo.lock_envelope_scope(slot) + envelope = await self._repo.envelope(slot, lock=True) + if envelope is None: + raise ModelCallUnavailable("model_call_parent_unavailable") + await self._repo.lock_slot_scope(slot) + slot_row = await self._repo.slot(slot, lock=True) + if slot_row is None: + raise ModelCallUnavailable("model_call_parent_unavailable") + if slot_row.first_request_sha256 != request_sha256: + raise ModelCallConflict("model_call_request_conflict") + latest = await self._repo.latest_attempt(slot_row.id, lock=True) + if latest is None: + raise ModelCallUnavailable("model_call_parent_unavailable") + parent = ( + latest + if latest.id == str(parent_attempt_id) + else await self._repo.attempt(parent_attempt_id) + ) + if ( + parent is None + or parent.checker_slot_id != slot_row.id + or not self._eligible_parent(parent, terminal_digest) + ): + raise ModelCallConflict("model_call_parent_not_retryable") + if latest.id != str(parent_attempt_id): + if ( + latest.attempt_number > 1 + and latest.state == "prepared" + and latest.parent_attempt_id == str(parent_attempt_id) + and latest.request_sha256 == request_sha256 + ): + return PreparedModelCallAttempt.model_validate( + _attempt(slot_row, latest, replayed=True) + ) + raise ModelCallConflict("model_call_parent_superseded") + + child = ModelUsageEffect( + id=str(new_record_id()), + checker_slot_id=slot_row.id, + attempt_number=latest.attempt_number + 1, + internal_call_request_id=str(new_record_id()), + parent_attempt_id=str(parent_attempt_id), + request_sha256=request_sha256, + state="prepared", + prepared_authorization_evidence_id=str(authority.evidence_id), + ) + await self._repo.add(child) + return PreparedModelCallAttempt.model_validate(_attempt(slot_row, child, replayed=False)) + + @staticmethod + def _eligible_parent(row: ModelUsageEffect, terminal_digest: str) -> bool: + """Accept a retry parent only when complete stored terminal facts match the digest.""" + return _stored_retry_terminal_digest(row) == terminal_digest + + async def reserve_dispatch( + self, + execution: ModelCallExecutionFacts, + attempt: PreparedModelCallAttempt, + request_evidence: ModelCallEvidence, + ) -> ModelDispatchReservation: + """Verify ART, then atomically persist intent and reserve lifetime spend.""" + self._require_transaction() + execution = ModelCallExecutionFacts.model_validate(execution) + attempt = PreparedModelCallAttempt.model_validate(attempt) + request_evidence = ModelCallEvidence.model_validate(request_evidence) + self._require_execution_slot(execution, attempt.slot_key) + # ``replayed`` describes how this caller obtained the immutable attempt; + # it is not dispatch identity. Normalize it so a successor invocation can + # authorize the already-created recovery child against durable SQL facts. + authority_attempt = attempt.model_copy(update={"replayed": False}) + authority = await self._authority.authorize_dispatch( + execution, authority_attempt, request_evidence + ) + dispatch_facts = { + "execution": execution.model_dump(mode="json"), + "attempt": authority_attempt.model_dump(mode="json"), + "request_evidence": request_evidence.model_dump(mode="json"), + } + _require_authority("dispatch", dispatch_facts, authority) + await self._current.require_current(execution, attempt.slot_key) + await self._evidence.require_request(attempt, request_evidence) + profile = await self._profile_for_stored_slot(attempt.slot_key) + if ( + request_evidence.content_sha256 != attempt.request_sha256 + or request_evidence.byte_count > profile.spec.maximum_request_bytes + ): + raise ModelCallConflict("model_call_request_evidence_conflict") + + await self._repo.lock_envelope_scope(attempt.slot_key) + envelope = await self._repo.envelope(attempt.slot_key, lock=True) + if envelope is None: + raise ModelCallUnavailable("model_call_envelope_unavailable") + await self._repo.lock_slot_scope(attempt.slot_key) + slot_row = await self._repo.slot(attempt.slot_key, lock=True) + if slot_row is None or slot_row.id != str(attempt.slot_id): + raise ModelCallUnavailable("model_call_attempt_unavailable") + row = await self._repo.attempt(attempt.attempt_id, lock=True) + if row is None or row.checker_slot_id != slot_row.id: + raise ModelCallUnavailable("model_call_attempt_unavailable") + if row.state != "prepared": + if _evidence(row, "request") != request_evidence: + raise ModelCallConflict("model_call_dispatch_conflict") + return self._dispatch_result(row, slot=slot_row, replayed=True) + if ( + row.request_sha256 != attempt.request_sha256 + or row.attempt_number != attempt.attempt_number + or row.internal_call_request_id != str(attempt.internal_call_request_id) + ): + raise ModelCallConflict("model_call_attempt_conflict") + + totals = await self._repo.envelope_totals(envelope.id) + reservation = profile.spec.maximum_dispatch_reservation_nanodollars + if ( + totals.dispatches >= profile.spec.maximum_calls + or totals.reserved_nanodollars + totals.settled_nanodollars + reservation + > profile.spec.maximum_reservation_nanodollars + or totals.request_bytes + request_evidence.byte_count + > profile.spec.maximum_total_request_bytes + or totals.response_bytes + + (totals.pending_dispatches + 1) * profile.spec.maximum_response_bytes + > profile.spec.maximum_total_response_bytes + or totals.input_tokens + + (totals.pending_dispatches + 1) * profile.spec.maximum_input_tokens + > profile.spec.maximum_total_input_tokens + or totals.output_tokens + + (totals.pending_dispatches + 1) * profile.spec.maximum_output_tokens + > profile.spec.maximum_total_output_tokens + ): + raise ModelCallBudgetUnavailable("model_run_envelope_unavailable") + + await self._repo.lock_budget_scope(attempt.slot_key.project_id) + account = await self._repo.budget_account(attempt.slot_key.project_id, lock=True) + if account is None: + raise ModelCallBudgetUnavailable("model_budget_unavailable") + budget = await self._repo.current_budget_configuration(account) + if budget is None: + raise ModelCallBudgetUnavailable("model_budget_unavailable") + if ( + account.lifetime_reserved_nanodollars + + account.lifetime_settled_nanodollars + + reservation + > budget.lifetime_limit_nanodollars + ): + raise ModelCallBudgetUnavailable("model_budget_exhausted") + + account.lifetime_reserved_nanodollars += reservation + self._set_evidence(row, "request", request_evidence) + row.budget_configuration_id = budget.id + row.budget_configuration_version = budget.configuration_version + row.budget_configuration_digest = budget.configuration_digest + row.reserved_nanodollars = reservation + row.dispatch_execution_facts = execution.model_dump(mode="json") + row.dispatch_authorization_evidence_id = str(authority.evidence_id) + row.dispatched_at = await self._repo.now() + row.state = "dispatching" + await self._repo.flush() + return self._dispatch_result(row, slot=slot_row, replayed=False) + + @staticmethod + def _set_evidence(row: ModelUsageEffect, prefix: str, evidence: ModelCallEvidence) -> None: + """Copy validated ART custody fields into the selected attempt evidence side.""" + for field, value in evidence.model_dump(mode="json", exclude={"media_type"}).items(): + setattr(row, f"{prefix}_{field}", value) + + @staticmethod + def _dispatch_result( + row: ModelUsageEffect, + *, + slot: CheckerModelUsageSlot | None = None, + replayed: bool, + ) -> ModelDispatchReservation: + """Project a dispatch reservation only from effect-bearing stored states.""" + if row.state not in {"dispatching", "uncertain", "completed"}: + raise ModelCallConflict("model_call_not_dispatchable") + if slot is None: + raise ModelCallUnavailable("model_call_effect_unavailable") + return ModelDispatchReservation( + slot_id=_uuid(row.checker_slot_id), + attempt_id=_uuid(row.id), + attempt_number=row.attempt_number, + internal_call_request_id=_uuid(row.internal_call_request_id), + effect=_effect(slot, row), + request_sha256=row.request_sha256, + request_evidence=_evidence(row, "request"), + budget_configuration_id=_uuid(row.budget_configuration_id), + budget_configuration_version=row.budget_configuration_version, + budget_configuration_digest=row.budget_configuration_digest, + reserved_nanodollars=row.reserved_nanodollars, + state=row.state, + provider_response_id=row.provider_response_id, + response_evidence=( + _evidence(row, "response") if row.response_content_sha256 is not None else None + ), + replayed=replayed, + ) + + async def attach_recovery( + self, + execution: ModelCallExecutionFacts, + effect: ModelCallEffectAuthorityFacts, + ) -> ModelCallRecoveryAttachment: + """Attach a fresh run to an existing effect; this never creates a call.""" + self._require_transaction() + execution = ModelCallExecutionFacts.model_validate(execution) + effect = ModelCallEffectAuthorityFacts.model_validate(effect) + self._require_execution_slot(execution, effect.slot_key) + if execution.task_id != effect.task_id: + raise ModelCallUnavailable("model_call_execution_unavailable") + authority = await self._authority.authorize_recover(execution, effect) + facts = { + "execution": execution.model_dump(mode="json"), + "effect": effect.model_dump(mode="json"), + } + _require_authority("recover", facts, authority) + await self._current.require_current(execution, effect.slot_key) + row = await self._repo.attempt(effect.attempt_id) + if row is None: + raise ModelCallUnavailable("model_call_attempt_unavailable") + slot = await self._session.get(CheckerModelUsageSlot, row.checker_slot_id) + if slot is None: + raise ModelCallUnavailable("model_call_attempt_unavailable") + self._require_execution_slot(execution, _slot_key(slot)) + await self._repo.lock_envelope_scope(_slot_key(slot)) + if await self._repo.envelope(_slot_key(slot), lock=True) is None: + raise ModelCallUnavailable("model_call_envelope_unavailable") + await self._repo.lock_slot_scope(_slot_key(slot)) + locked_slot = await self._repo.slot(_slot_key(slot), lock=True) + if locked_slot is None or locked_slot.id != slot.id: + raise ModelCallUnavailable("model_call_attempt_unavailable") + slot = locked_slot + row = await self._repo.attempt(effect.attempt_id, lock=True) + if row is not None: + _require_effect(effect, slot, row) + recoverable = ( + row is not None + and row.provider_response_id is not None + and row.state in {"dispatching", "uncertain"} + ) + if not recoverable: + raise ModelCallUnavailable("model_call_recovery_unavailable") + existing = await self._repo.recovery( + effect.attempt_id, + execution.checker_run_id, + execution.worker_lease_id, + execution.worker_lease_generation, + ) + if existing is None: + await self._repo.add( + CheckerModelUsageRecovery( + id=str(new_record_id()), + attempt_id=str(effect.attempt_id), + checker_run_id=str(execution.checker_run_id), + worker_lease_id=str(execution.worker_lease_id), + worker_lease_generation=execution.worker_lease_generation, + execution_facts=execution.model_dump(mode="json"), + effect_facts=effect.model_dump(mode="json"), + authorization_evidence_id=str(authority.evidence_id), + ) + ) + return ModelCallRecoveryAttachment( + slot_id=_uuid(slot.id), + attempt_id=_uuid(row.id), + attempt_number=row.attempt_number, + internal_call_request_id=_uuid(row.internal_call_request_id), + provider_response_id=row.provider_response_id, + state=row.state, + replayed=existing is not None, + ) + + async def attach_completed( + self, + execution: ModelCallExecutionFacts, + effect: ModelCallEffectAuthorityFacts, + ) -> CompletedModelCall: + """Attach a fresh current run to immutable cached response evidence.""" + self._require_transaction() + execution = ModelCallExecutionFacts.model_validate(execution) + effect = ModelCallEffectAuthorityFacts.model_validate(effect) + self._require_execution_slot(execution, effect.slot_key) + if execution.task_id != effect.task_id: + raise ModelCallUnavailable("model_call_execution_unavailable") + authority = await self._authority.authorize_recover(execution, effect) + facts = { + "execution": execution.model_dump(mode="json"), + "effect": effect.model_dump(mode="json"), + } + _require_authority("recover", facts, authority) + await self._current.require_current(execution, effect.slot_key) + row = await self._repo.attempt(effect.attempt_id) + if row is None: + raise ModelCallUnavailable("model_call_attempt_unavailable") + slot = await self._session.get(CheckerModelUsageSlot, row.checker_slot_id) + if slot is None: + raise ModelCallUnavailable("model_call_attempt_unavailable") + self._require_execution_slot(execution, _slot_key(slot)) + await self._repo.lock_envelope_scope(_slot_key(slot)) + if await self._repo.envelope(_slot_key(slot), lock=True) is None: + raise ModelCallUnavailable("model_call_envelope_unavailable") + await self._repo.lock_slot_scope(_slot_key(slot)) + locked_slot = await self._repo.slot(_slot_key(slot), lock=True) + if locked_slot is None or locked_slot.id != slot.id: + raise ModelCallUnavailable("model_call_attempt_unavailable") + slot = locked_slot + row = await self._repo.attempt(effect.attempt_id, lock=True) + if row is None or row.state != "completed": + raise ModelCallUnavailable("model_call_completion_unavailable") + _require_effect(effect, slot, row) + existing = await self._repo.recovery( + effect.attempt_id, + execution.checker_run_id, + execution.worker_lease_id, + execution.worker_lease_generation, + ) + if existing is None: + await self._repo.add( + CheckerModelUsageRecovery( + id=str(new_record_id()), + attempt_id=str(effect.attempt_id), + checker_run_id=str(execution.checker_run_id), + worker_lease_id=str(execution.worker_lease_id), + worker_lease_generation=execution.worker_lease_generation, + execution_facts=execution.model_dump(mode="json"), + effect_facts=effect.model_dump(mode="json"), + authorization_evidence_id=str(authority.evidence_id), + ) + ) + return self._completed_result(row, replayed=existing is not None) + + async def mark_uncertain( + self, + effect: ModelCallEffectAuthorityFacts, + uncertainty_digest: str, + *, + provider_response_id: str | None = None, + ) -> ModelDispatchReservation: + """Conserve the full reservation after an ambiguous dispatched effect.""" + self._require_transaction() + effect = ModelCallEffectAuthorityFacts.model_validate(effect) + uncertainty_digest = _sha(uncertainty_digest) + if provider_response_id is not None: + provider_response_id = _PROVIDER_RESPONSE_ADAPTER.validate_python( + provider_response_id, strict=True + ) + authority = await self._authority.authorize_observe( + effect, provider_response_id, uncertainty_digest + ) + observation_facts = { + "effect": effect.model_dump(mode="json"), + "provider_response_id": provider_response_id, + "observation_digest": uncertainty_digest, + } + _require_authority("observe", observation_facts, authority) + _, slot, _ = await self._terminal_preflight(effect.attempt_id) + row = await self._lock_terminal(slot, effect.attempt_id) + _require_effect(effect, slot, row) + if row.state == "uncertain": + if ( + row.uncertainty_digest != uncertainty_digest + or row.provider_response_id != provider_response_id + ): + raise ModelCallConflict("model_call_uncertainty_conflict") + return self._dispatch_result(row, slot=slot, replayed=True) + if row.state != "dispatching": + raise ModelCallConflict("model_call_uncertainty_conflict") + row.state = "uncertain" + row.uncertainty_digest = uncertainty_digest + row.provider_response_id = provider_response_id + row.observation_authorization_evidence_id = str(authority.evidence_id) + await self._repo.flush() + return self._dispatch_result(row, slot=slot, replayed=False) + + async def complete( + self, + effect: ModelCallEffectAuthorityFacts, + *, + provider_response_id: str, + provider_model_snapshot: str, + response_evidence: ModelCallEvidence, + usage: ModelCallUsage, + ) -> CompletedModelCall: + """Store verified usable response evidence and settle cost exactly once.""" + effect = ModelCallEffectAuthorityFacts.model_validate(effect) + attempt_id = effect.attempt_id + provider_response_id = _PROVIDER_RESPONSE_ADAPTER.validate_python( + provider_response_id, strict=True + ) + response_evidence = ModelCallEvidence.model_validate(response_evidence) + usage = ModelCallUsage.model_validate(usage) + terminal = { + "status": "completed", + "provider_response_id": provider_response_id, + "provider_model_snapshot": provider_model_snapshot, + "response_evidence": response_evidence.model_dump(mode="json"), + "usage": usage.model_dump(mode="json"), + } + terminal_digest = canonical_json_hash(terminal) + authority = await self._authority.authorize_complete( + effect, response_evidence, terminal_digest + ) + _require_authority( + "complete", + { + "effect": effect.model_dump(mode="json"), + "response_evidence": response_evidence.model_dump(mode="json"), + "terminal_digest": terminal_digest, + }, + authority, + ) + await self._evidence.require_response(attempt_id, response_evidence) + row, slot, profile = await self._terminal_preflight(attempt_id) + if ( + provider_model_snapshot != profile.spec.model_snapshot + or response_evidence.byte_count > profile.spec.maximum_response_bytes + ): + raise ModelCallConflict("model_call_response_conflict") + cost = model_usage_cost(profile.spec, usage) + row = await self._lock_terminal(slot, attempt_id) + _require_effect(effect, slot, row) + if row.state == "completed": + if ( + row.provider_response_id != provider_response_id + or _evidence(row, "response") != response_evidence + or _usage(row) != usage + or row.settled_nanodollars != cost + ): + raise ModelCallConflict("model_call_terminal_conflict") + return self._completed_result(row, replayed=True) + self._require_effect_pending(row) + if ( + row.provider_response_id is not None + and row.provider_response_id != provider_response_id + ): + raise ModelCallConflict("model_call_provider_response_conflict") + await self._settle(row, slot, cost) + self._set_evidence(row, "response", response_evidence) + self._set_usage(row, usage) + row.provider_response_id = provider_response_id + row.settled_nanodollars = cost + row.terminal_authorization_evidence_id = str(authority.evidence_id) + row.terminal_at = await self._repo.now() + row.state = "completed" + await self._repo.flush() + return self._completed_result(row, replayed=False) + + async def complete_known_failure( + self, + effect: ModelCallEffectAuthorityFacts, + *, + provider_response_id: str, + provider_model_snapshot: str, + response_evidence: ModelCallEvidence, + usage: ModelCallUsage, + ) -> KnownFailedModelCall: + """Record and settle a terminal failed effect with complete authenticated usage.""" + effect = ModelCallEffectAuthorityFacts.model_validate(effect) + attempt_id = effect.attempt_id + provider_response_id = _PROVIDER_RESPONSE_ADAPTER.validate_python( + provider_response_id, strict=True + ) + response_evidence = ModelCallEvidence.model_validate(response_evidence) + usage = ModelCallUsage.model_validate(usage) + terminal = { + "status": "known_failed", + "provider_response_id": provider_response_id, + "provider_model_snapshot": provider_model_snapshot, + "response_evidence": response_evidence.model_dump(mode="json"), + "usage": usage.model_dump(mode="json"), + } + terminal_digest = canonical_json_hash(terminal) + authority = await self._authority.authorize_complete( + effect, response_evidence, terminal_digest + ) + _require_authority( + "complete", + { + "effect": effect.model_dump(mode="json"), + "response_evidence": response_evidence.model_dump(mode="json"), + "terminal_digest": terminal_digest, + }, + authority, + ) + await self._evidence.require_response(attempt_id, response_evidence) + row, slot, profile = await self._terminal_preflight(attempt_id) + if ( + provider_model_snapshot != profile.spec.model_snapshot + or response_evidence.byte_count > profile.spec.maximum_response_bytes + ): + raise ModelCallConflict("model_call_response_conflict") + cost = model_usage_cost(profile.spec, usage) + row = await self._lock_terminal(slot, attempt_id) + _require_effect(effect, slot, row) + if row.state == "known_failed": + stored_usage = _usage(row) + same_response = ( + row.provider_response_id == provider_response_id + and _evidence(row, "response") == response_evidence + ) + if not same_response or stored_usage != usage or row.settled_nanodollars != cost: + raise ModelCallConflict("model_call_terminal_conflict") + return self._failed_result(row, replayed=True) + self._require_effect_pending(row) + if ( + row.provider_response_id is not None + and row.provider_response_id != provider_response_id + ): + raise ModelCallConflict("model_call_provider_response_conflict") + await self._settle(row, slot, cost) + self._set_usage(row, usage) + row.settled_nanodollars = cost + row.settlement_authorization_evidence_id = str(authority.evidence_id) + row.settled_at = await self._repo.now() + self._set_evidence(row, "response", response_evidence) + row.provider_response_id = provider_response_id + row.terminal_authorization_evidence_id = str(authority.evidence_id) + row.terminal_at = await self._repo.now() + row.state = "known_failed" + await self._repo.flush() + return self._failed_result(row, replayed=False) + + async def mark_known_no_effect( + self, effect: ModelCallEffectAuthorityFacts, proof: KnownNoEffectProof + ) -> AttachedModelCallAttempt: + """Release reservation only for authenticated proof that create had no effect.""" + effect = ModelCallEffectAuthorityFacts.model_validate(effect) + attempt_id = effect.attempt_id + proof = KnownNoEffectProof.model_validate(proof) + terminal_digest = model_retry_terminal_digest(proof) + authority = await self._authority.authorize_complete(effect, None, terminal_digest) + _require_authority( + "complete", + { + "effect": effect.model_dump(mode="json"), + "response_evidence": None, + "terminal_digest": terminal_digest, + }, + authority, + ) + _, slot, _ = await self._terminal_preflight(attempt_id) + row = await self._lock_terminal(slot, attempt_id) + _require_effect(effect, slot, row) + if row.state == "known_no_effect": + if ( + row.no_effect_proof_id != str(proof.proof_id) + or row.no_effect_proof_digest != proof.proof_digest + or row.no_effect_observed_at != proof.observed_at + ): + raise ModelCallConflict("model_call_terminal_conflict") + return _attempt(slot, row, replayed=True) + self._require_effect_pending(row) + if row.provider_response_id is not None: + raise ModelCallConflict("model_call_no_effect_conflict") + await self._release(row, slot) + row.no_effect_proof_id = str(proof.proof_id) + row.no_effect_proof_digest = proof.proof_digest + row.no_effect_observed_at = proof.observed_at + row.terminal_authorization_evidence_id = str(authority.evidence_id) + row.terminal_at = await self._repo.now() + row.state = "known_no_effect" + await self._repo.flush() + return _attempt(slot, row, replayed=False) + + async def _terminal_preflight( + self, attempt_id: UUID + ) -> tuple[ModelUsageEffect, CheckerModelUsageSlot, ProjectModelProfile]: + """Load the attempt, slot, and exact profile before taking terminal locks.""" + self._require_transaction() + row = await self._repo.attempt(attempt_id) + if row is None: + raise ModelCallUnavailable("model_call_attempt_unavailable") + slot = await self._session.get(CheckerModelUsageSlot, row.checker_slot_id) + if slot is None: + raise ModelCallUnavailable("model_call_attempt_unavailable") + profile_row = await self._repo.profile_exact( + _uuid(slot.profile_id), slot.profile_version, slot.profile_digest + ) + if profile_row is None: + raise ModelCallUnavailable("model_profile_unavailable") + return row, slot, stored_model_profile(profile_row) + + async def _lock_terminal( + self, slot: CheckerModelUsageSlot, attempt_id: UUID + ) -> ModelUsageEffect: + """Take envelope then slot/effect custody before any account mutation.""" + await self._repo.lock_envelope_scope(_slot_key(slot)) + if await self._repo.envelope(_slot_key(slot), lock=True) is None: + raise ModelCallUnavailable("model_call_envelope_unavailable") + await self._repo.lock_slot_scope(_slot_key(slot)) + locked_slot = await self._repo.slot(_slot_key(slot), lock=True) + if locked_slot is None or locked_slot.id != slot.id: + raise ModelCallUnavailable("model_call_attempt_unavailable") + row = await self._repo.attempt(attempt_id, lock=True) + if row is None or row.checker_slot_id != slot.id: + raise ModelCallUnavailable("model_call_attempt_unavailable") + return row + + @staticmethod + def _require_effect_pending(row: ModelUsageEffect) -> None: + """Require an effect-bearing nonterminal attempt with an active reservation.""" + if row.state not in {"dispatching", "uncertain"}: + raise ModelCallConflict("model_call_terminal_conflict") + if row.reserved_nanodollars is None: + raise ModelCallUnavailable("model_call_reservation_unavailable") + + async def _settle( + self, + row: ModelUsageEffect, + slot: CheckerModelUsageSlot, + cost: int, + ) -> None: + """Atomically move one reservation into the project's lifetime settled total.""" + await self._repo.lock_budget_scope(_uuid(slot.project_id)) + account = await self._repo.budget_account(_uuid(slot.project_id), lock=True) + if account is None or row.reserved_nanodollars is None: + raise ModelCallUnavailable("model_budget_unavailable") + if account.lifetime_reserved_nanodollars < row.reserved_nanodollars: + raise ModelCallUnavailable("model_budget_conservation_failed") + account.lifetime_reserved_nanodollars -= row.reserved_nanodollars + account.lifetime_settled_nanodollars += cost + + async def _release(self, row: ModelUsageEffect, slot: CheckerModelUsageSlot) -> None: + """Return a proven no-effect reservation to the locked project account.""" + await self._repo.lock_budget_scope(_uuid(slot.project_id)) + account = await self._repo.budget_account(_uuid(slot.project_id), lock=True) + if account is None or row.reserved_nanodollars is None: + raise ModelCallUnavailable("model_budget_unavailable") + if account.lifetime_reserved_nanodollars < row.reserved_nanodollars: + raise ModelCallUnavailable("model_budget_conservation_failed") + account.lifetime_reserved_nanodollars -= row.reserved_nanodollars + + @staticmethod + def _set_usage(row: ModelUsageEffect, usage: ModelCallUsage) -> None: + """Persist every normalized usage counter as one inseparable terminal set.""" + row.input_tokens = usage.input_tokens + row.cached_input_tokens = usage.cached_input_tokens + row.output_tokens = usage.output_tokens + row.reasoning_tokens = usage.reasoning_tokens + + @staticmethod + def _completed_result(row: ModelUsageEffect, *, replayed: bool) -> CompletedModelCall: + """Project a completed call only when usage, evidence, cost, and time are complete.""" + usage = _usage(row) + if usage is None or row.terminal_at is None or row.settled_nanodollars is None: + raise ModelCallUnavailable("model_call_completion_unavailable") + return CompletedModelCall( + slot_id=_uuid(row.checker_slot_id), + attempt_id=_uuid(row.id), + attempt_number=row.attempt_number, + internal_call_request_id=_uuid(row.internal_call_request_id), + provider_response_id=row.provider_response_id, + request_evidence=_evidence(row, "request"), + response_evidence=_evidence(row, "response"), + usage=usage, + settled_nanodollars=row.settled_nanodollars, + completed_at=row.terminal_at, + replayed=replayed, + ) + + @staticmethod + def _failed_result(row: ModelUsageEffect, *, replayed: bool) -> KnownFailedModelCall: + """Project a known failed call only from complete billed terminal custody.""" + usage = _usage(row) + if row.terminal_at is None or usage is None or row.settled_nanodollars is None: + raise ModelCallUnavailable("model_call_completion_unavailable") + return KnownFailedModelCall( + slot_id=_uuid(row.checker_slot_id), + attempt_id=_uuid(row.id), + attempt_number=row.attempt_number, + internal_call_request_id=_uuid(row.internal_call_request_id), + provider_response_id=row.provider_response_id, + request_evidence=_evidence(row, "request"), + response_evidence=_evidence(row, "response"), + usage=usage, + settled_nanodollars=row.settled_nanodollars, + completed_at=row.terminal_at, + replayed=replayed, + ) + + +__all__ = ("ModelCallService",) diff --git a/backend/app/modules/model_usage/setup.py b/backend/app/modules/model_usage/setup.py new file mode 100644 index 000000000..8d7b68bd3 --- /dev/null +++ b/backend/app/modules/model_usage/setup.py @@ -0,0 +1,621 @@ +"""Shared ledger service for exact setup-owned model-use subjects.""" + +from __future__ import annotations + +from uuid import UUID + +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.hashing import canonical_json_hash +from app.core.identifiers import new_record_id +from app.modules.model_usage.api import ( + CompletedSetupModelUsageEffect, + KnownNoEffectProof, + KnownNoEffectSetupModelUsageEffect, + ModelActionEvidence, + ModelCallBudgetUnavailable, + ModelCallConflict, + ModelCallEvidence, + ModelCallUnavailable, + ModelCallUsage, + ObservedSetupModelUsageEffect, + PreparedSetupModelUsageEffect, + SetupModelDispatchReservation, + SetupModelEffectAuthorityFacts, + SetupModelOwnerProfile, + SetupModelUsageAuthorityPort, + SetupModelUsageEvidencePort, + SetupModelUsageOwnerPort, + SetupModelUsageSlotKey, + model_action_digest, + model_retry_terminal_digest, + model_usage_cost, +) +from app.modules.model_usage.models import ( + ModelUsageEffect, + SetupModelUsageEnvelope, + SetupModelUsageSlot, +) +from app.modules.model_usage.profiles import stored_model_profile +from app.modules.model_usage.repository import ( + ModelCallRepository, + require_model_call_transaction, +) +from app.modules.model_usage.service import ( + _PROVIDER_RESPONSE_ADAPTER, + _evidence, + _sha, + _usage, +) + + +def _require_authority(action: str, facts: object, evidence: ModelActionEvidence) -> None: + if evidence.facts_digest != model_action_digest(action, facts): + raise ModelCallUnavailable("setup_model_authority_unavailable") + + +def _prepared( + envelope: SetupModelUsageEnvelope, + slot: SetupModelUsageSlot, + effect: ModelUsageEffect, + key: SetupModelUsageSlotKey, + *, + replayed: bool, +) -> PreparedSetupModelUsageEffect: + return PreparedSetupModelUsageEffect( + envelope_id=UUID(envelope.id), + slot_id=UUID(slot.id), + slot_key=key, + effect_id=UUID(effect.id), + attempt_number=effect.attempt_number, + internal_call_request_id=UUID(effect.internal_call_request_id), + request_sha256=effect.request_sha256, + replayed=replayed, + ) + + +def _slot_key(slot: SetupModelUsageSlot) -> SetupModelUsageSlotKey: + return SetupModelUsageSlotKey( + compilation_attempt_id=UUID(str(slot.compilation_attempt_id)), + project_id=UUID(slot.project_id), + guide_id=UUID(slot.guide_id), + source_snapshot_id=UUID(slot.source_snapshot_id), + setup_run_id=UUID(slot.setup_run_id), + setup_generation=slot.setup_generation, + runtime_configuration_hash=slot.runtime_configuration_hash, + profile_id=UUID(slot.profile_id), + profile_version=slot.profile_version, + profile_digest=slot.profile_digest, + phase=slot.phase, + call_index=slot.call_index, + ) + + +def _effect(slot: SetupModelUsageSlot, row: ModelUsageEffect) -> SetupModelEffectAuthorityFacts: + key = _slot_key(slot) + return SetupModelEffectAuthorityFacts( + project_id=key.project_id, + slot_id=UUID(slot.id), + slot_key=key, + effect_id=UUID(row.id), + internal_call_request_id=UUID(row.internal_call_request_id), + ) + + +def _require_effect( + supplied: SetupModelEffectAuthorityFacts, + slot: SetupModelUsageSlot, + row: ModelUsageEffect, +) -> None: + if supplied != _effect(slot, row): + raise ModelCallUnavailable("setup_model_effect_unavailable") + + +class SetupModelUsageService: + """Allocate setup effects only after exact PROJECTS and AUTH custody.""" + + def __init__( + self, + session: AsyncSession, + *, + authority: SetupModelUsageAuthorityPort, + evidence: SetupModelUsageEvidencePort, + owner: SetupModelUsageOwnerPort, + ) -> None: + self._session = session + self._repo = ModelCallRepository(session) + self._authority = authority + self._evidence = evidence + self._owner = owner + + async def _profile( + self, + key: SetupModelUsageSlotKey, + owner_profile: SetupModelOwnerProfile | None = None, + ): + row = await self._repo.profile_exact( + key.profile_id, key.profile_version, key.profile_digest + ) + if row is None: + raise ModelCallUnavailable("model_profile_unavailable") + profile = stored_model_profile(row) + if profile.spec.project_id != key.project_id or ( + owner_profile is not None + and ( + profile.selector != owner_profile.selector + or (profile.spec.provider, profile.spec.api, profile.spec.model_snapshot) + != ( + owner_profile.provider, + owner_profile.api, + owner_profile.model_snapshot, + ) + ) + ): + raise ModelCallUnavailable("model_profile_unavailable") + return profile + + async def prepare( + self, key: SetupModelUsageSlotKey, request_sha256: str + ) -> PreparedSetupModelUsageEffect: + """Allocate one durable setup call identity before request persistence.""" + require_model_call_transaction(self._session) + key = SetupModelUsageSlotKey.model_validate(key) + facts = {"slot": key.model_dump(mode="json"), "request_sha256": request_sha256} + authority = await self._authority.authorize_setup_prepare(key, request_sha256) + _require_authority("setup_prepare", facts, authority) + owner_profile = await self._owner.require_setup_current(key) + profile = await self._profile(key, owner_profile) + if key.call_index >= profile.spec.maximum_calls: + raise ModelCallConflict("setup_model_call_index_conflict") + + await self._repo.lock_setup_envelope_scope(key) + envelope = await self._repo.setup_envelope(key, lock=True) + if envelope is None: + values = key.model_dump(mode="json", exclude={"phase", "call_index"}) + values["compilation_attempt_id"] = key.compilation_attempt_id + envelope = SetupModelUsageEnvelope(id=str(new_record_id()), **values) + await self._repo.add(envelope) + await self._repo.lock_setup_slot_scope(key) + slot = await self._repo.setup_slot(key, lock=True) + if slot is None: + slot_values = key.model_dump(mode="json") + slot_values["compilation_attempt_id"] = key.compilation_attempt_id + slot = SetupModelUsageSlot( + id=str(new_record_id()), + envelope_id=envelope.id, + request_sha256=request_sha256, + **slot_values, + ) + await self._repo.add(slot) + elif slot.request_sha256 != request_sha256 or slot.envelope_id != envelope.id: + raise ModelCallConflict("setup_model_request_conflict") + effect = await self._repo.latest_setup_effect(slot.id, lock=True) + if effect is not None: + return _prepared(envelope, slot, effect, key, replayed=True) + effect = ModelUsageEffect( + id=str(new_record_id()), + setup_slot_id=slot.id, + attempt_number=1, + internal_call_request_id=str(new_record_id()), + request_sha256=request_sha256, + state="prepared", + prepared_authorization_evidence_id=str(authority.evidence_id), + ) + await self._repo.add(effect) + return _prepared(envelope, slot, effect, key, replayed=False) + + async def recover_existing( + self, key: SetupModelUsageSlotKey, request_sha256: str + ) -> CompletedSetupModelUsageEffect | ObservedSetupModelUsageEffect | None: + """Return a retained terminal or uncertain effect without recreating I/O.""" + require_model_call_transaction(self._session) + key = SetupModelUsageSlotKey.model_validate(key) + request_sha256 = _sha(request_sha256) + facts = {"slot": key.model_dump(mode="json"), "request_sha256": request_sha256} + authority = await self._authority.authorize_setup_prepare(key, request_sha256) + _require_authority("setup_prepare", facts, authority) + owner_profile = await self._owner.require_setup_current(key) + await self._profile(key, owner_profile) + await self._repo.lock_setup_envelope_scope(key) + envelope = await self._repo.setup_envelope(key, lock=True) + if envelope is None: + return None + await self._repo.lock_setup_slot_scope(key) + slot = await self._repo.setup_slot(key, lock=True) + if slot is None: + return None + if slot.envelope_id != envelope.id or slot.request_sha256 != request_sha256: + raise ModelCallConflict("setup_model_request_conflict") + effect = await self._repo.latest_setup_effect(slot.id, lock=True) + if effect is None or effect.state == "prepared": + return None + if effect.state == "uncertain": + if effect.uncertainty_digest is None: + raise ModelCallUnavailable("setup_model_effect_not_replayable") + return ObservedSetupModelUsageEffect( + effect=_effect(slot, effect), + uncertainty_digest=effect.uncertainty_digest, + provider_response_id=effect.provider_response_id, + replayed=True, + ) + if effect.state not in {"completed", "known_failed"}: + raise ModelCallUnavailable("setup_model_effect_not_replayable") + return self._completed(effect, slot, replayed=True) + + async def reserve_dispatch( + self, + prepared: PreparedSetupModelUsageEffect, + request_evidence: ModelCallEvidence, + ) -> SetupModelDispatchReservation: + """Reserve one actual setup provider dispatch under run and project caps.""" + require_model_call_transaction(self._session) + prepared = PreparedSetupModelUsageEffect.model_validate(prepared) + request_evidence = ModelCallEvidence.model_validate(request_evidence) + facts = { + "prepared": prepared.model_dump(mode="json"), + "request_evidence": request_evidence.model_dump(mode="json"), + } + authority = await self._authority.authorize_setup_dispatch(prepared, request_evidence) + _require_authority("setup_dispatch", facts, authority) + key = prepared.slot_key + owner_profile = await self._owner.require_setup_current(key) + await self._evidence.require_setup_request(prepared, request_evidence) + profile = await self._profile(key, owner_profile) + if ( + request_evidence.content_sha256 != prepared.request_sha256 + or request_evidence.byte_count > profile.spec.maximum_request_bytes + ): + raise ModelCallConflict("setup_model_request_evidence_conflict") + + await self._repo.lock_setup_envelope_scope(key) + envelope = await self._repo.setup_envelope(key, lock=True) + if envelope is None or envelope.id != str(prepared.envelope_id): + raise ModelCallUnavailable("setup_model_envelope_unavailable") + await self._repo.lock_setup_slot_scope(key) + slot = await self._repo.setup_slot(key, lock=True) + if slot is None or slot.id != str(prepared.slot_id): + raise ModelCallUnavailable("setup_model_effect_unavailable") + effect = await self._repo.attempt(prepared.effect_id, lock=True) + if effect is None or effect.setup_slot_id != slot.id: + raise ModelCallUnavailable("setup_model_effect_unavailable") + if effect.state != "prepared": + if effect.request_content_sha256 != request_evidence.content_sha256: + raise ModelCallConflict("setup_model_dispatch_conflict") + if effect.state != "dispatching": + raise ModelCallConflict("setup_model_effect_not_dispatchable") + return self._dispatch_result(envelope, slot, effect, key, request_evidence, True) + + totals = await self._repo.setup_envelope_totals(envelope.id) + reservation = profile.spec.maximum_dispatch_reservation_nanodollars + if ( + totals.dispatches >= profile.spec.maximum_calls + or totals.reserved_nanodollars + totals.settled_nanodollars + reservation + > profile.spec.maximum_reservation_nanodollars + or totals.request_bytes + request_evidence.byte_count + > profile.spec.maximum_total_request_bytes + or totals.response_bytes + + (totals.pending_dispatches + 1) * profile.spec.maximum_response_bytes + > profile.spec.maximum_total_response_bytes + or totals.input_tokens + + (totals.pending_dispatches + 1) * profile.spec.maximum_input_tokens + > profile.spec.maximum_total_input_tokens + or totals.output_tokens + + (totals.pending_dispatches + 1) * profile.spec.maximum_output_tokens + > profile.spec.maximum_total_output_tokens + ): + raise ModelCallBudgetUnavailable("model_run_envelope_unavailable") + await self._repo.lock_budget_scope(key.project_id) + account = await self._repo.budget_account(key.project_id, lock=True) + if account is None: + raise ModelCallBudgetUnavailable("model_budget_unavailable") + budget = await self._repo.current_budget_configuration(account) + if budget is None or ( + account.lifetime_reserved_nanodollars + + account.lifetime_settled_nanodollars + + reservation + > budget.lifetime_limit_nanodollars + ): + raise ModelCallBudgetUnavailable("model_budget_unavailable") + + for field, value in request_evidence.model_dump( + mode="json", exclude={"media_type"} + ).items(): + setattr(effect, f"request_{field}", value) + effect.budget_configuration_id = budget.id + effect.budget_configuration_version = budget.configuration_version + effect.budget_configuration_digest = budget.configuration_digest + effect.reserved_nanodollars = reservation + effect.dispatch_authorization_evidence_id = str(authority.evidence_id) + effect.dispatched_at = await self._repo.now() + effect.state = "dispatching" + account.lifetime_reserved_nanodollars += reservation + await self._repo.flush() + return self._dispatch_result(envelope, slot, effect, key, request_evidence, False) + + @staticmethod + def _dispatch_result( + envelope: SetupModelUsageEnvelope, + slot: SetupModelUsageSlot, + effect: ModelUsageEffect, + key: SetupModelUsageSlotKey, + evidence: ModelCallEvidence, + replayed: bool, + ) -> SetupModelDispatchReservation: + return SetupModelDispatchReservation( + envelope_id=UUID(envelope.id), + slot_id=UUID(slot.id), + slot_key=key, + effect_id=UUID(effect.id), + attempt_number=effect.attempt_number, + internal_call_request_id=UUID(effect.internal_call_request_id), + effect=_effect(slot, effect), + request_sha256=effect.request_sha256, + request_evidence=evidence, + budget_configuration_id=UUID(effect.budget_configuration_id), + budget_configuration_version=effect.budget_configuration_version, + budget_configuration_digest=effect.budget_configuration_digest, + reserved_nanodollars=effect.reserved_nanodollars, + replayed=replayed, + ) + + async def mark_uncertain( + self, + supplied: SetupModelEffectAuthorityFacts, + uncertainty_digest: str, + *, + provider_response_id: str | None = None, + ) -> ObservedSetupModelUsageEffect: + """Retain an ambiguous setup provider effect without releasing its reservation.""" + require_model_call_transaction(self._session) + supplied = SetupModelEffectAuthorityFacts.model_validate(supplied) + uncertainty_digest = _sha(uncertainty_digest) + if provider_response_id is not None: + provider_response_id = _PROVIDER_RESPONSE_ADAPTER.validate_python( + provider_response_id, strict=True + ) + authority = await self._authority.authorize_setup_observe( + supplied, provider_response_id, uncertainty_digest + ) + facts = { + "effect": supplied.model_dump(mode="json"), + "provider_response_id": provider_response_id, + "observation_digest": uncertainty_digest, + } + _require_authority("setup_observe", facts, authority) + row, slot, _ = await self._terminal_preflight(supplied.effect_id) + row = await self._lock_terminal(slot, supplied.effect_id) + _require_effect(supplied, slot, row) + if row.state == "uncertain": + if ( + row.uncertainty_digest != uncertainty_digest + or row.provider_response_id != provider_response_id + ): + raise ModelCallConflict("setup_model_uncertainty_conflict") + replayed = True + else: + if row.state != "dispatching": + raise ModelCallConflict("setup_model_uncertainty_conflict") + row.state = "uncertain" + row.uncertainty_digest = uncertainty_digest + row.provider_response_id = provider_response_id + row.observation_authorization_evidence_id = str(authority.evidence_id) + await self._repo.flush() + replayed = False + return ObservedSetupModelUsageEffect( + effect=_effect(slot, row), + uncertainty_digest=row.uncertainty_digest, + provider_response_id=row.provider_response_id, + replayed=replayed, + ) + + async def complete( + self, + supplied: SetupModelEffectAuthorityFacts, + *, + provider_response_id: str, + provider_model_snapshot: str, + response_evidence: ModelCallEvidence, + usage: ModelCallUsage, + usable: bool = True, + ) -> CompletedSetupModelUsageEffect: + """Settle one verified setup response under narrow retained-effect authority.""" + require_model_call_transaction(self._session) + supplied = SetupModelEffectAuthorityFacts.model_validate(supplied) + provider_response_id = _PROVIDER_RESPONSE_ADAPTER.validate_python( + provider_response_id, strict=True + ) + response_evidence = ModelCallEvidence.model_validate(response_evidence) + usage = ModelCallUsage.model_validate(usage) + terminal_digest = canonical_json_hash( + { + "status": "completed", + "provider_response_id": provider_response_id, + "provider_model_snapshot": provider_model_snapshot, + "response_evidence": response_evidence.model_dump(mode="json"), + "usage": usage.model_dump(mode="json"), + "usable": usable, + } + ) + authority = await self._authority.authorize_setup_complete( + supplied, response_evidence, terminal_digest + ) + _require_authority( + "setup_complete", + { + "effect": supplied.model_dump(mode="json"), + "response_evidence": response_evidence.model_dump(mode="json"), + "terminal_digest": terminal_digest, + }, + authority, + ) + await self._evidence.require_setup_response(supplied.effect_id, response_evidence) + row, slot, profile = await self._terminal_preflight(supplied.effect_id) + if ( + provider_model_snapshot != profile.spec.model_snapshot + or response_evidence.byte_count > profile.spec.maximum_response_bytes + ): + raise ModelCallConflict("setup_model_response_conflict") + cost = model_usage_cost(profile.spec, usage) + row = await self._lock_terminal(slot, supplied.effect_id) + _require_effect(supplied, slot, row) + target_state = "completed" if usable else "known_failed" + if row.state in {"completed", "known_failed"}: + if ( + row.state != target_state + or row.provider_response_id != provider_response_id + or _evidence(row, "response") != response_evidence + or _usage(row) != usage + or row.settled_nanodollars != cost + ): + raise ModelCallConflict("setup_model_terminal_conflict") + return self._completed(row, slot, replayed=True) + self._require_pending(row) + if row.provider_response_id not in {None, provider_response_id}: + raise ModelCallConflict("setup_model_provider_response_conflict") + await self._settle(row, slot, cost) + self._set_evidence(row, "response", response_evidence) + self._set_usage(row, usage) + row.provider_response_id = provider_response_id + row.settled_nanodollars = cost + if target_state == "known_failed": + row.settlement_authorization_evidence_id = str(authority.evidence_id) + row.settled_at = await self._repo.now() + row.terminal_authorization_evidence_id = str(authority.evidence_id) + row.terminal_at = await self._repo.now() + row.state = target_state + await self._repo.flush() + return self._completed(row, slot, replayed=False) + + async def mark_known_no_effect( + self, + supplied: SetupModelEffectAuthorityFacts, + proof: KnownNoEffectProof, + ) -> KnownNoEffectSetupModelUsageEffect: + """Release only an authenticated setup dispatch proven to have no effect.""" + require_model_call_transaction(self._session) + supplied = SetupModelEffectAuthorityFacts.model_validate(supplied) + proof = KnownNoEffectProof.model_validate(proof) + terminal_digest = model_retry_terminal_digest(proof) + authority = await self._authority.authorize_setup_complete(supplied, None, terminal_digest) + _require_authority( + "setup_complete", + { + "effect": supplied.model_dump(mode="json"), + "response_evidence": None, + "terminal_digest": terminal_digest, + }, + authority, + ) + _, slot, _ = await self._terminal_preflight(supplied.effect_id) + row = await self._lock_terminal(slot, supplied.effect_id) + _require_effect(supplied, slot, row) + if row.state == "known_no_effect": + if ( + row.no_effect_proof_id != str(proof.proof_id) + or row.no_effect_proof_digest != proof.proof_digest + or row.no_effect_observed_at != proof.observed_at + ): + raise ModelCallConflict("setup_model_terminal_conflict") + replayed = True + else: + self._require_pending(row) + if row.provider_response_id is not None: + raise ModelCallConflict("setup_model_no_effect_conflict") + await self._release(row, slot) + row.no_effect_proof_id = str(proof.proof_id) + row.no_effect_proof_digest = proof.proof_digest + row.no_effect_observed_at = proof.observed_at + row.terminal_authorization_evidence_id = str(authority.evidence_id) + row.terminal_at = await self._repo.now() + row.state = "known_no_effect" + await self._repo.flush() + replayed = False + return KnownNoEffectSetupModelUsageEffect( + effect=_effect(slot, row), proof=proof, replayed=replayed + ) + + async def _terminal_preflight(self, effect_id: UUID): + row = await self._repo.attempt(effect_id) + if row is None or row.setup_slot_id is None: + raise ModelCallUnavailable("setup_model_effect_unavailable") + slot = await self._session.get(SetupModelUsageSlot, row.setup_slot_id) + if slot is None: + raise ModelCallUnavailable("setup_model_effect_unavailable") + return row, slot, await self._profile(_slot_key(slot)) + + async def _lock_terminal(self, slot: SetupModelUsageSlot, effect_id: UUID) -> ModelUsageEffect: + key = _slot_key(slot) + await self._repo.lock_setup_envelope_scope(key) + if await self._repo.setup_envelope(key, lock=True) is None: + raise ModelCallUnavailable("setup_model_envelope_unavailable") + await self._repo.lock_setup_slot_scope(key) + locked_slot = await self._repo.setup_slot(key, lock=True) + if locked_slot is None or locked_slot.id != slot.id: + raise ModelCallUnavailable("setup_model_effect_unavailable") + row = await self._repo.attempt(effect_id, lock=True) + if row is None or row.setup_slot_id != slot.id: + raise ModelCallUnavailable("setup_model_effect_unavailable") + return row + + @staticmethod + def _require_pending(row: ModelUsageEffect) -> None: + if row.state not in {"dispatching", "uncertain"}: + raise ModelCallConflict("setup_model_terminal_conflict") + if row.reserved_nanodollars is None: + raise ModelCallUnavailable("model_budget_unavailable") + + async def _settle(self, row: ModelUsageEffect, slot: SetupModelUsageSlot, cost: int) -> None: + await self._repo.lock_budget_scope(UUID(slot.project_id)) + account = await self._repo.budget_account(UUID(slot.project_id), lock=True) + if account is None or row.reserved_nanodollars is None: + raise ModelCallUnavailable("model_budget_unavailable") + if account.lifetime_reserved_nanodollars < row.reserved_nanodollars: + raise ModelCallUnavailable("model_budget_conservation_failed") + account.lifetime_reserved_nanodollars -= row.reserved_nanodollars + account.lifetime_settled_nanodollars += cost + + async def _release(self, row: ModelUsageEffect, slot: SetupModelUsageSlot) -> None: + await self._repo.lock_budget_scope(UUID(slot.project_id)) + account = await self._repo.budget_account(UUID(slot.project_id), lock=True) + if account is None or row.reserved_nanodollars is None: + raise ModelCallUnavailable("model_budget_unavailable") + if account.lifetime_reserved_nanodollars < row.reserved_nanodollars: + raise ModelCallUnavailable("model_budget_conservation_failed") + account.lifetime_reserved_nanodollars -= row.reserved_nanodollars + + @staticmethod + def _set_evidence(row: ModelUsageEffect, prefix: str, evidence: ModelCallEvidence) -> None: + for field, value in evidence.model_dump(mode="json", exclude={"media_type"}).items(): + setattr(row, f"{prefix}_{field}", value) + + @staticmethod + def _set_usage(row: ModelUsageEffect, usage: ModelCallUsage) -> None: + row.input_tokens = usage.input_tokens + row.cached_input_tokens = usage.cached_input_tokens + row.output_tokens = usage.output_tokens + row.reasoning_tokens = usage.reasoning_tokens + + @staticmethod + def _completed( + row: ModelUsageEffect, + slot: SetupModelUsageSlot, + *, + replayed: bool, + ) -> CompletedSetupModelUsageEffect: + usage = _usage(row) + if usage is None or row.terminal_at is None or row.settled_nanodollars is None: + raise ModelCallUnavailable("setup_model_completion_unavailable") + return CompletedSetupModelUsageEffect( + effect=_effect(slot, row), + provider_response_id=row.provider_response_id, + request_evidence=_evidence(row, "request"), + response_evidence=_evidence(row, "response"), + usage=usage, + settled_nanodollars=row.settled_nanodollars, + completed_at=row.terminal_at, + state=row.state, + replayed=replayed, + ) + + +__all__ = ("SetupModelUsageService",) diff --git a/backend/app/modules/projects/api/guide_documents.py b/backend/app/modules/projects/api/guide_documents.py index cbd9146f4..48b5b60e7 100644 --- a/backend/app/modules/projects/api/guide_documents.py +++ b/backend/app/modules/projects/api/guide_documents.py @@ -2,18 +2,20 @@ from __future__ import annotations +import hashlib from contextlib import AbstractAsyncContextManager from dataclasses import dataclass from datetime import datetime from typing import BinaryIO, Literal, Protocol from uuid import UUID -import hashlib from pydantic import BaseModel, ConfigDict, Field, StrictInt, model_validator from app.core.hashing import canonical_json_hash from app.interfaces.external_services import ExternalServiceAdapter from app.interfaces.guide_document_tools import GuideDocumentMediaType, GuideDocumentToolPort +from app.interfaces.setup_model_proxy import SetupModelDispatchPort + DOCUMENT_EXTENSIONS: dict[str, str] = { "application/pdf": "pdf", "application/vnd.openxmlformats-officedocument.wordprocessingml.document": "docx", @@ -212,6 +214,7 @@ class GuideRuntimeCapabilities: """The sole non-serializable capability issued to an agent runtime.""" tools: GuideDocumentToolPort + model: SetupModelDispatchPort | None = None class GuideDocumentAccessFactory(Protocol): diff --git a/backend/app/modules/projects/guide_activation/service.py b/backend/app/modules/projects/guide_activation/service.py index 6a1dfd876..4e9e76713 100644 --- a/backend/app/modules/projects/guide_activation/service.py +++ b/backend/app/modules/projects/guide_activation/service.py @@ -9,9 +9,12 @@ from app.interfaces.project_agents import RequirementDisposition from app.modules.authorization.api import ActorKind from app.modules.checkers.api.external_policy import ( + ExternalCheckerPolicyError, + require_locked_model_profiles, validate_stored_external_checker_policies, ) from app.modules.checkers.api.external import ExternalCheckerRegistryPort +from app.modules.model_usage.api import ModelProfileReaderPort from app.modules.projects.api.guide_activation import ( GuideActivationCommand, GuideActivationCondition, @@ -53,6 +56,7 @@ def __init__( contribution, authorization=None, registry: ExternalCheckerRegistryPort | None = None, + profiles: ModelProfileReaderPort | None = None, ): self.session = session self.authorization = authorization @@ -61,6 +65,7 @@ def __init__( self.replay = GuideMutationRepository(session) self.post = PostPolicyRepository(session) self.registry = registry + self.profiles = profiles async def activate( self, command: GuideActivationCommand, *, actor, request_id: UUID @@ -124,7 +129,11 @@ async def activate( command.contribution_policy_version_id, ), ) - guide, previous, contribution = checked.guide, checked.previous, checked.contribution + guide, previous, contribution = ( + checked.guide, + checked.previous, + checked.contribution, + ) operation_id = new_record_id() receipt = GuideActivationReceipt( operation_id=operation_id, @@ -232,7 +241,10 @@ async def readiness( expected=None, contribution_selection=None if published is None - else (published.contribution_policy_id, published.contribution_policy_version_id), + else ( + published.contribution_policy_id, + published.contribution_policy_version_id, + ), ) except GuideProposalError as exc: if exc.code not in _PRODUCT_REFUSALS: @@ -277,8 +289,12 @@ async def _evaluate( # The lock admits only a draft guide; name that cause with the # lock's own refusal code, which activation raises unchanged. guide = await self.projects.get_guide(str(selection.guide_id)) - conditions.fail(exc.code, "guide_not_draft" if guide is not None and guide.status != "draft" - else "post_policy_unavailable") + conditions.fail( + exc.code, + "guide_not_draft" + if guide is not None and guide.status != "draft" + else "post_policy_unavailable", + ) return None try: await self.post.require_current_upstream(locked) @@ -364,6 +380,14 @@ async def _readiness(self, conditions, locked, policy, post, review, revision): ) if policies.pre_submit.model_dump(mode="json") != approval.pre.compiled_bundle: conditions.fail("approval_blocked", "checker_policy_mismatch") + try: + await require_locked_model_profiles( + policies, + project_id=UUID(locked.view.guide.project_id), + profiles=self.profiles, + ) + except ExternalCheckerPolicyError: + conditions.fail("approval_blocked", "checker_model_profile_unavailable") for binding in (*policies.pre_submit.entries, *policies.post_submit.entries): current = await self.registry.read_exact( binding.registry.registry_entry_id, diff --git a/backend/app/modules/projects/guide_compilation/correction_dispatch.py b/backend/app/modules/projects/guide_compilation/correction_dispatch.py index 7ea1d5bcb..3add66ee6 100644 --- a/backend/app/modules/projects/guide_compilation/correction_dispatch.py +++ b/backend/app/modules/projects/guide_compilation/correction_dispatch.py @@ -1,12 +1,16 @@ """Publish a committed human correction through the existing setup queue.""" +from dataclasses import replace from uuid import UUID +from app.interfaces.project_guide_runtime import ProjectGuideRuntimeConfiguration +from app.modules.model_usage.api import ModelCallUnavailable, ModelProfileReaderPort + from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.modules.authorization.api import ActorIdentityFacts, ProjectGuideCompilationAuthorizationPort from app.modules.projects.api.guide_proposals import GuideProposalSelection -from .request_inputs import CompilationRequestInputs +from .request_inputs import CompilationRequestInputs, select_model_runtime_configuration from sqlalchemy.exc import SQLAlchemyError from app.modules.authorization.api import AuthorizationDenied, AuthorizationUnavailable, PreparedAuthorizationInvalid @@ -15,7 +19,7 @@ from app.modules.projects.models import ProjectSetupRun from app.modules.projects.setup_queue import dispatch_project_guide_compilation_after_commit -from .models import ProjectGuideProposalCorrection +from .models import ProjectGuideCompilationAttempt, ProjectGuideProposalCorrection from .repository import GuideCompilationIntegrityError, GuideCompilationStorageError from .service import GuideCompilationService from .diagnostics import compilation_setup_response @@ -26,10 +30,12 @@ class GuideCorrectionDispatchService: def __init__( self, session: AsyncSession, authorization: ProjectGuideCompilationAuthorizationPort, - inputs: CompilationRequestInputs, + inputs: CompilationRequestInputs, *, profiles: ModelProfileReaderPort, ) -> None: self.session = session - self.requests = GuideCompilationService(session, authorization, request_inputs=inputs) + self.authorization = authorization + self.inputs = inputs + self.profiles = profiles async def dispatch( self, selection: GuideProposalSelection, correction_operation_id: UUID, *, @@ -51,7 +57,11 @@ async def dispatch( setup_id = correction.successor_setup_run_id generation = correction.successor_setup_generation source_id = correction.target_json["source_snapshot_id"] - await self.requests.request_correction(actor=actor, correction_operation_id=correction_operation_id) + inputs = await self._selected_inputs(selection.project_id, setup_id, generation) + requests = GuideCompilationService( + self.session, self.authorization, request_inputs=inputs + ) + await requests.request_correction(actor=actor, correction_operation_id=correction_operation_id) task_id = project_guide_compilation_task_id(setup_id, generation) await dispatch_project_guide_compilation_after_commit( self.session, project_id=str(selection.project_id), guide_id=str(selection.guide_id), @@ -70,9 +80,33 @@ async def dispatch( correction_operation_id=correction_operation_id, setup_run_id=setup_id, setup_generation=generation, status=state.status, ) + except ModelCallUnavailable: + raise GuideProposalError("proposal_unavailable") from None except (AuthorizationDenied, AuthorizationUnavailable, PreparedAuthorizationInvalid): raise GuideProposalError("authority_unavailable") from None except (SQLAlchemyError, GuideCompilationStorageError): raise GuideProposalError("storage_unavailable") from None except GuideCompilationIntegrityError: raise GuideProposalError("operation_conflict") from None + + async def _selected_inputs( + self, project_id: UUID, setup_id: str, generation: int, + ) -> CompilationRequestInputs: + """Reuse a retained attempt's exact profile; select only for a fresh request.""" + attempt = await self.session.scalar( + select(ProjectGuideCompilationAttempt).where( + ProjectGuideCompilationAttempt.setup_run_id == setup_id, + ProjectGuideCompilationAttempt.setup_generation == generation, + ) + ) + if attempt is not None: + configuration = ProjectGuideRuntimeConfiguration.model_validate( + attempt.runtime_configuration + ) + else: + if self.inputs.runtime_configuration is None: + raise GuideCompilationIntegrityError("compilation runtime configuration unavailable") + configuration = await select_model_runtime_configuration( + self.inputs.runtime_configuration, self.profiles, project_id + ) + return replace(self.inputs, runtime_configuration=configuration) diff --git a/backend/app/modules/projects/guide_compilation/live.py b/backend/app/modules/projects/guide_compilation/live.py index e66df72db..49192f5d9 100644 --- a/backend/app/modules/projects/guide_compilation/live.py +++ b/backend/app/modules/projects/guide_compilation/live.py @@ -7,40 +7,44 @@ from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker -from app.modules.projects.api.guide_documents import GuideDocumentManifestPort -from app.modules.projects.api.task_examples import ( - GuideTaskExampleInputError, - require_task_example_commitment, -) from app.interfaces.project_guide_runtime import ProjectGuideRuntimeConfiguration from app.modules.authorization.api import ( ActorIdentityFacts, ProjectGuideCompilationAuthorizationPort, ) from app.modules.checkers.api.external import ExternalCheckerCapabilityProjection +from app.modules.model_usage.api import ModelProfileReaderPort from app.modules.projects.api.guide_compilation import ( ProjectGuideCompilationDelivery, ProjectGuideCompilationDeliveryError, + ProjectGuideCompilationExecutionClassification, ProjectGuideCompilationExecutionCommand, ProjectGuideCompilationExecutionPort, - ProjectGuideCompilationExecutionClassification, ProjectGuideSetupFinalizationCommand, ) from app.modules.projects.api.guide_compilation_projections import ProjectGuideProjectionCommand +from app.modules.projects.api.guide_documents import GuideDocumentManifestPort from app.modules.projects.api.setup_identity import project_guide_compilation_task_id -from app.modules.projects.repository import ProjectRepository +from app.modules.projects.api.task_examples import ( + GuideTaskExampleInputError, + require_task_example_commitment, +) from app.modules.projects.models import ProjectSetupRun -from .source_state import is_compilation_source_setup +from app.modules.projects.repository import ProjectRepository + from .diagnostics import compilation_setup_response -from .request_inputs import CompilationRequestInputs, automatic_request_selector +from .finalization import GuideCompilationFinalizationService from .models import ( - ProjectGuideCompilationAttempt, ProjectGuideCompilation, + ProjectGuideCompilationAttempt, ProjectGuideSetupFinalization, ) from .repository import GuideCompilationIntegrityError +from .request_inputs import ( + CompilationRequestInputs, automatic_request_selector, select_model_runtime_configuration, +) from .service import GuideCompilationService -from .finalization import GuideCompilationFinalizationService +from .source_state import is_compilation_source_setup RequestAuthority = Callable[ [AsyncSession, UUID], @@ -61,6 +65,7 @@ def __init__( checker_capabilities: ExternalCheckerCapabilityProjection, request_authority: RequestAuthority, configuration_factory: Callable[[], ProjectGuideRuntimeConfiguration], + model_profiles: Callable[[AsyncSession], ModelProfileReaderPort], execution: ProjectGuideCompilationExecutionPort, projections, finalization_authorization_factory, @@ -70,6 +75,7 @@ def __init__( self._checker_capabilities = checker_capabilities self._request_authority = request_authority self._configuration = configuration_factory + self._model_profiles = model_profiles self._execution = execution self._projections = projections self._finalization_authorization = finalization_authorization_factory @@ -88,11 +94,7 @@ async def run(self, delivery: ProjectGuideCompilationDelivery) -> dict: return await self._finalize(finalization) if has_attempt and snapshot is None: raise GuideCompilationIntegrityError("compilation runtime configuration unavailable") - configuration = ( - ProjectGuideRuntimeConfiguration.model_validate(snapshot) - if snapshot is not None - else self._configuration() - ) + configuration = ProjectGuideRuntimeConfiguration.model_validate(snapshot) if manual_attempt is None: operation_id = automatic_request_selector( delivery.setup_run_id, delivery.setup_generation @@ -232,13 +234,27 @@ async def _admit(self, delivery: ProjectGuideCompilationDelivery): from .delivery_request import manual_delivery_attempt manual_attempt = await manual_delivery_attempt(session, setup, attempt) + runtime_configuration = ( + attempt.runtime_configuration + if attempt is not None + else (await self._select_runtime_configuration(session, delivery.project_id)) + .model_dump(mode="json") + ) return ( None, attempt is not None, - attempt.runtime_configuration if attempt else None, + runtime_configuration, manual_attempt, ) + async def _select_runtime_configuration( + self, session: AsyncSession, project_id: UUID + ) -> ProjectGuideRuntimeConfiguration: + """Snapshot the newest exact Operator profile while PROJECTS custody is held.""" + return await select_model_runtime_configuration( + self._configuration(), self._model_profiles(session), project_id + ) + async def _finalize(self, command: ProjectGuideSetupFinalizationCommand) -> dict: """Create or replay the existing finalizer's receipt under fresh authority.""" async with self._sessions() as session, session.begin(): diff --git a/backend/app/modules/projects/guide_compilation/model_usage.py b/backend/app/modules/projects/guide_compilation/model_usage.py new file mode 100644 index 000000000..60a2c9978 --- /dev/null +++ b/backend/app/modules/projects/guide_compilation/model_usage.py @@ -0,0 +1,67 @@ +"""PROJECTS-owned currentness for shared setup model-use operations.""" + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.interfaces.project_guide_runtime import ProjectGuideRuntimeConfiguration +from app.modules.model_usage.api import ( + ModelCallUnavailable, + ModelProfileSelector, + SetupModelOwnerProfile, + SetupModelUsageSlotKey, +) +from app.modules.projects.guide_compilation.models import ProjectGuideCompilationAttempt + + +class SetupModelUsageCurrent: + """Lock the exact compilation attempt before any shared MODEL-USAGE lock.""" + + def __init__(self, session: AsyncSession) -> None: + self._session = session + + async def require_setup_current(self, key: SetupModelUsageSlotKey) -> SetupModelOwnerProfile: + key = SetupModelUsageSlotKey.model_validate(key) + attempt = await self._session.scalar( + select(ProjectGuideCompilationAttempt) + .where( + ProjectGuideCompilationAttempt.id == key.compilation_attempt_id, + ProjectGuideCompilationAttempt.project_id == str(key.project_id), + ProjectGuideCompilationAttempt.guide_id == str(key.guide_id), + ProjectGuideCompilationAttempt.source_snapshot_id == str(key.source_snapshot_id), + ProjectGuideCompilationAttempt.setup_run_id == str(key.setup_run_id), + ProjectGuideCompilationAttempt.setup_generation == key.setup_generation, + ProjectGuideCompilationAttempt.runtime_configuration_hash + == key.runtime_configuration_hash, + ProjectGuideCompilationAttempt.status == "compilation_provider_uncertain", + ) + .with_for_update() + .execution_options(populate_existing=True) + ) + if attempt is None: + raise ModelCallUnavailable("setup_model_owner_unavailable") + try: + configuration = ProjectGuideRuntimeConfiguration.model_validate( + attempt.runtime_configuration + ) + selector = ModelProfileSelector( + profile_id=configuration.model_profile_id, + profile_version=configuration.model_profile_version, + profile_digest=configuration.model_profile_digest, + ) + except ValueError as exc: + raise ModelCallUnavailable("setup_model_owner_unavailable") from exc + if selector != ModelProfileSelector( + profile_id=key.profile_id, + profile_version=key.profile_version, + profile_digest=key.profile_digest, + ): + raise ModelCallUnavailable("setup_model_owner_unavailable") + return SetupModelOwnerProfile( + selector=selector, + provider=configuration.model_provider, + api=configuration.model_api, + model_snapshot=configuration.model, + ) + + +__all__ = ("SetupModelUsageCurrent",) diff --git a/backend/app/modules/projects/guide_compilation/models.py b/backend/app/modules/projects/guide_compilation/models.py index 129642e8a..9720dc82b 100644 --- a/backend/app/modules/projects/guide_compilation/models.py +++ b/backend/app/modules/projects/guide_compilation/models.py @@ -100,6 +100,16 @@ class ProjectGuideCompilationAttempt(Base): "setup_generation", name="uq_compilation_attempt_exact_request", ), + UniqueConstraint( + "id", + "project_id", + "guide_id", + "source_snapshot_id", + "setup_run_id", + "setup_generation", + "runtime_configuration_hash", + name="uq_compilation_attempt_model_usage_owner", + ), CheckConstraint( "status in ('compilation_reserved','compilation_provider_uncertain'," "'provider_result_accepted','compilation_invalid_terminal'," diff --git a/backend/app/modules/projects/guide_compilation/orchestrator.py b/backend/app/modules/projects/guide_compilation/orchestrator.py index 22704aa7e..ec428840e 100644 --- a/backend/app/modules/projects/guide_compilation/orchestrator.py +++ b/backend/app/modules/projects/guide_compilation/orchestrator.py @@ -12,21 +12,23 @@ from sqlalchemy.exc import SQLAlchemyError from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker - -from app.modules.projects.api.guide_documents import ( - GuideDocumentManifestPort, - GuideDocumentUnavailable, -) +from app.interfaces.external_services import ExternalServiceAdapterError from app.interfaces.project_agents import ( + ProjectAgentRuntimeError, ProjectGuideAgentRuntime, ProjectGuideCompilationContext, ProjectGuideCompilationInvalidOutputError, ProjectGuideCompilationResult, - require_complete_project_guide_compilation_result, + project_guide_compilation_prompt_bytes, require_complete_generated_specifications, + require_complete_project_guide_compilation_result, validate_project_guide_compilation_result, ) -from app.modules.checkers.api.external import ExternalCheckerCapabilityProjection +from app.interfaces.project_guide_runtime import ProjectGuideRuntimeConfiguration +from app.interfaces.setup_model_proxy import ( + SetupModelDispatchPort, + SetupModelProxySubject, +) from app.modules.authorization.api import ( ActorIdentityFacts, AuthorizationDenied, @@ -34,6 +36,7 @@ PreparedAuthorizationInvalid, ProjectGuideCompilationAuthorizationPort, ) +from app.modules.checkers.api.external import ExternalCheckerCapabilityProjection from app.modules.projects.api import ( ProjectGuideCompilationExecutionClassification, ProjectGuideCompilationExecutionCommand, @@ -41,6 +44,12 @@ ProjectGuideCompilationExecutionPort, ProjectGuideCompilationExecutionResult, ) +from app.modules.projects.api.guide_documents import ( + GuideDocumentManifestPort, + GuideDocumentToolsFactory, + GuideDocumentUnavailable, + GuideRuntimeCapabilities, +) from .context import build_project_guide_compilation_context from .contracts import ( @@ -56,16 +65,6 @@ GuideCompilationService, load_compilation_execution_state, ) -from app.interfaces.project_guide_runtime import ProjectGuideRuntimeConfiguration -from app.modules.projects.api.guide_documents import ( - GuideDocumentToolsFactory, - GuideRuntimeCapabilities, -) -from app.interfaces.external_services import ExternalServiceAdapterError -from app.interfaces.project_agents import ( - ProjectAgentRuntimeError, - project_guide_compilation_prompt_bytes, -) class GuideCompilationAuthorizationContext(Protocol): @@ -95,6 +94,10 @@ def __init__( checker_capabilities: ExternalCheckerCapabilityProjection, authorization_context: GuideCompilationAuthorizationContext, document_tools_factory: GuideDocumentToolsFactory, + model_session_factory: Callable[ + [SetupModelProxySubject | None, ProjectGuideRuntimeConfiguration], + AbstractAsyncContextManager[SetupModelDispatchPort | None], + ] | None = None, ) -> None: """Store the owner-supplied ports used by each short transaction.""" self._session_factory = session_factory @@ -102,16 +105,26 @@ def __init__( self._checker_capabilities = checker_capabilities self._authorization_context = authorization_context self._document_tools_factory = document_tools_factory + self._model_session_factory = model_session_factory or _no_model_session + self._model_session_configured = model_session_factory is not None @asynccontextmanager async def capabilities(self, state, context): """Issue resources only from the orchestrator's winning dispatch branch.""" - async with self._document_tools_factory( - state.preflight_facts.attempt_id, - context.material, - context.runtime_configuration, - ) as tools: - yield GuideRuntimeCapabilities(tools=tools) + model_subject = ( + _setup_model_subject(state, context.runtime_configuration) + if self._model_session_configured + else None + ) + async with ( + self._document_tools_factory( + state.preflight_facts.attempt_id, + context.material, + context.runtime_configuration, + ) as tools, + self._model_session_factory(model_subject, context.runtime_configuration) as model, + ): + yield GuideRuntimeCapabilities(tools=tools, model=model) async def load(self, attempt_id: UUID) -> CompilationExecutionState: """Load one exact attempt and translate storage failures safely.""" @@ -225,6 +238,10 @@ def project_guide_compilation_execution_port( authorization_context: GuideCompilationAuthorizationContext, runtime_factory: Callable[[ProjectGuideRuntimeConfiguration], ProjectGuideAgentRuntime], document_tools_factory: GuideDocumentToolsFactory, + model_session_factory: Callable[ + [SetupModelProxySubject | None, ProjectGuideRuntimeConfiguration], + AbstractAsyncContextManager[SetupModelDispatchPort | None], + ] | None = None, ) -> ProjectGuideCompilationExecutionPort: """Compose the hidden port from existing owner-supplied dependencies.""" backend = SqlAlchemyGuideCompilationExecutionBackend( @@ -233,10 +250,47 @@ def project_guide_compilation_execution_port( checker_capabilities=checker_capabilities, authorization_context=authorization_context, document_tools_factory=document_tools_factory, + model_session_factory=model_session_factory, ) return GuideCompilationOrchestrator(backend, runtime_factory) +@asynccontextmanager +async def _no_model_session(_subject, _configuration): + """Keep provider-free and isolated owner tests free of production composition.""" + yield None + + +def _setup_model_subject( + state: CompilationExecutionState, + configuration: ProjectGuideRuntimeConfiguration, +) -> SetupModelProxySubject | None: + """Project exact attempt/profile lineage into the shared model capability.""" + if not configuration.uses_inference: + return None + if ( + configuration.model_profile_id is None + or configuration.model_profile_version is None + or configuration.model_profile_digest is None + ): + raise ProjectGuideCompilationExecutionError("runtime_unavailable") + return SetupModelProxySubject( + compilation_attempt_id=UUID(str(state.preflight_facts.attempt_id)), + project_id=UUID(str(state.identity.project_id)), + guide_id=UUID(str(state.identity.guide_id)), + source_snapshot_id=UUID(str(state.identity.source_snapshot_id)), + setup_run_id=UUID(str(state.identity.setup_run_id)), + setup_generation=state.identity.setup_generation, + runtime_configuration_hash=configuration.sha256, + profile_id=configuration.model_profile_id, + profile_version=configuration.model_profile_version, + profile_digest=configuration.model_profile_digest, + provider=configuration.model_provider, + api=configuration.model_api, + model=configuration.model, + ) + + class GuideCompilationExecutionBackend(Protocol): """Exact persistence/context operations required by the state machine.""" @@ -287,6 +341,7 @@ async def execute( ) -> ProjectGuideCompilationExecutionResult: """Execute or safely recover one previously authorized attempt.""" state = await self._backend.load(command.attempt_id) + resuming_metered = _metered_setup_resume(state) recovered = await self._recover(state) if recovered is not None: return recovered @@ -310,8 +365,8 @@ async def execute( await runtime.aclose() raise ProjectGuideCompilationExecutionError("runtime_unavailable") from None try: - dispatch = await self._backend.fence(state) - if not dispatch.dispatch_permitted: + dispatch = None if resuming_metered else await self._backend.fence(state) + if dispatch is not None and not dispatch.dispatch_permitted: raced_state = await self._backend.load(command.attempt_id) recovered = await self._recover(raced_state) if recovered is None: @@ -353,7 +408,9 @@ async def execute( # No model call exists to be uncertain about; reopen and retry. await self._backend.release_no_inference(state) raise ProjectGuideCompilationExecutionError("runtime_unavailable") from None - return _receipt_result(dispatch) + if dispatch is not None: + return _receipt_result(dispatch) + return _state_result(await self._backend.load(command.attempt_id)) if terminal_receipt is not None: return _receipt_result(terminal_receipt) return _receipt_result(await self._backend.persist(state, context)) @@ -373,6 +430,10 @@ async def _recover( stale_before = datetime.now(UTC) - timedelta(seconds=configuration.timeout_seconds) if await self._backend.release_no_inference(state, stale_before=stale_before): return None + if state.classification is CompilationRecoveryClassification.PROVIDER_UNCERTAIN and ( + _metered_setup_resume(state) + ): + return None if state.classification in { CompilationRecoveryClassification.PERSISTED, CompilationRecoveryClassification.INVALID_TERMINAL, @@ -385,6 +446,19 @@ async def _recover( return None +def _metered_setup_resume(state: CompilationExecutionState) -> bool: + """Resume only attempts whose every provider effect is guarded by MODEL-USAGE.""" + configuration = state.runtime_configuration + return ( + state.classification is CompilationRecoveryClassification.PROVIDER_UNCERTAIN + and configuration is not None + and configuration.uses_inference + and configuration.model_profile_id is not None + and configuration.model_profile_version is not None + and configuration.model_profile_digest is not None + ) + + def _require_valid_result( context: ProjectGuideCompilationContext, result: ProjectGuideCompilationResult, diff --git a/backend/app/modules/projects/guide_compilation/proposal_approval.py b/backend/app/modules/projects/guide_compilation/proposal_approval.py index 410ba637d..b3e15b6c1 100644 --- a/backend/app/modules/projects/guide_compilation/proposal_approval.py +++ b/backend/app/modules/projects/guide_compilation/proposal_approval.py @@ -18,6 +18,7 @@ from app.modules.checkers.api.policy_custody import CompiledPreSubmitCheckerPolicy from app.modules.checkers.api.external import ExternalCheckerRegistryPort from app.modules.checkers.api.external import ExternalCheckerCapabilityProjection +from app.modules.model_usage.api import ModelProfileReaderPort from app.modules.checkers.api.external_policy import ( LockedExternalCheckerPolicies, compile_external_checker_policy_bundle, @@ -64,6 +65,7 @@ async def approve_proposal( material, capabilities: ExternalCheckerCapabilityProjection, registry: ExternalCheckerRegistryPort, + profiles: ModelProfileReaderPort | None = None, ) -> GuideProposalApprovalReceipt: """Compile and persist in the caller transaction after exact authority closes.""" target = command.target @@ -110,6 +112,7 @@ async def approve_proposal( material, capabilities, registry, + profiles, ) receipt = outputs.receipt facts = _facts(locator, target, request_digest, receipt, command) @@ -207,6 +210,7 @@ async def _compile_approval( material, capabilities, registry, + profiles, ) -> _CompiledApproval: target = command.target inputs = CompilationRequestInputs( @@ -220,7 +224,12 @@ async def _compile_approval( locked.view.policy.policy_body, ) effective_hash = canonical_json_hash(effective_body) - policies = await compile_external_checker_policy_bundle(locked.result, registry) + policies = await compile_external_checker_policy_bundle( + locked.result, + registry, + project_id=target.project_id, + profiles=profiles, + ) external_pre = policies.pre_submit compiled = CompiledPreSubmitCheckerPolicy( compiler_version="external_checker_policy.v1", diff --git a/backend/app/modules/projects/guide_compilation/proposal_service.py b/backend/app/modules/projects/guide_compilation/proposal_service.py index 00374e2a3..8f5f1e507 100644 --- a/backend/app/modules/projects/guide_compilation/proposal_service.py +++ b/backend/app/modules/projects/guide_compilation/proposal_service.py @@ -32,6 +32,7 @@ from app.modules.projects.api.guide_documents import GuideDocumentManifestPort from app.modules.checkers.api.external import ExternalCheckerCapabilityProjection from app.modules.checkers.api.external import ExternalCheckerRegistryPort +from app.modules.model_usage.api import ModelProfileReaderPort from app.modules.projects.api.guide_proposal_package import GuideProposalReviewPackage from .proposal_repository import GuideProposalRepository @@ -46,11 +47,13 @@ def __init__( session: AsyncSession, authorization: GuideProposalAuthorizationPort, registry: ExternalCheckerRegistryPort | None = None, + profiles: ModelProfileReaderPort | None = None, ) -> None: self.session = session self.authorization = authorization self.repository = GuideProposalRepository(session) self.registry = registry + self.profiles = profiles async def review_package( self, @@ -120,6 +123,7 @@ async def approve( material=material, capabilities=capabilities, registry=self.registry, + profiles=self.profiles, ) def _require_transaction(self) -> None: diff --git a/backend/app/modules/projects/guide_compilation/request_inputs.py b/backend/app/modules/projects/guide_compilation/request_inputs.py index 05676ac3b..4650409cf 100644 --- a/backend/app/modules/projects/guide_compilation/request_inputs.py +++ b/backend/app/modules/projects/guide_compilation/request_inputs.py @@ -30,6 +30,7 @@ from .repository import GuideCompilationIntegrityError, GuideCompilationRepository from app.interfaces.project_guide_runtime import ProjectGuideRuntimeConfiguration from app.core.identifiers import new_record_id +from app.modules.model_usage.api import ModelProfileReaderPort @dataclass(frozen=True) @@ -148,3 +149,26 @@ def automatic_request_selector(setup_run_id: UUID, generation: int) -> UUID: def new_compilation_request_operation_id() -> UUID: """Mint one persisted request-operation identity under its setup owner lock.""" return new_record_id() + + +async def select_model_runtime_configuration( + configuration: ProjectGuideRuntimeConfiguration, + profiles: ModelProfileReaderPort, + project_id: UUID, +) -> ProjectGuideRuntimeConfiguration: + """Bind automatic and human setup requests to one immutable project profile.""" + if not configuration.uses_inference: + return configuration + profile = await profiles.read_latest_matching( + project_id=project_id, + provider=configuration.model_provider, + api=configuration.model_api, + model_snapshot=configuration.model, + ) + return configuration.model_copy( + update={ + "model_profile_id": profile.profile_id, + "model_profile_version": profile.spec.profile_version, + "model_profile_digest": profile.profile_digest, + } + ) diff --git a/backend/app/modules/tasks/post_submit_routing/evaluation_guard.py b/backend/app/modules/tasks/post_submit_routing/evaluation_guard.py index 45665f0a8..4f531cff0 100644 --- a/backend/app/modules/tasks/post_submit_routing/evaluation_guard.py +++ b/backend/app/modules/tasks/post_submit_routing/evaluation_guard.py @@ -7,6 +7,7 @@ from app.modules.checkers.api.execution import CheckerExecutionUnavailable from app.modules.checkers.api.evaluation_contract import PostSubmissionExecutionRequest +from app.modules.checkers.api.output_custody import CheckerOutputSelector, CheckerOutputUnavailable from app.modules.tasks.api.submitted_bundle import SubmittedBundleRequest, SubmittedBundleUnavailable from app.modules.tasks.repository import TaskRepository from app.modules.tasks.submitted_bundle import SubmittedBundleReader @@ -43,6 +44,27 @@ def __init__(self, session: AsyncSession): self._repository = TaskRepository(session) self._submitted = SubmittedBundleReader(session) + async def lock_model_evidence_scope(self, selector: CheckerOutputSelector) -> None: + """Retain exact historical owners before private model evidence FK writes.""" + slot = selector.paid_slot + task = await self._repository.lock_project_task(slot.project_id, selector.task_id) + if task is None: + raise CheckerOutputUnavailable("checker_output_task_unavailable") + assignment = await self._repository.lock_accepted_assignment( + project_id=slot.project_id, task_id=selector.task_id, + assignment_id=slot.assignment_id, + ) + submission = await self._repository.lock_exact_submission_for_task( + project_id=slot.project_id, task_id=selector.task_id, + submission_id=slot.submission_id, + ) + if assignment is None or submission is None or ( + submission.version != slot.submission_version + or submission.task_assignment_id != str(slot.assignment_id) + or submission.contributor_id != assignment.contributor_id + ): + raise CheckerOutputUnavailable("checker_output_task_unavailable") + async def lock_evaluation_scope(self, request: PostSubmissionExecutionRequest) -> bool: """Return new-generation eligibility after exact Task/Assignment/Submission locks.""" task = await self._repository.lock_project_task(request.project_id, request.task_id) diff --git a/backend/app/workers/checker_model_proxy.py b/backend/app/workers/checker_model_proxy.py new file mode 100644 index 000000000..18e4f6d27 --- /dev/null +++ b/backend/app/workers/checker_model_proxy.py @@ -0,0 +1,31 @@ +"""Singleton private model-proxy process; durable effects live in CHECKERS and ART.""" + +import asyncio +import signal + +from app.adapters.checkers.model_runtime import configured_model_proxy_runtime +from app.core.config import get_settings +from app.db.session import dispose_engine, get_session_factory + + +async def serve() -> None: + """Serve the configured private socket until termination, then drain admitted work.""" + settings = get_settings() + if settings.checker_model_proxy_socket is None: + raise RuntimeError("checker model proxy socket is required") + stop = asyncio.Event() + loop = asyncio.get_running_loop() + signals = (signal.SIGINT, signal.SIGTERM) + for signum in signals: + loop.add_signal_handler(signum, stop.set) + try: + async with configured_model_proxy_runtime(settings, get_session_factory()): + await stop.wait() + finally: + for signum in signals: + loop.remove_signal_handler(signum) + await dispose_engine() + + +if __name__ == "__main__": + asyncio.run(serve()) diff --git a/backend/app/workers/project_setup.py b/backend/app/workers/project_setup.py index 1b187a7ee..f83326aaa 100644 --- a/backend/app/workers/project_setup.py +++ b/backend/app/workers/project_setup.py @@ -1,38 +1,40 @@ """Celery composition for automatic unified project-guide compilation.""" -from celery.utils.log import get_task_logger from functools import partial from pathlib import Path + +from celery.utils.log import get_task_logger from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine +from app.adapters.artifacts import guide_document_access_runtime, guide_document_manifest_port +from app.adapters.auth import ( + artifact_policy_projection_authorization, + guide_compilation_execution_authority, + guide_compilation_request_authority, + guide_sufficiency_projection_authorization, + setup_finalization_authorization, +) +from app.adapters.checkers import external_checker_capability_projection, model_profile_reader +from app.adapters.guide_document_tools import ( + GUIDE_DOCUMENT_PARSER_PROVIDER_KEY, + guide_document_parser_factory, +) from app.adapters.project_agents import ( RETAINED_OPENAI_GUIDE_RUNTIME_KEY, + build_project_guide_adapter_profile, create_guide_runtime_cleanup_factory, create_project_guide_runtime, - build_project_guide_adapter_profile, ) -from app.adapters.guide_document_tools import ( - GUIDE_DOCUMENT_PARSER_PROVIDER_KEY, - guide_document_parser_factory, +from app.adapters.project_agents.setup_model_proxy import setup_model_session_factory +from app.adapters.projects import ( + project_guide_compilation_delivery_port, + project_guide_document_tools_factory, ) from app.core.config import get_settings from app.core.project_agents import ( project_guide_runtime_configuration, ) from app.db.session import get_database_url -from app.adapters.auth import ( - guide_compilation_request_authority, - guide_compilation_execution_authority, - guide_sufficiency_projection_authorization, - artifact_policy_projection_authorization, - setup_finalization_authorization, -) -from app.adapters.projects import ( - project_guide_compilation_delivery_port, - project_guide_document_tools_factory, -) -from app.adapters.checkers import external_checker_capability_projection -from app.adapters.artifacts import guide_document_manifest_port, guide_document_access_runtime from app.modules.projects.api.guide_compilation import ( ProjectGuideCompilationDelivery, ProjectGuideCompilationDeliveryError, @@ -64,6 +66,10 @@ async def _coordinator(session_factory): timeout_seconds=30.0, ).create(GUIDE_DOCUMENT_PARSER_PROVIDER_KEY), ), + model_session_factory=setup_model_session_factory( + sessions=session_factory, + socket_path=settings.checker_model_proxy_socket, + ), checker_capabilities=capabilities, request_authority=guide_compilation_request_authority, execution_authority=guide_compilation_execution_authority, @@ -73,6 +79,7 @@ async def _coordinator(session_factory): build_project_guide_adapter_profile, settings.project_agent_runtime ), ), + model_profiles=model_profile_reader, runtime_factory=create_project_guide_runtime, sufficiency_authorization_factory=guide_sufficiency_projection_authorization, policy_authorization_factory=artifact_policy_projection_authorization, diff --git a/backend/scripts/api_contract_e2e.py b/backend/scripts/api_contract_e2e.py index 0603e4f04..b223f7bc7 100644 --- a/backend/scripts/api_contract_e2e.py +++ b/backend/scripts/api_contract_e2e.py @@ -243,6 +243,34 @@ def api_environment() -> dict[str, str]: return env +def configure_scripted_model_session_environment(env: dict[str, str]) -> None: + """Provision the private test paths required only by the full setup drill.""" + runtime_token = f"{os.getpid()}-{uuid4().hex}" + runner_temp = Path(env.get("RUNNER_TEMP", "/tmp")) + model_socket = runner_temp / f"workstream-api-contract-model-{runtime_token}.sock" + if os.path.lexists(model_socket): + raise RuntimeError("scripted model socket path is not fresh") + if env.get("WORKSTREAM_ARTIFACT_STORE_BACKEND", "disabled") == "disabled": + local_root = runner_temp / f"workstream-api-contract-artifacts-{runtime_token}" + scratch_root = runner_temp / f"workstream-api-contract-scratch-{runtime_token}" + local_root.mkdir(mode=0o700) + scratch_root.mkdir(mode=0o700) + env.update( + { + "WORKSTREAM_ARTIFACT_STORE_BACKEND": "local", + "WORKSTREAM_ARTIFACT_LOCAL_ROOT": str(local_root), + "WORKSTREAM_ARTIFACT_SCRATCH_ROOT": str(scratch_root), + "WORKSTREAM_ARTIFACT_ADMISSION_TASK_MAXIMUM_BYTES": "67108864", + "WORKSTREAM_ARTIFACT_ADMISSION_PRODUCER_MAXIMUM_BYTES": "67108864", + "WORKSTREAM_ARTIFACT_ADMISSION_PROJECT_MAXIMUM_BYTES": "67108864", + "WORKSTREAM_ARTIFACT_ADMISSION_DEPLOYMENT_MAXIMUM_BYTES": "67108864", + } + ) + # The deterministic runtime does not call the capability. A concrete absent + # path proves typed profile/session admission without a provider bypass. + env["WORKSTREAM_CHECKER_MODEL_PROXY_SOCKET"] = str(model_socket) + + async def exercise_rate_control_contract(env: dict[str, str]) -> None: """Prove one durable denial without exposing the pseudonymous key.""" subject = f"api-contract-rate-{uuid4()}" @@ -1544,6 +1572,37 @@ async def exercise_api_contract(base_url: str, env: dict[str, str]) -> None: idempotency_key=str(uuid4()), ) assert patched_guide["change_summary"] == "Patched before activation through real API" + setup_operator_token = issue_flow_token( + f"real-api-setup-operator-{run_id}", + [], + issuer=flow_issuer, + audience=flow_audience, + secret=flow_secret, + ) + setup_operator = await request_json( + client, "GET", "/api/v1/actors/me", setup_operator_token, + ) + await request_json( + client, + "POST", + "/api/v1/admin-role-grants", + manager_token, + { + "target_actor_profile_id": setup_operator["actor_profile_id"], + "role": "operator", + "scope_type": "system", + "scope_project_id": None, + "reason": "Provision the scripted setup model profile", + }, + 201, + idempotency_key=str(uuid4()), + ) + from guide_compilation_e2e import publish_scripted_model_profile + published_model_profile = await publish_scripted_model_profile( + client, setup_operator_token, project["id"], + ) + assert published_model_profile["spec"]["project_id"] == project["id"] + assert published_model_profile["spec"]["profile_version"] == 1 await exercise_guide_setup_contract( client, manager_token, @@ -2246,6 +2305,7 @@ async def main(env: dict[str, str]) -> None: if __name__ == "__main__": api_env = api_environment() + configure_scripted_model_session_environment(api_env) assert_isolated_database_url(api_env["WORKSTREAM_DATABASE_URL"]) os.environ.update(api_env) get_settings.cache_clear() diff --git a/backend/scripts/behavior_ownership.py b/backend/scripts/behavior_ownership.py index 12101da93..8a639acf8 100644 --- a/backend/scripts/behavior_ownership.py +++ b/backend/scripts/behavior_ownership.py @@ -117,6 +117,41 @@ "backend/app/modules/authorization/artifact_operator_authorization.py", "backend/app/modules/authorization/domain/artifact_operator.py", }) +CHECKER_MODEL_RUNTIME_TARGETS = frozenset({ + "backend/app/adapters/checkers/model_execution.py", + "backend/app/adapters/checkers/model_provider.py", + "backend/app/adapters/checkers/model_proxy.py", + "backend/app/adapters/checkers/model_proxy_transport.py", + "backend/app/adapters/checkers/model_runtime.py", + "backend/app/adapters/project_agents/setup_model_handler.py", + "backend/app/adapters/project_agents/setup_model_proxy.py", + "backend/app/api/deps/checker_models.py", + "backend/app/interfaces/checker_model_provider.py", + "backend/app/interfaces/checker_model_proxy.py", + "backend/app/interfaces/setup_model_proxy.py", + "backend/app/modules/artifacts/api/checker_output_authority.py", + "backend/app/modules/artifacts/checker_model_evidence.py", + "backend/app/modules/authorization/action_definitions.py", + "backend/app/modules/authorization/checker_output_authorization.py", + "backend/app/modules/authorization/domain/checker_outputs.py", + "backend/app/modules/authorization/domain/model_usage.py", + "backend/app/modules/authorization/domain/prepared_checker_runtime.py", + "backend/app/modules/authorization/model_usage_authorization.py", + "backend/app/modules/checkers/api/model_profiles.py", + "backend/app/modules/checkers/model_usage_custody.py", + "backend/app/modules/model_usage/api.py", + "backend/app/modules/model_usage/grants.py", + "backend/app/modules/model_usage/inspection.py", + "backend/app/modules/model_usage/models.py", + "backend/app/modules/model_usage/output_custody.py", + "backend/app/modules/model_usage/owner_ports.py", + "backend/app/modules/model_usage/profiles.py", + "backend/app/modules/model_usage/repository.py", + "backend/app/modules/model_usage/service.py", + "backend/app/modules/model_usage/setup.py", + "backend/app/modules/projects/guide_compilation/model_usage.py", + "backend/app/workers/checker_model_proxy.py", +}) PILOT_READ_1_TARGETS = frozenset({ "backend/app/modules/tasks/submission_attempts.py", }) @@ -1051,6 +1086,7 @@ def _validate_additive_partition_transition( | PILOT_READ_1_TARGETS | AWS_ARTIFACT_ACTIVATION_TARGETS | AUTH_ART_OPERATOR_TARGETS + | CHECKER_MODEL_RUNTIME_TARGETS | CHECKER_IMAGE_PUBLISH_TARGETS | PILOT_READ_2_TARGETS | PILOT_READ_3_TARGETS diff --git a/backend/scripts/external_api_drill.py b/backend/scripts/external_api_drill.py index a6302db3e..aa580881c 100644 --- a/backend/scripts/external_api_drill.py +++ b/backend/scripts/external_api_drill.py @@ -99,6 +99,11 @@ def guide_metadata(response): project.guide_compilation.execute project.effective_policy.manage project.task.manage project.review_policy.manage project.role_grant.read project.role_grant.manage task.queue.read task.claim submission.create submission.read_own submission.read_for_review +task.post_submit.route task.revision.expire task.revision.recover +checker.pre_submit.execute checker.pre_submit.finalize +checker.post_submit.execute checker.post_submit.finalize +checker.model_call.prepare checker.model_call.dispatch checker.model_call.recover +checker.model_call.observe checker.model_call.complete checker.model_call.grant review.queue.read review.queue.inspect review.claim review.release review.decline_preference review.decision review.lease.force_release review.chain.read review.queue.override contribution.read_self contribution.read_project compensation.policy.manage @@ -111,6 +116,7 @@ def guide_metadata(response): artifact.audit.read artifact.guide_source.ingest artifact.binding.create artifact.verification.execute artifact.pending_work.scan artifact.put_attempt.resolve artifact.guide_source.read artifact.checker_input.materialize artifact.checker_output.write artifact.review_packet.materialize +project.model_profile.manage project.model_budget.manage project.model_call.recovery_child.authorize audit.read audit.export """.split())) EXPECTED_ROLE_CONTRACT = ( @@ -121,6 +127,7 @@ def guide_metadata(response): review.lease.force_release contribution.read_project operations.status.read operations.timer.run operations.reconcile.run operations.outbox.retry operations.projection.rebuild operations.task.start_override operations.submission_gate.repair operations.checker.retry +project.model_profile.manage project.model_budget.manage project.model_call.recovery_child.authorize artifact.binding.read artifact.replica.read artifact.receipt.read artifact.verification_job.read artifact.verification_job.retry artifact.recovery_attempt.read artifact.audit.read audit.read"""), ("project_manager", ("system", "project"), """project.create project.read project.setup_diagnostic.read project.effective_policy.read project.update @@ -138,7 +145,7 @@ def guide_metadata(response): def catalogue_expectations(): """Fresh expected JSON objects; callers cannot mutate the frozen oracle.""" return { - "permissions": {"items": [{"permission_id": value} for value in EXPECTED_PERMISSIONS], "total": 75}, + "permissions": {"items": [{"permission_id": value} for value in EXPECTED_PERMISSIONS], "total": 91}, "admin-role-definitions": {"items": [ {"role": role, "allowed_scopes": list(scopes), "permission_ids": permissions.split()} for role, scopes, permissions in EXPECTED_ROLE_CONTRACT], "total": 5}, diff --git a/backend/scripts/guide_compilation_e2e.py b/backend/scripts/guide_compilation_e2e.py index 78bf17017..046e04b1e 100644 --- a/backend/scripts/guide_compilation_e2e.py +++ b/backend/scripts/guide_compilation_e2e.py @@ -4,6 +4,7 @@ from hashlib import sha256 from pathlib import Path from unittest.mock import patch +from uuid import UUID, uuid4 from app.adapters.guide_document_tools import ( @@ -15,6 +16,8 @@ ) from app.adapters.project_agents import build_project_guide_adapter_profile from app.core.config import get_settings +from app.core.hashing import canonical_json_hash +from app.core.identifiers import new_record_id from app.core.project_agents import project_guide_runtime_configuration from app.interfaces.external_services import ( ExternalServiceAdapterFactory, @@ -31,6 +34,11 @@ from app.modules.projects.api.guide_compilation import ( ProjectGuideCompilationDelivery, ) +from app.modules.model_usage.api import ( + ModelGenerationSettings, + ModelPricing, + ProjectModelProfileSpec, +) class E2EProjectGuideRuntime: @@ -145,6 +153,62 @@ def create(configuration): return create +async def publish_scripted_model_profile( + client, operator_token: str, project_id: str, +) -> dict: + """Publish the exact finite profile required by the scripted setup runtime.""" + settings = get_settings() + configuration = project_guide_runtime_configuration( + settings, + adapter_profile_builder=partial( + build_project_guide_adapter_profile, settings.project_agent_runtime + ), + ) + maximum_calls = configuration.maximum_turns + spec = ProjectModelProfileSpec( + project_id=UUID(project_id), + profile_version=1, + provider=configuration.model_provider, + api=configuration.model_api, + model_snapshot=configuration.model, + settings=ModelGenerationSettings( + reasoning_effort="medium", text_verbosity="low", + ), + maximum_request_bytes=1_048_576, + maximum_output_bytes=131_072, + maximum_response_bytes=1_048_576, + maximum_input_tokens=1_000_000, + maximum_output_tokens=16_384, + maximum_calls=maximum_calls, + maximum_total_request_bytes=1_048_576 * maximum_calls, + maximum_total_response_bytes=1_048_576 * maximum_calls, + maximum_total_input_tokens=1_000_000 * maximum_calls, + maximum_total_output_tokens=16_384 * maximum_calls, + allowed_tools=("read_document", "search_document"), + maximum_tool_calls=configuration.maximum_tool_calls, + maximum_tool_output_bytes=configuration.maximum_tool_output_characters * 4, + deadline_ms=configuration.timeout_seconds * 1000, + pricing=ModelPricing( + pricing_policy_id=new_record_id(), + pricing_policy_version=1, + pricing_policy_digest=canonical_json_hash( + {"contract": "real_api_scripted_model_pricing", "version": 1} + ), + uncached_input_nanodollars_per_token=1, + cached_input_nanodollars_per_token=1, + output_nanodollars_per_token=1, + ), + ) + response = await client.post( + f"/api/v1/operations/projects/{project_id}/model-profiles", + headers={"Authorization": f"Bearer {operator_token}", + "Idempotency-Key": str(uuid4())}, + json=spec.model_dump(mode="json"), + ) + assert response.status_code == 201, response.text + return response.json() + + async def compile_live_guide(delivery: ProjectGuideCompilationDelivery) -> dict: """Drive the sole worker delivery and prove its finalization replays without inference.""" from app.workers import project_setup as worker diff --git a/backend/scripts/identifier_generation_classifications.json b/backend/scripts/identifier_generation_classifications.json index 6c8de04a6..8db95b1aa 100644 --- a/backend/scripts/identifier_generation_classifications.json +++ b/backend/scripts/identifier_generation_classifications.json @@ -18,10 +18,12 @@ }, "sites": { "app/adapters/artifacts/__init__.py|guide_document_access_runtime.authority|Assign:ebf142d9fade54e0|0": "uuid4:call:transport_token", + "app/adapters/artifacts/__init__.py|checker_output_storage.authority|Assign:ebf142d9fade54e0|0": "uuid4:call:transport_token", "app/adapters/artifacts/internal_workers.py|run_artifact_internal_operation|Assign:ebf142d9fade54e0|0": "uuid4:call:transport_token", "app/adapters/artifacts/internal_workers.py|scan_artifact_pending_work|Assign:ebf142d9fade54e0|0": "uuid4:call:transport_token", "app/adapters/artifacts/local.py|LocalStorageAdapter._create_temporary|Assign:77673e2972bd2a92|0": "uuid4:call:ephemeral_storage_name", "app/adapters/auth/__init__.py|guide_compilation_request_authority|AsyncWith:34f3111a2ab9b1eb|0": "uuid5:call:deterministic_selector", + "app/adapters/auth/__init__.py|setup_model_service_authorization|Assign:45b5028da712058a|0": "uuid5:call:deterministic_selector", "app/adapters/auth/__init__.py|post_policy_service_authority|Assign:e417699a1652ebf1|0": "uuid5:call:deterministic_selector", "app/core/api_controls.py|request_ids|Assign:338317bb71eae860|0": "uuid4:call:transport_token", "app/core/api_controls.py|_resolve_request_ids|Assign:055343e0234192a3|0": "uuid4:call:transport_token", @@ -38,6 +40,8 @@ "app/modules/authorization/api/project_guide_projections.py|_uuid|Return:d56c92d39a6e8a97|0": "uuid5:call:deterministic_selector", "app/modules/authorization/api/project_setup_finalization.py|setup_finalization_preparation_identity|Return:203f69a3a3d4f34c|0": "uuid5:call:deterministic_selector", "app/modules/authorization/api/project_setup_finalization.py|setup_finalization_preparation_identity|Return:a4302f57ad6f184f|0": "uuid5:call:deterministic_selector", + "app/modules/authorization/model_usage_authorization.py|CheckerModelAuthorization.authorize_setup_prepare|Return:2044afb99ebde376|0": "uuid5:call:deterministic_selector", + "app/adapters/checkers/model_proxy.py|CheckerModelProxy._handle|Assign:b0d39ceef89a3db0|0": "uuid4:call:transport_token", "app/modules/outbox/delivery.py|OutboxDelivery.claim|AsyncWith:5200885f8fab9061|0": "uuid4:call:transport_token", "app/modules/outbox/delivery.py|OutboxDelivery.claim|AsyncWith:3eebeb550e8493c8|0": "uuid4:call:transport_token", "app/modules/outbox/delivery.py|OutboxDelivery._begin_invocation|AsyncWith:0ddbadb4f6489377|0": "uuid4:call:transport_token", @@ -79,6 +83,7 @@ "scripts/admin_api_drill.py|AuthorityDrill.last_admin.revoke|Assign:5eb0e430a7bfa378|0": "uuid4:call:transport_token", "scripts/admin_api_drill.py|AuthorityDrill.last_admin.revoke|Assign:50c09de1b190b007|0": "uuid4:call:transport_token", "scripts/api_contract_e2e.py|flow_settings|Return:dc575af68f5f5f0c|0": "uuid4:call:ephemeral_secret", + "scripts/api_contract_e2e.py|configure_scripted_model_session_environment|Assign:8450cf2c8dff45cc|0": "uuid4:call:drill_namespace", "scripts/local_flow_tokens.py|issue_flow_token|Assign:dd98645354be1f57|0": "uuid4:call:external_identity_fixture", "scripts/api_contract_e2e.py|exercise_rate_control_contract|Assign:fed221fef92f603b|0": "uuid4:call:external_identity_fixture", "scripts/api_contract_e2e.py|request_json|Assign:9a076497f47031d6|0": "uuid4:call:transport_token", @@ -198,6 +203,8 @@ "scripts/api_contract_e2e.py|activate_guide_for_e2e|Expr:8ef328046b881a1c|0": "uuid4:call:transport_token", "scripts/api_contract_e2e.py|activate_guide_for_e2e|Assign:71aef60013930cda|0": "uuid4:call:transport_token", "scripts/api_contract_e2e.py|exercise_api_contract|Expr:2866edfb74707b74|0": "uuid4:call:transport_token", + "scripts/api_contract_e2e.py|exercise_api_contract|Expr:6ac1bf5c75b1d132|0": "uuid4:call:transport_token", + "scripts/guide_compilation_e2e.py|publish_scripted_model_profile|Assign:d4603dbadbb4e32f|0": "uuid4:call:transport_token", "app/modules/tasks/task_batch_transitions.py|TaskBatchTransitionCommands._prepare|Assign:b01b4c43bfe447dd|0": "uuid4:call:transport_token", "app/modules/tasks/task_import_batches.py|TaskImportBatchCommands._prepare_rows|Expr:49b976e107dcb310|0": "uuid4:call:transport_token", "scripts/provision_local_service_actors.py|idempotency_key|Return:7cb5dd9bb0dd8d6d|0": "uuid5:call:deterministic_selector", diff --git a/backend/scripts/identifier_inventory.py b/backend/scripts/identifier_inventory.py index fbf8aeeb7..862001922 100644 --- a/backend/scripts/identifier_inventory.py +++ b/backend/scripts/identifier_inventory.py @@ -22,8 +22,14 @@ SEMANTIC_KEYS = { "submission_dispatches": ("submission-owned original dispatch receipt", ("submission_id",)), "submission_binding_receipts": ("admission-owned binding authority receipt", ("admission_id",)), - "review_packet_guide_items": ("packet-to-declared-source membership", ("packet_id", "source_item_id")), - "actor_profile_migration_state": ("seeded schema-state singleton, not a record sequence", ("id",)), + "review_packet_guide_items": ( + "packet-to-declared-source membership", + ("packet_id", "source_item_id"), + ), + "actor_profile_migration_state": ( + "seeded schema-state singleton, not a record sequence", + ("id",), + ), "api_rate_control_counters": ("rate-limit scope and digest", ("control_scope", "key_digest")), "artifact_admission_scopes": ("artifact quota scope", ("scope_type", "scope_id")), "artifact_put_attempt_charges": ("attempt-to-charge association", ("attempt_id", "charge_id")), @@ -33,6 +39,14 @@ "guide_source_extraction_retry_budgets": ("binding-owned shared primary key", ("binding_id",)), "iso_4217_currency_codes": ("ISO currency code", ("code",)), "legacy_actor_identities": ("external legacy actor key", ("actor_id",)), + "model_usage_budget_accounts": ( + "project-owned lifetime budget account", + ("project_id",), + ), + "checker_model_invocation_grant_calls": ( + "grant call-index binding", + ("grant_id", "call_index"), + ), "outbox_delivery_attempts": ("event delivery generation", ("event_id", "claim_generation")), "project_compensation_units": ( "project instrument and unit code", @@ -204,9 +218,12 @@ def parse_orm_models(app_root: Path, report_root: Path | None = None) -> dict[st for owner in [tables.get(target.rpartition(".")[0])] if column["storage_type"] == "inferred" and owner for target_column in [_columns(owner).get(target.rpartition(".")[2])] - if target_column and target_column["storage_kind"] != "other" - and (target_column["storage_type"] != "inferred" - or target_column.get("storage_inferred_from_foreign_key")) + if target_column + and target_column["storage_kind"] != "other" + and ( + target_column["storage_type"] != "inferred" + or target_column.get("storage_inferred_from_foreign_key") + ) } if len(kinds) == 1 and column["storage_kind"] != next(iter(kinds)): column["storage_kind"] = next(iter(kinds)) @@ -303,19 +320,26 @@ def parse_schema(backend_root: Path) -> dict[str, dict[str, Any]]: and ast.unparse(statement.value.func) == "op.drop_table" } downgrade_nodes = { - node for function in tree.body + node + for function in tree.body if isinstance(function, ast.FunctionDef) and function.name == "downgrade" for node in ast.walk(function) } - for item in sorted(ast.walk(tree), key=lambda node: ( - getattr(node, "lineno", 0), getattr(node, "col_offset", 0), - )): + for item in sorted( + ast.walk(tree), + key=lambda node: ( + getattr(node, "lineno", 0), + getattr(node, "col_offset", 0), + ), + ): if item in downgrade_nodes: continue if item in drops and item.args and (table_name := _string(item.args[0])): - if all(keyword.arg is not None and ( - keyword.arg != "schema" or _string(keyword.value) == "public" - ) for keyword in item.keywords): + if all( + keyword.arg is not None + and (keyword.arg != "schema" or _string(keyword.value) == "public") + for keyword in item.keywords + ): tables.pop(table_name, None) continue if ( @@ -331,14 +355,16 @@ def parse_schema(backend_root: Path) -> dict[str, dict[str, Any]]: or not (table_name := _string(item.args[0])) ): continue - primary_key = list(dict.fromkeys( - name - for argument in item.args[1:] - if isinstance(argument, ast.Call) - and _name(argument.func) == "PrimaryKeyConstraint" - for value in argument.args - if (name := _string(value)) - )) + primary_key = list( + dict.fromkeys( + name + for argument in item.args[1:] + if isinstance(argument, ast.Call) + and _name(argument.func) == "PrimaryKeyConstraint" + for value in argument.args + if (name := _string(value)) + ) + ) columns = [] for argument in item.args[1:]: if ( @@ -526,9 +552,7 @@ def scan_generation_sites( continue tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path)) parents = { - child: parent - for parent in ast.walk(tree) - for child in ast.iter_child_nodes(parent) + child: parent for parent in ast.walk(tree) for child in ast.iter_child_nodes(parent) } direct, modules = _uuid_bindings(tree) occurrences = _uuid_occurrences(tree, parents, direct, modules) @@ -565,7 +589,9 @@ def _key(table: dict[str, Any], orm_present: bool) -> dict[str, str]: ) if not orm_present: return {"classification": "unresolved", "reason": "schema table has no ORM owner"} - if table["name"] == "task_post_submit_routing_requests" and table["primary_key"] == ["route_operation_id"]: + if table["name"] == "task_post_submit_routing_requests" and table["primary_key"] == [ + "route_operation_id" + ]: return { "classification": "generated_surrogate", "reason": "TASK-owned generated routing operation record", @@ -613,9 +639,7 @@ def _string_uuid_references(tables: list[dict[str, Any]]) -> list[dict[str, str] "candidate": "convert relationship to native UUID", } ) - return sorted( - references, key=lambda item: (item["table"], item["column"], item["target"]) - ) + return sorted(references, key=lambda item: (item["table"], item["column"], item["target"])) def _generation_inventory(backend_root: Path) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]: @@ -680,15 +704,21 @@ def build_inventory(backend_root: Path) -> dict[str, Any]: if key["classification"] == "unresolved": unresolved.append({"kind": "table_key", "table": name, "reason": key["reason"]}) if owner and not ddl: - unresolved.append({ - "kind": "missing_schema_table", "table": name, - "reason": "ORM table is absent from the schema inventory", - }) + unresolved.append( + { + "kind": "missing_schema_table", + "table": name, + "reason": "ORM table is absent from the schema inventory", + } + ) if owner and ddl and owner["primary_key"] != ddl["primary_key"]: - unresolved.append({ - "kind": "primary_key_shape_mismatch", "table": name, - "reason": "ORM and schema primary keys differ", - }) + unresolved.append( + { + "kind": "primary_key_shape_mismatch", + "table": name, + "reason": "ORM and schema primary keys differ", + } + ) if owner and ddl and owner["primary_key"] == ddl["primary_key"]: for column_name in owner["primary_key"]: left, right = _columns(owner).get(column_name), _columns(ddl).get(column_name) diff --git a/backend/scripts/module_boundaries.py b/backend/scripts/module_boundaries.py index 39eef94af..3653cc703 100644 --- a/backend/scripts/module_boundaries.py +++ b/backend/scripts/module_boundaries.py @@ -22,7 +22,7 @@ / ".ci/auth-boundaries/IMPORT_LEDGER.md" ) MODULE_PREFIX = "app.modules." -BUSINESS_MODULES = 9 +BUSINESS_MODULES = 10 SUPPORTING_MODULES = 3 diff --git a/backend/scripts/test_lane_catalogue.py b/backend/scripts/test_lane_catalogue.py index df7494ee3..5a35a62b5 100644 --- a/backend/scripts/test_lane_catalogue.py +++ b/backend/scripts/test_lane_catalogue.py @@ -491,6 +491,24 @@ class TestLane: # Checker delivery shares project dispatch ownership across the project family. CHECKER_DELIVERY_MODULES = ( + "tests/checkers_model_calls/test_composed_execution.py", + "tests/checkers_model_calls/test_model_call_contract.py", + "tests/checkers_model_calls/test_model_call_transactions_postgresql.py", + "tests/checkers_model_calls/test_model_invocation_grants_postgresql.py", + "tests/checkers_model_calls/test_model_shared_setup_postgresql.py", + "tests/checkers_model_calls/test_model_terminal_authority_postgresql.py", + "tests/checkers_model_calls/test_model_usage_inspection.py", + "tests/checkers_model_calls/test_model_runtime.py", + "tests/checkers_model_calls/test_proxy_orchestration.py", + "tests/checkers_model_calls/test_public_api.py", + "tests/checkers_model_calls/test_setup_model_handler.py", + "tests/checkers_model_calls/test_setup_model_handler_postgresql.py", + "tests/checkers_model_calls/test_setup_model_proxy_transport.py", + "tests/checkers_model_calls/test_setup_output_custody.py", + "tests/checkers/model_provider/test_openai_responses.py", + "tests/checkers/external/test_model_transport.py", + "tests/checkers/external/test_model_proxy_transport.py", + "tests/authorization/checker_models/test_contracts.py", "tests/checkers/default/test_contract.py", "tests/checkers/default/test_execution.py", "tests/checkers/external/test_contracts.py", diff --git a/backend/tests/architecture/test_module_boundaries.py b/backend/tests/architecture/test_module_boundaries.py index ae9bcd583..88afd98b4 100644 --- a/backend/tests/architecture/test_module_boundaries.py +++ b/backend/tests/architecture/test_module_boundaries.py @@ -13,9 +13,7 @@ ROOT = Path(__file__).resolve().parents[3] REGISTRY = ROOT / ".ci/module-boundaries/registry.v1.json" LEDGER = ROOT / ".ci/module-boundaries/private-edge-debt.v1.json" -AUTH_LEDGER = ( - ROOT / ".ci/auth-boundaries/IMPORT_LEDGER.md" -) +AUTH_LEDGER = ROOT / ".ci/auth-boundaries/IMPORT_LEDGER.md" def _write(path: Path, value: str) -> None: @@ -32,14 +30,25 @@ def test_repository_matches_the_protected_module_boundary() -> None: boundary.validate(ROOT, REGISTRY, LEDGER, AUTH_LEDGER) -def test_registry_names_exactly_nine_business_and_three_supporting_modules() -> None: +def test_registry_names_exactly_ten_business_and_three_supporting_modules() -> None: """The canonical ownership map cannot silently grow or collapse.""" registry = boundary.load_registry(REGISTRY) - assert len(registry.business) == 9 + assert len(registry.business) == 10 assert len(registry.supporting) == 3 assert registry.names == { - "actors", "authorization", "projects", "tasks", "artifacts", "checkers", - "reviews", "contributions", "compensation", "audit", "outbox", "api_controls", + "actors", + "authorization", + "projects", + "tasks", + "artifacts", + "checkers", + "model_usage", + "reviews", + "contributions", + "compensation", + "audit", + "outbox", + "api_controls", } assert dict(registry.application_surfaces)["backend/app/interfaces"] == ( "legacy-interface-debt-tracked" @@ -58,9 +67,7 @@ def test_new_private_edge_fails_exact_ledger_comparison( ) graph = {name: set() for name in boundary.load_registry(REGISTRY).names} monkeypatch.setattr(boundary.authorization_boundary, "validate", lambda *_: None) - monkeypatch.setattr( - boundary.authorization_boundary, "load_ledger", lambda *_: (set(), set()) - ) + monkeypatch.setattr(boundary.authorization_boundary, "load_ledger", lambda *_: (set(), set())) monkeypatch.setattr(boundary, "scan", lambda *_: ({edge}, graph, set())) monkeypatch.setattr(boundary, "load_ledger", lambda *_: set()) monkeypatch.setattr(boundary, "_validate_public_apis", lambda *_: None) @@ -78,9 +85,7 @@ def test_owner_adapter_may_bind_only_its_own_private_implementation( "from app.modules.tasks.repository import TaskRepository\n" "from app.modules.projects.repository import ProjectRepository\n", ) - private, _, _ = boundary.scan( - tmp_path, boundary.load_registry(tmp_path / "registry.json") - ) + private, _, _ = boundary.scan(tmp_path, boundary.load_registry(tmp_path / "registry.json")) assert private == { boundary.PrivateEdge( "backend/app/adapters/tasks/__init__.py", @@ -100,9 +105,7 @@ def test_non_root_adapter_keeps_same_owner_private_debt_visible( tmp_path / "backend/app/adapters/tasks/worker.py", "from app.modules.tasks.repository import TaskRepository\n", ) - private, _, _ = boundary.scan( - tmp_path, boundary.load_registry(tmp_path / "registry.json") - ) + private, _, _ = boundary.scan(tmp_path, boundary.load_registry(tmp_path / "registry.json")) assert private == { boundary.PrivateEdge( "backend/app/adapters/tasks/worker.py", @@ -205,16 +208,13 @@ def test_auth_view_contains_only_cross_boundary_private_edges(tmp_path: Path) -> _registry(tmp_path / "registry.json") _write( tmp_path / "backend/app/modules/authorization/service.py", - "from app.modules.authorization import repository\n" - "from app.modules.tasks import models\n", + "from app.modules.authorization import repository\nfrom app.modules.tasks import models\n", ) _write( tmp_path / "backend/app/modules/tasks/service.py", "from app.modules.authorization import runtime\n", ) - _, _, auth_edges = boundary.scan( - tmp_path, boundary.load_registry(tmp_path / "registry.json") - ) + _, _, auth_edges = boundary.scan(tmp_path, boundary.load_registry(tmp_path / "registry.json")) assert auth_edges == { boundary.authorization_boundary.ImportEdge( "backend/app/modules/authorization/service.py", "app.modules.tasks" @@ -250,8 +250,7 @@ def test_tasks_public_api_has_no_private_or_mutable_dependency() -> None: imports.update(boundary.exact_source_imports(path, ROOT)) assert imports assert all( - not target.startswith("app.modules.") - or target.startswith("app.modules.tasks.api") + not target.startswith("app.modules.") or target.startswith("app.modules.tasks.api") for target in imports ) @@ -290,6 +289,7 @@ def test_checkers_public_api_has_no_private_or_mutable_dependency() -> None: not target.startswith("app.modules.") or target == "app.modules.checkers.api" or target.startswith("app.modules.checkers.api.") + or target == "app.modules.model_usage.api" for target in imports ) @@ -341,12 +341,14 @@ def test_general_ledger_rejects_copied_authorization_edges(tmp_path: Path) -> No """AUTH debt remains exclusively owned by the AUTH-003 ledger.""" document = { "schema_version": 1, - "edges": [{ - "source_file": "backend/app/modules/tasks/service.py", - "target_module": "authorization", - "imported_private_path": "app.modules.authorization.runtime", - "repair_owner": "security-triage-required", - }], + "edges": [ + { + "source_file": "backend/app/modules/tasks/service.py", + "target_module": "authorization", + "imported_private_path": "app.modules.authorization.runtime", + "repair_owner": "security-triage-required", + } + ], } path = tmp_path / "ledger.json" path.write_text(json.dumps(document), encoding="utf-8") @@ -358,12 +360,14 @@ def test_general_ledger_allows_non_auth_security_triage_owner(tmp_path: Path) -> """Unresolved authorization-affecting non-AUTH debt can block its capability.""" document = { "schema_version": 1, - "edges": [{ - "source_file": "backend/app/modules/tasks/service.py", - "target_module": "actors", - "imported_private_path": "app.modules.actors.models", - "repair_owner": "security-triage-required", - }], + "edges": [ + { + "source_file": "backend/app/modules/tasks/service.py", + "target_module": "actors", + "imported_private_path": "app.modules.actors.models", + "repair_owner": "security-triage-required", + } + ], } path = tmp_path / "ledger.json" path.write_text(json.dumps(document), encoding="utf-8") @@ -407,10 +411,13 @@ def test_initial_ledgers_capture_high_risk_application_edges() -> None: for source, target in actual ) auth_inbound, _ = boundary.authorization_boundary.load_ledger(AUTH_LEDGER) - assert boundary.authorization_boundary.ImportEdge( - "backend/app/interfaces/artifact_operations.py", - "app.modules.authorization.prepared", - ) in auth_inbound + assert ( + boundary.authorization_boundary.ImportEdge( + "backend/app/interfaces/artifact_operations.py", + "app.modules.authorization.prepared", + ) + in auth_inbound + ) def test_metadata_discovery_exception_is_exact_and_model_only(tmp_path: Path) -> None: @@ -423,9 +430,7 @@ def test_metadata_discovery_exception_is_exact_and_model_only(tmp_path: Path) -> assert not edges _write(path, "from app.modules.tasks import repository\n") edges, _, _ = boundary.scan(tmp_path, registry) - assert {edge.imported_private_path for edge in edges} == { - "app.modules.tasks.repository" - } + assert {edge.imported_private_path for edge in edges} == {"app.modules.tasks.repository"} @pytest.mark.parametrize("mutation", ("missing", "additional")) @@ -480,8 +485,7 @@ def test_exact_auth_adapter_root_uses_owner_parity_without_nested_exception( _registry(tmp_path / "registry.json") _write( tmp_path / "backend/app/adapters/auth/__init__.py", - "import app.modules.authorization.runtime\n" - "import app.modules.tasks.repository\n", + "import app.modules.authorization.runtime\nimport app.modules.tasks.repository\n", ) nested = "backend/app/adapters/auth/adapter_bindings.py" _write( @@ -515,9 +519,7 @@ def test_exact_auth_adapter_root_uses_owner_parity_without_nested_exception( "from app.modules.projects.repository import *\n", ), ) -def test_dynamic_and_wildcard_imports_fail_repository_wide( - tmp_path: Path, source: str -) -> None: +def test_dynamic_and_wildcard_imports_fail_repository_wide(tmp_path: Path, source: str) -> None: """The general scanner inherits AUTH's fail-closed AST parser.""" _registry(tmp_path / "registry.json") _write(tmp_path / "backend/app/modules/tasks/service.py", source) diff --git a/backend/tests/authorization/admin_access/test_admin_reads_postgresql.py b/backend/tests/authorization/admin_access/test_admin_reads_postgresql.py index b923a0b8f..d8ad8b3ca 100644 --- a/backend/tests/authorization/admin_access/test_admin_reads_postgresql.py +++ b/backend/tests/authorization/admin_access/test_admin_reads_postgresql.py @@ -183,7 +183,7 @@ async def test_missing_admin_target_has_concealed_response_and_no_success_effect assert await authority_snapshot() == before -@pytest.mark.parametrize("surface,total", [("permissions", 82), ("admin-role-definitions", 5)]) +@pytest.mark.parametrize("surface,total", [("permissions", 95), ("admin-role-definitions", 5)]) async def test_admin_catalogue_read_returns_public_definitions( admin_access: AdminAccess, surface: str, diff --git a/backend/tests/authorization/admin_access/test_grant_reads_postgresql.py b/backend/tests/authorization/admin_access/test_grant_reads_postgresql.py index 1fa316b55..21f2bfc3a 100644 --- a/backend/tests/authorization/admin_access/test_grant_reads_postgresql.py +++ b/backend/tests/authorization/admin_access/test_grant_reads_postgresql.py @@ -92,7 +92,7 @@ async def test_system_auditor_has_exact_read_surface( assert response.status_code == 200, response.text body = response.json() if surface in {"permissions", "definitions"}: - assert body["total"] == len(body["items"]) == (82 if surface == "permissions" else 5) + assert body["total"] == len(body["items"]) == (95 if surface == "permissions" else 5) elif surface in {"list", "history"}: expected = {grant_id, access.bootstrap_grant_id} if surface == "list" else {grant_id} assert body["total"] == len(expected) diff --git a/backend/tests/authorization/catalogue_fixtures.py b/backend/tests/authorization/catalogue_fixtures.py index c52b812c2..8393c71e5 100644 --- a/backend/tests/authorization/catalogue_fixtures.py +++ b/backend/tests/authorization/catalogue_fixtures.py @@ -1,6 +1,7 @@ """Explicit catalogue expectations, independent of production definitions.""" from app.modules.actors.api import ServiceIdentity +from app.modules.authorization.schemas import AdminRole, AdminScope FIXED_SERVICE_ACTION_EXPECTATIONS = { ServiceIdentity.TASK_REVISION_EXPIRER: {"task.revision.expire", "task.revision.recover"}, @@ -25,11 +26,24 @@ "checker.pre_submit.execute", "checker.pre_submit.finalize", }, - ServiceIdentity.CHECKER_POST_SUBMIT: {"checker.post_submit.execute", "checker.post_submit.finalize"}, + ServiceIdentity.CHECKER_POST_SUBMIT: { + "checker.post_submit.execute", + "checker.post_submit.finalize", + "checker.model_call.prepare", + "checker.model_call.dispatch", + "checker.model_call.recover", + "checker.model_call.observe", + "checker.model_call.complete", + "checker.model_call.grant", + }, ServiceIdentity.ARTIFACT_CHECKER_OUTPUT: {"artifact.checker_output.write"}, ServiceIdentity.PROJECT_SETUP: { "project.guide_compilation.request_automatic", "project.guide_compilation.execute", + "project.guide_compilation.model.prepare", + "project.guide_compilation.model.dispatch", + "project.guide_compilation.model.observe", + "project.guide_compilation.model.complete", "project.guide_sufficiency.run", "project.submission_artifact_policy.derive", "project.post_submit_checker_policy.derive", @@ -122,7 +136,11 @@ "WS-XINT-002-06A", "active", ), - "artifact.submission.binding.create": ("artifact.binding.create", "WS-AUTH-001-ART-05", "active"), + "artifact.submission.binding.create": ( + "artifact.binding.create", + "WS-AUTH-001-ART-05", + "active", + ), "artifact.post_submit.checker_input.materialize": ( "artifact.checker_input.materialize", "WS-AUTH-001-ART-06A", @@ -131,7 +149,7 @@ "artifact.checker_output.write": ( "artifact.checker_output.write", "WS-AUTH-001-ART-06B", - "planned", + "active", ), "artifact.review_packet.materialize": ( "artifact.review_packet.materialize", @@ -146,7 +164,7 @@ "artifact.checker_output.binding.create": ( "artifact.binding.create", "WS-AUTH-001-ART-06B", - "planned", + "active", ), } @@ -235,7 +253,8 @@ ), } -historical_permissions = frozenset("""actor.profile.read_self actor.profile.update_self actor.profile.read_any +historical_permissions = frozenset( + """actor.profile.read_self actor.profile.update_self actor.profile.read_any actor.profile.suspend actor.profile.reactivate actor.profile.deactivate actor.identity_link.read actor.identity_link.revoke actor.identity_link.reactivate actor.service.provision admin_role.read admin_role.grant admin_role.revoke @@ -249,7 +268,8 @@ compensation.adapter_binding.manage compensation.award.read compensation.delivery.reconcile operations.status.read operations.timer.run operations.reconcile.run operations.outbox.retry operations.projection.rebuild - audit.read audit.export""".split()) + audit.read audit.export""".split() +) new_permissions = frozenset( """ @@ -262,24 +282,49 @@ artifact.verification.execute artifact.pending_work.scan artifact.put_attempt.resolve artifact.guide_source.read artifact.checker_input.materialize artifact.checker_output.write artifact.review_packet.materialize - review.queue.override project.guide_compilation.request project.guide_compilation.execute""".split() + review.queue.override project.guide_compilation.request project.guide_compilation.execute + checker.model_call.prepare checker.model_call.dispatch checker.model_call.recover + checker.model_call.observe checker.model_call.complete checker.model_call.grant + project.guide_compilation.model.prepare + project.guide_compilation.model.dispatch + project.guide_compilation.model.observe + project.guide_compilation.model.complete + project.model_profile.manage project.model_budget.manage + project.model_call.recovery_child.authorize""".split() ) expected = { "actor.project.list": ("actor.profile.read_self", "PILOT-READ-2"), "project.catalogue.list": ("project.read", "WS-CLI-001-46"), - **dict.fromkeys(( - "actor.assignment.list", "actor.submission.list", "actor.work_summary.read", - "task.submission_bundle_preparation.list", "task.submission_bundle_preparation.read", - ), ("submission.read_own", "PILOT-READ-2")), - **dict.fromkeys(( - "project.guide.list", "project.guide.read", "project.guide.activation_readiness.read", - "project.guide.document.read", - ), ("project.guide.manage", "PILOT-READ-3")), - **dict.fromkeys(( - "project.submission.list", "project.assignment.list", "project.summary.read", - "project.task.import_batch.list", "project.submission_bundle_preparation.list", - ), ("project.task.manage", "PILOT-READ-3")), + **dict.fromkeys( + ( + "actor.assignment.list", + "actor.submission.list", + "actor.work_summary.read", + "task.submission_bundle_preparation.list", + "task.submission_bundle_preparation.read", + ), + ("submission.read_own", "PILOT-READ-2"), + ), + **dict.fromkeys( + ( + "project.guide.list", + "project.guide.read", + "project.guide.activation_readiness.read", + "project.guide.document.read", + ), + ("project.guide.manage", "PILOT-READ-3"), + ), + **dict.fromkeys( + ( + "project.submission.list", + "project.assignment.list", + "project.summary.read", + "project.task.import_batch.list", + "project.submission_bundle_preparation.list", + ), + ("project.task.manage", "PILOT-READ-3"), + ), "contribution.policy.list": ("compensation.policy.manage", "PILOT-READ-3"), "contribution.read_self": ("contribution.read_self", "PILOT-10"), "contribution.read_project": ("contribution.read_project", "PILOT-10"), @@ -290,6 +335,35 @@ "checker.pre_submit.finalize": ("checker.pre_submit.finalize", "PILOT-04"), "checker.post_submit.execute": ("checker.post_submit.execute", "WS-ARCH-001-04D2"), "checker.post_submit.finalize": ("checker.post_submit.finalize", "WS-ARCH-001-04D2"), + "checker.model_call.prepare": ("checker.model_call.prepare", "PILOT-05"), + "checker.model_call.dispatch": ("checker.model_call.dispatch", "PILOT-05"), + "checker.model_call.recover": ("checker.model_call.recover", "PILOT-05"), + "checker.model_call.observe": ("checker.model_call.observe", "PILOT-05"), + "checker.model_call.complete": ("checker.model_call.complete", "PILOT-05"), + "checker.model_call.grant": ("checker.model_call.grant", "PILOT-05"), + "project.guide_compilation.model.prepare": ( + "project.guide_compilation.model.prepare", + "PILOT-05", + ), + "project.guide_compilation.model.dispatch": ( + "project.guide_compilation.model.dispatch", + "PILOT-05", + ), + "project.guide_compilation.model.observe": ( + "project.guide_compilation.model.observe", + "PILOT-05", + ), + "project.guide_compilation.model.complete": ( + "project.guide_compilation.model.complete", + "PILOT-05", + ), + "project.model_profile.provision": ("project.model_profile.manage", "PILOT-05"), + "project.model_budget.configure": ("project.model_budget.manage", "PILOT-05"), + "project.model_usage.read": ("project.model_budget.manage", "PILOT-05"), + "project.model_call.recovery_child.authorize": ( + "project.model_call.recovery_child.authorize", + "PILOT-05", + ), "checker.registry.register": ("operations.reconcile.run", "PILOT-04"), "checker.registry.read": ("operations.status.read", "PILOT-04"), "task.submission.list": ("submission.read_own", "task-checker-auth-cleanup"), @@ -300,11 +374,17 @@ "project.submission.read": ("project.task.manage", "task-checker-auth-cleanup"), "project.submission.checker_run.list": ("project.task.manage", "task-checker-auth-cleanup"), "project.checker_run.read": ("project.task.manage", "task-checker-auth-cleanup"), - "task.assignment.authority_reconcile": ("task.assignment.authority_reconcile", "WS-ARCH-001-03C1"), + "task.assignment.authority_reconcile": ( + "task.assignment.authority_reconcile", + "WS-ARCH-001-03C1", + ), "outbox.dispatch": ("outbox.dispatch", "WS-AUTH-001-OUTBOX-01"), "actor.profile.read_self": ("actor.profile.read_self", "WS-AUTH-001-07B"), "actor.profile.update_self": ("actor.profile.update_self", "WS-AUTH-001-07B"), - "operations.task.start_override": ("operations.task.start_override", "task-project-grant-authorization"), + "operations.task.start_override": ( + "operations.task.start_override", + "task-project-grant-authorization", + ), "task.claim": ("task.claim", "task-project-grant-authorization"), "task.start": ("task.claim", "task-project-grant-authorization"), "task.work_context.read": ("task.queue.read", "task-project-grant-authorization"), @@ -336,13 +416,16 @@ ), "operations.checker.retry": ("operations.checker.retry", "WS-AUTH-001-14"), "operations.checker_retry_source.list": ( - "operations.status.read", "WS-AUTH-001-14", + "operations.status.read", + "WS-AUTH-001-14", ), "operations.post_submit_delivery.list": ( - "operations.status.read", "WS-AUTH-001-14", + "operations.status.read", + "WS-AUTH-001-14", ), "operations.post_submit_delivery.reconcile": ( - "operations.reconcile.run", "WS-AUTH-001-14", + "operations.reconcile.run", + "WS-AUTH-001-14", ), "submission.create": ("submission.create", "WS-AUTH-001-14"), **{ @@ -417,10 +500,16 @@ "project.guide.create": ("project.guide.manage", "WS-AUTH-001-12D"), "project.guide.update": ("project.guide.manage", "WS-AUTH-001-12D"), "project.guide_source_snapshot.create": ("project.guide.manage", "WS-AUTH-001-12D"), - "project.guide_compilation.request_automatic": ("project.guide_compilation.execute", "WS-AUTH-001-12I"), + "project.guide_compilation.request_automatic": ( + "project.guide_compilation.execute", + "WS-AUTH-001-12I", + ), "project.guide_compilation.request": ("project.guide_compilation.request", "WS-AUTH-001-12I"), "project.guide_compilation.review_package.read": ("project.guide.manage", "WS-AUTH-001-12F4"), - "project.guide_compilation.correction.request": ("project.guide_compilation.request", "WS-AUTH-001-12F4"), + "project.guide_compilation.correction.request": ( + "project.guide_compilation.request", + "WS-AUTH-001-12F4", + ), "project.guide_compilation.execute": ("project.guide_compilation.execute", "WS-AUTH-001-12I"), "project.review_policy.update": ( "project.review_policy.manage", @@ -446,9 +535,9 @@ "project.effective_policy.manage", "WS-AUTH-001-12F2", ), - "project.submission_artifact_policy.derive": ( - "project.effective_policy.manage", - "WS-AUTH-001-12F3", + "project.submission_artifact_policy.derive": ( + "project.effective_policy.manage", + "WS-AUTH-001-12F3", ), "project.submission_artifact_policy.update": ( "project.effective_policy.manage", @@ -470,14 +559,45 @@ "project.effective_policy.manage", "WS-AUTH-001-12G", ), - "project.setup_run.update": ("project.guide.manage", "WS-AUTH-001-12B2"), "project.guide.activate": ("project.guide.manage", "WS-AUTH-001-12H"), # noqa: E501 - **dict.fromkeys((f"compensation.adapter_binding.{op}" for op in ("read", "create", "suspend", "resume")), ("compensation.adapter_binding.manage", "WS-ARCH-001-CP01A")), # noqa: E501 - **dict.fromkeys((f"contribution.policy.{op}" for op in ("read", "create_draft", "update_draft", "publish", "retire")), ("compensation.policy.manage", "WS-ARCH-001-CP01B")), # noqa: E501 - "contribution.compensation_unit.create": ("compensation.policy.manage", "project-points-unit-creation"), + "project.setup_run.update": ("project.guide.manage", "WS-AUTH-001-12B2"), + "project.guide.activate": ("project.guide.manage", "WS-AUTH-001-12H"), # noqa: E501 + **dict.fromkeys( + (f"compensation.adapter_binding.{op}" for op in ("read", "create", "suspend", "resume")), + ("compensation.adapter_binding.manage", "WS-ARCH-001-CP01A"), + ), # noqa: E501 + **dict.fromkeys( + ( + f"contribution.policy.{op}" + for op in ("read", "create_draft", "update_draft", "publish", "retire") + ), + ("compensation.policy.manage", "WS-ARCH-001-CP01B"), + ), # noqa: E501 + "contribution.compensation_unit.create": ( + "compensation.policy.manage", + "project-points-unit-creation", + ), } AUDIT_ALLOWED_ACTION_VALUES = { + "project.catalogue.list", + "checker.model_call.prepare", + "checker.model_call.dispatch", + "checker.model_call.recover", + "checker.model_call.observe", + "checker.model_call.complete", + "checker.model_call.grant", + "project.guide_compilation.model.prepare", + "project.guide_compilation.model.dispatch", + "project.guide_compilation.model.observe", + "project.guide_compilation.model.complete", + "project.model_profile.provision", + "project.model_budget.configure", + "project.model_usage.read", + "project.model_call.recovery_child.authorize", + "artifact.checker_output.write", + "artifact.checker_output.binding.create", + "task.revision.expire", "task.revision.recover", "checker.pre_submit.execute", @@ -490,7 +610,6 @@ "contribution.read_self", "contribution.read_project", "actor.project.list", - "project.catalogue.list", "actor.assignment.list", "actor.submission.list", "actor.work_summary.read", @@ -631,3 +750,45 @@ "project_role_grant.revoke", "submission.create", } + + +ADMIN_ROLE_PERMISSION_EXPECTATIONS = { + AdminRole.ACCESS_ADMINISTRATOR: """actor.profile.read_any actor.profile.suspend + actor.profile.reactivate actor.profile.deactivate actor.identity_link.read + actor.identity_link.revoke actor.identity_link.reactivate actor.service.provision + admin_role.read admin_role.grant admin_role.revoke audit.read audit.export""".split(), + AdminRole.OPERATOR: """project.read project.setup_diagnostic.read + project.effective_policy.read review.queue.inspect review.lease.force_release + contribution.read_project operations.status.read operations.timer.run operations.reconcile.run operations.outbox.retry + operations.projection.rebuild operations.task.start_override + operations.submission_gate.repair operations.checker.retry + project.model_profile.manage project.model_budget.manage + project.model_call.recovery_child.authorize artifact.binding.read + artifact.replica.read artifact.receipt.read artifact.verification_job.read + artifact.verification_job.retry artifact.recovery_attempt.read artifact.audit.read + audit.read""".split(), + AdminRole.PROJECT_MANAGER: """project.create project.read project.setup_diagnostic.read + project.effective_policy.read project.update project.archive project.guide.manage + project.guide_compilation.request project.effective_policy.manage project.task.manage + project.review_policy.manage project.role_grant.read project.role_grant.manage + artifact.guide_source.ingest + review.queue.inspect contribution.read_project compensation.award.read + audit.read""".split(), + AdminRole.FINANCE_AUTHORITY: """project.read contribution.read_project + compensation.policy.manage compensation.adapter_binding.manage + compensation.award.read compensation.delivery.reconcile audit.read""".split(), + AdminRole.AUDIT_AUTHORITY: """actor.profile.read_any actor.identity_link.read + admin_role.read project.read project.setup_diagnostic.read + project.effective_policy.read project.role_grant.read review.queue.inspect + review.chain.read contribution.read_project compensation.award.read audit.read + audit.export""".split(), +} + + +ADMIN_ROLE_SCOPE_EXPECTATIONS = { + AdminRole.ACCESS_ADMINISTRATOR: [AdminScope.SYSTEM], + AdminRole.OPERATOR: [AdminScope.SYSTEM], + AdminRole.PROJECT_MANAGER: [AdminScope.SYSTEM, AdminScope.PROJECT], + AdminRole.FINANCE_AUTHORITY: [AdminScope.SYSTEM, AdminScope.PROJECT], + AdminRole.AUDIT_AUTHORITY: [AdminScope.SYSTEM, AdminScope.PROJECT], +} diff --git a/backend/tests/authorization/checker_models/test_contracts.py b/backend/tests/authorization/checker_models/test_contracts.py new file mode 100644 index 000000000..2af1f0769 --- /dev/null +++ b/backend/tests/authorization/checker_models/test_contracts.py @@ -0,0 +1,411 @@ +"""Discriminating pure contracts for checker model and output authority.""" + +from __future__ import annotations + +from datetime import UTC, datetime, timedelta +from types import SimpleNamespace + +import pytest +from pydantic import TypeAdapter, ValidationError + +from app.core.hashing import canonical_json_hash +from app.core.identifiers import new_record_id +from app.modules.actors.api import ServiceIdentity +from app.modules.authorization.catalogue import ActionId, PermissionId +from app.modules.authorization.domain.audit import ( + AuthorizationDecisionResourceType, + CONTEXT_DIGEST_RESOURCE_TYPES, +) +from app.modules.authorization.model_usage_authorization import CheckerModelAuthorization +from app.modules.authorization.domain.model_usage import ( + GRANT, + RECOVER, + SETUP_PREPARE, + ModelCallGrantAuthorityFacts, + ModelCallRecoverAuthorityFacts, + SetupModelPrepareAuthorityFacts, + checker_model_call_resource, + setup_model_usage_resource, +) +from app.modules.authorization.domain.checker_outputs import ( + BIND, + WRITE, + checker_output_prepare_matches, + checker_output_prepare_resource, + checker_output_stored_resource, + parse_checker_output_prepare, +) +from app.modules.authorization.prepared import PreparedAuthorizationHandle +from app.modules.authorization.runtime import ( + ActorKind, + ActorStatus, + AuthorizationDecision, + IdentityLinkStatus, + MatchedAuthorityKind, + ServiceAuthorizationContext, +) +from app.modules.model_usage.api import ( + ModelCallEffectAuthorityFacts, + ModelCallExecutionFacts, + ModelCallUnavailable, + ModelPaidSlotKey, + SetupModelOutputReservation, + SetupModelOutputSelector, + SetupModelOutputSlot, + SetupModelUsageSlotKey, +) +from app.modules.checkers.api.output_custody import ( + CheckerOutputReservation, + CheckerOutputSelector, + CheckerOutputSlot, + generated_output_subject, +) + + +def sha(label: str) -> str: + return canonical_json_hash({"label": label}) + + +def execution(*, project_id=None, task_id=None) -> ModelCallExecutionFacts: + return ModelCallExecutionFacts( + project_id=project_id or new_record_id(), + task_id=task_id or new_record_id(), + assignment_id=new_record_id(), + submission_id=new_record_id(), + submission_version=1, + evaluation_request_id=new_record_id(), + evaluation_generation=1, + checker_run_id=new_record_id(), + worker_lease_id=new_record_id(), + worker_lease_generation=1, + worker_lease_expires_at=datetime.now(UTC) + timedelta(minutes=5), + ) + + +def paid_slot(current: ModelCallExecutionFacts) -> ModelPaidSlotKey: + return ModelPaidSlotKey( + project_id=current.project_id, + assignment_id=current.assignment_id, + submission_id=current.submission_id, + submission_version=current.submission_version, + requirement_id="model_quality", + registry_entry_digest=sha("registry"), + configuration_sha256=sha("configuration"), + profile_id=new_record_id(), + profile_version=1, + profile_digest=sha("profile"), + ) + + +def effect(current: ModelCallExecutionFacts, slot: ModelPaidSlotKey): + return ModelCallEffectAuthorityFacts( + project_id=current.project_id, + task_id=current.task_id, + slot_id=new_record_id(), + slot_key=slot, + attempt_id=new_record_id(), + internal_call_request_id=new_record_id(), + ) + + +def selector(current: ModelCallExecutionFacts, slot: ModelPaidSlotKey, *, role="model_request"): + return CheckerOutputSelector( + paid_slot=slot, + task_id=current.task_id, + model_call_slot_id=new_record_id(), + model_call_attempt_id=new_record_id(), + slot_key=role, + content_sha256=sha(role), + execution=current if role == "model_request" else None, + ) + + +def test_recovery_resource_rejects_substituted_effect_scope() -> None: + current = execution() + slot = paid_slot(current) + altered = effect(current, slot).model_copy(update={"task_id": new_record_id()}) + + with pytest.raises(ValueError, match="recovery effect mismatch"): + checker_model_call_resource( + RECOVER, + ModelCallRecoverAuthorityFacts(execution=current, effect=altered), + ) + + +def test_grant_resource_binds_opaque_digests_and_exact_slot() -> None: + current = execution() + slot = paid_slot(current) + facts = ModelCallGrantAuthorityFacts( + execution=current, + slot=slot, + external_request_digest=sha("external request"), + token_hash=sha("opaque token"), + ) + resource = checker_model_call_resource(GRANT, facts) + + assert resource.scope_project_id == current.project_id + assert resource.resource_id == current.evaluation_request_id + assert "token" not in resource.model_dump(mode="json")["facts"] + assert resource.facts.token_hash == sha("opaque token") + + +@pytest.mark.parametrize("action", [WRITE, BIND]) +def test_output_prepare_accepts_its_json_packet_without_coercing_integers(action) -> None: + """Prepared AUTH receives JSON values, including nested UUIDs and timestamps.""" + current = execution() + prepared = checker_output_prepare_resource(action, selector(current, paid_slot(current))) + packet = prepared.model_dump(mode="json") + assert parse_checker_output_prepare(action, packet, RuntimeError) == prepared + + packet["selector"]["paid_slot"]["submission_version"] = "1" + with pytest.raises(RuntimeError) as rejected: + parse_checker_output_prepare(action, packet, RuntimeError) + assert isinstance(rejected.value.__cause__, ValidationError) + assert any( + error["loc"][-2:] == ("paid_slot", "submission_version") + and error["type"] == "int_type" + for error in rejected.value.__cause__.errors() + ) + + +def setup_selector(*, role="model_request") -> SetupModelOutputSelector: + key = SetupModelUsageSlotKey( + compilation_attempt_id=new_record_id(), + project_id=new_record_id(), + guide_id=new_record_id(), + source_snapshot_id=new_record_id(), + setup_run_id=new_record_id(), + setup_generation=1, + runtime_configuration_hash=sha("runtime configuration"), + profile_id=new_record_id(), + profile_version=1, + profile_digest=sha("setup profile"), + phase="draft", + call_index=0, + ) + return SetupModelOutputSelector( + slot_key=key, + model_call_slot_id=new_record_id(), + model_call_attempt_id=new_record_id(), + role=role, + content_sha256=sha(role), + ) + + +def test_setup_usage_resource_is_admitted_by_decision_and_audit_vocabularies() -> None: + slot = setup_selector().slot_key + resource = setup_model_usage_resource( + SETUP_PREPARE, + SetupModelPrepareAuthorityFacts( + slot=slot, + request_sha256=sha("setup request"), + ), + ) + + assert resource.resource_type == "setup_model_usage" + assert ( + TypeAdapter(AuthorizationDecisionResourceType).validate_python( + resource.resource_type + ) + == resource.resource_type + ) + assert resource.resource_type in CONTEXT_DIGEST_RESOURCE_TYPES + + +@pytest.mark.parametrize("action", [WRITE, BIND]) +def test_setup_output_resource_is_closed_to_exact_effect_and_project(action) -> None: + selected = setup_selector() + reservation = SetupModelOutputReservation( + selector=selected, + slot=SetupModelOutputSlot(key="model_request", maximum_bytes=2048), + ) + prepared = checker_output_prepare_resource(action, selected) + + assert prepared.resource_type == "setup_model_call" + assert prepared.resource_id == selected.model_call_attempt_id + assert prepared.scope_project_id == selected.slot_key.project_id + subject = generated_output_subject(reservation) + assert subject.operation_identity == canonical_json_hash( + { + "request_type": "setup_model_output", + "model_call_attempt_id": str(selected.model_call_attempt_id), + "logical_role": "model_request", + } + ) + assert subject.request_digest_facts == { + "slot_key": selected.slot_key.model_dump(mode="json"), + "model_call_slot_id": str(selected.model_call_slot_id), + "model_call_attempt_id": str(selected.model_call_attempt_id), + "content_sha256": selected.content_sha256, + "role": "model_request", + "media_type": "application/json", + "maximum_bytes": 2048, + } + assert parse_checker_output_prepare( + action, prepared.model_dump(mode="json"), RuntimeError + ) == prepared + + substituted = selected.model_copy( + update={"model_call_attempt_id": new_record_id()} + ) + with pytest.raises(ValueError, match="differs from selector"): + checker_output_stored_resource( + action, + substituted, + reservation, + SimpleNamespace( + put_attempt_id=new_record_id(), + checker_request_digest=sha("custody"), + sha256=substituted.content_sha256, + byte_count=100, + media_type="application/json", + status="object_confirmed", + content_id=None, + replica_id=None, + verification_receipt_id=None, + ), + ) + + +def test_output_final_facts_cannot_substitute_selector_or_unverified_binding() -> None: + current = execution() + slot = paid_slot(current) + selected = selector(current, slot) + reservation = CheckerOutputReservation( + selector=selected, + slot=CheckerOutputSlot(key="model_request", maximum_bytes=1024), + ) + request_digest = canonical_json_hash( + { + "model_call_slot_id": str(selected.model_call_slot_id), + "model_call_attempt_id": str(selected.model_call_attempt_id), + "paid_slot": selected.paid_slot.model_dump(mode="json"), + "task_id": str(selected.task_id), + "content_sha256": selected.content_sha256, + "slot_key": "model_request", + "media_type": "application/json", + "maximum_bytes": 1024, + } + ) + stored = SimpleNamespace( + put_attempt_id=new_record_id(), + checker_request_digest=request_digest, + sha256=selected.content_sha256, + byte_count=100, + media_type="application/json", + status="verified", + content_id=new_record_id(), + replica_id=new_record_id(), + verification_receipt_id=new_record_id(), + ) + prepared = checker_output_prepare_resource(BIND, selected) + final = checker_output_stored_resource(BIND, selected, reservation, stored) + + assert checker_output_prepare_matches(prepared, final) + substituted = selected.model_copy(update={"model_call_attempt_id": new_record_id()}) + assert not checker_output_prepare_matches( + prepared, + final.model_copy( + update={ + "resource_id": substituted.model_call_attempt_id, + "selector": substituted, + } + ), + ) + unverified = SimpleNamespace( + **{ + **stored.__dict__, + "status": "object_confirmed", + "content_id": None, + "replica_id": None, + "verification_receipt_id": None, + } + ) + assert checker_output_prepare_matches( + checker_output_prepare_resource(WRITE, selected), + checker_output_stored_resource(WRITE, selected, reservation, unverified), + ) + with pytest.raises(ValidationError, match="binding requires verified ancestry"): + checker_output_stored_resource( + BIND, + selected, + reservation, + unverified, + ) + + +class _Prepared: + def __init__(self, authorization) -> None: + self._authorization = authorization + self._context = authorization._context + self.resource = None + + async def prepare(self, action, caller_input, scope): + self.action, self.input, self.scope = action, caller_input, scope + return object.__new__(PreparedAuthorizationHandle) + + async def consume(self, handle, action, caller_input, resource): + self.resource = resource + return AuthorizationDecision( + decision_id=new_record_id(), + allowed=True, + action_id=action, + permission_id=PermissionId.CHECKER_MODEL_CALL_GRANT, + resource_type=resource.resource_type, + resource_id=resource.resource_id, + resource_context_digest=sha("decision resource"), + matched_authority_kind=MatchedAuthorityKind.FIXED_SERVICE, + matched_grant_id=None, + matched_scope_project_id=None, + request_id=self._context.request_id, + correlation_id=self._context.correlation_id, + revalidated=True, + denial_code=None, + ) + + +class _Authorization: + def __init__(self, context) -> None: + self._context = context + + +@pytest.mark.asyncio +async def test_grant_adapter_emits_exact_resource_and_rejects_wrong_service() -> None: + current = execution() + slot = paid_slot(current) + context = ServiceAuthorizationContext( + actor_profile_id=new_record_id(), + actor_kind=ActorKind.SERVICE, + actor_status=ActorStatus.ACTIVE, + identity_link_id=new_record_id(), + identity_link_status=IdentityLinkStatus.ACTIVE, + service_identity=ServiceIdentity.CHECKER_POST_SUBMIT, + request_id=new_record_id(), + correlation_id=new_record_id(), + ) + authorization = _Authorization(context) + prepared = _Prepared(authorization) + adapter = CheckerModelAuthorization(authorization, prepared) + + evidence = await adapter.authorize_grant( + current, slot, sha("external request"), sha("opaque token") + ) + + assert prepared.action is ActionId.CHECKER_MODEL_CALL_GRANT + assert prepared.resource.facts.execution == current + assert prepared.resource.facts.slot == slot + assert evidence.facts_digest == canonical_json_hash( + { + "action": "grant", + "facts": prepared.resource.facts.model_dump(mode="json"), + } + ) + + wrong_context = context.model_copy( + update={"service_identity": ServiceIdentity.ARTIFACT_CHECKER_OUTPUT} + ) + wrong_authorization = _Authorization(wrong_context) + with pytest.raises(ModelCallUnavailable, match="authority_unavailable"): + await CheckerModelAuthorization( + wrong_authorization, _Prepared(wrong_authorization) + ).authorize_grant(current, slot, sha("external request"), sha("opaque token")) diff --git a/backend/tests/authorization/setup_finalization/test_catalogue.py b/backend/tests/authorization/setup_finalization/test_catalogue.py index a5eb34e94..9213e5c03 100644 --- a/backend/tests/authorization/setup_finalization/test_catalogue.py +++ b/backend/tests/authorization/setup_finalization/test_catalogue.py @@ -29,6 +29,23 @@ EXPECTED_ACTIVE_ACTIONS = frozenset({ + ActionId.CHECKER_MODEL_CALL_PREPARE, + ActionId.CHECKER_MODEL_CALL_DISPATCH, + ActionId.CHECKER_MODEL_CALL_RECOVER, + ActionId.CHECKER_MODEL_CALL_OBSERVE, + ActionId.CHECKER_MODEL_CALL_COMPLETE, + ActionId.CHECKER_MODEL_CALL_GRANT, + ActionId.PROJECT_GUIDE_COMPILATION_MODEL_PREPARE, + ActionId.PROJECT_GUIDE_COMPILATION_MODEL_DISPATCH, + ActionId.PROJECT_GUIDE_COMPILATION_MODEL_OBSERVE, + ActionId.PROJECT_GUIDE_COMPILATION_MODEL_COMPLETE, + ActionId.PROJECT_MODEL_PROFILE_PROVISION, + ActionId.PROJECT_MODEL_BUDGET_CONFIGURE, + ActionId.PROJECT_MODEL_USAGE_READ, + ActionId.PROJECT_MODEL_CALL_RECOVERY_CHILD_AUTHORIZE, + ActionId.ARTIFACT_CHECKER_OUTPUT_WRITE, + ActionId.ARTIFACT_CHECKER_OUTPUT_BINDING_CREATE, + ActionId.PROJECT_TASK_IMPORT, ActionId.PROJECT_TASK_IMPORT_BATCH_READ, ActionId.CONTRIBUTION_READ_SELF, ActionId.CONTRIBUTION_READ_PROJECT, ActionId.ACTOR_PROJECT_LIST, ActionId.ACTOR_ASSIGNMENT_LIST, ActionId.ACTOR_SUBMISSION_LIST, @@ -241,6 +258,10 @@ async def grant(*args, **kwargs): def test_setup_existing_active_pairs_and_downstream_plans_are_preserved(): expected = { + ActionId.PROJECT_GUIDE_COMPILATION_MODEL_PREPARE: PermissionId.PROJECT_GUIDE_COMPILATION_MODEL_PREPARE, + ActionId.PROJECT_GUIDE_COMPILATION_MODEL_DISPATCH: PermissionId.PROJECT_GUIDE_COMPILATION_MODEL_DISPATCH, + ActionId.PROJECT_GUIDE_COMPILATION_MODEL_OBSERVE: PermissionId.PROJECT_GUIDE_COMPILATION_MODEL_OBSERVE, + ActionId.PROJECT_GUIDE_COMPILATION_MODEL_COMPLETE: PermissionId.PROJECT_GUIDE_COMPILATION_MODEL_COMPLETE, ActionId.PROJECT_GUIDE_COMPILATION_REQUEST_AUTOMATIC: PermissionId.PROJECT_GUIDE_COMPILATION_EXECUTE, ActionId.PROJECT_GUIDE_COMPILATION_EXECUTE: PermissionId.PROJECT_GUIDE_COMPILATION_EXECUTE, ActionId.PROJECT_GUIDE_SUFFICIENCY_RUN: PermissionId.PROJECT_GUIDE_MANAGE, diff --git a/backend/tests/authorization/test_catalogue.py b/backend/tests/authorization/test_catalogue.py index e85b7ea4f..8806af85d 100644 --- a/backend/tests/authorization/test_catalogue.py +++ b/backend/tests/authorization/test_catalogue.py @@ -8,25 +8,38 @@ from app.modules.authorization.admin_service import AdminRoleGrantService from app.modules.authorization.catalogue import ( - ACTION_IDS, ACTION_DEFINITIONS, ACTION_BY_ID, PERMISSION_IDS, - HISTORICAL_PERMISSION_IDS, NEW_PERMISSION_IDS, ActionId, PermissionId, - ActionOwner, ActionAvailability, resolve_executable_action, - FUTURE_INTENT_REQUIRED_ACTIONS, SERVICE_ACTIONS_BY_IDENTITY, + ACTION_IDS, + ACTION_DEFINITIONS, + ACTION_BY_ID, + PERMISSION_IDS, + HISTORICAL_PERMISSION_IDS, + NEW_PERMISSION_IDS, + ActionId, + PermissionId, + ActionOwner, + ActionAvailability, + resolve_executable_action, + FUTURE_INTENT_REQUIRED_ACTIONS, + SERVICE_ACTIONS_BY_IDENTITY, ) from tests.authorization.catalogue_fixtures import ( - ART_CUSTODY_EXPECTATIONS, REV_CUSTODY_EXPECTATIONS, FIXED_SERVICE_ACTION_EXPECTATIONS, + ART_CUSTODY_EXPECTATIONS, + REV_CUSTODY_EXPECTATIONS, + FIXED_SERVICE_ACTION_EXPECTATIONS, ) def test_closed_permission_and_action_catalogue_is_exact_and_non_executable() -> None: from tests.authorization.catalogue_fixtures import ( - historical_permissions, new_permissions, expected + historical_permissions, + new_permissions, + expected, ) assert {item.value for item in HISTORICAL_PERMISSION_IDS} == historical_permissions assert {item.value for item in NEW_PERMISSION_IDS} == new_permissions assert {item.value for item in PERMISSION_IDS} == historical_permissions | new_permissions - assert len(ACTION_IDS) == len(ACTION_DEFINITIONS) == len(ACTION_BY_ID) == 178 + assert len(ACTION_IDS) == len(ACTION_DEFINITIONS) == len(ACTION_BY_ID) == 192 assert set(ACTION_BY_ID) == ACTION_IDS assert {definition.owner for definition in ACTION_DEFINITIONS} == set(ActionOwner) assert { @@ -105,10 +118,13 @@ def test_closed_permission_and_action_catalogue_is_exact_and_non_executable() -> } assert all(not owner.value.startswith("WS-REV-") for owner in ActionOwner) assert Counter(definition.availability for definition in ACTION_DEFINITIONS) == { - ActionAvailability.ACTIVE: 152, - ActionAvailability.PLANNED: 26, + ActionAvailability.ACTIVE: 168, + ActionAvailability.PLANNED: 24, } - assert resolve_executable_action(ActionId.ACTOR_PROFILE_READ_SELF).permission_id is PermissionId.ACTOR_PROFILE_READ_SELF + assert ( + resolve_executable_action(ActionId.ACTOR_PROFILE_READ_SELF).permission_id + is PermissionId.ACTOR_PROFILE_READ_SELF + ) with pytest.raises(ValueError, match="not active"): resolve_executable_action(ActionId.REVIEW_QUEUE_READ) with pytest.raises(TypeError): @@ -132,7 +148,7 @@ def test_proposal_actions_have_exact_executable_authority(action): def test_permission_definition_response_is_exact() -> None: permission_response = AdminRoleGrantService.permission_definitions() - assert permission_response.total == 82 + assert permission_response.total == 95 assert [item.permission_id.value for item in permission_response.items] == sorted( permission.value for permission in PermissionId ) @@ -147,7 +163,7 @@ def test_fixed_service_action_matrix_and_activation_are_exact_and_immutable() -> identity: {action.value for action in actions} for identity, actions in SERVICE_ACTIONS_BY_IDENTITY.items() } == expected - assert sum(map(len, SERVICE_ACTIONS_BY_IDENTITY.values())) == 32 + assert sum(map(len, SERVICE_ACTIONS_BY_IDENTITY.values())) == 42 assert FUTURE_INTENT_REQUIRED_ACTIONS == { ActionId.REVIEW_FINDING_EVIDENCE_INGEST, ActionId.REVIEW_FINDING_RESPONSE_EVIDENCE_INGEST, @@ -178,6 +194,26 @@ def test_fixed_service_action_matrix_and_activation_are_exact_and_immutable() -> ActionOwner.AUTH_12I, ActionAvailability.ACTIVE, ), + ActionId.PROJECT_GUIDE_COMPILATION_MODEL_PREPARE: ( + PermissionId.PROJECT_GUIDE_COMPILATION_MODEL_PREPARE, + ActionOwner.PILOT_05, + ActionAvailability.ACTIVE, + ), + ActionId.PROJECT_GUIDE_COMPILATION_MODEL_DISPATCH: ( + PermissionId.PROJECT_GUIDE_COMPILATION_MODEL_DISPATCH, + ActionOwner.PILOT_05, + ActionAvailability.ACTIVE, + ), + ActionId.PROJECT_GUIDE_COMPILATION_MODEL_OBSERVE: ( + PermissionId.PROJECT_GUIDE_COMPILATION_MODEL_OBSERVE, + ActionOwner.PILOT_05, + ActionAvailability.ACTIVE, + ), + ActionId.PROJECT_GUIDE_COMPILATION_MODEL_COMPLETE: ( + PermissionId.PROJECT_GUIDE_COMPILATION_MODEL_COMPLETE, + ActionOwner.PILOT_05, + ActionAvailability.ACTIVE, + ), ActionId.PROJECT_GUIDE_SUFFICIENCY_RUN: ( PermissionId.PROJECT_GUIDE_MANAGE, ActionOwner.AUTH_12E, @@ -208,10 +244,16 @@ def test_active_fixed_service_actions_are_exact(): ActionId.CHECKER_PRE_SUBMIT_FINALIZE, ActionId.CHECKER_POST_SUBMIT_EXECUTE, ActionId.CHECKER_POST_SUBMIT_FINALIZE, + ActionId.CHECKER_MODEL_CALL_PREPARE, + ActionId.CHECKER_MODEL_CALL_DISPATCH, + ActionId.CHECKER_MODEL_CALL_RECOVER, + ActionId.CHECKER_MODEL_CALL_OBSERVE, + ActionId.CHECKER_MODEL_CALL_COMPLETE, + ActionId.CHECKER_MODEL_CALL_GRANT, ActionId.ARTIFACT_POST_SUBMIT_CHECKER_INPUT_MATERIALIZE, ActionId.TASK_ASSIGNMENT_AUTHORITY_RECONCILE, - ActionId.TASK_REVISION_EXPIRE, - ActionId.TASK_REVISION_RECOVER, + ActionId.TASK_REVISION_EXPIRE, + ActionId.TASK_REVISION_RECOVER, ActionId.OUTBOX_DISPATCH, ActionId.TASK_POST_SUBMIT_ROUTE, ActionId.ARTIFACT_VERIFICATION_EXECUTE, @@ -219,9 +261,15 @@ def test_active_fixed_service_actions_are_exact(): ActionId.ARTIFACT_PRE_SUBMIT_CHECKER_INPUT_MATERIALIZE, ActionId.ARTIFACT_PENDING_WORK_SCAN, ActionId.ARTIFACT_SUBMISSION_BINDING_CREATE, + ActionId.ARTIFACT_CHECKER_OUTPUT_BINDING_CREATE, + ActionId.ARTIFACT_CHECKER_OUTPUT_WRITE, ActionId.ARTIFACT_GUIDE_SOURCE_READ, ActionId.PROJECT_GUIDE_COMPILATION_EXECUTE, ActionId.PROJECT_GUIDE_COMPILATION_REQUEST_AUTOMATIC, + ActionId.PROJECT_GUIDE_COMPILATION_MODEL_PREPARE, + ActionId.PROJECT_GUIDE_COMPILATION_MODEL_DISPATCH, + ActionId.PROJECT_GUIDE_COMPILATION_MODEL_OBSERVE, + ActionId.PROJECT_GUIDE_COMPILATION_MODEL_COMPLETE, ActionId.PROJECT_GUIDE_SUFFICIENCY_RUN, ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_DERIVE, ActionId.PROJECT_SETUP_RUN_UPDATE, diff --git a/backend/tests/checker_output_admission_helpers.py b/backend/tests/checker_output_admission_helpers.py index e9376dd50..2c88fae67 100644 --- a/backend/tests/checker_output_admission_helpers.py +++ b/backend/tests/checker_output_admission_helpers.py @@ -1,17 +1,26 @@ """Controlled complete CHECKERS owner facts for ART output-custody tests.""" +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager +from datetime import UTC, datetime, timedelta +import re from uuid import UUID -from sqlalchemy import func, select -from sqlalchemy.ext.asyncio import async_sessionmaker +from sqlalchemy import func, select, text +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker from app.core.identifiers import new_record_id +from app.core.hashing import canonical_json_hash from app.modules.actors.api import ServiceIdentity +from app.modules.actors.models import ActorProfile from app.modules.actors.service import ActorService from app.modules.artifacts.schemas import ( ArtifactAuthorityDeniedError, CheckerOutputArtifactAdmissionRequest, ) +from app.modules.artifacts.api.checker_output_authority import ( + CheckerOutputCommitmentAuthorityFacts, +) from app.modules.artifacts.models import ( ArtifactAdmissionCharge, ArtifactAdmissionScope, @@ -24,24 +33,80 @@ ) from app.modules.checkers.api.output_custody import ( CheckerOutputReservation, + CheckerOutputSelector, CheckerOutputSlot, ) +from app.modules.model_usage.api import ModelCallExecutionFacts, ModelPaidSlotKey from app.modules.checkers.execution_repository import stored_request +from app.modules.model_usage.models import ( + CheckerModelUsageEnvelope, + CheckerModelUsageSlot, + ModelUsageEffect, + ModelUsageProfile, +) from app.modules.checkers.models import CheckerRun -from app.modules.tasks.models import Submission, WorkstreamTask +from app.modules.tasks.models import AuditEvent, Submission, WorkstreamTask + + +_ISOLATED_DATABASE_RE = re.compile(r"workstream_test_([a-f0-9]{12})") +_ISOLATED_ROLE_RE = re.compile(r"workstream_role_([a-f0-9]{12})") +_CONTROLLED_MODEL_GUARDS = { + "model_usage_profiles": ("model_usage_profiles_guard",), + "model_usage_effects": ( + "model_usage_effects_guard", + "checker_model_call_attempt_reconcile", + ), +} + + +@asynccontextmanager +async def _suspend_controlled_model_guard( + session: AsyncSession, *, table: str +) -> AsyncIterator[None]: + """Suspend one named guard only in the isolated database for this ART fixture.""" + triggers = _CONTROLLED_MODEL_GUARDS.get(table) + if triggers is None: + raise RuntimeError("unsupported model usage guard suspension") + database_name, database_role = ( + await session.execute(text("select current_database(), current_user")) + ).one() + database_match = _ISOLATED_DATABASE_RE.fullmatch(str(database_name)) + role_match = _ISOLATED_ROLE_RE.fullmatch(str(database_role)) + if ( + database_match is None + or role_match is None + or database_match.group(1) != role_match.group(1) + ): + raise RuntimeError("model usage guard suspension requires an isolated test database") + try: + for trigger in triggers: + await session.execute(text(f"alter table {table} disable trigger {trigger}")) + yield + # PostgreSQL will not ALTER a table with deferred FK events pending. This + # fixture owns its dedicated arrangement session, so finish the controlled + # seed before restoring the guard in a new transaction. + await session.commit() + for trigger in triggers: + await session.execute(text(f"alter table {table} enable trigger {trigger}")) + await session.commit() + except BaseException: + await session.rollback() + for trigger in triggers: + await session.execute(text(f"alter table {table} enable trigger {trigger}")) + await session.commit() + raise async def make_checker_output_reservation( session, checker_run_id: str | UUID, *, - slot_key: str = "platform-review", - media_type: str = "application/octet-stream", + slot_key: str = "model_response", + media_type: str = "application/json", maximum_bytes: int = 1024, - worker_lease_id: UUID | None = None, - worker_lease_generation: int = 1, + content_sha256: str | None = None, ) -> CheckerOutputReservation: - """Derive hash-valid public owner facts from one actual seeded run.""" + """Create one controlled effect beneath an actual seeded run for ART tests.""" checker_run = await session.get(CheckerRun, str(checker_run_id)) assert checker_run is not None submission = await session.scalar( @@ -54,22 +119,183 @@ async def make_checker_output_reservation( task = await session.get(WorkstreamTask, checker_run.task_id) assert submission is not None and task is not None evaluation = stored_request(checker_run) - assert (evaluation.project_id, evaluation.task_id, evaluation.submission_id, - evaluation.submission_version, evaluation.assignment_id) == ( - UUID(task.project_id), UUID(task.id), UUID(submission.id), - submission.version, UUID(submission.task_assignment_id), + assert ( + evaluation.project_id, + evaluation.task_id, + evaluation.submission_id, + evaluation.submission_version, + evaluation.assignment_id, + ) == ( + UUID(task.project_id), + UUID(task.id), + UUID(submission.id), + submission.version, + UUID(submission.task_assignment_id), + ) + profile_version = ( + int( + await session.scalar( + select(func.coalesce(func.max(ModelUsageProfile.profile_version), 0)).where( + ModelUsageProfile.project_id == str(evaluation.project_id) + ) ) - return CheckerOutputReservation( - evaluation=evaluation, + or 0 + ) + + 1 + ) + profile_id = new_record_id() + actor_id = await session.scalar(select(ActorProfile.id).order_by(ActorProfile.id).limit(1)) + audit_event_id = await session.scalar( + select(AuditEvent.id) + .where( + ~select(ModelUsageProfile.id) + .where(ModelUsageProfile.authorization_evidence_id == AuditEvent.id) + .exists() + ) + .order_by(AuditEvent.id) + .limit(1) + ) + assert actor_id is not None and audit_event_id is not None + per_call_bytes = max(maximum_bytes, 1024) + profile_digest = canonical_json_hash( + {"project_id": str(evaluation.project_id), "profile_id": str(profile_id)} + ) + profile = ModelUsageProfile( + id=str(profile_id), + project_id=str(evaluation.project_id), + profile_version=profile_version, + profile_digest=profile_digest, + provider="test_provider", + api="test.responses.v1", + model_snapshot="test-model-2026-10-01", + generation_settings={"reasoning_effort": "low", "text_verbosity": "low"}, + maximum_request_bytes=per_call_bytes, + maximum_output_bytes=per_call_bytes, + maximum_response_bytes=6 * per_call_bytes + 4096, + maximum_input_tokens=1024, + maximum_output_tokens=1024, + maximum_calls=1, + maximum_total_request_bytes=per_call_bytes, + maximum_total_response_bytes=6 * per_call_bytes + 4096, + maximum_total_input_tokens=1024, + maximum_total_output_tokens=1024, + allowed_tools=(), + maximum_tool_calls=0, + maximum_tool_output_bytes=0, + deadline_ms=30_000, + pricing_policy_id=str(new_record_id()), + pricing_policy_version=1, + pricing_policy_digest=canonical_json_hash({"profile_id": str(profile_id), "rate": 1}), + uncached_input_rate=1, + cached_input_rate=1, + output_rate=1, + operation_id=str(new_record_id()), + actor_profile_id=str(actor_id), + authorization_evidence_id=str(audit_event_id), + ) + session.add(profile) + # This fixture proves ART admission and intentionally does not reproduce MODEL-USAGE + # authorization/evidence setup. Dedicated real-PG tests exercise both guards. + async with _suspend_controlled_model_guard(session, table="model_usage_profiles"): + await session.flush() + configuration_sha256 = canonical_json_hash({"profile_id": str(profile_id)}) + paid_slot = ModelPaidSlotKey( + project_id=evaluation.project_id, + assignment_id=evaluation.assignment_id, + submission_id=evaluation.submission_id, + submission_version=evaluation.submission_version, + requirement_id="model_evidence", + registry_entry_digest=canonical_json_hash({"checker_run_id": checker_run.id}), + configuration_sha256=configuration_sha256, + profile_id=profile_id, + profile_version=profile_version, + profile_digest=profile_digest, + ) + digest = content_sha256 or canonical_json_hash( + {"model_call": str(profile_id), "role": slot_key} + ) + envelope_id, model_slot_id, attempt_id = ( + new_record_id(), + new_record_id(), + new_record_id(), + ) + session.add( + CheckerModelUsageEnvelope( + id=str(envelope_id), + project_id=str(evaluation.project_id), + task_id=str(evaluation.task_id), + assignment_id=str(evaluation.assignment_id), + submission_id=str(evaluation.submission_id), + submission_version=evaluation.submission_version, + requirement_id=paid_slot.requirement_id, + registry_entry_digest=paid_slot.registry_entry_digest, + configuration_sha256=paid_slot.configuration_sha256, + profile_id=str(profile_id), + profile_version=profile_version, + profile_digest=profile_digest, + ) + ) + session.add( + CheckerModelUsageSlot( + id=str(model_slot_id), + envelope_id=str(envelope_id), + project_id=str(evaluation.project_id), + task_id=str(evaluation.task_id), + assignment_id=str(evaluation.assignment_id), + submission_id=str(evaluation.submission_id), + submission_version=evaluation.submission_version, + requirement_id=paid_slot.requirement_id, + registry_entry_digest=paid_slot.registry_entry_digest, + configuration_sha256=paid_slot.configuration_sha256, + profile_id=str(profile_id), + profile_version=profile_version, + profile_digest=profile_digest, + slot_index=0, + first_request_sha256=digest, + ) + ) + await session.flush() + session.add( + ModelUsageEffect( + id=str(attempt_id), + checker_slot_id=str(model_slot_id), + attempt_number=1, + internal_call_request_id=str(new_record_id()), + request_sha256=digest, + state="dispatching", + prepared_authorization_evidence_id=str(audit_event_id), + ) + ) + async with _suspend_controlled_model_guard(session, table="model_usage_effects"): + await session.flush() + execution = ModelCallExecutionFacts( + project_id=evaluation.project_id, + task_id=evaluation.task_id, + assignment_id=evaluation.assignment_id, + submission_id=evaluation.submission_id, + submission_version=evaluation.submission_version, + evaluation_request_id=evaluation.evaluation_request_id, + evaluation_generation=evaluation.evaluation_generation, checker_run_id=UUID(checker_run.id), - worker_lease_id=worker_lease_id or new_record_id(), - worker_lease_generation=worker_lease_generation, - slots=( - CheckerOutputSlot( - key=slot_key, - media_type=media_type, - maximum_bytes=maximum_bytes, - ), + worker_lease_id=new_record_id(), + worker_lease_generation=1, + worker_lease_expires_at=datetime.now(UTC) + timedelta(minutes=5), + ) + selector = CheckerOutputSelector( + paid_slot=paid_slot, + task_id=evaluation.task_id, + model_call_slot_id=model_slot_id, + model_call_attempt_id=attempt_id, + slot_key=slot_key, + content_sha256=digest, + execution=execution if slot_key == "model_request" else None, + ) + return CheckerOutputReservation( + selector=selector, + slot=CheckerOutputSlot( + key=slot_key, + media_type=media_type, + maximum_bytes=maximum_bytes, ), ) @@ -79,9 +305,7 @@ async def make_checker_output_admission_request( checker_run_id: str, source, *, - slot_key: str = "platform-review", - worker_lease_id: UUID | None = None, - worker_lease_generation: int = 1, + slot_key: str = "model_response", ) -> CheckerOutputArtifactAdmissionRequest: """Build detached owner facts without rolling back or expiring caller state.""" factory = async_sessionmaker(session.bind, expire_on_commit=False) @@ -92,9 +316,9 @@ async def make_checker_output_admission_request( slot_key=slot_key, media_type=source.commitment.media_type, maximum_bytes=max(1, source.commitment.byte_count), - worker_lease_id=worker_lease_id, - worker_lease_generation=worker_lease_generation, + content_sha256=source.commitment.sha256, ) + await reservation_session.commit() return CheckerOutputArtifactAdmissionRequest( reservation=reservation, slot_key=slot_key, @@ -137,7 +361,6 @@ async def assert_exact_checker_admission( *, result, replay, - takeover, request, authority, project_id: str, @@ -147,35 +370,46 @@ async def assert_exact_checker_admission( ) -> None: """Retain the complete fixed-service, quota and replay assertion set.""" attempt = await session.get(ArtifactPutAttempt, str(result.attempt_id)) - scopes = (await session.scalars(select(ArtifactAdmissionScope).order_by( - ArtifactAdmissionScope.scope_type, ArtifactAdmissionScope.scope_id, - ))).all() - links = (await session.scalars(select(ArtifactPutAttemptCharge).where( - ArtifactPutAttemptCharge.attempt_id == str(result.attempt_id), - ))).all() + scopes = ( + await session.scalars( + select(ArtifactAdmissionScope).order_by( + ArtifactAdmissionScope.scope_type, + ArtifactAdmissionScope.scope_id, + ) + ) + ).all() + links = ( + await session.scalars( + select(ArtifactPutAttemptCharge).where( + ArtifactPutAttemptCharge.attempt_id == str(result.attempt_id), + ) + ) + ).all() assert attempt is not None assert replay.attempt_id == result.attempt_id assert replay.charge_ids == result.charge_ids - assert takeover.attempt_id == result.attempt_id - assert takeover.replayed is True assert attempt.status == "prepared" assert attempt.producer_request_type == "checker_output" assert attempt.producer_type == "service_identity" assert attempt.producer_ref == ServiceIdentity.ARTIFACT_CHECKER_OUTPUT.value assert attempt.project_id == project_id assert attempt.task_id == task_id - assert attempt.checker_run_id == checker_run_id - assert attempt.logical_role == "platform-review" - assert attempt.submission_id == str(request.reservation.evaluation.submission_id) - assert attempt.submission_version == request.reservation.evaluation.submission_version + assert attempt.checker_run_id is None + assert attempt.model_call_attempt_id == str(request.reservation.selector.model_call_attempt_id) + assert attempt.logical_role == request.reservation.selector.slot_key + assert attempt.submission_id == str(request.reservation.selector.paid_slot.submission_id) + assert attempt.submission_version == request.reservation.selector.paid_slot.submission_version assert attempt.checker_request_digest is not None - assert authority.calls == 3 + assert authority.calls == 2 assert attempt.executor_id is None assert attempt.lease_expires_at is None assert attempt.next_run_at is None assert attempt.execution_generation == 0 assert {scope.scope_type for scope in scopes} == { - "deployment", "producer", "project", "task", + "deployment", + "producer", + "project", + "task", } assert len(result.charge_ids) == 4 assert len(links) == 4 @@ -198,7 +432,7 @@ def __init__(self, session, actor_id: UUID, identity_link_id: UUID) -> None: self._identity_link_id = identity_link_id self.calls = 0 - async def consume(self, request: CheckerOutputArtifactAdmissionRequest) -> None: + async def consume(self, facts: CheckerOutputCommitmentAuthorityFacts) -> None: self.calls += 1 proof = await ActorService(self._session).lock_admission_proof( self._actor_id, @@ -211,15 +445,14 @@ async def consume(self, request: CheckerOutputArtifactAdmissionRequest) -> None: or proof.identity_link_id != str(self._identity_link_id) or proof.identity_link_subject_kind != "service" or proof.identity_link_status != "active" - or proof.service_identity - != ServiceIdentity.ARTIFACT_CHECKER_OUTPUT.value + or proof.service_identity != ServiceIdentity.ARTIFACT_CHECKER_OUTPUT.value ): - raise ArtifactAuthorityDeniedError( - "checker output service identity is unavailable" - ) + raise ArtifactAuthorityDeniedError("checker output service identity is unavailable") -async def seed_checker_output_relationships(session, namespace, *, policy_bundle=None) -> tuple[str, str, str]: +async def seed_checker_output_relationships( + session, namespace, *, policy_bundle=None +) -> tuple[str, str, str]: """Prepare canonical input before callers mint bounded output scratch.""" from tests.test_artifact_admission import _seed_human_actor, _context from tests.projects.unified_policy_fixtures import create_standalone_unified_policy @@ -227,7 +460,8 @@ async def seed_checker_output_relationships(session, namespace, *, policy_bundle if policy_bundle is None: assert not session.in_transaction(), "Arrange canonical setup before staging artifact rows" policy_bundle = await create_standalone_unified_policy( - async_sessionmaker(session.bind, expire_on_commit=False), namespace, + async_sessionmaker(session.bind, expire_on_commit=False), + namespace, ) values, effective, _pre = policy_bundle project_id = str(values["project"]) @@ -247,8 +481,12 @@ async def seed_checker_output_relationships(session, namespace, *, policy_bundle from tests.test_tasks import complete_submission_payload assignment_id = str(new_record_id()) - params = {"task": task_id, "assignment": assignment_id, "project": project_id, - "actor": contributor_id} + params = { + "task": task_id, + "assignment": assignment_id, + "project": project_id, + "actor": contributor_id, + } connection = await session.connection() await seed_started_task_for_artifact_test(connection, params) await install_submitter_grant(connection, params) @@ -259,6 +497,8 @@ async def seed_checker_output_relationships(session, namespace, *, policy_bundle ) submission_id = await seed_retained_submission(task_id, payload) from tests.checkers.execution.storage_fixture import seed_storage_run - checker_run_id = await seed_storage_run(async_sessionmaker(session.bind, expire_on_commit=False), - submission_id, state="queued") + + checker_run_id = await seed_storage_run( + async_sessionmaker(session.bind, expire_on_commit=False), submission_id, state="queued" + ) return project_id, task_id, checker_run_id diff --git a/backend/tests/checker_output_custody_helpers.py b/backend/tests/checker_output_custody_helpers.py index ed6d20948..c215fe5a7 100644 --- a/backend/tests/checker_output_custody_helpers.py +++ b/backend/tests/checker_output_custody_helpers.py @@ -5,12 +5,15 @@ from tests.checker_output_admission_helpers import seed_checker_output_relationships import asyncio +import hashlib +import json from collections.abc import AsyncIterator from contextlib import asynccontextmanager from dataclasses import dataclass, field +from datetime import UTC, datetime, timedelta from pathlib import Path from typing import Any -from uuid import UUID +from uuid import UUID, uuid4 from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine @@ -22,6 +25,7 @@ CheckerOutputArtifactResult, CheckerOutputBindingRequest, CheckerOutputBindingResult, + CheckerOutputSlot, ) from app.interfaces.artifacts import ( ArtifactByteRange, @@ -33,29 +37,46 @@ from app.modules.artifacts.checker_output_bindings import ( CheckerOutputBindingService, ) -from app.modules.artifacts.checker_output_custody import CheckerOutputStoredFacts +from app.modules.artifacts.api.checker_output_authority import ( + CheckerOutputCommitmentAuthorityFacts, + CheckerOutputStoredFacts, +) from app.modules.artifacts.checker_outputs import CheckerArtifactOutputService +from app.modules.artifacts.checker_model_evidence import CheckerModelEvidence from app.modules.artifacts.models import ArtifactBinding from app.modules.artifacts.preparation import ( ArtifactPreparationService, ArtifactScratchManager, ) -from app.modules.artifacts.schemas import CheckerOutputArtifactAdmissionRequest from app.modules.artifacts.service import artifact_storage_namespace_spec from app.modules.checkers.api.output_custody import ( CheckerOutputReservation, CheckerOutputSelector, CheckerOutputUnavailable, ) -from app.modules.checkers.models import CheckerRun +from app.modules.model_usage.api import ( + ModelCallEvidence, + ModelCallExecutionFacts, + ModelPaidSlotKey, + ProjectModelProfile, +) +from app.modules.checkers.execution_repository import stored_request +from app.modules.checkers.models import CheckerRun, ExternalCheckerRegistryEntryRecord +from app.modules.model_usage.service import ModelCallService +from app.adapters.auth import checker_model_service_authorization +from app.modules.authorization.checker_output_authorization import ( + PreparedCheckerOutputBindingAuthorization, + PreparedCheckerOutputWriteAuthorization, +) +from app.modules.model_usage.models import ModelUsageEffect from tests.artifact_store_helpers import ( artifact_admission_limit_settings, artifact_preparation_limits, ) -from tests.checker_output_admission_helpers import ( - ControlledCheckerOutputAdmissionAuthority, - make_checker_output_reservation, -) +from tests.checkers_model_calls.model_usage_boundary_support import BoundaryPorts +from tests.checkers_model_calls.test_public_api import profile_spec +from tests.checkers.execution.support import provision_post_submit_service +from tests.checkers.external.support import post_registry_spec from tests.projects.unified_policy_fixtures import create_standalone_unified_policy from tests.test_artifact_admission import ( _AllowArtifactAuthority, @@ -65,15 +86,8 @@ def selector_for(reservation: CheckerOutputReservation) -> CheckerOutputSelector: - """Select the sole controlled output slot under the reservation's current lease.""" - assert len(reservation.slots) == 1 - return CheckerOutputSelector( - evaluation=reservation.evaluation, - checker_run_id=reservation.checker_run_id, - worker_lease_id=reservation.worker_lease_id, - worker_lease_generation=reservation.worker_lease_generation, - slot_key=reservation.slots[0].key, - ) + """Select the exact call-stable effect role issued by CHECKERS.""" + return reservation.selector async def byte_stream(*chunks: bytes, observed: list[bytes] | None = None) -> AsyncIterator[bytes]: @@ -93,7 +107,7 @@ class ControlledReservationState: revoked: bool = False preflights: list[CheckerOutputSelector] = field(default_factory=list) prepares: list[CheckerOutputSelector] = field(default_factory=list) - admissions: list[CheckerOutputArtifactAdmissionRequest] = field(default_factory=list) + admissions: list[CheckerOutputCommitmentAuthorityFacts] = field(default_factory=list) recoveries: list[tuple[CheckerOutputReservation, CheckerOutputStoredFacts]] = field( default_factory=list ) @@ -108,56 +122,56 @@ def require_authority(self) -> None: class ControlledReservationPort: - """Lock a real run, then return controlled detached CHECKERS owner facts.""" + """Lock a real call effect, then return controlled detached owner facts.""" def __init__(self, session: AsyncSession, state: ControlledReservationState) -> None: self._session = session self._state = state async def resolve(self, selector: CheckerOutputSelector) -> CheckerOutputReservation: - """Fence the selected real run before ART acquires any artifact row lock.""" - checker_run = await self._session.scalar( - select(CheckerRun) - .where(CheckerRun.id == str(selector.checker_run_id)) + """Fence the selected call attempt before ART acquires any artifact row lock.""" + attempt = await self._session.scalar( + select(ModelUsageEffect) + .where( + ModelUsageEffect.id == str(selector.model_call_attempt_id), + ModelUsageEffect.checker_slot_id == str(selector.model_call_slot_id), + ) .with_for_update() .execution_options(populate_existing=True) ) - if checker_run is None: + if attempt is None: raise CheckerOutputUnavailable("checker_output_reservation_unavailable") self._state.resolved.append(selector) + self._state.reservation.select(selector) return self._state.reservation class ControlledWriteAuthority: - """Test-only exact action authority plus real fixed-service admission proof.""" + """Record test controls around production prepared fixed-service authority.""" def __init__( self, session: AsyncSession, state: ControlledReservationState, - actor_id: UUID, - identity_link_id: UUID, ) -> None: self._state = state - self._admission = ControlledCheckerOutputAdmissionAuthority( - session, - actor_id, - identity_link_id, - ) + self._authority = PreparedCheckerOutputWriteAuthorization(session) async def preflight(self, selector: CheckerOutputSelector) -> None: self._state.require_authority() self._state.preflights.append(selector) + await self._authority.preflight(selector) async def prepare(self, selector: CheckerOutputSelector) -> None: self._state.require_authority() self._state.prepares.append(selector) + await self._authority.prepare(selector) - async def consume(self, request: CheckerOutputArtifactAdmissionRequest) -> None: + async def consume(self, facts: CheckerOutputCommitmentAuthorityFacts) -> None: self._state.require_authority() - assert request.reservation == self._state.reservation - await self._admission.consume(request) - self._state.admissions.append(request) + assert facts.reservation == self._state.reservation + await self._authority.consume(facts) + self._state.admissions.append(facts) async def consume_existing( self, @@ -166,25 +180,30 @@ async def consume_existing( ) -> None: self._state.require_authority() assert reservation == self._state.reservation + await self._authority.consume_existing(reservation, facts) self._state.recoveries.append((reservation, facts)) def discard(self) -> None: - """The controlled seam retains no prepared capability.""" + """Dispose the production prepared capability.""" + self._authority.discard() class ControlledBindingAuthority: - """Test-only binding authority that records exact verified custody.""" + """Record test controls around production binding authority.""" - def __init__(self, state: ControlledReservationState) -> None: + def __init__(self, session: AsyncSession, state: ControlledReservationState) -> None: self._state = state + self._authority = PreparedCheckerOutputBindingAuthorization(session) async def preflight(self, selector: CheckerOutputSelector) -> None: self._state.require_authority() self._state.preflights.append(selector) + await self._authority.preflight(selector) async def prepare(self, selector: CheckerOutputSelector) -> None: self._state.require_authority() self._state.prepares.append(selector) + await self._authority.prepare(selector) async def consume( self, @@ -193,10 +212,12 @@ async def consume( ) -> None: self._state.require_authority() assert reservation == self._state.reservation + await self._authority.consume(reservation, facts) self._state.bindings.append((reservation, facts)) def discard(self) -> None: - """The controlled seam retains no prepared capability.""" + """Dispose the production prepared capability.""" + self._authority.discard() class ObservedStore: @@ -237,6 +258,11 @@ def open( async def head(self, provider_object_ref: str, **_kwargs): return await self.wrapped.head(provider_object_ref) + def reset_observations(self) -> None: + """Start the response-custody observation window after request evidence setup.""" + self.puts = 0 + self.opens = 0 + @dataclass(slots=True) class OutputCustodyHarness: @@ -255,6 +281,8 @@ class OutputCustodyHarness: actor_id: UUID identity_link_id: UUID policy_bundle: Any + model_profile: Any + model_ports: BoundaryPorts @property def selector(self) -> CheckerOutputSelector: @@ -266,6 +294,16 @@ def request( selector: CheckerOutputSelector | None = None, observed: list[bytes] | None = None, ) -> CheckerOutputArtifactRequest: + if selector is None and not self.state.preflights: + content_sha256 = "sha256:" + hashlib.sha256(b"".join(chunks)).hexdigest() + current = self.state.reservation + self.state.reservation = current.model_copy( + update={ + "selector": current.selector.model_copy( + update={"content_sha256": content_sha256} + ) + } + ) return CheckerOutputArtifactRequest( selector=selector or self.selector, byte_source=byte_stream(*chunks, observed=observed), @@ -275,7 +313,7 @@ def binding_service(self, session: AsyncSession) -> CheckerOutputBindingService: return CheckerOutputBindingService( session, reservations=ControlledReservationPort(session, self.state), - authority=ControlledBindingAuthority(self.state), + authority=ControlledBindingAuthority(session, self.state), namespace_fingerprint=self.namespace.namespace_fingerprint, ) @@ -306,7 +344,12 @@ async def binding_rows(self) -> list[tuple[str, str, str, str, str]]: ArtifactBinding.resource_id, ArtifactBinding.put_attempt_id, ArtifactBinding.verification_receipt_id, - ).where(ArtifactBinding.resource_type == "checker_run").order_by(ArtifactBinding.id) + ) + .where( + ArtifactBinding.resource_type == "checker_model_call", + ArtifactBinding.logical_role == "model_response", + ) + .order_by(ArtifactBinding.id) ) return [tuple(row) for row in rows] @@ -314,8 +357,10 @@ async def seed_reservation( self, *, maximum_bytes: int = 1024, + content_sha256: str | None = None, ) -> CheckerOutputReservation: """Create a second coherent lineage without changing the active test reservation.""" + provider_counts = self.store.puts, self.store.opens async with self.factory() as session: _, _, checker_run_id = await seed_checker_output_relationships( session, @@ -325,10 +370,272 @@ async def seed_reservation( reservation = await make_checker_output_reservation( session, checker_run_id, + factory=self.factory, + preparation=self.preparation, + store=self.store, + namespace=self.namespace, + settings=self.settings, + model_profile=self.model_profile, + model_ports=self.model_ports, maximum_bytes=maximum_bytes, + content_sha256=content_sha256, + ) + await session.commit() + self.store.puts, self.store.opens = provider_counts + return reservation + + +async def _model_execution(session, checker_run_id: str): + run = await session.get(CheckerRun, checker_run_id) + assert run is not None + request = stored_request(run) + return run, request + + +async def _prepare_model_attempt( + session: AsyncSession, + checker_run_id: str, + request_sha256: str, + *, + model_profile, + model_ports: BoundaryPorts, +): + run, request = await _model_execution(session, checker_run_id) + registry = await session.scalar(select(ExternalCheckerRegistryEntryRecord).limit(1)) + assert registry is not None + slot = ModelPaidSlotKey( + project_id=request.project_id, + assignment_id=request.assignment_id, + submission_id=request.submission_id, + submission_version=request.submission_version, + requirement_id="model_evidence", + registry_entry_digest=registry.entry_digest, + configuration_sha256="sha256:" + hashlib.sha256(b"controlled-configuration").hexdigest(), + profile_id=model_profile.profile_id, + profile_version=model_profile.spec.profile_version, + profile_digest=model_profile.profile_digest, + ) + execution = ModelCallExecutionFacts( + project_id=request.project_id, + task_id=request.task_id, + assignment_id=request.assignment_id, + submission_id=request.submission_id, + submission_version=request.submission_version, + evaluation_request_id=request.evaluation_request_id, + evaluation_generation=request.evaluation_generation, + checker_run_id=UUID(run.id), + worker_lease_id=new_record_id(), + worker_lease_generation=1, + worker_lease_expires_at=datetime.now(UTC) + timedelta(minutes=5), + ) + authority = await checker_model_service_authorization(session, execution.evaluation_request_id) + owner = ModelCallService( + session, + authority=authority, + current_execution=model_ports, + evidence=CheckerModelEvidence(session), + ) + attempt = await owner.prepare_attempt( + execution, + slot, + registry_entry_id=UUID(str(registry.id)), + request_sha256=request_sha256, + ) + return execution, attempt, owner + + +async def _store_model_request_evidence( + *, + factory, + preparation, + store, + namespace, + settings, + reservation: CheckerOutputReservation, + request_bytes: bytes, +) -> ModelCallEvidence: + """Store and bind exact model-request evidence through production ART custody.""" + state = ControlledReservationState(reservation) + service = CheckerArtifactOutputService( + sessions=factory, + reservations=lambda value: ControlledReservationPort(value, state), + authority=lambda value: ControlledWriteAuthority(value, state), + internal_authority=lambda value: _AllowArtifactAuthority(), + verification_authority=lambda value: _AllowArtifactAuthority(), + preparation=preparation, + store=store, + namespace=namespace, + settings=settings, + ) + stored = await service.store( + CheckerOutputArtifactRequest(reservation.selector, byte_stream(request_bytes)) + ) + assert stored.content_id is not None and stored.verification_receipt_id is not None + async with factory() as session, session.begin(): + bound = await CheckerOutputBindingService( + session, + reservations=ControlledReservationPort(session, state), + authority=ControlledBindingAuthority(session, state), + namespace_fingerprint=namespace.namespace_fingerprint, + ).bind_checker_output( + CheckerOutputBindingRequest( + reservation.selector, + stored.put_attempt_id, + stored.verification_receipt_id, + ) + ) + return ModelCallEvidence( + binding_id=bound.binding_id, + content_id=bound.content_id, + put_attempt_id=bound.put_attempt_id, + verification_receipt_id=bound.verification_receipt_id, + content_sha256=reservation.selector.content_sha256, + byte_count=len(request_bytes), + ) + + +async def make_checker_output_reservation( + session: AsyncSession, + checker_run_id: str, + *, + factory, + preparation, + store, + namespace, + settings, + model_profile, + model_ports: BoundaryPorts, + maximum_bytes: int, + content_sha256: str | None = None, +) -> CheckerOutputReservation: + """Create a dispatch-reserved call through real request ART custody.""" + request_bytes = b'{"prompt":"controlled output-custody evidence"}' + request_sha256 = "sha256:" + hashlib.sha256(request_bytes).hexdigest() + execution, attempt, _ = await _prepare_model_attempt( + session, + checker_run_id, + request_sha256, + model_profile=model_profile, + model_ports=model_ports, + ) + await session.commit() + request_reservation = CheckerOutputReservation( + selector=CheckerOutputSelector( + paid_slot=attempt.slot_key, + task_id=execution.task_id, + model_call_slot_id=attempt.slot_id, + model_call_attempt_id=attempt.attempt_id, + slot_key="model_request", + content_sha256=request_sha256, + execution=execution, + ), + slot=CheckerOutputSlot( + key="model_request", + media_type="application/json", + maximum_bytes=model_profile.spec.maximum_request_bytes, + ), + ) + evidence = await _store_model_request_evidence( + factory=factory, + preparation=preparation, + store=store, + namespace=namespace, + settings=settings, + reservation=request_reservation, + request_bytes=request_bytes, + ) + async with factory() as dispatch_session, dispatch_session.begin(): + authority = await checker_model_service_authorization( + dispatch_session, execution.evaluation_request_id + ) + await ModelCallService( + dispatch_session, + authority=authority, + current_execution=model_ports, + evidence=CheckerModelEvidence(dispatch_session), + ).reserve_dispatch(execution, attempt, evidence) + digest = ( + content_sha256 + or "sha256:" + hashlib.sha256(f"{attempt.attempt_id}:model_response".encode()).hexdigest() + ) + return CheckerOutputReservation( + selector=CheckerOutputSelector( + paid_slot=attempt.slot_key, + task_id=execution.task_id, + model_call_slot_id=attempt.slot_id, + model_call_attempt_id=attempt.attempt_id, + slot_key="model_response", + content_sha256=digest, + ), + slot=CheckerOutputSlot( + key="model_response", + media_type="application/json", + maximum_bytes=maximum_bytes, + ), + ) + + +async def _provision_model_policy(factory, namespace, admin_access): + """Publish one ordinary policy plus its exact trusted model controls.""" + await admin_access.signed.grant(admin_access.admin, admin_access.target, role="operator") + policy_bundle = await create_standalone_unified_policy( + factory, + namespace, + post_submit_registry_spec=post_registry_spec(), + ) + async with factory() as session: + registry = await session.scalar(select(ExternalCheckerRegistryEntryRecord).limit(1)) + assert registry is not None + project_id = policy_bundle[0]["project"] + spec = profile_spec(project_id).model_copy( + update={"maximum_request_bytes": 1024, "maximum_total_request_bytes": 1024} + ) + response = await admin_access.signed.client.post( + f"/api/v1/operations/projects/{project_id}/model-profiles", + headers=admin_access.target.headers | {"Idempotency-Key": str(uuid4())}, + json=spec.model_dump(mode="json"), + ) + assert response.status_code == 201, response.text + budget = await admin_access.signed.client.post( + f"/api/v1/operations/projects/{project_id}/model-budget-configurations", + headers=admin_access.target.headers | {"Idempotency-Key": str(uuid4())}, + json={ + "project_id": str(project_id), + "configuration_version": 1, + "authorized_total_nanodollars": 10_000_000_000, + "externally_reconciled_prior_spend_nanodollars": 0, + "lifetime_limit_nanodollars": 10_000_000_000, + }, + ) + assert budget.status_code == 201, budget.text + await provision_post_submit_service(factory, ServiceIdentity.CHECKER_POST_SUBMIT) + return policy_bundle, ProjectModelProfile.model_validate_json(json.dumps(response.json())) + + +async def _ensure_output_actor(factory): + """Reuse the canonical output service actor, creating it only when absent.""" + async with factory() as session: + existing = ( + await session.execute( + select(ActorProfile.id, ActorIdentityLink.id) + .join(ActorIdentityLink, ActorIdentityLink.actor_profile_id == ActorProfile.id) + .where( + ActorProfile.service_identity == ServiceIdentity.ARTIFACT_CHECKER_OUTPUT.value + ) ) + ).one_or_none() + if existing is not None: await session.rollback() - return reservation + return tuple(UUID(value) for value in existing) + actor_id, identity_link_id = new_record_id(), new_record_id() + _add_checker_output_actor( + session, + actor_id, + identity_link_id, + subject=f"checker-output-{actor_id}", + ) + await session.commit() + return actor_id, identity_link_id @asynccontextmanager @@ -336,6 +643,7 @@ async def output_custody_harness( tmp_path: Path, database_url: str, *, + admin_access, provider: str = "local", maximum_bytes: int = 1024, ) -> AsyncIterator[OutputCustodyHarness]: @@ -370,52 +678,39 @@ async def output_custody_harness( ) preparation = ArtifactPreparationService(manager) try: - policy_bundle = await create_standalone_unified_policy(factory, namespace) + policy_bundle, model_profile = await _provision_model_policy( + factory, namespace, admin_access + ) async with factory() as session: _, _, checker_run_id = await seed_checker_output_relationships( session, namespace, policy_bundle=policy_bundle, ) - existing = ( - await session.execute( - select(ActorProfile.id, ActorIdentityLink.id) - .join(ActorIdentityLink, ActorIdentityLink.actor_profile_id == ActorProfile.id) - .where( - ActorProfile.service_identity - == ServiceIdentity.ARTIFACT_CHECKER_OUTPUT.value - ) - ) - ).one_or_none() - if existing is None: - actor_id, identity_link_id = new_record_id(), new_record_id() - _add_checker_output_actor( - session, - actor_id, - identity_link_id, - subject=f"checker-output-{actor_id}", - ) - await session.commit() - else: - actor_id, identity_link_id = (UUID(value) for value in existing) - await session.rollback() + actor_id, identity_link_id = await _ensure_output_actor(factory) + model_ports = BoundaryPorts() + async with factory() as session: reservation = await make_checker_output_reservation( session, checker_run_id, + factory=factory, + preparation=preparation, + store=store, + namespace=namespace, + settings=settings, + model_profile=model_profile, + model_ports=model_ports, maximum_bytes=maximum_bytes, ) - await session.rollback() + await session.commit() + store.reset_observations() state = ControlledReservationState(reservation) service = CheckerArtifactOutputService( sessions=factory, reservations=lambda session: ControlledReservationPort(session, state), - authority=lambda session: ControlledWriteAuthority( - session, - state, - actor_id, - identity_link_id, - ), + authority=lambda session: ControlledWriteAuthority(session, state), internal_authority=lambda session: _AllowArtifactAuthority(), + verification_authority=lambda session: _AllowArtifactAuthority(), preparation=preparation, store=store, namespace=namespace, @@ -435,6 +730,8 @@ async def output_custody_harness( actor_id=actor_id, identity_link_id=identity_link_id, policy_bundle=policy_bundle, + model_profile=model_profile, + model_ports=model_ports, ) finally: manager.close() diff --git a/backend/tests/checkers/execution/test_checker_retry_authority.py b/backend/tests/checkers/execution/test_checker_retry_authority.py index 8860acf0e..0f28487b8 100644 --- a/backend/tests/checkers/execution/test_checker_retry_authority.py +++ b/backend/tests/checkers/execution/test_checker_retry_authority.py @@ -283,10 +283,11 @@ def capture_projection(_conn, _cursor, _statement, _parameters, context, _many): item = body["items"][0] assert set(item) == { "source", "recovery_state", "created_at", "completed_at", - "retry_receipt_id", "successor", + "retry_receipt_id", "successor", "model_recovery_candidates", } assert item["recovery_state"] == "eligible" assert item["retry_receipt_id"] is None and item["successor"] is None + assert item["model_recovery_candidates"] == [] assert item["source"] == { "project_id": str(h.request.project_id), "task_id": str(h.request.task_id), diff --git a/backend/tests/checkers/execution/test_execution.py b/backend/tests/checkers/execution/test_execution.py index 5333faa02..6da274324 100644 --- a/backend/tests/checkers/execution/test_execution.py +++ b/backend/tests/checkers/execution/test_execution.py @@ -193,30 +193,14 @@ async def prepare_finalization(self, request): async def test_zero_output_finalization(tmp_path, isolated_database_env): from pydantic import ValidationError from app.core.identifiers import new_record_id - from app.modules.checkers.api.output_custody import ( - CheckerOutputSelector, - CheckerOutputUnavailable, - ) - from app.modules.checkers.execution_coordination import CheckerOutputReservations from .test_concurrency import final_facts async with material_fixture(tmp_path, isolated_database_env) as h: await reserve(h) executor = live_executor(h) lease, _ = await executor._claim(h.request) - selector = CheckerOutputSelector( - evaluation=h.request, - checker_run_id=lease.reservation.attempt_id, - worker_lease_id=lease.lease_id, - worker_lease_generation=lease.lease_generation, - slot_key="invented", - ) - async with h.factory() as session, session.begin(): - slots = await CheckerOutputReservations(session).resolve(selector) - assert slots.slots == () - with pytest.raises(CheckerOutputUnavailable, match="slot_unavailable"): - slots.select(selector) facts = await final_facts(h, lease) + assert facts.output_binding_ids == () with pytest.raises(ValidationError): await executor.finalize( facts.model_copy(update={"output_binding_ids": (new_record_id(),)}) diff --git a/backend/tests/checkers/external/test_harnessy_proof_checker.py b/backend/tests/checkers/external/test_harnessy_proof_checker.py index 349db03b8..f1cd2cd8b 100644 --- a/backend/tests/checkers/external/test_harnessy_proof_checker.py +++ b/backend/tests/checkers/external/test_harnessy_proof_checker.py @@ -156,6 +156,7 @@ def test_published_registry_bodies_are_backend_valid_for_both_phases() -> None: def test_published_service_paths_match_the_compose_overlay() -> None: """The service config and the overlay must name the same daemon and socket paths.""" + base = yaml.safe_load((CHECKER_ROOT.parents[1] / "docker-compose.yml").read_text()) overlay = yaml.safe_load( (CHECKER_ROOT.parents[1] / "docker-compose.agent-loop.yml").read_text() ) @@ -169,6 +170,8 @@ def test_published_service_paths_match_the_compose_overlay() -> None: assert config["material_root"] == environment["WORKSTREAM_EXTERNAL_CHECKER_MATERIAL_ROOT"] assert config["material_root"] == environment["WORKSTREAM_ARTIFACT_SCRATCH_ROOT"] assert config["socket_path"] == environment["WORKSTREAM_EXTERNAL_CHECKER_SERVICE_SOCKET"] + assert "model_proxy_socket" not in config + assert "model_relay_root" not in config assert len(config["socket_path"].encode()) <= 107 for name in ("backend", "worker", "beat", "checker", "checker-volumes-init"): socket_mounts = [ @@ -179,3 +182,60 @@ def test_published_service_paths_match_the_compose_overlay() -> None: ] mode = ":ro" if name in ("backend", "worker", "beat") else "" assert socket_mounts == [f"checker_socket:/run/ws-checker{mode}"], name + + model_config = publish_local.service_config( + {"operating_system": "linux", "architecture": "arm64"}, + compose_project="${COMPOSE_PROJECT_NAME}", + sandbox_uid=501, + sandbox_gid=20, + enable_model_proxy=True, + ) + relay_root = "/var/lib/docker/volumes/${COMPOSE_PROJECT_NAME}-checker-relay/_data" + assert model_config["model_proxy_socket"] == "/run/ws-model-proxy/model-proxy.sock" + assert model_config["model_relay_root"] == relay_root + assert len(f"{relay_root}/.model-{'0' * 16}/model.sock".encode()) < 108 + base_proxy = base["services"]["model-proxy"] + overlay_proxy = overlay["services"]["model-proxy"] + assert base_proxy["profiles"] == ["model-proxy"] + assert base_proxy["command"] == ["python", "-m", "app.workers.checker_model_proxy"] + assert base_proxy["stop_grace_period"] == "125m" + assert "extends" not in overlay_proxy + assert "profiles" not in overlay_proxy + assert overlay_proxy["environment"]["WORKSTREAM_CHECKER_MODEL_PROXY_SOCKET"] == ( + "/run/ws-model-proxy/model-proxy.sock" + ) + assert "checker_model_proxy:/run/ws-model-proxy" in overlay_proxy["volumes"] + for name in ("backend", "worker", "beat", "model-proxy"): + assert all( + "/var/run/docker.sock" not in str(mount) + for mount in overlay["services"].get(name, {}).get("volumes", ()) + ) + for name in ("backend", "beat"): + assert all( + "checker_model_proxy" not in str(mount) + for mount in overlay["services"][name]["volumes"] + ) + worker = overlay["services"]["worker"] + assert worker["environment"]["WORKSTREAM_CHECKER_MODEL_PROXY_SOCKET"] == ( + "/run/ws-model-proxy/model-proxy.sock" + ) + assert "checker_model_proxy:/run/ws-model-proxy:ro" in worker["volumes"] + assert "checker_model_proxy:/run/ws-model-proxy:ro" in overlay["services"]["checker"]["volumes"] + assert ( + "checker_model_relay:" + "/var/lib/docker/volumes/${COMPOSE_PROJECT_NAME}-checker-relay/_data" + in overlay["services"]["checker"]["volumes"] + ) + for volume in ("checker_model_proxy", "checker_model_relay"): + assert any( + isinstance(mount, str) and mount.startswith(f"{volume}:") + for mount in overlay["services"]["checker-volumes-init"]["volumes"] + ) + with pytest.raises(ValueError, match="relay socket path too long"): + publish_local.service_config( + {"operating_system": "linux", "architecture": "arm64"}, + compose_project="x" * 80, + sandbox_uid=501, + sandbox_gid=20, + enable_model_proxy=True, + ) diff --git a/backend/tests/checkers/external/test_model_proxy_transport.py b/backend/tests/checkers/external/test_model_proxy_transport.py new file mode 100644 index 000000000..382ceff9b --- /dev/null +++ b/backend/tests/checkers/external/test_model_proxy_transport.py @@ -0,0 +1,368 @@ +"""The private model proxy transport is bounded and retains dispatched work.""" + +import asyncio +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager +import json +import os +from pathlib import Path +import socket +import stat +import struct + +import pytest + +from app.adapters.checkers.model_proxy_transport import ( + ModelProxyCleanupError, + ModelProxyCustodyError, + ModelProxyTransport, +) +from app.interfaces.checker_model_proxy import ( + MODEL_PROXY_MAXIMUM_FRAME_BYTES, + MODEL_PROXY_PROTOCOL, + ModelProxyReply, + ModelProxyRequest, +) + + +GRANT_ID = "model_" + "a" * 32 +REQUEST_DIGEST = "sha256:" + "b" * 64 +RECEIPT_DIGEST = "sha256:" + "c" * 64 + + +def request_value(*, prompt: str = "Judge this bounded artifact.") -> dict[str, object]: + """Build the exact Rust relay request root.""" + return { + "protocol_version": MODEL_PROXY_PROTOCOL, + "grant_id": GRANT_ID, + "request_digest": REQUEST_DIGEST, + "call_index": 0, + "prompt": prompt, + "resolved_receipts": [], + } + + +def frame(value: object) -> bytes: + """Encode one private protocol frame.""" + body = json.dumps(value, separators=(",", ":")).encode() + return struct.pack(">I", len(body)) + body + + +async def read_frame(reader: asyncio.StreamReader) -> object: + """Read one private protocol frame.""" + size = struct.unpack(">I", await reader.readexactly(4))[0] + return json.loads(await reader.readexactly(size)) + + +@asynccontextmanager +async def connection( + path: Path, +) -> AsyncIterator[tuple[asyncio.StreamReader, asyncio.StreamWriter]]: + """Open and close one real Unix connection.""" + reader, writer = await asyncio.open_unix_connection(path) + try: + yield reader, writer + finally: + writer.close() + try: + await asyncio.wait_for(writer.wait_closed(), 1) + except (TimeoutError, OSError): + pass + + +def private_socket_path(tmp_path: Path) -> Path: + """Create a canonical same-identity private listener parent.""" + parent = tmp_path / "private" + parent.mkdir(mode=0o700) + return parent / "proxy.sock" + + +@pytest.mark.asyncio +async def test_real_socket_round_trip_has_exact_contract_and_private_custody(tmp_path): + path = private_socket_path(tmp_path) + received: list[ModelProxyRequest] = [] + + async def handle(request: ModelProxyRequest) -> ModelProxyReply: + received.append(request) + return ModelProxyReply( + outcome="completed", + output="bounded result", + receipt_digest=RECEIPT_DIGEST, + failure_code=None, + call_index=0, + session_exhausted=False, + ) + + transport = ModelProxyTransport(socket_path=path, handler=handle) + async with transport: + metadata = os.lstat(path) + assert stat.S_ISSOCK(metadata.st_mode) + assert stat.S_IMODE(metadata.st_mode) == 0o600 + assert (metadata.st_uid, metadata.st_gid) == (os.geteuid(), os.getegid()) + async with connection(path) as (reader, writer): + writer.write(frame(request_value())) + await writer.drain() + assert await read_frame(reader) == { + "protocol_version": MODEL_PROXY_PROTOCOL, + "outcome": "completed", + "output": "bounded result", + "receipt_digest": RECEIPT_DIGEST, + "failure_code": None, + "call_index": 0, + "session_exhausted": False, + } + + assert received == [ + ModelProxyRequest( + grant_id=GRANT_ID, + request_digest=REQUEST_DIGEST, + call_index=0, + prompt="Judge this bounded artifact.", + ) + ] + assert GRANT_ID not in repr(received[0]) + assert received[0].prompt not in repr(received[0]) + assert "bounded result" not in repr( + ModelProxyReply("completed", "bounded result", RECEIPT_DIGEST, None, 0, False) + ) + assert not path.exists() + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "payload", + [ + b"not-json", + json.dumps({**request_value(), "provider": "secret"}).encode(), + json.dumps( + {key: value for key, value in request_value().items() if key != "prompt"} + ).encode(), + ( + b'{"protocol_version":"workstream.model_session.v2",' + b'"grant_id":"model_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",' + b'"request_digest":"sha256:' + b"b" * 64 + b'","call_index":0,' + b'"prompt":"first","prompt":"second","resolved_receipts":[]}' + ), + json.dumps({**request_value(), "grant_id": "model_" + "A" * 32}).encode(), + json.dumps({**request_value(), "protocol_version": "model_proxy.v0"}).encode(), + json.dumps({**request_value(), "prompt": None}).encode(), + ], +) +async def test_malformed_or_open_request_is_closed_without_dispatch(tmp_path, payload): + path = private_socket_path(tmp_path) + calls = 0 + + async def handle(_: ModelProxyRequest) -> ModelProxyReply: + nonlocal calls + calls += 1 + return ModelProxyReply("unavailable", None, None, "provider_unavailable", 0, True) + + async with ModelProxyTransport(socket_path=path, handler=handle): + async with connection(path) as (reader, writer): + writer.write(struct.pack(">I", len(payload)) + payload) + await writer.drain() + assert await asyncio.wait_for(reader.read(), 1) == b"" + + assert calls == 0 + + +@pytest.mark.asyncio +async def test_oversize_and_incomplete_frames_close_within_read_deadline(tmp_path): + path = private_socket_path(tmp_path) + + async def handle(_: ModelProxyRequest) -> ModelProxyReply: + raise AssertionError("invalid frame reached the handler") + + async with ModelProxyTransport( + socket_path=path, + handler=handle, + read_timeout_seconds=0.05, + ): + async with connection(path) as (reader, writer): + writer.write(struct.pack(">I", MODEL_PROXY_MAXIMUM_FRAME_BYTES + 1)) + await writer.drain() + assert await asyncio.wait_for(reader.read(), 1) == b"" + async with connection(path) as (reader, writer): + writer.write(struct.pack(">I", 10) + b"ab") + await writer.drain() + assert await asyncio.wait_for(reader.read(), 1) == b"" + + +@pytest.mark.asyncio +async def test_oversize_handler_reply_is_closed_without_writing_a_frame(tmp_path): + path = private_socket_path(tmp_path) + + async def handle(_: ModelProxyRequest) -> ModelProxyReply: + return ModelProxyReply( + "completed", + "x" * MODEL_PROXY_MAXIMUM_FRAME_BYTES, + RECEIPT_DIGEST, + None, + 0, + False, + ) + + async with ModelProxyTransport(socket_path=path, handler=handle): + async with connection(path) as (reader, writer): + writer.write(frame(request_value())) + await writer.drain() + assert await asyncio.wait_for(reader.read(), 1) == b"" + + +@pytest.mark.asyncio +async def test_connection_bound_rejects_another_dispatch(tmp_path): + path = private_socket_path(tmp_path) + entered = asyncio.Event() + release = asyncio.Event() + calls = 0 + + async def handle(_: ModelProxyRequest) -> ModelProxyReply: + nonlocal calls + calls += 1 + entered.set() + await release.wait() + return ModelProxyReply("unavailable", None, None, "budget_unavailable", 0, True) + + async with ModelProxyTransport( + socket_path=path, + handler=handle, + maximum_connections=1, + ): + async with connection(path) as (first_reader, first_writer): + first_writer.write(frame(request_value(prompt="first"))) + await first_writer.drain() + await asyncio.wait_for(entered.wait(), 1) + async with connection(path) as (_, second_writer): + second_writer.write(frame(request_value(prompt="second"))) + await second_writer.drain() + await asyncio.sleep(0.05) + assert calls == 1 + release.set() + assert await read_frame(first_reader) == { + "protocol_version": MODEL_PROXY_PROTOCOL, + "outcome": "unavailable", + "output": None, + "receipt_digest": None, + "failure_code": "budget_unavailable", + "call_index": 0, + "session_exhausted": True, + } + + assert calls == 1 + + +@pytest.mark.asyncio +async def test_client_disconnect_does_not_cancel_or_reissue_dispatched_handler(tmp_path): + path = private_socket_path(tmp_path) + entered = asyncio.Event() + release = asyncio.Event() + completed = asyncio.Event() + cancelled = False + calls = 0 + + async def handle(_: ModelProxyRequest) -> ModelProxyReply: + nonlocal calls, cancelled + calls += 1 + entered.set() + try: + await release.wait() + except asyncio.CancelledError: + cancelled = True + raise + completed.set() + return ModelProxyReply("unavailable", None, None, "provider_unavailable", 0, True) + + transport = ModelProxyTransport( + socket_path=path, + handler=handle, + shutdown_timeout_seconds=0.05, + ) + await transport.start() + _, writer = await asyncio.open_unix_connection(path) + writer.write(frame(request_value())) + await writer.drain() + await asyncio.wait_for(entered.wait(), 1) + writer.close() + await writer.wait_closed() + + with pytest.raises(TimeoutError, match="handler drain"): + await transport.close() + assert path.exists() + assert calls == 1 + assert not cancelled + release.set() + await asyncio.wait_for(completed.wait(), 1) + await transport.close() + assert calls == 1 + assert not cancelled + assert not path.exists() + + +@pytest.mark.asyncio +async def test_start_refuses_nonprivate_symlink_and_occupied_paths(tmp_path): + public_parent = tmp_path / "public" + public_parent.mkdir(mode=0o755) + + async def handle(_: ModelProxyRequest) -> ModelProxyReply: + return ModelProxyReply("unavailable", None, None, "provider_unavailable", 0, True) + + with pytest.raises(ModelProxyCustodyError, match="parent custody"): + await ModelProxyTransport(socket_path=public_parent / "proxy.sock", handler=handle).start() + + private_parent = tmp_path / "private" + private_parent.mkdir(mode=0o700) + linked_parent = tmp_path / "linked" + linked_parent.symlink_to(private_parent, target_is_directory=True) + with pytest.raises(ModelProxyCustodyError, match="parent custody"): + await ModelProxyTransport(socket_path=linked_parent / "proxy.sock", handler=handle).start() + + occupied = private_parent / "occupied.sock" + occupied.write_text("not a socket") + with pytest.raises(ModelProxyCustodyError, match="occupied"): + await ModelProxyTransport(socket_path=occupied, handler=handle).start() + assert occupied.read_text() == "not a socket" + + stale = private_parent / "stale.sock" + stale_socket = socket.socket(socket.AF_UNIX) + stale_socket.bind(str(stale)) + stale_socket.close() + with pytest.raises(ModelProxyCustodyError, match="occupied"): + await ModelProxyTransport(socket_path=stale, handler=handle).start() + assert stat.S_ISSOCK(os.lstat(stale).st_mode) + + +@pytest.mark.asyncio +async def test_cleanup_refuses_replaced_socket_and_leaves_it_in_place(tmp_path): + path = private_socket_path(tmp_path) + + async def handle(_: ModelProxyRequest) -> ModelProxyReply: + return ModelProxyReply("unavailable", None, None, "provider_unavailable", 0, True) + + transport = ModelProxyTransport(socket_path=path, handler=handle) + await transport.start() + path.unlink() + replacement = socket.socket(socket.AF_UNIX) + replacement.bind(str(path)) + os.chmod(path, 0o600) + try: + with pytest.raises(ModelProxyCleanupError, match="identity changed"): + await transport.close() + assert stat.S_ISSOCK(os.lstat(path).st_mode) + finally: + replacement.close() + path.unlink() + + +@pytest.mark.parametrize( + "reply", + [ + ("completed", "", RECEIPT_DIGEST, None, 0, False), + ("completed", "output", None, None, 0, False), + ("unavailable", "output", None, "provider_unavailable", 0, True), + ("unavailable", None, None, "Provider-Unavailable", 0, True), + ("unavailable", None, None, "a" * 65, 0, True), + ], +) +def test_reply_rejects_invalid_closed_outcomes(reply): + with pytest.raises(ValueError, match="reply is invalid"): + ModelProxyReply(*reply) diff --git a/backend/tests/checkers/external/test_model_transport.py b/backend/tests/checkers/external/test_model_transport.py new file mode 100644 index 000000000..73d3d5c38 --- /dev/null +++ b/backend/tests/checkers/external/test_model_transport.py @@ -0,0 +1,113 @@ +"""Model relay selectors remain private, POST-only and request-bound.""" + +import asyncio +import json +import struct +from unittest.mock import AsyncMock + +import pytest + +from app.adapters.checkers import external_checker_execution_factory +from app.interfaces.external_checker_execution import ( + ExternalCheckerMaterialGrant, + ExternalCheckerModelGrant, +) +from app.interfaces.external_services import ExternalServiceProtocolError +from app.modules.checkers.api.external import ( + external_checker_json_hash, + make_external_checker_execution_request, + make_external_checker_execution_result, +) +from tests.checkers.external.support import ( + execution_identity, + execution_material, + registry_entry, +) + + +def request(phase="post_submit"): + """Build actual normalized custody without assigning model authority.""" + identity = execution_identity(phase) + inputs = {"task_version": "v3"} + return make_external_checker_execution_request( + registry=registry_entry(phase), + identity=identity, + configuration={}, + configuration_sha256=external_checker_json_hash({}), + input=inputs, + input_sha256=external_checker_json_hash(inputs), + materials=(execution_material(identity),), + ) + + +@pytest.mark.asyncio +async def test_model_selector_is_outside_image_request(tmp_path): + value = request() + grant = ExternalCheckerModelGrant("model_" + "a" * 32, value.request_digest) + result = make_external_checker_execution_result( + request_digest=value.request_digest, + registry_entry_id=value.registry.registry_entry_id, + registry_entry_digest=value.registry.entry_digest, + phase="post_submit", + outcome="infrastructure_failed", + verdict=None, + findings=(), + infrastructure_failure_code="implementation_unavailable", + ) + received = asyncio.get_running_loop().create_future() + + async def handle(reader, writer): + try: + size = struct.unpack(">I", await reader.readexactly(4))[0] + received.set_result(json.loads(await reader.readexactly(size))) + body = json.dumps({ + "protocol_version": "external_checker_service.v1", + "result": result.model_dump(mode="json"), + "isolation": None, + }).encode() + writer.write(struct.pack(">I", len(body)) + body) + await writer.drain() + finally: + writer.close() + await writer.wait_closed() + + path = tmp_path / "checker.sock" + server = await asyncio.start_unix_server(handle, path) + try: + adapter = external_checker_execution_factory( + socket_path=path, timeout_seconds=90.0, + ).create("unix_socket") + await adapter.execute( + value, ExternalCheckerMaterialGrant("extract_" + "b" * 32, value.request_digest), + model_grant=grant, + ) + wire = await asyncio.wait_for(received, 1) + assert wire["model_grant"] == { + "grant_id": grant.grant_id, "binding_digest": value.request_digest, + } + assert wire["request"] == value.model_dump(mode="json") + assert grant.grant_id not in json.dumps(wire["request"]) + assert grant.grant_id not in repr(grant) + finally: + server.close() + await server.wait_closed() + + +@pytest.mark.asyncio +@pytest.mark.parametrize("phase,substituted", [("pre_submit", False), ("post_submit", True)]) +async def test_wrong_phase_or_request_cannot_open_model_transport(tmp_path, monkeypatch, phase, substituted): + value = request(phase) + connect = AsyncMock() + monkeypatch.setattr(asyncio, "open_unix_connection", connect) + adapter = external_checker_execution_factory( + socket_path=tmp_path / "unused.sock", timeout_seconds=90.0, + ).create("unix_socket") + grant = ExternalCheckerModelGrant( + "model_" + "a" * 32, "sha256:" + "0" * 64 if substituted else value.request_digest, + ) + with pytest.raises(ExternalServiceProtocolError): + await adapter.execute( + value, ExternalCheckerMaterialGrant("extract_" + "b" * 32, value.request_digest), + model_grant=grant, + ) + connect.assert_not_called() diff --git a/backend/tests/checkers/external/test_policy.py b/backend/tests/checkers/external/test_policy.py index 4893ce7e1..27593903d 100644 --- a/backend/tests/checkers/external/test_policy.py +++ b/backend/tests/checkers/external/test_policy.py @@ -9,6 +9,9 @@ from app.interfaces.project_agents import ( AtomicGuideRequirement, CapabilityParameter, + CheckInfrastructureFailure, + CheckRuntimeDependency, + CheckSpecification, CompilationStage, PostSubmissionBindingProposal, PreSubmissionBindingProposal, @@ -29,10 +32,16 @@ selected_post_submit_execution_budget_ms, selected_pre_submit_execution_budget_ms, ) +from app.modules.model_usage.api import ( + ModelGenerationSettings, + ModelPricing, + ProjectModelProfile, + ProjectModelProfileSpec, +) from app.modules.checkers.api.external import EXTERNAL_CHECKER_EXECUTION_CLEANUP_MARGIN_MS from app.modules.artifacts.preparation import ArtifactPreparationLimits from tests.checkers.external.support import registry_spec -from tests.projects.guide_compilation.helpers import result +from tests.projects.guide_compilation.helpers import result, valid_specification def _entry( @@ -86,6 +95,83 @@ async def read_identity(self, **identity): ) from exc +class _Profiles: + def __init__(self, profile): + self.profile = profile + self.calls = [] + + async def read_exact(self, selector, **scope): + self.calls.append((selector, scope)) + return self.profile + + +def _model_configuration_schema() -> ExternalCheckerSchema: + document = { + "type": "object", + "properties": { + "model_profile_id": {"type": "string"}, + "model_profile_version": {"type": "integer"}, + "model_profile_digest": {"type": "string"}, + }, + "required": [ + "model_profile_id", + "model_profile_version", + "model_profile_digest", + ], + "additionalProperties": False, + } + return ExternalCheckerSchema( + schema_id="acme.model.configuration", + schema_version="v1", + document=document, + schema_sha256=external_checker_json_hash(document), + ) + + +def _model_profile(project_id, entry) -> ProjectModelProfile: + profile_id = new_record_id() + pricing = ModelPricing( + pricing_policy_id=new_record_id(), + pricing_policy_version=1, + pricing_policy_digest=external_checker_json_hash({"pricing": "fixed"}), + uncached_input_nanodollars_per_token=3, + cached_input_nanodollars_per_token=1, + output_nanodollars_per_token=7, + ) + spec = ProjectModelProfileSpec( + project_id=project_id, + profile_version=1, + provider="openai_responses", + api="responses.v1", + model_snapshot="gpt-example-2026-10-01", + settings=ModelGenerationSettings(reasoning_effort="medium", text_verbosity="low"), + maximum_request_bytes=10_000, + maximum_output_bytes=4_000, + maximum_response_bytes=30_000, + maximum_input_tokens=100, + maximum_output_tokens=50, + maximum_calls=1, + maximum_total_request_bytes=10_000, + maximum_total_response_bytes=30_000, + maximum_total_input_tokens=100, + maximum_total_output_tokens=50, + allowed_tools=(), + maximum_tool_calls=0, + maximum_tool_output_bytes=0, + deadline_ms=30_000, + pricing=pricing, + ) + return ProjectModelProfile( + operation_id=uuid4(), + actor_profile_id=uuid4(), + spec=spec, + profile_digest=external_checker_json_hash(spec.model_dump(mode="json")), + profile_id=profile_id, + authorization_evidence_id=uuid4(), + created_at=datetime.now(UTC), + ) + + def _proposal(): pre = AtomicGuideRequirement( requirement_id="safe_archive", @@ -120,6 +206,36 @@ def _proposal(): ) +def _model_proposal(): + proposal = _proposal() + model_specification = CheckSpecification.model_validate( + valid_specification().model_dump(mode="json") + | { + "evaluation_method": "model_evaluation", + "execution_mode": "background", + "stage_rationale": "Model review runs after immutable submission creation.", + "runtime_dependencies": ( + CheckRuntimeDependency( + dependency_id="model_runtime", + kind="model_service", + provisioning="model_proxy", + purpose="Bounded trusted model access for the project image.", + unavailable_code="implementation_unavailable", + ), + ), + "infrastructure_failures": ( + *valid_specification().infrastructure_failures, + CheckInfrastructureFailure( + code="implementation_unavailable", + condition="The trusted model service is unavailable.", + ), + ), + } + ) + post = proposal.requirements[1].model_copy(update={"check_specification": model_specification}) + return proposal.model_copy(update={"requirements": (proposal.requirements[0], post)}) + + @pytest.mark.asyncio async def test_compilation_locks_exact_phase_registry_entries_and_default(): entries = ( @@ -142,6 +258,166 @@ async def test_compilation_locks_exact_phase_registry_entries_and_default(): assert [call["phase"] for call in registry.calls] == ["pre_submit", "post_submit"] +@pytest.mark.asyncio +async def test_model_configuration_requires_exact_post_profile_scope(): + project_id = new_record_id() + pre = _entry("acme.safe_archive", "pre_submit") + post = _entry( + "acme.quality_review", + "post_submit", + _model_configuration_schema(), + ) + profile = _model_profile(project_id, post) + profiles = _Profiles(profile) + proposal = _model_proposal() + binding = proposal.post_submit_bindings[0].model_copy( + update={ + "parameters": ( + CapabilityParameter(name="model_profile_id", value=str(profile.profile_id)), + CapabilityParameter(name="model_profile_version", value=1), + CapabilityParameter(name="model_profile_digest", value=profile.profile_digest), + ) + } + ) + + _, policy = await compile_external_checker_policies( + proposal.model_copy(update={"post_submit_bindings": (binding,)}), + _Registry((pre, post)), + project_id=project_id, + profiles=profiles, + ) + + assert policy.entries[0].configuration == { + "model_profile_id": str(profile.profile_id), + "model_profile_version": 1, + "model_profile_digest": profile.profile_digest, + } + selector, scope = profiles.calls[0] + assert selector == profile.selector + assert scope == { + "project_id": project_id, + } + + +@pytest.mark.asyncio +async def test_isolated_execution_model_dependency_also_requires_exact_profile(): + project_id = new_record_id() + pre = _entry("acme.safe_archive", "pre_submit") + post = _entry( + "acme.quality_review", + "post_submit", + _model_configuration_schema(), + ) + profile = _model_profile(project_id, post) + proposal = _model_proposal() + specification = proposal.requirements[1].check_specification + assert specification is not None + specification = specification.model_copy( + update={ + "evaluation_method": "isolated_execution", + "runtime_dependencies": ( + CheckRuntimeDependency( + dependency_id="isolated_runtime", + kind="isolated_execution", + provisioning="current_runner", + purpose="Execute the bounded authenticated trial.", + unavailable_code="implementation_unavailable", + ), + *specification.runtime_dependencies, + ), + } + ) + requirement = proposal.requirements[1].model_copy(update={"check_specification": specification}) + proposal = proposal.model_copy(update={"requirements": (proposal.requirements[0], requirement)}) + + with pytest.raises(ValueError, match="model evaluation profile"): + await compile_external_checker_policies( + proposal, + _Registry((pre, post)), + project_id=project_id, + profiles=_Profiles(profile), + ) + + binding = proposal.post_submit_bindings[0].model_copy( + update={ + "parameters": ( + CapabilityParameter(name="model_profile_id", value=str(profile.profile_id)), + CapabilityParameter(name="model_profile_version", value=1), + CapabilityParameter(name="model_profile_digest", value=profile.profile_digest), + ) + } + ) + _, policy = await compile_external_checker_policies( + proposal.model_copy(update={"post_submit_bindings": (binding,)}), + _Registry((pre, post)), + project_id=project_id, + profiles=_Profiles(profile), + ) + assert policy.entries[0].configuration["model_profile_id"] == str(profile.profile_id) + + +@pytest.mark.asyncio +async def test_model_configuration_rejects_partial_pre_and_substituted_profile(): + project_id = new_record_id() + pre = _entry( + "acme.safe_archive", + "pre_submit", + _model_configuration_schema(), + ) + ordinary_pre = _entry("acme.safe_archive", "pre_submit") + post = _entry( + "acme.quality_review", + "post_submit", + _model_configuration_schema(), + ) + profile = _model_profile(project_id, post) + parameters = ( + CapabilityParameter(name="model_profile_id", value=str(profile.profile_id)), + CapabilityParameter(name="model_profile_version", value=1), + CapabilityParameter(name="model_profile_digest", value=profile.profile_digest), + ) + proposal = _model_proposal() + with pytest.raises(ValueError, match="model evaluation profile"): + await compile_external_checker_policies( + proposal, + _Registry( + ( + ordinary_pre, + _entry("acme.quality_review", "post_submit"), + ) + ), + project_id=project_id, + profiles=_Profiles(profile), + ) + post_binding = proposal.post_submit_bindings[0].model_copy(update={"parameters": parameters}) + partial = post_binding.model_copy(update={"parameters": parameters[:1]}) + with pytest.raises(ValueError, match="configuration"): + await compile_external_checker_policies( + proposal.model_copy(update={"post_submit_bindings": (partial,)}), + _Registry((ordinary_pre, post)), + project_id=project_id, + profiles=_Profiles(profile), + ) + + pre_binding = proposal.pre_submit_bindings[0].model_copy(update={"parameters": parameters}) + with pytest.raises(ValueError, match="configuration"): + await compile_external_checker_policies( + proposal.model_copy(update={"pre_submit_bindings": (pre_binding,)}), + _Registry((pre, post)), + project_id=project_id, + profiles=_Profiles(profile), + ) + + substituted = _model_profile(new_record_id(), post) + with pytest.raises(ValueError, match="unavailable"): + await compile_external_checker_policies( + proposal.model_copy(update={"post_submit_bindings": (post_binding,)}), + _Registry((ordinary_pre, post)), + project_id=project_id, + profiles=_Profiles(substituted), + ) + + @pytest.mark.asyncio async def test_compilation_refuses_missing_or_wrong_phase_registry_identity(): proposal = _proposal() @@ -240,10 +516,13 @@ async def test_selected_post_maximum_fits_default_art_custody_and_rejects_overru ) limits = ArtifactPreparationLimits() - assert selected_post_submit_execution_budget_ms( - policy, - artifact_deadline_ms=int(limits.total_deadline_seconds * 1000), - ) == MAXIMUM_SELECTED_POST_EXECUTION_BUDGET_MS + assert ( + selected_post_submit_execution_budget_ms( + policy, + artifact_deadline_ms=int(limits.total_deadline_seconds * 1000), + ) + == MAXIMUM_SELECTED_POST_EXECUTION_BUDGET_MS + ) assert ( limits.total_deadline_seconds + limits.cleanup_margin_seconds < limits.reservation_ttl_seconds @@ -285,13 +564,17 @@ async def test_pre_configuration_is_schema_checked_and_locked_as_json(): "additionalProperties": False, } schema = ExternalCheckerSchema( - schema_id="acme.pre.configuration", schema_version="v1", document=document, + schema_id="acme.pre.configuration", + schema_version="v1", + document=document, schema_sha256=external_checker_json_hash(document), ) - registry = _Registry(( - _entry("acme.safe_archive", "pre_submit", schema), - _entry("acme.quality_review", "post_submit"), - )) + registry = _Registry( + ( + _entry("acme.safe_archive", "pre_submit", schema), + _entry("acme.quality_review", "post_submit"), + ) + ) proposal = _proposal() parameters = ( CapabilityParameter(name="threshold", value=3), @@ -308,21 +591,26 @@ async def test_pre_configuration_is_schema_checked_and_locked_as_json(): wrong = binding.model_copy(update={"parameters": invalid}) with pytest.raises(ValueError): await compile_external_checker_policies( - proposal.model_copy(update={"pre_submit_bindings": (wrong,)}), registry, + proposal.model_copy(update={"pre_submit_bindings": (wrong,)}), + registry, ) def test_pre_configuration_preserves_retained_empty_binding_and_rejects_duplicates(): binding = _proposal().pre_submit_bindings[0] retained = { - "requirement_id": "safe_archive", "capability_id": "acme.safe_archive", - "capability_version": "v1.2.3", "stage": "pre_submit", + "requirement_id": "safe_archive", + "capability_id": "acme.safe_archive", + "capability_version": "v1.2.3", + "stage": "pre_submit", } assert binding.model_dump(mode="json") == retained assert PreSubmissionBindingProposal.model_validate(retained).model_dump(mode="json") == retained duplicate = CapabilityParameter(name="threshold", value=3) with pytest.raises(ValueError, match="unique"): - PreSubmissionBindingProposal.model_validate(retained | {"parameters": (duplicate, duplicate)}) + PreSubmissionBindingProposal.model_validate( + retained | {"parameters": (duplicate, duplicate)} + ) @pytest.mark.asyncio @@ -333,24 +621,29 @@ async def test_policy_compilation_refuses_unrunnable_post_images(aggregate: bool if aggregate: binding = proposal.post_submit_bindings[0] requirement = next( - item for item in proposal.requirements - if item.requirement_id == binding.requirement_id + item for item in proposal.requirements if item.requirement_id == binding.requirement_id + ) + proposal = proposal.model_copy( + update={ + "requirements": proposal.requirements + + (requirement.model_copy(update={"requirement_id": "secondary_review"}),), + "post_submit_bindings": proposal.post_submit_bindings + + ( + binding.model_copy( + update={ + "requirement_id": "secondary_review", + "capability_id": "acme.secondary_review", + } + ), + ), + } + ) + entries.extend( + [ + _entry("acme.quality_review", "post_submit", deadline_ms=1_700_000), + _entry("acme.secondary_review", "post_submit", deadline_ms=1_700_000), + ] ) - proposal = proposal.model_copy(update={ - "requirements": proposal.requirements + ( - requirement.model_copy(update={"requirement_id": "secondary_review"}), - ), - "post_submit_bindings": proposal.post_submit_bindings + ( - binding.model_copy(update={ - "requirement_id": "secondary_review", - "capability_id": "acme.secondary_review", - }), - ), - }) - entries.extend([ - _entry("acme.quality_review", "post_submit", deadline_ms=1_700_000), - _entry("acme.secondary_review", "post_submit", deadline_ms=1_700_000), - ]) else: entries.append(_entry("acme.quality_review", "post_submit", deadline_ms=3_600_000)) with pytest.raises(ValueError, match="durable worker deadline"): @@ -362,17 +655,21 @@ async def test_policy_compilation_refuses_pre_images_exceeding_platform_custody( proposal = _proposal() binding = proposal.pre_submit_bindings[0] requirement = proposal.requirements[0] - proposal = proposal.model_copy(update={ - "requirements": proposal.requirements + ( - requirement.model_copy(update={"requirement_id": "secondary_archive"}), - ), - "pre_submit_bindings": proposal.pre_submit_bindings + ( - binding.model_copy(update={ - "requirement_id": "secondary_archive", - "capability_id": "acme.secondary_archive", - }), - ), - }) + proposal = proposal.model_copy( + update={ + "requirements": proposal.requirements + + (requirement.model_copy(update={"requirement_id": "secondary_archive"}),), + "pre_submit_bindings": proposal.pre_submit_bindings + + ( + binding.model_copy( + update={ + "requirement_id": "secondary_archive", + "capability_id": "acme.secondary_archive", + } + ), + ), + } + ) entries = [ _entry("acme.safe_archive", "pre_submit", deadline_ms=3_600_000), _entry("acme.secondary_archive", "pre_submit", deadline_ms=3_600_000), diff --git a/backend/tests/checkers/external/test_postgresql.py b/backend/tests/checkers/external/test_postgresql.py index 1b11db589..348893df5 100644 --- a/backend/tests/checkers/external/test_postgresql.py +++ b/backend/tests/checkers/external/test_postgresql.py @@ -416,15 +416,25 @@ async def test_registry_rejects_update_delete_truncate_and_unauthorized_insert( request = registration_request(actor_profile_id=admin_access.target.id) await _register(admin_access, request) + baseline = await _snapshot() for statement in ( "update external_checker_registry_entries set capability_version='v2'", "delete from external_checker_registry_entries", - "truncate external_checker_registry_entries", ): async with db_session.get_session_factory()() as session: with pytest.raises(DBAPIError, match="external checker registry is immutable"): async with session.begin(): await session.execute(text(statement)) + assert await _snapshot() == baseline + + async with db_session.get_session_factory()() as session: + with pytest.raises(DBAPIError) as truncate_error: + async with session.begin(): + await session.execute(text("truncate external_checker_registry_entries")) + truncate_message = str(truncate_error.value) + assert "cannot truncate a table referenced in a foreign key constraint" in truncate_message + assert 'references "external_checker_registry_entries"' in truncate_message + assert await _snapshot() == baseline spec = registry_spec("post_submit") async with db_session.get_session_factory()() as session: diff --git a/backend/tests/checkers/external/test_runtime.py b/backend/tests/checkers/external/test_runtime.py index ba4285b24..5e73f5fce 100644 --- a/backend/tests/checkers/external/test_runtime.py +++ b/backend/tests/checkers/external/test_runtime.py @@ -262,6 +262,7 @@ async def handle(reader, writer): writer.close() return assert received["request"]["request_digest"] == request.request_digest + assert received["model_grant"] is None body = json.dumps( { "protocol_version": "external_checker_service.v1", diff --git a/backend/tests/checkers/model_provider/test_openai_responses.py b/backend/tests/checkers/model_provider/test_openai_responses.py new file mode 100644 index 000000000..c2b0e7b48 --- /dev/null +++ b/backend/tests/checkers/model_provider/test_openai_responses.py @@ -0,0 +1,460 @@ +"""Offline OpenAI Responses transport tests for checker model custody.""" + +from __future__ import annotations + +from dataclasses import FrozenInstanceError +import json + +import httpx2 +from openai import AsyncOpenAI +import pytest + +from app.adapters.checkers.model_provider import ( + OpenAIResponsesCheckerModelProvider, + OpenAIResponsesSettings, +) +from app.adapters.checkers import create_checker_model_provider +from app.interfaces.checker_model_provider import ( + CHECKER_MODEL_CAPABILITY_KEY, + MAX_CHECKER_MODEL_OUTPUT_BYTES, + MAX_CHECKER_MODEL_PROMPT_BYTES, + CheckerModelCreateFailure, + CheckerModelCreateOutcome, + CheckerModelObservation, + CheckerModelProviderStatus, + CheckerModelRecoveryOutcome, + CheckerModelRequest, + CheckerModelResponseReference, + CheckerModelUsage, +) + + +def settings(**overrides) -> OpenAIResponsesSettings: + values = { + "api_key": "secret-test-key", + "model": "gpt-5.6-terra", + "maximum_request_bytes": 1_000, + "maximum_input_tokens": 100, + "maximum_output_tokens": 20, + "maximum_output_bytes": 1_000, + "request_timeout_seconds": 3.0, + "reasoning_effort": "medium", + "text_verbosity": "low", + } + values.update(overrides) + return OpenAIResponsesSettings(**values) + + +def completed_response( + *, + response_id: str = "resp_known", + model: str = "gpt-5.6-terra", + status: str = "completed", + text: str = "verbatim output", + usage: dict | None = None, +) -> dict: + body = { + "id": response_id, + "object": "response", + "created_at": 1.0, + "model": model, + "status": status, + "service_tier": "default", + "output": [], + "parallel_tool_calls": False, + "tool_choice": "auto", + "tools": [], + "temperature": None, + "top_p": None, + "usage": usage, + } + if status in {"completed", "failed", "cancelled", "incomplete"} and text: + body["output"] = [ + { + "id": "msg_1", + "type": "message", + "role": "assistant", + "status": "completed", + "content": [{"type": "output_text", "text": text, "annotations": []}], + } + ] + return body + + +def usage(**overrides) -> dict: + values = { + "input_tokens": 8, + "input_tokens_details": {"cached_tokens": 3, "cache_write_tokens": 0}, + "output_tokens": 5, + "output_tokens_details": {"reasoning_tokens": 2}, + "total_tokens": 13, + } + values.update(overrides) + return values + + +def adapter(handler, *, profile=None): + http_client = httpx2.AsyncClient(transport=httpx2.MockTransport(handler)) + client = AsyncOpenAI( + api_key="secret-test-key", + base_url="https://provider.invalid/v1", + http_client=http_client, + max_retries=0, + ) + return OpenAIResponsesCheckerModelProvider(profile or settings(), client) + + +def test_dtos_are_immutable_bounded_and_secret_safe() -> None: + request = CheckerModelRequest("private prompt") + observed = CheckerModelObservation( + CheckerModelResponseReference("resp_known"), + "gpt-5.6-terra", + CheckerModelProviderStatus.COMPLETED, + "private output", + CheckerModelUsage(8, 3, 5, 2, 13), + ) + configured = settings() + + assert "private prompt" not in repr(request) + assert "private output" not in repr(observed) + assert "secret-test-key" not in repr(configured) + with pytest.raises(FrozenInstanceError): + request.prompt = "changed" # type: ignore[misc] + with pytest.raises(ValueError, match="prompt"): + CheckerModelRequest("x" * (MAX_CHECKER_MODEL_PROMPT_BYTES + 1)) + with pytest.raises(ValueError, match="usage"): + CheckerModelUsage(8, 9, 5, 2, 13) + with pytest.raises(ValueError, match="usage"): + CheckerModelUsage(8, 3, 5, 6, 13) + with pytest.raises(ValueError, match="usage"): + CheckerModelUsage(8, 3, 5, 2, 12) + with pytest.raises(ValueError, match="settings"): + settings(model="unusable/model") + with pytest.raises(ValueError, match="settings"): + settings(maximum_request_bytes=MAX_CHECKER_MODEL_PROMPT_BYTES + 1) + with pytest.raises(ValueError, match="settings"): + settings(maximum_output_bytes=MAX_CHECKER_MODEL_OUTPUT_BYTES + 1) + + +async def test_create_counts_then_dispatches_one_stored_background_response() -> None: + requests: list[httpx2.Request] = [] + + def handler(request: httpx2.Request) -> httpx2.Response: + requests.append(request) + if request.url.path.endswith("/responses/input_tokens"): + return httpx2.Response( + 200, + json={"object": "response.input_tokens", "input_tokens": 8}, + ) + return httpx2.Response(200, json=completed_response(usage=usage())) + + provider = adapter(handler) + result = await provider.create(CheckerModelRequest("inspect this package")) + await provider.aclose() + + assert result.outcome is CheckerModelCreateOutcome.ACCEPTED + assert result.failure is None + assert result.known_response == CheckerModelResponseReference("resp_known") + assert result.observation is not None + assert result.observation.provider_model == "gpt-5.6-terra" + assert result.observation.output_text == "verbatim output" + assert result.observation.usage == CheckerModelUsage(8, 3, 5, 2, 13) + assert [request.url.path for request in requests] == [ + "/v1/responses/input_tokens", + "/v1/responses", + ] + counted, created = (json.loads(request.content) for request in requests) + for body in (counted, created): + assert body["model"] == "gpt-5.6-terra" + assert body["input"] == "inspect this package" + assert body["reasoning"] == {"effort": "medium"} + assert body["text"] == {"verbosity": "low"} + assert body["parallel_tool_calls"] is False + assert body["tools"] == [] + assert body["truncation"] == "disabled" + assert created["background"] is True + assert created["store"] is True + assert created["service_tier"] == "default" + assert created["max_output_tokens"] == 20 + + +@pytest.mark.parametrize( + ("model_request", "count_response", "failure"), + [ + ( + CheckerModelRequest("x" * 11), + None, + CheckerModelCreateFailure.REQUEST_BYTES_EXCEEDED, + ), + ( + CheckerModelRequest("bounded"), + httpx2.Response(503), + CheckerModelCreateFailure.INPUT_COUNT_UNAVAILABLE, + ), + ( + CheckerModelRequest("bounded"), + httpx2.Response( + 200, + json={"object": "response.input_tokens", "input_tokens": 101}, + ), + CheckerModelCreateFailure.INPUT_LIMIT_EXCEEDED, + ), + ], + ids=["request-exceeds-profile", "count-unavailable", "count-exceeds-profile"], +) +async def test_precreate_count_failures_prove_no_create_effect( + model_request: CheckerModelRequest, + count_response: httpx2.Response | None, + failure: CheckerModelCreateFailure, +) -> None: + requests: list[httpx2.Request] = [] + + def handler(request: httpx2.Request) -> httpx2.Response: + requests.append(request) + assert count_response is not None + return count_response + + provider = adapter(handler, profile=settings(maximum_request_bytes=10)) + result = await provider.create(model_request) + await provider.aclose() + + assert result.outcome is CheckerModelCreateOutcome.NO_EFFECT + assert result.failure is failure + assert result.known_response is None + expected_paths = [] if count_response is None else ["/v1/responses/input_tokens"] + assert [request.url.path for request in requests] == expected_paths + + +async def test_lost_create_is_uncertain_and_never_retried() -> None: + requests: list[httpx2.Request] = [] + + def handler(request: httpx2.Request) -> httpx2.Response: + requests.append(request) + if request.url.path.endswith("/responses/input_tokens"): + return httpx2.Response( + 200, + json={"object": "response.input_tokens", "input_tokens": 8}, + ) + raise httpx2.ReadTimeout("lost create response", request=request) + + provider = adapter(handler) + result = await provider.create(CheckerModelRequest("one paid effect")) + await provider.aclose() + + assert result.outcome is CheckerModelCreateOutcome.UNCERTAIN + assert result.failure is CheckerModelCreateFailure.DELIVERY_UNCERTAIN + assert result.known_response is None + assert [request.url.path for request in requests] == [ + "/v1/responses/input_tokens", + "/v1/responses", + ] + + +async def test_create_http_rejection_remains_uncertain_and_never_retried() -> None: + requests: list[httpx2.Request] = [] + + def handler(request: httpx2.Request) -> httpx2.Response: + requests.append(request) + if request.url.path.endswith("/responses/input_tokens"): + return httpx2.Response( + 200, + json={"object": "response.input_tokens", "input_tokens": 8}, + ) + return httpx2.Response( + 400, + json={ + "error": { + "message": "provider detail must remain private", + "type": "invalid_request_error", + } + }, + ) + + provider = adapter(handler) + result = await provider.create(CheckerModelRequest("one paid effect")) + await provider.aclose() + + assert result.outcome is CheckerModelCreateOutcome.UNCERTAIN + assert result.failure is CheckerModelCreateFailure.DELIVERY_UNCERTAIN + assert "provider detail" not in repr(result) + assert [request.url.path for request in requests] == [ + "/v1/responses/input_tokens", + "/v1/responses", + ] + + +async def test_recover_uses_only_the_known_response_id() -> None: + requests: list[httpx2.Request] = [] + + def handler(request: httpx2.Request) -> httpx2.Response: + requests.append(request) + return httpx2.Response( + 200, + json=completed_response( + status="queued", + text="", + usage=None, + ), + ) + + provider = adapter(handler) + reference = CheckerModelResponseReference("resp_known") + result = await provider.recover(reference) + await provider.aclose() + + assert result.outcome is CheckerModelRecoveryOutcome.OBSERVED + assert result.reference == reference + assert result.observation is not None + assert result.observation.status is CheckerModelProviderStatus.QUEUED + assert len(requests) == 1 + assert requests[0].method == "GET" + assert requests[0].url.path == "/v1/responses/resp_known" + + +@pytest.mark.parametrize( + ("body", "profile_overrides"), + [ + ( + completed_response( + usage=usage( + input_tokens_details={ + "cached_tokens": 9, + "cache_write_tokens": 0, + } + ) + ), + {}, + ), + (completed_response(usage=usage(total_tokens=12)), {}), + ( + completed_response(usage=usage(input_tokens=101, total_tokens=106)), + {}, + ), + ( + completed_response(usage=usage(output_tokens=21, total_tokens=29)), + {}, + ), + ( + completed_response(usage=usage(output_tokens_details={"reasoning_tokens": 6})), + {}, + ), + (completed_response(model="gpt-5.6-terra-other", usage=usage()), {}), + ( + completed_response(text="x" * 11, usage=usage()), + {"maximum_output_bytes": 10}, + ), + (completed_response(response_id="", usage=usage()), {}), + (completed_response(usage=usage()) | {"service_tier": "priority"}, {}), + (completed_response(usage=usage()) | {"service_tier": None}, {}), + ], + ids=[ + "cached-exceeds-input", + "total-mismatch", + "input-exceeds-profile", + "output-exceeds-profile", + "reasoning-exceeds-output", + "model-mismatch", + "output-byte-bound", + "missing-response-id", + "unexpected-pricing-tier", + "missing-pricing-tier", + ], +) +async def test_malformed_create_response_is_uncertain_and_never_releases_effect( + body: dict, + profile_overrides: dict, +) -> None: + def handler(request: httpx2.Request) -> httpx2.Response: + if request.url.path.endswith("/responses/input_tokens"): + return httpx2.Response( + 200, + json={"object": "response.input_tokens", "input_tokens": 8}, + ) + return httpx2.Response(200, json=body) + + provider = adapter(handler, profile=settings(**profile_overrides)) + result = await provider.create(CheckerModelRequest("one effect")) + await provider.aclose() + + assert result.outcome is CheckerModelCreateOutcome.UNCERTAIN + assert result.failure is CheckerModelCreateFailure.RESPONSE_INVALID + if body["id"]: + assert result.known_response == CheckerModelResponseReference(body["id"]) + else: + assert result.known_response is None + + +async def test_recovery_protocol_failure_is_invalid_without_create() -> None: + requests: list[httpx2.Request] = [] + + def handler(request: httpx2.Request) -> httpx2.Response: + requests.append(request) + return httpx2.Response( + 200, + json=completed_response(model="wrong-model", usage=usage()), + ) + + provider = adapter(handler) + reference = CheckerModelResponseReference("resp_known") + result = await provider.recover(reference) + await provider.aclose() + + assert result.outcome is CheckerModelRecoveryOutcome.INVALID + assert result.reference == reference + assert result.observation is None + assert len(requests) == 1 and requests[0].method == "GET" + + +@pytest.mark.parametrize("status", ["failed", "cancelled", "incomplete"]) +async def test_known_terminal_failures_remain_accepted_effects(status: str) -> None: + def handler(request: httpx2.Request) -> httpx2.Response: + if request.url.path.endswith("/responses/input_tokens"): + return httpx2.Response( + 200, + json={"object": "response.input_tokens", "input_tokens": 8}, + ) + return httpx2.Response( + 200, + json=completed_response(status=status, text="", usage=None), + ) + + provider = adapter(handler) + result = await provider.create(CheckerModelRequest("one effect")) + await provider.aclose() + + assert result.outcome is CheckerModelCreateOutcome.ACCEPTED + assert result.observation is not None + assert result.observation.status.value == status + assert result.observation.usage is None + + +async def test_factory_registers_one_zero_retry_openai_provider(monkeypatch) -> None: + constructed: list[dict] = [] + + class FakeInputTokens: + async def count(self, **kwargs): + raise AssertionError("factory test must not call provider") + + class FakeResponses: + input_tokens = FakeInputTokens() + + class FakeClient: + responses = FakeResponses() + + async def close(self): + return None + + def client(**kwargs): + constructed.append(kwargs) + return FakeClient() + + monkeypatch.setattr("openai.AsyncOpenAI", client) + provider = create_checker_model_provider(settings()) + + assert provider.identity.capability_key == CHECKER_MODEL_CAPABILITY_KEY + assert provider.identity.provider_key == "openai_responses" + assert len(constructed) == 1 + assert constructed[0]["max_retries"] == 0 + assert constructed[0]["timeout"] == 3.0 + assert constructed[0]["api_key"] == "secret-test-key" + await provider.aclose() diff --git a/backend/tests/checkers_model_calls/__init__.py b/backend/tests/checkers_model_calls/__init__.py new file mode 100644 index 000000000..0316fdc26 --- /dev/null +++ b/backend/tests/checkers_model_calls/__init__.py @@ -0,0 +1 @@ +"""Model-call owner and adapter integration tests with scoped pytest fixtures.""" diff --git a/backend/tests/checkers_model_calls/conftest.py b/backend/tests/checkers_model_calls/conftest.py new file mode 100644 index 000000000..6f405723b --- /dev/null +++ b/backend/tests/checkers_model_calls/conftest.py @@ -0,0 +1,11 @@ +"""Explicit signed public Operator fixtures for model controls and composed execution.""" + +from tests.authentication.fixtures import ( + auth_database_env as auth_database_env, + clear_settings_cache as clear_settings_cache, + rsa_signing_material as rsa_signing_material, +) +from tests.authorization.admin_access.fixtures import ( + signed_access as signed_access, + admin_access as admin_access, +) diff --git a/backend/tests/checkers_model_calls/model_usage_boundary_support.py b/backend/tests/checkers_model_calls/model_usage_boundary_support.py new file mode 100644 index 000000000..897345f6e --- /dev/null +++ b/backend/tests/checkers_model_calls/model_usage_boundary_support.py @@ -0,0 +1,608 @@ +"""Canonical fake owner boundaries for real MODEL-USAGE PostgreSQL tests.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +import json +from uuid import UUID + +from sqlalchemy import text +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.hashing import canonical_json_hash +from app.core.identifiers import new_record_id +from app.modules.authorization.domain.model_usage import ( + BUDGET_CONFIGURE, + COMPLETE, + DISPATCH, + OBSERVE, + PREPARE, + PROFILE_PROVISION, + RECOVER, + RECOVERY_CHILD_AUTHORIZE, + SETUP_COMPLETE, + SETUP_DISPATCH, + SETUP_OBSERVE, + SETUP_PREPARE, + ModelCallCompleteAuthorityFacts, + ModelCallDispatchAuthorityFacts, + ModelCallObserveAuthorityFacts, + ModelCallPrepareAuthorityFacts, + ModelCallRecoverAuthorityFacts, + ModelCallRecoveryChildAuthorityFacts, + SetupModelCompleteAuthorityFacts, + SetupModelDispatchAuthorityFacts, + SetupModelObserveAuthorityFacts, + SetupModelPrepareAuthorityFacts, + checker_model_call_resource, + project_model_admin_resource, + setup_model_usage_resource, +) +from app.modules.authorization.domain.audit_targets import project_authority_audit_target +from app.modules.authorization.domain.resource_digest import authorization_resource_digest +from app.modules.model_usage.api import ( + ModelActionEvidence, + ModelCallUnavailable, + ModelProfileSelector, + SetupModelOwnerProfile, + model_action_digest, +) + + +def sha(label: str) -> str: + return canonical_json_hash({"label": label}) + + +@dataclass +class BoundaryPorts: + decisions: dict[str, UUID] = field(default_factory=dict) + fresh_authority_evidence: bool = True + request_checks: int = 0 + response_checks: int = 0 + current_checks: int = 0 + events: list[str] = field(default_factory=list) + corrupt_admin_receipt: bool = False + corrupt_runtime_receipt_action: str | None = None + terminal_receipt_change: str | None = None + + def _decision(self, action: str, facts: object) -> ModelActionEvidence: + self.events.append(action) + digest = model_action_digest(action, facts) + evidence_id = ( + new_record_id() + if self.fresh_authority_evidence + else self.decisions.setdefault(digest, new_record_id()) + ) + return ModelActionEvidence(evidence_id=evidence_id, facts_digest=digest) + + async def authorize_profile(self, facts): + return self._decision("profile", facts) + + async def authorize_budget(self, facts): + return self._decision("budget", facts) + + async def authorize_prepare(self, execution, slot, request_sha256): + return self._decision( + "prepare", + { + "execution": execution.model_dump(mode="json"), + "slot": slot.model_dump(mode="json"), + "request_sha256": request_sha256, + }, + ) + + async def authorize_dispatch(self, execution, attempt, request_evidence): + return self._decision( + "dispatch", + { + "execution": execution.model_dump(mode="json"), + "attempt": attempt.model_dump(mode="json"), + "request_evidence": request_evidence.model_dump(mode="json"), + }, + ) + + async def authorize_recover(self, execution, effect): + return self._decision( + "recover", + { + "execution": execution.model_dump(mode="json"), + "effect": effect.model_dump(mode="json"), + }, + ) + + async def authorize_observe(self, effect, provider_response_id, observation_digest): + return self._decision( + "observe", + { + "effect": effect.model_dump(mode="json"), + "provider_response_id": provider_response_id, + "observation_digest": observation_digest, + }, + ) + + async def authorize_child(self, project_id, parent_attempt_id, terminal_digest): + return self._decision( + "child", + { + "project_id": str(project_id), + "parent_attempt_id": str(parent_attempt_id), + "terminal_digest": terminal_digest, + }, + ) + + async def authorize_complete(self, effect, response_evidence, terminal_digest): + return self._decision( + "complete", + { + "effect": effect.model_dump(mode="json"), + "response_evidence": ( + response_evidence.model_dump(mode="json") + if response_evidence is not None + else None + ), + "terminal_digest": terminal_digest, + }, + ) + + async def authorize_setup_prepare(self, slot, request_sha256): + return self._decision( + "setup_prepare", + {"slot": slot.model_dump(mode="json"), "request_sha256": request_sha256}, + ) + + async def authorize_setup_dispatch(self, prepared, request_evidence): + return self._decision( + "setup_dispatch", + { + "prepared": prepared.model_dump(mode="json"), + "request_evidence": request_evidence.model_dump(mode="json"), + }, + ) + + async def authorize_setup_observe(self, effect, provider_response_id, observation_digest): + return self._decision( + "setup_observe", + { + "effect": effect.model_dump(mode="json"), + "provider_response_id": provider_response_id, + "observation_digest": observation_digest, + }, + ) + + async def authorize_setup_complete(self, effect, response_evidence, terminal_digest): + return self._decision( + "setup_complete", + { + "effect": effect.model_dump(mode="json"), + "response_evidence": ( + response_evidence.model_dump(mode="json") + if response_evidence is not None + else None + ), + "terminal_digest": terminal_digest, + }, + ) + + async def require_setup_request(self, prepared, evidence): + self.request_checks += 1 + + async def require_setup_response(self, effect_id, evidence): + self.response_checks += 1 + + async def require_current(self, facts, slot): + self.current_checks += 1 + self.events.append("current") + + async def require_recovery_current(self, slot, parent_attempt_id): + self.current_checks += 1 + self.events.append("recovery_current") + + async def require_request(self, attempt, evidence): + self.request_checks += 1 + + async def require_response(self, attempt_id, evidence): + self.response_checks += 1 + + +class DatabaseBoundaryPorts: + """Persist fake boundary receipts so isolated PostgreSQL exercises real ancestry FKs.""" + + def __init__(self, session: AsyncSession, ports: BoundaryPorts): + self._session = session + self._ports = ports + + async def _authority( + self, + evidence, + *, + action: str, + permission: str, + actor_id=None, + resource=None, + resource_digest=None, + service_identity: str | None = None, + matched_grant_id: UUID | None = None, + ): + actor = str(actor_id or new_record_id()) + if actor_id is not None or service_identity is not None: + await self._session.execute( + text( + "insert into actor_profiles(id,actor_kind,status,service_identity) " + "values (:id,:kind,'active',:identity) on conflict do nothing" + ), + { + "id": actor, + "kind": "service" if service_identity is not None else "human", + "identity": service_identity, + }, + ) + project_id = None + resource_type = None + resource_id = None + if resource is not None: + resource_project_id = getattr(resource, "scope_project_id", None) or getattr( + resource, "project_id", None + ) + if resource_digest is None: + resource_digest = authorization_resource_digest(resource) + target = project_authority_audit_target( + resource, + resource.action_id, + matched_scope_project_id=( + resource_project_id + if resource.resource_type == "project_model_administration" + else None + ), + ) + if target is not None: + project_id, resource_type, resource_id, _, _ = target + await self._session.execute( + text( + "insert into audit_events(id,event_domain,event_type,actor_ref_kind,actor_id," + "action_id,permission_id,project_id,resource_type,resource_id,matched_grant_id," + "denial_code,after_facts) values (:id,'authority'," + "'SensitiveAuthorizationAllowed','actor_profile',:actor,:action,:permission," + ":project,:resource_type,:resource_id,:matched_grant,null,cast(:after as json))" + ), + { + "id": evidence.evidence_id, + "actor": actor, + "action": action, + "permission": permission, + "project": project_id, + "resource_type": resource_type, + "resource_id": str(resource_id) if resource_id is not None else None, + "matched_grant": (str(matched_grant_id) if matched_grant_id is not None else None), + "after": json.dumps( + { + "allowed": True, + "resource_context_digest": ( + sha("substituted-authority") + if ( + actor_id is not None and self._ports.corrupt_admin_receipt + ) + or self._ports.corrupt_runtime_receipt_action == action + else resource_digest or sha("authority") + ), + } + ), + }, + ) + return evidence + + async def authorize_profile(self, facts): + evidence = await self._ports.authorize_profile(facts) + resource = project_model_admin_resource( + PROFILE_PROVISION, facts, actor_profile_id=facts.actor_profile_id + ) + return await self._authority( + evidence, + action="project.model_profile.provision", + permission="project.model_profile.manage", + actor_id=facts.actor_profile_id, + resource=resource, + resource_digest=authorization_resource_digest(resource), + ) + + async def authorize_budget(self, facts): + evidence = await self._ports.authorize_budget(facts) + resource = project_model_admin_resource( + BUDGET_CONFIGURE, facts, actor_profile_id=facts.actor_profile_id + ) + return await self._authority( + evidence, + action="project.model_budget.configure", + permission="project.model_budget.manage", + actor_id=facts.actor_profile_id, + resource=resource, + resource_digest=authorization_resource_digest(resource), + ) + + async def authorize_prepare(self, execution, slot, request_sha256): + evidence = await self._ports.authorize_prepare(execution, slot, request_sha256) + facts = ModelCallPrepareAuthorityFacts( + execution=execution, slot=slot, request_sha256=request_sha256 + ) + return await self._authority( + evidence, + action="checker.model_call.prepare", + permission="checker.model_call.prepare", + resource=checker_model_call_resource(PREPARE, facts), + service_identity="workstream.checker.post_submit", + ) + + async def authorize_dispatch(self, execution, attempt, request_evidence): + evidence = await self._ports.authorize_dispatch(execution, attempt, request_evidence) + facts = ModelCallDispatchAuthorityFacts( + execution=execution, attempt=attempt, request_evidence=request_evidence + ) + return await self._authority( + evidence, + action="checker.model_call.dispatch", + permission="checker.model_call.dispatch", + resource=checker_model_call_resource(DISPATCH, facts), + service_identity="workstream.checker.post_submit", + ) + + async def authorize_recover(self, execution, effect): + evidence = await self._ports.authorize_recover(execution, effect) + actor_id = new_record_id() + await self._session.execute( + text( + "insert into actor_profiles(id,actor_kind,status,service_identity) " + "values (:id,'service','active','workstream.checker.post_submit')" + ), + {"id": actor_id}, + ) + facts = ModelCallRecoverAuthorityFacts(execution=execution, effect=effect) + return await self._authority( + evidence, + action="checker.model_call.recover", + permission="checker.model_call.recover", + actor_id=actor_id, + resource=checker_model_call_resource(RECOVER, facts), + resource_digest=authorization_resource_digest( + checker_model_call_resource(RECOVER, facts) + ), + ) + + async def authorize_observe(self, effect, provider_response_id, observation_digest): + evidence = await self._ports.authorize_observe( + effect, provider_response_id, observation_digest + ) + facts = ModelCallObserveAuthorityFacts( + effect=effect, + provider_response_id=provider_response_id, + observation_digest=observation_digest, + ) + return await self._authority( + evidence, + action="checker.model_call.observe", + permission="checker.model_call.observe", + resource=checker_model_call_resource(OBSERVE, facts), + service_identity="workstream.checker.post_submit", + ) + + async def authorize_child(self, project_id, parent_attempt_id, terminal_digest): + evidence = await self._ports.authorize_child(project_id, parent_attempt_id, terminal_digest) + actor_id = new_record_id() + facts = ModelCallRecoveryChildAuthorityFacts( + project_id=project_id, + parent_attempt_id=parent_attempt_id, + terminal_digest=terminal_digest, + ) + return await self._authority( + evidence, + action="project.model_call.recovery_child.authorize", + permission="project.model_call.recovery_child.authorize", + actor_id=actor_id, + resource=project_model_admin_resource( + RECOVERY_CHILD_AUTHORIZE, facts, actor_profile_id=actor_id + ), + matched_grant_id=new_record_id(), + ) + + async def authorize_complete(self, effect, response_evidence, terminal_digest): + evidence = await self._ports.authorize_complete(effect, response_evidence, terminal_digest) + actor_id = new_record_id() + service_identity = ( + "workstream.project.setup" + if self._ports.terminal_receipt_change == "actor" + else "workstream.checker.post_submit" + ) + await self._session.execute( + text( + "insert into actor_profiles(id,actor_kind,status,service_identity) " + "values (:id,'service','active',:identity)" + ), + {"id": actor_id, "identity": service_identity}, + ) + authority_effect = effect + if self._ports.terminal_receipt_change == "effect": + authority_effect = effect.model_copy(update={"attempt_id": new_record_id()}) + elif self._ports.terminal_receipt_change == "project": + foreign_project = new_record_id() + authority_effect = effect.model_copy( + update={ + "project_id": foreign_project, + "slot_key": effect.slot_key.model_copy(update={"project_id": foreign_project}), + } + ) + facts = ModelCallCompleteAuthorityFacts( + effect=authority_effect, + response_evidence=response_evidence, + terminal_digest=terminal_digest, + ) + action = ( + "checker.model_call.observe" + if self._ports.terminal_receipt_change == "action" + else "checker.model_call.complete" + ) + resource_digest = authorization_resource_digest( + checker_model_call_resource(COMPLETE, facts) + ) + if self._ports.terminal_receipt_change == "digest": + resource_digest = sha("foreign terminal resource") + return await self._authority( + evidence, + action=action, + permission=action, + actor_id=actor_id, + resource=checker_model_call_resource(COMPLETE, facts), + resource_digest=resource_digest, + ) + + async def authorize_setup_prepare(self, slot, request_sha256): + evidence = await self._ports.authorize_setup_prepare(slot, request_sha256) + facts = SetupModelPrepareAuthorityFacts(slot=slot, request_sha256=request_sha256) + return await self._authority( + evidence, + action="project.guide_compilation.model.prepare", + permission="project.guide_compilation.model.prepare", + resource=setup_model_usage_resource(SETUP_PREPARE, facts), + service_identity="workstream.project.setup", + ) + + async def authorize_setup_dispatch(self, prepared, request_evidence): + evidence = await self._ports.authorize_setup_dispatch(prepared, request_evidence) + facts = SetupModelDispatchAuthorityFacts( + prepared=prepared, request_evidence=request_evidence + ) + return await self._authority( + evidence, + action="project.guide_compilation.model.dispatch", + permission="project.guide_compilation.model.dispatch", + resource=setup_model_usage_resource(SETUP_DISPATCH, facts), + service_identity="workstream.project.setup", + ) + + async def authorize_setup_observe(self, effect, provider_response_id, observation_digest): + evidence = await self._ports.authorize_setup_observe( + effect, provider_response_id, observation_digest + ) + facts = SetupModelObserveAuthorityFacts( + effect=effect, + provider_response_id=provider_response_id, + observation_digest=observation_digest, + ) + return await self._authority( + evidence, + action="project.guide_compilation.model.observe", + permission="project.guide_compilation.model.observe", + resource=setup_model_usage_resource(SETUP_OBSERVE, facts), + service_identity="workstream.project.setup", + ) + + async def authorize_setup_complete(self, effect, response_evidence, terminal_digest): + evidence = await self._ports.authorize_setup_complete( + effect, response_evidence, terminal_digest + ) + actor_id = new_record_id() + service_identity = ( + "workstream.checker.post_submit" + if self._ports.terminal_receipt_change == "actor" + else "workstream.project.setup" + ) + await self._session.execute( + text( + "insert into actor_profiles(id,actor_kind,status,service_identity) " + "values (:id,'service','active',:identity)" + ), + {"id": actor_id, "identity": service_identity}, + ) + authority_effect = effect + if self._ports.terminal_receipt_change == "effect": + authority_effect = effect.model_copy(update={"effect_id": new_record_id()}) + elif self._ports.terminal_receipt_change == "project": + foreign_project = new_record_id() + authority_effect = effect.model_copy( + update={ + "project_id": foreign_project, + "slot_key": effect.slot_key.model_copy(update={"project_id": foreign_project}), + } + ) + facts = SetupModelCompleteAuthorityFacts( + effect=authority_effect, + response_evidence=response_evidence, + terminal_digest=terminal_digest, + ) + action = ( + "project.guide_compilation.model.observe" + if self._ports.terminal_receipt_change == "action" + else "project.guide_compilation.model.complete" + ) + resource_digest = authorization_resource_digest( + setup_model_usage_resource(SETUP_COMPLETE, facts) + ) + if self._ports.terminal_receipt_change == "digest": + resource_digest = sha("foreign setup terminal resource") + return await self._authority( + evidence, + action=action, + permission=action, + actor_id=actor_id, + resource=setup_model_usage_resource(SETUP_COMPLETE, facts), + resource_digest=resource_digest, + ) + + async def require_setup_request(self, prepared, evidence): + await self._ports.require_setup_request(prepared, evidence) + await self._artifact(evidence) + + async def require_setup_response(self, effect_id, evidence): + await self._ports.require_setup_response(effect_id, evidence) + await self._artifact(evidence) + + async def require_setup_current(self, slot): + attempt_id = await self._session.scalar( + text( + "select id from project_guide_compilation_attempts where id=:id and project_id=:project and guide_id=:guide and source_snapshot_id=:snapshot and setup_run_id=:run and setup_generation=:generation and runtime_configuration_hash=:runtime and status='compilation_reserved' for update" + ), + { + "id": slot.compilation_attempt_id, + "project": slot.project_id, + "guide": slot.guide_id, + "snapshot": slot.source_snapshot_id, + "run": slot.setup_run_id, + "generation": slot.setup_generation, + "runtime": slot.runtime_configuration_hash, + }, + ) + if attempt_id is None: + raise ModelCallUnavailable("setup_model_owner_unavailable") + return SetupModelOwnerProfile( + selector=ModelProfileSelector( + profile_id=slot.profile_id, + profile_version=slot.profile_version, + profile_digest=slot.profile_digest, + ), + provider="openai_responses", + api="responses.v1", + model_snapshot="gpt-example-2026-10-01", + ) + + async def require_current(self, facts, slot): + await self._ports.require_current(facts, slot) + + async def require_recovery_current(self, slot, parent_attempt_id): + await self._ports.require_recovery_current(slot, parent_attempt_id) + + async def _artifact(self, evidence): + for table_name, value in ( + ("artifact_bindings", evidence.binding_id), + ("artifact_contents", evidence.content_id), + ("artifact_put_attempts", evidence.put_attempt_id), + ("artifact_verification_receipts", evidence.verification_receipt_id), + ): + await self._session.execute( + text(f"insert into {table_name}(id) values (:id) on conflict do nothing"), + {"id": value}, + ) + + async def require_request(self, attempt, evidence): + await self._ports.require_request(attempt, evidence) + await self._artifact(evidence) + + async def require_response(self, attempt_id, evidence): + await self._ports.require_response(attempt_id, evidence) + await self._artifact(evidence) diff --git a/backend/tests/checkers_model_calls/model_usage_pg_support.py b/backend/tests/checkers_model_calls/model_usage_pg_support.py new file mode 100644 index 000000000..015e7e44e --- /dev/null +++ b/backend/tests/checkers_model_calls/model_usage_pg_support.py @@ -0,0 +1,118 @@ +"""Shared isolated PostgreSQL substrate for MODEL-USAGE owner tests.""" + +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path +import runpy +from uuid import UUID, uuid4 + +import pytest +from sqlalchemy import text +from sqlalchemy.ext.asyncio import AsyncEngine, create_async_engine + +from app.core.hashing import canonical_json_hash +from app.core.identifiers import new_record_id + + +def _sha(label: str) -> str: + return canonical_json_hash({"label": label}) + + +@dataclass(frozen=True) +class DatabaseFacts: + """Stable owner identities and engine for one isolated MODEL schema.""" + + engine: AsyncEngine + project_id: UUID + task_id: UUID + assignment_id: UUID + submission_id: UUID + registry_entry_id: UUID + registry_entry_digest: str + + +@pytest.fixture +async def model_database(postgres_database_url: str): + """Install 0061 against the minimal real owner tables used by MODEL tests.""" + schema = f"model_calls_{uuid4().hex}" + engine = create_async_engine( + postgres_database_url, + connect_args={"server_settings": {"search_path": schema}}, + ) + project_id = new_record_id() + task_id = new_record_id() + assignment_id = new_record_id() + submission_id = new_record_id() + registry_entry_id = new_record_id() + registry_entry_digest = _sha("registry") + migration = runpy.run_path( + str(Path(__file__).resolve().parents[2] / "alembic/versions/0062_model_usage.py") + ) + + def isolated(statement: str) -> str: + return statement.replace("public.", f'"{schema}".').replace( + "pg_catalog,public,pg_temp", f'pg_catalog,"{schema}",pg_temp' + ) + + async with engine.begin() as connection: + await connection.execute(text(f'create schema "{schema}"')) + for statement in ( + "create table projects(id uuid primary key)", + "create table workstream_tasks(id uuid primary key, project_id uuid not null, unique(id,project_id))", + "create table task_assignments(id uuid primary key, task_id uuid not null, project_id uuid not null)", + "create table submissions(id uuid primary key, task_id uuid not null, version integer not null, unique(id,task_id,version))", + "create table external_checker_registry_entries(id uuid primary key,entry_digest text not null,phase text not null)", + "create table checker_runs(id uuid primary key,task_id uuid,submission_id uuid,submission_version integer,project_id uuid,evaluation_request_id uuid,phase text,evaluation_generation integer,request_json text,worker_lease_id uuid,worker_lease_generation integer,worker_lease_expires_at timestamptz,status text,selected_post_submit_policy_sha256 text,selected_post_submit_policy_body jsonb,locked_post_submit_checker_policy_hash text)", + "create table actor_profiles(id uuid primary key,actor_kind text default 'human',status text default 'active',service_identity text)", + "create table audit_events(id uuid primary key, event_domain text, event_type text, actor_ref_kind text, actor_id text, action_id text, permission_id text, project_id uuid, resource_type text, resource_id text, matched_grant_id uuid, denial_code text, after_facts json)", + "create table artifact_bindings(id uuid primary key)", + "create table artifact_contents(id uuid primary key)", + "create table artifact_put_attempts(id uuid primary key)", + "create table artifact_verification_receipts(id uuid primary key)", + "create table project_guide_compilation_attempts(id uuid primary key,project_id uuid not null,guide_id uuid not null,source_snapshot_id uuid not null,setup_run_id uuid not null,setup_generation bigint not null,runtime_configuration jsonb,runtime_configuration_hash text,status text)", + ): + await connection.execute(text(statement)) + for statement in migration["_TABLES"]: + await connection.execute(text(isolated(statement))) + for statement in migration["_FUNCTIONS"]: + await connection.execute(text(isolated(statement))) + for statement in migration["_TRIGGERS"]: + await connection.execute(text(isolated(statement))) + await connection.execute(text("insert into projects(id) values (:id)"), {"id": project_id}) + await connection.execute( + text("insert into workstream_tasks(id,project_id) values (:id,:project)"), + {"id": task_id, "project": project_id}, + ) + await connection.execute( + text("insert into task_assignments(id,task_id,project_id) values (:id,:task,:project)"), + {"id": assignment_id, "task": task_id, "project": project_id}, + ) + await connection.execute( + text("insert into submissions(id,task_id,version) values (:id,:task,1)"), + {"id": submission_id, "task": task_id}, + ) + await connection.execute( + text( + "insert into external_checker_registry_entries(id,entry_digest,phase) " + "values (:id,:digest,'post_submit')" + ), + {"id": registry_entry_id, "digest": registry_entry_digest}, + ) + try: + yield DatabaseFacts( + engine, + project_id, + task_id, + assignment_id, + submission_id, + registry_entry_id, + registry_entry_digest, + ) + finally: + try: + async with engine.begin() as connection: + await connection.execute(text("set local search_path = pg_catalog")) + await connection.execute(text(f'drop schema if exists "{schema}" cascade')) + finally: + await engine.dispose() diff --git a/backend/tests/checkers_model_calls/test_composed_execution.py b/backend/tests/checkers_model_calls/test_composed_execution.py new file mode 100644 index 000000000..983b59730 --- /dev/null +++ b/backend/tests/checkers_model_calls/test_composed_execution.py @@ -0,0 +1,426 @@ +"""Real approval, AUTH, PostgreSQL and ART with an offline provider and image boundary.""" + +from contextlib import asynccontextmanager +import asyncio +import os +from uuid import UUID, uuid4 + +import pytest +from sqlalchemy import select, func + +from app.adapters.artifacts import checker_output_binding, checker_output_storage +from app.adapters.auth import checker_model_service_authorization +from app.adapters.checkers import ( + evaluation_coordinator, + model_call_service, + model_invocation_grants, + model_profile_reader, +) +from app.adapters.checkers.model_execution import ModelAwareExternalExecution +from app.interfaces.checker_model_provider import ( + CheckerModelCreateFailure, + CheckerModelCreateOutcome, + CheckerModelCreateResult, + CheckerModelObservation, + CheckerModelProviderStatus, + CheckerModelRecoveryOutcome, + CheckerModelRecoveryResult, + CheckerModelResponseReference, + CheckerModelUsage, +) +from app.interfaces.checker_model_proxy import ModelProxyRequest +from app.interfaces.external_services import ( + ExternalServiceAdapterIdentity, + ExternalServiceUnavailableError, +) +from app.interfaces.project_agents import CapabilityParameter, CheckSpecification +from app.modules.actors.api import ServiceIdentity +from app.modules.artifacts.preparation import HARD_MAXIMUM_ARTIFACT_BYTES +from app.modules.checkers.api.external import ExternalCheckerSchema, external_checker_json_hash +from app.modules.model_usage.models import ( + ModelUsageEffect, + ModelUsageBudgetAccount, +) +from app.adapters.checkers.model_proxy import CheckerModelProxy +from tests.checkers.external.support import post_registry_spec +from tests.checkers.execution.support import ( + ControlledSelectedExecution, + live_executor, + reserve, + provision_post_submit_service, +) +from tests.checkers_model_calls.test_public_api import profile_spec +from tests.post_submit_materialization_helpers import material_fixture +from tests.test_checker_materialization import _limits +from tests.projects.guide_compilation.helpers import valid_specification + + +def model_review_specification(): + """Declare model review explicitly before the real policy compiler binds it.""" + specification = valid_specification().model_dump(mode="json") + specification.update( + evaluation_method="model_evaluation", + execution_mode="background", + maximum_duration_seconds=120, + stage_rationale="Content review runs after immutable submission creation.", + runtime_dependencies=[ + { + "dependency_id": "model_runtime", + "kind": "model_service", + "provisioning": "model_proxy", + "purpose": "Evaluate submitted evidence through the budgeted model service.", + "unavailable_code": "implementation_unavailable", + } + ], + infrastructure_failures=[ + *specification["infrastructure_failures"], + { + "code": "implementation_unavailable", + "condition": "The budgeted model service is unavailable.", + }, + ], + ) + return CheckSpecification.model_validate(specification) + + +def model_registry_spec(): + spec = post_registry_spec() + document = { + "type": "object", + "additionalProperties": False, + "properties": { + "model_profile_id": {"type": "string"}, + "model_profile_version": {"type": "integer"}, + "model_profile_digest": {"type": "string"}, + }, + "required": ["model_profile_id", "model_profile_version", "model_profile_digest"], + } + return spec.model_copy( + update={ + "resources": spec.resources.model_copy(update={"deadline_ms": 120_000}), + "configuration_schema": ExternalCheckerSchema( + schema_id="acme.model_review.configuration", + schema_version="v1", + document=document, + schema_sha256=external_checker_json_hash(document), + ), + } + ) + + +@pytest.mark.parametrize( + ("storage_provider", "lose_first_image_reply", "concurrent_callback", "terminal_mode"), + [ + pytest.param("local", False, False, None, id="completed"), + pytest.param("local", True, False, None, id="retry-retains-paid-effect"), + pytest.param("minio", True, False, None, id="minio-retry-retains-paid-effect"), + pytest.param("local", False, True, None, id="concurrent-callback-single-charge"), + pytest.param("local", False, False, "no_effect", id="operator-recovers-no-effect"), + pytest.param("minio", False, False, "failed", id="operator-recovers-settled-failure"), + ], +) +async def test_public_profile_to_private_model_call_and_completed_checker( + tmp_path, + isolated_database_env, + admin_access, + monkeypatch, + storage_provider, + lose_first_image_reply, + concurrent_callback, + terminal_mode, +): + if storage_provider == "minio": + from tests import test_s3_artifact_store + + monkeypatch.setattr( + test_s3_artifact_store, "MINIO_BUCKET", os.environ["WORKSTREAM_TEST_MINIO_BUCKET"] + ) + await admin_access.signed.grant(admin_access.admin, admin_access.target, role="operator") + profiles = [] + + async def publish(_factory, project_id, registry): + spec = profile_spec(project_id).model_copy( + update={ + "registry_entry_id": registry.registry_entry_id, + "registry_entry_digest": registry.entry_digest, + } + ) + if terminal_mode is not None: + # An authorized child is a second provider dispatch in the same + # immutable envelope, even though it reuses logical call slot zero. + spec = spec.model_copy( + update={ + "maximum_calls": 2, + "maximum_total_request_bytes": 20_000, + "maximum_total_response_bytes": 60_000, + "maximum_total_input_tokens": 200, + "maximum_total_output_tokens": 100, + } + ) + response = await admin_access.signed.client.post( + f"/api/v1/operations/projects/{project_id}/model-profiles", + headers=admin_access.target.headers | {"Idempotency-Key": str(uuid4())}, + json=spec.model_dump(mode="json"), + ) + assert response.status_code == 201, response.text + profile = response.json() + profiles.append(profile) + budget = await admin_access.signed.client.post( + f"/api/v1/operations/projects/{project_id}/model-budget-configurations", + headers=admin_access.target.headers | {"Idempotency-Key": str(uuid4())}, + json={ + "project_id": str(project_id), + "configuration_version": 1, + "authorized_total_nanodollars": 10_000_000_000, + "externally_reconciled_prior_spend_nanodollars": 0, + "lifetime_limit_nanodollars": 10_000_000_000, + }, + ) + assert budget.status_code == 201, budget.text + return tuple( + CapabilityParameter(name=key, value=value) + for key, value in { + "model_profile_id": profile["profile_id"], + "model_profile_version": profile["spec"]["profile_version"], + "model_profile_digest": profile["profile_digest"], + }.items() + ) + + async with material_fixture( + tmp_path, + isolated_database_env, + provider=storage_provider, + post_submit_registry_spec=model_registry_spec(), + post_submit_binding_setup=publish, + post_submit_specification=model_review_specification(), + # This composed process holds ZIP material while also retaining model + # evidence; the ordinary two-slot material-only fixture is insufficient. + scratch_limits=_limits( + aggregate_reserved_bytes=4 * HARD_MAXIMUM_ARTIFACT_BYTES, + maximum_files=4, + maximum_concurrency=4, + total_deadline_seconds=120.0, + reservation_ttl_seconds=150.0, + ), + ) as h: + await provision_post_submit_service(h.factory, ServiceIdentity.ARTIFACT_CHECKER_OUTPUT) + creates = [] + lost_replies = [] + recovered_references = [] + first_response_waiting = asyncio.Event() + release_first_response = asyncio.Event() + + class Provider: + async def create(self, request): + creates.append(request.prompt) + if terminal_mode == "no_effect" and len(creates) == 1: + return CheckerModelCreateResult( + CheckerModelCreateOutcome.NO_EFFECT, + failure=CheckerModelCreateFailure.INPUT_COUNT_UNAVAILABLE, + ) + failed = terminal_mode == "failed" and len(creates) == 1 + reference = CheckerModelResponseReference(f"resp_composed_{len(creates)}") + return CheckerModelCreateResult( + CheckerModelCreateOutcome.ACCEPTED, + known_response=reference, + observation=CheckerModelObservation( + reference, + profiles[0]["spec"]["model_snapshot"], + CheckerModelProviderStatus.FAILED + if failed + else CheckerModelProviderStatus.COMPLETED, + None if failed else '{"verdict":"passed"}', + CheckerModelUsage(20, 5, 10, 2, 30), + ), + ) + + async def recover(self, reference): + # A concurrent callback can attach after the first dispatch has + # persisted its provider identity but before response ART is + # terminal. It may retrieve that exact effect, never create it + # again; completed replays still read retained ART directly. + recovered_references.append(reference.provider_response_id) + if concurrent_callback: + assert first_response_waiting.is_set() + assert not release_first_response.is_set() + return CheckerModelRecoveryResult( + CheckerModelRecoveryOutcome.OBSERVED, + reference, + CheckerModelObservation( + reference, + profiles[0]["spec"]["model_snapshot"], + CheckerModelProviderStatus.COMPLETED, + '{"verdict":"passed"}', + CheckerModelUsage(20, 5, 10, 2, 30), + ), + ) + + @asynccontextmanager + async def provider(_): + yield Provider() + + outputs = checker_output_storage( + sessions=h.factory, + store=h.store.wrapped, + namespace=h.namespace, + preparation=h.preparation, + settings=h.settings, + ) + original_store = outputs.store + + async def hold_first_response(request): + if ( + concurrent_callback + and request.selector.slot_key == "model_response" + and not first_response_waiting.is_set() + ): + first_response_waiting.set() + await asyncio.wait_for(release_first_response.wait(), timeout=30) + return await original_store(request) + + monkeypatch.setattr(outputs, "store", hold_first_response) + proxy = CheckerModelProxy( + sessions=h.factory, + authorization=checker_model_service_authorization, + grants=model_invocation_grants, + calls=model_call_service, + profiles=model_profile_reader, + outputs=outputs, + bindings=lambda session: checker_output_binding(session, namespace=h.namespace), + provider=provider, + ) + + class Image(ControlledSelectedExecution): + identity = ExternalServiceAdapterIdentity("external_checker_execution", "unix_socket") + + def __init__(self): + super().__init__() + self.invocations = 0 + + async def execute(self, request, grant, *, model_grant=None): + self.invocations += 1 + assert model_grant is not None + callback = ModelProxyRequest( + grant_id=model_grant.grant_id, + request_digest=request.request_digest, + call_index=0, + prompt="Evaluate the verified work", + ) + if concurrent_callback: + first = asyncio.create_task(proxy.handle(callback)) + try: + await asyncio.wait_for(first_response_waiting.wait(), timeout=30) + async with h.factory() as session: + pending = await session.scalar(select(ModelUsageEffect)) + assert pending.provider_response_id == "resp_composed_1" + assert pending.state == "uncertain" + assert pending.response_binding_id is None + # The first callback cannot settle while the second retrieves + # the known response. A cached terminal replay cannot pass. + second_reply = await proxy.handle(callback) + assert recovered_references == ["resp_composed_1"] + finally: + release_first_response.set() + first_reply = await first + replies = [first_reply, second_reply] + assert [reply.outcome for reply in replies] == ["completed", "completed"] + assert replies[0].receipt_digest == replies[1].receipt_digest + reply = replies[0] + else: + reply = await proxy.handle(callback) + if terminal_mode is not None and self.invocations == 1: + assert reply.outcome == "unavailable", reply.outcome + assert reply.failure_code == ( + "model_provider_no_effect" + if terminal_mode == "no_effect" + else "model_provider_failed" + ) + raise ExternalServiceUnavailableError(self.identity) + assert reply.outcome == "completed", reply.failure_code + assert reply.output == '{"verdict":"passed"}' + if lose_first_image_reply and self.invocations == 1: + lost_replies.append(reply.receipt_digest) + raise ExternalServiceUnavailableError(self.identity) + return await super().execute(request, grant) + + await reserve(h) + executor = live_executor( + h, + selected_execution=ModelAwareExternalExecution( + execution=Image(), + sessions=h.factory, + ), + ) + current_request = h.request + result = await executor.evaluate_post_submission(current_request) + if lose_first_image_reply or terminal_mode is not None: + assert result.outcome == "infrastructure_failed" + if lose_first_image_reply: + assert len(lost_replies) == 1, result.infrastructure_failure_code + if terminal_mode is not None: + sources = await admin_access.signed.client.get( + f"/api/v1/operations/projects/{h.request.project_id}/checker-retry-sources", + headers=admin_access.target.headers, + ) + assert sources.status_code == 200, sources.text + candidates = sources.json()["items"][0]["model_recovery_candidates"] + assert len(candidates) == 1 + candidate = candidates[0] + parent = candidate.pop("parent_attempt_id") + recovery_path = ( + f"/api/v1/operations/projects/{h.request.project_id}/model-call-attempts/" + f"{parent}/recovery-children" + ) + recovery_headers = admin_access.target.headers + child = await admin_access.signed.client.post( + recovery_path, + headers=recovery_headers, + json=candidate, + ) + assert child.status_code == 201, child.text + replay = await admin_access.signed.client.post( + recovery_path, + headers=recovery_headers, + json=candidate, + ) + assert replay.status_code == 201, replay.text + assert replay.json()["attempt_id"] == child.json()["attempt_id"] + assert len(creates) == 1 # Recovery authorization itself cannot dispatch. + retry = await admin_access.signed.client.post( + f"/api/v1/operations/projects/{h.request.project_id}/checker-runs/" + f"{h.created.evaluation_attempt_id}/retry", + headers=admin_access.target.headers | {"Idempotency-Key": str(uuid4())}, + json={ + "reason": "Recover lost image response without repeating the paid model call" + }, + ) + assert retry.status_code == 201, retry.text + async with h.factory() as session, session.begin(): + reserved = await evaluation_coordinator(session).read_reserved_evaluation( + project_id=h.request.project_id, + task_id=h.request.task_id, + submission_id=h.request.submission_id, + request_id=UUID(retry.json()["successor"]["request_id"]), + ) + current_request = reserved.request + assert current_request.evaluation_generation == 2 + result = await executor.evaluate_post_submission(current_request) + assert result.outcome == "completed" + assert len(result.execution.external_members) == 1 + assert await executor.evaluate_post_submission(current_request) == result + assert len(creates) == (2 if terminal_mode else 1) + async with h.factory() as session: + assert await session.scalar(select(func.count()).select_from(ModelUsageEffect)) == ( + 2 if terminal_mode else 1 + ) + attempt = await session.scalar( + select(ModelUsageEffect).where(ModelUsageEffect.state == "completed") + ) + account = await session.get(ModelUsageBudgetAccount, str(h.request.project_id)) + assert attempt.state == "completed" + assert account.lifetime_reserved_nanodollars == 0 + assert account.lifetime_settled_nanodollars == ( + (15 * 11 + 5 * 2 + 10 * 29) * (2 if terminal_mode == "failed" else 1) + ) + assert UUID(attempt.response_binding_id).version == 7 + assert UUID(attempt.request_binding_id).version == 7 diff --git a/backend/tests/checkers_model_calls/test_model_call_contract.py b/backend/tests/checkers_model_calls/test_model_call_contract.py new file mode 100644 index 000000000..1a19643d4 --- /dev/null +++ b/backend/tests/checkers_model_calls/test_model_call_contract.py @@ -0,0 +1,253 @@ +from __future__ import annotations + +from uuid import uuid4 + +from pydantic import ValidationError +import pytest + +from app.core.hashing import canonical_json_hash +from app.core.identifiers import new_record_id +from app.modules.model_usage.api import ( + ModelCallConflict, + ModelCallUsage, + ModelGenerationSettings, + ModelProfileProvisioningFacts, + ModelPaidSlotKey, + ModelPricing, + ProjectModelBudgetConfigurationSpec, + ProjectModelProfileSpec, + SetupModelUsageSlotKey, + model_usage_cost, +) + + +def sha(label: str) -> str: + return canonical_json_hash({"label": label}) + + +def profile_spec() -> ProjectModelProfileSpec: + return ProjectModelProfileSpec( + project_id=new_record_id(), + profile_version=3, + provider="openai_responses", + api="responses.v1", + model_snapshot="gpt-example-2026-10-01", + settings=ModelGenerationSettings( + reasoning_effort="medium", + text_verbosity="low", + ), + maximum_request_bytes=10_000, + maximum_output_bytes=2_000, + maximum_response_bytes=20_000, + maximum_input_tokens=100, + maximum_output_tokens=50, + maximum_calls=1, + maximum_total_request_bytes=10_000, + maximum_total_response_bytes=20_000, + maximum_total_input_tokens=100, + maximum_total_output_tokens=50, + allowed_tools=(), + maximum_tool_calls=0, + maximum_tool_output_bytes=0, + deadline_ms=60_000, + pricing=ModelPricing( + pricing_policy_id=new_record_id(), + pricing_policy_version=7, + pricing_policy_digest=sha("pricing"), + uncached_input_nanodollars_per_token=11, + cached_input_nanodollars_per_token=2, + output_nanodollars_per_token=29, + ), + ) + + +def test_reservation_uses_worst_input_rate_and_single_output_rate() -> None: + spec = profile_spec() + + assert spec.maximum_reservation_nanodollars == 100 * 11 + 50 * 29 + + +def test_profile_requires_room_for_canonical_response_evidence() -> None: + with pytest.raises(ValidationError, match="cannot preserve"): + ProjectModelProfileSpec.model_validate( + profile_spec().model_dump() + | {"maximum_output_bytes": 15_000, "maximum_response_bytes": 20_000} + ) + + +def test_profile_requires_finite_canonical_run_and_tool_envelopes() -> None: + multi = ProjectModelProfileSpec.model_validate( + profile_spec().model_dump() + | { + "maximum_calls": 3, + "maximum_total_request_bytes": 30_000, + "maximum_total_response_bytes": 60_000, + "maximum_total_input_tokens": 300, + "maximum_total_output_tokens": 150, + "allowed_tools": ("execution_receipt", "read_material"), + "maximum_tool_calls": 8, + "maximum_tool_output_bytes": 100_000, + } + ) + assert multi.maximum_reservation_nanodollars == 300 * 11 + 150 * 29 + with pytest.raises(ValidationError, match="canonical"): + ProjectModelProfileSpec.model_validate( + multi.model_dump() | {"allowed_tools": ("read_material", "execution_receipt")} + ) + with pytest.raises(ValidationError, match="run totals"): + ProjectModelProfileSpec.model_validate( + multi.model_dump() | {"maximum_total_input_tokens": 301} + ) + + +def test_budget_requires_explicit_reconciled_remaining_allowance() -> None: + with pytest.raises(ValidationError, match="reconciled authorization"): + ProjectModelBudgetConfigurationSpec( + project_id=new_record_id(), + configuration_version=1, + authorized_total_nanodollars=10_000_000_000, + externally_reconciled_prior_spend_nanodollars=2_000_000_000, + lifetime_limit_nanodollars=9_000_000_000, + ) + + +def test_profile_is_provider_neutral_but_rejects_unbounded_identifiers() -> None: + for changes in ( + {"provider": "openai", "api": "responses"}, + {"provider": "openrouter", "api": "chat_completions"}, + ): + assert ProjectModelProfileSpec.model_validate( + profile_spec().model_dump() | changes + ).provider == changes["provider"] + with pytest.raises(ValidationError): + ProjectModelProfileSpec.model_validate( + profile_spec().model_dump() | {"provider": "https://bad host"} + ) + + +def test_settlement_prices_cached_input_separately_and_reasoning_once() -> None: + spec = profile_spec() + usage = ModelCallUsage( + input_tokens=80, + cached_input_tokens=30, + output_tokens=20, + reasoning_tokens=17, + ) + + assert model_usage_cost(spec, usage) == 50 * 11 + 30 * 2 + 20 * 29 + + +@pytest.mark.parametrize( + "usage", + [ + ModelCallUsage( + input_tokens=101, + cached_input_tokens=0, + output_tokens=1, + reasoning_tokens=0, + ), + ModelCallUsage( + input_tokens=1, + cached_input_tokens=0, + output_tokens=51, + reasoning_tokens=0, + ), + ], +) +def test_settlement_rejects_usage_above_locked_profile( + usage: ModelCallUsage, +) -> None: + with pytest.raises(ModelCallConflict, match="exceeds the approved profile"): + model_usage_cost(profile_spec(), usage) + + +def test_usage_requires_nested_cached_and_reasoning_classes() -> None: + with pytest.raises(ValidationError): + ModelCallUsage( + input_tokens=1, + cached_input_tokens=2, + output_tokens=1, + reasoning_tokens=0, + ) + with pytest.raises(ValidationError): + ModelCallUsage( + input_tokens=1, + cached_input_tokens=0, + output_tokens=1, + reasoning_tokens=2, + ) + + +def test_stable_slot_identity_has_no_lease_or_evaluation_generation() -> None: + spec = profile_spec() + slot = ModelPaidSlotKey( + project_id=spec.project_id, + assignment_id=new_record_id(), + submission_id=new_record_id(), + submission_version=2, + requirement_id="model_output_quality", + registry_entry_digest=sha("registry"), + configuration_sha256=sha("configuration"), + profile_id=new_record_id(), + profile_version=spec.profile_version, + profile_digest=sha("profile"), + ) + + assert set(type(slot).model_fields) == { + "project_id", + "assignment_id", + "submission_id", + "submission_version", + "requirement_id", + "registry_entry_digest", + "configuration_sha256", + "profile_id", + "profile_version", + "profile_digest", + "slot_index", + } + + +def test_setup_slot_binds_attempt_generation_runtime_profile_and_call_order() -> None: + key = SetupModelUsageSlotKey( + compilation_attempt_id=new_record_id(), + project_id=new_record_id(), + guide_id=new_record_id(), + source_snapshot_id=new_record_id(), + setup_run_id=new_record_id(), + setup_generation=4, + runtime_configuration_hash=sha("setup-runtime"), + profile_id=new_record_id(), + profile_version=2, + profile_digest=sha("setup-profile"), + phase="review", + call_index=3, + ) + + assert key.digest == canonical_json_hash(key.model_dump(mode="json")) + assert "checker_run_id" not in type(key).model_fields + + +def test_profile_settings_reject_prompt_or_unapproved_provider_parameters() -> None: + with pytest.raises(ValidationError): + ModelGenerationSettings.model_validate( + { + "temperature_millionths": 0, + "reasoning_effort": "medium", + "text_verbosity": "low", + "prompt": "must not be retained in an operator profile", + } + ) + + +def test_caller_operation_key_may_be_uuid4_and_record_id_is_server_output() -> None: + spec = profile_spec() + facts = ModelProfileProvisioningFacts( + operation_id=uuid4(), + actor_profile_id=new_record_id(), + spec=spec, + profile_digest=canonical_json_hash(spec.model_dump(mode="json")), + ) + + assert facts.operation_id.version == 4 + assert "profile_id" not in type(facts).model_fields diff --git a/backend/tests/checkers_model_calls/test_model_call_transactions_postgresql.py b/backend/tests/checkers_model_calls/test_model_call_transactions_postgresql.py new file mode 100644 index 000000000..084dd2b91 --- /dev/null +++ b/backend/tests/checkers_model_calls/test_model_call_transactions_postgresql.py @@ -0,0 +1,714 @@ +from __future__ import annotations + +from datetime import UTC, datetime, timedelta, timezone +import asyncio +import json +from uuid import uuid4 + +import pytest +from sqlalchemy import text +from sqlalchemy.exc import DBAPIError +from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession + +from app.core.hashing import canonical_json_hash +from app.core.identifiers import new_record_id +from app.modules.model_usage.api import ( + KnownNoEffectProof, + ModelBudgetProvisioningFacts, + ModelCallBudgetUnavailable, + ModelCallConflict, + ModelCallEvidence, + ModelCallExecutionFacts, + ModelCallUsage, + ModelCallUnavailable, + ModelGenerationSettings, + ModelPaidSlotKey, + ModelPricing, + ModelProfileProvisioningFacts, + ProjectModelBudgetConfigurationSpec, + ProjectModelProfileSpec, + SetupModelUsageSlotKey, + model_retry_terminal_digest, + model_usage_instant, +) +from app.modules.model_usage.models import ModelUsageBudgetAccount +from app.modules.model_usage.service import ModelCallService +from app.modules.model_usage.setup import SetupModelUsageService +from tests.checkers_model_calls.model_usage_pg_support import ( + DatabaseFacts, + model_database as _model_database, +) +from tests.checkers_model_calls.model_usage_boundary_support import ( + BoundaryPorts, + DatabaseBoundaryPorts, +) + +model_database = _model_database + + +def sha(label: str) -> str: + return canonical_json_hash({"label": label}) + + +def service(session: AsyncSession, ports: BoundaryPorts) -> ModelCallService: + boundaries = DatabaseBoundaryPorts(session, ports) + return ModelCallService( + session, + authority=boundaries, + current_execution=boundaries, + evidence=boundaries, + ) + + +def setup_service(session: AsyncSession, ports: BoundaryPorts) -> SetupModelUsageService: + boundaries = DatabaseBoundaryPorts(session, ports) + return SetupModelUsageService( + session, + authority=boundaries, + evidence=boundaries, + owner=boundaries, + ) + + +async def setup_slot_key( + facts: DatabaseFacts, + profile, + *, + call_index: int = 0, +) -> SetupModelUsageSlotKey: + attempt_id = new_record_id() + guide_id = new_record_id() + snapshot_id = new_record_id() + setup_run_id = new_record_id() + runtime_configuration = { + "model_provider": profile.spec.provider, + "model_api": profile.spec.api, + "model": profile.spec.model_snapshot, + "model_profile_id": str(profile.profile_id), + "model_profile_version": profile.spec.profile_version, + "model_profile_digest": profile.profile_digest, + } + runtime_hash = canonical_json_hash(runtime_configuration) + async with facts.engine.begin() as connection: + await connection.execute( + text( + "insert into project_guide_compilation_attempts(id,project_id,guide_id,source_snapshot_id,setup_run_id,setup_generation,runtime_configuration,runtime_configuration_hash,status) values(:id,:project,:guide,:snapshot,:run,1,cast(:configuration as jsonb),:runtime,'compilation_reserved')" + ), + { + "id": attempt_id, + "project": facts.project_id, + "guide": guide_id, + "snapshot": snapshot_id, + "run": setup_run_id, + "configuration": json.dumps(runtime_configuration), + "runtime": runtime_hash, + }, + ) + return SetupModelUsageSlotKey( + compilation_attempt_id=attempt_id, + project_id=facts.project_id, + guide_id=guide_id, + source_snapshot_id=snapshot_id, + setup_run_id=setup_run_id, + setup_generation=1, + runtime_configuration_hash=runtime_hash, + profile_id=profile.profile_id, + profile_version=profile.spec.profile_version, + profile_digest=profile.profile_digest, + phase="draft", + call_index=call_index, + ) + + +async def provision( + facts: DatabaseFacts, + ports: BoundaryPorts, + *, + limit: int, + replay: bool = False, + profile_changes: dict | None = None, +): + profile_spec = ProjectModelProfileSpec( + project_id=facts.project_id, + profile_version=1, + provider="openai_responses", + api="responses.v1", + model_snapshot="gpt-example-2026-10-01", + settings=ModelGenerationSettings(reasoning_effort="medium", text_verbosity="low"), + maximum_request_bytes=10_000, + maximum_output_bytes=4_000, + maximum_response_bytes=30_000, + maximum_input_tokens=100, + maximum_output_tokens=50, + maximum_calls=1, + maximum_total_request_bytes=10_000, + maximum_total_response_bytes=30_000, + maximum_total_input_tokens=100, + maximum_total_output_tokens=50, + allowed_tools=(), + maximum_tool_calls=0, + maximum_tool_output_bytes=0, + deadline_ms=30_000, + pricing=ModelPricing( + pricing_policy_id=new_record_id(), + pricing_policy_version=1, + pricing_policy_digest=sha("pricing"), + uncached_input_nanodollars_per_token=11, + cached_input_nanodollars_per_token=2, + output_nanodollars_per_token=29, + ), + ) + if profile_changes: + profile_spec = profile_spec.model_copy(update=profile_changes) + profile_facts = ModelProfileProvisioningFacts( + operation_id=uuid4(), + actor_profile_id=new_record_id(), + spec=profile_spec, + profile_digest=canonical_json_hash(profile_spec.model_dump(mode="json")), + ) + budget_spec = ProjectModelBudgetConfigurationSpec( + project_id=facts.project_id, + configuration_version=1, + authorized_total_nanodollars=10_000_000_000, + externally_reconciled_prior_spend_nanodollars=0, + lifetime_limit_nanodollars=limit, + ) + budget_facts = ModelBudgetProvisioningFacts( + operation_id=uuid4(), + actor_profile_id=new_record_id(), + spec=budget_spec, + configuration_digest=canonical_json_hash(budget_spec.model_dump(mode="json")), + ) + async with AsyncSession(facts.engine, expire_on_commit=False) as session: + async with session.begin(): + owner = service(session, ports) + profile = await owner.provision_profile(profile_facts) + budget = await owner.provision_budget(budget_facts) + if replay: + replayed_profile = await owner.provision_profile(profile_facts) + replayed_budget = await owner.provision_budget(budget_facts) + assert replayed_profile.authorization_evidence_id == ( + profile.authorization_evidence_id + ) + assert replayed_budget.authorization_evidence_id == ( + budget.authorization_evidence_id + ) + return profile + + +def execution(facts: DatabaseFacts) -> ModelCallExecutionFacts: + return ModelCallExecutionFacts( + project_id=facts.project_id, + task_id=facts.task_id, + assignment_id=facts.assignment_id, + submission_id=facts.submission_id, + submission_version=1, + evaluation_request_id=new_record_id(), + evaluation_generation=1, + checker_run_id=new_record_id(), + worker_lease_id=new_record_id(), + worker_lease_generation=1, + worker_lease_expires_at=datetime.now(UTC) + timedelta(minutes=5), + ) + + +async def insert_checker_run(session: AsyncSession, run: ModelCallExecutionFacts) -> None: + """Persist the exact running lease used by a recovery attachment.""" + await session.execute( + text( + "insert into checker_runs(id,task_id,submission_id,submission_version,project_id," + "evaluation_request_id,phase,evaluation_generation,request_json,worker_lease_id," + "worker_lease_generation,worker_lease_expires_at,status) values " + "(:id,:task,:submission,:version,:project,:request,'post_submission',:generation," + ":request_json,:lease,:lease_generation,:expires,'running')" + ), + { + "id": run.checker_run_id, + "task": run.task_id, + "submission": run.submission_id, + "version": run.submission_version, + "project": run.project_id, + "request": run.evaluation_request_id, + "generation": run.evaluation_generation, + "request_json": json.dumps({"assignment_id": str(run.assignment_id)}), + "lease": run.worker_lease_id, + "lease_generation": run.worker_lease_generation, + "expires": run.worker_lease_expires_at, + }, + ) + + +def slot(facts: DatabaseFacts, profile, requirement: str) -> ModelPaidSlotKey: + return ModelPaidSlotKey( + project_id=facts.project_id, + assignment_id=facts.assignment_id, + submission_id=facts.submission_id, + submission_version=1, + requirement_id=requirement, + registry_entry_digest=facts.registry_entry_digest, + configuration_sha256=sha("configuration"), + profile_id=profile.profile_id, + profile_version=profile.spec.profile_version, + profile_digest=profile.profile_digest, + ) + + +def evidence(label: str, content_sha256: str) -> ModelCallEvidence: + return ModelCallEvidence( + binding_id=new_record_id(), + content_id=new_record_id(), + put_attempt_id=new_record_id(), + verification_receipt_id=new_record_id(), + content_sha256=content_sha256, + byte_count=100, + ) + + +async def wait_until_blocked_by( + engine: AsyncEngine, + *, + blocked_pid: int, + blocker_pid: int, +) -> None: + """Observe an actual PostgreSQL lock wait instead of inferring final totals.""" + for _ in range(200): + async with engine.connect() as connection: + blockers = await connection.scalar( + text("select pg_blocking_pids(:pid)"), {"pid": blocked_pid} + ) + if blocker_pid in blockers: + return + await asyncio.sleep(0.01) + raise AssertionError(f"backend {blocked_pid} did not block behind backend {blocker_pid}") + + +async def prepare_and_reserve( + facts: DatabaseFacts, + ports: BoundaryPorts, + profile, + requirement: str, +): + run = execution(facts) + paid_slot = slot(facts, profile, requirement) + request_sha = sha(f"request-{requirement}") + request_evidence = evidence(f"request-{requirement}", request_sha) + async with AsyncSession(facts.engine, expire_on_commit=False) as session: + async with session.begin(): + owner = service(session, ports) + prepared = await owner.prepare_attempt( + run, + paid_slot, + registry_entry_id=facts.registry_entry_id, + request_sha256=request_sha, + ) + reservation = await owner.reserve_dispatch(run, prepared, request_evidence) + return run, paid_slot, prepared, reservation, request_evidence + + +async def finish_for_lock_order(session, ports, dispatched, profile, terminal_kind): + """Run either supported terminal mutation behind the same parent lock.""" + owner = service(session, ports) + if terminal_kind == "complete": + await owner.complete( + dispatched.effect, + provider_response_id="resp_lock_order", + provider_model_snapshot=profile.spec.model_snapshot, + response_evidence=evidence("terminal-order-response", sha("terminal-order-response")), + usage=ModelCallUsage( + input_tokens=40, + cached_input_tokens=10, + output_tokens=12, + reasoning_tokens=7, + ), + ) + return + await owner.mark_known_no_effect( + dispatched.effect, + KnownNoEffectProof( + proof_id=new_record_id(), + proof_digest=sha("terminal-order-no-effect"), + observed_at=datetime.now(UTC), + ), + ) + + +@pytest.mark.parametrize("terminal_kind", ["complete", "known_no_effect"]) +async def test_terminal_holds_envelope_before_other_slot_and_account( + model_database: DatabaseFacts, + terminal_kind: str, +) -> None: + ports = BoundaryPorts() + reservation = 100 * 11 + 50 * 29 + profile = await provision( + model_database, + ports, + limit=reservation * 2, + profile_changes={ + "maximum_calls": 2, + "maximum_total_request_bytes": 20_000, + "maximum_total_response_bytes": 60_000, + "maximum_total_input_tokens": 200, + "maximum_total_output_tokens": 100, + }, + ) + run = execution(model_database) + first_slot = slot(model_database, profile, f"terminal_order_{terminal_kind}") + second_slot = first_slot.model_copy(update={"slot_index": 1}) + first_sha = sha(f"terminal-order-{terminal_kind}-first") + second_sha = sha(f"terminal-order-{terminal_kind}-second") + async with AsyncSession(model_database.engine, expire_on_commit=False) as session: + async with session.begin(): + owner = service(session, ports) + first_prepared = await owner.prepare_attempt( + run, + first_slot, + registry_entry_id=model_database.registry_entry_id, + request_sha256=first_sha, + ) + first_dispatched = await owner.reserve_dispatch( + run, + first_prepared, + evidence("terminal-order-first", first_sha), + ) + second_prepared = await owner.prepare_attempt( + run, + second_slot, + registry_entry_id=model_database.registry_entry_id, + request_sha256=second_sha, + ) + + holder = AsyncSession(model_database.engine, expire_on_commit=False) + terminal = AsyncSession(model_database.engine, expire_on_commit=False) + contender = AsyncSession(model_database.engine, expire_on_commit=False) + terminal_task = None + contender_task = None + try: + await holder.begin() + await terminal.begin() + await contender.begin() + holder_pid = await holder.scalar(text("select pg_backend_pid()")) + terminal_pid = await terminal.scalar(text("select pg_backend_pid()")) + contender_pid = await contender.scalar(text("select pg_backend_pid()")) + await holder.execute( + text( + "select project_id from model_usage_budget_accounts where project_id=:project_id for update" + ), + {"project_id": model_database.project_id}, + ) + + terminal_task = asyncio.create_task( + finish_for_lock_order(terminal, ports, first_dispatched, profile, terminal_kind) + ) + await wait_until_blocked_by( + model_database.engine, + blocked_pid=terminal_pid, + blocker_pid=holder_pid, + ) + + contender_task = asyncio.create_task( + service(contender, ports).reserve_dispatch( + run, + second_prepared, + evidence("terminal-order-second", second_sha), + ) + ) + await wait_until_blocked_by( + model_database.engine, + blocked_pid=contender_pid, + blocker_pid=terminal_pid, + ) + + # The contender is waiting at the run envelope; it owns neither child row. + async with AsyncSession(model_database.engine) as observer: + async with observer.begin(): + await observer.execute( + text("select id from checker_model_usage_slots where id=:id for update nowait"), + {"id": second_prepared.slot_id}, + ) + await observer.execute( + text("select id from model_usage_effects where id=:id for update nowait"), + {"id": second_prepared.attempt_id}, + ) + + await holder.commit() + await terminal_task + await terminal.commit() + await contender_task + await contender.commit() + finally: + for task in (terminal_task, contender_task): + if task is not None and not task.done(): + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + for session in (holder, terminal, contender): + if session.in_transaction(): + await session.rollback() + await session.close() + + async with AsyncSession(model_database.engine) as session: + account = await session.get(ModelUsageBudgetAccount, str(model_database.project_id)) + assert account.lifetime_reserved_nanodollars == reservation + if terminal_kind == "complete": + assert account.lifetime_settled_nanodollars == 30 * 11 + 10 * 2 + 12 * 29 + else: + assert account.lifetime_settled_nanodollars == 0 + + +async def test_uncertain_holds_budget_and_only_proven_no_effect_allows_child( + model_database: DatabaseFacts, +) -> None: + ports = BoundaryPorts() + reservation = 100 * 11 + 50 * 29 + profile = await provision(model_database, ports, limit=reservation * 2) + run, paid_slot, prepared, dispatched, _ = await prepare_and_reserve( + model_database, ports, profile, "model_recovery" + ) + uncertainty = sha("uncertain-delivery") + async with AsyncSession(model_database.engine, expire_on_commit=False) as session: + async with session.begin(): + owner = service(session, ports) + with pytest.raises(ModelCallUnavailable, match="model_call_effect_unavailable"): + await owner.mark_uncertain( + dispatched.effect.model_copy(update={"task_id": new_record_id()}), + uncertainty, + provider_response_id="resp_recoverable", + ) + observed = await owner.mark_uncertain( + dispatched.effect, + uncertainty, + provider_response_id="resp_recoverable", + ) + assert observed.state == "uncertain" + with pytest.raises(ModelCallConflict, match="not_retryable"): + await owner.prepare_recovery_child( + paid_slot, + registry_entry_id=model_database.registry_entry_id, + parent_attempt_id=prepared.attempt_id, + terminal_digest=uncertainty, + request_sha256=prepared.request_sha256, + ) + account = await session.get(ModelUsageBudgetAccount, str(model_database.project_id)) + assert account.lifetime_reserved_nanodollars == reservation + + # A distinct call proves the only zero-cost child path: authenticated no effect. + run2, paid_slot2, prepared2, dispatched2, _ = await prepare_and_reserve( + model_database, ports, profile, "model_no_effect" + ) + proof = KnownNoEffectProof( + proof_id=new_record_id(), + proof_digest=sha("provider-no-effect"), + observed_at=datetime( + 2026, + 10, + 24, + 5, + 30, + 0, + 123_000, + tzinfo=timezone(timedelta(hours=5, minutes=30)), + ), + ) + assert model_usage_instant(proof.observed_at) == "2026-10-24T00:00:00.123+00:00" + async with AsyncSession(model_database.engine, expire_on_commit=False) as session: + async with session.begin(): + owner = service(session, ports) + await owner.mark_known_no_effect(dispatched2.effect, proof) + child = await owner.prepare_recovery_child( + paid_slot2, + registry_entry_id=model_database.registry_entry_id, + parent_attempt_id=prepared2.attempt_id, + terminal_digest=model_retry_terminal_digest(proof), + request_sha256=prepared2.request_sha256, + ) + assert child.attempt_number == 2 + assert child.state == "prepared" + with pytest.raises(ModelCallConflict, match="parent_not_retryable"): + await owner.prepare_recovery_child( + paid_slot2, + registry_entry_id=model_database.registry_entry_id, + parent_attempt_id=prepared2.attempt_id, + terminal_digest=sha("substituted-terminal"), + request_sha256=prepared2.request_sha256, + ) + account = await session.get(ModelUsageBudgetAccount, str(model_database.project_id)) + assert account.lifetime_reserved_nanodollars == reservation + + +async def test_budget_adjustment_preserves_reserved_and_settles_old_snapshot( + model_database: DatabaseFacts, +) -> None: + ports = BoundaryPorts() + reservation = 100 * 11 + 50 * 29 + profile = await provision(model_database, ports, limit=reservation) + _run, _paid_slot, _prepared, dispatched, _ = await prepare_and_reserve( + model_database, ports, profile, "model_budget_adjustment" + ) + adjusted_spec = ProjectModelBudgetConfigurationSpec( + project_id=model_database.project_id, + configuration_version=2, + authorized_total_nanodollars=20_000_000_000, + externally_reconciled_prior_spend_nanodollars=123, + lifetime_limit_nanodollars=reservation * 3, + ) + adjusted_facts = ModelBudgetProvisioningFacts( + operation_id=uuid4(), + actor_profile_id=new_record_id(), + spec=adjusted_spec, + configuration_digest=canonical_json_hash(adjusted_spec.model_dump(mode="json")), + ) + async with AsyncSession(model_database.engine, expire_on_commit=False) as session: + async with session.begin(): + adjusted = await service(session, ports).provision_budget(adjusted_facts) + assert adjusted.lifetime_reserved_nanodollars == reservation + assert adjusted.lifetime_settled_nanodollars == 0 + + # The explicitly increased ceiling admits another dispatch without resetting + # the first reservation or counting ledger usage as external prior spend. + _second = await prepare_and_reserve( + model_database, ports, profile, "model_budget_adjustment_second" + ) + + too_low_spec = ProjectModelBudgetConfigurationSpec( + project_id=model_database.project_id, + configuration_version=3, + authorized_total_nanodollars=20_000_000_000, + externally_reconciled_prior_spend_nanodollars=123, + lifetime_limit_nanodollars=reservation * 2 - 1, + ) + too_low_facts = ModelBudgetProvisioningFacts( + operation_id=uuid4(), + actor_profile_id=new_record_id(), + spec=too_low_spec, + configuration_digest=canonical_json_hash(too_low_spec.model_dump(mode="json")), + ) + async with AsyncSession(model_database.engine, expire_on_commit=False) as session: + with pytest.raises(ModelCallBudgetUnavailable, match="below_lifetime_usage"): + async with session.begin(): + await service(session, ports).provision_budget(too_low_facts) + + usage = ModelCallUsage( + input_tokens=31, + cached_input_tokens=7, + output_tokens=11, + reasoning_tokens=4, + ) + async with AsyncSession(model_database.engine, expire_on_commit=False) as session: + async with session.begin(): + completed = await service(session, ports).complete( + dispatched.effect, + provider_response_id="resp_after_budget_adjustment", + provider_model_snapshot=profile.spec.model_snapshot, + response_evidence=evidence("response", sha("budget-adjustment-response")), + usage=usage, + ) + account = await session.get(ModelUsageBudgetAccount, str(model_database.project_id)) + assert account.current_configuration_id == str(adjusted.configuration_id) + assert account.lifetime_reserved_nanodollars == reservation + assert account.lifetime_settled_nanodollars == completed.settled_nanodollars + + next_spec = ProjectModelBudgetConfigurationSpec( + project_id=model_database.project_id, + configuration_version=3, + authorized_total_nanodollars=30_000_000_000, + externally_reconciled_prior_spend_nanodollars=456, + lifetime_limit_nanodollars=reservation * 4, + ) + next_facts = ModelBudgetProvisioningFacts( + operation_id=uuid4(), + actor_profile_id=new_record_id(), + spec=next_spec, + configuration_digest=canonical_json_hash(next_spec.model_dump(mode="json")), + ) + async with AsyncSession(model_database.engine, expire_on_commit=False) as session: + async with session.begin(): + next_budget = await service(session, ports).provision_budget(next_facts) + replayed_old = await service(session, ports).provision_budget(adjusted_facts) + account = await session.get(ModelUsageBudgetAccount, str(model_database.project_id)) + assert replayed_old.configuration_id == adjusted.configuration_id + assert account.current_configuration_id == str(next_budget.configuration_id) + assert account.lifetime_reserved_nanodollars == reservation + assert account.lifetime_settled_nanodollars == completed.settled_nanodollars + + for authorized_total, reconciled_prior in ( + (29_000_000_000, 456), + (30_000_000_000, 455), + ): + shrinking_spec = ProjectModelBudgetConfigurationSpec( + project_id=model_database.project_id, + configuration_version=4, + authorized_total_nanodollars=authorized_total, + externally_reconciled_prior_spend_nanodollars=reconciled_prior, + lifetime_limit_nanodollars=reservation * 4, + ) + shrinking_facts = ModelBudgetProvisioningFacts( + operation_id=uuid4(), + actor_profile_id=new_record_id(), + spec=shrinking_spec, + configuration_digest=canonical_json_hash(shrinking_spec.model_dump(mode="json")), + ) + async with AsyncSession(model_database.engine, expire_on_commit=False) as session: + with pytest.raises(ModelCallConflict, match="reconciliation_conflict"): + async with session.begin(): + await service(session, ports).provision_budget(shrinking_facts) + + +async def test_database_guards_reject_ledger_mutation_and_counter_substitution( + model_database: DatabaseFacts, +) -> None: + ports = BoundaryPorts() + reservation = 100 * 11 + 50 * 29 + profile = await provision(model_database, ports, limit=reservation * 2) + _, _, _, dispatched, _ = await prepare_and_reserve( + model_database, ports, profile, "model_guarded" + ) + + async with AsyncSession(model_database.engine) as session: + with pytest.raises(DBAPIError): + async with session.begin(): + await session.execute( + text( + "update checker_model_usage_slots set first_request_sha256=:digest where id=:slot_id" + ), + {"digest": sha("substituted"), "slot_id": dispatched.slot_id}, + ) + + async with AsyncSession(model_database.engine) as session: + with pytest.raises(DBAPIError): + async with session.begin(): + await session.execute( + text( + "update model_usage_budget_accounts set lifetime_reserved_nanodollars=lifetime_reserved_nanodollars-1 where project_id=:project_id" + ), + {"project_id": model_database.project_id}, + ) + + async with AsyncSession(model_database.engine) as session: + account = await session.get(ModelUsageBudgetAccount, str(model_database.project_id)) + assert account.lifetime_reserved_nanodollars == reservation + assert account.lifetime_settled_nanodollars == 0 + + +async def test_database_ancestry_prevents_receipt_and_artifact_deletion( + model_database: DatabaseFacts, +) -> None: + ports = BoundaryPorts() + profile = await provision(model_database, ports, limit=10_000) + _, _, _, _, request = await prepare_and_reserve( + model_database, ports, profile, "model_ancestry" + ) + for statement, identifier in ( + ("delete from actor_profiles where id=:id", profile.actor_profile_id), + ("delete from audit_events where id=:id", profile.authorization_evidence_id), + ("delete from artifact_bindings where id=:id", request.binding_id), + ("delete from artifact_contents where id=:id", request.content_id), + ("delete from artifact_put_attempts where id=:id", request.put_attempt_id), + ( + "delete from artifact_verification_receipts where id=:id", + request.verification_receipt_id, + ), + ): + async with AsyncSession(model_database.engine) as session: + with pytest.raises(DBAPIError): + async with session.begin(): + await session.execute(text(statement), {"id": identifier}) diff --git a/backend/tests/checkers_model_calls/test_model_invocation_grants_postgresql.py b/backend/tests/checkers_model_calls/test_model_invocation_grants_postgresql.py new file mode 100644 index 000000000..2e24a3cfb --- /dev/null +++ b/backend/tests/checkers_model_calls/test_model_invocation_grants_postgresql.py @@ -0,0 +1,718 @@ +"""Real PostgreSQL proof for bounded multi-call checker model invocation grants.""" + +from __future__ import annotations + +from dataclasses import dataclass +from datetime import UTC, datetime, timedelta +import json +from pathlib import Path +import runpy +from uuid import UUID + +import pytest +from sqlalchemy import text +from sqlalchemy.exc import DBAPIError +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.identifiers import new_record_id +from app.modules.authorization.domain.model_usage import ( + GRANT, + ModelCallGrantAuthorityFacts, + checker_model_call_resource, +) +from app.modules.authorization.domain.resource_digest import authorization_resource_digest +from app.modules.model_usage.api import ( + ModelActionEvidence, + ModelCallExecutionFacts, + ModelCallUsage, + ModelCallUnavailable, + ModelPaidSlotKey, + PreparedModelCallAttempt, + model_action_digest, +) +from app.modules.model_usage.grants import ModelInvocationGrants +from app.modules.model_usage.models import ModelUsageBudgetAccount, ModelUsageEffect +from tests.checkers_model_calls.model_usage_pg_support import ( + DatabaseFacts, + model_database as _ledger_database, +) +from tests.checkers_model_calls.model_usage_boundary_support import BoundaryPorts +from tests.checkers_model_calls.test_model_call_transactions_postgresql import ( + evidence, + execution, + provision, + service, + sha, + slot, +) + + +ledger_database_fixture = _ledger_database + + +class _CaptureOperations: + def __init__(self) -> None: + self.statements: list[str] = [] + + def execute(self, statement: str) -> None: + self.statements.append(statement) + + +@dataclass(frozen=True) +class GrantDatabase: + facts: DatabaseFacts + execution: ModelCallExecutionFacts + slot: ModelPaidSlotKey + prepared: PreparedModelCallAttempt + prepared_attempt_id: UUID + prepared_request_sha256: str + + +class GrantBoundaries: + """Persist exact fake AUTH receipts while PostgreSQL owns all ancestry checks.""" + + def __init__( + self, + session: AsyncSession, + *, + evidence_id: UUID | None = None, + action: str = "checker.model_call.grant", + corrupt_digest: bool = False, + ) -> None: + self._session = session + self._evidence_id = evidence_id + self._action = action + self._corrupt_digest = corrupt_digest + + async def authorize_grant( + self, + execution_facts: ModelCallExecutionFacts, + paid_slot: ModelPaidSlotKey, + external_request_digest: str, + token_hash: str, + ) -> ModelActionEvidence: + facts = { + "execution": execution_facts.model_dump(mode="json"), + "slot": paid_slot.model_dump(mode="json"), + "external_request_digest": external_request_digest, + "token_hash": token_hash, + } + evidence = ModelActionEvidence( + evidence_id=self._evidence_id or new_record_id(), + facts_digest=model_action_digest("grant", facts), + ) + actor_id = new_record_id() + resource = checker_model_call_resource( + GRANT, + ModelCallGrantAuthorityFacts( + execution=execution_facts, + slot=paid_slot, + external_request_digest=external_request_digest, + token_hash=token_hash, + ), + ) + await self._session.execute( + text( + "insert into actor_profiles(id,actor_kind,status,service_identity) " + "values (:id,'service','active','workstream.checker.post_submit')" + ), + {"id": actor_id}, + ) + await self._session.execute( + text( + "insert into audit_events(id,event_domain,event_type,actor_ref_kind,actor_id," + "action_id,permission_id,project_id,resource_type,resource_id," + "denial_code,after_facts) values " + "(:id,'authority','SensitiveAuthorizationAllowed','actor_profile',:actor," + ":action,'checker.model_call.grant',null,null,null," + "null,cast(:after as jsonb)) " + "on conflict(id) do nothing" + ), + { + "id": evidence.evidence_id, + "actor": str(actor_id), + "action": self._action, + "after": json.dumps( + { + "allowed": True, + "resource_context_digest": ( + sha("foreign grant authority") + if self._corrupt_digest + else authorization_resource_digest(resource) + ), + } + ), + }, + ) + return evidence + + async def require_current( + self, + _: ModelCallExecutionFacts, + __: ModelPaidSlotKey, + ) -> None: + return None + + +def _isolated(statement: str, schema: str) -> str: + return statement.replace("public.", f'"{schema}".').replace( + "pg_catalog,public,pg_temp", f'pg_catalog,"{schema}",pg_temp' + ) + + +@pytest.fixture +async def grant_database(ledger_database_fixture: DatabaseFacts) -> GrantDatabase: + ledger_database = ledger_database_fixture + async with ledger_database.engine.connect() as connection: + schema = str(await connection.scalar(text("select current_schema()"))) + migration = runpy.run_path( + str(Path(__file__).resolve().parents[2] / "alembic/versions/0062_model_usage.py") + ) + capture = _CaptureOperations() + migration["_upgrade_invocation_grants"].__globals__["op"] = capture + migration["_upgrade_invocation_grants"]() + async with ledger_database.engine.begin() as connection: + for statement in capture.statements: + await connection.execute(text(_isolated(statement, schema))) + + profile = await provision( + ledger_database, + BoundaryPorts(), + limit=100_000, + profile_changes={ + "maximum_calls": 3, + "maximum_total_request_bytes": 30_000, + "maximum_total_response_bytes": 90_000, + "maximum_total_input_tokens": 300, + "maximum_total_output_tokens": 150, + }, + ) + current = execution(ledger_database) + paid_slot = slot(ledger_database, profile, "model_judge") + request_sha256 = sha("prepared request") + async with AsyncSession(ledger_database.engine, expire_on_commit=False) as session: + async with session.begin(): + prepared = await service(session, BoundaryPorts()).prepare_attempt( + current, + paid_slot, + registry_entry_id=ledger_database.registry_entry_id, + request_sha256=request_sha256, + ) + + policy_sha256 = sha("locked post policy") + policy = { + "schema_version": "external_post_submit_policy.v1", + "entries": [ + { + "requirement_id": paid_slot.requirement_id, + "stage": "post_submit", + "registry": { + "registry_entry_id": str(ledger_database.registry_entry_id), + "entry_digest": paid_slot.registry_entry_digest, + }, + "configuration": { + "model_profile_id": str(paid_slot.profile_id), + "model_profile_version": paid_slot.profile_version, + "model_profile_digest": paid_slot.profile_digest, + }, + "configuration_sha256": paid_slot.configuration_sha256, + } + ], + "policy_sha256": policy_sha256, + } + async with ledger_database.engine.begin() as connection: + await connection.execute( + text( + "insert into checker_runs(id,project_id,task_id,submission_id,submission_version," + "evaluation_request_id,evaluation_generation,request_json,worker_lease_id," + "worker_lease_generation,worker_lease_expires_at,status,phase," + "selected_post_submit_policy_sha256,selected_post_submit_policy_body," + "locked_post_submit_checker_policy_hash) values " + "(:id,:project,:task,:submission,:version,:request,:generation,:request_json," + ":lease,:lease_generation,:expires,'running','post_submission',:policy_sha," + "cast(:policy as jsonb),:policy_sha)" + ), + { + "id": current.checker_run_id, + "project": current.project_id, + "task": current.task_id, + "submission": current.submission_id, + "version": current.submission_version, + "request": current.evaluation_request_id, + "generation": current.evaluation_generation, + "request_json": json.dumps({"assignment_id": str(current.assignment_id)}), + "lease": current.worker_lease_id, + "lease_generation": current.worker_lease_generation, + "expires": current.worker_lease_expires_at, + "policy_sha": policy_sha256, + "policy": json.dumps(policy), + }, + ) + return GrantDatabase( + ledger_database, + current, + paid_slot, + prepared, + prepared.attempt_id, + request_sha256, + ) + + +async def _issue( + database: GrantDatabase, + *, + boundaries: GrantBoundaries, + session: AsyncSession, +): + return await ModelInvocationGrants( + session, + authority=boundaries, + current=boundaries, + ).issue( + database.execution, + database.slot, + sha("external checker request"), + registry_entry_id=database.facts.registry_entry_id, + ) + + +async def test_grant_round_trip_binds_exact_calls_and_becomes_immutable( + grant_database: GrantDatabase, +) -> None: + async with AsyncSession(grant_database.facts.engine, expire_on_commit=False) as session: + async with session.begin(): + boundaries = GrantBoundaries(session) + grant = await _issue( + grant_database, + boundaries=boundaries, + session=session, + ) + owner = ModelInvocationGrants( + session, + authority=boundaries, + current=boundaries, + ) + facts = await owner.resolve(grant.grant_id, grant.binding_digest) + await owner.consume_prepared( + facts, + call_index=0, + request_sha256=grant_database.prepared_request_sha256, + prepared_attempt_id=grant_database.prepared_attempt_id, + ) + # An exact transport replay is idempotent and does not mint another binding. + await owner.consume_prepared( + facts, + call_index=0, + request_sha256=grant_database.prepared_request_sha256, + prepared_attempt_id=grant_database.prepared_attempt_id, + ) + row = ( + ( + await session.execute( + text( + "select c.request_sha256,c.attempt_id,c.bound_at," + "g.issued_at,g.expires_at,g.token_hash,g.id as grant_id from " + "checker_model_invocation_grant_calls c join " + "checker_model_invocation_grants g on g.id=c.grant_id" + ) + ) + ) + .mappings() + .one() + ) + assert row.request_sha256 == grant_database.prepared_request_sha256 + assert row.attempt_id == grant_database.prepared_attempt_id + assert row.issued_at < row.bound_at < row.expires_at + assert row.token_hash != grant.grant_id + + second_request = sha("second prepared request") + second_slot = grant_database.slot.model_copy(update={"slot_index": 1}) + second_attempt = await service(session, BoundaryPorts()).prepare_attempt( + grant_database.execution, + second_slot, + registry_entry_id=grant_database.facts.registry_entry_id, + request_sha256=second_request, + ) + await owner.consume_prepared( + facts, + call_index=1, + request_sha256=second_request, + prepared_attempt_id=second_attempt.attempt_id, + ) + assert ( + await session.scalar( + text( + "select count(*) from checker_model_invocation_grant_calls " + "where grant_id=:grant" + ), + {"grant": row.grant_id}, + ) + == 2 + ) + + for mutation in ( + "update checker_model_invocation_grants set external_request_digest=" + "'sha256:" + "d" * 64 + "'", + "delete from checker_model_invocation_grants", + "truncate checker_model_invocation_grants", + "update checker_model_invocation_grant_calls set request_sha256='sha256:" + "e" * 64 + "'", + "delete from checker_model_invocation_grant_calls", + "truncate checker_model_invocation_grant_calls", + ): + async with AsyncSession(grant_database.facts.engine) as session: + with pytest.raises(DBAPIError): + async with session.begin(): + await session.execute(text(mutation)) + + +async def _consume_grant(owner, grant, database: GrantDatabase) -> None: + facts = await owner.resolve(grant.grant_id, grant.binding_digest) + await owner.consume_prepared( + facts, + call_index=0, + request_sha256=database.prepared_request_sha256, + prepared_attempt_id=database.prepared_attempt_id, + ) + + +async def _attached_grants(session: AsyncSession, database: GrantDatabase) -> int: + count = await session.scalar( + text( + "select count(*) from checker_model_invocation_grant_calls " + "where attempt_id=:attempt" + ), + {"attempt": database.prepared_attempt_id}, + ) + assert count is not None + return count + + +async def _renew_and_consume(session, owner, boundaries, database: GrantDatabase): + renewed = database.execution.model_copy( + update={ + "worker_lease_id": new_record_id(), + "worker_lease_generation": database.execution.worker_lease_generation + 1, + "worker_lease_expires_at": datetime.now(UTC) + timedelta(minutes=10), + } + ) + await session.execute( + text( + "update checker_runs set worker_lease_id=:lease," + "worker_lease_generation=:generation,worker_lease_expires_at=:expires " + "where id=:run" + ), + { + "lease": renewed.worker_lease_id, + "generation": renewed.worker_lease_generation, + "expires": renewed.worker_lease_expires_at, + "run": renewed.checker_run_id, + }, + ) + renewed_database = GrantDatabase( + database.facts, + renewed, + database.slot, + database.prepared, + database.prepared_attempt_id, + database.prepared_request_sha256, + ) + grant = await _issue(renewed_database, boundaries=boundaries, session=session) + await _consume_grant(owner, grant, database) + return renewed + + +async def _settle_renewed_effect(session, database: GrantDatabase, renewed) -> None: + dispatched = await service(session, BoundaryPorts()).reserve_dispatch( + renewed, + database.prepared, + evidence("renewed-dispatch-request", database.prepared_request_sha256), + ) + completed = await service(session, BoundaryPorts()).complete_known_failure( + dispatched.effect, + provider_response_id="resp_renewed_dispatch", + provider_model_snapshot="gpt-example-2026-10-01", + response_evidence=evidence( + "renewed-dispatch-response", sha("renewed-dispatch-response") + ), + usage=ModelCallUsage( + input_tokens=22, + cached_input_tokens=2, + output_tokens=3, + reasoning_tokens=1, + ), + ) + effect = await session.get(ModelUsageEffect, str(completed.attempt_id)) + account = await session.get(ModelUsageBudgetAccount, str(database.facts.project_id)) + assert effect.prepared_execution_facts == database.execution.model_dump(mode="json") + assert effect.dispatch_execution_facts == renewed.model_dump(mode="json") + assert account.lifetime_reserved_nanodollars == 0 + assert account.lifetime_settled_nanodollars == completed.settled_nanodollars + + +async def _attach_successor(session, owner, boundaries, database, renewed) -> None: + successor = renewed.model_copy( + update={ + "checker_run_id": new_record_id(), + "evaluation_request_id": new_record_id(), + "evaluation_generation": renewed.evaluation_generation + 1, + "worker_lease_id": new_record_id(), + } + ) + await session.execute( + text( + "insert into checker_runs select :run,task_id,submission_id,submission_version," + "project_id,:request,phase,:generation,request_json,:lease," + "worker_lease_generation,worker_lease_expires_at,status," + "selected_post_submit_policy_sha256,selected_post_submit_policy_body," + "locked_post_submit_checker_policy_hash from checker_runs where id=:source" + ), + { + "run": successor.checker_run_id, + "request": successor.evaluation_request_id, + "generation": successor.evaluation_generation, + "lease": successor.worker_lease_id, + "source": database.execution.checker_run_id, + }, + ) + successor_database = GrantDatabase( + database.facts, + successor, + database.slot, + database.prepared, + database.prepared_attempt_id, + database.prepared_request_sha256, + ) + grant = await _issue(successor_database, boundaries=boundaries, session=session) + await _consume_grant(owner, grant, database) + + +async def test_renewed_and_successor_grants_reattach_the_same_paid_effect( + grant_database: GrantDatabase, +) -> None: + """Lease renewal and an authorized retry run reuse one stable paid effect.""" + async with AsyncSession(grant_database.facts.engine, expire_on_commit=False) as session: + async with session.begin(): + boundaries = GrantBoundaries(session) + owner = ModelInvocationGrants(session, authority=boundaries, current=boundaries) + first = await _issue(grant_database, boundaries=boundaries, session=session) + await _consume_grant(owner, first, grant_database) + renewed = await _renew_and_consume(session, owner, boundaries, grant_database) + assert await _attached_grants(session, grant_database) == 2 + await _settle_renewed_effect(session, grant_database, renewed) + await _attach_successor(session, owner, boundaries, grant_database, renewed) + assert await _attached_grants(session, grant_database) == 3 + + +async def test_grant_rejects_reused_or_wrong_authority_and_stale_db_clock( + grant_database: GrantDatabase, +) -> None: + evidence_id = new_record_id() + async with AsyncSession(grant_database.facts.engine) as session: + async with session.begin(): + boundaries = GrantBoundaries(session, evidence_id=evidence_id) + await _issue(grant_database, boundaries=boundaries, session=session) + + async with AsyncSession(grant_database.facts.engine) as session: + with pytest.raises(DBAPIError): + async with session.begin(): + boundaries = GrantBoundaries(session, evidence_id=evidence_id) + await _issue(grant_database, boundaries=boundaries, session=session) + + async with AsyncSession(grant_database.facts.engine) as session: + with pytest.raises(DBAPIError): + async with session.begin(): + boundaries = GrantBoundaries(session, action="checker.model_call.dispatch") + await _issue(grant_database, boundaries=boundaries, session=session) + + async with AsyncSession(grant_database.facts.engine) as session: + with pytest.raises(DBAPIError): + async with session.begin(): + boundaries = GrantBoundaries(session, corrupt_digest=True) + await _issue(grant_database, boundaries=boundaries, session=session) + + expired = grant_database.execution.model_copy( + update={"worker_lease_expires_at": datetime.now(UTC) - timedelta(seconds=1)} + ) + async with grant_database.facts.engine.begin() as connection: + await connection.execute( + text("update checker_runs set worker_lease_expires_at=:expires where id=:id"), + {"expires": expired.worker_lease_expires_at, "id": expired.checker_run_id}, + ) + async with AsyncSession(grant_database.facts.engine) as session: + with pytest.raises(DBAPIError): + async with session.begin(): + boundaries = GrantBoundaries(session) + await ModelInvocationGrants( + session, + authority=boundaries, + current=boundaries, + ).issue( + expired, + grant_database.slot, + sha("expired request"), + registry_entry_id=grant_database.facts.registry_entry_id, + ) + + +async def _reject_grant_scope_substitutions(database: GrantDatabase) -> None: + foreign_registry = new_record_id() + async with database.facts.engine.begin() as connection: + await connection.execute( + text( + "insert into external_checker_registry_entries(id,entry_digest,phase) " + "values (:id,:digest,'post_submit')" + ), + {"id": foreign_registry, "digest": database.slot.registry_entry_digest}, + ) + async with AsyncSession(database.facts.engine) as session: + with pytest.raises(DBAPIError): + async with session.begin(): + boundaries = GrantBoundaries(session) + await ModelInvocationGrants( + session, authority=boundaries, current=boundaries + ).issue( + database.execution, + database.slot, + sha("foreign registry request"), + registry_entry_id=foreign_registry, + ) + + foreign_project = new_record_id() + async with database.facts.engine.begin() as connection: + await connection.execute( + text("insert into projects(id) values (:id)"), {"id": foreign_project} + ) + async with AsyncSession(database.facts.engine) as session: + with pytest.raises(DBAPIError): + async with session.begin(): + boundaries = GrantBoundaries(session) + await ModelInvocationGrants( + session, authority=boundaries, current=boundaries + ).issue( + database.execution.model_copy(update={"project_id": foreign_project}), + database.slot.model_copy(update={"project_id": foreign_project}), + sha("foreign project request"), + registry_entry_id=database.facts.registry_entry_id, + ) + + async with database.facts.engine.begin() as connection: + await connection.execute( + text( + "update checker_runs set selected_post_submit_policy_body=" + "jsonb_set(selected_post_submit_policy_body," + "'{entries,0,configuration_sha256}',to_jsonb(cast(:digest as text)))" + ), + {"digest": sha("substituted configuration")}, + ) + async with AsyncSession(database.facts.engine) as session: + with pytest.raises(DBAPIError): + async with session.begin(): + boundaries = GrantBoundaries(session) + await _issue(database, boundaries=boundaries, session=session) + + +async def test_grant_rejects_locked_binding_and_call_substitution_or_gaps( + grant_database: GrantDatabase, +) -> None: + async with AsyncSession(grant_database.facts.engine, expire_on_commit=False) as session: + async with session.begin(): + boundaries = GrantBoundaries(session) + grant = await _issue(grant_database, boundaries=boundaries, session=session) + facts = await ModelInvocationGrants( + session, + authority=boundaries, + current=boundaries, + ).resolve(grant.grant_id, grant.binding_digest) + + async with AsyncSession(grant_database.facts.engine) as session: + with pytest.raises(DBAPIError): + async with session.begin(): + await session.execute( + text( + "update checker_model_invocation_grants " + "set external_request_digest=:request" + ), + {"request": sha("immutable grant facts")}, + ) + + async with AsyncSession(grant_database.facts.engine) as session: + async with session.begin(): + owner = ModelInvocationGrants( + session, + authority=GrantBoundaries(session), + current=GrantBoundaries(session), + ) + await owner.consume_prepared( + facts, + call_index=0, + request_sha256=grant_database.prepared_request_sha256, + prepared_attempt_id=grant_database.prepared_attempt_id, + ) + + # A call index is an immutable replay key, not permission to replace its request. + async with AsyncSession(grant_database.facts.engine) as session: + with pytest.raises(ModelCallUnavailable): + async with session.begin(): + await ModelInvocationGrants( + session, + authority=GrantBoundaries(session), + current=GrantBoundaries(session), + ).consume_prepared( + facts, + call_index=0, + request_sha256=sha("substituted request"), + prepared_attempt_id=grant_database.prepared_attempt_id, + ) + + other_slot = grant_database.slot.model_copy(update={"slot_index": 1}) + other_request = sha("other prepared request") + async with AsyncSession(grant_database.facts.engine, expire_on_commit=False) as session: + async with session.begin(): + other_attempt = await service(session, BoundaryPorts()).prepare_attempt( + grant_database.execution, + other_slot, + registry_entry_id=grant_database.facts.registry_entry_id, + request_sha256=other_request, + ) + async with AsyncSession(grant_database.facts.engine) as session: + with pytest.raises(DBAPIError): + async with session.begin(): + grant_id = await session.scalar( + text( + "select id from checker_model_invocation_grants " + "where token_hash=:token_hash" + ), + {"token_hash": facts.token_hash}, + ) + await session.execute( + text( + "insert into checker_model_invocation_grant_calls" + "(grant_id,call_index,attempt_id,request_sha256) " + "values (:grant,2,:attempt,:request)" + ), + { + "grant": grant_id, + "request": other_request, + "attempt": other_attempt.attempt_id, + }, + ) + + await _reject_grant_scope_substitutions(grant_database) + assert facts.slot == grant_database.slot + + +async def test_grant_trigger_locks_authority_then_task_then_checker_ancestry( + grant_database: GrantDatabase, +) -> None: + async with grant_database.facts.engine.connect() as connection: + definition = await connection.scalar( + text( + "select pg_get_functiondef(p.oid) from pg_proc p join pg_namespace n " + "on n.oid=p.pronamespace where p.proname=" + "'guard_checker_model_invocation_grant' and n.nspname=current_schema()" + ) + ) + insert_branch = definition.split("IF jsonb_typeof", maxsplit=1)[1] + assert insert_branch.index("audit_events") < insert_branch.index("workstream_tasks") + assert insert_branch.index("workstream_tasks") < insert_branch.index("checker_runs") + assert insert_branch.index("checker_runs") < insert_branch.index("model_usage_profiles") diff --git a/backend/tests/checkers_model_calls/test_model_runtime.py b/backend/tests/checkers_model_calls/test_model_runtime.py new file mode 100644 index 000000000..bdd1badd1 --- /dev/null +++ b/backend/tests/checkers_model_calls/test_model_runtime.py @@ -0,0 +1,135 @@ +"""Configured proxy startup and provider ownership boundaries.""" + +import asyncio +from contextlib import asynccontextmanager +from pathlib import Path +import signal +from types import SimpleNamespace + +import pytest +from pydantic import ValidationError + +from app.adapters.checkers import model_runtime +from app.core.config import Settings +from app.modules.model_usage.api import ModelCallUnavailable +from app.workers import checker_model_proxy +from tests.artifact_store_helpers import artifact_admission_limit_settings +from tests.checkers_model_calls.test_proxy_orchestration import Harness + + +async def test_disabled_proxy_needs_no_database_storage_or_provider(monkeypatch): + def forbidden(*_args, **_kwargs): + raise AssertionError("disabled model proxy constructed a runtime") + + monkeypatch.setattr(model_runtime, "create_artifact_store_bootstrap", forbidden) + async with model_runtime.configured_model_proxy_runtime( + SimpleNamespace(checker_model_proxy_socket=None), + None, + ): + pass + + +def test_proxy_requires_absolute_socket_and_enabled_storage(tmp_path): + assert ( + Settings(checker_model_proxy_socket="", _env_file=None).checker_model_proxy_socket is None + ) + with pytest.raises(ValidationError, match="must be absolute"): + Settings(checker_model_proxy_socket=Path("relative.sock"), _env_file=None) + with pytest.raises(ValidationError, match="requires enabled artifact"): + Settings(checker_model_proxy_socket=tmp_path / "proxy.sock", _env_file=None) + settings = Settings( + environment="test", + artifact_store_backend="local", + artifact_local_root=tmp_path / "objects", + artifact_scratch_root=tmp_path / "scratch", + checker_model_proxy_socket=tmp_path / "sockets/proxy.sock", + **artifact_admission_limit_settings(), + _env_file=None, + ) + assert settings.checker_model_proxy_socket == tmp_path / "sockets/proxy.sock" + + +async def test_provider_uses_profile_bounds_and_closes_owned_client(monkeypatch): + profile = Harness().profile + captured = [] + closed = [] + + async def close(): + closed.append(True) + + def construct(settings): + captured.append(settings) + return SimpleNamespace(aclose=close) + + monkeypatch.setenv("OPENAI_API_KEY", "private-test-key") + monkeypatch.setattr(model_runtime, "create_checker_model_provider", construct) + async with model_runtime.configured_model_provider(profile): + assert len(captured) == 1 + settings = captured[0] + assert settings.model == profile.spec.model_snapshot + assert settings.maximum_input_tokens == profile.spec.maximum_input_tokens + assert settings.maximum_output_tokens == profile.spec.maximum_output_tokens + assert settings.reasoning_effort == profile.spec.settings.reasoning_effort + assert "private-test-key" not in repr(settings) + assert closed == [True] + + +async def test_missing_provider_key_stops_before_reserving_budget(monkeypatch): + monkeypatch.delenv("OPENAI_API_KEY", raising=False) + h = Harness() + h.provider = model_runtime.configured_model_provider + assert (await h.invoke()).outcome == "unavailable" + assert h.state == "prepared" + assert "dispatch_committed" not in h.events + with pytest.raises(ModelCallUnavailable): + async with model_runtime.configured_model_provider(h.profile): + pass + + +async def test_singleton_worker_drains_before_engine_disposal(monkeypatch): + """SIGTERM stops admission and awaits the owned runtime before DB disposal.""" + calls = [] + settings = SimpleNamespace(checker_model_proxy_socket=Path("/private/model.sock")) + sessions = object() + real_loop = asyncio.get_running_loop() + + class SignalLoop: + def add_signal_handler(self, signum, callback): + calls.append(("add_signal", signum)) + if signum == signal.SIGTERM: + real_loop.call_soon(callback) + + def remove_signal_handler(self, signum): + calls.append(("remove_signal", signum)) + return True + + @asynccontextmanager + async def runtime(received_settings, received_sessions): + assert received_settings is settings + assert received_sessions is sessions + calls.append("runtime_started") + try: + yield + finally: + calls.append("runtime_drained") + + async def dispose(): + calls.append("engine_disposed") + + monkeypatch.setattr(checker_model_proxy, "get_settings", lambda: settings) + monkeypatch.setattr(checker_model_proxy, "get_session_factory", lambda: sessions) + monkeypatch.setattr(checker_model_proxy, "configured_model_proxy_runtime", runtime) + monkeypatch.setattr(checker_model_proxy, "dispose_engine", dispose) + monkeypatch.setattr(checker_model_proxy.asyncio, "get_running_loop", SignalLoop) + + await checker_model_proxy.serve() + + assert calls == [ + ("add_signal", signal.SIGINT), + ("add_signal", signal.SIGTERM), + "runtime_started", + "runtime_drained", + ("remove_signal", signal.SIGINT), + ("remove_signal", signal.SIGTERM), + "engine_disposed", + ] diff --git a/backend/tests/checkers_model_calls/test_model_shared_setup_postgresql.py b/backend/tests/checkers_model_calls/test_model_shared_setup_postgresql.py new file mode 100644 index 000000000..433b19313 --- /dev/null +++ b/backend/tests/checkers_model_calls/test_model_shared_setup_postgresql.py @@ -0,0 +1,967 @@ +"""Focused setup and shared-budget PostgreSQL model-usage proofs.""" + +from copy import deepcopy +import json + +from app.modules.model_usage.models import ModelUsageEffect +from app.modules.model_usage.profiles import ModelProfileReader +from tests.checkers_model_calls.model_usage_pg_support import ( + DatabaseFacts, + model_database as _model_database, +) +from tests.checkers_model_calls.model_usage_boundary_support import BoundaryPorts +from tests.checkers_model_calls.test_model_call_transactions_postgresql import ( + AsyncSession, + DBAPIError, + ModelCallBudgetUnavailable, + ModelCallConflict, + ModelCallUsage, + ModelGenerationSettings, + ModelProfileProvisioningFacts, + ModelCallUnavailable, + ModelUsageBudgetAccount, + asyncio, + canonical_json_hash, + evidence, + execution, + insert_checker_run, + new_record_id, + provision, + pytest, + service, + setup_service, + setup_slot_key, + sha, + slot, + text, + uuid4, +) + +model_database = _model_database + + +async def test_profile_reader_requires_exact_project_and_selector( + model_database: DatabaseFacts, +) -> None: + ports = BoundaryPorts() + profile = await provision(model_database, ports, limit=10_000) + async with AsyncSession(model_database.engine) as session: + async with session.begin(): + reader = ModelProfileReader(session) + assert ( + await reader.read_exact( + profile.selector, + project_id=model_database.project_id, + ) + == profile + ) + with pytest.raises(ModelCallUnavailable, match="model_profile_unavailable"): + await reader.read_exact(profile.selector, project_id=new_record_id()) + with pytest.raises(ModelCallUnavailable, match="model_profile_unavailable"): + await reader.read_exact( + profile.selector.model_copy( + update={"profile_digest": sha("substituted-profile")} + ), + project_id=model_database.project_id, + ) + + +async def test_provisioning_replay_retains_original_receipts_with_fresh_authority( + model_database: DatabaseFacts, +) -> None: + await provision(model_database, BoundaryPorts(), limit=10_000, replay=True) + + +async def test_profile_publication_rejects_substituted_authority_digest( + model_database: DatabaseFacts, +) -> None: + with pytest.raises(DBAPIError): + await provision( + model_database, + BoundaryPorts(corrupt_admin_receipt=True), + limit=10_000, + ) + + +async def test_profile_publication_is_not_owned_by_a_checker_registry_entry( + model_database: DatabaseFacts, +) -> None: + async with model_database.engine.begin() as connection: + await connection.execute( + text("update external_checker_registry_entries set phase='pre_submit' where id=:id"), + {"id": model_database.registry_entry_id}, + ) + profile = await provision(model_database, BoundaryPorts(), limit=10_000) + assert profile.spec.project_id == model_database.project_id + + +async def test_setup_profile_selection_pins_latest_matching_identity( + model_database: DatabaseFacts, +) -> None: + """New attempts see a matching update; old snapshots and other models do not drift.""" + ports = BoundaryPorts() + first = await provision(model_database, ports, limit=10_000) + + async def publish(spec): + facts = ModelProfileProvisioningFacts( + operation_id=uuid4(), + actor_profile_id=new_record_id(), + spec=spec, + profile_digest=canonical_json_hash(spec.model_dump(mode="json")), + ) + async with AsyncSession(model_database.engine, expire_on_commit=False) as session: + async with session.begin(): + return await service(session, ports).provision_profile(facts) + + second = await publish( + first.spec.model_copy( + update={ + "profile_version": 2, + "settings": ModelGenerationSettings(reasoning_effort="high", text_verbosity="low"), + } + ) + ) + await publish( + first.spec.model_copy( + update={ + "profile_version": 3, + "model_snapshot": "gpt-unrelated-2026-10-01", + } + ) + ) + + async with AsyncSession(model_database.engine, expire_on_commit=False) as session: + async with session.begin(): + reader = ModelProfileReader(session) + selected = await reader.read_latest_matching( + project_id=model_database.project_id, + provider=first.spec.provider, + api=first.spec.api, + model_snapshot=first.spec.model_snapshot, + ) + pinned = await reader.read_exact( + { + "profile_id": first.profile_id, + "profile_version": first.spec.profile_version, + "profile_digest": first.profile_digest, + }, + project_id=model_database.project_id, + ) + + assert selected.profile_id == second.profile_id + assert selected.spec.profile_version == 2 + assert pinned.profile_id == first.profile_id + assert pinned.spec.profile_version == 1 + + +async def test_database_guards_recompute_profile_and_budget_digests( + model_database: DatabaseFacts, +) -> None: + await provision(model_database, BoundaryPorts(), limit=10_000) + async with AsyncSession(model_database.engine) as session: + with pytest.raises(DBAPIError, match="profile digest is invalid"): + async with session.begin(): + await session.execute( + text( + "insert into model_usage_profiles select :id,project_id,2,:digest,provider,api,model_snapshot,generation_settings,maximum_request_bytes,maximum_output_bytes,maximum_response_bytes,maximum_input_tokens,maximum_output_tokens,maximum_calls,maximum_total_request_bytes,maximum_total_response_bytes,maximum_total_input_tokens,maximum_total_output_tokens,allowed_tools,maximum_tool_calls,maximum_tool_output_bytes,deadline_ms,pricing_policy_id,pricing_policy_version,pricing_policy_digest,uncached_input_rate,cached_input_rate,output_rate,:operation,actor_profile_id,:authority,clock_timestamp() from model_usage_profiles where profile_version=1" + ), + { + "id": new_record_id(), + "digest": sha("noncanonical-profile"), + "operation": uuid4(), + "authority": new_record_id(), + }, + ) + + async with AsyncSession(model_database.engine) as session: + with pytest.raises(DBAPIError, match="configuration digest is invalid"): + async with session.begin(): + await session.execute( + text( + "insert into model_usage_budget_configurations select :id,project_id,2,:digest,authorized_total_nanodollars,externally_reconciled_prior_spend_nanodollars,lifetime_limit_nanodollars,:operation,actor_profile_id,:authority,clock_timestamp() from model_usage_budget_configurations where configuration_version=1" + ), + { + "id": new_record_id(), + "digest": sha("noncanonical-budget"), + "operation": uuid4(), + "authority": new_record_id(), + }, + ) + + +async def test_concurrent_dispatch_reserves_one_lifetime_budget( + model_database: DatabaseFacts, +) -> None: + ports = BoundaryPorts() + reservation = 100 * 11 + 50 * 29 + profile = await provision(model_database, ports, limit=reservation) + prepared = [] + for requirement in ("model_a", "model_b"): + run = execution(model_database) + paid_slot = slot(model_database, profile, requirement) + request_sha = sha(requirement) + async with AsyncSession(model_database.engine, expire_on_commit=False) as session: + async with session.begin(): + item = await service(session, ports).prepare_attempt( + run, + paid_slot, + registry_entry_id=model_database.registry_entry_id, + request_sha256=request_sha, + ) + prepared.append((run, item, evidence(requirement, request_sha))) + + async def dispatch(item): + run, attempt, request_evidence = item + async with AsyncSession(model_database.engine, expire_on_commit=False) as session: + async with session.begin(): + return await service(session, ports).reserve_dispatch( + run, attempt, request_evidence + ) + + results = await asyncio.gather(*(dispatch(item) for item in prepared), return_exceptions=True) + assert sum(not isinstance(result, BaseException) for result in results) == 1 + assert sum(isinstance(result, ModelCallBudgetUnavailable) for result in results) == 1 + async with AsyncSession(model_database.engine) as session: + account = await session.get(ModelUsageBudgetAccount, str(model_database.project_id)) + assert account.lifetime_reserved_nanodollars == reservation + assert account.lifetime_settled_nanodollars == 0 + + +async def test_setup_and_checker_dispatches_share_one_account_and_both_win( + model_database: DatabaseFacts, +) -> None: + ports = BoundaryPorts() + per_dispatch = 100 * 11 + 50 * 29 + profile = await provision( + model_database, + ports, + limit=per_dispatch * 2, + profile_changes={ + "maximum_calls": 2, + "maximum_total_request_bytes": 20_000, + "maximum_total_response_bytes": 60_000, + "maximum_total_input_tokens": 200, + "maximum_total_output_tokens": 100, + }, + ) + checker_run = execution(model_database) + checker_slot = slot(model_database, profile, "shared_budget_checker") + checker_sha = sha("shared-budget-checker") + checker_evidence = evidence("shared-budget-checker", checker_sha) + setup_key = await setup_slot_key(model_database, profile) + setup_sha = sha("shared-budget-setup") + setup_evidence = evidence("shared-budget-setup", setup_sha) + + async with AsyncSession(model_database.engine, expire_on_commit=False) as session: + async with session.begin(): + checker_prepared = await service(session, ports).prepare_attempt( + checker_run, + checker_slot, + registry_entry_id=model_database.registry_entry_id, + request_sha256=checker_sha, + ) + async with AsyncSession(model_database.engine, expire_on_commit=False) as session: + async with session.begin(): + setup_prepared = await setup_service(session, ports).prepare(setup_key, setup_sha) + + async def reserve_checker(): + async with AsyncSession(model_database.engine, expire_on_commit=False) as session: + async with session.begin(): + return await service(session, ports).reserve_dispatch( + checker_run, checker_prepared, checker_evidence + ) + + async def reserve_setup(): + async with AsyncSession(model_database.engine, expire_on_commit=False) as session: + async with session.begin(): + return await setup_service(session, ports).reserve_dispatch( + setup_prepared, setup_evidence + ) + + checker_reserved, setup_reserved = await asyncio.gather(reserve_checker(), reserve_setup()) + assert checker_reserved.reserved_nanodollars == per_dispatch + assert setup_reserved.reserved_nanodollars == per_dispatch + async with AsyncSession(model_database.engine) as session: + account = await session.get(ModelUsageBudgetAccount, str(model_database.project_id)) + assert account.lifetime_reserved_nanodollars == per_dispatch * 2 + assert account.lifetime_settled_nanodollars == 0 + + +async def test_cheap_completed_call_reserves_remaining_aggregate_before_next_dispatch( + model_database: DatabaseFacts, +) -> None: + """A cheap first result cannot defer an aggregate-token failure until after I/O.""" + ports = BoundaryPorts() + per_dispatch = 100 * 11 + 50 * 29 + profile = await provision( + model_database, + ports, + limit=per_dispatch * 2, + profile_changes={ + "maximum_calls": 2, + "maximum_total_request_bytes": 20_000, + "maximum_total_response_bytes": 60_000, + "maximum_total_input_tokens": 100, + "maximum_total_output_tokens": 100, + }, + ) + run = execution(model_database) + first_slot = slot(model_database, profile, "aggregate_pre_dispatch") + second_slot = first_slot.model_copy(update={"slot_index": 1}) + first_sha = sha("aggregate-pre-dispatch-first") + second_sha = sha("aggregate-pre-dispatch-second") + + async with AsyncSession(model_database.engine, expire_on_commit=False) as session: + async with session.begin(): + owner = service(session, ports) + first_prepared = await owner.prepare_attempt( + run, + first_slot, + registry_entry_id=model_database.registry_entry_id, + request_sha256=first_sha, + ) + first_dispatched = await owner.reserve_dispatch( + run, + first_prepared, + evidence("aggregate-pre-dispatch-first", first_sha), + ) + await owner.complete( + first_dispatched.effect, + provider_response_id="resp_cheap_cached", + provider_model_snapshot=profile.spec.model_snapshot, + response_evidence=evidence( + "aggregate-pre-dispatch-response", + sha("aggregate-pre-dispatch-response"), + ), + usage=ModelCallUsage( + input_tokens=10, + cached_input_tokens=10, + output_tokens=0, + reasoning_tokens=0, + ), + ) + second_prepared = await owner.prepare_attempt( + run, + second_slot, + registry_entry_id=model_database.registry_entry_id, + request_sha256=second_sha, + ) + with pytest.raises(ModelCallBudgetUnavailable, match="run_envelope"): + await owner.reserve_dispatch( + run, + second_prepared, + evidence("aggregate-pre-dispatch-second", second_sha), + ) + + async with AsyncSession(model_database.engine) as session: + account = await session.get(ModelUsageBudgetAccount, str(model_database.project_id)) + second = await session.get(ModelUsageEffect, str(second_prepared.attempt_id)) + assert account.lifetime_reserved_nanodollars == 0 + assert account.lifetime_settled_nanodollars == 10 * 2 + assert second is not None and second.state == "prepared" + + +async def test_setup_draft_review_correction_settle_three_shared_calls( + model_database: DatabaseFacts, +) -> None: + """One exact setup attempt can run a finite three-call owner-controlled loop.""" + ports = BoundaryPorts() + per_dispatch = 100 * 11 + 50 * 29 + profile = await provision( + model_database, + ports, + limit=per_dispatch * 3, + profile_changes={ + "maximum_calls": 3, + "maximum_total_request_bytes": 30_000, + "maximum_total_response_bytes": 90_000, + "maximum_total_input_tokens": 300, + "maximum_total_output_tokens": 150, + }, + ) + base = await setup_slot_key(model_database, profile) + settled = 0 + for index, phase in enumerate(("draft", "review", "correction")): + key = base.model_copy(update={"phase": phase, "call_index": index}) + request_sha = sha(f"setup-loop-{phase}-request") + response_sha = sha(f"setup-loop-{phase}-response") + usage = ModelCallUsage( + input_tokens=10 + index, + cached_input_tokens=index, + output_tokens=5 + index, + reasoning_tokens=index, + ) + async with AsyncSession(model_database.engine, expire_on_commit=False) as session: + async with session.begin(): + owner = setup_service(session, ports) + prepared = await owner.prepare(key, request_sha) + dispatched = await owner.reserve_dispatch( + prepared, evidence(f"setup-loop-{phase}-request", request_sha) + ) + completed = await owner.complete( + dispatched.effect, + provider_response_id=f"resp_setup_{phase}", + provider_model_snapshot=profile.spec.model_snapshot, + response_evidence=evidence(f"setup-loop-{phase}-response", response_sha), + usage=usage, + ) + assert completed.effect.slot_key == key + assert completed.state == "completed" + assert completed.replayed is False + settled += completed.settled_nanodollars + + async with AsyncSession(model_database.engine) as session: + account = await session.get(ModelUsageBudgetAccount, str(model_database.project_id)) + states = ( + ( + await session.execute( + text( + "select effect.state from model_usage_effects effect join setup_model_usage_slots slot on slot.id=effect.setup_slot_id where slot.compilation_attempt_id=:attempt order by slot.call_index" + ), + {"attempt": base.compilation_attempt_id}, + ) + ) + .scalars() + .all() + ) + assert states == ["completed", "completed", "completed"] + assert account.lifetime_reserved_nanodollars == 0 + assert account.lifetime_settled_nanodollars == settled + + +async def test_setup_known_failure_settles_once_and_replays_after_restart( + model_database: DatabaseFacts, +) -> None: + """An unusable paid response conserves exact spend and never dispatches twice.""" + ports = BoundaryPorts() + reservation = 100 * 11 + 50 * 29 + profile = await provision(model_database, ports, limit=reservation) + key = await setup_slot_key(model_database, profile) + request_sha = sha("setup-known-failure-request") + response = evidence("setup-known-failure-response", sha("setup-known-failure-response")) + usage = ModelCallUsage( + input_tokens=40, + cached_input_tokens=10, + output_tokens=12, + reasoning_tokens=3, + ) + + async with AsyncSession(model_database.engine, expire_on_commit=False) as session: + async with session.begin(): + owner = setup_service(session, ports) + prepared = await owner.prepare(key, request_sha) + dispatched = await owner.reserve_dispatch( + prepared, + evidence("setup-known-failure-request", request_sha), + ) + failed = await owner.complete( + dispatched.effect, + provider_response_id="resp_setup_known_failure", + provider_model_snapshot=profile.spec.model_snapshot, + response_evidence=response, + usage=usage, + usable=False, + ) + assert failed.state == "known_failed" + assert failed.replayed is False + + async with AsyncSession(model_database.engine, expire_on_commit=False) as session: + async with session.begin(): + replay = await setup_service(session, ports).complete( + dispatched.effect, + provider_response_id="resp_setup_known_failure", + provider_model_snapshot=profile.spec.model_snapshot, + response_evidence=response, + usage=usage, + usable=False, + ) + account = await session.get(ModelUsageBudgetAccount, str(model_database.project_id)) + row = await session.get(ModelUsageEffect, str(dispatched.effect.effect_id)) + assert replay.state == "known_failed" + assert replay.replayed is True + assert account.lifetime_reserved_nanodollars == 0 + assert account.lifetime_settled_nanodollars == 30 * 11 + 10 * 2 + 12 * 29 + assert row.settlement_authorization_evidence_id is not None + assert row.settled_at is not None + + assert ports.events.count("setup_dispatch") == 1 + + +async def test_setup_rejects_same_project_profile_substitution_before_effect( + model_database: DatabaseFacts, +) -> None: + ports = BoundaryPorts() + profile_a = await provision(model_database, ports, limit=1_000_000) + spec_b = profile_a.spec.model_copy( + update={ + "profile_version": 2, + "settings": ModelGenerationSettings( + reasoning_effort="high", + text_verbosity="low", + ), + } + ) + facts_b = ModelProfileProvisioningFacts( + operation_id=uuid4(), + actor_profile_id=new_record_id(), + spec=spec_b, + profile_digest=canonical_json_hash(spec_b.model_dump(mode="json")), + ) + async with AsyncSession(model_database.engine, expire_on_commit=False) as session: + async with session.begin(): + profile_b = await service(session, ports).provision_profile(facts_b) + + locked_key = await setup_slot_key(model_database, profile_a) + async with AsyncSession(model_database.engine, expire_on_commit=False) as session: + async with session.begin(): + await setup_service(session, ports).prepare( + locked_key, + sha("locked-profile-a-request"), + ) + substituted = locked_key.model_copy( + update={ + "profile_id": profile_b.profile_id, + "profile_version": profile_b.spec.profile_version, + "profile_digest": profile_b.profile_digest, + } + ) + async with AsyncSession(model_database.engine, expire_on_commit=False) as session: + with pytest.raises(DBAPIError, match="invalid setup model usage envelope ownership"): + async with session.begin(): + await setup_service(session, ports).prepare( + substituted, + sha("same-project-profile-substitution"), + ) + async with AsyncSession(model_database.engine) as session: + counts = ( + await session.execute( + text( + "select count(distinct envelope.id),count(effect.id),min(envelope.profile_id::text) from setup_model_usage_envelopes envelope join setup_model_usage_slots slot on slot.envelope_id=envelope.id join model_usage_effects effect on effect.setup_slot_id=slot.id where envelope.compilation_attempt_id=:attempt" + ), + {"attempt": locked_key.compilation_attempt_id}, + ) + ).one() + assert counts == (1, 1, str(profile_a.profile_id)) + + +async def test_uncertain_setup_effect_replay_never_reauthorizes_dispatch( + model_database: DatabaseFacts, +) -> None: + ports = BoundaryPorts() + reservation = 100 * 11 + 50 * 29 + profile = await provision(model_database, ports, limit=reservation) + key = await setup_slot_key(model_database, profile) + request_sha = sha("setup-uncertain-request") + request_evidence = evidence("setup-uncertain-request", request_sha) + async with AsyncSession(model_database.engine, expire_on_commit=False) as session: + async with session.begin(): + owner = setup_service(session, ports) + prepared = await owner.prepare(key, request_sha) + dispatched = await owner.reserve_dispatch(prepared, request_evidence) + observed = await owner.mark_uncertain( + dispatched.effect, + sha("setup-uncertain-observation"), + ) + assert observed.state == "uncertain" + + async with AsyncSession(model_database.engine, expire_on_commit=False) as session: + async with session.begin(): + owner = setup_service(session, ports) + observed = await owner.recover_existing(key, request_sha) + assert observed is not None + assert observed.state == "uncertain" + assert observed.replayed is True + replayed = await owner.prepare(key, request_sha) + with pytest.raises(ModelCallConflict, match="not_dispatchable"): + await owner.reserve_dispatch(replayed, request_evidence) + account = await session.get(ModelUsageBudgetAccount, str(model_database.project_id)) + assert account.lifetime_reserved_nanodollars == reservation + assert account.lifetime_settled_nanodollars == 0 + + +async def test_setup_and_checker_dispatches_share_one_account_cap( + model_database: DatabaseFacts, +) -> None: + ports = BoundaryPorts() + per_dispatch = 100 * 11 + 50 * 29 + profile = await provision( + model_database, + ports, + limit=per_dispatch, + profile_changes={ + "maximum_calls": 2, + "maximum_total_request_bytes": 20_000, + "maximum_total_response_bytes": 60_000, + "maximum_total_input_tokens": 200, + "maximum_total_output_tokens": 100, + }, + ) + checker_run = execution(model_database) + checker_slot = slot(model_database, profile, "shared_cap_checker") + checker_sha = sha("shared-cap-checker") + checker_evidence = evidence("shared-cap-checker", checker_sha) + setup_key = await setup_slot_key(model_database, profile) + setup_sha = sha("shared-cap-setup") + setup_evidence = evidence("shared-cap-setup", setup_sha) + + async with AsyncSession(model_database.engine, expire_on_commit=False) as session: + async with session.begin(): + checker_prepared = await service(session, ports).prepare_attempt( + checker_run, + checker_slot, + registry_entry_id=model_database.registry_entry_id, + request_sha256=checker_sha, + ) + async with AsyncSession(model_database.engine, expire_on_commit=False) as session: + async with session.begin(): + setup_prepared = await setup_service(session, ports).prepare(setup_key, setup_sha) + + async def reserve_checker(): + async with AsyncSession(model_database.engine, expire_on_commit=False) as session: + async with session.begin(): + return await service(session, ports).reserve_dispatch( + checker_run, checker_prepared, checker_evidence + ) + + async def reserve_setup(): + async with AsyncSession(model_database.engine, expire_on_commit=False) as session: + async with session.begin(): + return await setup_service(session, ports).reserve_dispatch( + setup_prepared, setup_evidence + ) + + results = await asyncio.gather(reserve_checker(), reserve_setup(), return_exceptions=True) + assert sum(not isinstance(result, BaseException) for result in results) == 1 + assert sum(isinstance(result, ModelCallBudgetUnavailable) for result in results) == 1 + async with AsyncSession(model_database.engine) as session: + account = await session.get(ModelUsageBudgetAccount, str(model_database.project_id)) + assert account.lifetime_reserved_nanodollars == per_dispatch + assert account.lifetime_settled_nanodollars == 0 + + +async def test_setup_reservation_rollback_releases_shared_account( + model_database: DatabaseFacts, +) -> None: + ports = BoundaryPorts() + per_dispatch = 100 * 11 + 50 * 29 + profile = await provision(model_database, ports, limit=per_dispatch) + setup_key = await setup_slot_key(model_database, profile) + setup_sha = sha("rollback-shared-setup") + setup_evidence = evidence("rollback-shared-setup", setup_sha) + async with AsyncSession(model_database.engine, expire_on_commit=False) as session: + async with session.begin(): + setup_prepared = await setup_service(session, ports).prepare(setup_key, setup_sha) + with pytest.raises(RuntimeError, match="force setup rollback"): + async with session.begin(): + await setup_service(session, ports).reserve_dispatch(setup_prepared, setup_evidence) + raise RuntimeError("force setup rollback") + + checker_run = execution(model_database) + checker_slot = slot(model_database, profile, "after_setup_rollback") + checker_sha = sha("after-setup-rollback") + async with AsyncSession(model_database.engine, expire_on_commit=False) as session: + async with session.begin(): + checker_prepared = await service(session, ports).prepare_attempt( + checker_run, + checker_slot, + registry_entry_id=model_database.registry_entry_id, + request_sha256=checker_sha, + ) + reserved = await service(session, ports).reserve_dispatch( + checker_run, + checker_prepared, + evidence("after-setup-rollback", checker_sha), + ) + assert reserved.reserved_nanodollars == per_dispatch + account = await session.get(ModelUsageBudgetAccount, str(model_database.project_id)) + assert account.lifetime_reserved_nanodollars == per_dispatch + assert account.lifetime_settled_nanodollars == 0 + + +async def test_fresh_execution_authority_precedes_currentness_and_owner_locks( + model_database: DatabaseFacts, +) -> None: + ports = BoundaryPorts() + reservation = 100 * 11 + 50 * 29 + profile = await provision(model_database, ports, limit=reservation * 2) + run = execution(model_database) + paid_slot = slot(model_database, profile, "model_order") + request_sha = sha("ordered-request") + ports.events.clear() + async with AsyncSession(model_database.engine, expire_on_commit=False) as session: + async with session.begin(): + owner = service(session, ports) + prepared = await owner.prepare_attempt( + run, + paid_slot, + registry_entry_id=model_database.registry_entry_id, + request_sha256=request_sha, + ) + assert ports.events == ["prepare", "current"] + ports.events.clear() + dispatched = await owner.reserve_dispatch( + run, + prepared, + evidence("ordered-request", request_sha), + ) + assert ports.events == ["dispatch", "current"] + await owner.mark_uncertain( + dispatched.effect, + sha("ordered-uncertain"), + provider_response_id="resp_ordered", + ) + + async with AsyncSession(model_database.engine, expire_on_commit=False) as session: + async with session.begin(): + await insert_checker_run(session, run) + owner = service(session, ports) + ports.events.clear() + await owner.attach_recovery(run, dispatched.effect) + assert ports.events == ["recover", "current"] + ports.events.clear() + with pytest.raises(ModelCallConflict, match="not_retryable"): + await owner.prepare_recovery_child( + paid_slot, + registry_entry_id=model_database.registry_entry_id, + parent_attempt_id=prepared.attempt_id, + terminal_digest=sha("ordered-uncertain"), + request_sha256=prepared.request_sha256, + ) + assert ports.events == ["child", "recovery_current"] + + +async def test_recovery_insert_rejects_every_substituted_owner_and_authority_fact( + model_database: DatabaseFacts, +) -> None: + """The SQL boundary proves a fresh successor and rejects unrelated recovery facts.""" + ports = BoundaryPorts() + profile = await provision(model_database, ports, limit=10_000) + origin = execution(model_database) + paid_slot = slot(model_database, profile, "recovery_guard_origin") + request_sha = sha("recovery-guard-origin") + async with AsyncSession(model_database.engine, expire_on_commit=False) as session: + async with session.begin(): + owner = service(session, ports) + prepared = await owner.prepare_attempt( + origin, + paid_slot, + registry_entry_id=model_database.registry_entry_id, + request_sha256=request_sha, + ) + dispatched = await owner.reserve_dispatch( + origin, + prepared, + evidence("recovery-guard-origin", request_sha), + ) + await owner.mark_uncertain( + dispatched.effect, + sha("recovery-guard-origin-uncertain"), + provider_response_id="resp_recovery_guard_origin", + ) + + other_origin = execution(model_database) + other_slot = slot(model_database, profile, "recovery_guard_other") + other_sha = sha("recovery-guard-other") + async with AsyncSession(model_database.engine, expire_on_commit=False) as session: + async with session.begin(): + owner = service(session, ports) + other_prepared = await owner.prepare_attempt( + other_origin, + other_slot, + registry_entry_id=model_database.registry_entry_id, + request_sha256=other_sha, + ) + other_dispatched = await owner.reserve_dispatch( + other_origin, + other_prepared, + evidence("recovery-guard-other", other_sha), + ) + await owner.mark_uncertain( + other_dispatched.effect, + sha("recovery-guard-other-uncertain"), + provider_response_id="resp_recovery_guard_other", + ) + + successor = execution(model_database) + async with AsyncSession(model_database.engine, expire_on_commit=False) as session: + async with session.begin(): + await insert_checker_run(session, successor) + attached = await service(session, ports).attach_recovery(successor, dispatched.effect) + assert attached.replayed is False + stored = ( + ( + await session.execute( + text( + "select attempt_id,checker_run_id,worker_lease_id," + "worker_lease_generation,execution_facts,effect_facts," + "authorization_evidence_id from checker_model_usage_recoveries " + "where attempt_id=:effect" + ), + {"effect": dispatched.effect.attempt_id}, + ) + ) + .mappings() + .one() + ) + authority = ( + ( + await session.execute( + text( + "select actor_id,action_id,permission_id,after_facts " + "from audit_events where id=:id" + ), + {"id": stored["authorization_evidence_id"]}, + ) + ) + .mappings() + .one() + ) + + unrelated_run = execution(model_database) + async with AsyncSession(model_database.engine, expire_on_commit=False) as session: + async with session.begin(): + await insert_checker_run(session, unrelated_run) + + base_execution = deepcopy(stored["execution_facts"]) + base_effect = deepcopy(stored["effect_facts"]) + wrong_project = str(new_record_id()) + wrong_lease = str(new_record_id()) + mutations = { + "project": lambda row: row["execution"].__setitem__("project_id", wrong_project), + "run": lambda row: row.__setitem__("run", str(unrelated_run.checker_run_id)), + "lease": lambda row: row.__setitem__("lease", wrong_lease), + "generation": lambda row: row.__setitem__("generation", 99), + "effect": lambda row: ( + row.__setitem__("effect_id", str(other_dispatched.effect.attempt_id)), + row.__setitem__("effect", other_dispatched.effect.model_dump(mode="json")), + ), + "action": lambda row: row.__setitem__("action", "checker.model_call.dispatch"), + "actor": lambda row: row.__setitem__("actor", str(new_record_id())), + "resource_digest": lambda row: row.__setitem__( + "after", {"allowed": True, "resource_context_digest": sha("wrong-recover")} + ), + } + for label, mutate in mutations.items(): + row = { + "effect_id": str(stored["attempt_id"]), + "run": str(stored["checker_run_id"]), + "lease": str(stored["worker_lease_id"]), + "generation": stored["worker_lease_generation"], + "execution": deepcopy(base_execution), + "effect": deepcopy(base_effect), + "actor": authority["actor_id"], + "action": authority["action_id"], + "permission": authority["permission_id"], + "after": authority["after_facts"], + } + mutate(row) + async with AsyncSession(model_database.engine) as session: + with pytest.raises(DBAPIError, match="recovery (ancestry|authority) is invalid"): + async with session.begin(): + if label == "actor": + await session.execute( + text( + "insert into actor_profiles(id,actor_kind,status) " + "values (:id,'human','active')" + ), + {"id": row["actor"]}, + ) + evidence_id = new_record_id() + await session.execute( + text( + "insert into audit_events(id,event_domain,event_type,actor_ref_kind," + "actor_id,action_id,permission_id,denial_code,after_facts) values " + "(:id,'authority','SensitiveAuthorizationAllowed','actor_profile'," + ":actor,:action,:permission,null,cast(:after as json))" + ), + { + "id": evidence_id, + "actor": row["actor"], + "action": row["action"], + "permission": row["permission"], + "after": json.dumps(row["after"]), + }, + ) + await session.execute( + text( + "insert into checker_model_usage_recoveries(" + "id,attempt_id,checker_run_id,worker_lease_id," + "worker_lease_generation,execution_facts,effect_facts," + "authorization_evidence_id) values " + "(:id,:effect,:run,:lease,:generation,cast(:execution as jsonb)," + "cast(:effect_facts as jsonb),:authority)" + ), + { + "id": new_record_id(), + "effect": row["effect_id"], + "run": row["run"], + "lease": row["lease"], + "generation": row["generation"], + "execution": json.dumps(row["execution"]), + "effect_facts": json.dumps(row["effect"]), + "authority": evidence_id, + }, + ) + + +async def test_reservation_rollback_and_completion_settle_exactly_once( + model_database: DatabaseFacts, +) -> None: + ports = BoundaryPorts() + reservation = 100 * 11 + 50 * 29 + profile = await provision(model_database, ports, limit=reservation * 2) + run = execution(model_database) + paid_slot = slot(model_database, profile, "model_rollback") + request_sha = sha("rollback-request") + request_art = evidence("request", request_sha) + async with AsyncSession(model_database.engine, expire_on_commit=False) as session: + async with session.begin(): + prepared = await service(session, ports).prepare_attempt( + run, + paid_slot, + registry_entry_id=model_database.registry_entry_id, + request_sha256=request_sha, + ) + with pytest.raises(RuntimeError, match="force rollback"): + async with session.begin(): + await service(session, ports).reserve_dispatch(run, prepared, request_art) + raise RuntimeError("force rollback") + async with session.begin(): + dispatched = await service(session, ports).reserve_dispatch(run, prepared, request_art) + + usage = ModelCallUsage( + input_tokens=40, + cached_input_tokens=10, + output_tokens=12, + reasoning_tokens=7, + ) + response_art = evidence("response", sha("response")) + async with AsyncSession(model_database.engine, expire_on_commit=False) as session: + async with session.begin(): + owner = service(session, ports) + completed = await owner.complete( + dispatched.effect, + provider_response_id="resp_exact_once", + provider_model_snapshot=profile.spec.model_snapshot, + response_evidence=response_art, + usage=usage, + ) + replay = await owner.complete( + dispatched.effect, + provider_response_id="resp_exact_once", + provider_model_snapshot=profile.spec.model_snapshot, + response_evidence=response_art, + usage=usage, + ) + account = await session.get(ModelUsageBudgetAccount, str(model_database.project_id)) + assert account.lifetime_reserved_nanodollars == 0 + assert account.lifetime_settled_nanodollars == completed.settled_nanodollars + assert replay.replayed + + async with AsyncSession(model_database.engine, expire_on_commit=False) as session: + async with session.begin(): + await insert_checker_run(session, run) + ports.events.clear() + attached = await service(session, ports).attach_completed(run, dispatched.effect) + assert attached.replayed is False + assert ports.events == ["recover", "current"] diff --git a/backend/tests/checkers_model_calls/test_model_terminal_authority_postgresql.py b/backend/tests/checkers_model_calls/test_model_terminal_authority_postgresql.py new file mode 100644 index 000000000..4bab35838 --- /dev/null +++ b/backend/tests/checkers_model_calls/test_model_terminal_authority_postgresql.py @@ -0,0 +1,346 @@ +"""Exact terminal AUTH and settlement custody for shared model effects.""" + +import pytest +from sqlalchemy import text +from sqlalchemy.exc import DBAPIError +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.hashing import canonical_json_hash +from app.modules.model_usage.api import ModelCallUsage, model_retry_terminal_digest +from app.modules.model_usage.models import ModelUsageBudgetAccount +from tests.checkers_model_calls.model_usage_pg_support import ( + DatabaseFacts, + model_database as _model_database, +) +from tests.checkers_model_calls.model_usage_boundary_support import ( + BoundaryPorts, + DatabaseBoundaryPorts, +) +from tests.checkers_model_calls.test_model_call_transactions_postgresql import ( + evidence, + execution, + prepare_and_reserve, + provision, + service, + setup_service, + setup_slot_key, + sha, + slot, +) + +model_database = _model_database + + +@pytest.mark.parametrize( + "action", + ( + "checker.model_call.prepare", + "checker.model_call.dispatch", + "checker.model_call.observe", + ), +) +async def test_runtime_transition_rejects_foreign_authority_digest( + model_database: DatabaseFacts, + action: str, +) -> None: + """Each paid transition binds the exact private AUTH resource, not digest shape.""" + ports = BoundaryPorts() + profile = await provision(model_database, ports, limit=10_000) + run = execution(model_database) + paid_slot = slot(model_database, profile, action.rsplit(".", 1)[-1]) + request_sha = sha("authority-" + action) + request_art = evidence("authority-" + action, request_sha) + + if action == "checker.model_call.prepare": + ports.corrupt_runtime_receipt_action = action + with pytest.raises(DBAPIError, match="model usage prepare authority is invalid"): + async with AsyncSession(model_database.engine, expire_on_commit=False) as session: + async with session.begin(): + await service(session, ports).prepare_attempt( + run, + paid_slot, + registry_entry_id=model_database.registry_entry_id, + request_sha256=request_sha, + ) + return + + async with AsyncSession(model_database.engine, expire_on_commit=False) as session: + async with session.begin(): + prepared = await service(session, ports).prepare_attempt( + run, + paid_slot, + registry_entry_id=model_database.registry_entry_id, + request_sha256=request_sha, + ) + ports.corrupt_runtime_receipt_action = action + if action == "checker.model_call.dispatch": + with pytest.raises(DBAPIError, match="invalid dispatched checker model call"): + async with AsyncSession(model_database.engine, expire_on_commit=False) as session: + async with session.begin(): + await service(session, ports).reserve_dispatch(run, prepared, request_art) + return + + ports.corrupt_runtime_receipt_action = None + async with AsyncSession(model_database.engine, expire_on_commit=False) as session: + async with session.begin(): + dispatched = await service(session, ports).reserve_dispatch( + run, prepared, request_art + ) + ports.corrupt_runtime_receipt_action = action + with pytest.raises(DBAPIError, match="invalid uncertain checker model call"): + async with AsyncSession(model_database.engine, expire_on_commit=False) as session: + async with session.begin(): + await service(session, ports).mark_uncertain( + dispatched.effect, + sha("authority-observation"), + provider_response_id=None, + ) + + +@pytest.mark.parametrize( + "action", + ( + "project.guide_compilation.model.prepare", + "project.guide_compilation.model.dispatch", + "project.guide_compilation.model.observe", + ), +) +async def test_setup_transition_rejects_foreign_authority_digest( + model_database: DatabaseFacts, + action: str, +) -> None: + """Setup transitions independently bind their exact private AUTH resource.""" + ports = BoundaryPorts() + profile = await provision(model_database, ports, limit=10_000) + key = await setup_slot_key(model_database, profile) + request_sha = sha("setup-authority-" + action) + request_art = evidence("setup-authority-" + action, request_sha) + + if action == "project.guide_compilation.model.prepare": + ports.corrupt_runtime_receipt_action = action + with pytest.raises(DBAPIError, match="model usage prepare authority is invalid"): + async with AsyncSession(model_database.engine, expire_on_commit=False) as session: + async with session.begin(): + await setup_service(session, ports).prepare(key, request_sha) + return + + async with AsyncSession(model_database.engine, expire_on_commit=False) as session: + async with session.begin(): + prepared = await setup_service(session, ports).prepare(key, request_sha) + ports.corrupt_runtime_receipt_action = action + if action == "project.guide_compilation.model.dispatch": + with pytest.raises(DBAPIError, match="invalid dispatched checker model call"): + async with AsyncSession(model_database.engine, expire_on_commit=False) as session: + async with session.begin(): + await setup_service(session, ports).reserve_dispatch(prepared, request_art) + return + + ports.corrupt_runtime_receipt_action = None + async with AsyncSession(model_database.engine, expire_on_commit=False) as session: + async with session.begin(): + dispatched = await setup_service(session, ports).reserve_dispatch( + prepared, request_art + ) + ports.corrupt_runtime_receipt_action = action + with pytest.raises(DBAPIError, match="invalid uncertain checker model call"): + async with AsyncSession(model_database.engine, expire_on_commit=False) as session: + async with session.begin(): + await setup_service(session, ports).mark_uncertain( + dispatched.effect, + sha("setup-authority-observation"), + provider_response_id=None, + ) + + +async def test_known_failed_settles_once_and_allows_authorized_child( + model_database: DatabaseFacts, +) -> None: + ports = BoundaryPorts() + reservation = 100 * 11 + 50 * 29 + profile = await provision(model_database, ports, limit=reservation * 2) + _, paid_slot, prepared, dispatched, _ = await prepare_and_reserve( + model_database, ports, profile, "model_known_failure" + ) + usage = ModelCallUsage( + input_tokens=22, + cached_input_tokens=2, + output_tokens=3, + reasoning_tokens=1, + ) + response_art = evidence("failed-response", sha("failed-response")) + async with AsyncSession(model_database.engine, expire_on_commit=False) as session: + async with session.begin(): + owner = service(session, ports) + failed = await owner.complete_known_failure( + dispatched.effect, + provider_response_id="resp_terminal_failure", + provider_model_snapshot=profile.spec.model_snapshot, + response_evidence=response_art, + usage=usage, + ) + replay = await owner.complete_known_failure( + dispatched.effect, + provider_response_id="resp_terminal_failure", + provider_model_snapshot=profile.spec.model_snapshot, + response_evidence=response_art, + usage=usage, + ) + assert replay.replayed + child = await owner.prepare_recovery_child( + paid_slot, + registry_entry_id=model_database.registry_entry_id, + parent_attempt_id=prepared.attempt_id, + terminal_digest=model_retry_terminal_digest(failed), + request_sha256=prepared.request_sha256, + ) + assert child.attempt_number == 2 + account = await session.get(ModelUsageBudgetAccount, str(model_database.project_id)) + assert account.lifetime_reserved_nanodollars == 0 + assert account.lifetime_settled_nanodollars == failed.settled_nanodollars + + +@pytest.mark.parametrize("substitution", ("project", "effect", "action", "actor", "digest")) +async def test_known_failed_rejects_substituted_terminal_authority( + model_database: DatabaseFacts, + substitution: str, +) -> None: + """A shape-valid receipt cannot terminalize any other effect or service scope.""" + ports = BoundaryPorts(terminal_receipt_change=substitution) + profile = await provision(model_database, ports, limit=10_000) + _, _, _, dispatched, _ = await prepare_and_reserve( + model_database, + ports, + profile, + "model_terminal_authority_" + substitution, + ) + with pytest.raises(DBAPIError, match="invalid known-failed checker model call"): + async with AsyncSession(model_database.engine, expire_on_commit=False) as session: + async with session.begin(): + await service(session, ports).complete_known_failure( + dispatched.effect, + provider_response_id="resp_terminal_authority_" + substitution, + provider_model_snapshot=profile.spec.model_snapshot, + response_evidence=evidence( + "terminal-authority-" + substitution, + sha("terminal-authority-" + substitution), + ), + usage=ModelCallUsage( + input_tokens=22, + cached_input_tokens=2, + output_tokens=3, + reasoning_tokens=1, + ), + ) + + +@pytest.mark.parametrize("substitution", ("project", "effect", "action", "actor", "digest")) +async def test_setup_known_failure_rejects_substituted_terminal_authority( + model_database: DatabaseFacts, + substitution: str, +) -> None: + """Setup terminal custody rejects every crossed project, effect or service receipt.""" + ports = BoundaryPorts(terminal_receipt_change=substitution) + profile = await provision(model_database, ports, limit=10_000) + key = await setup_slot_key(model_database, profile) + request_sha = sha("setup-terminal-authority-" + substitution) + async with AsyncSession(model_database.engine, expire_on_commit=False) as session: + async with session.begin(): + owner = setup_service(session, ports) + prepared = await owner.prepare(key, request_sha) + dispatched = await owner.reserve_dispatch( + prepared, + evidence("setup-terminal-request-" + substitution, request_sha), + ) + with pytest.raises(DBAPIError, match="invalid known-failed checker model call"): + async with AsyncSession(model_database.engine, expire_on_commit=False) as session: + async with session.begin(): + await setup_service(session, ports).complete( + dispatched.effect, + provider_response_id="resp_setup_terminal_" + substitution, + provider_model_snapshot=profile.spec.model_snapshot, + response_evidence=evidence( + "setup-terminal-response-" + substitution, + sha("setup-terminal-response-" + substitution), + ), + usage=ModelCallUsage( + input_tokens=22, + cached_input_tokens=2, + output_tokens=3, + reasoning_tokens=1, + ), + usable=False, + ) + + +async def test_known_failed_rejects_distinct_settlement_authority( + model_database: DatabaseFacts, +) -> None: + """Settlement must use the exact terminal decision, not a second allowed receipt.""" + ports = BoundaryPorts() + profile = await provision(model_database, ports, limit=10_000) + _, _, _, dispatched, _ = await prepare_and_reserve( + model_database, + ports, + profile, + "model_terminal_settlement_authority", + ) + response = evidence("settlement-authority", sha("settlement-authority")) + usage = ModelCallUsage( + input_tokens=22, + cached_input_tokens=2, + output_tokens=3, + reasoning_tokens=1, + ) + terminal_digest = canonical_json_hash( + { + "status": "known_failed", + "provider_response_id": "resp_settlement_authority", + "provider_model_snapshot": profile.spec.model_snapshot, + "response_evidence": response.model_dump(mode="json"), + "usage": usage.model_dump(mode="json"), + } + ) + async with AsyncSession(model_database.engine, expire_on_commit=False) as session: + boundaries = DatabaseBoundaryPorts(session, ports) + with pytest.raises(DBAPIError, match="invalid known-failed checker model call"): + async with session.begin(): + terminal = await boundaries.authorize_complete( + dispatched.effect, response, terminal_digest + ) + settlement = await boundaries.authorize_complete( + dispatched.effect, response, terminal_digest + ) + await boundaries.require_response(dispatched.effect.attempt_id, response) + await session.execute( + text( + "update model_usage_effects set state='known_failed'," + "provider_response_id='resp_settlement_authority'," + "response_binding_id=:binding,response_content_id=:content," + "response_put_attempt_id=:put," + "response_verification_receipt_id=:receipt," + "response_content_sha256=:sha,response_byte_count=:bytes," + "input_tokens=:input,cached_input_tokens=:cached," + "output_tokens=:output,reasoning_tokens=:reasoning," + "settled_nanodollars=:cost," + "terminal_authorization_evidence_id=:terminal," + "settlement_authorization_evidence_id=:settlement," + "terminal_at=clock_timestamp(),settled_at=clock_timestamp() " + "where id=:effect" + ), + { + "binding": response.binding_id, + "content": response.content_id, + "put": response.put_attempt_id, + "receipt": response.verification_receipt_id, + "sha": response.content_sha256, + "bytes": response.byte_count, + "input": usage.input_tokens, + "cached": usage.cached_input_tokens, + "output": usage.output_tokens, + "reasoning": usage.reasoning_tokens, + "cost": 311, + "terminal": terminal.evidence_id, + "settlement": settlement.evidence_id, + "effect": dispatched.effect.attempt_id, + }, + ) diff --git a/backend/tests/checkers_model_calls/test_model_usage_inspection.py b/backend/tests/checkers_model_calls/test_model_usage_inspection.py new file mode 100644 index 000000000..76401c32c --- /dev/null +++ b/backend/tests/checkers_model_calls/test_model_usage_inspection.py @@ -0,0 +1,160 @@ +"""Redacted exact-effect inspection without provider or transcript disclosure.""" + +from datetime import UTC, datetime +from types import SimpleNamespace + +import pytest + +from app.core.hashing import canonical_json_hash +from app.core.identifiers import new_record_id +from app.modules.model_usage.api import ( + ModelActionEvidence, + ModelCallUnavailable, + ModelUsageInspectionFacts, + model_action_digest, +) +from app.modules.model_usage.inspection import ModelUsageInspectionService +from app.modules.model_usage.models import CheckerModelUsageSlot, ModelUsageEffect + + +def sha(label: str) -> str: + return canonical_json_hash({"label": label}) + + +class _Transaction: + is_active = True + + +class _Session: + def __init__(self, effect, slot) -> None: + self.sync_session = self + self._effect = effect + self._slot = slot + + def get_transaction(self): + return _Transaction() + + def in_nested_transaction(self): + return False + + async def get(self, model, identity): + if model is ModelUsageEffect and identity == self._effect.id: + return self._effect + if model is CheckerModelUsageSlot and identity == self._slot.id: + return self._slot + return None + + +class _Authority: + def __init__(self) -> None: + self.facts = None + + async def authorize_usage_read(self, facts): + self.facts = facts + return ModelActionEvidence( + evidence_id=new_record_id(), + facts_digest=model_action_digest("usage_read", facts), + ) + + +def _rows(project_id): + slot = SimpleNamespace( + id=str(new_record_id()), + project_id=str(project_id), + assignment_id=str(new_record_id()), + submission_id=str(new_record_id()), + submission_version=1, + requirement_id="model_review", + registry_entry_digest=sha("registry"), + configuration_sha256=sha("configuration"), + profile_id=str(new_record_id()), + profile_version=2, + profile_digest=sha("profile"), + slot_index=1, + ) + effect = SimpleNamespace( + id=str(new_record_id()), + checker_slot_id=slot.id, + setup_slot_id=None, + attempt_number=1, + state="uncertain", + provider_response_id="provider-secret-coordinate", + request_binding_id=None, + request_content_id=None, + request_put_attempt_id=None, + request_verification_receipt_id=None, + request_content_sha256=None, + request_byte_count=None, + response_binding_id=None, + response_content_id=None, + response_put_attempt_id=None, + response_verification_receipt_id=None, + response_content_sha256=None, + response_byte_count=None, + input_tokens=None, + cached_input_tokens=None, + output_tokens=None, + reasoning_tokens=None, + budget_configuration_id=str(new_record_id()), + budget_configuration_version=3, + budget_configuration_digest=sha("budget"), + reserved_nanodollars=42, + settled_nanodollars=None, + uncertainty_digest=sha("uncertain"), + prepared_at=datetime.now(UTC), + dispatched_at=datetime.now(UTC), + terminal_at=None, + ) + return effect, slot + + +@pytest.mark.asyncio +async def test_inspection_authorizes_first_and_redacts_provider_coordinate() -> None: + project_id = new_record_id() + effect, slot = _rows(project_id) + authority = _Authority() + facts = ModelUsageInspectionFacts( + project_id=project_id, + effect_id=effect.id, + actor_profile_id=new_record_id(), + request_id=new_record_id(), + ) + + result = await ModelUsageInspectionService(_Session(effect, slot), authority).inspect_effect( + facts + ) + + assert authority.facts == facts + assert result.provider_response_known is True + assert result.model_dump(mode="json").get("provider_response_id") is None + assert result.owner.project_id == project_id + assert result.owner.assignment_id.hex == slot.assignment_id.replace("-", "") + assert result.owner.slot_index == 1 + assert result.reserved_nanodollars == 42 + + +@pytest.mark.asyncio +async def test_inspection_conceals_wrong_project_and_missing_effect() -> None: + project_id = new_record_id() + effect, slot = _rows(project_id) + authority = _Authority() + service = ModelUsageInspectionService(_Session(effect, slot), authority) + + with pytest.raises(ModelCallUnavailable, match="inspection_unavailable"): + await service.inspect_effect( + ModelUsageInspectionFacts( + project_id=new_record_id(), + effect_id=effect.id, + actor_profile_id=new_record_id(), + request_id=new_record_id(), + ) + ) + with pytest.raises(ModelCallUnavailable, match="inspection_unavailable"): + await service.inspect_effect( + ModelUsageInspectionFacts( + project_id=project_id, + effect_id=new_record_id(), + actor_profile_id=new_record_id(), + request_id=new_record_id(), + ) + ) diff --git a/backend/tests/checkers_model_calls/test_proxy_orchestration.py b/backend/tests/checkers_model_calls/test_proxy_orchestration.py new file mode 100644 index 000000000..440ad2165 --- /dev/null +++ b/backend/tests/checkers_model_calls/test_proxy_orchestration.py @@ -0,0 +1,409 @@ +"""Cross-port call sequencing; PostgreSQL/ART custody have separate integration proof.""" + +import hashlib +import json +from contextlib import asynccontextmanager +from datetime import UTC, datetime, timedelta +from types import SimpleNamespace +from uuid import uuid4 + +import pytest + +from app.adapters.checkers.model_proxy import CheckerModelProxy +from app.core.hashing import canonical_json_hash +from app.core.identifiers import new_record_id +from app.interfaces.checker_model_provider import ( + CheckerModelCreateFailure, + CheckerModelCreateOutcome, + CheckerModelCreateResult, + CheckerModelObservation, + CheckerModelProviderStatus, + CheckerModelRecoveryOutcome, + CheckerModelRecoveryResult, + CheckerModelResponseReference, + CheckerModelUsage, +) +from app.interfaces.checker_model_proxy import ModelProxyRequest +from app.modules.model_usage.api import ( + AttachedModelCallAttempt, + CompletedModelCall, + ModelCallBudgetUnavailable, + ModelCallEvidence, + ModelCallExecutionFacts, + ModelCallUnavailable, + ModelPaidSlotKey, + ProjectModelProfile, +) +from tests.checkers_model_calls.test_model_call_contract import profile_spec, sha + + +def digest(data: bytes) -> str: + return "sha256:" + hashlib.sha256(data).hexdigest() + + +class Harness: + """Retain port-observable effects across fresh proxy instances and invocations.""" + + def __init__(self): + spec = profile_spec() + self.profile = ProjectModelProfile( + operation_id=uuid4(), + actor_profile_id=new_record_id(), + spec=spec, + profile_digest=canonical_json_hash(spec.model_dump(mode="json")), + profile_id=new_record_id(), + authorization_evidence_id=new_record_id(), + created_at=datetime.now(UTC), + ) + self.execution = ModelCallExecutionFacts( + project_id=spec.project_id, + task_id=new_record_id(), + assignment_id=new_record_id(), + submission_id=new_record_id(), + submission_version=1, + evaluation_request_id=new_record_id(), + evaluation_generation=1, + checker_run_id=new_record_id(), + worker_lease_id=uuid4(), + worker_lease_generation=1, + worker_lease_expires_at=datetime.now(UTC) + timedelta(minutes=5), + ) + self.slot = ModelPaidSlotKey( + project_id=spec.project_id, + assignment_id=self.execution.assignment_id, + submission_id=self.execution.submission_id, + submission_version=1, + requirement_id="quality", + registry_entry_digest=sha("registry"), + configuration_sha256=canonical_json_hash({"selector": "quality"}), + **self.profile.selector.model_dump(), + ) + self.facts = SimpleNamespace( + execution=self.execution, + slot=self.slot, + registry_entry_id=new_record_id(), + ) + self.state, self.request_sha = "prepared", None + self.slot_id, self.attempt_id, self.internal_id = (new_record_id() for _ in range(3)) + self.reference = CheckerModelResponseReference("resp_original") + self.observation = CheckerModelObservation( + reference=self.reference, + provider_model=spec.model_snapshot, + status=CheckerModelProviderStatus.COMPLETED, + output_text='{"verdict":"pass"}', + usage=CheckerModelUsage(20, 5, 10, 2, 30), + ) + self.create_result = CheckerModelCreateResult( + CheckerModelCreateOutcome.ACCEPTED, + observation=self.observation, + known_response=self.reference, + ) + self.active_transactions = 0 + self.events, self.blobs, self.evidence = [], {}, {} + self.create_count, self.recover_count, self.settle_count = 0, 0, 0 + self.budget_available = True + self.provider_id = None + self.completed = None + self.stale = False + + @asynccontextmanager + async def sessions(self): + yield self + + @asynccontextmanager + async def begin(self): + self.active_transactions += 1 + try: + yield self + finally: + self.active_transactions -= 1 + + async def scalar(self, _): + return datetime.now(UTC) + + async def authorization(self, *_): + return object() + + async def resolve(self, *_): + return self.facts + + async def read_exact(self, *_args, **_kwargs): + return self.profile + + async def prepare_attempt(self, _execution, _slot, *, request_sha256, **_): + if self.request_sha not in {None, request_sha256}: + raise ModelCallUnavailable("changed_prompt") + self.request_sha = request_sha256 + return AttachedModelCallAttempt( + slot_id=self.slot_id, + slot_key=self.slot, + attempt_id=self.attempt_id, + attempt_number=1, + internal_call_request_id=self.internal_id, + request_sha256=request_sha256, + state=self.state, + replayed=self.state != "prepared", + ) + + async def consume_prepared(self, *_args, **_kwargs): + self.events.append("grant_consumed") + + async def reserve_dispatch(self, _execution, _prepared, evidence): + if not self.budget_available: + raise ModelCallBudgetUnavailable("no_budget") + self.events.append("dispatch_committed") + self.state = "dispatching" + return SimpleNamespace(state=self.state, replayed=False) + + async def mark_uncertain(self, _effect, _digest, *, provider_response_id): + self.provider_id = provider_response_id + self.state = "uncertain" + self.events.append("reference_committed") + + async def mark_known_no_effect(self, *_): + self.state = "known_no_effect" + + async def attach_recovery(self, *_): + if self.provider_id is None: + raise ModelCallUnavailable("unknown_effect") + return SimpleNamespace(provider_response_id=self.provider_id) + + async def complete(self, _effect, *, response_evidence, usage, **_): + self.settle_count += 1 + self.state = "completed" + self.completed = CompletedModelCall( + slot_id=self.slot_id, + attempt_id=self.attempt_id, + attempt_number=1, + internal_call_request_id=self.internal_id, + provider_response_id=self.provider_id, + request_evidence=self.evidence["model_request"], + response_evidence=response_evidence, + usage=usage, + settled_nanodollars=465, + completed_at=datetime.now(UTC), + replayed=False, + ) + + async def complete_known_failure(self, *args, **kwargs): + await self.complete(*args, **kwargs) + self.state = "known_failed" + + async def attach_completed(self, *_): + if self.stale: + raise ModelCallUnavailable("stale_invocation") + return self.completed + + @asynccontextmanager + async def provider(self, _): + yield self + + async def create(self, _): + assert self.active_transactions == 0 + assert "dispatch_committed" in self.events + self.create_count += 1 + return self.create_result + + async def recover(self, reference): + assert self.active_transactions == 0 + assert reference == self.reference + self.recover_count += 1 + return CheckerModelRecoveryResult( + CheckerModelRecoveryOutcome.OBSERVED, + self.reference, + self.observation, + ) + + async def store(self, request): + assert self.active_transactions == 0 + data = b"".join([chunk async for chunk in request.byte_source]) + assert digest(data) == request.selector.content_sha256 + role = request.selector.slot_key + if role == "model_response": + assert self.provider_id is not None + self.blobs[role] = data + evidence = self.evidence.setdefault( + role, + ModelCallEvidence( + binding_id=new_record_id(), + content_id=new_record_id(), + put_attempt_id=new_record_id(), + verification_receipt_id=new_record_id(), + content_sha256=digest(data), + byte_count=len(data), + ), + ) + return SimpleNamespace(status="verified", **evidence.model_dump()) + + async def bind_checker_output(self, request): + return self.evidence[request.selector.slot_key] + + async def read_response(self, _selector, evidence): + assert evidence == self.evidence["model_response"] + return self.blobs["model_response"] + + def proxy(self): + return CheckerModelProxy( + sessions=self.sessions, + authorization=self.authorization, + grants=lambda *_: self, + calls=lambda *_: self, + profiles=lambda *_: self, + outputs=self, + bindings=lambda *_: self, + provider=self.provider, + ) + + async def invoke(self, prompt="Evaluate this work"): + return await self.proxy().handle( + ModelProxyRequest( + grant_id="model_" + uuid4().hex, + request_digest=canonical_json_hash({"request": 1}), + call_index=0, + prompt=prompt, + ) + ) + + +async def test_completed_effect_is_reused_across_proxy_restart(): + h = Harness() + first = await h.invoke() + second = await h.invoke() + assert first == second + assert first.outcome == "completed" + assert (h.create_count, h.recover_count, h.settle_count) == (1, 0, 1) + assert json.loads(h.blobs["model_request"])["prompt"] == "Evaluate this work" + + +async def test_invalid_prompt_cannot_consume_grant_store_bytes_or_reserve_spend(): + h = Harness() + with pytest.raises(ValueError, match="model proxy request is invalid"): + await h.invoke("") + assert h.state == "prepared" + assert h.request_sha is None + assert h.events == [] + assert h.blobs == {} + assert h.create_count == 0 + + +async def test_crash_before_provider_identity_commit_retains_one_paid_effect(): + h = Harness() + + async def crash_before_reference(*_args, **_kwargs): + raise RuntimeError("controlled process loss before provider identity commit") + + h.mark_uncertain = crash_before_reference + with pytest.raises(RuntimeError, match="controlled process loss"): + await h.invoke() + assert h.state == "dispatching" + assert h.provider_id is None + assert h.create_count == 1 + assert (await h.invoke()).outcome == "unavailable" + assert (h.create_count, h.recover_count, h.settle_count) == (1, 0, 0) + + +async def test_lease_loss_during_cached_art_read_denies_output_but_preserves_effect(): + h = Harness() + original = await h.invoke() + read_response = h.read_response + + async def lose_lease_while_reading(*args): + data = await read_response(*args) + h.stale = True + return data + + h.read_response = lose_lease_while_reading + assert (await h.invoke()).outcome == "unavailable" + assert (h.create_count, h.settle_count) == (1, 1) + h.read_response, h.stale = read_response, False + assert await h.invoke() == original + assert (h.create_count, h.settle_count) == (1, 1) + + +@pytest.mark.parametrize("known_id", [True, False]) +async def test_lost_create_response_never_creates_again(known_id): + h = Harness() + h.create_result = CheckerModelCreateResult( + CheckerModelCreateOutcome.UNCERTAIN, + known_response=h.reference if known_id else None, + failure=CheckerModelCreateFailure.DELIVERY_UNCERTAIN, + ) + assert (await h.invoke()).outcome == "unavailable" + assert h.state == "uncertain" + retried = await h.invoke() + assert retried.outcome == ("completed" if known_id else "unavailable") + assert h.create_count == 1 + assert h.recover_count == int(known_id) + assert h.settle_count == int(known_id) + + +async def test_budget_denial_never_reaches_provider(): + h = Harness() + h.budget_available = False + assert (await h.invoke()).outcome == "unavailable" + assert h.create_count == h.recover_count == h.settle_count == 0 + assert h.state == "prepared" + + +async def test_changed_prompt_is_not_a_new_paid_call(): + h = Harness() + assert (await h.invoke()).outcome == "completed" + assert (await h.invoke("Different work")).outcome == "unavailable" + assert h.create_count == h.settle_count == 1 + + +async def test_missing_usage_retains_uncertain_effect_for_later_recovery(): + h = Harness() + h.create_result = CheckerModelCreateResult( + CheckerModelCreateOutcome.ACCEPTED, + known_response=h.reference, + observation=CheckerModelObservation( + reference=h.reference, + provider_model=h.profile.spec.model_snapshot, + status=CheckerModelProviderStatus.FAILED, + ), + ) + assert (await h.invoke()).outcome == "unavailable" + assert h.state == "uncertain" and h.settle_count == 0 + assert "model_response" not in h.blobs + assert (await h.invoke()).outcome == "completed" + assert h.create_count == h.settle_count == 1 + + +@pytest.mark.parametrize("extra, expected", [("", "completed"), ("a", "unavailable")]) +async def test_output_byte_bound_settles_incurred_multibyte_response(extra, expected): + h = Harness() + output = "£" * (h.profile.spec.maximum_output_bytes // 2) + extra + h.observation = CheckerModelObservation( + reference=h.reference, + provider_model=h.profile.spec.model_snapshot, + status=CheckerModelProviderStatus.COMPLETED, + output_text=output, + usage=CheckerModelUsage(20, 5, 10, 2, 30), + ) + h.create_result = CheckerModelCreateResult( + CheckerModelCreateOutcome.ACCEPTED, + observation=h.observation, + known_response=h.reference, + ) + + reply = await h.invoke() + + assert reply.outcome == expected + assert h.create_count == h.settle_count == 1 + assert h.state == ("known_failed" if extra else "completed") + assert "model_response" in h.blobs + if extra: + assert reply.failure_code == "model_response_too_large" + assert (await h.invoke()).outcome == "unavailable" + assert h.create_count == h.settle_count == 1 + + +async def test_stale_invocation_cannot_read_a_completed_effect(): + h = Harness() + h.stale = True + assert (await h.invoke()).outcome == "unavailable" + assert h.state == "completed" and h.settle_count == 1 + h.stale = False + assert (await h.invoke()).outcome == "completed" + assert h.create_count == 1 diff --git a/backend/tests/checkers_model_calls/test_public_api.py b/backend/tests/checkers_model_calls/test_public_api.py new file mode 100644 index 000000000..e548bd1ab --- /dev/null +++ b/backend/tests/checkers_model_calls/test_public_api.py @@ -0,0 +1,415 @@ +"""Public Operator contract for checker model profile, budget and recovery controls.""" + +from datetime import UTC, datetime, timedelta +from uuid import UUID, uuid4 + +import pytest +from sqlalchemy import text + +from app.db import session as db_session +from app.api.deps.checker_models import CheckerModelAdminCommands +from app.core.hashing import canonical_json_hash +from app.core.identifiers import new_record_id +from app.main import create_app +from app.modules.model_usage.api import ( + ModelCallExecutionFacts, + ModelCallConflict, + ModelGenerationSettings, + ModelPaidSlotKey, + ModelPricing, + ModelRecoveryChildRequest, + ProjectModelBudgetConfigurationSpec, + ProjectModelProfileSpec, +) +from tests.authorization.admin_access.support import create_project +from tests.checkers.external.support import post_registry_spec + + +PROFILE_PATH = "/api/v1/operations/projects/{project_id}/model-profiles" +BUDGET_PATH = "/api/v1/operations/projects/{project_id}/model-budget-configurations" + + +def sha(label: str) -> str: + return canonical_json_hash({"label": label}) + + +def profile_spec(project_id): + return ProjectModelProfileSpec( + project_id=project_id, + profile_version=1, + provider="openai_responses", + api="responses.v1", + model_snapshot="gpt-example-2026-10-01", + settings=ModelGenerationSettings(reasoning_effort="medium", text_verbosity="low"), + maximum_request_bytes=10_000, + maximum_output_bytes=4_000, + maximum_response_bytes=30_000, + maximum_input_tokens=100, + maximum_output_tokens=50, + maximum_calls=1, + maximum_total_request_bytes=10_000, + maximum_total_response_bytes=30_000, + maximum_total_input_tokens=100, + maximum_total_output_tokens=50, + allowed_tools=(), + maximum_tool_calls=0, + maximum_tool_output_bytes=0, + deadline_ms=30_000, + pricing=ModelPricing( + pricing_policy_id=new_record_id(), + pricing_policy_version=1, + pricing_policy_digest=sha("pricing"), + uncached_input_nanodollars_per_token=11, + cached_input_nanodollars_per_token=2, + output_nanodollars_per_token=29, + ), + ) + + +def test_openapi_exposes_only_exact_operator_model_mutations() -> None: + paths = create_app().openapi()["paths"] + expected = { + "/api/v1/operations/projects/{project_id}/model-profiles": ( + "project.model_profile.provision", + "ProjectModelProfileSpec", + ), + "/api/v1/operations/projects/{project_id}/model-budget-configurations": ( + "project.model_budget.configure", + "ProjectModelBudgetConfigurationSpec", + ), + "/api/v1/operations/projects/{project_id}/model-call-attempts/" + "{parent_attempt_id}/recovery-children": ( + "project.model_call.recovery_child.authorize", + "ModelRecoveryChildRequest", + ), + } + for path, (action, schema_name) in expected.items(): + operation = paths[path]["post"] + assert operation["x-workstream-action-id"] == action + assert operation["requestBody"]["content"]["application/json"]["schema"] == { + "$ref": f"#/components/schemas/{schema_name}" + } + keys = [item for item in operation["parameters"] if item["name"] == "Idempotency-Key"] + if schema_name == "ModelRecoveryChildRequest": + assert keys == [] + else: + assert len(keys) == 1 + assert keys[0]["required"] is True + + read = paths["/api/v1/operations/projects/{project_id}/model-usage/effects/{effect_id}"]["get"] + assert read["x-workstream-action-id"] == "project.model_usage.read" + assert "requestBody" not in read + assert read["responses"]["200"]["content"]["application/json"]["schema"] == { + "$ref": "#/components/schemas/ModelUsageEffectInspection" + } + + +@pytest.mark.asyncio +async def test_public_commands_bind_actor_key_project_and_exact_recovery_facts() -> None: + class Service: + calls = [] + + async def provision_profile(self, facts): + self.calls.append(("profile", facts)) + return facts + + async def provision_budget(self, facts): + self.calls.append(("budget", facts)) + return facts + + async def prepare_recovery_child(self, slot, **values): + self.calls.append(("child", slot, values)) + return values + + project_id = new_record_id() + actor_id = new_record_id() + operation_id = new_record_id() + service = Service() + commands = CheckerModelAdminCommands(service, actor_id, operation_id) + profile = profile_spec(project_id) + await commands.provision_profile(project_id, profile) + budget = ProjectModelBudgetConfigurationSpec( + project_id=project_id, + configuration_version=1, + authorized_total_nanodollars=10_000_000_000, + externally_reconciled_prior_spend_nanodollars=0, + lifetime_limit_nanodollars=10_000_000_000, + ) + await commands.configure_budget(project_id, budget) + + execution = ModelCallExecutionFacts( + project_id=project_id, + task_id=new_record_id(), + assignment_id=new_record_id(), + submission_id=new_record_id(), + submission_version=1, + evaluation_request_id=new_record_id(), + evaluation_generation=1, + checker_run_id=new_record_id(), + worker_lease_id=new_record_id(), + worker_lease_generation=1, + worker_lease_expires_at=datetime.now(UTC) + timedelta(minutes=5), + ) + slot = ModelPaidSlotKey( + project_id=project_id, + assignment_id=execution.assignment_id, + submission_id=execution.submission_id, + submission_version=1, + requirement_id="model_review", + registry_entry_digest=sha("registry"), + configuration_sha256=sha("configuration"), + profile_id=new_record_id(), + profile_version=1, + profile_digest=sha("profile"), + ) + registry_entry_id = new_record_id() + request = ModelRecoveryChildRequest( + slot=slot, + registry_entry_id=registry_entry_id, + terminal_digest=sha("terminal"), + request_sha256=sha("request"), + ) + parent_id = new_record_id() + await commands.prepare_recovery_child(project_id, parent_id, request) + + profile_facts = service.calls[0][1] + assert profile_facts.operation_id == operation_id + assert profile_facts.actor_profile_id == actor_id + assert profile_facts.spec == profile + assert profile_facts.profile_digest == canonical_json_hash(profile.model_dump(mode="json")) + budget_facts = service.calls[1][1] + assert budget_facts.operation_id == operation_id + assert budget_facts.actor_profile_id == actor_id + assert budget_facts.spec == budget + assert service.calls[2] == ( + "child", + slot, + { + "registry_entry_id": registry_entry_id, + "parent_attempt_id": parent_id, + "terminal_digest": request.terminal_digest, + "request_sha256": request.request_sha256, + }, + ) + + with pytest.raises(ModelCallConflict, match="project_conflict"): + await commands.provision_profile(new_record_id(), profile) + + +async def _model_control_counts() -> dict[str, int]: + async with db_session.get_session_factory()() as session: + rows = {} + for name in ( + "model_usage_profiles", + "model_usage_budget_configurations", + "model_usage_budget_accounts", + "model_usage_effects", + ): + rows[name] = int(await session.scalar(text(f"select count(*) from {name}")) or 0) + events = ( + await session.execute( + text( + "select action_id,after_facts->>'allowed' as allowed,count(*) as count " + "from audit_events where action_id in " + "('project.model_profile.provision','project.model_budget.configure'," + "'project.model_call.recovery_child.authorize') " + "group by action_id,after_facts->>'allowed'" + ) + ) + ).mappings() + for event in events: + rows[f"{event['action_id']}:{event['allowed']}"] = int(event["count"]) + return rows + + +@pytest.mark.asyncio +async def test_signed_operator_profile_budget_replay_and_fresh_denial( + admin_access, +) -> None: + """Replay retains one row, while revoked authority still denies the same key.""" + grant_id = await admin_access.signed.grant( + admin_access.admin, admin_access.target, role="operator" + ) + registry_key = uuid4() + registered = await admin_access.signed.client.post( + "/api/v1/operations/external-checker-registry", + headers=admin_access.target.headers | {"Idempotency-Key": str(registry_key)}, + json=post_registry_spec().model_dump(mode="json"), + ) + assert registered.status_code == 201, registered.text + entry = registered.json() + project_id = await create_project("Model control replay") + + profile_key = uuid4() + profile = profile_spec(project_id) + profile_headers = admin_access.target.headers | {"Idempotency-Key": str(profile_key)} + profile_path = PROFILE_PATH.format(project_id=project_id) + malformed_profile = profile.model_dump(mode="json") + malformed_profile["registry_entry_id"] = "profiles-are-not-registry-owned" + malformed = await admin_access.signed.client.post( + profile_path, + headers=admin_access.target.headers, + json=malformed_profile, + ) + assert malformed.status_code == 422, malformed.text + first_profile = await admin_access.signed.client.post( + profile_path, + headers=profile_headers, + json=profile.model_dump(mode="json"), + ) + assert first_profile.status_code == 201, first_profile.text + replayed_profile = await admin_access.signed.client.post( + profile_path, + headers=profile_headers, + json=profile.model_dump(mode="json"), + ) + assert replayed_profile.status_code == 201, replayed_profile.text + assert replayed_profile.json() == first_profile.json() + assert first_profile.json()["operation_id"] == str(profile_key) + substituted_profile = profile.model_copy(update={"model_snapshot": "gpt-substituted"}) + substituted = await admin_access.signed.client.post( + profile_path, + headers=admin_access.target.headers | {"Idempotency-Key": str(uuid4())}, + json=substituted_profile.model_dump(mode="json"), + ) + assert substituted.status_code == 409, substituted.text + assert substituted.json()["error"]["code"] == "checker_model_control_conflict" + + budget_key = uuid4() + budget = ProjectModelBudgetConfigurationSpec( + project_id=project_id, + configuration_version=1, + authorized_total_nanodollars=10_000_000_000, + externally_reconciled_prior_spend_nanodollars=0, + lifetime_limit_nanodollars=10_000_000_000, + ) + budget_headers = admin_access.target.headers | {"Idempotency-Key": str(budget_key)} + budget_path = BUDGET_PATH.format(project_id=project_id) + malformed_budget = budget.model_dump(mode="json") + malformed_budget["project_id"] = "not-a-uuid" + malformed = await admin_access.signed.client.post( + budget_path, + headers=admin_access.target.headers | {"Idempotency-Key": str(uuid4())}, + json=malformed_budget, + ) + assert malformed.status_code == 422, malformed.text + first_budget = await admin_access.signed.client.post( + budget_path, + headers=budget_headers, + json=budget.model_dump(mode="json"), + ) + assert first_budget.status_code == 201, first_budget.text + replayed_budget = await admin_access.signed.client.post( + budget_path, + headers=budget_headers, + json=budget.model_dump(mode="json"), + ) + assert replayed_budget.status_code == 201, replayed_budget.text + assert replayed_budget.json() == first_budget.json() + assert first_budget.json()["operation_id"] == str(budget_key) + async with db_session.get_session_factory()() as session: + targets = ( + ( + await session.execute( + text( + "select action_id,project_id,resource_type,resource_id," + "target_ref_kind,target_ref_id from audit_events " + "where id in (:profile,:budget) order by action_id" + ), + { + "profile": first_profile.json()["authorization_evidence_id"], + "budget": first_budget.json()["authorization_evidence_id"], + }, + ) + ) + .mappings() + .all() + ) + assert len(targets) == 2 + assert all( + ( + str(row["project_id"]), + row["resource_type"], + row["resource_id"], + row["target_ref_kind"], + row["target_ref_id"], + ) + == (str(project_id), "project", str(project_id), "project", str(project_id)) + for row in targets + ) + + revoked = await admin_access.signed.revoke(admin_access.admin, grant_id) + assert revoked.status_code == 200, revoked.text + for path, headers, payload in ( + (profile_path, profile_headers, profile.model_dump(mode="json")), + (budget_path, budget_headers, budget.model_dump(mode="json")), + ): + denied = await admin_access.signed.client.post( + path, + headers=headers, + json=payload, + ) + assert denied.status_code == 404, denied.text + assert denied.json()["error"]["code"] == "checker_model_control_not_found" + + selector = first_profile.json() + execution = ModelCallExecutionFacts( + project_id=project_id, + task_id=new_record_id(), + assignment_id=new_record_id(), + submission_id=new_record_id(), + submission_version=1, + evaluation_request_id=new_record_id(), + evaluation_generation=1, + checker_run_id=new_record_id(), + worker_lease_id=new_record_id(), + worker_lease_generation=1, + worker_lease_expires_at=datetime.now(UTC) + timedelta(minutes=5), + ) + slot = ModelPaidSlotKey( + project_id=project_id, + assignment_id=execution.assignment_id, + submission_id=execution.submission_id, + submission_version=1, + requirement_id="model_review", + registry_entry_digest=entry["entry_digest"], + configuration_sha256=sha("configuration"), + profile_id=UUID(selector["profile_id"]), + profile_version=selector["spec"]["profile_version"], + profile_digest=selector["profile_digest"], + ) + recovery = ModelRecoveryChildRequest( + slot=slot, + registry_entry_id=UUID(entry["registry_entry_id"]), + terminal_digest=sha("terminal"), + request_sha256=sha("request"), + ) + malformed_recovery = recovery.model_dump(mode="json") + malformed_recovery["slot"]["profile_id"] = "not-a-uuid" + malformed = await admin_access.signed.client.post( + "/api/v1/operations/projects/" + f"{project_id}/model-call-attempts/{new_record_id()}/recovery-children", + headers=admin_access.target.headers, + json=malformed_recovery, + ) + assert malformed.status_code == 422, malformed.text + denied_recovery = await admin_access.signed.client.post( + "/api/v1/operations/projects/" + f"{project_id}/model-call-attempts/{new_record_id()}/recovery-children", + headers=admin_access.target.headers, + json=recovery.model_dump(mode="json"), + ) + assert denied_recovery.status_code == 404, denied_recovery.text + assert denied_recovery.json()["error"]["code"] == "checker_model_control_not_found" + + assert await _model_control_counts() == { + "model_usage_profiles": 1, + "model_usage_budget_configurations": 1, + "model_usage_budget_accounts": 1, + "model_usage_effects": 0, + "project.model_profile.provision:true": 2, + "project.model_profile.provision:false": 1, + "project.model_budget.configure:true": 2, + "project.model_budget.configure:false": 1, + "project.model_call.recovery_child.authorize:false": 1, + } diff --git a/backend/tests/checkers_model_calls/test_setup_model_handler.py b/backend/tests/checkers_model_calls/test_setup_model_handler.py new file mode 100644 index 000000000..b4e68485a --- /dev/null +++ b/backend/tests/checkers_model_calls/test_setup_model_handler.py @@ -0,0 +1,563 @@ +"""Closed provider-shape and metadata checks before setup model spend.""" + +import json +from contextlib import asynccontextmanager +from datetime import UTC, datetime +from types import SimpleNamespace + +import pytest + +from app.adapters.project_agents.openrouter_policy import openrouter_request_options +from app.adapters.project_agents.setup_model_handler import ( + OpenAISetupProvider, + OpenRouterSetupProvider, + SetupModelProxyHandler, + SetupProviderResponse, + _bounded_response_bytes, + _completed_reply, + _validate_request, +) +from app.core.hashing import canonical_json_hash +from app.core.identifiers import new_record_id +from app.interfaces.setup_model_proxy import SetupModelProxyRequest, SetupModelProxySubject +from app.modules.model_usage.api import ( + ModelCallUnavailable, + ModelCallUsage, + ObservedSetupModelUsageEffect, + ProjectModelProfile, + SetupModelEffectAuthorityFacts, + SetupModelUsageSlotKey, +) +from tests.checkers_model_calls.test_model_call_contract import profile_spec + + +def _profile(*, provider="openrouter", api="chat_completions", model="openai/model"): + source = profile_spec().model_dump(mode="python") + source.update( + provider=provider, + api=api, + model_snapshot=model, + maximum_input_tokens=1_050_000, + maximum_output_tokens=128_000, + maximum_calls=1, + maximum_total_input_tokens=1_050_000, + maximum_total_output_tokens=128_000, + allowed_tools=("read_document", "search_document"), + maximum_tool_calls=10, + maximum_tool_output_bytes=100_000, + ) + source["pricing"].update( + uncached_input_nanodollars_per_token=5_000, + cached_input_nanodollars_per_token=400, + output_nanodollars_per_token=18_000, + ) + spec = type(profile_spec()).model_validate(source) + return ProjectModelProfile( + operation_id=new_record_id(), + actor_profile_id=new_record_id(), + spec=spec, + profile_digest=canonical_json_hash(spec.model_dump(mode="json")), + profile_id=new_record_id(), + authorization_evidence_id=new_record_id(), + created_at=datetime.now(UTC), + ) + + +def _metadata(): + return { + "data": { + "id": "openai/model", + "canonical_slug": "openai/model-20261023", + "context_length": 1_050_000, + "top_provider": { + "context_length": 1_050_000, + "max_completion_tokens": 128_000, + }, + "supported_parameters": [ + "max_tokens", + "reasoning_effort", + "tool_choice", + "tools", + "verbosity", + ], + "pricing": { + "prompt": "0.000002", + "completion": "0.000012", + "input_cache_read": "0.0000002", + "input_cache_write": "0.0000025", + "overrides": [ + { + "min_prompt_tokens": 272_000, + "prompt": "0.000004", + "completion": "0.000018", + "input_cache_read": "0.0000004", + "input_cache_write": "0.000005", + } + ], + }, + } + } + + +class _MetadataClient: + def __init__(self, payload): + self.payload = payload + self.gets = [] + + @asynccontextmanager + async def stream(self, method, url, **_kwargs): + assert method == "GET" + self.gets.append(url) + body = json.dumps(self.payload).encode() + + class Response: + status_code = 200 + + async def aiter_bytes(self): + yield body + + yield Response() + + async def aclose(self): + return None + + +async def test_openrouter_reserves_full_profile_only_after_current_metadata(monkeypatch): + import httpx + + client = _MetadataClient(_metadata()) + monkeypatch.setattr(httpx, "AsyncClient", lambda **_kwargs: client) + provider = OpenRouterSetupProvider(api_key="private", profile=_profile()) + + assert await provider.count_input({"messages": []}) == 1_050_000 + assert client.gets == ["https://openrouter.ai/api/v1/model/openai/model"] + + +async def test_provider_body_reader_stops_before_accumulating_over_profile(): + class Response: + async def aiter_bytes(self): + yield b"a" * 8 + yield b"b" + + with pytest.raises(ValueError, match="exceeds profile"): + await _bounded_response_bytes(Response(), 8) + + +async def test_openai_count_includes_every_semantic_input_field(): + recorded = [] + + class Counter: + async def count(self, **kwargs): + recorded.append(kwargs) + return SimpleNamespace(input_tokens=73) + + provider = object.__new__(OpenAISetupProvider) + provider._counter = SimpleNamespace( + responses=SimpleNamespace(input_tokens=Counter()) + ) + schema = { + "type": "object", + "properties": {f"field_{index}": {"type": "string"} for index in range(200)}, + } + body = { + "model": "gpt-exact", + "input": "bounded", + "instructions": "bounded", + "parallel_tool_calls": False, + "reasoning": {"effort": "medium"}, + "text": { + "verbosity": "low", + "format": { + "type": "json_schema", + "name": "result", + "strict": True, + "schema": schema, + }, + }, + "tool_choice": "auto", + "tools": _tools(), + "truncation": "disabled", + "max_output_tokens": 128_000, + "store": False, + } + + assert await provider.count_input(body) == 73 + assert recorded == [ + { + key: value + for key, value in body.items() + if key + in { + "input", + "instructions", + "model", + "parallel_tool_calls", + "reasoning", + "text", + "tool_choice", + "tools", + "truncation", + } + } + ] + + +@pytest.mark.parametrize( + "mutation", ["context", "pricing", "request_fee", "alias", "parameters"] +) +async def test_openrouter_metadata_drift_fails_before_dispatch(monkeypatch, mutation): + import httpx + + payload = _metadata() + data = payload["data"] + if mutation == "context": + data["context_length"] = data["top_provider"]["context_length"] = 1_050_001 + elif mutation == "pricing": + data["pricing"]["overrides"][0]["completion"] = "0.000018001" + elif mutation == "request_fee": + data["pricing"]["request"] = "1" + elif mutation == "alias": + data["id"] = "openai/resolved-other-model" + else: + data["supported_parameters"].remove("tools") + client = _MetadataClient(payload) + monkeypatch.setattr(httpx, "AsyncClient", lambda **_kwargs: client) + provider = OpenRouterSetupProvider(api_key="private", profile=_profile()) + + with pytest.raises(ModelCallUnavailable): + await provider.count_input({"messages": []}) + + +def _subject(profile): + return SetupModelProxySubject( + compilation_attempt_id=new_record_id(), + project_id=profile.spec.project_id, + guide_id=new_record_id(), + source_snapshot_id=new_record_id(), + setup_run_id=new_record_id(), + setup_generation=1, + runtime_configuration_hash=canonical_json_hash({"runtime": 1}), + profile_id=profile.profile_id, + profile_version=profile.spec.profile_version, + profile_digest=profile.profile_digest, + provider=profile.spec.provider, + api=profile.spec.api, + model=profile.spec.model_snapshot, + ) + + +def _tools(): + return [ + {"type": "function", "function": {"name": "read_document"}}, + {"type": "function", "function": {"name": "search_document"}}, + ] + + +@pytest.mark.parametrize("extra", [{"prompt": {"id": "remote"}}, {"service_tier": "flex"}]) +def test_openai_remote_prompt_and_unpriced_tier_are_rejected(extra): + profile = _profile(provider="openai", api="responses", model="gpt-exact") + body = { + "model": "gpt-exact", + "input": "bounded", + "instructions": "bounded", + "max_output_tokens": 128_000, + "parallel_tool_calls": False, + "store": False, + "stream": False, + "truncation": "disabled", + "reasoning": {"effort": "medium"}, + "text": { + "verbosity": "low", + "format": {"type": "json_schema", "name": "result", "strict": True, "schema": {}}, + }, + "tools": _tools(), + **extra, + } + request = SetupModelProxyRequest( + subject=_subject(profile), + phase="draft", + call_index=0, + provider_request_json=json.dumps(body, sort_keys=True, separators=(",", ":")), + ) + + with pytest.raises(ModelCallUnavailable): + _validate_request(profile, request, body) + + +def test_openrouter_requires_locked_settings_and_routing_controls(): + profile = _profile() + body = { + "model": "openai/model", + "messages": [], + "max_tokens": 128_000, + "stream": False, + "tools": _tools(), + "usage": {"include": True}, + "reasoning_effort": "high", + "verbosity": "low", + **openrouter_request_options(), + } + request = SetupModelProxyRequest( + subject=_subject(profile), + phase="draft", + call_index=0, + provider_request_json=json.dumps(body, sort_keys=True, separators=(",", ":")), + ) + + with pytest.raises(ModelCallUnavailable): + _validate_request(profile, request, body) + + +def test_paid_quote_heavy_response_becomes_bounded_unavailable_reply(): + profile = _profile() + body = { + "model": "openai/model", + "messages": [], + "max_tokens": 128_000, + "stream": False, + "tools": _tools(), + "usage": {"include": True}, + "reasoning_effort": "medium", + "verbosity": "low", + **openrouter_request_options(), + } + request = SetupModelProxyRequest( + subject=_subject(profile), + phase="draft", + call_index=0, + provider_request_json=json.dumps(body, sort_keys=True, separators=(",", ":")), + ) + response_json = json.dumps( + {"output": '"' * 500_000}, sort_keys=True, separators=(",", ":") + ) + assert len(response_json.encode()) < 1024 * 1024 + + reply = _completed_reply( + request, + profile, + status_code=200, + response_json=response_json, + ) + + assert reply.outcome == "unavailable" + assert reply.failure_code == "model_response_too_large" + + +async def test_handler_persists_observation_before_response_art_and_replays_without_send(): + profile = _profile() + body = { + "model": "openai/model", + "messages": [], + "max_tokens": 128_000, + "stream": False, + "tools": _tools(), + "usage": {"include": True}, + "reasoning_effort": "medium", + "verbosity": "low", + **openrouter_request_options(), + } + request = SetupModelProxyRequest( + subject=_subject(profile), + phase="draft", + call_index=0, + provider_request_json=json.dumps(body, sort_keys=True, separators=(",", ":")), + ) + key = SetupModelUsageSlotKey( + compilation_attempt_id=request.subject.compilation_attempt_id, + project_id=request.subject.project_id, + guide_id=request.subject.guide_id, + source_snapshot_id=request.subject.source_snapshot_id, + setup_run_id=request.subject.setup_run_id, + setup_generation=request.subject.setup_generation, + runtime_configuration_hash=request.subject.runtime_configuration_hash, + profile_id=request.subject.profile_id, + profile_version=request.subject.profile_version, + profile_digest=request.subject.profile_digest, + phase=request.phase, + call_index=request.call_index, + ) + slot_id, effect_id, internal_id = (new_record_id() for _ in range(3)) + effect = SetupModelEffectAuthorityFacts( + project_id=profile.spec.project_id, + slot_id=slot_id, + slot_key=key, + effect_id=effect_id, + internal_call_request_id=internal_id, + ) + prepared = SimpleNamespace( + slot_key=key, + slot_id=slot_id, + effect_id=effect_id, + internal_call_request_id=internal_id, + ) + dispatch = SimpleNamespace( + replayed=False, + effect=effect, + internal_call_request_id=internal_id, + ) + events = [] + stored = {} + + class Session: + @asynccontextmanager + async def begin(self): + yield self + + @asynccontextmanager + async def sessions(): + yield Session() + + class Profiles: + async def read_exact(self, *_args, **_kwargs): + return profile + + class Calls: + completed = None + observed = None + fail_completion = True + + async def recover_existing(self, *_args): + events.append("recover") + return self.completed or self.observed + + async def prepare(self, *_args): + events.append("prepare") + return prepared + + async def reserve_dispatch(self, *_args): + events.append("reserve") + return dispatch + + async def mark_uncertain(self, supplied, digest, *, provider_response_id=None): + events.append(("observe", provider_response_id)) + self.observed = ObservedSetupModelUsageEffect( + effect=supplied, + uncertainty_digest=digest, + provider_response_id=provider_response_id, + replayed=False, + ) + + async def complete(self, *_args, response_evidence, **_kwargs): + events.append("complete") + if self.fail_completion: + self.fail_completion = False + raise ModelCallUnavailable("simulated_response_commit_loss") + self.completed = SimpleNamespace( + effect=effect, + response_evidence=response_evidence, + state="completed", + ) + self.observed = None + return self.completed + + calls = Calls() + + class Outputs: + async def store_setup_output(self, artifact): + data = b"".join([chunk async for chunk in artifact.byte_source]) + events.append("store_" + artifact.selector.role) + stored[artifact.selector.role] = data + return SimpleNamespace( + status="verified", + put_attempt_id=new_record_id(), + content_id=new_record_id(), + verification_receipt_id=new_record_id(), + ) + + async def recover_setup_output(self, selector): + events.append("recover_response_art") + data = stored["model_response"] + return SimpleNamespace( + status="verified", + put_attempt_id=new_record_id(), + content_id=new_record_id(), + verification_receipt_id=new_record_id(), + content_sha256=selector.content_sha256, + byte_count=len(data), + ) + + async def read_setup_response(self, *_args): + events.append("read_response") + return stored["model_response"] + + class Bindings: + async def bind_setup_output(self, binding): + events.append("bind_" + binding.selector.role) + return SimpleNamespace( + binding_id=new_record_id(), + content_id=new_record_id(), + put_attempt_id=binding.put_attempt_id, + verification_receipt_id=binding.verification_receipt_id, + ) + + provider_calls = 0 + response_json = json.dumps( + { + "id": "response-1", + "model": "openai/model", + "choices": [{"message": {"content": "bounded"}}], + "usage": {"prompt_tokens": 1, "completion_tokens": 1}, + }, + sort_keys=True, + separators=(",", ":"), + ) + + class Provider: + async def count_input(self, _body): + return 1 + + async def dispatch(self, _body): + nonlocal provider_calls + provider_calls += 1 + events.append("provider") + return SetupProviderResponse( + status_code=200, + response_json=response_json, + provider_response_id="response-1", + provider_model="openai/model", + usage=ModelCallUsage( + input_tokens=1, + cached_input_tokens=0, + output_tokens=1, + reasoning_tokens=0, + ), + ) + + @asynccontextmanager + async def provider(_profile): + yield Provider() + + handler = SetupModelProxyHandler( + sessions=sessions, + authorization=lambda *_args: _async_value(object()), + calls=lambda *_args: calls, + profiles=lambda _session: Profiles(), + outputs=Outputs(), + bindings=lambda _session: Bindings(), + provider=provider, + ) + + assert (await handler.handle(request)).outcome == "unavailable" + first_events = list(events) + assert first_events.index(("observe", "response-1")) < first_events.index( + "store_model_response" + ) + assert first_events[-1] == "complete" + + assert (await handler.handle(request)).outcome == "completed" + assert provider_calls == 1 + assert events[-5:] == [ + "recover", + "recover_response_art", + "bind_model_response", + "read_response", + "complete", + ] + + assert (await handler.handle(request)).outcome == "completed" + assert provider_calls == 1 + assert events[-2:] == ["recover", "read_response"] + + +async def _async_value(value): + return value diff --git a/backend/tests/checkers_model_calls/test_setup_model_handler_postgresql.py b/backend/tests/checkers_model_calls/test_setup_model_handler_postgresql.py new file mode 100644 index 000000000..51eeb1334 --- /dev/null +++ b/backend/tests/checkers_model_calls/test_setup_model_handler_postgresql.py @@ -0,0 +1,887 @@ +"""Real PostgreSQL and MinIO custody for one setup-model provider call.""" + +from __future__ import annotations + +import asyncio +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager +from dataclasses import asdict, dataclass, replace +import json +import os +from types import SimpleNamespace +from uuid import UUID, uuid4 + +from asyncpg.pgproto.pgproto import UUID as AsyncpgUUID +import pytest +from sqlalchemy import text +from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine + +from tests.authentication.fixtures import ( + auth_database_env as auth_database_env, + clear_settings_cache as clear_settings_cache, + rsa_signing_material as rsa_signing_material, +) +from tests.authorization.admin_access.fixtures import ( + admin_access as admin_access, + signed_access as signed_access, +) +from tests.authorization.admin_access.support import create_project + +from app.adapters.artifacts import ( + create_artifact_store_bootstrap, + setup_model_evidence, + setup_model_output_binding, + setup_model_output_storage, +) +from app.adapters.auth import setup_model_service_authorization +from app.adapters.checkers import model_profile_reader +from app.adapters.model_usage import setup_model_usage_service +from app.adapters.project_agents.openrouter_policy import openrouter_request_options +from app.adapters.project_agents.setup_model_handler import ( + SetupModelProxyHandler, + SetupProviderResponse, +) +from app.adapters.projects import setup_model_usage_owner +from app.core.identifiers import new_record_id +from app.interfaces.artifacts import ArtifactStoreNamespaceClaim +from app.interfaces.setup_model_proxy import ( + SetupModelProxyRequest, + SetupModelProxySubject, + canonical_provider_json, +) +from app.modules.actors.api import ServiceIdentity +from app.modules.artifacts.preparation import ( + ArtifactPreparationService, + ArtifactScratchManager, +) +from app.modules.artifacts.service import artifact_storage_namespace_spec +from app.modules.authorization.api import ( + ActorIdentityFacts, + ActorKind, + ProjectGuideCompilationExecutePreflightFacts, + ProjectGuideCompilationRequestOrigin, +) +from app.modules.model_usage.api import ( + ModelCallUnavailable, + ModelCallUsage, + ModelUsageEffectInspection, + ProjectModelProfile, + ProjectModelProfileSpec, +) +from app.modules.projects.guide_compilation.orchestrator import _setup_model_subject +from app.modules.projects.guide_compilation.service import ( + load_compilation_execution_state, +) +from tests.artifact_store_helpers import ( + artifact_admission_limit_settings, + artifact_preparation_limits, +) +from tests.checkers.execution.support import provision_post_submit_service +from tests.checkers_model_calls.test_model_call_contract import profile_spec +from tests.projects.guide_compilation.helpers import ( + context, + identity, + persistence_facts, + runtime_configuration, + seed_database, + service_actor, +) +from tests.projects.guide_compilation.test_authorized_execution_service import ( + _execution_service, +) +from tests.projects.guide_compilation.test_authorized_request_service import ( + _authorized_service, + _request, + _seed_human, +) + + +def _setup_profile(project_id: UUID) -> ProjectModelProfileSpec: + source = profile_spec().model_dump(mode="python") + source.update( + project_id=project_id, + profile_version=1, + provider="openrouter", + api="chat_completions", + model_snapshot="openai/model", + maximum_input_tokens=1_050_000, + maximum_output_tokens=128_000, + maximum_calls=1, + maximum_total_input_tokens=1_050_000, + maximum_total_output_tokens=128_000, + allowed_tools=("read_document", "search_document"), + maximum_tool_calls=10, + maximum_tool_output_bytes=100_000, + ) + source["pricing"].update( + uncached_input_nanodollars_per_token=5_000, + cached_input_nanodollars_per_token=400, + output_nanodollars_per_token=18_000, + ) + return ProjectModelProfileSpec.model_validate(source) + + +def _setup_output_preparation_limits(): + return replace( + artifact_preparation_limits(), + maximum_source_bytes=64 * 1024, + ) + + +def test_setup_profile_fits_output_preparation_capacity() -> None: + profile = _setup_profile(new_record_id()) + limits = _setup_output_preparation_limits() + + assert max(profile.maximum_request_bytes, profile.maximum_response_bytes) <= ( + limits.maximum_source_bytes + ) + + +async def _publish_profile(access, project_id: UUID) -> ProjectModelProfile: + await access.signed.grant(access.admin, access.target, role="operator") + spec = _setup_profile(project_id) + response = await access.signed.client.post( + f"/api/v1/operations/projects/{project_id}/model-profiles", + headers=access.target.headers | {"Idempotency-Key": str(uuid4())}, + json=spec.model_dump(mode="json"), + ) + assert response.status_code == 201, response.text + budget = await access.signed.client.post( + f"/api/v1/operations/projects/{project_id}/model-budget-configurations", + headers=access.target.headers | {"Idempotency-Key": str(uuid4())}, + json={ + "project_id": str(project_id), + "configuration_version": 1, + "authorized_total_nanodollars": 10_000_000_000, + "externally_reconciled_prior_spend_nanodollars": 0, + "lifetime_limit_nanodollars": 10_000_000_000, + }, + ) + assert budget.status_code == 201, budget.text + return ProjectModelProfile.model_validate_json(json.dumps(response.json())) + + +def _runtime(profile: ProjectModelProfile): + return runtime_configuration().model_copy( + update={ + "model_provider": profile.spec.provider, + "model_api": profile.spec.api, + "model": profile.spec.model_snapshot, + "model_profile_id": profile.profile_id, + "model_profile_version": profile.spec.profile_version, + "model_profile_digest": profile.profile_digest, + } + ) + + +def _preflight(values, requested, configuration): + attempt_identity = identity(context(values, configuration=configuration)) + complete = replace( + persistence_facts(values, requested.attempt_id, attempt_identity), + operation_id=requested.operation_id, + ) + names = ProjectGuideCompilationExecutePreflightFacts.__dataclass_fields__ + return ProjectGuideCompilationExecutePreflightFacts( + **{name: asdict(complete)[name] for name in names} + ) + + +async def _fence_setup_attempt(database_url, values, configuration): + human, link, _grant = await _seed_human(database_url, values) + actor = ActorIdentityFacts(human, link, ActorKind.HUMAN) + engine = create_async_engine(database_url) + factory = async_sessionmaker(engine, expire_on_commit=False) + try: + async with factory() as session: + requested = await _authorized_service(session, actor).authorize_request( + origin=ProjectGuideCompilationRequestOrigin(trigger="project_manager"), + actor=actor, + facts=_request(values, configuration), + identity=identity(context(values, configuration=configuration)), + runtime_configuration=configuration, + ) + async with factory() as session: + fenced = await _execution_service(session, service_actor(values)).fence_dispatch( + actor=service_actor(values), + facts=_preflight(values, requested, configuration), + ) + assert fenced.dispatch_permitted is True + return requested + finally: + await engine.dispose() + + +def _provider_request(profile: ProjectModelProfile) -> str: + return canonical_provider_json( + { + "model": profile.spec.model_snapshot, + "messages": [], + "max_tokens": profile.spec.maximum_output_tokens, + "stream": False, + "tools": [ + {"type": "function", "function": {"name": "read_document"}}, + {"type": "function", "function": {"name": "search_document"}}, + ], + "usage": {"include": True}, + "reasoning_effort": profile.spec.settings.reasoning_effort, + "verbosity": profile.spec.settings.text_verbosity, + **openrouter_request_options(), + } + ) + + +def test_setup_subject_normalizes_database_uuid_subclasses_at_owner_projection() -> None: + """Keep the wire exact while accepting UUID values returned by asyncpg.""" + configuration_type = type(runtime_configuration()) + configuration = configuration_type.model_validate( + runtime_configuration().model_dump(mode="json") + | { + "model_profile_id": str(new_record_id()), + "model_profile_version": 1, + "model_profile_digest": "sha256:" + "1" * 64, + } + ) + database_ids = { + name: AsyncpgUUID(str(new_record_id())) + for name in ( + "attempt", + "project", + "guide", + "snapshot", + "setup", + ) + } + raw = dict( + compilation_attempt_id=database_ids["attempt"], + project_id=database_ids["project"], + guide_id=database_ids["guide"], + source_snapshot_id=database_ids["snapshot"], + setup_run_id=database_ids["setup"], + setup_generation=1, + runtime_configuration_hash=configuration.sha256, + profile_id=configuration.model_profile_id, + profile_version=configuration.model_profile_version, + profile_digest=configuration.model_profile_digest, + provider=configuration.model_provider, + api=configuration.model_api, + model=configuration.model, + ) + assert all( + isinstance(value, UUID) and type(value) is not UUID for value in database_ids.values() + ) + with pytest.raises(ValueError, match="subject is invalid"): + SetupModelProxySubject(**raw) + + state = SimpleNamespace( + preflight_facts=SimpleNamespace(attempt_id=database_ids["attempt"]), + identity=SimpleNamespace( + project_id=database_ids["project"], + guide_id=database_ids["guide"], + source_snapshot_id=database_ids["snapshot"], + setup_run_id=database_ids["setup"], + setup_generation=1, + ), + ) + subject = _setup_model_subject(state, configuration) + + assert subject is not None + assert all( + type(getattr(subject, name)) is UUID + for name in ( + "compilation_attempt_id", + "project_id", + "guide_id", + "source_snapshot_id", + "setup_run_id", + "profile_id", + ) + ) + assert str(subject.compilation_attempt_id) == str(database_ids["attempt"]) + + +@dataclass(slots=True) +class _StorageCase: + database_url: str + factory: object + engine: object + bootstrap: object + manager: ArtifactScratchManager + settings: object + namespace: object + store: object + values: dict + + +@asynccontextmanager +async def _storage_case(tmp_path, database_url, monkeypatch) -> AsyncIterator[_StorageCase]: + """Own one real MinIO namespace and its exact PostgreSQL namespace row.""" + from tests import test_s3_artifact_store + from tests.test_s3_artifact_store import minio_settings, provision_minio_bucket + + monkeypatch.setattr( + test_s3_artifact_store, + "MINIO_BUCKET", + os.environ["WORKSTREAM_TEST_MINIO_BUCKET"], + ) + await provision_minio_bucket.__wrapped__() + settings = minio_settings(private_prefix=f"setup-model/{uuid4()}").model_copy( + update={ + **artifact_admission_limit_settings(64 * 1024), + "artifact_scratch_root": tmp_path / "setup-model-scratch", + "artifact_scratch_minimum_free_bytes": 0, + } + ) + bootstrap = create_artifact_store_bootstrap(settings, activation_guard=None) + namespace = artifact_storage_namespace_spec(settings, bootstrap) + store = bootstrap.initialize_after_namespace_claim( + ArtifactStoreNamespaceClaim( + bootstrap.identity, + bootstrap.namespace_identity, + namespace.namespace_fingerprint, + ) + ) + manager = ArtifactScratchManager( + root=tmp_path / "setup-model-preparation", + limits=_setup_output_preparation_limits(), + ) + engine = create_async_engine(database_url) + factory = async_sessionmaker(engine, expire_on_commit=False) + try: + values = await seed_database( + database_url, + namespace=namespace, + provider_object_ref="fixture/guide-source-not-read", + ) + for service_identity in ( + ServiceIdentity.ARTIFACT_CHECKER_OUTPUT, + ServiceIdentity.ARTIFACT_BINDING, + ServiceIdentity.ARTIFACT_PUT_RESOLVER, + ServiceIdentity.ARTIFACT_VERIFIER, + ): + await provision_post_submit_service(factory, service_identity) + yield _StorageCase( + database_url, + factory, + engine, + bootstrap, + manager, + settings, + namespace, + store, + values, + ) + finally: + manager.close() + bootstrap.close() + await engine.dispose() + + +@dataclass(slots=True) +class _Journey: + storage: _StorageCase + profile: ProjectModelProfile + request: SetupModelProxyRequest + response_json: str + response_marker: str + provider_calls: list[str] + reserve_observations: list[tuple[str, str, str]] + stages: list[str] + failures: list[BaseException] + evidence_diagnostics: list[dict[str, object] | None] + output_diagnostics: list[dict[str, object] | None] + fail_completion: list[bool] + outputs: object + provider_entered: asyncio.Event | None = None + provider_release: asyncio.Event | None = None + + def handler(self) -> SetupModelProxyHandler: + return _ObservedHandler( + failures=self.failures, + sessions=self.storage.factory, + authorization=setup_model_service_authorization, + calls=lambda session, authority: _ObservedCalls(session, authority, self), + profiles=model_profile_reader, + outputs=self.outputs, + bindings=lambda session: _ObservedBindings( + setup_model_output_binding( + session, + namespace=self.storage.namespace, + owner=setup_model_usage_owner(session), + ), + self.stages, + ), + provider=lambda profile: _provider(self, profile), + ) + + +class _Provider: + def __init__(self, journey: _Journey) -> None: + self.journey = journey + + async def count_input(self, _body): + self.journey.stages.append("count_input") + return 1 + + async def dispatch(self, _body): + self.journey.provider_calls.append("dispatch") + assert self.journey.reserve_observations == [ + ("setup_model_call", "model_request", "verified") + ] + if self.journey.provider_entered is not None: + assert self.journey.provider_release is not None + self.journey.provider_entered.set() + await asyncio.wait_for(self.journey.provider_release.wait(), timeout=30) + return SetupProviderResponse( + status_code=200, + response_json=self.journey.response_json, + provider_response_id="setup-response-1", + provider_model=self.journey.profile.spec.model_snapshot, + usage=ModelCallUsage( + input_tokens=1, + cached_input_tokens=0, + output_tokens=1, + reasoning_tokens=0, + ), + ) + + +@asynccontextmanager +async def _provider(journey: _Journey, _profile) -> AsyncIterator[_Provider]: + yield _Provider(journey) + + +class _ObservedHandler(SetupModelProxyHandler): + def __init__(self, *, failures: list[BaseException], **kwargs) -> None: + super().__init__(**kwargs) + self.failures = failures + + async def _handle(self, request): + try: + return await super()._handle(request) + except Exception as exc: + self.failures.append(exc) + raise + + +class _ObservedCalls: + def __init__(self, session, authority, journey: _Journey) -> None: + self.session = session + self.journey = journey + self.owner = setup_model_usage_service( + session, + authority=authority, + evidence=setup_model_evidence(session), + owner=setup_model_usage_owner(session), + ) + + def __getattr__(self, name): + return getattr(self.owner, name) + + async def recover_existing(self, *args, **kwargs): + self.journey.stages.append("recover_existing") + result = await self.owner.recover_existing(*args, **kwargs) + self.journey.stages.append("recover_existing_done") + return result + + async def prepare(self, *args, **kwargs): + self.journey.stages.append("prepare") + result = await self.owner.prepare(*args, **kwargs) + self.journey.stages.append("prepare_done") + return result + + async def reserve_dispatch(self, prepared, request_evidence): + self.journey.stages.append("reserve_dispatch") + diagnostic = await self.session.scalar( + text( + "select jsonb_build_object(" + "'binding',jsonb_build_array(binding.resource_type='setup_model_call'," + "binding.resource_id::uuid=:effect,binding.logical_role='model_request'," + "binding.scope_version=1,binding.attribution_type='service_identity'," + "binding.actor_id='workstream.artifact.checker_output')," + "'content',jsonb_build_array(binding.content_id=:content," + "content.sha256=:sha,content.byte_count=:bytes,content.media_type=:media)," + "'attempt',jsonb_build_array(attempt.id=:attempt," + "attempt.model_call_attempt_id=:effect,attempt.checker_run_id is null," + "attempt.producer_request_type='setup_model_output'," + "attempt.logical_role='model_request',attempt.sha256=:sha," + "attempt.byte_count=:bytes,attempt.media_type=:media," + "attempt.status='object_confirmed',attempt.checker_output_custody_sealed," + "attempt.replica_id=replica.id," + "attempt.storage_namespace_id=replica.storage_namespace_id," + "attempt.namespace_fingerprint=replica.namespace_fingerprint," + "attempt.canonical_target=replica.provider_object_ref)," + "'receipt',jsonb_build_array(binding.verification_receipt_id=:receipt," + "receipt.outcome='verified'," + "receipt.execution_generation=job.execution_generation," + "receipt.observed_sha256=:sha,receipt.observed_byte_count=:bytes)," + "'job',jsonb_build_array(job.originating_put_attempt_id=attempt.id," + "job.status='verified',job.terminal_result_code='verified'," + "job.terminal_at is not null,job.checker_output_custody_sealed)," + "'replica',jsonb_build_array(replica.content_id=content.id," + "replica.checker_output_custody_sealed," + "replica.verification_state='verified'," + "replica.availability_state='available',replica.integrity_state='valid')) " + "from artifact_bindings binding " + "left join artifact_contents content on content.id=binding.content_id " + "left join artifact_put_attempts attempt on attempt.id=binding.put_attempt_id " + "left join artifact_verification_receipts receipt " + "on receipt.id=binding.verification_receipt_id " + "left join artifact_verification_jobs job " + "on job.id=receipt.verification_job_id " + "left join artifact_replicas replica on replica.id=job.replica_id " + "where binding.id=:binding" + ), + { + "binding": str(request_evidence.binding_id), + "content": str(request_evidence.content_id), + "attempt": str(request_evidence.put_attempt_id), + "receipt": str(request_evidence.verification_receipt_id), + "effect": str(prepared.effect_id), + "sha": request_evidence.content_sha256, + "bytes": request_evidence.byte_count, + "media": request_evidence.media_type, + }, + ) + self.journey.evidence_diagnostics.append(diagnostic) + row = ( + await self.session.execute( + text( + "select binding.resource_type,binding.logical_role,receipt.outcome " + "from artifact_bindings binding join artifact_verification_receipts receipt " + "on receipt.id=binding.verification_receipt_id " + "where binding.id=:binding and binding.resource_id=:effect" + ), + { + "binding": str(request_evidence.binding_id), + "effect": str(prepared.effect_id), + }, + ) + ).one() + self.journey.reserve_observations.append(tuple(row)) + return await self.owner.reserve_dispatch(prepared, request_evidence) + + async def complete(self, *args, **kwargs): + if self.journey.fail_completion[0]: + self.journey.fail_completion[0] = False + raise ModelCallUnavailable("simulated_setup_ledger_unavailable") + return await self.owner.complete(*args, **kwargs) + + +class _ObservedOutputs: + def __init__(self, owner, sessions, stages: list[str], diagnostics) -> None: + self.owner = owner + self.sessions = sessions + self.stages = stages + self.diagnostics = diagnostics + + def __getattr__(self, name): + return getattr(self.owner, name) + + async def store_setup_output(self, request): + role = request.selector.role + self.stages.append(f"store_{role}") + try: + result = await self.owner.store_setup_output(request) + except Exception as exc: + self.stages.append(f"store_{role}_failed:{type(exc).__name__}:{exc}") + raise + self.stages.append( + f"store_{role}_done:{result.status}:" + f"receipt={result.verification_receipt_id is not None}" + ) + async with self.sessions() as session: + diagnostic = await session.scalar( + text( + "select jsonb_build_object(" + "'attempt_status',attempt.status," + "'attempt_result',attempt.terminal_result_code," + "'job_status',job.status,'job_result',job.terminal_result_code," + "'replica_verification',replica.verification_state," + "'replica_availability',replica.availability_state," + "'replica_integrity',replica.integrity_state," + "'receipt_outcome',receipt.outcome) " + "from artifact_put_attempts attempt " + "left join artifact_verification_jobs job " + "on job.originating_put_attempt_id=attempt.id " + "left join artifact_replicas replica on replica.id=job.replica_id " + "left join artifact_verification_receipts receipt " + "on receipt.verification_job_id=job.id " + "where attempt.id=:attempt order by job.id desc limit 1" + ), + {"attempt": str(result.put_attempt_id)}, + ) + self.diagnostics.append(diagnostic) + return result + + +class _ObservedBindings: + def __init__(self, owner, stages: list[str]) -> None: + self.owner = owner + self.stages = stages + + def __getattr__(self, name): + return getattr(self.owner, name) + + async def bind_setup_output(self, request): + role = request.selector.role + self.stages.append(f"bind_{role}") + try: + result = await self.owner.bind_setup_output(request) + except Exception as exc: + self.stages.append(f"bind_{role}_failed:{type(exc).__name__}:{exc}") + raise + self.stages.append(f"bind_{role}_done") + return result + + +async def _journey(storage: _StorageCase, access) -> _Journey: + profile = await _publish_profile(access, storage.values["project"]) + configuration = _runtime(profile) + requested = await _fence_setup_attempt(storage.database_url, storage.values, configuration) + async with storage.factory() as session: + state = await load_compilation_execution_state(session, requested.attempt_id) + assert state.runtime_configuration == configuration + subject = _setup_model_subject(state, configuration) + assert subject is not None + assert subject.profile_id == profile.profile_id + request = SetupModelProxyRequest(subject, "draft", 0, _provider_request(profile)) + marker = f"private-setup-response-{uuid4()}" + response = canonical_provider_json( + { + "id": "setup-response-1", + "model": profile.spec.model_snapshot, + "choices": [{"message": {"content": marker}}], + "usage": {"prompt_tokens": 1, "completion_tokens": 1}, + } + ) + outputs = setup_model_output_storage( + sessions=storage.factory, + store=storage.store, + namespace=storage.namespace, + preparation=ArtifactPreparationService(storage.manager), + settings=storage.settings, + owner=setup_model_usage_owner, + ) + stages: list[str] = [] + output_diagnostics: list[dict[str, object] | None] = [] + observed_outputs = _ObservedOutputs( + outputs, + storage.factory, + stages, + output_diagnostics, + ) + return _Journey( + storage, + profile, + request, + response, + marker, + [], + [], + stages, + [], + [], + output_diagnostics, + [True], + observed_outputs, + ) + + +async def _state(journey: _Journey): + async with journey.storage.factory() as session: + return ( + await session.execute( + text( + "select effect.id,effect.state,effect.request_binding_id," + "effect.request_content_id,effect.request_put_attempt_id," + "effect.request_verification_receipt_id,effect.request_content_sha256," + "effect.request_byte_count,effect.response_binding_id," + "effect.response_content_id,effect.response_put_attempt_id," + "effect.response_verification_receipt_id,effect.response_content_sha256," + "effect.response_byte_count,effect.settled_nanodollars," + "account.lifetime_reserved_nanodollars," + "account.lifetime_settled_nanodollars " + "from model_usage_effects effect " + "join setup_model_usage_slots slot on slot.id=effect.setup_slot_id " + "left join model_usage_budget_accounts account " + "on account.project_id=slot.project_id " + "where slot.compilation_attempt_id=:attempt" + ), + {"attempt": str(journey.request.subject.compilation_attempt_id)}, + ) + ).one() + + +async def _assert_bound_private_art(journey: _Journey, state) -> None: + async with journey.storage.factory() as session: + bindings = ( + await session.execute( + text( + "select binding.logical_role,binding.resource_id,receipt.outcome " + "from artifact_bindings binding join artifact_verification_receipts receipt " + "on receipt.id=binding.verification_receipt_id " + "where binding.resource_type='setup_model_call' " + "and binding.resource_id=:effect order by binding.logical_role" + ), + {"effect": str(state.id)}, + ) + ).all() + assert [tuple(row) for row in bindings] == [ + ("model_request", str(state.id), "verified"), + ("model_response", str(state.id), "verified"), + ] + for table in ( + "model_usage_effects", + "artifact_contents", + "artifact_put_attempts", + "artifact_operation_receipts", + "artifact_verification_receipts", + "artifact_bindings", + "audit_events", + ): + leaked = await session.scalar( + text( + f"select exists(select 1 from {table} row " + "where to_jsonb(row)::text like :needle)" + ), + {"needle": f"%{journey.response_marker}%"}, + ) + assert leaked is False, table + + +def _assert_evidence_identity(observed, state, role: str) -> None: + assert observed is not None + for field in ("binding_id", "content_id", "put_attempt_id", "verification_receipt_id"): + assert str(getattr(observed, field)) == str(getattr(state, f"{role}_{field}")) + assert observed.content_sha256 == getattr(state, f"{role}_content_sha256") + assert observed.byte_count == getattr(state, f"{role}_byte_count") + + +async def _assert_operator_inspection(journey: _Journey, state, access) -> None: + endpoint = ( + f"/api/v1/operations/projects/{journey.request.subject.project_id}" + f"/model-usage/effects/{state.id}" + ) + response = await access.signed.client.get(endpoint, headers=access.target.headers) + assert response.status_code == 200, response.text + observed = ModelUsageEffectInspection.model_validate_json(response.text) + assert observed.project_id == journey.request.subject.project_id + assert observed.effect_id == UUID(str(state.id)) + assert observed.owner_kind == "setup" + assert observed.owner.compilation_attempt_id == journey.request.subject.compilation_attempt_id + assert observed.state == "completed" + assert observed.provider_response_known is True + assert observed.usage == ModelCallUsage( + input_tokens=1, + cached_input_tokens=0, + output_tokens=1, + reasoning_tokens=0, + ) + assert observed.settled_nanodollars == 23_000 + _assert_evidence_identity(observed.request_evidence, state, "request") + _assert_evidence_identity(observed.response_evidence, state, "response") + assert journey.response_marker not in response.text + assert "setup-response-1" not in response.text + assert "provider_response_id" not in response.json() + + foreign_project_id = await create_project("setup model inspection foreign project") + wrong_project = await access.signed.client.get( + endpoint.replace(str(journey.request.subject.project_id), str(foreign_project_id)), + headers=access.target.headers, + ) + assert wrong_project.status_code == 404, wrong_project.text + missing = await access.signed.client.get( + endpoint.rsplit("/", 1)[0] + f"/{new_record_id()}", + headers=access.target.headers, + ) + assert missing.status_code == 404, missing.text + + +async def test_setup_response_recovers_from_verified_minio_after_ledger_loss( + tmp_path, + isolated_database_env, + admin_access, # noqa: F811 - imported fixture is intentionally requested here + monkeypatch, +) -> None: + """Recover exact response ART after a crash boundary without a second send.""" + async with _storage_case(tmp_path, isolated_database_env, monkeypatch) as storage: + journey = await _journey(storage, admin_access) + first = await journey.handler().handle(journey.request) + assert first.outcome == "unavailable" + assert first.failure_code == "model_call_unavailable" + failures = [f"{type(exc).__name__}:{exc}" for exc in journey.failures] + if failures != ["ModelCallUnavailable:simulated_setup_ledger_unavailable"]: + raise AssertionError( + json.dumps( + { + "failures": failures, + "stages": journey.stages, + "evidence": journey.evidence_diagnostics, + "output": journey.output_diagnostics, + }, + sort_keys=True, + ) + ) + assert journey.reserve_observations == [("setup_model_call", "model_request", "verified")] + assert journey.provider_calls == ["dispatch"] + assert journey.fail_completion == [False] + uncertain = await _state(journey) + assert uncertain.state == "uncertain" + assert uncertain.response_binding_id is None + assert uncertain.lifetime_reserved_nanodollars > 0 + assert uncertain.lifetime_settled_nanodollars == 0 + await _assert_bound_private_art(journey, uncertain) + + recovered = await journey.handler().handle(journey.request) + assert recovered.outcome == "completed" + assert recovered.response_json == journey.response_json + completed = await _state(journey) + assert completed.state == "completed" + assert completed.request_binding_id is not None + assert completed.response_binding_id is not None + assert completed.lifetime_reserved_nanodollars == 0 + assert completed.settled_nanodollars == 23_000 + assert completed.lifetime_settled_nanodollars == 23_000 + await _assert_bound_private_art(journey, completed) + await _assert_operator_inspection(journey, completed, admin_access) + + replay = await journey.handler().handle(journey.request) + assert replay == recovered + assert journey.provider_calls == ["dispatch"] + assert await _state(journey) == completed + + +async def test_concurrent_setup_callbacks_share_one_paid_effect( + tmp_path, + isolated_database_env, + admin_access, # noqa: F811 - imported fixture is intentionally requested here + monkeypatch, +) -> None: + """A duplicate callback observes the durable reservation and cannot send again.""" + async with _storage_case(tmp_path, isolated_database_env, monkeypatch) as storage: + journey = await _journey(storage, admin_access) + journey.fail_completion[0] = False + journey.provider_entered = asyncio.Event() + journey.provider_release = asyncio.Event() + first = asyncio.create_task(journey.handler().handle(journey.request)) + try: + await asyncio.wait_for(journey.provider_entered.wait(), timeout=30) + duplicate = await journey.handler().handle(journey.request) + assert duplicate.outcome == "unavailable" + assert duplicate.failure_code == "model_call_unavailable" + pending = await _state(journey) + assert pending.state == "dispatching" + assert pending.response_binding_id is None + assert pending.lifetime_reserved_nanodollars > 0 + assert pending.lifetime_settled_nanodollars == 0 + finally: + journey.provider_release.set() + completed = await asyncio.wait_for(first, timeout=30) + replay = await journey.handler().handle(journey.request) + assert completed.outcome == replay.outcome == "completed" + assert completed.response_json == replay.response_json == journey.response_json + assert journey.provider_calls == ["dispatch"] + state = await _state(journey) + assert state.state == "completed" + assert state.lifetime_reserved_nanodollars == 0 + assert state.lifetime_settled_nanodollars == state.settled_nanodollars == 23_000 diff --git a/backend/tests/checkers_model_calls/test_setup_model_proxy_transport.py b/backend/tests/checkers_model_calls/test_setup_model_proxy_transport.py new file mode 100644 index 000000000..b568d5510 --- /dev/null +++ b/backend/tests/checkers_model_calls/test_setup_model_proxy_transport.py @@ -0,0 +1,256 @@ +"""Credential-free setup model frames share the private listener without widening it.""" + +from contextlib import asynccontextmanager +from dataclasses import replace +import json +from types import SimpleNamespace + +import pytest + +from app.adapters.checkers.model_proxy_transport import ModelProxyTransport +import app.adapters.project_agents.setup_model_proxy as setup_model_proxy_adapter +from app.adapters.project_agents.setup_model_proxy import ( + SetupModelProxySession, + SetupModelProxyUnavailable, + setup_model_session_factory, +) +from app.core.identifiers import new_record_id +from app.interfaces.checker_model_proxy import ModelProxyReply +from app.interfaces.setup_model_proxy import SetupModelProxyReply, SetupModelProxySubject +from tests.projects.guide_compilation.helpers import runtime_configuration + + +def _sha(label: str) -> str: + import hashlib + + return "sha256:" + hashlib.sha256(label.encode()).hexdigest() + + +def _subject() -> SetupModelProxySubject: + return SetupModelProxySubject( + compilation_attempt_id=new_record_id(), + project_id=new_record_id(), + guide_id=new_record_id(), + source_snapshot_id=new_record_id(), + setup_run_id=new_record_id(), + setup_generation=3, + runtime_configuration_hash=_sha("runtime"), + profile_id=new_record_id(), + profile_version=4, + profile_digest=_sha("profile"), + provider="openai", + api="responses", + model="gpt-5.6-test", + ) + + +async def _unused_checker(_request): + return ModelProxyReply("unavailable", None, None, "unused", 0, True) + + +@pytest.mark.asyncio +async def test_setup_round_trip_preserves_exact_subject_and_canonical_body(tmp_path): + parent = tmp_path / "private" + parent.mkdir(mode=0o700) + socket = parent / "model.sock" + observed = [] + + async def handle(request): + observed.append(request) + return SetupModelProxyReply( + outcome="completed", + call_index=request.call_index, + status_code=200, + response_json=json.dumps( + {"id": "resp_test", "usage": {"input_tokens": 2, "output_tokens": 1}}, + sort_keys=True, + separators=(",", ":"), + ), + response_headers=(), + failure_code=None, + session_exhausted=False, + ) + + async with ModelProxyTransport( + socket_path=socket, + handler=_unused_checker, + setup_handler=handle, + ): + session = SetupModelProxySession( + socket_path=socket, + subject=_subject(), + maximum_calls=2, + maximum_output_tokens=128, + reasoning_effort="medium", + text_verbosity="low", + timeout_seconds=2.0, + ) + reply = await session.dispatch( + "draft", + {"model": "gpt-5.6-test", "input": "bounded", "max_output_tokens": 128}, + ) + + assert reply.outcome == "completed" + assert len(observed) == 1 + assert observed[0].subject == session._subject + assert observed[0].provider_request_json == ( + '{"input":"bounded","max_output_tokens":128,"model":"gpt-5.6-test"}' + ) + + +@pytest.mark.asyncio +async def test_unavailable_dispatch_permanently_blocks_sdk_retry(tmp_path): + parent = tmp_path / "private" + parent.mkdir(mode=0o700) + socket = parent / "model.sock" + calls = 0 + + async def handle(request): + nonlocal calls + calls += 1 + return SetupModelProxyReply( + outcome="unavailable", + call_index=request.call_index, + status_code=None, + response_json=None, + response_headers=(), + failure_code="model_provider_delivery_uncertain", + session_exhausted=True, + ) + + async with ModelProxyTransport( + socket_path=socket, + handler=_unused_checker, + setup_handler=handle, + ): + session = SetupModelProxySession( + socket_path=socket, + subject=_subject(), + maximum_calls=3, + maximum_output_tokens=128, + reasoning_effort="medium", + text_verbosity="low", + timeout_seconds=2.0, + ) + with pytest.raises(SetupModelProxyUnavailable): + await session.dispatch("draft", {"model": "gpt-5.6-test"}) + with pytest.raises(SetupModelProxyUnavailable): + await session.dispatch("draft", {"model": "gpt-5.6-test"}) + + assert calls == 1 + + +def test_completed_reply_must_fit_after_inner_json_escaping(): + response_json = json.dumps( + {"output": '"' * 500_000}, + sort_keys=True, + separators=(",", ":"), + ) + assert len(response_json.encode()) < 1024 * 1024 + + with pytest.raises(ValueError, match="transport bound"): + SetupModelProxyReply( + outcome="completed", + call_index=0, + status_code=200, + response_json=response_json, + response_headers=(), + failure_code=None, + session_exhausted=True, + ) + + +def _tool_session_contract(): + profile_id = new_record_id() + profile_digest = _sha("profile") + configuration = runtime_configuration().model_copy( + update={ + "model_profile_id": profile_id, + "model_profile_version": 4, + "model_profile_digest": profile_digest, + "maximum_tool_calls": 3, + "maximum_tool_output_characters": 65_536, + } + ) + profile = SimpleNamespace( + profile_id=profile_id, + profile_digest=profile_digest, + spec=SimpleNamespace( + profile_version=4, + provider="openai", + api="responses", + model_snapshot="test-model", + allowed_tools=("read_document", "search_document"), + maximum_tool_calls=3, + maximum_tool_output_bytes=65_536 * 4, + maximum_calls=3, + maximum_output_tokens=128, + settings=SimpleNamespace(reasoning_effort="medium", text_verbosity="low"), + deadline_ms=1_000, + ), + ) + subject = replace( + _subject(), + runtime_configuration_hash=configuration.sha256, + profile_id=profile_id, + profile_version=4, + profile_digest=profile_digest, + model="test-model", + ) + return configuration, profile, subject + + +class _Database: + @asynccontextmanager + async def begin(self): + yield + + +@asynccontextmanager +async def _sessions(): + yield _Database() + + +def _profile_reader(profile): + class Reader: + async def read_exact(self, selector, *, project_id): + del selector, project_id + return profile + + return lambda _database: Reader() + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "runtime_limits", + ( + {"maximum_tool_calls": 4}, + {"maximum_tool_output_characters": 65_537}, + ), +) +async def test_setup_session_rejects_tool_limits_above_profile_before_yield( + monkeypatch, tmp_path, runtime_limits +): + configuration, profile, subject = _tool_session_contract() + monkeypatch.setattr(setup_model_proxy_adapter, "model_profile_reader", _profile_reader(profile)) + open_session = setup_model_session_factory( + sessions=_sessions, socket_path=tmp_path / "model.sock" + ) + + yielded = False + with pytest.raises(SetupModelProxyUnavailable, match="profile is unavailable"): + async with open_session(subject, configuration.model_copy(update=runtime_limits)): + yielded = True + assert yielded is False + + +@pytest.mark.asyncio +async def test_setup_session_accepts_exact_profile_tool_limits(monkeypatch, tmp_path): + configuration, profile, subject = _tool_session_contract() + monkeypatch.setattr(setup_model_proxy_adapter, "model_profile_reader", _profile_reader(profile)) + open_session = setup_model_session_factory( + sessions=_sessions, socket_path=tmp_path / "model.sock" + ) + + async with open_session(subject, configuration) as session: + assert isinstance(session, SetupModelProxySession) diff --git a/backend/tests/checkers_model_calls/test_setup_output_custody.py b/backend/tests/checkers_model_calls/test_setup_output_custody.py new file mode 100644 index 000000000..94ee5d651 --- /dev/null +++ b/backend/tests/checkers_model_calls/test_setup_output_custody.py @@ -0,0 +1,176 @@ +"""Owner-order and exact-identity proofs for setup ART reservations.""" + +from types import SimpleNamespace + +import pytest + +from app.core.hashing import canonical_json_hash +from app.core.identifiers import new_record_id +from app.modules.model_usage.api import ( + ModelCallUnavailable, + ModelProfileSelector, + SetupModelOutputSelector, + SetupModelOwnerProfile, + SetupModelUsageSlotKey, +) +from app.modules.model_usage.output_custody import SetupModelOutputReservations + + +def sha(label: str) -> str: + return canonical_json_hash({"label": label}) + + +def selector(*, role: str = "model_request") -> SetupModelOutputSelector: + key = SetupModelUsageSlotKey( + compilation_attempt_id=new_record_id(), + project_id=new_record_id(), + guide_id=new_record_id(), + source_snapshot_id=new_record_id(), + setup_run_id=new_record_id(), + setup_generation=2, + runtime_configuration_hash=sha("runtime"), + profile_id=new_record_id(), + profile_version=3, + profile_digest=sha("profile"), + phase="review", + call_index=1, + ) + return SetupModelOutputSelector( + slot_key=key, + model_call_slot_id=new_record_id(), + model_call_attempt_id=new_record_id(), + role=role, + content_sha256=sha(role), + ) + + +class Owner: + def __init__(self, calls: list[str]) -> None: + self.calls = calls + + async def require_setup_current(self, key): + self.calls.append("owner") + return SetupModelOwnerProfile( + selector=ModelProfileSelector( + profile_id=key.profile_id, + profile_version=key.profile_version, + profile_digest=key.profile_digest, + ), + provider="provider", + api="api.v1", + model_snapshot="model-2026-10-01", + ) + + +class Repository: + def __init__(self, selected: SetupModelOutputSelector, calls: list[str]) -> None: + self.selected = selected + self.calls = calls + key = selected.slot_key + self.envelope = SimpleNamespace(id=str(new_record_id())) + self.slot = SimpleNamespace( + id=str(selected.model_call_slot_id), + envelope_id=self.envelope.id, + compilation_attempt_id=str(key.compilation_attempt_id), + project_id=str(key.project_id), + guide_id=str(key.guide_id), + source_snapshot_id=str(key.source_snapshot_id), + setup_run_id=str(key.setup_run_id), + setup_generation=key.setup_generation, + runtime_configuration_hash=key.runtime_configuration_hash, + profile_id=str(key.profile_id), + profile_version=key.profile_version, + profile_digest=key.profile_digest, + phase=key.phase, + call_index=key.call_index, + ) + self.effect = SimpleNamespace( + setup_slot_id=self.slot.id, + request_sha256=selected.content_sha256, + response_content_sha256=None, + state="dispatching", + ) + self.profile = SimpleNamespace( + project_id=str(key.project_id), + maximum_request_bytes=10_000, + maximum_response_bytes=20_000, + ) + + async def lock_setup_envelope_scope(self, _key): + self.calls.append("envelope-scope") + + async def setup_envelope(self, _key, *, lock): + assert lock + self.calls.append("envelope") + return self.envelope + + async def lock_setup_slot_scope(self, _key): + self.calls.append("slot-scope") + + async def setup_slot(self, _key, *, lock): + assert lock + self.calls.append("slot") + return self.slot + + async def attempt(self, _attempt_id, *, lock): + assert lock + self.calls.append("effect") + return self.effect + + async def profile_exact(self, *_identity): + self.calls.append("profile") + return self.profile + + +async def resolve(monkeypatch, selected): + calls: list[str] = [] + service = SetupModelOutputReservations(object(), owner=Owner(calls)) + service._repo = Repository(selected, calls) + monkeypatch.setattr( + "app.modules.model_usage.output_custody.require_model_call_transaction", + lambda _session: None, + ) + return await service.resolve(selected), calls + + +@pytest.mark.asyncio +async def test_request_checks_owner_before_canonical_model_lock_order(monkeypatch) -> None: + reservation, calls = await resolve(monkeypatch, selector()) + + assert reservation.slot.maximum_bytes == 10_000 + assert calls == [ + "owner", + "envelope-scope", + "envelope", + "slot-scope", + "slot", + "effect", + "profile", + ] + + +@pytest.mark.asyncio +async def test_response_uses_retained_effect_without_projects_callback(monkeypatch) -> None: + selected = selector(role="model_response") + reservation, calls = await resolve(monkeypatch, selected) + + assert reservation.slot.maximum_bytes == 20_000 + assert "owner" not in calls + assert calls[:2] == ["envelope-scope", "envelope"] + + +@pytest.mark.asyncio +async def test_substituted_response_hash_is_rejected(monkeypatch) -> None: + selected = selector(role="model_response") + calls: list[str] = [] + service = SetupModelOutputReservations(object(), owner=Owner(calls)) + repository = Repository(selected, calls) + repository.effect.response_content_sha256 = sha("different-response") + service._repo = repository + monkeypatch.setattr( + "app.modules.model_usage.output_custody.require_model_call_transaction", + lambda _session: None, + ) + + with pytest.raises(ModelCallUnavailable, match="response_conflict"): + await service.resolve(selected) diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index d470f9b6f..89c77af4c 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -23,7 +23,7 @@ DDL_LOCK_DIRECTORY = Path("/tmp") # Match the PostgreSQL 16 engine used by Backend CI. Catalog identity rendering # differs across major versions; regenerate only after comparing actual objects. -EXPECTED_PUBLIC_SCHEMA_SHA256 = "4551d00ae48c338e60cdb55d6cf213461450fe67078a00f28862f956e70b0a3a" +EXPECTED_PUBLIC_SCHEMA_SHA256 = "d14a06c088aae0e8876bebb637a7b3fb0c19344f8cc4fdfd31aa2c1a47348593" PROTECTED_TEST_TABLES = ( "actor_profile_migration_state", "alembic_version", @@ -56,6 +56,17 @@ "authority_control", "authority_idempotency_records", "checker_policies", + "checker_model_invocation_grants", + "checker_model_invocation_grant_calls", + "model_usage_profiles", + "model_usage_budget_accounts", + "model_usage_budget_configurations", + "checker_model_usage_envelopes", + "checker_model_usage_slots", + "setup_model_usage_envelopes", + "setup_model_usage_slots", + "model_usage_effects", + "checker_model_usage_recoveries", "external_checker_registry_entries", "checker_retry_receipts", "checker_result_findings", @@ -176,6 +187,17 @@ "task_post_submit_routing_manifests", "task_post_submit_routing_requests", "checker_policies", + "checker_model_invocation_grants", + "checker_model_invocation_grant_calls", + "model_usage_profiles", + "model_usage_budget_accounts", + "model_usage_budget_configurations", + "checker_model_usage_envelopes", + "checker_model_usage_slots", + "setup_model_usage_envelopes", + "setup_model_usage_slots", + "model_usage_effects", + "checker_model_usage_recoveries", "admin_role_grants", "audit_events", "authority_control", diff --git a/backend/tests/post_submit_materialization_helpers.py b/backend/tests/post_submit_materialization_helpers.py index aecb4b5b7..f3e154634 100644 --- a/backend/tests/post_submit_materialization_helpers.py +++ b/backend/tests/post_submit_materialization_helpers.py @@ -248,13 +248,14 @@ async def _material_fixture( max_revision_rounds=2, pre_submit_registry_spec=None, post_submit_registry_spec=None, + post_submit_binding_setup=None, + post_submit_specification=None, post_scratch_root=None, ): engine = create_async_engine(database_url) factory = async_sessionmaker(engine, expire_on_commit=False) settings, bootstrap, namespace, store, scratch, manager = _material_storage( - tmp_path, provider, storage_settings, scratch_limits, post_scratch_root - ) + tmp_path, provider, storage_settings, scratch_limits, post_scratch_root) try: plan, policy = await approved_selected_policy_fixture( factory, @@ -265,15 +266,10 @@ async def _material_fixture( max_revision_rounds=max_revision_rounds, pre_submit_registry_spec=pre_submit_registry_spec, post_submit_registry_spec=post_submit_registry_spec, + post_submit_binding_setup=post_submit_binding_setup, + post_submit_specification=post_submit_specification, ) - ( - context, - task_id, - data, - admission_id, - creation_request, - created, - ) = await _create_material_submission( + context, task_id, data, admission_id, creation_request, created = await _create_material_submission( factory, engine, plan, diff --git a/backend/tests/projects/guide_activation/source_fixtures.py b/backend/tests/projects/guide_activation/source_fixtures.py index d78e3e8f8..a1b14343b 100644 --- a/backend/tests/projects/guide_activation/source_fixtures.py +++ b/backend/tests/projects/guide_activation/source_fixtures.py @@ -1,5 +1,6 @@ """Current guide creation and compilation prerequisites; no lifecycle guards disabled.""" +from collections.abc import Awaitable, Callable from contextlib import asynccontextmanager from uuid import UUID, uuid4 @@ -22,6 +23,8 @@ from app.modules.projects.schemas import ProjectGuideCreate from app.modules.projects.models import ProjectSetupRun from app.interfaces.project_agents import GuideEvidenceRef +from app.interfaces.project_agents import CapabilityParameter +from app.modules.checkers.api.external import ExternalCheckerRegistryEntry from tests.committed_guide_fixtures import ( create_committed_document_fixture, seed_setup_service_for_compiled_fixture, @@ -72,7 +75,22 @@ async def _create_guide_source(factory, values, actor, version): return setup.source_snapshot_id -def _external_compilation_outcome(outcome, registry_entries, pre_entry, post_entry, refs): +PostSubmitBindingSetup = Callable[ + [async_sessionmaker, UUID, ExternalCheckerRegistryEntry], + Awaitable[tuple[CapabilityParameter, ...]], +] + + +def _external_compilation_outcome( + outcome, + registry_entries, + pre_entry, + post_entry, + refs, + *, + post_submit_parameters=(), + post_submit_specification=None, +): if not registry_entries: return outcome from app.interfaces.project_agents import ( @@ -110,7 +128,7 @@ def _external_compilation_outcome(outcome, registry_entries, pre_entry, post_ent disposition="supported_post_submit", evidence_refs=refs, classification_rationale="Evaluate the submitted work in the background.", - check_specification=CheckSpecification.model_validate({ + check_specification=post_submit_specification or CheckSpecification.model_validate({ **valid_specification().model_dump(mode="json"), "execution_mode": "background", "stage_rationale": "The selected registry entry evaluates immutable submitted evidence.", @@ -123,7 +141,7 @@ def _external_compilation_outcome(outcome, registry_entries, pre_entry, post_ent capability_id=post_entry.capability_id, capability_version=post_entry.capability_version, stage="post_submit", - parameters=(), + parameters=post_submit_parameters, ) ) return outcome.model_copy( @@ -144,6 +162,8 @@ async def create_compiled_guide( artifact_proposal=None, pre_submit_registry_entry=None, post_submit_registry_entry=None, + post_submit_parameters=(), + post_submit_specification=None, compilation_outcome=None, configuration=None, ): @@ -204,6 +224,8 @@ async def create_compiled_guide( pre_submit_registry_entry, post_submit_registry_entry, refs, + post_submit_parameters=post_submit_parameters, + post_submit_specification=post_submit_specification, ) url = factory.kw["bind"].url.render_as_string(hide_password=False) finalization = await compilation_and_projections( @@ -226,6 +248,8 @@ async def source_case( artifact_proposal=None, pre_submit_registry_spec=None, post_submit_registry_spec=None, + post_submit_binding_setup: PostSubmitBindingSetup | None = None, + post_submit_specification=None, compilation_outcome=None, configuration=None, ): @@ -280,6 +304,11 @@ async def source_case( if post_submit_registry_spec is not None else None ) + post_submit_parameters = ( + await post_submit_binding_setup(factory, values["project"], post_registry_entry) + if post_registry_entry is not None and post_submit_binding_setup is not None + else () + ) values, finalization = await create_compiled_guide( factory, values, @@ -288,6 +317,8 @@ async def source_case( artifact_proposal=artifact_proposal, pre_submit_registry_entry=pre_registry_entry, post_submit_registry_entry=post_registry_entry, + post_submit_parameters=post_submit_parameters, + post_submit_specification=post_submit_specification, compilation_outcome=compilation_outcome, configuration=configuration, ) diff --git a/backend/tests/projects/guide_compilation/finalization/pg_generations.py b/backend/tests/projects/guide_compilation/finalization/pg_generations.py index be02e1127..6abc14e99 100644 --- a/backend/tests/projects/guide_compilation/finalization/pg_generations.py +++ b/backend/tests/projects/guide_compilation/finalization/pg_generations.py @@ -10,7 +10,7 @@ from app.modules.projects.api.guide_proposals import GuideProposalCorrection from app.modules.projects.guide_compilation.proposal_service import GuideProposalService -from ..helpers import context +from ..helpers import context, manager_runtime_configuration from ..proposals.pg_support import ProposalAuthority, read_package, request_corrected_attempt from .pg_support import finalize @@ -63,7 +63,11 @@ async def second_generation(factory, values, first): setup_generation=correction.successor_setup_generation, ) ) - compilation_context = context(values, generation=2).model_copy( + compilation_context = context( + values, + generation=2, + configuration=manager_runtime_configuration(), + ).model_copy( update={ "setup_run_id": correction.successor_setup_run_id, "setup_generation": correction.successor_setup_generation, diff --git a/backend/tests/projects/guide_compilation/helpers.py b/backend/tests/projects/guide_compilation/helpers.py index f71bc86d1..e41b6dc0f 100644 --- a/backend/tests/projects/guide_compilation/helpers.py +++ b/backend/tests/projects/guide_compilation/helpers.py @@ -7,7 +7,7 @@ import json from uuid import UUID, uuid4 -from sqlalchemy import text +from sqlalchemy import insert, text, update from sqlalchemy.ext.asyncio import ( AsyncEngine, AsyncSession, @@ -457,86 +457,96 @@ async def _seed_artifact_custody( namespace=None, provider_object_ref: str | None = None, ) -> None: - from datetime import datetime, timezone - - namespace_row = ArtifactStorageNamespace( - id="primary", - backend=namespace.backend if namespace else "local", - adapter=namespace.adapter if namespace else "local", - provider_profile=namespace.provider_profile if namespace else "test", - namespace_descriptor=namespace.namespace_descriptor + namespace_values = { + "id": "primary", + "backend": namespace.backend if namespace else "local", + "adapter": namespace.adapter if namespace else "local", + "provider_profile": namespace.provider_profile if namespace else "test", + "namespace_descriptor": namespace.namespace_descriptor if namespace else {"root": "guide-compilation-fixture"}, - namespace_fingerprint=namespace.namespace_fingerprint if namespace else SOURCE_SHA256, + "namespace_fingerprint": namespace.namespace_fingerprint + if namespace + else SOURCE_SHA256, + } + provider_ref = provider_object_ref or f"fixtures/{CONTENT_ID}" + # Explicit Core statements let historical-migration tests arrange the ART + # columns that existed at that revision without making current ORM metadata + # project newer nullable columns into the older schema. + await session.execute(insert(ArtifactStorageNamespace).values(**namespace_values)) + await session.execute( + insert(ArtifactContent).values( + id=str(CONTENT_ID), + sha256=SOURCE_SHA256, + byte_count=len(SOURCE_BYTES), + media_type="application/pdf", + normalized_display_name="guide.pdf", + ) ) - session.add_all( - [ - namespace_row, - ArtifactContent( - id=str(CONTENT_ID), - sha256=SOURCE_SHA256, - byte_count=len(SOURCE_BYTES), - media_type="application/pdf", - normalized_display_name="guide.pdf", - ), - GuideSourceArtifactIngest( - id=str(DOCUMENT_VERSION_ID), - source_item_id=str(SOURCE_ITEM_ID), - actor_profile_id=str(values["actor"]), - sha256=SOURCE_SHA256, - byte_count=len(SOURCE_BYTES), - media_type="application/pdf", - ), - ] + await session.execute( + insert(GuideSourceArtifactIngest).values( + id=str(DOCUMENT_VERSION_ID), + source_item_id=str(SOURCE_ITEM_ID), + actor_profile_id=str(values["actor"]), + sha256=SOURCE_SHA256, + byte_count=len(SOURCE_BYTES), + media_type="application/pdf", + ) ) - await session.flush() - replica = ArtifactReplica( - id=str(REPLICA_ID), - content_id=str(CONTENT_ID), - storage_namespace_id="primary", - namespace_fingerprint=namespace_row.namespace_fingerprint, - adapter=namespace_row.adapter, - provider_profile=namespace_row.provider_profile, - provider_object_ref=provider_object_ref or f"fixtures/{CONTENT_ID}", - verification_state="pending", - availability_state="unknown", - integrity_state="unknown", + await session.execute( + insert(ArtifactReplica).values( + id=str(REPLICA_ID), + content_id=str(CONTENT_ID), + storage_namespace_id="primary", + namespace_fingerprint=namespace_values["namespace_fingerprint"], + adapter=namespace_values["adapter"], + provider_profile=namespace_values["provider_profile"], + provider_object_ref=provider_ref, + verification_state="pending", + availability_state="unknown", + integrity_state="unknown", + ) ) - session.add(replica) - await session.flush() - put = ArtifactPutAttempt( - id=str(PUT_ATTEMPT_ID), - producer_request_type="guide", - producer_type="actor_profile", - producer_ref=str(values["actor"]), - project_id=str(values["project"]), - guide_source_item_id=str(SOURCE_ITEM_ID), - sha256=SOURCE_SHA256, - byte_count=len(SOURCE_BYTES), - media_type="application/pdf", - storage_namespace_id="primary", - namespace_fingerprint=namespace_row.namespace_fingerprint, - canonical_target=f"sha256/{SOURCE_SHA256[7:9]}/{SOURCE_SHA256[9:]}", - operation_identity=SOURCE_SHA256, - request_digest=SOURCE_SHA256, - status="object_confirmed", - terminal_result_code="document_stored", - replica_id=str(REPLICA_ID), - terminal_at=datetime.now(timezone.utc), + await _seed_verified_guide_put(session, values, namespace_values, provider_ref) + + +async def _seed_verified_guide_put(session, values, namespace_values, provider_ref) -> None: + """Arrange the retained put, its terminal receipt and their exact linkage.""" + from datetime import datetime, timezone + + await session.execute( + insert(ArtifactPutAttempt).values( + id=str(PUT_ATTEMPT_ID), + producer_request_type="guide", + producer_type="actor_profile", + producer_ref=str(values["actor"]), + project_id=str(values["project"]), + guide_source_item_id=str(SOURCE_ITEM_ID), + sha256=SOURCE_SHA256, + byte_count=len(SOURCE_BYTES), + media_type="application/pdf", + storage_namespace_id="primary", + namespace_fingerprint=namespace_values["namespace_fingerprint"], + canonical_target=f"sha256/{SOURCE_SHA256[7:9]}/{SOURCE_SHA256[9:]}", + operation_identity=SOURCE_SHA256, + request_digest=SOURCE_SHA256, + status="object_confirmed", + terminal_result_code="document_stored", + replica_id=str(REPLICA_ID), + terminal_at=datetime.now(timezone.utc), + ) ) - session.add(put) - await session.flush() - session.add( - ArtifactOperationReceipt( + await session.execute( + insert(ArtifactOperationReceipt).values( id=str(RECEIPT_ID), contract_version=2, - put_attempt_id=put.id, + put_attempt_id=str(PUT_ATTEMPT_ID), guide_source_item_id=str(SOURCE_ITEM_ID), - replica_id=replica.id, + replica_id=str(REPLICA_ID), operation="put", - idempotency_key=put.operation_identity, - request_digest=put.request_digest, - provider_object_ref=replica.provider_object_ref, + idempotency_key=SOURCE_SHA256, + request_digest=SOURCE_SHA256, + provider_object_ref=provider_ref, replayed=False, outcome="document_stored", attempt_number=1, @@ -544,8 +554,11 @@ async def _seed_artifact_custody( details=[], ) ) - await session.flush() - put.receipt_id = str(RECEIPT_ID) + await session.execute( + update(ArtifactPutAttempt) + .where(ArtifactPutAttempt.id == str(PUT_ATTEMPT_ID)) + .values(receipt_id=str(RECEIPT_ID)) + ) async def seed_database( diff --git a/backend/tests/projects/guide_compilation/proposals/pg_support.py b/backend/tests/projects/guide_compilation/proposals/pg_support.py index 24f24c9c4..e7bd6af22 100644 --- a/backend/tests/projects/guide_compilation/proposals/pg_support.py +++ b/backend/tests/projects/guide_compilation/proposals/pg_support.py @@ -238,7 +238,9 @@ async def read_package(factory, command, actor, grant_id): raise exc from exc.__context__ -async def request_corrected_attempt(factory, actor, correction, *, capabilities=None): +async def request_corrected_attempt( + factory, actor, correction, *, capabilities=None, configuration=None +): """Enter the existing production human request port; no provider is invoked.""" from app.adapters.artifacts import guide_document_manifest_port from app.modules.authorization.guide_compilation import ( @@ -255,7 +257,7 @@ async def request_corrected_attempt(factory, actor, correction, *, capabilities= from tests.checkers.external.support import capability_projection from app.modules.projects.guide_compilation.request_inputs import CompilationRequestInputs from app.modules.projects.guide_compilation.service import GuideCompilationService - from tests.projects.guide_compilation.helpers import runtime_configuration + from tests.projects.guide_compilation.helpers import manager_runtime_configuration ctx = HumanAuthorizationContext( actor_profile_id=actor.actor_profile_id, @@ -276,7 +278,7 @@ async def request_corrected_attempt(factory, actor, correction, *, capabilities= request_inputs=CompilationRequestInputs( guide_document_manifest_port(session), capabilities or capability_projection(), - runtime_configuration(), + configuration or manager_runtime_configuration(), ), ) return await service.request_correction( @@ -284,7 +286,9 @@ async def request_corrected_attempt(factory, actor, correction, *, capabilities= ) -async def finalize_corrected_attempt(factory, values, actor, correction, *, outcome=None): +async def finalize_corrected_attempt( + factory, values, actor, correction, *, outcome=None, configuration=None +): """Run a scripted result through actual execution, projections and finalization.""" from app.adapters.artifacts import guide_document_manifest_port from app.adapters.auth import ( @@ -299,7 +303,7 @@ async def finalize_corrected_attempt(factory, values, actor, correction, *, outc from app.modules.projects.api import ProjectGuideSetupFinalizationCommand from app.modules.projects.api.guide_compilation import ProjectGuideCompilationDelivery from app.modules.projects.api.setup_identity import project_guide_compilation_task_id - from tests.projects.guide_compilation.helpers import runtime_configuration + from tests.projects.guide_compilation.helpers import manager_runtime_configuration from tests.projects.guide_compilation.runtime_fixtures import ( ScriptedGuideRuntime, document_tools, @@ -307,20 +311,34 @@ async def finalize_corrected_attempt(factory, values, actor, correction, *, outc async with factory() as session, session.begin(): capabilities = await external_checker_capability_projection(session) - await request_corrected_attempt(factory, actor, correction, capabilities=capabilities) + configuration = configuration or manager_runtime_configuration() + await request_corrected_attempt( + factory, + actor, + correction, + capabilities=capabilities, + configuration=configuration, + ) async with factory() as session: setup = await session.get(ProjectSetupRun, str(correction.successor_setup_run_id)) - runtime = ScriptedGuideRuntime() + runtime = ScriptedGuideRuntime(configuration) if outcome is not None: runtime.outcome = outcome + + @asynccontextmanager + async def no_model_session(_state, _context): + yield None + coordinator = project_guide_compilation_delivery_port( factory, material_factory=guide_document_manifest_port, document_tools_factory=document_tools(factory), + model_session_factory=no_model_session, checker_capabilities=capabilities, request_authority=guide_compilation_request_authority, execution_authority=guide_compilation_execution_authority, - configuration_factory=runtime_configuration, + configuration_factory=lambda: configuration, + model_profiles=lambda _session: None, runtime_factory=lambda configuration: runtime, sufficiency_authorization_factory=guide_sufficiency_projection_authorization, policy_authorization_factory=artifact_policy_projection_authorization, diff --git a/backend/tests/projects/guide_compilation/proposals/public_support.py b/backend/tests/projects/guide_compilation/proposals/public_support.py index 597bb28a9..36dd1e718 100644 --- a/backend/tests/projects/guide_compilation/proposals/public_support.py +++ b/backend/tests/projects/guide_compilation/proposals/public_support.py @@ -1,14 +1,17 @@ """Real HTTP/SQL proposal owner with only verified identity admission supplied.""" from contextlib import asynccontextmanager +from time import time from httpx import ASGITransport, AsyncClient from app.api.deps.authorization import enforce_human_authorization_read, get_authorization_actor +from app.api.deps.auth import get_auth_verification_result from app.db.session import get_db_session from app.main import create_app from app.modules.actors.models import ActorIdentityLink, ActorProfile from app.modules.actors.service import ResolvedActor +from app.schemas.auth import AuthVerificationResult, VerifiedIssuerToken @asynccontextmanager @@ -23,9 +26,22 @@ async def database(): yield session async def identity(): return resolved + async def verified_identity(): + now = int(time()) + return AuthVerificationResult(token=VerifiedIssuerToken( + issuer=resolved.identity_link.issuer, + subject=resolved.identity_link.subject, + subject_kind=resolved.profile.actor_kind, + audience=("workstream",), + issued_at=now, + expires_at=now + 300, + token_id="proposal-fixture", + scopes=frozenset(), + )) app = create_app() app.dependency_overrides[get_db_session] = database app.dependency_overrides[get_authorization_actor] = identity + app.dependency_overrides[get_auth_verification_result] = verified_identity app.dependency_overrides[enforce_human_authorization_read] = lambda: None async with AsyncClient(transport=ASGITransport(app=app), base_url="http://testserver") as client: yield client diff --git a/backend/tests/projects/guide_compilation/proposals/test_delivery_custody.py b/backend/tests/projects/guide_compilation/proposals/test_delivery_custody.py index 19c470e69..ac5596afc 100644 --- a/backend/tests/projects/guide_compilation/proposals/test_delivery_custody.py +++ b/backend/tests/projects/guide_compilation/proposals/test_delivery_custody.py @@ -101,7 +101,10 @@ async def test_correction_dispatch_distinguishes_storage_from_conflict(monkeypat monkeypatch.setattr(correction_dispatch.GuideCompilationService, "request_correction", request) publish = AsyncMock() monkeypatch.setattr(correction_dispatch, "dispatch_project_guide_compilation_after_commit", publish) - service = correction_dispatch.GuideCorrectionDispatchService(session, object(), object()) + service = correction_dispatch.GuideCorrectionDispatchService( + session, object(), object(), profiles=object() + ) + monkeypatch.setattr(service, "_selected_inputs", AsyncMock(return_value=object())) selection = GuideProposalSelection(project_id=uuid4(), guide_id=uuid4(), compilation_id=uuid4()) actor, operation = object(), uuid4() with pytest.raises(GuideProposalError) as error: diff --git a/backend/tests/projects/guide_compilation/proposals/test_postgresql.py b/backend/tests/projects/guide_compilation/proposals/test_postgresql.py index a0de2bf96..9eee54f16 100644 --- a/backend/tests/projects/guide_compilation/proposals/test_postgresql.py +++ b/backend/tests/projects/guide_compilation/proposals/test_postgresql.py @@ -60,6 +60,7 @@ async def approve(factory, command, actor, grant, payload): from app.adapters.checkers import ( external_checker_capability_projection, external_checker_registry_reader, + model_profile_reader, ) return await GuideProposalService( @@ -71,6 +72,7 @@ async def approve(factory, command, actor, grant, payload): grant, ), external_checker_registry_reader(session), + model_profile_reader(session), ).approve( payload, actor=actor, diff --git a/backend/tests/projects/guide_compilation/proposals/test_public_dispatch.py b/backend/tests/projects/guide_compilation/proposals/test_public_dispatch.py index 4bf2d2ba7..b14528936 100644 --- a/backend/tests/projects/guide_compilation/proposals/test_public_dispatch.py +++ b/backend/tests/projects/guide_compilation/proposals/test_public_dispatch.py @@ -8,8 +8,11 @@ from app.core.identifiers import new_record_id from app.modules.projects.api.guide_compilation import ProjectGuideCompilationDelivery from app.modules.projects.api.setup_identity import project_guide_compilation_task_id +from tests.projects.client_fixtures import project_database_env as project_database_env from tests.projects.guide_compilation.runtime_fixtures import ( ScriptedGuideRuntime, + configure_scripted_model_transport, + publish_scripted_model_profile, scripted_document_access_runtime, scripted_guide_document_parser_factory, scripted_runtime_configuration, @@ -35,17 +38,51 @@ async def assert_other_actors_cannot_recover(factory, creator, latest_path, endp assert rejected.status_code == 404, rejected.text -@pytest.mark.parametrize("broker_failure", [False, True]) -@pytest.mark.parametrize("manager_handoff", [False, True]) -async def test_manual_dispatch_retains_one_human_request_and_one_execution( - isolated_database_env, monkeypatch, broker_failure, manager_handoff, +async def retained_runtime_configuration(factory, setup_id): + """Read the exact immutable runtime selected by this correction attempt.""" + async with factory() as session: + return await session.scalar(text( + "SELECT runtime_configuration FROM project_guide_compilation_attempts " + "WHERE setup_run_id=:id" + ), {"id": setup_id}) + + +async def assert_dispatch_keeps_selected_profile( + factory, actor, project_id, setup_id, configuration, profile, ): + """A later Operator profile cannot rewrite the committed correction attempt.""" + saved = await retained_runtime_configuration(factory, setup_id) + assert saved["model_profile_id"] == profile["profile_id"] + assert saved["model_profile_version"] == profile["spec"]["profile_version"] + assert saved["model_profile_digest"] == profile["profile_digest"] + async with proposal_client(factory, actor) as operator: + newer = await publish_scripted_model_profile(operator, project_id, configuration, version=2) + assert newer["profile_id"] != profile["profile_id"] + + +async def replay_without_latest_profile_read(monkeypatch, client, endpoint): + """An admitted correction reuses its attempt without mutable profile selection.""" + from app.modules.model_usage.profiles import ModelProfileReader + + async def forbidden_latest_profile_read(*_args, **_kwargs): + pytest.fail("correction replay consulted the latest model profile") + + with monkeypatch.context() as replay_guard: + replay_guard.setattr( + ModelProfileReader, "read_latest_matching", forbidden_latest_profile_read + ) + return await client.post(endpoint) + + +def configure_manual_dispatch(monkeypatch, tmp_path, broker_failure): + """Wire the scripted worker and a broker with observable delivery attempts.""" from app.core.config import get_settings from app.core import project_agents from app.modules.projects import setup_queue monkeypatch.setenv("WORKSTREAM_CELERY_BROKER_URL", "memory://") monkeypatch.setenv("WORKSTREAM_CELERY_RESULT_BACKEND_URL", "cache+memory://") + configure_scripted_model_transport(monkeypatch, tmp_path / "unused-model-proxy.sock") get_settings.cache_clear() from app.workers import project_setup as worker monkeypatch.setattr(project_agents, "project_guide_runtime_configuration", scripted_runtime_configuration) @@ -63,7 +100,21 @@ def enqueue(**values): raise setup_queue.ProjectSetupQueueError("broker unavailable") return values["task_id"] monkeypatch.setattr(setup_queue, "enqueue_project_guide_compilation", enqueue) - async with proposal_case(isolated_database_env) as (_, factory, command, actor, grant): + return worker, runtime, published + + +@pytest.mark.parametrize("broker_failure", [False, True]) +@pytest.mark.parametrize("manager_handoff", [False, True]) +async def test_manual_dispatch_retains_one_human_request_and_one_execution( + project_database_env, monkeypatch, tmp_path, broker_failure, manager_handoff, # noqa: F811 +): + from app.core.config import get_settings + + worker, runtime, published = configure_manual_dispatch( + monkeypatch, tmp_path, broker_failure + ) + async with proposal_case(project_database_env) as (_, factory, command, actor, grant): + operator, _ = await seed_review_actor(factory, None, role="operator", scope="system") path = proposal_path(command) latest_path = f"/api/v1/projects/{command.project_id}/guides/{command.guide_id}/setup-runs/latest" async def predecessor_record(): @@ -72,6 +123,13 @@ async def predecessor_record(): "SELECT row_to_json(f) FROM project_guide_setup_finalizations f WHERE compilation_id=:id" ), {"id": command.compilation_id}) original_finalization = await predecessor_record() + configuration = scripted_runtime_configuration( + get_settings(), adapter_profile_builder=lambda value: value + ) + async with proposal_client(factory, operator) as operator_client: + profile = await publish_scripted_model_profile( + operator_client, command.project_id, configuration + ) async with proposal_client(factory, actor) as client: package = await client.get(path+"/proposal") assert package.status_code == 200, package.text @@ -108,13 +166,14 @@ async def predecessor_record(): assert response.status_code == 202, response.text assert "provider_idempotency_key" not in response.text assert len(published) == 1 and runtime.calls == 0 - if broker_failure: - replay = await client.post(endpoint) - assert replay.status_code == 202, replay.text - assert len(published) == 2 - else: - assert (await client.post(endpoint)).status_code == 202 - assert len(published) == 1 + await assert_dispatch_keeps_selected_profile( + factory, operator, command.project_id, pending["id"], configuration, profile, + ) + replay = await replay_without_latest_profile_read( + monkeypatch, client, endpoint + ) + assert replay.status_code == 202, replay.text + assert len(published) == (2 if broker_failure else 1) async with factory() as session: row = (await session.execute(text( "SELECT request_trigger,setup_run_id,setup_generation,source_snapshot_id,actor_profile_id " @@ -154,3 +213,51 @@ async def deliver(): assert completed.json()["correction_operation_id"] == operation assert completed.json()["predecessor_compilation_id"] == predecessor assert await predecessor_record() == original_finalization + saved = await retained_runtime_configuration(factory, pending["id"]) + assert saved["model_profile_id"] == profile["profile_id"] + + +async def test_manual_dispatch_without_matching_profile_has_no_effects( + project_database_env, monkeypatch, tmp_path, # noqa: F811 +): + """A missing Operator profile is concealed before request or queue effects.""" + _worker, runtime, published = configure_manual_dispatch(monkeypatch, tmp_path, False) + async with proposal_case(project_database_env) as (_, factory, command, actor, _grant): + path = proposal_path(command) + async with factory() as session: + profile_count = await session.scalar(text( + "SELECT count(*) FROM model_usage_profiles WHERE project_id=:project" + ), {"project": command.project_id}) + assert profile_count == 0 + + async with proposal_client(factory, actor) as client: + package = await client.get(path + "/proposal") + assert package.status_code == 200, package.text + correction = await client.post( + path + "/corrections", + headers={"Idempotency-Key": str(uuid4())}, + json={ + "target": package.json()["target"], + "reason": "Reconsider the requirement using the guide evidence.", + }, + ) + assert correction.status_code == 201, correction.text + receipt = correction.json() + endpoint = ( + f"{path}/corrections/{receipt['operation_id']}/dispatch" + ) + response = await client.post(endpoint) + assert response.status_code == 404, response.text + assert response.json()["error"]["code"] == "proposal_unavailable" + + assert published == [] and runtime.calls == 0 + async with factory() as session: + state = (await session.execute(text( + "SELECT s.status,s.current_step,s.celery_task_id," + "(SELECT count(*) FROM project_guide_compilation_attempts a " + "WHERE a.setup_run_id=s.id) AS attempts," + "(SELECT count(*) FROM project_guide_compilation_request_operations r " + "WHERE r.setup_run_id=s.id) AS requests " + "FROM project_setup_runs s WHERE s.id=:setup" + ), {"setup": receipt["successor_setup_run_id"]})).one() + assert tuple(state) == ("correction_requested", "correction_requested", None, 0, 0) diff --git a/backend/tests/projects/guide_compilation/runtime_fixtures.py b/backend/tests/projects/guide_compilation/runtime_fixtures.py index ea26326b9..8c1159460 100644 --- a/backend/tests/projects/guide_compilation/runtime_fixtures.py +++ b/backend/tests/projects/guide_compilation/runtime_fixtures.py @@ -8,7 +8,17 @@ from contextlib import asynccontextmanager from io import BytesIO from pathlib import Path +from uuid import uuid4 +import pytest +from sqlalchemy import select + +from app.core.config import get_settings +from app.modules.model_usage.api import ProjectModelProfileSpec +from app.modules.projects.models import ProjectSetupRun +from tests.actors.support import set_dev_actor +from tests.checkers_model_calls.test_model_call_contract import profile_spec +from tests.projects.client_fixtures import auth_headers from app.adapters.guide_document_tools import ( GUIDE_DOCUMENT_PARSER_CAPABILITY_KEY, GUIDE_DOCUMENT_PARSER_PROVIDER_KEY, @@ -93,6 +103,114 @@ def scripted_document_access_runtime( return document_access(attempt_id, manifest, configuration) +def scripted_model_profile_spec(project_id, configuration) -> ProjectModelProfileSpec: + """Build the bounded profile required by the setup runtime's fixed tools.""" + source = profile_spec().model_dump(mode="python") + source.update( + project_id=project_id, + profile_version=1, + provider=configuration.model_provider, + api=configuration.model_api, + model_snapshot=configuration.model, + allowed_tools=("read_document", "search_document"), + maximum_tool_calls=configuration.maximum_tool_calls, + maximum_tool_output_bytes=configuration.maximum_tool_output_characters * 4, + ) + return ProjectModelProfileSpec.model_validate(source) + + +async def publish_scripted_model_profile(client, project_id, configuration, *, version=1): + """Publish one exact scripted-runtime profile through the Operator API.""" + spec = scripted_model_profile_spec(project_id, configuration).model_copy( + update={"profile_version": version} + ) + published = await client.post( + f"/api/v1/operations/projects/{project_id}/model-profiles", + headers=auth_headers() | {"Idempotency-Key": str(uuid4())}, + json=spec.model_dump(mode="json"), + ) + assert published.status_code == 201, published.text + return published.json() + + +def configure_scripted_model_transport(monkeypatch, socket_path: Path) -> None: + """Enable the typed setup-model session without providing a live transport.""" + assert socket_path.is_absolute() and not socket_path.exists() + fixture_root = socket_path.parent + values = { + "ENVIRONMENT": "test", + "ARTIFACT_STORE_BACKEND": "local", + "ARTIFACT_LOCAL_ROOT": str(fixture_root / "durable-artifacts"), + "ARTIFACT_SCRATCH_ROOT": str(fixture_root / "artifact-scratch"), + "CHECKER_MODEL_PROXY_SOCKET": str(socket_path), + } + for scope in ("TASK", "PRODUCER", "PROJECT", "DEPLOYMENT"): + values[f"ARTIFACT_ADMISSION_{scope}_MAXIMUM_BYTES"] = str(1024 * 1024) + for name, value in values.items(): + monkeypatch.setenv("WORKSTREAM_" + name, value) + get_settings.cache_clear() + + +@pytest.fixture +async def scripted_model_profile(automatic_source, project_client, monkeypatch, tmp_path): + """Publish the exact inference profile selected by live coordinator tests.""" + configure_scripted_model_transport(monkeypatch, tmp_path / "unused-model-proxy.sock") + factory, _actor, setup_id, _snapshot = automatic_source + async with factory() as session: + project_id = await session.scalar( + select(ProjectSetupRun.project_id).where(ProjectSetupRun.id == str(setup_id)) + ) + assert project_id is not None + set_dev_actor( + monkeypatch, + roles="viewer", + subject="scripted-model-operator", + issuer="flow-test", + token="project-token", + ) + admitted = await project_client.get("/api/v1/actors/me", headers=auth_headers()) + assert admitted.status_code == 200, admitted.text + operator_id = admitted.json()["actor_profile_id"] + set_dev_actor( + monkeypatch, + roles="project_manager", + subject="project-manager-subject", + issuer="flow-test", + token="project-token", + ) + grant = await project_client.post( + "/api/v1/admin-role-grants", + headers=auth_headers(), + json={ + "target_actor_profile_id": operator_id, + "role": "operator", + "scope_type": "system", + "scope_project_id": None, + "reason": "Exercise profile-selected project guide compilation", + }, + ) + assert grant.status_code == 201, grant.text + set_dev_actor( + monkeypatch, + roles="viewer", + subject="scripted-model-operator", + issuer="flow-test", + token="project-token", + ) + configuration = scripted_runtime_configuration( + get_settings(), adapter_profile_builder=lambda value: value + ) + await publish_scripted_model_profile(project_client, project_id, configuration) + set_dev_actor( + monkeypatch, + roles="project_manager", + subject="project-manager-subject", + issuer="flow-test", + token="project-token", + ) + yield + + def document_tools(session_factory, *, document_access_factory=None): """Bind the test grant/parser to the production PROJECTS receipt owner.""" return project_guide_document_tools_factory( @@ -126,8 +244,10 @@ async def record_attempt_document_access(sessions, attempt_id, context): class ScriptedGuideRuntime: outcome = generated_result() - identity = runtime_configuration().adapter_identity - calls = 0 + + def __init__(self, configuration=None): + self.identity = (configuration or runtime_configuration()).adapter_identity + self.calls = 0 def admit_execution(self): pass diff --git a/backend/tests/projects/guide_compilation/test_capability_growth_postgresql.py b/backend/tests/projects/guide_compilation/test_capability_growth_postgresql.py index d4d4f4c4e..0ba8bbc91 100644 --- a/backend/tests/projects/guide_compilation/test_capability_growth_postgresql.py +++ b/backend/tests/projects/guide_compilation/test_capability_growth_postgresql.py @@ -12,15 +12,21 @@ project_client as project_client, project_database_env as project_database_env, ) from .test_automatic_request import automatic_source as automatic_source -from .runtime_fixtures import ScriptedGuideRuntime, scripted_runtime_configuration +from .runtime_fixtures import ( + ScriptedGuideRuntime, + scripted_model_profile, # noqa: F401 - registered pytest fixture + scripted_runtime_configuration, +) from .test_live_cutover_postgresql import ( - _delivery, scripted_document_port as scripted_document_port, + _delivery, + scripted_document_port as scripted_document_port, ) from .test_capability_growth import growth_report from .test_compilation_storage_limit import boundary_reports @pytest.mark.parametrize("oversized", [False, True]) +@pytest.mark.usefixtures("scripted_model_profile") async def test_handoff_persistence_and_terminal_replay(automatic_source, monkeypatch, oversized): from app.workers import project_setup as worker diff --git a/backend/tests/projects/guide_compilation/test_check_specifications.py b/backend/tests/projects/guide_compilation/test_check_specifications.py index 5b4208518..983181a32 100644 --- a/backend/tests/projects/guide_compilation/test_check_specifications.py +++ b/backend/tests/projects/guide_compilation/test_check_specifications.py @@ -238,7 +238,7 @@ def test_model_evaluation_requires_post_even_with_a_short_duration(stage: str) - { "dependency_id": "model_judge", "kind": "model_service", - "provisioning": "runtime_gap", + "provisioning": "model_proxy", "purpose": "Evaluate the report against the rubric.", "unavailable_code": "implementation_unavailable", } @@ -271,12 +271,108 @@ def test_model_evaluation_requires_post_even_with_a_short_duration(stage: str) - compilation_context = context(ids()) validate_project_guide_compilation_result(compilation_context, proposal) if stage == "pre_submit": - with pytest.raises(ValueError, match="model evaluation requires post-submit"): + with pytest.raises(ValueError, match="model service requires post-submit"): require_complete_generated_specifications(compilation_context, proposal) else: require_complete_generated_specifications(compilation_context, proposal) +def test_model_assisted_isolated_execution_requires_post_submit() -> None: + specification = _specification( + evaluation_method="isolated_execution", + execution_mode="bounded_pre_submit", + maximum_duration_seconds=30, + runtime_dependencies=[ + { + "dependency_id": "execution_runner", + "kind": "isolated_execution", + "provisioning": "current_runner", + "purpose": "Execute the bounded trial.", + "unavailable_code": "implementation_unavailable", + }, + { + "dependency_id": "model_judge", + "kind": "model_service", + "provisioning": "model_proxy", + "purpose": "Assess the authenticated trial observations.", + "unavailable_code": "implementation_unavailable", + }, + ], + infrastructure_failures=[ + { + "code": "implementation_unavailable", + "condition": "The execution or model service is unavailable.", + } + ], + ) + requirement = _requirement( + disposition="pre_submit_capability_gap", + check_specification=specification.model_dump(mode="json"), + ) + proposal = _with_requirement(requirement).model_copy( + update={ + "status": "draft_ready_with_warnings", + "capability_suggestions": ( + CapabilitySuggestion( + requirement_id=requirement.requirement_id, + stage="pre_submit", + title="Trial assessment", + rationale="The trial requires model-assisted assessment.", + evidence_refs=requirement.evidence_refs, + ), + ), + } + ) + + with pytest.raises(ValueError, match="model service requires post-submit"): + require_complete_generated_specifications(context(ids()), proposal) + + +def test_new_generation_rejects_legacy_model_runtime_gap() -> None: + specification = _specification( + evaluation_method="model_evaluation", + execution_mode="background", + runtime_dependencies=[ + { + "dependency_id": "model_judge", + "kind": "model_service", + "provisioning": "runtime_gap", + "purpose": "Evaluate the report against the rubric.", + "unavailable_code": "implementation_unavailable", + } + ], + infrastructure_failures=[ + { + "code": "implementation_unavailable", + "condition": "The model service is unavailable.", + } + ], + ) + requirement = _requirement( + disposition="post_submit_capability_gap", + check_specification=specification.model_dump(mode="json"), + ) + proposal = _with_requirement(requirement).model_copy( + update={ + "status": "draft_ready_with_warnings", + "capability_suggestions": ( + CapabilitySuggestion( + requirement_id=requirement.requirement_id, + stage="post_submit", + title="Report evaluation", + rationale="The project image or exact model profile is unavailable.", + evidence_refs=requirement.evidence_refs, + ), + ), + } + ) + + # Immutable older results remain decodable, but cannot pass new generation admission. + ProjectGuideCompilationResult.model_validate(proposal.model_dump(mode="json")) + with pytest.raises(ValueError, match="model service requires model proxy"): + require_complete_generated_specifications(context(ids()), proposal) + + def test_pre_submit_binding_must_fit_the_stage_budget() -> None: entry = _entry_with_deadline(60_001) compilation_context = context(ids(), checker_capabilities=capability_projection(entry)) diff --git a/backend/tests/projects/guide_compilation/test_hidden_orchestrator_postgresql.py b/backend/tests/projects/guide_compilation/test_hidden_orchestrator_postgresql.py index f3c951587..276b6d9da 100644 --- a/backend/tests/projects/guide_compilation/test_hidden_orchestrator_postgresql.py +++ b/backend/tests/projects/guide_compilation/test_hidden_orchestrator_postgresql.py @@ -127,7 +127,7 @@ def _backend(factory): ) -def _port(factory, runtime, *, checker_capabilities=None): +def _port(factory, runtime, *, checker_capabilities=None, model_session_factory=None): return project_guide_compilation_execution_port( factory, material_factory=guide_document_manifest_port, @@ -135,6 +135,7 @@ def _port(factory, runtime, *, checker_capabilities=None): checker_capabilities=checker_capabilities or capability_projection(), authorization_context=_fixed_service_authorization, runtime_factory=lambda configuration: runtime, + model_session_factory=model_session_factory, ) @@ -820,7 +821,9 @@ async def test_sdk_parser_rejection_persists_terminal_without_reinvocation( from types import SimpleNamespace from agents import Runner from app.adapters.project_agents.openai_agent_sdk import OpenAIAgentSdkProjectGuideRuntime - from tests.test_agent_runtime import _provider_payload + from app.core.hashing import canonical_json_hash + from app.core.identifiers import new_record_id + from tests.test_agent_runtime import _TestModelSession, _provider_payload from agents import _debug @@ -845,13 +848,32 @@ async def run(agent, prompt, **kwargs): monkeypatch.setattr(Runner, "run", run) + configuration = type(runtime_configuration()).model_validate( + runtime_configuration().model_dump(mode="json") + | { + "model_profile_id": new_record_id(), + "model_profile_version": 1, + "model_profile_digest": canonical_json_hash({"profile": "sdk-parser-test"}), + } + ) + + @asynccontextmanager + async def model_session(subject, selected_configuration): + assert subject is not None + assert selected_configuration == configuration + yield _TestModelSession() + values = await seed_database(clean_postgres_database) - requested = await _authorized_attempt(clean_postgres_database, values) + requested = await _authorized_attempt(clean_postgres_database, values, configuration) engine = create_async_engine(clean_postgres_database) factory = async_sessionmaker(engine, expire_on_commit=False) command = ProjectGuideCompilationExecutionCommand(attempt_id=requested.attempt_id) try: - port = _port(factory, OpenAIAgentSdkProjectGuideRuntime(runtime_configuration())) + port = _port( + factory, + OpenAIAgentSdkProjectGuideRuntime(configuration), + model_session_factory=model_session, + ) first = await port.execute(command) assert ( first.classification is ProjectGuideCompilationExecutionClassification.INVALID_TERMINAL diff --git a/backend/tests/projects/guide_compilation/test_live_cutover_postgresql.py b/backend/tests/projects/guide_compilation/test_live_cutover_postgresql.py index c3e94ff59..25112ffd5 100644 --- a/backend/tests/projects/guide_compilation/test_live_cutover_postgresql.py +++ b/backend/tests/projects/guide_compilation/test_live_cutover_postgresql.py @@ -19,6 +19,7 @@ ScriptedGuideRuntime, scripted_document_access_runtime, scripted_guide_document_parser_factory, + scripted_model_profile, # noqa: F401 - registered pytest fixture scripted_runtime_configuration, ) @@ -67,6 +68,7 @@ async def _delivery(factory, setup_id): +@pytest.mark.usefixtures("scripted_model_profile") @pytest.mark.parametrize("status", ["draft_ready", "draft_ready_with_warnings", "guide_blocked"]) async def test_pending_exact_delivery_compiles_once_and_replays_finalization( automatic_source, monkeypatch, status, project_client @@ -177,6 +179,7 @@ def forbidden_configuration(_settings, *, adapter_profile_builder): assert runtime.calls == 1 +@pytest.mark.usefixtures("scripted_model_profile") @pytest.mark.parametrize("phase", ["compilation", "sufficiency", "finalization"]) async def test_compilation_phase_and_public_setup_read_share_parent_lock_order( automatic_source, project_client, project_database_env, monkeypatch, phase @@ -300,6 +303,7 @@ def forbidden_configuration(_settings, *, adapter_profile_builder): assert await session.scalar(text("select count(*) from " + table)) == 0 +@pytest.mark.usefixtures("scripted_model_profile") @pytest.mark.parametrize("outcome", ["invalid", "uncertain"]) async def test_invalid_or_uncertain_attempt_reports_durable_diagnostics_without_projection( automatic_source, monkeypatch, outcome, project_client @@ -335,7 +339,9 @@ async def test_invalid_or_uncertain_attempt_reports_durable_diagnostics_without_ first = await coordinator.run(delivery) replay = await coordinator.run(delivery) assert first == replay - assert runtime.calls == 1 + # The scripted runtime re-enters once for uncertain delivery. Production + # model custody separately prevents repeating the same paid provider effect. + assert runtime.calls == (2 if outcome == "uncertain" else 1) assert first["status"] == ( "compilation_invalid_terminal" if outcome == "invalid" else "provider_outcome_unresolved" ) @@ -388,6 +394,7 @@ async def test_invalid_or_uncertain_attempt_reports_durable_diagnostics_without_ assert await session.scalar(text("select count(*) from " + table)) == 0 +@pytest.mark.usefixtures("scripted_model_profile") async def test_finalized_receipt_excludes_every_recovery_shape(automatic_source, monkeypatch): from app.workers import project_setup as worker @@ -479,6 +486,7 @@ def forbidden_enqueue(**kwargs): ) +@pytest.mark.usefixtures("scripted_model_profile") async def test_publisher_acknowledgement_cannot_overwrite_worker_finalization( automatic_source, monkeypatch ): @@ -558,6 +566,7 @@ async def dispatch(): @pytest.mark.parametrize( "boundary", ["persisted", "sufficiency", "both_projections", "retained_unconfigured"] ) +@pytest.mark.usefixtures("scripted_model_profile") async def test_phase_crash_recovers_same_attempt_without_reinference( automatic_source, monkeypatch, boundary ): @@ -648,6 +657,7 @@ async def project_submission_artifact_policy(self, command): ) +@pytest.mark.usefixtures("scripted_model_profile") @pytest.mark.parametrize("phase", ["request", "fence", "sufficiency", "policy", "finalization"]) async def test_each_phase_rechecks_service_authority_and_restoration_reuses_attempt( automatic_source, monkeypatch, phase @@ -746,9 +756,11 @@ async def revoked_call(*args, **kwargs): ) -async def test_concurrent_live_deliveries_share_one_provider_and_finalization( +@pytest.mark.usefixtures("scripted_model_profile") +async def test_concurrent_live_deliveries_converge_on_one_finalization( automatic_source, monkeypatch ): + """Scripted resumptions converge; paid-effect dedupe has a separate owner proof.""" import asyncio from app.core.config import get_settings @@ -759,12 +771,17 @@ async def test_concurrent_live_deliveries_share_one_provider_and_finalization( factory, actor, setup_id, snapshot = automatic_source await create_committed_document_fixture(snapshot["id"]) delivery = await _delivery(factory, setup_id) - entered, release = asyncio.Event(), asyncio.Event() + entered, both_entered, release = asyncio.Event(), asyncio.Event(), asyncio.Event() + active = 0 runtime = ScriptedGuideRuntime() original = runtime.compile_project_guide async def blocked_provider(context, capabilities): + nonlocal active + active += 1 entered.set() + if active == 2: + both_entered.set() await release.wait() return await original(context, capabilities) @@ -776,15 +793,19 @@ async def blocked_provider(context, capabilities): first = asyncio.create_task((await worker._coordinator(factory)).run(delivery)) try: await asyncio.wait_for(entered.wait(), timeout=20) - duplicate = await asyncio.wait_for( - (await worker._coordinator(factory)).run(delivery), timeout=20 + duplicate = asyncio.create_task( + (await worker._coordinator(factory)).run(delivery) ) - assert duplicate["error_code"] == "provider_outcome_unresolved" + await asyncio.wait_for(both_entered.wait(), timeout=20) finally: release.set() - receipt = await asyncio.wait_for(first, timeout=30) + receipt, duplicate_receipt = await asyncio.wait_for( + asyncio.gather(first, duplicate), timeout=30 + ) assert receipt["status"] == "policy_draft_ready" - assert runtime.calls == 1 + assert duplicate_receipt["status"] == "provider_result_accepted_not_persisted" + assert duplicate_receipt["attempt_id"] + assert runtime.calls == 2 assert await (await worker._coordinator(factory)).run(delivery) == receipt async with factory() as session: for table in [ @@ -845,6 +866,7 @@ def forbidden_configuration(_settings, *, adapter_profile_builder): assert await session.scalar(text("select count(*) from " + table)) == 0 +@pytest.mark.usefixtures("scripted_model_profile") async def test_stale_queued_configuration_failure_is_reclaimed_and_finishes_same_delivery( automatic_source, monkeypatch ): diff --git a/backend/tests/projects/guide_compilation/test_live_worker.py b/backend/tests/projects/guide_compilation/test_live_worker.py index dcec3c48c..afacf9f8a 100644 --- a/backend/tests/projects/guide_compilation/test_live_worker.py +++ b/backend/tests/projects/guide_compilation/test_live_worker.py @@ -8,6 +8,7 @@ from app.core.config import get_settings from app.interfaces.external_services import UnknownExternalServiceProviderError from app.workers.task_settings import sync_task_settings + from .helpers import manager_runtime_configuration, runtime_configuration @@ -117,16 +118,19 @@ def test_runtime_factory_constructs_manager_authored_runtime_without_provider_cr worker, monkeypatch ): from app.adapters.project_agents.manager_authored import ManagerAuthoredProjectGuideRuntime - from app.interfaces.external_services import ExternalServiceConfigurationError + from app.adapters.project_agents.openai_agent_sdk import OpenAIAgentSdkProjectGuideRuntime monkeypatch.delenv("OPENAI_API_KEY", raising=False) configuration = manager_runtime_configuration() runtime = worker.create_project_guide_runtime(configuration) assert isinstance(runtime, ManagerAuthoredProjectGuideRuntime) assert runtime.identity == configuration.adapter_identity - # The OpenAI runtime still refuses to start without its credential. - with pytest.raises(ExternalServiceConfigurationError): - worker.create_project_guide_runtime(runtime_configuration()) + # Provider credentials belong only to the trusted model proxy; construction + # is credential-free and execution still requires the typed model capability. + assert isinstance( + worker.create_project_guide_runtime(runtime_configuration()), + OpenAIAgentSdkProjectGuideRuntime, + ) def test_stale_coordinator_error_is_rejected_without_transport_retry(worker, monkeypatch): diff --git a/backend/tests/projects/guide_compilation/test_neutral_document_tools.py b/backend/tests/projects/guide_compilation/test_neutral_document_tools.py index a777ea5c3..e80a93127 100644 --- a/backend/tests/projects/guide_compilation/test_neutral_document_tools.py +++ b/backend/tests/projects/guide_compilation/test_neutral_document_tools.py @@ -287,12 +287,14 @@ async def test_grant_or_custody_failure_returns_no_successful_receipt(): assert corrupt_custody.receipts == [] -def test_runtime_capability_exposes_only_tools(): +def test_runtime_capability_exposes_only_bounded_tools_and_model_ports(): tools, _handle, _document, _custody = _tools() capabilities = GuideRuntimeCapabilities(tools=tools) - assert tuple(capabilities.__dataclass_fields__) == ("tools",) + assert tuple(capabilities.__dataclass_fields__) == ("tools", "model") + assert capabilities.model is None assert not hasattr(capabilities, "documents") assert not hasattr(capabilities, "resources") + assert not hasattr(capabilities, "credentials") def test_parser_source_limit_rejects_manifest_before_any_tool_or_inference_call(): diff --git a/backend/tests/projects/guide_compilation/test_runtime_configuration_postgresql.py b/backend/tests/projects/guide_compilation/test_runtime_configuration_postgresql.py index dbfde6143..25b8d3e3c 100644 --- a/backend/tests/projects/guide_compilation/test_runtime_configuration_postgresql.py +++ b/backend/tests/projects/guide_compilation/test_runtime_configuration_postgresql.py @@ -218,6 +218,42 @@ async def test_sql_requires_all_none_or_all_concrete_inference_identity( await engine.dispose() +async def test_sql_model_profile_selector_is_all_null_or_complete( + clean_postgres_database, +): + """Partial selectors fail closed even when a missing JSON key yields SQL NULL.""" + values = await seed_database(clean_postgres_database) + null_selector = runtime_configuration().model_dump(mode="json") + complete = null_selector | { + "model_profile_id": str(new_record_id()), + "model_profile_version": 1, + "model_profile_digest": canonical_json_hash({"profile": "runtime-test"}), + } + invalid = ( + {key: value for key, value in complete.items() if key != "model_profile_id"}, + {key: value for key, value in complete.items() if key != "model_profile_digest"}, + ) + engine = create_async_engine(clean_postgres_database) + try: + for configuration in invalid: + async with engine.begin() as connection: + with pytest.raises( + DBAPIError, match="compilation runtime configuration is invalid" + ) as error: + await connection.execute( + insert(ProjectGuideCompilationAttempt).values( + **_row(values, configuration) + ) + ) + assert error.value.orig.sqlstate == "23514" + async with engine.begin() as connection: + await connection.execute( + insert(ProjectGuideCompilationAttempt).values(**_row(values, complete)) + ) + finally: + await engine.dispose() + + async def test_sql_runtime_configuration_and_hash_are_immutable(clean_postgres_database): values = await seed_database(clean_postgres_database) configuration = runtime_configuration().model_dump(mode="json") diff --git a/backend/tests/projects/post_policy/test_public_recovery.py b/backend/tests/projects/post_policy/test_public_recovery.py index 46e2853ce..f975aeb10 100644 --- a/backend/tests/projects/post_policy/test_public_recovery.py +++ b/backend/tests/projects/post_policy/test_public_recovery.py @@ -44,12 +44,19 @@ async def test_lost_post_policy_correction_discovered_and_dispatched_by_new_mana isolated_database_env, monkeypatch, post_policy_worker, ): from app.core import project_agents + from app.core.config import get_settings from app.modules.projects import setup_queue from tests.projects.guide_compilation.runtime_fixtures import ( + publish_scripted_model_profile, scripted_runtime_configuration, ) monkeypatch.setattr(project_agents, "project_guide_runtime_configuration", scripted_runtime_configuration) + monkeypatch.setenv( + "WORKSTREAM_API_RATE_LIMIT_KEY_SECRET", + "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=", + ) + get_settings.cache_clear() dispatched = [] def enqueue(**values): dispatched.append(values) @@ -58,6 +65,16 @@ def enqueue(**values): async with public_approved_case(isolated_database_env, monkeypatch) as ( factory, command, creator, grant, approval_id, _, ): + configuration = scripted_runtime_configuration( + get_settings(), adapter_profile_builder=lambda value: value + ) + operator, _ = await seed_review_actor( + factory, None, role="operator", scope="system" + ) + async with proposal_client(factory, operator) as operator_client: + await publish_scripted_model_profile( + operator_client, command.project_id, configuration + ) assert deliver(post_policy_worker, approval_id)["status"] == "policy_draft_ready" path = proposal_path(command) async with proposal_client(factory, creator) as client: diff --git a/backend/tests/projects/post_policy/test_replacement.py b/backend/tests/projects/post_policy/test_replacement.py index 55457ab48..59b78a4e9 100644 --- a/backend/tests/projects/post_policy/test_replacement.py +++ b/backend/tests/projects/post_policy/test_replacement.py @@ -1,6 +1,6 @@ """A corrected unified generation replaces one policy chain without rewriting evidence.""" -from uuid import uuid4 +from uuid import UUID, uuid4 import pytest from sqlalchemy import text @@ -8,18 +8,50 @@ from app.core.identifiers import new_record_id from app.core.hashing import canonical_json_hash +from app.core.config import get_settings from app.modules.projects.api.guide_proposals import GuideProposalApproval, GuideProposalSelection from app.modules.projects.api.post_policy import PostPolicyApproval, PostPolicyCorrection, PostPolicyDerive from tests.projects.guide_compilation.helpers import service_actor -from tests.projects.guide_compilation.proposals.pg_support import proposal_case, read_package, finalize_corrected_attempt +from tests.projects.guide_compilation.proposals.pg_support import ( + finalize_corrected_attempt, + proposal_case, + read_package, + seed_review_actor, +) +from tests.projects.guide_compilation.runtime_fixtures import ( + publish_scripted_model_profile, + scripted_runtime_configuration, +) from tests.projects.guide_compilation.proposals.test_postgresql import approve from tests.projects.guide_compilation.proposals.public_support import proposal_client, proposal_path from .pg_support import prepare_post_policy, operate @pytest.mark.parametrize('origin', ['post_policy', 'unified']) -async def test_corrected_generation_replaces_policy_and_retains_original_receipts(clean_postgres_database, origin): +async def test_corrected_generation_replaces_policy_and_retains_original_receipts( + clean_postgres_database, monkeypatch, origin +): + monkeypatch.setenv( + "WORKSTREAM_API_RATE_LIMIT_KEY_SECRET", + "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=", + ) + get_settings.cache_clear() async with proposal_case(clean_postgres_database) as (values, factory, command, actor, grant): + operator, _ = await seed_review_actor(factory, None, role="operator", scope="system") + configuration = scripted_runtime_configuration( + get_settings(), adapter_profile_builder=lambda value: value + ) + async with proposal_client(factory, operator) as operator_client: + profile = await publish_scripted_model_profile( + operator_client, command.project_id, configuration + ) + configuration = configuration.model_copy( + update={ + "model_profile_id": UUID(profile["profile_id"]), + "model_profile_version": profile["spec"]["profile_version"], + "model_profile_digest": profile["profile_digest"], + } + ) setup = service_actor(values) derive, first = await prepare_post_policy(factory, command, actor, grant, setup) approval = PostPolicyApproval(target=first.target, idempotency_key=uuid4()) @@ -33,7 +65,9 @@ async def test_corrected_generation_replaces_policy_and_retains_original_receipt from tests.projects.guide_compilation.proposals.test_postgresql import correct successor = await correct(factory, command, actor, grant, GuideProposalCorrection( target=first.target.proposal, idempotency_key=uuid4(), reason='Reconsider the complete guide proposal')) - next_command = await finalize_corrected_attempt(factory, values, actor, successor) + next_command = await finalize_corrected_attempt( + factory, values, actor, successor, configuration=configuration + ) package = await read_package(factory, next_command, actor, grant) upstream = await approve(factory, next_command, actor, grant, GuideProposalApproval( target=package.target, idempotency_key=uuid4(), diff --git a/backend/tests/projects/unified_policy_fixtures.py b/backend/tests/projects/unified_policy_fixtures.py index 772059f2d..135d759d6 100644 --- a/backend/tests/projects/unified_policy_fixtures.py +++ b/backend/tests/projects/unified_policy_fixtures.py @@ -177,6 +177,8 @@ async def create_standalone_unified_policy( max_revision_rounds=2, pre_submit_registry_spec=None, post_submit_registry_spec=None, + post_submit_binding_setup=None, + post_submit_specification=None, ): """Arrange complete activated context before a downstream artifact transaction.""" from app.modules.projects.models import PreSubmitCheckerPolicy @@ -196,6 +198,8 @@ async def create_standalone_unified_policy( artifact_proposal=artifact_proposal, pre_submit_registry_spec=pre_submit_registry_spec, post_submit_registry_spec=post_submit_registry_spec, + post_submit_binding_setup=post_submit_binding_setup, + post_submit_specification=post_submit_specification, ) as (values, _, command, actor, grant): await seed_selected_review_revision_inputs( sessions, command, actor, human_review_required=human_review_required, diff --git a/backend/tests/selected_policy_fixtures.py b/backend/tests/selected_policy_fixtures.py index 74ab74b9c..236692038 100644 --- a/backend/tests/selected_policy_fixtures.py +++ b/backend/tests/selected_policy_fixtures.py @@ -16,6 +16,8 @@ async def approved_selected_policy_fixture( max_revision_rounds=2, pre_submit_registry_spec=None, post_submit_registry_spec=None, + post_submit_binding_setup=None, + post_submit_specification=None, ): """Build an activated guide backed only by immutable external policy custody.""" from app.interfaces.project_agents import SubmissionArtifactPolicyProposal @@ -38,6 +40,8 @@ async def approved_selected_policy_fixture( ), pre_submit_registry_spec=pre_submit_registry_spec, post_submit_registry_spec=post_submit_registry_spec, + post_submit_binding_setup=post_submit_binding_setup, + post_submit_specification=post_submit_specification, ) async with factory() as session: guide = await session.get(ProjectGuide, str(values["guide"])) diff --git a/backend/tests/tasks/submission_dispatch/test_revision_exhaustion_recovery.py b/backend/tests/tasks/submission_dispatch/test_revision_exhaustion_recovery.py index 0a4c7f802..2d39e0631 100644 --- a/backend/tests/tasks/submission_dispatch/test_revision_exhaustion_recovery.py +++ b/backend/tests/tasks/submission_dispatch/test_revision_exhaustion_recovery.py @@ -37,7 +37,11 @@ ) from tests.tasks.submission_dispatch.test_revision_expiry import _provision_expirer from tests.tasks.test_public_submission_intake import _client -from tests.test_alembic import _alembic_config, _execute +from tests.test_alembic import ( + _alembic_config, + _execute, + _historical_artifact_model_usage_projection, +) pytestmark = pytest.mark.postgres_schema_contract @@ -88,34 +92,41 @@ async def old_source(preparer, *args, **kwargs): monkeypatch, "0047_revision_source_cap_one", ) as h: - await assert_historical_revision_guards(h.factory, "0047_revision_recovery") - h.envelope = await invoked_completion(h) - await assert_historical_revision_guards(h.factory, "0047_revision_recovery") - with monkeypatch.context() as patch: - patch.setattr(TaskRoutingSourcePreparer, "prepare", old_source) - patch.setattr("app.modules.tasks.post_submit_routing.outcome.prepare_revision", insert_0047_preparation) - patch.setattr("app.modules.tasks.revision.service.head_for_source", _0047_head) - patch.setattr("app.modules.tasks.submission_context.head_for_source", _0047_head) - initial = await _apply(h) - async with h.factory() as session: - first = await _0047_head(session, initial.routing_manifest_id) - h.revision_preparation_id = first.id - h.revision_preparation_sha256 = first.preparation_sha256 - h.data = revision_archive(h.data) - prepared, admission = await _ready_replacement(h, h.data) - h.created = await _create(h, _creation_request(h, prepared, admission)) - assert h.created.submission_version == 2 - retained = await _fail_current_submission(h, terminal=True) - async with h.factory() as session: - blocked = await _0047_head(session, retained.routing_manifest_id) - assert (blocked.outcome, blocked.blocked_reason) == ("blocked", "revision_limit_reached") - assert blocked.next_revision_round == 2 and blocked.max_revision_rounds == 1 - before = await retained_revision_rows(session, retained.routing_manifest_id) - assert await session.scalar(text(""" - SELECT count(*) FROM pg_trigger t JOIN pg_class c ON c.oid=t.tgrelid - JOIN pg_namespace n ON n.oid=c.relnamespace - WHERE n.nspname='public' AND NOT t.tgisinternal AND t.tgenabled='D' - """)) == 0 + async with _historical_artifact_model_usage_projection(database_url): + await assert_historical_revision_guards(h.factory, "0047_revision_recovery") + h.envelope = await invoked_completion(h) + await assert_historical_revision_guards(h.factory, "0047_revision_recovery") + with monkeypatch.context() as patch: + patch.setattr(TaskRoutingSourcePreparer, "prepare", old_source) + patch.setattr( + "app.modules.tasks.post_submit_routing.outcome.prepare_revision", + insert_0047_preparation, + ) + patch.setattr("app.modules.tasks.revision.service.head_for_source", _0047_head) + patch.setattr("app.modules.tasks.submission_context.head_for_source", _0047_head) + initial = await _apply(h) + async with h.factory() as session: + first = await _0047_head(session, initial.routing_manifest_id) + h.revision_preparation_id = first.id + h.revision_preparation_sha256 = first.preparation_sha256 + h.data = revision_archive(h.data) + prepared, admission = await _ready_replacement(h, h.data) + h.created = await _create(h, _creation_request(h, prepared, admission)) + assert h.created.submission_version == 2 + retained = await _fail_current_submission(h, terminal=True) + async with h.factory() as session: + blocked = await _0047_head(session, retained.routing_manifest_id) + assert (blocked.outcome, blocked.blocked_reason) == ( + "blocked", + "revision_limit_reached", + ) + assert blocked.next_revision_round == 2 and blocked.max_revision_rounds == 1 + before = await retained_revision_rows(session, retained.routing_manifest_id) + assert await session.scalar(text(""" + SELECT count(*) FROM pg_trigger t JOIN pg_class c ON c.oid=t.tgrelid + JOIN pg_namespace n ON n.oid=c.relnamespace + WHERE n.nspname='public' AND NOT t.tgisinternal AND t.tgenabled='D' + """)) == 0 shape.undo() await leave_predecessor_owner_shape(_execute, database_url) await asyncio.to_thread(command.upgrade, _alembic_config(), current_schema_revision()) @@ -236,20 +247,27 @@ async def test_upgrade_backfills_assignments_around_a_retained_exhausted_assignm monkeypatch, "0048_revision_source_cap_zero", ) as h: - await assert_historical_revision_guards(h.factory, "0048_revision_exhaustion") - h.envelope = await invoked_completion(h) - await assert_historical_revision_guards(h.factory, "0048_revision_exhaustion") - exhausted = await _apply(h) - async with h.factory() as session: - receipt = await session.scalar(select(TaskRevisionExhaustion).where( - TaskRevisionExhaustion.routing_manifest_id == exhausted.routing_manifest_id, - )) - assert receipt is not None - before = await session.scalar(text( - "SELECT to_jsonb(a)-ARRAY['started_at','updated_at'] " - "FROM public.task_assignments a WHERE id=:id" - ), {"id": receipt.assignment_id}) - assert before["status"] == "revision_exhausted" + async with _historical_artifact_model_usage_projection(database_url): + await assert_historical_revision_guards(h.factory, "0048_revision_exhaustion") + h.envelope = await invoked_completion(h) + await assert_historical_revision_guards(h.factory, "0048_revision_exhaustion") + exhausted = await _apply(h) + async with h.factory() as session: + receipt = await session.scalar( + select(TaskRevisionExhaustion).where( + TaskRevisionExhaustion.routing_manifest_id + == exhausted.routing_manifest_id, + ) + ) + assert receipt is not None + before = await session.scalar( + text( + "SELECT to_jsonb(a)-ARRAY['started_at','updated_at'] " + "FROM public.task_assignments a WHERE id=:id" + ), + {"id": receipt.assignment_id}, + ) + assert before["status"] == "revision_exhausted" shape.undo() await leave_predecessor_owner_shape(_execute, database_url) await asyncio.to_thread(command.upgrade, _alembic_config(), current_schema_revision()) diff --git a/backend/tests/test_agent_runtime.py b/backend/tests/test_agent_runtime.py index d6ed46d9a..aaf300fd3 100644 --- a/backend/tests/test_agent_runtime.py +++ b/backend/tests/test_agent_runtime.py @@ -9,32 +9,39 @@ from types import SimpleNamespace from uuid import uuid4 -import pytest import httpx2 +import pytest from pydantic import ValidationError -from app.adapters.project_agents.openai_agent_sdk import OpenAIAgentSdkProjectGuideRuntime from app.adapters.project_agents import build_project_guide_adapter_profile +from app.adapters.project_agents.openai_agent_sdk import OpenAIAgentSdkProjectGuideRuntime from app.core.config import Settings from app.core.project_agents import ( project_guide_runtime_configuration, ) from app.core.project_guide_instructions import PROJECT_GUIDE_INSTRUCTIONS +from app.interfaces.guide_document_tools import GuideDocumentToolResult from app.interfaces.project_agents import ( - ProjectAgentRuntimeError, ProjectAgentRuntimeConfigurationError, + ProjectAgentRuntimeError, ProjectGuideCompilationInvalidOutputError, canonical_project_guide_compilation_context_bytes, invalid_compilation_failure_code, project_guide_compilation_prompt_bytes, ) from app.interfaces.project_guide_runtime import ProjectGuideRuntimeConfiguration -from app.interfaces.guide_document_tools import GuideDocumentToolResult +from app.interfaces.setup_model_proxy import SetupModelProxyReply from app.modules.projects.api.guide_documents import GuideDocumentManifest from tests.projects.guide_compilation.helpers import ( - SOURCE_SHA256, context, ids, generated_result as result, runtime_configuration, + SOURCE_SHA256, + context, + ids, + runtime_configuration, valid_specification, ) +from tests.projects.guide_compilation.helpers import ( + generated_result as result, +) @pytest.fixture(autouse=True) @@ -85,7 +92,38 @@ def _sdk_result(agent, value=None): )) -async def _compile(runtime, compilation_context, *, document_text="Assigned guide text."): +class _TestModelSession: + maximum_output_tokens = 4096 + reasoning_effort = "medium" + text_verbosity = "low" + + def __init__(self, dispatch=None): + self._dispatch = dispatch + + async def dispatch(self, phase, body): + if self._dispatch is None: + raise AssertionError("test unexpectedly dispatched a model request") + response = await self._dispatch(phase, body) + return SetupModelProxyReply( + outcome="completed", + call_index=0, + status_code=response.status_code, + response_json=json.dumps( + response.json(), sort_keys=True, separators=(",", ":") + ), + response_headers=(), + failure_code=None, + session_exhausted=False, + ) + + +async def _compile( + runtime, + compilation_context, + *, + document_text="Assigned guide text.", + model=None, +): """Supply scripted prior document access and always release circuit admission.""" opened = { compilation_context.material.handle_for(document) @@ -135,11 +173,14 @@ async def search_document(handle, query, *, offset=0, limit=20): "operation": "search", "text": value.text if query in value.text else "", }) - capabilities = SimpleNamespace(tools=SimpleNamespace( - opened_handles=opened_handles, - read_document=read_document, - search_document=search_document, - )) + capabilities = SimpleNamespace( + tools=SimpleNamespace( + opened_handles=opened_handles, + read_document=read_document, + search_document=search_document, + ), + model=model or _TestModelSession(), + ) runtime.admit_execution() try: return await runtime.compile_project_guide(compilation_context, capabilities) @@ -152,10 +193,10 @@ def test_unified_compilation_instructions_preserve_untrusted_and_lifecycle_bound assert text in PROJECT_GUIDE_INSTRUCTIONS -def test_runtime_requires_credentials_before_dispatch(monkeypatch): +def test_runtime_never_requires_provider_credentials(monkeypatch): monkeypatch.delenv("OPENAI_API_KEY") - with pytest.raises(ProjectAgentRuntimeConfigurationError, match="credentials are unavailable"): - OpenAIAgentSdkProjectGuideRuntime(runtime_configuration()) + runtime = OpenAIAgentSdkProjectGuideRuntime(runtime_configuration()) + assert runtime.identity.provider_key == "openai_agents_sdk" def test_configuration_concerns_are_independent_and_secret_free(): @@ -273,8 +314,8 @@ def test_snapshot_changes_identity_but_is_absent_from_provider_user_prompt(): async def test_unified_compilation_uses_scoped_tools_and_strict_output(monkeypatch): - from agents import Runner, FunctionTool import openai + from agents import FunctionTool, Runner clients = [] client_type = openai.AsyncOpenAI @@ -307,6 +348,8 @@ async def run(agent, prompt, **kwargs): assert agent.model_settings.store is False assert agent.model_settings.parallel_tool_calls is False assert agent.model_settings.extra_body is None + assert agent.model_settings.reasoning.effort == "medium" + assert agent.model_settings.verbosity == "low" assert agent.model_settings.retry.max_retries == 2 assert agent.model_settings.context_management is None assert agent.model_settings.response_include is None @@ -327,7 +370,7 @@ async def run(agent, prompt, **kwargs): assert calls[0][0] is calls[1][0] assert clients[0].max_retries == 0 and clients[0].is_closed() assert clients[0].timeout == 300 - assert str(clients[0].base_url).rstrip("/") == "https://api.openai.com/v1" + assert str(clients[0].base_url).rstrip("/") == "https://model-proxy.invalid/v1" async def test_actual_sdk_tool_loop_uses_only_openrouter_chat_and_neutral_tools( @@ -348,9 +391,9 @@ async def test_actual_sdk_tool_loop_uses_only_openrouter_chat_and_neutral_tools( ] ) - async def dispatch(request): - body = json.loads(request.content) - captured.append((str(request.url), dict(request.headers), body)) + async def dispatch(phase, body): + captured.append((phase, body)) + request = httpx2.Request("POST", "https://model-proxy.invalid/v1/chat/completions") return httpx2.Response(200, json=next(responses), request=request) clients, http_options = _patch_openai_transport(monkeypatch, dispatch) @@ -362,15 +405,20 @@ async def dispatch(request): } ) compilation = compilation.model_copy(update={"runtime_configuration": config}) - output = await _compile(OpenAIAgentSdkProjectGuideRuntime(config), compilation) + output = await _compile( + OpenAIAgentSdkProjectGuideRuntime(config), + compilation, + model=_TestModelSession(dispatch), + ) assert output == result() assert len(captured) == 4 - assert all(url == "https://openrouter.ai/api/v1/chat/completions" for url, _, _ in captured) - assert all(headers["authorization"] == "Bearer unit-test-only" for _, headers, _ in captured) - for _url, _headers, body in captured: + assert [phase for phase, _ in captured] == ["draft", "draft", "review", "review"] + for _phase, body in captured: assert body["model"] == "openai/gpt-5.6-terra" assert body["store"] is False assert body["parallel_tool_calls"] is False + assert body["reasoning_effort"] == "medium" + assert body["verbosity"] == "low" assert body["provider"] == { "allow_fallbacks": False, "data_collection": "deny", @@ -398,12 +446,12 @@ async def dispatch(request): owned_http_client = observed.pop("http_client") assert owned_http_client is not None assert observed == { - "api_key": "unit-test-only", - "base_url": "https://openrouter.ai/api/v1", + "api_key": "setup-model-proxy", + "base_url": "https://model-proxy.invalid/v1", "max_retries": 0, "timeout": 300, } - assert http_options == [{"trust_env": False, "follow_redirects": False}] + assert http_options == [] assert client.is_closed() @@ -423,8 +471,9 @@ async def test_actual_sdk_http_failures_are_bounded_and_sanitized(monkeypatch, f else: responses = None - async def dispatch(request): - calls.append(str(request.url)) + async def dispatch(_phase, body): + calls.append(body["model"]) + request = httpx2.Request("POST", "https://model-proxy.invalid/v1/chat/completions") if failure == "provider_fault": return httpx2.Response( 401, @@ -449,7 +498,11 @@ async def dispatch(request): else ProjectAgentRuntimeError ) with pytest.raises(expected) as caught: - await _compile(OpenAIAgentSdkProjectGuideRuntime(config), compilation) + await _compile( + OpenAIAgentSdkProjectGuideRuntime(config), + compilation, + model=_TestModelSession(dispatch), + ) assert "private" not in str(caught.value) assert len(calls) == {"malformed_schema": 3, "tool_bound": 2, "provider_fault": 1}[failure] assert clients[0][1].is_closed() @@ -459,7 +512,7 @@ async def test_actual_sdk_http_request_is_cancelled_and_client_closed(monkeypatc """Caller cancellation reaches the SDK transport and awaits client shutdown.""" started = asyncio.Event() - async def dispatch(_request): + async def dispatch(_phase, _body): started.set() await asyncio.Event().wait() @@ -472,7 +525,11 @@ async def dispatch(_request): }) compilation = compilation.model_copy(update={"runtime_configuration": config}) task = asyncio.create_task( - _compile(OpenAIAgentSdkProjectGuideRuntime(config), compilation) + _compile( + OpenAIAgentSdkProjectGuideRuntime(config), + compilation, + model=_TestModelSession(dispatch), + ) ) await started.wait() task.cancel() @@ -774,9 +831,7 @@ async def run(*args): @pytest.mark.parametrize("redacted", [True, False]) async def test_actual_sdk_parser_preserves_known_invalid_output(monkeypatch, kind, expected, redacted): """Exercise the installed SDK parser before the adapter receives any result.""" - from agents import Runner - - from agents import _debug + from agents import Runner, _debug monkeypatch.setattr(_debug, "DONT_LOG_MODEL_DATA", redacted) payload = _provider_payload() if kind == "schema": @@ -977,7 +1032,9 @@ async def run(*args): def test_sdk_output_schema_exposes_the_identifier_rule_to_the_model(): """The provider sees the same bounded lowercase rule that parsing enforces.""" import re + from agents import AgentOutputSchema + from app.interfaces.project_agents import ProjectGuideCompilationResult schema = AgentOutputSchema(ProjectGuideCompilationResult).json_schema() @@ -1001,6 +1058,7 @@ def test_sdk_output_schema_exposes_the_identifier_rule_to_the_model(): def test_sdk_output_schema_has_one_intake_configuration_and_separate_post_parameters(): """Actual SDK schema cannot ask the model for two conflicting intake policies.""" from agents import AgentOutputSchema + from app.interfaces.project_agents import ProjectGuideCompilationResult schema = AgentOutputSchema(ProjectGuideCompilationResult).json_schema() @@ -1041,6 +1099,8 @@ def test_default_instructions_advertise_contextual_output_constraints(): "Reopen the assigned originals", "Leave unspecified project", "source_item_id, document_version_id and sha256", + "model_evaluation requires model_service", + "registered project image and its exact approved profile", ): assert rule in instructions assert "setup_notes" not in instructions @@ -1133,7 +1193,8 @@ def test_manager_authored_settings_snapshot_records_absent_inference(): def _manager_material(rules: bytes, *, extra: tuple[bytes, ...] = (), source_ids=()): """Return a context with one PDF original, the rules document and optional Markdown.""" from tests.projects.guide_compilation.helpers import ( - SOURCE_BYTES, manager_runtime_configuration, + SOURCE_BYTES, + manager_runtime_configuration, ) base = context(ids(), configuration=manager_runtime_configuration()) @@ -1233,6 +1294,7 @@ async def _manager_compile(compilation_context, grant): async def test_manager_authored_runtime_converts_rules_without_credentials_or_network(monkeypatch): import socket + from app.adapters.project_agents.manager_authored import ManagerAuthoredProjectGuideRuntime monkeypatch.delenv("OPENAI_API_KEY") diff --git a/backend/tests/test_alembic.py b/backend/tests/test_alembic.py index 669546ecb..4950411de 100644 --- a/backend/tests/test_alembic.py +++ b/backend/tests/test_alembic.py @@ -1,6 +1,7 @@ from __future__ import annotations import asyncio +from contextlib import asynccontextmanager import hashlib import json from pathlib import Path @@ -74,6 +75,33 @@ async def _execute(database_url: str, statement: str, *arguments: object) -> Non await connection.close() +@asynccontextmanager +async def _historical_artifact_model_usage_projection(database_url: str): + """Expose nullable 0061 ORM columns while arranging older ART rows.""" + connection = await asyncpg.connect(database_url.replace("+asyncpg", "")) + columns = ( + ("artifact_put_attempts", "model_call_attempt_id"), + ("artifact_operation_receipts", "model_call_attempt_id"), + ) + installed: list[tuple[str, str]] = [] + try: + for table, column in columns: + await connection.execute(f"ALTER TABLE {table} ADD COLUMN {column} uuid") + installed.append((table, column)) + yield + finally: + unexpected = { + f"{table}.{column}": await connection.fetchval( + f"SELECT count(*) FROM {table} WHERE {column} IS NOT NULL" + ) + for table, column in installed + } + for table, column in reversed(installed): + await connection.execute(f"ALTER TABLE {table} DROP COLUMN {column}") + await connection.close() + assert all(count == 0 for count in unexpected.values()), unexpected + + async def _seed_retained_guide_runtime_rows( database_url: str, *, status: str, include_unknown: bool = False ) -> dict[str, object]: @@ -354,6 +382,7 @@ def test_v01_graph_has_one_root_and_head() -> None: assert [revision.revision for revision in revisions] == [ HEAD_REVISION, + "0061_project_catalogue_read", "0060_audit_restore_path", "0059_neutral_guide_runtime", "0058_compensation_unit_create", @@ -570,6 +599,7 @@ def test_repeated_upgrade_head_preserves_current_database( "0058_compensation_unit_create", "0059_neutral_guide_runtime", "0060_audit_restore_path", + "0061_project_catalogue_read", ], ) def test_preceding_head_upgrades_to_current_without_recreation( @@ -602,7 +632,12 @@ def test_preceding_head_upgrades_to_current_without_recreation( assert before["versions"] == [installed_revision] validators = ( asyncio.run(_audit_validator_custody(isolated_database_env)) - if installed_revision in ("0059_neutral_guide_runtime", "0060_audit_restore_path") + if installed_revision + in ( + "0059_neutral_guide_runtime", + "0060_audit_restore_path", + "0061_project_catalogue_read", + ) else None ) command.upgrade(config, HEAD_REVISION) @@ -610,7 +645,10 @@ def test_preceding_head_upgrades_to_current_without_recreation( updated = asyncio.run(_audit_validator_custody(isolated_database_env)) assert [row[:-1] for row in updated] == [row[:-1] for row in validators] assert all(row[-1] == ["search_path=pg_catalog, public, pg_temp"] for row in updated) - if installed_revision == "0060_audit_restore_path": + if installed_revision in ( + "0060_audit_restore_path", + "0061_project_catalogue_read", + ): assert updated == validators after = asyncio.run(_database_snapshot(isolated_database_env)) command.upgrade(config, "head") @@ -628,20 +666,7 @@ def test_neutral_runtime_upgrade_preserves_retained_provider_facts( isolated_database_env: str, migration_lock ) -> None: """0059 labels historical access without inventing parser evidence or losing IDs.""" - config = _alembic_config() - with migration_lock(): - asyncio.run( - _execute(isolated_database_env, "drop schema public cascade; create schema public") - ) - command.upgrade(config, "0058_compensation_unit_create") - seeded = asyncio.run( - _seed_retained_guide_runtime_rows( - isolated_database_env, status="compilation_provider_uncertain" - ) - ) - command.upgrade(config, HEAD_REVISION) - - async def retained(): + async def retained(seeded): connection = await asyncpg.connect(isolated_database_env.replace("+asyncpg", "")) try: attempt = await connection.fetchrow( @@ -663,7 +688,19 @@ async def retained(): finally: await connection.close() - attempt, allocations, access = asyncio.run(retained()) + config = _alembic_config() + with migration_lock(): + asyncio.run( + _execute(isolated_database_env, "drop schema public cascade; create schema public") + ) + command.upgrade(config, "0058_compensation_unit_create") + seeded = asyncio.run( + _seed_retained_guide_runtime_rows( + isolated_database_env, status="compilation_provider_uncertain" + ) + ) + command.upgrade(config, HEAD_REVISION) + attempt, allocations, access = asyncio.run(retained(seeded)) assert json.loads(attempt["runtime_configuration"]) == seeded["configuration"] assert attempt["runtime_configuration_hash"] == seeded["configuration_hash"] assert attempt["status"] == "compilation_provider_uncertain" @@ -689,6 +726,18 @@ def test_neutral_runtime_upgrade_refuses_old_predispatch_inference( """An old reserved inference attempt cannot cross into a runtime that cannot resume it.""" from sqlalchemy.exc import DBAPIError + async def retained_attempt_exists(attempt_id) -> bool: + connection = await asyncpg.connect(isolated_database_env.replace("+asyncpg", "")) + try: + return bool( + await connection.fetchval( + "select exists(select 1 from project_guide_compilation_attempts where id=$1)", + attempt_id, + ) + ) + finally: + await connection.close() + config = _alembic_config() with migration_lock(): asyncio.run( @@ -705,22 +754,11 @@ def test_neutral_runtime_upgrade_refuses_old_predispatch_inference( ): command.upgrade(config, HEAD_REVISION) after = asyncio.run(_database_snapshot(isolated_database_env)) + retained = asyncio.run(retained_attempt_exists(seeded["attempt_id"])) assert before == after assert after["versions"] == ["0058_compensation_unit_create"] - async def retained_attempt_exists() -> bool: - connection = await asyncpg.connect(isolated_database_env.replace("+asyncpg", "")) - try: - return bool( - await connection.fetchval( - "select exists(select 1 from project_guide_compilation_attempts where id=$1)", - seeded["attempt_id"], - ) - ) - finally: - await connection.close() - - assert asyncio.run(retained_attempt_exists()) + assert retained async def test_roles_anywhere_upgrade_preserves_old_proof_and_refuses_downgrade( diff --git a/backend/tests/test_api_contract_e2e.py b/backend/tests/test_api_contract_e2e.py index 09e022e6f..669f41b20 100644 --- a/backend/tests/test_api_contract_e2e.py +++ b/backend/tests/test_api_contract_e2e.py @@ -191,6 +191,40 @@ def test_api_contract_uses_runner_owned_minio_namespace( assert settings.artifact_s3_bucket == "workstream-ci-isolated-012345abcdef" +def test_full_api_contract_enables_local_artifacts_for_typed_model_sessions( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + """Provider-free API consumers still admit the typed setup-model capability.""" + api_contract = MODULES[0] + monkeypatch.setenv("RUNNER_TEMP", str(tmp_path)) + for key in ( + "WORKSTREAM_TEST_MINIO_ENDPOINT", + "WORKSTREAM_TEST_MINIO_BUCKET", + "WORKSTREAM_TEST_MINIO_PREFIX", + "WORKSTREAM_ARTIFACT_STORE_BACKEND", + ): + monkeypatch.delenv(key, raising=False) + + env = api_contract.api_environment() + assert env.get("WORKSTREAM_ARTIFACT_STORE_BACKEND", "disabled") == "disabled" + assert "WORKSTREAM_CHECKER_MODEL_PROXY_SOCKET" not in env + api_contract.configure_scripted_model_session_environment(env) + + assert env["WORKSTREAM_ARTIFACT_STORE_BACKEND"] == "local" + assert env["WORKSTREAM_ARTIFACT_LOCAL_ROOT"] != env["WORKSTREAM_ARTIFACT_SCRATCH_ROOT"] + assert Path(env["WORKSTREAM_ARTIFACT_LOCAL_ROOT"]).stat().st_mode & 0o777 == 0o700 + assert Path(env["WORKSTREAM_ARTIFACT_SCRATCH_ROOT"]).stat().st_mode & 0o777 == 0o700 + assert env["WORKSTREAM_CHECKER_MODEL_PROXY_SOCKET"].startswith(str(tmp_path) + "/") + for key, value in env.items(): + if key.startswith("WORKSTREAM_ARTIFACT_") or key == ( + "WORKSTREAM_CHECKER_MODEL_PROXY_SOCKET" + ): + monkeypatch.setenv(key, value) + settings = Settings(_env_file=None, environment=env["WORKSTREAM_ENVIRONMENT"]) + assert settings.artifact_store_backend == "local" + assert settings.checker_model_proxy_socket is not None + + def test_real_api_drill_provisions_exact_guide_artifact_pipeline_services() -> None: """The fan-in owns every fixed principal required before guide setup can run.""" api_contract = MODULES[0] diff --git a/backend/tests/test_api_controls.py b/backend/tests/test_api_controls.py index 463bb7702..9c10fddb5 100644 --- a/backend/tests/test_api_controls.py +++ b/backend/tests/test_api_controls.py @@ -634,13 +634,25 @@ def test_openapi_documents_request_error_and_response_context() -> None: assert repair_operation["x-workstream-action-id"] == "project.task.revision.repair" assert repair_operation["security"] new_manager_reads.add(revision_repair) - assert len(route_inventory) == 130 - retained_routes = sorted(set(route_inventory) - proposal_routes - post_policy_routes - queue_routes - new_manager_reads - guide_read - contribution_reads.keys() - public_entry_actions.keys() - registry_actions.keys() - recovery_actions.keys()) - retained_protected = sorted(set(protected_inventory) - proposal_routes - post_policy_routes - queue_routes - new_manager_reads - guide_read - contribution_reads.keys() - public_entry_actions.keys() - registry_actions.keys() - recovery_actions.keys()) + model_actions = { + "POST /api/v1/operations/projects/{project_id}/model-profiles": "project.model_profile.provision", + "POST /api/v1/operations/projects/{project_id}/model-budget-configurations": "project.model_budget.configure", + "GET /api/v1/operations/projects/{project_id}/model-usage/effects/{effect_id}": "project.model_usage.read", + "POST /api/v1/operations/projects/{project_id}/model-call-attempts/{parent_attempt_id}/recovery-children": "project.model_call.recovery_child.authorize", + } + for route, action in model_actions.items(): + assert route in protected_inventory + method, path = route.split(" ", 1) + operation = schema["paths"][path][method.lower()] + assert operation["x-workstream-action-id"] == action + assert operation["security"] + assert len(route_inventory) == 134 + retained_routes = sorted(set(route_inventory) - proposal_routes - post_policy_routes - queue_routes - new_manager_reads - guide_read - contribution_reads.keys() - public_entry_actions.keys() - registry_actions.keys() - recovery_actions.keys() - model_actions.keys()) + retained_protected = sorted(set(protected_inventory) - proposal_routes - post_policy_routes - queue_routes - new_manager_reads - guide_read - contribution_reads.keys() - public_entry_actions.keys() - registry_actions.keys() - recovery_actions.keys() - model_actions.keys()) assert sha256("\n".join(retained_routes).encode()).hexdigest() == ( "793237012e9256d308dd5d1e9c1a65e41381cf6306888fdd175dc9794b118444" ) - assert len(protected_inventory) == 128 + assert len(protected_inventory) == 132 assert sha256("\n".join(retained_protected).encode()).hexdigest() == ( "0953ae6392b81a4a7ba7c9f6b7fccc17768f34cd1f958572fdc553cd204ec82e" ) @@ -677,6 +689,7 @@ def test_openapi_documents_request_error_and_response_context() -> None: **public_entry_actions, **registry_actions, **recovery_actions, + **model_actions, **batch_actions, "GET /api/v1/tasks/{task_id}/submissions": "task.submission.list", "GET /api/v1/submissions/{submission_id}": "submission.read", diff --git a/backend/tests/test_api_rate_controls.py b/backend/tests/test_api_rate_controls.py index 88fa3fea1..02a446615 100644 --- a/backend/tests/test_api_rate_controls.py +++ b/backend/tests/test_api_rate_controls.py @@ -1093,6 +1093,16 @@ def test_production_rate_dependencies_match_exact_route_allowlist() -> None: route: {enforce_authorization_read_rate_limit} for route in ESTABLISHED_AUTHORIZATION_READ_RATE_ROUTES }, + **{ + ("POST", f"/operations/projects/{{project_id}}/{suffix}"): {enforce_admin_mutation_rate_limit} + for suffix in ( + "model-profiles", "model-budget-configurations", + "model-call-attempts/{parent_attempt_id}/recovery-children", + ) + }, + ("GET", "/operations/projects/{project_id}/model-usage/effects/{effect_id}"): { + enforce_authorization_read_rate_limit + }, **CHECKER_REGISTRY_RATE_BINDINGS, **CHECKER_RECOVERY_RATE_BINDINGS, } diff --git a/backend/tests/test_artifact_admission.py b/backend/tests/test_artifact_admission.py index ffac080d3..6718f4421 100644 --- a/backend/tests/test_artifact_admission.py +++ b/backend/tests/test_artifact_admission.py @@ -165,7 +165,7 @@ def admission_database_env(isolated_database_env: str) -> Iterator[str]: def _settings(tmp_path: Path, *, maximum_bytes: int = 1024) -> Settings: durable_root = tmp_path / "durable" - durable_root.mkdir(mode=0o700, parents=True) + durable_root.mkdir(mode=0o700, parents=True, exist_ok=True) return Settings( _env_file=None, **artifact_admission_limit_settings(maximum_bytes), @@ -476,7 +476,7 @@ async def test_committed_put_and_independent_verification_are_fenced( async with minted_source( tmp_path / "fenced-checker-output", b"independently verified bytes", - media_type="text/plain", + media_type="application/json", ) as source: _, _, _, admission = await _admit_checker_output( session, settings, namespace, source, relationships=relationships) @@ -567,7 +567,7 @@ def open(self, _provider_object_ref, **_kwargs): try: async with factory() as session: relationships = await seed_checker_output_relationships(session, namespace, policy_bundle=policy_bundle) - async with minted_source(tmp_path / "namespace-fence", b"fenced") as source: + async with minted_source(tmp_path / "namespace-fence", b"fenced", media_type="application/json") as source: _, _, _, admission = await _admit_checker_output( session, settings, namespace, source, relationships=relationships) drifted = ArtifactStorageOrchestrator( @@ -1316,7 +1316,7 @@ async def test_verification_resource_drift_after_read_is_stale_without_terminal_ try: async with factory() as session: relationships = await seed_checker_output_relationships(session, namespace, policy_bundle=policy_bundle) - async with minted_source(tmp_path / "verification-drift", b"expected") as source: + async with minted_source(tmp_path / "verification-drift", b"expected", media_type="application/json") as source: _, _, _, admission = await _admit_checker_output( session, settings, namespace, source, relationships=relationships) orchestrator = ArtifactStorageOrchestrator( @@ -1393,7 +1393,7 @@ async def test_verification_rechecks_relationship_after_prepare_before_io( try: async with factory() as session: relationships = await seed_checker_output_relationships(session, namespace, policy_bundle=policy_bundle) - async with minted_source(tmp_path / "preclaim-drift", b"expected") as source: + async with minted_source(tmp_path / "preclaim-drift", b"expected", media_type="application/json") as source: _, _, _, admission = await _admit_checker_output( session, settings, namespace, source, relationships=relationships) allowing = ArtifactStorageOrchestrator( @@ -1486,7 +1486,7 @@ async def prepare(self, **values: object) -> None: attempts: list[ArtifactPutAttempt] = [] for name, value in (("first", b"first"), ("second", b"second")): relationships = await seed_checker_output_relationships(session, namespace, policy_bundle=policy_bundle) - async with minted_source(tmp_path / name, value) as source: + async with minted_source(tmp_path / name, value, media_type="application/json") as source: _, _, _, admission = await _admit_checker_output( session, settings, namespace, source, relationships=relationships ) @@ -1544,7 +1544,7 @@ async def test_verification_rechecks_authorized_object_ref_before_io( try: async with factory() as session: relationships = await seed_checker_output_relationships(session, namespace, policy_bundle=policy_bundle) - async with minted_source(tmp_path / "preclaim-object-ref-drift", b"expected") as source: + async with minted_source(tmp_path / "preclaim-object-ref-drift", b"expected", media_type="application/json") as source: _, _, _, admission = await _admit_checker_output( session, settings, namespace, source, relationships=relationships) allowing = ArtifactStorageOrchestrator( @@ -1602,7 +1602,7 @@ async def test_verification_rechecks_authorized_object_ref_after_io( try: async with factory() as session: relationships = await seed_checker_output_relationships(session, namespace, policy_bundle=policy_bundle) - async with minted_source(tmp_path / "postread-object-ref-drift", b"expected") as source: + async with minted_source(tmp_path / "postread-object-ref-drift", b"expected", media_type="application/json") as source: _, _, _, admission = await _admit_checker_output( session, settings, namespace, source, relationships=relationships) orchestrator = ArtifactStorageOrchestrator( @@ -1664,7 +1664,7 @@ async def test_verification_terminal_result_matrix( try: async with factory() as session: relationships = await seed_checker_output_relationships(session, namespace, policy_bundle=policy_bundle) - async with minted_source(tmp_path / expected, b"verification matrix") as source: + async with minted_source(tmp_path / expected, b"verification matrix", media_type="application/json") as source: _, _, _, admission = await _admit_checker_output( session, settings, namespace, source, relationships=relationships) orchestrator = ArtifactStorageOrchestrator( @@ -1713,7 +1713,7 @@ async def test_verification_terminal_authority_denial_writes_zero_result_facts( async with factory() as session: relationships = await seed_checker_output_relationships(session, namespace, policy_bundle=policy_bundle) async with minted_source( - tmp_path / f"verify-{denial_reason}", denial_reason.encode() + tmp_path / f"verify-{denial_reason}", denial_reason.encode(), media_type="application/json" ) as source: _, _, _, admission = await _admit_checker_output( session, settings, namespace, source, relationships=relationships) @@ -2249,7 +2249,7 @@ async def test_checker_output_requires_exact_active_fixed_service_identity( baseline = await checker_admission_baseline(session) await session.rollback() - async with minted_source(tmp_path / "scratch-source", b"checker") as source: + async with minted_source(tmp_path / "scratch-source", b"checker", media_type="application/json") as source: service = ArtifactAdmissionService(session, settings, namespace) request = await _checker_output_request( session, checker_run_id, source @@ -2283,22 +2283,8 @@ async def test_checker_output_requires_exact_active_fixed_service_identity( replay = await service.admit( request, checker_output_authority=authority ) - takeover_request = replace( - request, - reservation=request.reservation.model_copy( - update={ - "worker_lease_id": new_record_id(), - "worker_lease_generation": 2, - } - ), - ) - takeover = await service.admit( - takeover_request, - checker_output_authority=authority, - ) - await assert_exact_checker_admission( - session, result=result, replay=replay, takeover=takeover, + session, result=result, replay=replay, request=request, authority=authority, project_id=project_id, task_id=task_id, checker_run_id=checker_run_id, baseline=baseline, ) @@ -2326,7 +2312,7 @@ async def test_invalid_checker_slot_precedes_namespace_drift( namespace, namespace_fingerprint="sha256:" + "f" * 64, ) - async with minted_source(tmp_path / "scratch-source", b"checker") as source: + async with minted_source(tmp_path / "scratch-source", b"checker", media_type="application/json") as source: valid = await _checker_output_request( session, checker_run_id, source ) @@ -2354,7 +2340,7 @@ async def _seed_verification_scan_jobs(factory, settings, namespace, store, tmp_ for index in range(3): relationships = await seed_checker_output_relationships(seed_session, namespace, policy_bundle=policy_bundle) async with minted_source( - tmp_path / f"verification-scan-{index}", f"job-{index}".encode() + tmp_path / f"verification-scan-{index}", f"job-{index}".encode(), media_type="application/json" ) as source: _, _, _, admission = await _admit_checker_output( seed_session, settings, namespace, source, relationships=relationships diff --git a/backend/tests/test_artifact_architecture.py b/backend/tests/test_artifact_architecture.py index 4822d5695..8886cb97c 100644 --- a/backend/tests/test_artifact_architecture.py +++ b/backend/tests/test_artifact_architecture.py @@ -8,12 +8,11 @@ from pathlib import Path import pytest -from tests.architecture_ast import imported_symbols_and_calls -from app.interfaces import artifact_operations from app.db.base import Base +from app.interfaces import artifact_operations from app.main import create_app - +from tests.architecture_ast import imported_symbols_and_calls BACKEND_ROOT = Path(__file__).parents[1] APP_ROOT = BACKEND_ROOT / "app" @@ -30,6 +29,7 @@ ) COMPOSITION_ROOT = APP_ROOT / "adapters" / "artifacts" / "__init__.py" CHECKER_COMPOSITION_ROOT = APP_ROOT / "adapters" / "checkers" / "__init__.py" +MODEL_RUNTIME_COMPOSITION = APP_ROOT / "adapters" / "checkers" / "model_runtime.py" AGENT_COMPOSITION_ROOT = APP_ROOT / "adapters/project_agents/__init__.py" GUIDE_PARSER_COMPOSITION_ROOT = APP_ROOT / "adapters/guide_document_tools/__init__.py" OBSERVABILITY_COMPOSITION_ROOT = APP_ROOT / "adapters" / "observability.py" @@ -96,6 +96,7 @@ "app.adapters.artifacts.local", "app.adapters.artifacts.s3_compatible", "app.adapters.checkers.external_service", + "app.adapters.checkers.model_provider", } CONCRETE_OBSERVABILITY_MODULES = { "opentelemetry.exporter.otlp.proto.http.metric_exporter", @@ -251,9 +252,9 @@ def test_only_artifact_custody_services_own_provider_execution() -> None: node.name: node for node in tree.body if isinstance(node, ast.ClassDef) - and node.name - in { + and node.name in { "ArtifactStorageOrchestrator", + "CheckerArtifactOutputService", "ScopedGuideDocumentGrant", "PostSubmissionMaterializer", } @@ -277,7 +278,12 @@ def test_only_artifact_custody_services_own_provider_execution() -> None: None, ) if owner is None or ( - owner.name in {"ScopedGuideDocumentGrant", "PostSubmissionMaterializer"} + owner.name + in { + "CheckerArtifactOutputService", + "ScopedGuideDocumentGrant", + "PostSubmissionMaterializer", + } and node.func.attr != "open" ): violations.append(f"{path.relative_to(BACKEND_ROOT)} calls {node.func.attr}") @@ -362,6 +368,7 @@ def _assert_concrete_adapter_composition( "LocalStorageAdapter", "S3CompatibleArtifactStore", "UnixSocketExternalCheckerAdapter", + "OpenAIResponsesCheckerModelProvider", } ) agent_calls.extend(path for name in calls if name in AGENT_RUNTIME_CONSTRUCTORS) @@ -372,17 +379,20 @@ def _assert_concrete_adapter_composition( ) assert Counter(factory_calls) == { COMPOSITION_ROOT: 1, - CHECKER_COMPOSITION_ROOT: 1, + CHECKER_COMPOSITION_ROOT: 2, # Inference and retained-resource cleanup are distinct typed capabilities. AGENT_COMPOSITION_ROOT: 2, OBSERVABILITY_COMPOSITION_ROOT: 1, GUIDE_PARSER_COMPOSITION_ROOT: 1, } - assert adapter_calls == [COMPOSITION_ROOT, S3_ADAPTER_MODULE, CHECKER_COMPOSITION_ROOT] + assert adapter_calls == [ + COMPOSITION_ROOT, S3_ADAPTER_MODULE, CHECKER_COMPOSITION_ROOT, CHECKER_COMPOSITION_ROOT, + ] assert set(concrete_imports) == { COMPOSITION_ROOT, CHECKER_COMPOSITION_ROOT, AWS_LIVE_PROOF_MODULE, + MODEL_RUNTIME_COMPOSITION, } assert agent_imports == [AGENT_COMPOSITION_ROOT] # Constructors are values in the one explicit registration map, invoked only @@ -670,7 +680,7 @@ def test_checker_output_contract_has_one_canonical_consumer_owner() -> None: expected_methods = { "CheckerOutputBindingPort": {"bind_checker_output"}, - "CheckerArtifactOutputPort": {"store", "recover"}, + "CheckerArtifactOutputPort": {"store", "recover", "read_response"}, } for name, methods in expected_methods.items(): assert { @@ -743,12 +753,17 @@ def _assert_checker_custody_consumers( expected = { "app.modules.checkers.api.output_custody": { "app/adapters/artifacts/__init__.py", + "app/adapters/checkers/model_proxy.py", + "app/modules/artifacts/api/checker_output_authority.py", "app/modules/artifacts/checker_output_bindings.py", "app/modules/artifacts/checker_output_custody.py", "app/modules/artifacts/checker_outputs.py", "app/modules/artifacts/schemas.py", "app/modules/artifacts/service.py", - "app/modules/checkers/execution_coordination.py", + "app/modules/authorization/checker_output_authorization.py", + "app/modules/authorization/domain/checker_outputs.py", + "app/modules/checkers/model_usage_custody.py", + "app/modules/tasks/post_submit_routing/evaluation_guard.py", }, "app.modules.checkers.api.materialization": { "app/modules/authorization/domain/post_submit.py", diff --git a/backend/tests/test_artifact_internal_authorization.py b/backend/tests/test_artifact_internal_authorization.py index 556b3b88b..fc5a0c38e 100644 --- a/backend/tests/test_artifact_internal_authorization.py +++ b/backend/tests/test_artifact_internal_authorization.py @@ -891,6 +891,39 @@ async def publish_job(job_id: str) -> None: await engine.dispose() +async def _pending_verification_with_service_authority( + session, settings, namespace, store, tmp_path, resolver, verifier, +): + """Store exact model evidence and return its pending verification job.""" + session.add_all((*resolver, *verifier)) + await session.commit() + relationships = await seed_checker_output_relationships(session, namespace) + async with minted_source( + tmp_path / "atomic-verify", b'{"verified":true}', media_type="application/json" + ) as source: + _, _, _, admission = await _admit_checker_output( + session, settings, namespace, source, relationships=relationships) + assert ( + await ArtifactStorageOrchestrator( + session, + store, + namespace, + settings, + _internal_authority(session, ServiceIdentity.ARTIFACT_PUT_RESOLVER), + ).execute_committed_put( + attempt_id=admission.attempt_id, + source=source, + ) + == "stored_pending_verification" + ) + + job = (await session.scalars(select(ArtifactVerificationJob).where( + ArtifactVerificationJob.originating_put_attempt_id == str(admission.attempt_id)))).one() + job_id = UUID(job.id) + await session.rollback() + return job, job_id + + @pytest.mark.asyncio async def test_verification_claim_and_terminal_failures_roll_back_both_sides( isolated_database_env: str, @@ -905,30 +938,9 @@ async def test_verification_claim_and_terminal_failures_roll_back_both_sides( bootstrap, store = _local_store(settings, namespace) try: async with factory() as session: - session.add_all((*resolver, *verifier)) - await session.commit() - relationships = await seed_checker_output_relationships(session, namespace) - async with minted_source(tmp_path / "atomic-verify", b"verified") as source: - _, _, _, admission = await _admit_checker_output( - session, settings, namespace, source, relationships=relationships) - assert ( - await ArtifactStorageOrchestrator( - session, - store, - namespace, - settings, - _internal_authority(session, ServiceIdentity.ARTIFACT_PUT_RESOLVER), - ).execute_committed_put( - attempt_id=admission.attempt_id, - source=source, - ) - == "stored_pending_verification" - ) - - job = (await session.scalars(select(ArtifactVerificationJob).where( - ArtifactVerificationJob.originating_put_attempt_id == str(admission.attempt_id)))).one() - job_id = UUID(job.id) - await session.rollback() + job, job_id = await _pending_verification_with_service_authority( + session, settings, namespace, store, tmp_path, resolver, verifier, + ) claim = ArtifactStorageOrchestrator( session, store, diff --git a/backend/tests/test_artifact_provider_activation_terminal_postgresql.py b/backend/tests/test_artifact_provider_activation_terminal_postgresql.py index 68d6e1372..96c396945 100644 --- a/backend/tests/test_artifact_provider_activation_terminal_postgresql.py +++ b/backend/tests/test_artifact_provider_activation_terminal_postgresql.py @@ -316,7 +316,8 @@ async def test_checker_output_shared_put_and_verification_lifecycle( async with factory() as session: relationships = await seed_checker_output_relationships(session, namespace, policy_bundle=policy_bundle) async with minted_source( - tmp_path / f"checker-{expected_outcome}", b"checker lifecycle" + tmp_path / f"checker-{expected_outcome}", b'{"output":"checker lifecycle"}', + media_type="application/json", ) as source: project_id, task_id, checker_run_id, admission = await _admit_checker_output( session, settings, namespace, source, relationships=relationships) @@ -344,12 +345,14 @@ async def test_checker_output_shared_put_and_verification_lifecycle( assert attempt is not None and receipt is not None and job is not None job_id = UUID(job.id) assert attempt.producer_request_type == "checker_output" - assert (attempt.project_id, attempt.task_id, attempt.checker_run_id) == ( + assert (attempt.project_id, attempt.task_id) == ( project_id, task_id, - checker_run_id, ) - assert receipt.checker_run_id == checker_run_id + assert attempt.checker_run_id is None + assert attempt.model_call_attempt_id is not None + assert receipt.checker_run_id is None + assert receipt.model_call_attempt_id == attempt.model_call_attempt_id with pytest.raises(DBAPIError): await session.execute( text( @@ -409,7 +412,8 @@ async def test_checker_output_put_observation_terminal_outcomes( async with factory() as session: relationships = await seed_checker_output_relationships(session, namespace, policy_bundle=policy_bundle) async with minted_source( - tmp_path / f"checker-observation-{expected_outcome}", b"checker observation" + tmp_path / f"checker-observation-{expected_outcome}", b'{"output":"checker observation"}', + media_type="application/json", ) as source: _project_id, _task_id, checker_run_id, admission = await _admit_checker_output( session, settings, namespace, source, relationships=relationships) @@ -491,7 +495,8 @@ async def observe(_commitment, *, operation_evidence): assert receipt.observed_sha256 is None assert receipt.observed_byte_count is None assert receipt.execution_generation == attempt.execution_generation - assert checker_run_id == attempt.checker_run_id + assert attempt.checker_run_id is None + assert attempt.model_call_attempt_id is not None assert orchestrator._require_terminal_activation.await_count >= 2 assert all( call.args == (activation_id,) @@ -526,7 +531,8 @@ async def test_expired_activation_rolls_back_provider_verification_facts( ) async with minted_source( tmp_path / f"expired-{provider_outcome}", - b"provider terminal evidence", + b'{"output":"provider terminal evidence"}', + media_type="application/json", ) as source: _project_id, _task_id, _checker_run_id, admission = await _admit_checker_output( session, @@ -748,7 +754,9 @@ async def test_verification_unavailable_retries_then_exhausts( try: async with factory() as session: relationships = await seed_checker_output_relationships(session, namespace, policy_bundle=policy_bundle) - async with minted_source(tmp_path / "unavailable", b"retry") as source: + async with minted_source( + tmp_path / "unavailable", b'{"output":"retry"}', media_type="application/json" + ) as source: _, _, _, admission = await _admit_checker_output( session, settings, namespace, source, relationships=relationships) orchestrator = ArtifactStorageOrchestrator( diff --git a/backend/tests/test_artifact_recovery.py b/backend/tests/test_artifact_recovery.py index 471cdad94..cafc6818c 100644 --- a/backend/tests/test_artifact_recovery.py +++ b/backend/tests/test_artifact_recovery.py @@ -192,7 +192,8 @@ async def _exhausted_job(session, settings, tmp_path, context): ) relationships = await seed_checker_output_relationships(session, namespace, policy_bundle=policy_bundle) async with minted_source( - tmp_path / "checker-output", b"recover checker output", limits=limits, + tmp_path / "checker-output", b'{"output":"recover checker output"}', + media_type="application/json", limits=limits, ) as source: project_id, task_id, _checker_run_id, admission = await _admit_checker_output( session, settings, namespace, source, relationships=relationships) diff --git a/backend/tests/test_authorization.py b/backend/tests/test_authorization.py index afcd7ccf4..a6f7cb818 100644 --- a/backend/tests/test_authorization.py +++ b/backend/tests/test_authorization.py @@ -15,6 +15,8 @@ ) from tests.authorization.catalogue_fixtures import ( + ADMIN_ROLE_PERMISSION_EXPECTATIONS, + ADMIN_ROLE_SCOPE_EXPECTATIONS, ART_CUSTODY_EXPECTATIONS, REV_CUSTODY_EXPECTATIONS, ) @@ -2233,42 +2235,8 @@ def test_fixed_service_action_matrix_rejects_metadata_drift( def test_administrative_role_policy_and_definition_responses_are_exact() -> None: - expected_permissions = { - AdminRole.ACCESS_ADMINISTRATOR: """actor.profile.read_any actor.profile.suspend - actor.profile.reactivate actor.profile.deactivate actor.identity_link.read - actor.identity_link.revoke actor.identity_link.reactivate actor.service.provision - admin_role.read admin_role.grant admin_role.revoke audit.read audit.export""".split(), - AdminRole.OPERATOR: """project.read project.setup_diagnostic.read - project.effective_policy.read review.queue.inspect review.lease.force_release - contribution.read_project operations.status.read operations.timer.run operations.reconcile.run operations.outbox.retry - operations.projection.rebuild operations.task.start_override - operations.submission_gate.repair operations.checker.retry artifact.binding.read - artifact.replica.read artifact.receipt.read artifact.verification_job.read - artifact.verification_job.retry artifact.recovery_attempt.read artifact.audit.read - audit.read""".split(), - AdminRole.PROJECT_MANAGER: """project.create project.read project.setup_diagnostic.read - project.effective_policy.read project.update project.archive project.guide.manage - project.guide_compilation.request project.effective_policy.manage project.task.manage - project.review_policy.manage project.role_grant.read project.role_grant.manage - artifact.guide_source.ingest - review.queue.inspect contribution.read_project compensation.award.read - audit.read""".split(), - AdminRole.FINANCE_AUTHORITY: """project.read contribution.read_project - compensation.policy.manage compensation.adapter_binding.manage - compensation.award.read compensation.delivery.reconcile audit.read""".split(), - AdminRole.AUDIT_AUTHORITY: """actor.profile.read_any actor.identity_link.read - admin_role.read project.read project.setup_diagnostic.read - project.effective_policy.read project.role_grant.read review.queue.inspect - review.chain.read contribution.read_project compensation.award.read audit.read - audit.export""".split(), - } - expected_scopes = { - AdminRole.ACCESS_ADMINISTRATOR: [AdminScope.SYSTEM], - AdminRole.OPERATOR: [AdminScope.SYSTEM], - AdminRole.PROJECT_MANAGER: [AdminScope.SYSTEM, AdminScope.PROJECT], - AdminRole.FINANCE_AUTHORITY: [AdminScope.SYSTEM, AdminScope.PROJECT], - AdminRole.AUDIT_AUTHORITY: [AdminScope.SYSTEM, AdminScope.PROJECT], - } + expected_permissions = ADMIN_ROLE_PERMISSION_EXPECTATIONS + expected_scopes = ADMIN_ROLE_SCOPE_EXPECTATIONS assert { role: [permission.value for permission in permissions] @@ -2715,7 +2683,7 @@ async def record_conflict(self, **kwargs) -> None: definition.action_id, definition.permission_id, definition.owner, - ActionAvailability.ACTIVE, + ActionAvailability.PLANNED, ) if definition.action_id is ActionId.ARTIFACT_CHECKER_OUTPUT_WRITE else definition diff --git a/backend/tests/test_checker_output_custody.py b/backend/tests/test_checker_output_custody.py index d42754f1f..e246e5d02 100644 --- a/backend/tests/test_checker_output_custody.py +++ b/backend/tests/test_checker_output_custody.py @@ -3,15 +3,22 @@ from __future__ import annotations import asyncio -from contextlib import asynccontextmanager -from types import SimpleNamespace +import hashlib from uuid import UUID import pytest from sqlalchemy import func, select -from app.adapters.artifacts import checker_output_binding, checker_output_storage -from app.core.identifiers import new_record_id +from tests.authentication.fixtures import ( + auth_database_env as auth_database_env, + clear_settings_cache as clear_settings_cache, + rsa_signing_material as rsa_signing_material, +) +from tests.authorization.admin_access.fixtures import ( + admin_access as admin_access, + signed_access as signed_access, +) + from app.interfaces.artifacts import ArtifactLimitExceededError from app.modules.actors.api import ServiceIdentity from app.modules.artifacts.models import ( @@ -19,83 +26,32 @@ ArtifactOperationReceipt, ArtifactPutObservationReceipt, ArtifactPutAttempt, + ArtifactReplica, ArtifactVerificationJob, ArtifactVerificationReceipt, ) -from app.modules.artifacts.service import ArtifactAdmissionConflictError from app.modules.checkers.api.output_custody import ( CheckerOutputArtifactRequest, CheckerOutputBindingRequest, - CheckerOutputSelector, CheckerOutputUnavailable, ) -from app.modules.checkers.models import CheckerRun +from app.modules.model_usage.models import ModelUsageEffect from tests.checker_output_custody_helpers import ( byte_stream, output_custody_harness, selector_for, ) -from tests.checkers.execution.support import selected_request - - -async def test_default_composition_denies_before_owner_source_or_storage() -> None: - """Keep both production output capabilities unavailable before protected access.""" - - class Forbidden: - def __getattr__(self, name): - pytest.fail(f"protected access: {name}") - - @asynccontextmanager - async def session_scope(): - yield object() - - iterated = False - - async def source(): - nonlocal iterated - iterated = True - yield b"must-not-be-read" - - selector = CheckerOutputSelector( - evaluation=selected_request(), - checker_run_id=new_record_id(), - worker_lease_id=new_record_id(), - worker_lease_generation=1, - slot_key="platform-review", - ) - forbidden = Forbidden() - storage = checker_output_storage( - sessions=session_scope, - store=forbidden, - namespace=forbidden, - preparation=forbidden, - settings=forbidden, - ) - with pytest.raises(CheckerOutputUnavailable, match="write_unavailable"): - await storage.store(CheckerOutputArtifactRequest(selector, source())) - assert not iterated - - class Transaction: - def in_transaction(self): - return True - - def in_nested_transaction(self): - return False - - binding = checker_output_binding( - Transaction(), - namespace=SimpleNamespace(namespace_fingerprint="sha256:" + "0" * 64), - ) - with pytest.raises(CheckerOutputUnavailable, match="binding_unavailable"): - await binding.bind_checker_output( - CheckerOutputBindingRequest(selector, new_record_id(), new_record_id()) - ) + + +def content_sha256(value: bytes) -> str: + return "sha256:" + hashlib.sha256(value).hexdigest() @pytest.mark.parametrize("provider", ["local", "minio"]) async def test_real_store_verification_and_binding( tmp_path, isolated_database_env, + admin_access, provider, ) -> None: """Store, independently verify and bind one exact output through real providers.""" @@ -106,6 +62,7 @@ async def test_real_store_verification_and_binding( async with output_custody_harness( tmp_path, isolated_database_env, + admin_access=admin_access, provider=provider, ) as harness: result = await harness.service.store(harness.request(b"verified checker output")) @@ -136,14 +93,41 @@ async def test_real_store_verification_and_binding( row.put_attempt_id, row.verification_receipt_id, ) == ( - "checker_run", - str(harness.selector.checker_run_id), + "checker_model_call", + str(harness.selector.model_call_attempt_id), harness.selector.slot_key, ServiceIdentity.ARTIFACT_CHECKER_OUTPUT.value, "service_identity", str(result.put_attempt_id), str(result.verification_receipt_id), ) + attempt = await session.get(ArtifactPutAttempt, str(result.put_attempt_id)) + receipt = await session.scalar( + select(ArtifactOperationReceipt).where( + ArtifactOperationReceipt.put_attempt_id == str(result.put_attempt_id) + ) + ) + job = await session.scalar( + select(ArtifactVerificationJob).where( + ArtifactVerificationJob.originating_put_attempt_id == str(result.put_attempt_id) + ) + ) + assert attempt is not None and receipt is not None and job is not None + replica = await session.get(ArtifactReplica, job.replica_id) + assert replica is not None + assert ( + receipt.model_call_attempt_id, + receipt.logical_role, + attempt.checker_output_custody_sealed, + job.checker_output_custody_sealed, + replica.checker_output_custody_sealed, + ) == ( + str(harness.selector.model_call_attempt_id), + harness.selector.slot_key, + True, + True, + True, + ) assert harness.state.admissions assert harness.state.recoveries assert harness.state.bindings @@ -152,9 +136,12 @@ async def test_real_store_verification_and_binding( async def test_identical_store_replay_reuses_verified_custody_without_provider_work( tmp_path, isolated_database_env, + admin_access, ) -> None: """Replay exact bytes through the stable attempt without another put or verify.""" - async with output_custody_harness(tmp_path, isolated_database_env) as harness: + async with output_custody_harness( + tmp_path, isolated_database_env, admin_access=admin_access + ) as harness: first = await harness.service.store(harness.request(b"stable replay output")) assert first.status == "verified" assert first.content_id is not None @@ -199,7 +186,9 @@ async def test_identical_store_replay_reuses_verified_custody_without_provider_w select(func.count()) .select_from(ArtifactPutAttempt) .where( - ArtifactPutAttempt.checker_run_id == str(harness.selector.checker_run_id) + ArtifactPutAttempt.model_call_attempt_id + == str(harness.selector.model_call_attempt_id), + ArtifactPutAttempt.logical_role == harness.selector.slot_key, ) ) == 1 @@ -235,17 +224,19 @@ async def test_identical_store_replay_reuses_verified_custody_without_provider_w async def test_foreign_lineage_and_changed_bytes_fail_before_provider( tmp_path, isolated_database_env, + admin_access, ) -> None: """Reject coherent foreign ownership before bytes and immutable replay drift before put.""" - async with output_custody_harness(tmp_path, isolated_database_env) as harness: + async with output_custody_harness( + tmp_path, isolated_database_env, admin_access=admin_access + ) as harness: original_reservation = harness.state.reservation - original_selector = harness.selector - foreign = await harness.seed_reservation() + foreign = await harness.seed_reservation(content_sha256=content_sha256(b"foreign control")) foreign_selector = selector_for(foreign) - original = await harness.service.store( - harness.request(b"stable output", selector=original_selector) - ) + original = await harness.service.store(harness.request(b"stable output")) + original_reservation = harness.state.reservation + original_selector = harness.selector assert original.status == "verified" harness.state.reservation = foreign foreign_result = await harness.service.store( @@ -258,7 +249,9 @@ async def test_foreign_lineage_and_changed_bytes_fail_before_provider( harness.state.reservation = original_reservation for wrong_selector in ( foreign_selector, - original_selector.model_copy(update={"evaluation": foreign.evaluation}), + original_selector.model_copy( + update={"model_call_attempt_id": foreign_selector.model_call_attempt_id} + ), ): observed: list[bytes] = [] with pytest.raises(CheckerOutputUnavailable, match="reservation_unavailable"): @@ -273,7 +266,7 @@ async def test_foreign_lineage_and_changed_bytes_fail_before_provider( assert harness.store.puts == 2 assert (await harness.manager.usage()).reservation_count == 0 - with pytest.raises(ArtifactAdmissionConflictError): + with pytest.raises(CheckerOutputUnavailable, match="authority_unavailable"): await harness.service.store( harness.request(b"changed output", selector=original_selector) ) @@ -284,15 +277,21 @@ async def test_foreign_lineage_and_changed_bytes_fail_before_provider( async def test_binding_rejects_mixed_stored_lineage_before_consumption_or_mutation( tmp_path, isolated_database_env, + admin_access, ) -> None: """Bind two valid controls, then reject every original/foreign custody mix.""" - async with output_custody_harness(tmp_path, isolated_database_env) as harness: + async with output_custody_harness( + tmp_path, isolated_database_env, admin_access=admin_access + ) as harness: original_reservation = harness.state.reservation - original_selector = harness.selector - foreign_reservation = await harness.seed_reservation() + foreign_reservation = await harness.seed_reservation( + content_sha256=content_sha256(b"foreign binding control") + ) foreign_selector = selector_for(foreign_reservation) original = await harness.service.store(harness.request(b"original binding control")) + original_reservation = harness.state.reservation + original_selector = harness.selector harness.state.reservation = foreign_reservation foreign = await harness.service.store( harness.request(b"foreign binding control", selector=foreign_selector) @@ -358,9 +357,12 @@ async def test_binding_rejects_mixed_stored_lineage_before_consumption_or_mutati async def test_binding_rollback_and_concurrent_replay_are_atomic( tmp_path, isolated_database_env, + admin_access, ) -> None: """Keep caller rollback empty and serialize concurrent binding to one row.""" - async with output_custody_harness(tmp_path, isolated_database_env) as harness: + async with output_custody_harness( + tmp_path, isolated_database_env, admin_access=admin_access + ) as harness: stored = await harness.service.store(harness.request(b"binding output")) assert stored.verification_receipt_id is not None request = CheckerOutputBindingRequest( @@ -386,9 +388,12 @@ async def bind_once(): assert sorted((first.replayed, second.replayed)) == [False, True] async with harness.factory() as session: count = await session.scalar( - select(func.count()).select_from(ArtifactBinding).where( - ArtifactBinding.resource_type == "checker_run", - ArtifactBinding.resource_id == str(harness.selector.checker_run_id), + select(func.count()) + .select_from(ArtifactBinding) + .where( + ArtifactBinding.resource_type == "checker_model_call", + ArtifactBinding.resource_id == str(harness.selector.model_call_attempt_id), + ArtifactBinding.logical_role == harness.selector.slot_key, ) ) assert count == 1 @@ -397,9 +402,12 @@ async def bind_once(): async def test_unknown_put_is_observed_recovered_and_bound_without_bytes( tmp_path, isolated_database_env, + admin_access, ) -> None: """Recover lost acknowledgement through typed observation evidence and bind it.""" - async with output_custody_harness(tmp_path, isolated_database_env) as harness: + async with output_custody_harness( + tmp_path, isolated_database_env, admin_access=admin_access + ) as harness: harness.store.lose_put_acknowledgement = True unknown = await harness.service.store(harness.request(b"observed output")) assert unknown.status == "acknowledgement_unknown" @@ -443,70 +451,57 @@ async def test_unknown_put_is_observed_recovered_and_bound_without_bytes( assert bound.content_id == recovered.content_id -async def test_worker_takeover_recovers_same_attempt_without_bytes( - tmp_path, - isolated_database_env, -) -> None: - """Fence the old lease and recover exact stored custody under its replacement.""" - async with output_custody_harness(tmp_path, isolated_database_env) as harness: - old_selector = harness.selector - stored = await harness.service.store(harness.request(b"takeover output")) - puts = harness.store.puts - harness.state.reservation = harness.state.reservation.model_copy( - update={ - "worker_lease_id": new_record_id(), - "worker_lease_generation": old_selector.worker_lease_generation + 1, - } - ) - - with pytest.raises(CheckerOutputUnavailable, match="reservation_unavailable"): - await harness.service.recover(old_selector) - recovered = await harness.service.recover(harness.selector) - assert recovered is not None - assert recovered.put_attempt_id == stored.put_attempt_id - assert recovered.verification_receipt_id == stored.verification_receipt_id - assert recovered.replayed - assert harness.store.puts == puts - - async def test_authority_revocation_during_put_prevents_return_and_binding( tmp_path, isolated_database_env, + admin_access, ) -> None: """Recheck authority after provider I/O and deny publication after revocation.""" - async with output_custody_harness(tmp_path, isolated_database_env) as harness: + async with output_custody_harness( + tmp_path, isolated_database_env, admin_access=admin_access + ) as harness: harness.store.put_entered = asyncio.Event() harness.store.put_release = asyncio.Event() operation = asyncio.create_task(harness.service.store(harness.request(b"revoked output"))) await asyncio.wait_for(harness.store.put_entered.wait(), timeout=10) async with harness.factory() as probe, probe.begin(): - locked_run = await probe.scalar( - select(CheckerRun) - .where(CheckerRun.id == str(harness.selector.checker_run_id)) + locked_effect = await probe.scalar( + select(ModelUsageEffect) + .where(ModelUsageEffect.id == str(harness.selector.model_call_attempt_id)) .with_for_update(nowait=True) ) locked_attempt = await probe.scalar( select(ArtifactPutAttempt) - .where(ArtifactPutAttempt.checker_run_id == str(harness.selector.checker_run_id)) + .where( + ArtifactPutAttempt.model_call_attempt_id + == str(harness.selector.model_call_attempt_id), + ArtifactPutAttempt.logical_role == harness.selector.slot_key, + ) .with_for_update(nowait=True) ) - assert locked_run is not None and locked_attempt is not None + assert locked_effect is not None and locked_attempt is not None harness.state.revoked = True harness.store.put_release.set() with pytest.raises(CheckerOutputUnavailable, match="authority_revoked"): await operation async with harness.factory() as session: - attempt = (await session.scalars( - select(ArtifactPutAttempt).where( - ArtifactPutAttempt.checker_run_id == str(harness.selector.checker_run_id) + attempt = ( + await session.scalars( + select(ArtifactPutAttempt).where( + ArtifactPutAttempt.model_call_attempt_id + == str(harness.selector.model_call_attempt_id), + ArtifactPutAttempt.logical_role == harness.selector.slot_key, + ) ) - )).one() - receipt = (await session.scalars( - select(ArtifactVerificationReceipt).join(ArtifactVerificationJob).where( - ArtifactVerificationJob.originating_put_attempt_id == attempt.id + ).one() + receipt = ( + await session.scalars( + select(ArtifactVerificationReceipt) + .join(ArtifactVerificationJob) + .where(ArtifactVerificationJob.originating_put_attempt_id == attempt.id) ) - )).one() + ).one() assert attempt is not None and receipt is not None attempt_id, receipt_id = UUID(attempt.id), UUID(receipt.id) await session.rollback() @@ -520,9 +515,12 @@ async def test_authority_revocation_during_put_prevents_return_and_binding( ) ) binding_count = await session.scalar( - select(func.count()).select_from(ArtifactBinding).where( - ArtifactBinding.resource_type == "checker_run", - ArtifactBinding.resource_id == str(harness.selector.checker_run_id), + select(func.count()) + .select_from(ArtifactBinding) + .where( + ArtifactBinding.resource_type == "checker_model_call", + ArtifactBinding.resource_id == str(harness.selector.model_call_attempt_id), + ArtifactBinding.logical_role == harness.selector.slot_key, ) ) assert binding_count == 0 @@ -531,9 +529,12 @@ async def test_authority_revocation_during_put_prevents_return_and_binding( async def test_cancellation_during_provider_put_cleans_scratch_and_retains_uncertainty( tmp_path, isolated_database_env, + admin_access, ) -> None: """Preserve cancellation while retaining only the durable in-flight attempt.""" - async with output_custody_harness(tmp_path, isolated_database_env) as harness: + async with output_custody_harness( + tmp_path, isolated_database_env, admin_access=admin_access + ) as harness: harness.store.put_entered = asyncio.Event() harness.store.put_release = asyncio.Event() operation = asyncio.create_task(harness.service.store(harness.request(b"cancelled output"))) @@ -563,7 +564,9 @@ async def test_cancellation_during_provider_put_cleans_scratch_and_retains_uncer async with harness.factory() as session: attempt = await session.scalar( select(ArtifactPutAttempt).where( - ArtifactPutAttempt.checker_run_id == str(harness.selector.checker_run_id) + ArtifactPutAttempt.model_call_attempt_id + == str(harness.selector.model_call_attempt_id), + ArtifactPutAttempt.logical_role == harness.selector.slot_key, ) ) assert attempt is not None @@ -580,8 +583,9 @@ async def test_cancellation_during_provider_put_cleans_scratch_and_retains_uncer select(func.count()) .select_from(ArtifactOperationReceipt) .where( - ArtifactOperationReceipt.checker_run_id - == str(harness.selector.checker_run_id) + ArtifactOperationReceipt.model_call_attempt_id + == str(harness.selector.model_call_attempt_id), + ArtifactOperationReceipt.logical_role == harness.selector.slot_key, ) ) == 0 @@ -607,8 +611,9 @@ async def test_cancellation_during_provider_put_cleans_scratch_and_retains_uncer select(func.count()) .select_from(ArtifactBinding) .where( - ArtifactBinding.resource_type == "checker_run", - ArtifactBinding.resource_id == str(harness.selector.checker_run_id), + ArtifactBinding.resource_type == "checker_model_call", + ArtifactBinding.resource_id == str(harness.selector.model_call_attempt_id), + ArtifactBinding.logical_role == harness.selector.slot_key, ) ) == 0 @@ -618,11 +623,13 @@ async def test_cancellation_during_provider_put_cleans_scratch_and_retains_uncer async def test_per_slot_cap_stops_source_and_cleans_scratch( tmp_path, isolated_database_env, + admin_access, ) -> None: """Apply the owner-issued cap before excess bytes reach durable intent or provider.""" async with output_custody_harness( tmp_path, isolated_database_env, + admin_access=admin_access, maximum_bytes=4, ) as harness: observed: list[bytes] = [] @@ -640,6 +647,10 @@ async def test_per_slot_cap_stops_source_and_cleans_scratch( attempts = await session.scalar( select(func.count()) .select_from(ArtifactPutAttempt) - .where(ArtifactPutAttempt.producer_request_type == "checker_output") + .where( + ArtifactPutAttempt.model_call_attempt_id + == str(harness.selector.model_call_attempt_id), + ArtifactPutAttempt.logical_role == harness.selector.slot_key, + ) ) assert attempts == 0 diff --git a/backend/tests/test_checker_output_storage.py b/backend/tests/test_checker_output_storage.py index 0f17241fd..871692274 100644 --- a/backend/tests/test_checker_output_storage.py +++ b/backend/tests/test_checker_output_storage.py @@ -55,11 +55,13 @@ async def _store_verified_output( reservation_ttl_seconds=360, total_deadline_seconds=300, ) - relationships = await seed_checker_output_relationships(session, namespace, policy_bundle=policy_bundle) + relationships = await seed_checker_output_relationships( + session, namespace, policy_bundle=policy_bundle + ) async with minted_source( source_path, payload, - media_type="text/plain", + media_type="application/json", limits=limits, ) as source: project_id, task_id, checker_run_id, admission = await _admit_checker_output( @@ -77,14 +79,16 @@ async def _store_verified_output( _AllowArtifactAuthority(), ) await orchestrator.ensure_storage_namespace() - assert await orchestrator.execute_committed_put( - attempt_id=admission.attempt_id, - source=source, - ) == "stored_pending_verification" + assert ( + await orchestrator.execute_committed_put( + attempt_id=admission.attempt_id, + source=source, + ) + == "stored_pending_verification" + ) job_id = await session.scalar( select(ArtifactVerificationJob.id).where( - ArtifactVerificationJob.originating_put_attempt_id - == str(admission.attempt_id) + ArtifactVerificationJob.originating_put_attempt_id == str(admission.attempt_id) ) ) assert job_id is not None @@ -98,8 +102,10 @@ async def _store_verified_output( ) attempt = await session.get(ArtifactPutAttempt, str(admission.attempt_id)) assert attempt is not None and receipt_id is not None + assert attempt.model_call_attempt_id is not None assert attempt.replica_id is not None logical_role = attempt.logical_role + model_call_attempt_id = attempt.model_call_attempt_id replica_id = attempt.replica_id content_id = await session.scalar( text("select content_id from artifact_replicas where id=:replica_id"), @@ -112,6 +118,7 @@ async def _store_verified_output( project_id=project_id, task_id=task_id, checker_run_id=checker_run_id, + model_call_attempt_id=model_call_attempt_id, put_attempt_id=str(admission.attempt_id), verification_job_id=str(job_id), replica_id=str(replica_id), @@ -140,7 +147,7 @@ async def _verified_output(database_url: str, tmp_path: Path): store=store, policy_bundle=policy_bundle, source_path=tmp_path / "checker-output", - payload=b"exact checker output", + payload=b'{"result":"exact checker output"}', ) case.settings = settings case.store = store @@ -156,8 +163,8 @@ def _binding_values(case, **changes): "id": str(new_record_id()), "content_id": case.content_id, "project_id": case.project_id, - "resource_type": "checker_run", - "resource_id": case.checker_run_id, + "resource_type": "checker_model_call", + "resource_id": case.model_call_attempt_id, "logical_role": case.logical_role, "scope_version": 1, "actor_id": ServiceIdentity.ARTIFACT_CHECKER_OUTPUT.value, @@ -170,6 +177,16 @@ def _binding_values(case, **changes): return values +def _historical_checker_run_binding_values(case, **changes): + """Build the retained pre-model-call CHECKERS binding used by migration proofs.""" + return _binding_values( + case, + resource_type="checker_run", + resource_id=case.checker_run_id, + **changes, + ) + + _INSERT_BINDING = text( """insert into artifact_bindings( id,content_id,project_id,resource_type,resource_id,logical_role,scope_version, @@ -212,10 +229,7 @@ async def _sealed_chain(session, case) -> tuple[bool, bool, bool]: ("artifact_put_attempts", "put_attempt_id"), ): value = await session.scalar( - text( - f"select checker_output_custody_sealed from {table} " - "where id=:id" - ), + text(f"select checker_output_custody_sealed from {table} where id=:id"), {"id": getattr(case, attribute)}, ) values.append(bool(value)) @@ -225,10 +239,7 @@ async def _sealed_chain(session, case) -> tuple[bool, bool, bool]: async def _wait_for_lock(session, backend_pid: int) -> None: for _ in range(500): waiting = await session.scalar( - text( - "select exists(select 1 from pg_locks " - "where pid=:pid and not granted)" - ), + text("select exists(select 1 from pg_locks where pid=:pid and not granted)"), {"pid": backend_pid}, ) if waiting: @@ -245,9 +256,7 @@ async def _transaction(connection, isolation: str): def _ancestor_update(case, ancestor: str): table, attribute, assignment, _trigger, _error = _ANCESTOR_MUTATIONS[ancestor] - return text(f"update {table} set {assignment} where id=:id"), { - "id": getattr(case, attribute) - } + return text(f"update {table} set {assignment} where id=:id"), {"id": getattr(case, attribute)} def _invalid_ancestor_update(case, ancestor: str): @@ -263,9 +272,7 @@ def _invalid_ancestor_update(case, ancestor: str): ), "replica": "provider_object_ref=provider_object_ref || '-moved'", }[ancestor] - return text(f"update {table} set {assignment} where id=:id"), { - "id": getattr(case, attribute) - } + return text(f"update {table} set {assignment} where id=:id"), {"id": getattr(case, attribute)} async def _ancestor_without_seal(session, case, ancestor: str): @@ -331,13 +338,13 @@ async def _binding_first_race(case, ancestor: str, isolation: str) -> None: async with case.factory() as session: assert await _sealed_chain(session, case) == (True, True, True) assert await _ancestor_without_seal(session, case, ancestor) == before - assert await session.scalar( - text( - "select count(*) from artifact_bindings " - "where put_attempt_id=:put_attempt_id" - ), - {"put_attempt_id": case.put_attempt_id}, - ) == 1 + assert ( + await session.scalar( + text("select count(*) from artifact_bindings where put_attempt_id=:put_attempt_id"), + {"put_attempt_id": case.put_attempt_id}, + ) + == 1 + ) async def _update_first_race(case, ancestor: str, isolation: str) -> None: @@ -381,13 +388,13 @@ async def _update_first_race(case, ancestor: str, isolation: str) -> None: async with case.factory() as session: assert await _sealed_chain(session, case) == (False, False, False) assert await _ancestor_without_seal(session, case, ancestor) != before - assert await session.scalar( - text( - "select count(*) from artifact_bindings " - "where put_attempt_id=:put_attempt_id" - ), - {"put_attempt_id": case.put_attempt_id}, - ) == 0 + assert ( + await session.scalar( + text("select count(*) from artifact_bindings where put_attempt_id=:put_attempt_id"), + {"put_attempt_id": case.put_attempt_id}, + ) + == 0 + ) @pytest.mark.asyncio @@ -413,12 +420,15 @@ async def test_exact_verified_checker_output_binding_and_generic_binding_remain_ ), ) async with case.factory() as session: - assert await session.scalar( - text( - "select count(*) from artifact_bindings " - "where resource_type in ('checker_run','task')" + assert ( + await session.scalar( + text( + "select count(*) from artifact_bindings " + "where resource_type in ('checker_model_call','task')" + ) ) - ) == 2 + == 2 + ) @pytest.mark.asyncio @@ -436,8 +446,7 @@ async def test_checker_binding_rejects_null_verification_terminal_result( async with case.factory() as session, session.begin(): await session.execute( text( - "update artifact_verification_jobs " - "set terminal_result_code=null where id=:id" + "update artifact_verification_jobs set terminal_result_code=null where id=:id" ), {"id": case.verification_job_id}, ) @@ -450,13 +459,16 @@ async def test_checker_binding_rejects_null_verification_terminal_result( await session.execute(_INSERT_BINDING, _binding_values(case)) async with case.factory() as session: assert await _sealed_chain(session, case) == (False, False, False) - assert await session.scalar( - text( - "select count(*) from artifact_bindings " - "where put_attempt_id=:put_attempt_id" - ), - {"put_attempt_id": case.put_attempt_id}, - ) == 0 + assert ( + await session.scalar( + text( + "select count(*) from artifact_bindings " + "where put_attempt_id=:put_attempt_id" + ), + {"put_attempt_id": case.put_attempt_id}, + ) + == 0 + ) async with case.factory() as session: mutation = await session.begin() @@ -477,13 +489,16 @@ async def test_checker_binding_rejects_null_verification_terminal_result( async with case.factory() as session: assert await _sealed_chain(session, case) == (False, False, False) - assert await session.scalar( - text( - "select count(*) from artifact_bindings " - "where put_attempt_id=:put_attempt_id" - ), - {"put_attempt_id": case.put_attempt_id}, - ) == 0 + assert ( + await session.scalar( + text( + "select count(*) from artifact_bindings " + "where put_attempt_id=:put_attempt_id" + ), + {"put_attempt_id": case.put_attempt_id}, + ) + == 0 + ) @pytest.mark.asyncio @@ -499,7 +514,7 @@ async def test_checker_binding_rejects_mixed_or_foreign_ancestry( store=case.store, policy_bundle=case.policy_bundle, source_path=tmp_path / "other-checker-output", - payload=b"distinct independently verified checker output", + payload=b'{"result":"distinct independently verified checker output"}', ) assert case.put_attempt_id != other.put_attempt_id assert case.verification_receipt_id != other.verification_receipt_id @@ -510,13 +525,25 @@ async def test_checker_binding_rejects_mixed_or_foreign_ancestry( control = await session.begin_nested() await session.execute(_INSERT_BINDING, _binding_values(case)) await session.execute(_INSERT_BINDING, _binding_values(other)) - assert await session.scalar( - text("select count(*) from artifact_bindings where resource_type='checker_run'") - ) == 2 + assert ( + await session.scalar( + text( + "select count(*) from artifact_bindings " + "where resource_type='checker_model_call'" + ) + ) + == 2 + ) await control.rollback() - assert await session.scalar( - text("select count(*) from artifact_bindings where resource_type='checker_run'") - ) == 0 + assert ( + await session.scalar( + text( + "select count(*) from artifact_bindings " + "where resource_type='checker_model_call'" + ) + ) + == 0 + ) mixed_lineages = ( { @@ -539,9 +566,15 @@ async def test_checker_binding_rejects_mixed_or_foreign_ancestry( _binding_values(case, **mixed), ) async with case.factory() as session: - assert await session.scalar( - text("select count(*) from artifact_bindings where resource_type='checker_run'") - ) == 0 + assert ( + await session.scalar( + text( + "select count(*) from artifact_bindings " + "where resource_type='checker_model_call'" + ) + ) + == 0 + ) @pytest.mark.asyncio @@ -564,9 +597,7 @@ async def test_checker_attempt_static_custody_allows_only_execution_lifecycle( ): async with case.factory() as session, session.begin(): await session.execute( - text( - f"update artifact_put_attempts set {assignment} where id=:id" - ), + text(f"update artifact_put_attempts set {assignment} where id=:id"), {"id": case.put_attempt_id}, ) with pytest.raises( @@ -605,25 +636,18 @@ async def test_binding_seals_exact_ancestry_but_preserves_replica_health( with pytest.raises(DBAPIError, match="seal requires binding"): async with case.factory() as session, session.begin(): await session.execute( - text( - f"update {table} set checker_output_custody_sealed=true " - "where id=:id" - ), + text(f"update {table} set checker_output_custody_sealed=true where id=:id"), {"id": getattr(case, attribute)}, ) async with case.factory() as session, session.begin(): await session.execute( - text( - "update artifact_put_attempts set cas_version=cas_version+1 " - "where id=:id" - ), + text("update artifact_put_attempts set cas_version=cas_version+1 where id=:id"), {"id": case.put_attempt_id}, ) await session.execute( text( - "update artifact_verification_jobs set cas_version=cas_version+1 " - "where id=:id" + "update artifact_verification_jobs set cas_version=cas_version+1 where id=:id" ), {"id": case.verification_job_id}, ) @@ -683,10 +707,13 @@ async def test_binding_seals_exact_ancestry_but_preserves_replica_health( ) assert after != before await transaction.rollback() - assert await session.scalar( - text(f"select to_jsonb(value) from {table} value where id=:id"), - {"id": getattr(case, attribute)}, - ) == before + assert ( + await session.scalar( + text(f"select to_jsonb(value) from {table} value where id=:id"), + {"id": getattr(case, attribute)}, + ) + == before + ) await session.rollback() @@ -707,10 +734,13 @@ async def test_binding_rollback_removes_all_ancestry_seals( await transaction.rollback() async with case.factory() as session: assert await _sealed_chain(session, case) == (False, False, False) - assert await session.scalar( - text("select count(*) from artifact_bindings where id=:id"), - {"id": binding_id}, - ) == 0 + assert ( + await session.scalar( + text("select count(*) from artifact_bindings where id=:id"), + {"id": binding_id}, + ) + == 0 + ) @pytest.mark.parametrize("ancestor", tuple(_ANCESTOR_MUTATIONS)) @@ -732,7 +762,7 @@ async def test_binding_and_ancestor_updates_serialize_fail_closed( store=case.store, policy_bundle=case.policy_bundle, source_path=tmp_path / f"update-first-{ancestor}", - payload=f"update-first-{isolation}-{ancestor}".encode(), + payload=('{"result":"update-first-' + isolation + "-" + ancestor + '"}').encode(), ) other.engine = case.engine await _update_first_race(other, ancestor, isolation) @@ -760,24 +790,45 @@ async def test_artifact_binding_truncate_custody_blocks_direct_and_cascade_delet async with case.factory() as session, session.begin(): await session.execute(text(statement)) async with case.factory() as session: - assert await session.scalar( - text("select count(*) from artifact_bindings where id=:id"), - {"id": binding_id}, - ) == 1 + assert ( + await session.scalar( + text("select count(*) from artifact_bindings where id=:id"), + {"id": binding_id}, + ) + == 1 + ) async with case.factory() as session: transaction = await session.begin() try: # Isolate the binding trigger from the new inbound packet FK. This # rollback-only probe has no packets; production custody stays intact. - assert await session.scalar(text("select count(*) from review_packet_manifests")) == 0 - await session.execute(text( - "alter table review_packet_manifests drop constraint " - "fk_review_packet_manifests_submission_binding_id_artifa_4ee3" - )) + assert ( + await session.scalar(text("select count(*) from review_packet_manifests")) == 0 + ) + await session.execute( + text( + "alter table review_packet_manifests drop constraint " + "fk_review_packet_manifests_submission_binding_id_artifa_4ee3" + ) + ) for table, constraint in ( - ("submission_binding_receipts", "fk_submission_binding_receipts_binding_id_artifact_bindings"), - ("submission_dispatches", "fk_submission_dispatches_artifact_binding_id_artifact_bindings"), + ( + "model_usage_effects", + "fk_model_usage_effects_request_binding", + ), + ( + "model_usage_effects", + "fk_model_usage_effects_response_binding", + ), + ( + "submission_binding_receipts", + "fk_submission_binding_receipts_binding_id_artifact_bindings", + ), + ( + "submission_dispatches", + "fk_submission_dispatches_artifact_binding_id_artifact_bindings", + ), ): # Canonical creation already retained one receipt. The # FK removal and deliberate truncation roll back together. @@ -793,18 +844,24 @@ async def test_artifact_binding_truncate_custody_blocks_direct_and_cascade_delet ) ) await session.execute(text("truncate artifact_bindings")) - assert await session.scalar( - text("select count(*) from artifact_bindings where id=:id"), - {"id": binding_id}, - ) == 0 + assert ( + await session.scalar( + text("select count(*) from artifact_bindings where id=:id"), + {"id": binding_id}, + ) + == 0 + ) finally: await transaction.rollback() async with case.factory() as session: - assert await session.scalar( - text("select count(*) from artifact_bindings where id=:id"), - {"id": binding_id}, - ) == 1 + assert ( + await session.scalar( + text("select count(*) from artifact_bindings where id=:id"), + {"id": binding_id}, + ) + == 1 + ) for table in ("submission_binding_receipts", "submission_dispatches"): assert await session.scalar(text(f"select count(*) from {table}")) == 1 trigger_definition = await session.scalar( @@ -817,16 +874,26 @@ async def test_artifact_binding_truncate_custody_blocks_direct_and_cascade_delet assert trigger_definition is not None assert "BEFORE TRUNCATE" in trigger_definition assert "EXECUTE FUNCTION reject_artifact_fact_mutation()" in trigger_definition - assert await session.scalar(text( - "select tgenabled='O' from pg_trigger " - "where tgrelid='artifact_bindings'::regclass " - "and tgname='trg_artifact_bindings_no_truncate'" - )) is True - assert await session.scalar(text( - "select count(*) from pg_constraint " - "where conrelid='review_packet_manifests'::regclass " - "and conname='fk_review_packet_manifests_submission_binding_id_artifa_4ee3'" - )) == 1 + assert ( + await session.scalar( + text( + "select tgenabled='O' from pg_trigger " + "where tgrelid='artifact_bindings'::regclass " + "and tgname='trg_artifact_bindings_no_truncate'" + ) + ) + is True + ) + assert ( + await session.scalar( + text( + "select count(*) from pg_constraint " + "where conrelid='review_packet_manifests'::regclass " + "and conname='fk_review_packet_manifests_submission_binding_id_artifa_4ee3'" + ) + ) + == 1 + ) @pytest.mark.asyncio @@ -847,28 +914,35 @@ async def test_binding_guard_uses_only_artifact_ancestry_and_exact_owner_fks( assert "submissions" not in definition assert "workstream_tasks" not in definition constraints = dict( - (await connection.execute(text( - "select conname,pg_get_constraintdef(oid) from pg_constraint " - "where conrelid='artifact_put_attempts'::regclass and conname in " - "('fk_artifact_put_attempts_checker_run_ownership'," - "'fk_artifact_put_attempts_submission_version'," - "'fk_artifact_put_attempts_task_project')" - ))).all() + ( + await connection.execute( + text( + "select conname,pg_get_constraintdef(oid) from pg_constraint " + "where conrelid='artifact_put_attempts'::regclass and conname in " + "('fk_artifact_put_attempts_checker_run_ownership'," + "'fk_artifact_put_attempts_submission_version'," + "'fk_artifact_put_attempts_task_project')" + ) + ) + ).all() ) assert set(constraints) == { "fk_artifact_put_attempts_checker_run_ownership", "fk_artifact_put_attempts_submission_version", "fk_artifact_put_attempts_task_project", } - assert "FOREIGN KEY (checker_run_id, task_id, submission_id)" in constraints[ - "fk_artifact_put_attempts_checker_run_ownership" - ] - assert "FOREIGN KEY (submission_id, task_id, submission_version)" in constraints[ - "fk_artifact_put_attempts_submission_version" - ] - assert "FOREIGN KEY (task_id, project_id)" in constraints[ - "fk_artifact_put_attempts_task_project" - ] + assert ( + "FOREIGN KEY (checker_run_id, task_id, submission_id)" + in constraints["fk_artifact_put_attempts_checker_run_ownership"] + ) + assert ( + "FOREIGN KEY (submission_id, task_id, submission_version)" + in constraints["fk_artifact_put_attempts_submission_version"] + ) + assert ( + "FOREIGN KEY (task_id, project_id)" + in constraints["fk_artifact_put_attempts_task_project"] + ) finally: await engine.dispose() @@ -944,26 +1018,51 @@ async def _restore_pre_0007_schema(connection) -> None: "drop column checker_output_custody_sealed" ) ) + await connection.execute( + text("alter table artifact_verification_jobs drop column checker_output_custody_sealed") + ) + await connection.execute( + text("alter table artifact_replicas drop column checker_output_custody_sealed") + ) + + +async def _arrange_historical_checker_run_output(connection, case) -> None: + """Convert one controlled current fixture into its retained pre-model lineage.""" + await connection.execute( + text("alter table artifact_put_attempts disable trigger checker_output_put_attempt_custody") + ) await connection.execute( text( - "alter table artifact_verification_jobs " - "drop column checker_output_custody_sealed" - ) + "update artifact_put_attempts set checker_run_id=:run, " + "model_call_attempt_id=null where id=:attempt" + ), + {"run": case.checker_run_id, "attempt": case.put_attempt_id}, + ) + await connection.execute( + text("alter table artifact_put_attempts enable trigger checker_output_put_attempt_custody") ) + # Only this historical fixture may replace current producer coordinates. + # Restore the immutable guard before the upgrade/preservation assertions. + await connection.execute(text( + "alter table artifact_operation_receipts disable trigger " + "trg_artifact_operation_receipts_immutable" + )) await connection.execute( text( - "alter table artifact_replicas " - "drop column checker_output_custody_sealed" - ) + "update artifact_operation_receipts set checker_run_id=:run, " + "model_call_attempt_id=null where put_attempt_id=:attempt" + ), + {"run": case.checker_run_id, "attempt": case.put_attempt_id}, ) + await connection.execute(text( + "alter table artifact_operation_receipts enable trigger " + "trg_artifact_operation_receipts_immutable" + )) def _run_0007_upgrade(connection) -> None: module = runpy.run_path( - str( - Path(__file__).resolve().parents[1] - / "alembic/versions/0007_checker_output_custody.py" - ) + str(Path(__file__).resolve().parents[1] / "alembic/versions/0007_checker_output_custody.py") ) with Operations.context(MigrationContext.configure(connection)): module["upgrade"]() @@ -984,6 +1083,7 @@ async def test_migration_refuses_unprovable_or_inconsistent_retained_checker_att ) async with case.factory() as session: connection = await session.connection() + await _arrange_historical_checker_run_output(connection, case) await _restore_pre_0007_schema(connection) before = await session.scalar( text("select to_jsonb(a) from artifact_put_attempts a where id=:id"), @@ -995,10 +1095,13 @@ async def test_migration_refuses_unprovable_or_inconsistent_retained_checker_att ): async with connection.begin_nested(): await connection.run_sync(_run_0007_upgrade) - assert await session.scalar( - text("select to_jsonb(a) from artifact_put_attempts a where id=:id"), - {"id": case.put_attempt_id}, - ) == before + assert ( + await session.scalar( + text("select to_jsonb(a) from artifact_put_attempts a where id=:id"), + {"id": case.put_attempt_id}, + ) + == before + ) await session.execute( text("update artifact_put_attempts set task_id=:task where id=:id"), @@ -1014,8 +1117,11 @@ async def test_migration_refuses_unprovable_or_inconsistent_retained_checker_att ): async with connection.begin_nested(): await connection.run_sync(_run_0007_upgrade) - assert await session.scalar( - text("select to_jsonb(a) from artifact_put_attempts a where id=:id"), - {"id": case.put_attempt_id}, - ) == mismatched + assert ( + await session.scalar( + text("select to_jsonb(a) from artifact_put_attempts a where id=:id"), + {"id": case.put_attempt_id}, + ) + == mismatched + ) await session.rollback() diff --git a/backend/tests/test_ci_lane_catalogue.py b/backend/tests/test_ci_lane_catalogue.py index 58c394568..1491a4b45 100644 --- a/backend/tests/test_ci_lane_catalogue.py +++ b/backend/tests/test_ci_lane_catalogue.py @@ -65,6 +65,24 @@ def test_architecture_inspections_share_capacity_without_losing_nodes() -> None: def test_checker_delivery_and_routing_preparation_partition_every_node_once() -> None: delivery = { + "tests/checkers_model_calls/test_composed_execution.py", + "tests/checkers_model_calls/test_model_call_contract.py", + "tests/checkers_model_calls/test_model_call_transactions_postgresql.py", + "tests/checkers_model_calls/test_model_invocation_grants_postgresql.py", + "tests/checkers_model_calls/test_model_shared_setup_postgresql.py", + "tests/checkers_model_calls/test_model_terminal_authority_postgresql.py", + "tests/checkers_model_calls/test_model_usage_inspection.py", + "tests/checkers_model_calls/test_model_runtime.py", + "tests/checkers_model_calls/test_proxy_orchestration.py", + "tests/checkers_model_calls/test_public_api.py", + "tests/checkers_model_calls/test_setup_model_handler.py", + "tests/checkers_model_calls/test_setup_model_handler_postgresql.py", + "tests/checkers_model_calls/test_setup_model_proxy_transport.py", + "tests/checkers_model_calls/test_setup_output_custody.py", + "tests/checkers/model_provider/test_openai_responses.py", + "tests/checkers/external/test_model_transport.py", + "tests/checkers/external/test_model_proxy_transport.py", + "tests/authorization/checker_models/test_contracts.py", "tests/checkers/default/test_contract.py", "tests/checkers/default/test_execution.py", "tests/checkers/external/test_contracts.py", @@ -272,8 +290,8 @@ def test_semantic_family_membership_has_an_independent_fingerprint() -> None: "2fe14cb5b5987c540fc30a153a2429adc16243b55aca0e565892338c306282dd", ), "project": ( - 213, - "3c2477009349137e7aefd268294a811c16ee8b2ffff23d5aa513f4b479b1889d", + 231, + "e068985c9983114786ca11ef4a42f417845ec91cb81c71572e879ee0e473e42b", ), "task": ( 95, @@ -345,9 +363,9 @@ def test_project_lane_ownership_has_an_independent_fingerprint() -> None: ) + "\n" ) - assert len(catalogue.PROJECT_PARTITIONED_MODULES) == 213 + assert len(catalogue.PROJECT_PARTITIONED_MODULES) == 231 assert hashlib.sha256(canonical.encode()).hexdigest() == ( - "41e0285a424449b5d639407cfd4ca5a4ac1065baa8057198417274d310adc3a8" + "8578f3096122628d1a1e51ac60d03838a2a125d762867d77fdd335ed98054404" ) diff --git a/backend/tests/test_identifier_schema.py b/backend/tests/test_identifier_schema.py index bf7bc513e..079571196 100644 --- a/backend/tests/test_identifier_schema.py +++ b/backend/tests/test_identifier_schema.py @@ -23,6 +23,8 @@ "guide_source_extraction_retry_budgets": ("binding_id",), "outbox_delivery_attempts": ("event_id", "claim_generation"), "project_compensation_units": ("project_id", "instrument_type", "unit_code"), + "model_usage_budget_accounts": ("project_id",), + "checker_model_invocation_grant_calls": ("grant_id", "call_index"), } @@ -38,7 +40,8 @@ def test_every_generated_primary_key_uses_native_uuid_and_v7_constraint(): assert str(key.type.compile(dialect=postgresql.dialect())) == "UUID" constraints = { constraint.name: str(constraint.sqltext) - for constraint in table.constraints if isinstance(constraint, CheckConstraint) + for constraint in table.constraints + if isinstance(constraint, CheckConstraint) } assert constraints[f"ck_{table.name}_{key.name}_uuid7"] == ( f"(get_byte(uuid_send({key.name}), 6) >> 4) = 7 and " @@ -85,7 +88,8 @@ def test_generated_non_primary_operation_identities_have_version_guards(): assert isinstance(table.c[column].type, Uuid) checks = { str(check.name): str(check.sqltext) - for check in table.constraints if isinstance(check, CheckConstraint) + for check in table.constraints + if isinstance(check, CheckConstraint) } expression = checks[f"ck_{owner}_{column}_uuid7"] assert f"(get_byte(uuid_send({column}), 6) >> 4) = 7" in expression diff --git a/backend/tests/test_omnicore_agent_sdk.py b/backend/tests/test_omnicore_agent_sdk.py index a7cd512ce..dd3610fbb 100644 --- a/backend/tests/test_omnicore_agent_sdk.py +++ b/backend/tests/test_omnicore_agent_sdk.py @@ -372,7 +372,12 @@ async def opened_handles() -> frozenset[str]: opened_handles=opened_handles, attempts=attempts, rejections=rejections, - ) + ), + model=SimpleNamespace( + maximum_output_tokens=16_384, + reasoning_effort="medium", + text_verbosity="low", + ), ), reads, handle, @@ -400,6 +405,7 @@ async def _send_guard_request( ], "temperature": 0, "max_tokens": 16_384, + "reasoning_effort": "medium", "tools": _MODEL_TOOLS, "usage": {"include": True}, }, @@ -445,8 +451,8 @@ def test_profile_provider_and_test_transport_are_closed(monkeypatch): compilation_context = _runtime_context() monkeypatch.delenv("OPENROUTER_API_KEY") - with pytest.raises(ProjectAgentRuntimeConfigurationError, match="credentials"): - OmniCoreAgentProjectGuideRuntime(compilation_context.runtime_configuration) + runtime = OmniCoreAgentProjectGuideRuntime(compilation_context.runtime_configuration) + assert runtime.identity == compilation_context.runtime_configuration.adapter_identity monkeypatch.setenv("OPENROUTER_API_KEY", "synthetic-omnicore-test-key") with pytest.raises(ProjectAgentRuntimeConfigurationError, match="loopback"): OmniCoreAgentProjectGuideRuntime._for_test( @@ -477,6 +483,7 @@ def test_profile_rejects_litellm_distribution_drift(): lambda body: body.update(plugins=[]), lambda body: body.update(model="openrouter/auto"), lambda body: body.update(usage={"include": False}), + lambda body: body.update(reasoning_effort="high"), lambda body: body.update(temperature=1), lambda body: body.update(max_tokens=1), lambda body: body.update(tools=[{"type": "web_search", "web_search": {}}]), @@ -491,12 +498,17 @@ def test_transport_rejects_each_routing_and_tool_mutation_before_forward(mutatio ], "temperature": 0, "max_tokens": 16_384, + "reasoning_effort": "medium", "tools": copy.deepcopy(_MODEL_TOOLS), "usage": {"include": True}, } mutation(body) with pytest.raises(_GuardRejection): - _validate_request_body(body, expected_model=OMNICORE_QUALIFICATION_MODEL) + _validate_request_body( + body, + expected_model=OMNICORE_QUALIFICATION_MODEL, + expected_reasoning_effort="medium", + ) def test_transport_rejects_duplicate_json_members(): @@ -751,6 +763,8 @@ def release(self, _lease): "usage", "provider", "plugins", + "reasoning_effort", + "verbosity", } assert all(set(body) == expected_fields for _, body in provider.requests) for request_index in (4, 6): diff --git a/backend/tests/test_project_agent_resilience.py b/backend/tests/test_project_agent_resilience.py index 4dfb43b10..52d351d6b 100644 --- a/backend/tests/test_project_agent_resilience.py +++ b/backend/tests/test_project_agent_resilience.py @@ -323,13 +323,17 @@ async def test_open_circuit_prevents_product_fence_and_resource_allocation(monke monkeypatch.setenv("OPENROUTER_API_KEY", "test-only") monkeypatch.setattr(module, "project_guide_compilation_prompt_bytes", lambda _: b"manifest") calls = [] + runtime_configuration = configuration() class Backend: async def load(self, attempt): - return SimpleNamespace(classification=CompilationRecoveryClassification.RESERVED) + return SimpleNamespace( + classification=CompilationRecoveryClassification.RESERVED, + runtime_configuration=runtime_configuration, + ) async def context(self, state): - return SimpleNamespace(runtime_configuration=configuration()) + return SimpleNamespace(runtime_configuration=runtime_configuration) async def fence(self, state): calls.append("fence") diff --git a/backend/tests/test_s3_artifact_store.py b/backend/tests/test_s3_artifact_store.py index fc91adbe4..fef5f1254 100644 --- a/backend/tests/test_s3_artifact_store.py +++ b/backend/tests/test_s3_artifact_store.py @@ -2,6 +2,7 @@ from __future__ import annotations +import asyncio from collections.abc import AsyncIterator from contextlib import asynccontextmanager import hashlib @@ -20,6 +21,7 @@ S3CompatibleArtifactStoreBootstrap, ) import app.adapters.artifacts.s3_compatible as s3_module +from app.core.cancellation import await_completion_preserving_cancellation from app.core.config import Settings from app.interfaces.artifacts import ( ArtifactByteRange, @@ -708,6 +710,63 @@ async def must_not_classify_as_replay(*_args: object) -> bool: store.close() +@pytest.mark.asyncio +@pytest.mark.parametrize("interruption", ["transport_timeout", "outer_cancellation"]) +async def test_interruption_joins_sdk_writer_before_releasing_partial_source( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + interruption: str, +) -> None: + """An interrupted SDK writer cannot race recovery or outlive put.""" + private_prefix = f"negative/writer-timeout-{hashlib.sha256(os.urandom(16)).hexdigest()[:20]}" + store = initialize_minio_store(private_prefix=private_prefix) + monkeypatch.setattr( + store, + "_operation_total_timeout_seconds", + 0.1 if interruption == "transport_timeout" else 5, + ) + content = b"cancellation-owned committed source" + read_started = asyncio.Event() + active_reads = 0 + completed_reads = 0 + try: + async with minted_source(tmp_path / "scratch", content) as source: + owner = source._owner # noqa: SLF001 - exact sealed-source owner boundary + original_read = owner._read_chunk # noqa: SLF001 + + async def slow_read(reader: object) -> bytes: + nonlocal active_reads, completed_reads + active_reads += 1 + read_started.set() + try: + await await_completion_preserving_cancellation(asyncio.sleep(0.3)) + return await original_read(reader) + finally: + active_reads -= 1 + completed_reads += 1 + + monkeypatch.setattr(owner, "_read_chunk", slow_read) + put = asyncio.create_task(store.put(source)) + await asyncio.wait_for(read_started.wait(), timeout=2) + if interruption == "outer_cancellation": + put.cancel() + with pytest.raises(asyncio.CancelledError): + await asyncio.wait_for(put, timeout=2) + else: + with pytest.raises( + ArtifactStoreUnavailableError, + match="S3 artifact operation failed", + ): + await asyncio.wait_for(put, timeout=2) + assert active_reads == 0 + reads_after_return = completed_reads + await asyncio.sleep(0.05) + assert active_reads == 0 + assert completed_reads == reads_after_return + finally: + store.close() + + @pytest.mark.asyncio async def test_nonprecondition_put_error_is_unavailable( monkeypatch: pytest.MonkeyPatch, diff --git a/cli/internal/api/admin_inspection.go b/cli/internal/api/admin_inspection.go index 1c9e1a9cd..19e5aa1af 100644 --- a/cli/internal/api/admin_inspection.go +++ b/cli/internal/api/admin_inspection.go @@ -303,8 +303,8 @@ func (c *Client) AdminPermissions(ctx context.Context) (Result[PermissionCatalog } var value PermissionCatalogue fields, err := contextObject(raw, &value, []string{"items", "total"}, nil) - // The public PermissionDefinitionsResponse schema fixes total to Literal[82]. - if err != nil || value.Items == nil || value.Total != 82 || len(value.Items) != value.Total || !c.safeMutationResponse(raw) { + // The public PermissionDefinitionsResponse schema fixes total to Literal[95]. + if err != nil || value.Items == nil || value.Total != 95 || len(value.Items) != value.Total || !c.safeMutationResponse(raw) { return result, &Failure{Code: "invalid_api_response"} } var members []json.RawMessage diff --git a/cli/internal/api/checker_models.go b/cli/internal/api/checker_models.go new file mode 100644 index 000000000..3e8165e63 --- /dev/null +++ b/cli/internal/api/checker_models.go @@ -0,0 +1,65 @@ +package api + +import ( + "encoding/json" + jsonv2 "encoding/json/v2" + "math/big" +) + +// CheckerModelPaidSlot identifies a retained effect across checker retries. +type CheckerModelPaidSlot struct { + ProjectID string `json:"project_id"` + AssignmentID string `json:"assignment_id"` + SubmissionID string `json:"submission_id"` + SubmissionVersion *big.Int `json:"submission_version"` + RequirementID string `json:"requirement_id"` + RegistryEntryDigest string `json:"registry_entry_digest"` + ConfigurationSHA256 string `json:"configuration_sha256"` + ProfileID string `json:"profile_id"` + ProfileVersion *big.Int `json:"profile_version"` + ProfileDigest string `json:"profile_digest"` + SlotIndex *big.Int `json:"slot_index"` +} + +// CheckerModelRecoveryCandidate is a repair candidate, never dispatch authority. +type CheckerModelRecoveryCandidate struct { + ParentAttemptID string `json:"parent_attempt_id"` + Slot CheckerModelPaidSlot `json:"slot"` + RegistryEntryID string `json:"registry_entry_id"` + TerminalDigest string `json:"terminal_digest"` + RequestSHA256 string `json:"request_sha256"` +} + +func validCheckerModelRecoveryCandidates(raw json.RawMessage, item *CheckerRetrySourceItem) bool { + var candidates []json.RawMessage + if jsonv2.Unmarshal(raw, &candidates) != nil || candidates == nil || len(candidates) != len(item.ModelRecoveryCandidates) { + return false + } + seen := map[[16]byte]bool{} + for i, value := range candidates { + candidate := &item.ModelRecoveryCandidates[i] + fields, err := contextObject(value, candidate, []string{"parent_attempt_id", "slot", "registry_entry_id", "terminal_digest", "request_sha256"}, nil) + if err != nil || !validUUID(candidate.RegistryEntryID) || !contextPolicyDigest.MatchString(candidate.TerminalDigest) || !contextPolicyDigest.MatchString(candidate.RequestSHA256) { + return false + } + id, valid := uuidIdentity(candidate.ParentAttemptID) + if !valid || seen[id] { + return false + } + seen[id] = true + slot := &candidate.Slot + _, err = contextObject(fields["slot"], slot, []string{"project_id", "assignment_id", "submission_id", "submission_version", "requirement_id", "registry_entry_digest", "configuration_sha256", "profile_id", "profile_version", "profile_digest", "slot_index"}, nil) + if err != nil || !sameUUID(slot.ProjectID, item.Source.ProjectID) || !sameUUID(slot.SubmissionID, item.Source.SubmissionID) || + !validUUID(slot.AssignmentID) || !validUUID(slot.ProfileID) || !boundedInteger(slot.SubmissionVersion, 1, 2_147_483_647) || + slot.SubmissionVersion.Cmp(item.Source.SubmissionVersion) != 0 || !boundedInteger(slot.ProfileVersion, 1, 2_147_483_647) || + !boundedInteger(slot.SlotIndex, 0, 0) || !postPolicyIdentifier.MatchString(slot.RequirementID) { + return false + } + for _, digest := range []string{slot.RegistryEntryDigest, slot.ConfigurationSHA256, slot.ProfileDigest} { + if !contextPolicyDigest.MatchString(digest) { + return false + } + } + } + return true +} diff --git a/cli/internal/api/checker_operations.go b/cli/internal/api/checker_operations.go index b5081bfdd..8777f225c 100644 --- a/cli/internal/api/checker_operations.go +++ b/cli/internal/api/checker_operations.go @@ -239,12 +239,13 @@ type CheckerRetrySuccessor struct { } type CheckerRetrySourceItem struct { - Source CheckerRetrySource `json:"source"` - RecoveryState string `json:"recovery_state"` - CreatedAt string `json:"created_at"` - CompletedAt string `json:"completed_at"` - RetryReceiptID *string `json:"retry_receipt_id"` - Successor *CheckerRetrySuccessor `json:"successor"` + Source CheckerRetrySource `json:"source"` + RecoveryState string `json:"recovery_state"` + CreatedAt string `json:"created_at"` + CompletedAt string `json:"completed_at"` + RetryReceiptID *string `json:"retry_receipt_id"` + Successor *CheckerRetrySuccessor `json:"successor"` + ModelRecoveryCandidates []CheckerModelRecoveryCandidate `json:"model_recovery_candidates"` } type CheckerRetrySourcePage struct { @@ -340,13 +341,16 @@ func (c *Client) CheckerRetrySources(ctx context.Context, project string, limit seen := map[[16]byte]bool{} for i, item := range page.Items { var wire CheckerRetrySourceItem - parts, err := contextObject(itemRaw[i], &wire, []string{"source", "recovery_state", "created_at", "completed_at"}, nil) + parts, err := contextObject(itemRaw[i], &wire, []string{"source", "recovery_state", "created_at", "completed_at", "model_recovery_candidates"}, nil) if err != nil || !requiredNullable(parts, "retry_receipt_id", "successor") || !validCheckerSource(parts["source"], &item.Source, project, "") || !validTime(item.CreatedAt) || !validTime(item.CompletedAt) || (item.RecoveryState != "eligible" && item.RecoveryState != "retried" && item.RecoveryState != "not_current") || (item.RetryReceiptID == nil) != (item.Successor == nil) || (item.RecoveryState == "retried") != (item.RetryReceiptID != nil) { return result, &Failure{Code: "invalid_api_response"} } + if !validCheckerModelRecoveryCandidates(parts["model_recovery_candidates"], &item) { + return result, &Failure{Code: "invalid_api_response"} + } if item.RetryReceiptID != nil { if !validUUID(*item.RetryReceiptID) || !validCheckerSuccessor(parts["successor"], item.Successor) || !validCheckerRetryLineage(&item.Source, item.Successor) { diff --git a/cli/internal/api/guide_proposal.go b/cli/internal/api/guide_proposal.go index f5bf05938..9e739e4e8 100644 --- a/cli/internal/api/guide_proposal.go +++ b/cli/internal/api/guide_proposal.go @@ -587,11 +587,15 @@ func validateProposalCheckSpecification(raw json.RawMessage, value *ProposalChec "nested_sandbox": {"runtime_gap", "implementation_unavailable"}, "task_context": {"runtime_gap", "material_unavailable"}, "guide_context": {"runtime_gap", "material_unavailable"}, - "model_service": {"runtime_gap", "implementation_unavailable"}, "execution_capacity": {"runtime_gap", "capacity_exceeded"}, "prepared_material": {"runtime_gap", "material_unavailable"}, }[item.Kind] - if expected == [2]string{} || item.Provisioning != expected[0] || item.UnavailableCode != expected[1] || (supported && item.Provisioning == "runtime_gap") { + valid := expected != [2]string{} && item.Provisioning == expected[0] && item.UnavailableCode == expected[1] + if item.Kind == "model_service" { + valid = item.UnavailableCode == "implementation_unavailable" && + (item.Provisioning == "model_proxy" || (!supported && item.Provisioning == "runtime_gap")) + } + if !valid || (supported && item.Provisioning == "runtime_gap") { return errors.New("invalid runtime dependency provisioning") } dependencyIDs[item.DependencyID], dependencyKinds[item.Kind] = true, true @@ -605,6 +609,9 @@ func validateProposalCheckSpecification(raw json.RawMessage, value *ProposalChec (value.EvaluationMethod == "isolated_execution" && !dependencyKinds["isolated_execution"]) { return errors.New("missing runtime dependency") } + if stage == "pre_submit" && dependencyKinds["model_service"] { + return errors.New("model service must run after submission") + } infraCodes := make(map[string]bool) infraMembers, err := proposalObjectMembers(fields["infrastructure_failures"], 1, 5) diff --git a/cli/tests/integration/test_admin_inspection_http.py b/cli/tests/integration/test_admin_inspection_http.py index 5df82f600..de5bb4147 100644 --- a/cli/tests/integration/test_admin_inspection_http.py +++ b/cli/tests/integration/test_admin_inspection_http.py @@ -59,8 +59,8 @@ "revoked_at": None, } PERMISSIONS = { - "items": [{"permission_id": f"test.permission_{i}"} for i in range(82)], - "total": 82, + "items": [{"permission_id": f"test.permission_{i}"} for i in range(95)], + "total": 95, } DEFINITIONS = { "items": [ @@ -447,7 +447,7 @@ def test_admin_rejects_malformed_and_mismatched_success(cli): assert_failure(result, "invalid_api_response") for command, body in ( ("permissions", PERMISSIONS | {"total": 81}), - ("permissions", PERMISSIONS | {"items": [PERMISSIONS["items"][0]] * 82}), + ("permissions", PERMISSIONS | {"items": [PERMISSIONS["items"][0]] * 95}), ("role-definitions", DEFINITIONS | {"items": DEFINITIONS["items"][:-1]}), ( "role-definitions", diff --git a/cli/tests/integration/test_admin_inspection_public_api.py b/cli/tests/integration/test_admin_inspection_public_api.py index 321661f1c..ed144f5cd 100644 --- a/cli/tests/integration/test_admin_inspection_public_api.py +++ b/cli/tests/integration/test_admin_inspection_public_api.py @@ -195,7 +195,7 @@ async def issue( assert value == expected.json() assert ( inspected(cli, origin, tokens["auditor"], "permissions")["total"] - == 82 + == 95 ) assert ( inspected(cli, origin, tokens["auditor"], "role-definitions")[ diff --git a/cli/tests/integration/test_checker_operations_http.py b/cli/tests/integration/test_checker_operations_http.py index 74fab4355..eece914d4 100644 --- a/cli/tests/integration/test_checker_operations_http.py +++ b/cli/tests/integration/test_checker_operations_http.py @@ -364,6 +364,7 @@ def test_retry_lineage_rejects_relational_substitutions_on_receipts_and_history( "completed_at": "2026-10-18T10:01:00Z", "retry_receipt_id": EVIDENCE, "successor": successor(), + "model_recovery_candidates": [], } ], "next_cursor": None, @@ -414,6 +415,7 @@ def test_retry_source_page_and_exact_reason_bound_receipt(cli): "completed_at": "2026-10-18T10:01:00Z", "retry_receipt_id": None, "successor": None, + "model_recovery_candidates": [], } page = {"project_id": PROJECT, "items": [item], "next_cursor": "opaque+/="} with http_fixture() as (origin, response, requests): @@ -432,6 +434,57 @@ def test_retry_source_page_and_exact_reason_bound_receipt(cli): assert parse_qs(request.query) == {"limit": ["1"], "cursor": ["a+/="]} assert requests[-1][0] == "GET" + candidate = { + "parent_attempt_id": EVIDENCE, + "registry_entry_id": ENTRY, + "terminal_digest": SHA, + "request_sha256": SHA, + "slot": { + "project_id": PROJECT, + "assignment_id": ACTOR, + "submission_id": SUBMISSION, + "submission_version": 1, + "requirement_id": "project.evidence", + "registry_entry_digest": SHA, + "configuration_sha256": SHA, + "profile_id": ENTRY, + "profile_version": 1, + "profile_digest": SHA, + "slot_index": 0, + }, + } + model_page = deepcopy(page) + model_page["items"][0]["model_recovery_candidates"] = [candidate] + response["body"] = json.dumps(model_page).encode() + result = invoke(cli, origin, "retry-sources", PROJECT) + assert result.returncode == 0, result.stderr + assert json.loads(result.stdout) == model_page + invalid_candidates = [ + None, + [candidate, candidate], + [candidate | {"provider_response_id": "private-coordinate"}], + [candidate | {"terminal_digest": "not-a-digest"}], + ] + for key, value in ( + ("project_id", ACTOR), + ("submission_id", ACTOR), + ("submission_version", 2), + ("profile_version", None), + ("slot_index", 1), + ("slot_index", True), + ("requirement_id", "invalid space"), + ): + invalid_candidates.append( + [candidate | {"slot": candidate["slot"] | {key: value}}] + ) + for candidates in invalid_candidates: + invalid_page = deepcopy(model_page) + invalid_page["items"][0]["model_recovery_candidates"] = candidates + response["body"] = json.dumps(invalid_page).encode() + assert_failure( + invoke(cli, origin, "retry-sources", PROJECT), "invalid_api_response" + ) + response.update(status=201, body=json.dumps(receipt()).encode()) for _ in range(2): result = invoke( @@ -458,7 +511,7 @@ def test_retry_source_page_and_exact_reason_bound_receipt(cli): [KEY], {"reason": "Fixed runtime é"}, ) - assert len(requests) == 3 + assert len(requests) == 4 + len(invalid_candidates) def test_retry_refuses_invalid_inputs_foreign_pages_and_unknown_writes(cli): @@ -493,6 +546,7 @@ def test_retry_refuses_invalid_inputs_foreign_pages_and_unknown_writes(cli): "completed_at": "2026-10-18T10:01:00Z", "retry_receipt_id": None, "successor": None, + "model_recovery_candidates": [], } ], "next_cursor": None, diff --git a/cli/tests/integration/test_guide_correction_public_api.py b/cli/tests/integration/test_guide_correction_public_api.py index 1f6c60a15..1479a13d2 100644 --- a/cli/tests/integration/test_guide_correction_public_api.py +++ b/cli/tests/integration/test_guide_correction_public_api.py @@ -2,6 +2,7 @@ import asyncio from copy import deepcopy +from functools import partial import json import subprocess import sys @@ -25,13 +26,22 @@ pagination_cursor_hmac_secret as pagination_cursor_hmac_secret, ) from test_public_self_service import _ready +from app.adapters.project_agents import build_project_guide_adapter_profile +from app.core.config import get_settings from app.core.identifiers import new_record_id +from app.core.project_agents import project_guide_runtime_configuration from tests.project_create_fixtures import seed_historical_project +from tests.projects.guide_compilation.runtime_fixtures import ( + scripted_model_profile_spec, +) @pytest.mark.asyncio async def test_guide_correction_public_save_dispatch_replay_and_authority( - clean_postgres_database, tmp_path, cli + clean_postgres_database, + tmp_path, + cli, + monkeypatch, # noqa: F811 ): env = api_environment() env["WORKSTREAM_E2E_FLOW_ISSUER"] = "https://identity.flowresearch.tech" @@ -64,6 +74,9 @@ def token(actor): administrator, _ = await seed_review_actor( factory, None, role="access_administrator", scope="system" ) + model_operator, _ = await seed_review_actor( + factory, None, role="operator", scope="system" + ) foreign_id = new_record_id() async with factory() as session, session.begin(): await seed_historical_project( @@ -227,6 +240,32 @@ async def stored(expected, saved=None, dispatched=None): ) source = await direct.get(prefix + "/proposal", headers=headers) assert source.status_code == 200, source.text + with monkeypatch.context() as runtime_environment: + for name, value in env.items(): + if name.startswith("WORKSTREAM_"): + runtime_environment.setenv(name, value) + get_settings.cache_clear() + try: + runtime_settings = get_settings() + configuration = project_guide_runtime_configuration( + runtime_settings, + adapter_profile_builder=partial( + build_project_guide_adapter_profile, + runtime_settings.project_agent_runtime, + ), + ) + finally: + get_settings.cache_clear() + profile = scripted_model_profile_spec(command.project_id, configuration) + published = await direct.post( + f"/api/v1/operations/projects/{command.project_id}/model-profiles", + headers={ + "Authorization": "Bearer " + token(model_operator), + "Idempotency-Key": str(uuid4()), + }, + json=profile.model_dump(mode="json"), + ) + assert published.status_code == 201, published.text body = { "target": source.json()["target"], "reason": " Clarify café evidence. ", diff --git a/cli/tests/integration/test_guide_proposal_http.py b/cli/tests/integration/test_guide_proposal_http.py index e591f82e7..3d2060bfa 100644 --- a/cli/tests/integration/test_guide_proposal_http.py +++ b/cli/tests/integration/test_guide_proposal_http.py @@ -812,6 +812,54 @@ def test_proposal_rejects_pre_model_evaluation_and_validates_post_runtime_gaps(c assert_failure(invoke(cli, origin), "invalid_api_response") +def test_proposal_accepts_supported_execution_with_model_proxy(cli): + from app.modules.projects.api.guide_proposal_package import ( + GuideProposalReviewPackage, + ) + + with http_fixture() as (origin, response, _): + value = proposal() + dependencies = value["result"]["requirements"][1]["check_specification"][ + "runtime_dependencies" + ] + dependencies.append( + { + "dependency_id": "quality_model", + "kind": "model_service", + "provisioning": "model_proxy", + "purpose": "Assess the authenticated execution observations.", + "unavailable_code": "implementation_unavailable", + } + ) + GuideProposalReviewPackage.model_validate(value) + response["body"] = json.dumps(value).encode() + accepted = invoke(cli, origin) + assert accepted.returncode == 0 and json.loads(accepted.stdout) == value + + dependencies[-1]["provisioning"] = "runtime_gap" + response["body"] = json.dumps(value).encode() + assert_failure(invoke(cli, origin), "invalid_api_response") + + +def test_proposal_rejects_pre_check_with_model_assistance(cli): + with http_fixture() as (origin, response, _): + value = proposal() + dependencies = value["result"]["requirements"][0]["check_specification"][ + "runtime_dependencies" + ] + dependencies.append( + { + "dependency_id": "quality_model", + "kind": "model_service", + "provisioning": "model_proxy", + "purpose": "Assist the bounded static inspection.", + "unavailable_code": "implementation_unavailable", + } + ) + response["body"] = json.dumps(value).encode() + assert_failure(invoke(cli, origin), "invalid_api_response") + + def test_proposal_rejects_malformed_nested_types_and_incomplete_policy_identity(cli): cases = [ (("current",), "yes"), diff --git a/deploy/hosted/README.md b/deploy/hosted/README.md index 1763454b3..0131cef38 100644 --- a/deploy/hosted/README.md +++ b/deploy/hosted/README.md @@ -72,10 +72,14 @@ docker build --file docker/checker/Dockerfile.release \ Both Dockerfiles pin their base images. Each backend build requires one exact SDK environment and installs only its committed `agents` or `omnicore` resolution, without the development extra. The image records that choice in a root-owned -read-only marker; startup rejects a configured inference runtime that does not -match it before reading the provider credential. `manager_authored` is accepted -in either image with the exact `none/none/none` model tuple and reads no provider -credential. The backend image +read-only marker. Select the worker image that matches the configured setup SDK. +For every model-proxy-enabled release, also publish the `agents` build separately as +`WORKSTREAM_MODEL_PROXY_IMAGE`: the dedicated trusted proxy uses its pinned +OpenAI 3.x input-count and Responses dependencies for both setup SDKs and POST +checkers. The proxy rejects any non-`agents` image before reading the provider +credential. `manager_authored` is accepted in either worker image with the exact +`none/none/none` setup tuple; `WORKSTREAM_MODEL_PROXY_ENABLED` independently +decides whether approved POST images can use the proxy. The backend image also installs the official Roles Anywhere helper 1.8.5 at its fixed path, with architecture-specific hashes checked during the build and image proof. The checker-service build compiles the committed Rust lock and copies the pinned @@ -102,7 +106,12 @@ that no smoke container owned by that run remains after inspection or timeout. Install and configure Docker and gVisor using the approved host process. Confirm that Docker reports a `runsc` runtime. Installation and daemon configuration are -outside this repository configuration. +outside this repository configuration. When model access is enabled, the outer +checker runtime must include exactly `--host-uds=open` so its invocation-local +Unix socket works under gVisor; full preflight checks Docker's reported runtime +arguments. This permits the mounted socket, not network access or a global proxy +mount inside the image. A separate trial runtime remains configured with host +Unix sockets disabled. The hosted profile fixes backend processes at UID/GID 10001 and the parser broker at UID 10002 with the shared client group 10001 after its identity bootstrap. @@ -112,11 +121,13 @@ create the fixed private directories with these exact identities: ```sh sudo install -d -m 0700 -o 10001 -g 10001 /var/lib/workstream/scratch sudo install -d -m 0700 -o 10001 -g 10001 /var/lib/workstream/checker-runtime +sudo install -d -m 0700 -o 10001 -g 10001 /var/lib/workstream/model-runtime +sudo install -d -m 0700 -o 10001 -g 10001 /var/lib/workstream/model-relays sudo install -d -m 0750 -o 10002 -g 10001 /var/lib/workstream/guide-runtime stat -c '%g' /var/run/docker.sock ``` -The checker runtime directory must be on durable host storage, not `/run`, +The checker and model runtime directories must be on durable host storage, not `/run`, tmpfs or ephemeral container storage. Its private `.workstream-checker-owner` file identifies containers recoverable after a service or host restart. Only the checker service has a writable mount; API and worker socket mounts are read-only @@ -136,18 +147,33 @@ development authentication are absent. Create each secret selected by the configured runtime as a one-value regular file at the configured path. Make it owned by the runtime UID/GID with mode `0400`. The explicit role loader gives the API only the database, broker, rate-limit and pagination secrets; the worker -gets the database and broker. Inference runtimes additionally select and mount -one project-agent provider credential because the worker hosts setup jobs; -`manager_authored` must omit that selector, mount and secret definition. Beat +gets the database and broker. `WORKSTREAM_MODEL_PROXY_ENABLED=true` starts one +trusted model-proxy process independently of the setup runtime. Only that process +gets the database, the two closed provider credentials and the runtime ART identity. +The setup worker receives a read-only +mount of `/var/lib/workstream/model-runtime` and the fixed socket selector; it +receives no provider credential or endpoint override. A proxy-disabled +`manager_authored` release omits both credential selectors, mounts and secret +definitions; a proxy-enabled one can still run approved POST model checks. Beat gets the database and broker secrets because its startup guard verifies the current artifact-provider activation before scheduling jobs. An absent required selector or unknown role stops startup, and an unrelated secret is neither mounted nor loaded. The loader independently requires the running identity and mode `0400`, performs no shell evaluation and never prints a value. Do not put AWS static keys in these files. -The provider selector is trusted release configuration. For `openrouter`, the -entrypoint loads the worker-only file as `OPENROUTER_API_KEY`; for `openai`, it -loads the same selector as `OPENAI_API_KEY`. It clears both native variables -before selecting one and never exports a neutral key variable. The endpoint is + +With the proxy enabled, the trusted checker service also mounts the global +model socket directory read-only. Its separate writable +`/var/lib/workstream/model-relays` directory has the same absolute path on the +Docker host and in the service, owned by the runtime UID/GID with mode `0700`. +The service waits for the proxy to become healthy and mounts only the current +invocation's relay into a project image. Project images receive neither the +global socket nor provider credentials. API, Beat and proof jobs receive +neither model directory. +The two provider selectors are trusted release configuration. The proxy loads +only its dedicated files as `OPENROUTER_API_KEY` and `OPENAI_API_KEY`; this is +independent of the setup provider because approved POST and setup profiles may +use different providers. It clears both native variables before loading them +and never exports a neutral key variable. The endpoints are fixed by the registered adapter and cannot be supplied in the release file. The parser receives no database, broker, S3, Flow, model or provider credential. The release file also fixes the runtime, provider, model, model API, adapter @@ -157,9 +183,10 @@ same release. The parser receives only its fixed socket selector. The example selects `openai_agents_sdk` through OpenRouter with `openai/gpt-5.6-terra`; changing a trusted selector requires another reviewed release and never changes retained attempt evidence. -To select `omnicore_agent`, publish and choose the `omnicore` image digest and -retain OpenRouter with `chat_completions`; ordinary model identity remains a -reviewed release choice. OpenRouter models with a colon routing suffix and +To select `omnicore_agent`, publish and choose the `omnicore` worker image digest, +retain the separate `agents` proxy digest, and retain OpenRouter with +`chat_completions`; ordinary model identity remains a reviewed release choice. +OpenRouter models with a colon routing suffix and `openrouter/*` router identities are rejected. Before either OpenRouter-backed runtime receives real guide material, the @@ -231,6 +258,11 @@ activation. Copy `checker-service.example.json` to the configured private path, mode `0400` and the same runtime owner. Replace the example cache with every approved single-platform checker image supplied by the project checker/registry work. +The example includes the exact `model_proxy_socket` and `model_relay_root` +selectors for a proxy-enabled release. Remove both keys when +`WORKSTREAM_MODEL_PROXY_ENABLED=false`; preflight rejects partial, substituted +or disabled-proxy selectors. Rust consumes these paths from the private JSON +configuration, not environment variables. For each entry, preserve the exact repository, platform manifest digest/media type/byte count, Linux architecture and Docker config image ID. Pull every `repository@sha256:...` identity before preflight. Full preflight checks that @@ -297,9 +329,10 @@ python3 deploy/hosted/compose.py --env-file /etc/workstream/release.env \ python3 deploy/hosted/compose.py --env-file /etc/workstream/release.env ps ``` -The API applies forward migrations before serving. The guide parser must pass -its protocol health check before the worker starts. Worker startup uses the -existing prefork lifecycle. Beat runs the existing recovery and cleanup +The API applies forward migrations before serving. The guide parser and an +enabled trusted model proxy must pass their protocol/socket health checks before +the worker starts. Worker startup uses the existing prefork lifecycle. Beat runs the +existing recovery and cleanup schedules. The checker health check requires its private socket and Docker's registered runsc runtime. It does not attest cache identity; full preflight observes the cache once, and the canonical runner verifies the exact image again @@ -328,9 +361,10 @@ outage does not change product results. Configuration alone is not that proof. The wrapper's `stop` operation stops Beat and API first so they cannot produce new work, gives the prefork worker its configured 7,320-second graceful window, -then stops the parser and checker controller. Keeping both socket services alive -while the worker drains lets cancellation and terminal persistence finish against -their original dependencies. Guide inference has a 7,260-second process backstop; +then drains the model proxy before stopping the parser and checker controller. +Keeping all socket services alive while the worker drains lets cancellation and +terminal persistence finish against their original dependencies. Guide inference +has a 7,260-second process backstop; the shared Redis transport uses a 7,380-second visibility timeout with prefetch disabled. The persisted guide-attempt deadline remains the ordinary, shorter execution bound. Celery task hard limits and durable database/outbox custody diff --git a/deploy/hosted/checker-service.example.json b/deploy/hosted/checker-service.example.json index 3018225f9..718061488 100644 --- a/deploy/hosted/checker-service.example.json +++ b/deploy/hosted/checker-service.example.json @@ -1,6 +1,8 @@ { "socket_path": "/var/lib/workstream/checker-runtime/checker.sock", "material_root": "/var/lib/workstream/scratch", + "model_proxy_socket": "/var/lib/workstream/model-runtime/model.sock", + "model_relay_root": "/var/lib/workstream/model-relays", "docker_binary": "/usr/local/bin/docker", "runtime": "runsc", "isolation_mode": "gvisor", diff --git a/deploy/hosted/compose.inference.yml b/deploy/hosted/compose.inference.yml index ecf7d234f..0a7561002 100644 --- a/deploy/hosted/compose.inference.yml +++ b/deploy/hosted/compose.inference.yml @@ -1,14 +1,47 @@ services: + checker-service: + volumes: + - type: bind + source: /var/lib/workstream/model-runtime + target: /var/lib/workstream/model-runtime + read_only: true + - type: bind + source: /var/lib/workstream/model-relays + target: /var/lib/workstream/model-relays + depends_on: + model-proxy: + condition: service_healthy + worker: environment: - WORKSTREAM_PROJECT_AGENT_API_KEY_FILE: /run/secrets/project_agent_api_key + WORKSTREAM_CHECKER_MODEL_PROXY_SOCKET: /var/lib/workstream/model-runtime/model.sock + volumes: + - type: bind + source: /var/lib/workstream/model-runtime + target: /var/lib/workstream/model-runtime + read_only: true + depends_on: + model-proxy: + condition: service_healthy + + model-proxy: + environment: + WORKSTREAM_MODEL_PROXY_OPENAI_API_KEY_FILE: /run/secrets/model_proxy_openai_api_key + WORKSTREAM_MODEL_PROXY_OPENROUTER_API_KEY_FILE: /run/secrets/model_proxy_openrouter_api_key secrets: - - source: project_agent_api_key - target: project_agent_api_key + - source: model_proxy_openai_api_key + target: model_proxy_openai_api_key + uid: "${WORKSTREAM_RUNTIME_UID}" + gid: "${WORKSTREAM_RUNTIME_GID}" + mode: 0400 + - source: model_proxy_openrouter_api_key + target: model_proxy_openrouter_api_key uid: "${WORKSTREAM_RUNTIME_UID}" gid: "${WORKSTREAM_RUNTIME_GID}" mode: 0400 secrets: - project_agent_api_key: - file: ${WORKSTREAM_PROJECT_AGENT_API_KEY_FILE:?set the private project-agent provider key file} + model_proxy_openai_api_key: + file: ${WORKSTREAM_MODEL_PROXY_OPENAI_API_KEY_FILE:?set the private model-proxy OpenAI key file} + model_proxy_openrouter_api_key: + file: ${WORKSTREAM_MODEL_PROXY_OPENROUTER_API_KEY_FILE:?set the private model-proxy OpenRouter key file} diff --git a/deploy/hosted/compose.yml b/deploy/hosted/compose.yml index e9d3f4e1f..55becc70e 100644 --- a/deploy/hosted/compose.yml +++ b/deploy/hosted/compose.yml @@ -305,6 +305,46 @@ services: retries: 6 start_period: 30s + model-proxy: + <<: *backend-runtime + profiles: [model-proxy] + image: ${WORKSTREAM_MODEL_PROXY_IMAGE:?set the digest-qualified agents model-proxy image} + environment: + <<: *backend-environment + WORKSTREAM_RELEASE_ROLE: model-proxy + WORKSTREAM_DATABASE_URL_FILE: /run/secrets/database_url + WORKSTREAM_CHECKER_MODEL_PROXY_SOCKET: /var/lib/workstream/model-runtime/model.sock + secrets: + - source: database_url + target: database_url + uid: "${WORKSTREAM_RUNTIME_UID}" + gid: "${WORKSTREAM_RUNTIME_GID}" + mode: 0400 + command: [python, -m, app.workers.checker_model_proxy] + volumes: + - type: bind + source: /var/lib/workstream/scratch + target: /var/lib/workstream/scratch + - type: bind + source: /var/lib/workstream/model-runtime + target: /var/lib/workstream/model-runtime + - type: bind + source: ${WORKSTREAM_ARTIFACT_S3_ROLES_ANYWHERE_RUNTIME_CERTIFICATE_HOST_FILE:?set the private runtime certificate path} + target: /run/workstream-roles-anywhere/certificate.pem + read_only: true + - type: bind + source: ${WORKSTREAM_ARTIFACT_S3_ROLES_ANYWHERE_RUNTIME_PRIVATE_KEY_HOST_FILE:?set the private runtime key path} + target: /run/workstream-roles-anywhere/private-key.pem + read_only: true + restart: on-failure:5 + stop_grace_period: 7320s + healthcheck: + test: [CMD-SHELL, "test -S /var/lib/workstream/model-runtime/model.sock"] + interval: 10s + timeout: 5s + retries: 6 + start_period: 10s + beat: <<: *backend-runtime environment: diff --git a/deploy/hosted/preflight.py b/deploy/hosted/preflight.py index 833eb02c0..85feb4809 100644 --- a/deploy/hosted/preflight.py +++ b/deploy/hosted/preflight.py @@ -25,6 +25,9 @@ SOCKET_PATH = SOCKET_ROOT / "checker.sock" GUIDE_SOCKET_ROOT = Path("/var/lib/workstream/guide-runtime") GUIDE_SOCKET_PATH = GUIDE_SOCKET_ROOT / "guide.sock" +MODEL_SOCKET_ROOT = Path("/var/lib/workstream/model-runtime") +MODEL_SOCKET_PATH = MODEL_SOCKET_ROOT / "model.sock" +MODEL_RELAY_ROOT = Path("/var/lib/workstream/model-relays") GUIDE_PARSER_UID = 10002 GUIDE_PARSER_GID = 10001 DOCKER_SOCKET = Path("/var/run/docker.sock") @@ -44,8 +47,11 @@ "WORKSTREAM_API_RATE_LIMIT_KEY_SECRET_FILE", "WORKSTREAM_PAGINATION_CURSOR_HMAC_SECRET_FILE", ) -PROJECT_AGENT_SECRET_FILE_KEY = "WORKSTREAM_PROJECT_AGENT_API_KEY_FILE" -SECRET_FILE_KEYS = (*BASE_SECRET_FILE_KEYS, PROJECT_AGENT_SECRET_FILE_KEY) +MODEL_PROXY_SECRET_FILE_KEYS = ( + "WORKSTREAM_MODEL_PROXY_OPENAI_API_KEY_FILE", + "WORKSTREAM_MODEL_PROXY_OPENROUTER_API_KEY_FILE", +) +SECRET_FILE_KEYS = (*BASE_SECRET_FILE_KEYS, *MODEL_PROXY_SECRET_FILE_KEYS) SECRET_FILE_TARGETS = { "WORKSTREAM_DATABASE_URL_FILE": ("database_url", "/run/secrets/database_url"), "WORKSTREAM_CELERY_BROKER_URL_FILE": ( @@ -60,9 +66,13 @@ "pagination_cursor_hmac", "/run/secrets/pagination_cursor_hmac", ), - "WORKSTREAM_PROJECT_AGENT_API_KEY_FILE": ( - "project_agent_api_key", - "/run/secrets/project_agent_api_key", + "WORKSTREAM_MODEL_PROXY_OPENAI_API_KEY_FILE": ( + "model_proxy_openai_api_key", + "/run/secrets/model_proxy_openai_api_key", + ), + "WORKSTREAM_MODEL_PROXY_OPENROUTER_API_KEY_FILE": ( + "model_proxy_openrouter_api_key", + "/run/secrets/model_proxy_openrouter_api_key", ), } ROLE_SECRET_FILE_KEYS = { @@ -80,6 +90,7 @@ "WORKSTREAM_DATABASE_URL_FILE", "WORKSTREAM_CELERY_BROKER_URL_FILE", ), + "model-proxy": ("WORKSTREAM_DATABASE_URL_FILE",), } FORBIDDEN_RELEASE_KEYS = ( "AWS_ACCESS_KEY_ID", @@ -112,6 +123,7 @@ EXACT_RELEASE_VALUES = { "WORKSTREAM_ARTIFACT_S3_AWS_WORKLOAD_IDENTITY_METHOD": {"roles-anywhere"}, "WORKSTREAM_TOKEN_INTROSPECTION_MODE": {"disabled"}, + "WORKSTREAM_MODEL_PROXY_ENABLED": {"true", "false"}, "WORKSTREAM_PROJECT_AGENT_RUNTIME": { "openai_agents_sdk", "omnicore_agent", @@ -192,7 +204,9 @@ REQUIRED_RELEASE_KEYS = ( "COMPOSE_PROJECT_NAME", "WORKSTREAM_BACKEND_IMAGE", + "WORKSTREAM_MODEL_PROXY_IMAGE", "WORKSTREAM_CHECKER_SERVICE_IMAGE", + "WORKSTREAM_MODEL_PROXY_ENABLED", "WORKSTREAM_RUNTIME_UID", "WORKSTREAM_RUNTIME_GID", "WORKSTREAM_DOCKER_SOCKET_GID", @@ -221,7 +235,7 @@ "WORKSTREAM_OBSERVABILITY_LOG_LEVEL", "WORKSTREAM_OBSERVABILITY_OTLP_ENDPOINT", "WORKSTREAM_OBSERVABILITY_TRACE_SAMPLE_RATIO", - PROJECT_AGENT_SECRET_FILE_KEY, + *MODEL_PROXY_SECRET_FILE_KEYS, ) RELEASE_KEYS = frozenset((*REQUIRED_RELEASE_KEYS, *OPTIONAL_RELEASE_KEYS)) @@ -292,7 +306,11 @@ def validate_environment(values: dict[str, str]) -> None: ) for key in REQUIRED_RELEASE_KEYS: _required(values, key) - for key in ("WORKSTREAM_BACKEND_IMAGE", "WORKSTREAM_CHECKER_SERVICE_IMAGE"): + for key in ( + "WORKSTREAM_BACKEND_IMAGE", + "WORKSTREAM_MODEL_PROXY_IMAGE", + "WORKSTREAM_CHECKER_SERVICE_IMAGE", + ): if not IMAGE_REFERENCE.fullmatch(_required(values, key)): raise PreflightError(f"release image must use repository@sha256: {key}") for key in ( @@ -327,12 +345,7 @@ def validate_environment(values: dict[str, str]) -> None: raise PreflightError( "manager-authored project agent configuration is invalid" ) - if values.get(PROJECT_AGENT_SECRET_FILE_KEY): - raise PreflightError( - "manager-authored releases cannot select a provider credential" - ) else: - _required(values, PROJECT_AGENT_SECRET_FILE_KEY) if provider not in {"openai", "openrouter"}: raise PreflightError("unsupported project agent model provider") if model_api not in {"chat_completions", "responses"}: @@ -348,6 +361,14 @@ def validate_environment(values: dict[str, str]) -> None: ":" in model or model.casefold().startswith("openrouter/") ): raise PreflightError("OpenRouter model routing variants are unsupported") + model_proxy_enabled = values["WORKSTREAM_MODEL_PROXY_ENABLED"] == "true" + if runtime != "manager_authored" and not model_proxy_enabled: + raise PreflightError("inference setup requires the trusted model proxy") + if model_proxy_enabled: + for key in MODEL_PROXY_SECRET_FILE_KEYS: + _required(values, key) + elif any(values.get(key) for key in MODEL_PROXY_SECRET_FILE_KEYS): + raise PreflightError("disabled model proxy cannot select provider credentials") try: runtime_options = json.loads(values["WORKSTREAM_PROJECT_AGENT_RUNTIME_OPTIONS"]) except json.JSONDecodeError as exc: @@ -425,6 +446,11 @@ def validate_checker_config(config: dict[str, Any], values: dict[str, str]) -> N "sandbox_uid": _positive_id(values, "WORKSTREAM_RUNTIME_UID"), "sandbox_gid": _positive_id(values, "WORKSTREAM_RUNTIME_GID"), } + model_proxy_enabled = values["WORKSTREAM_MODEL_PROXY_ENABLED"] == "true" + expected.update({ + "model_proxy_socket": str(MODEL_SOCKET_PATH) if model_proxy_enabled else None, + "model_relay_root": str(MODEL_RELAY_ROOT) if model_proxy_enabled else None, + }) for key, required in expected.items(): if config.get(key) != required: raise PreflightError( @@ -478,18 +504,15 @@ def _private_metadata(path: Path, *, kind: str, uid: int, gid: int, mode: int) - def _secret_file_keys(values: dict[str, str]) -> tuple[str, ...]: - if values["WORKSTREAM_PROJECT_AGENT_RUNTIME"] == "manager_authored": - return BASE_SECRET_FILE_KEYS - return SECRET_FILE_KEYS + if values["WORKSTREAM_MODEL_PROXY_ENABLED"] == "true": + return SECRET_FILE_KEYS + return BASE_SECRET_FILE_KEYS def _role_secret_file_keys(role: str, values: dict[str, str]) -> tuple[str, ...]: keys = ROLE_SECRET_FILE_KEYS[role] - if ( - role == "worker" - and values["WORKSTREAM_PROJECT_AGENT_RUNTIME"] != "manager_authored" - ): - return (*keys, PROJECT_AGENT_SECRET_FILE_KEY) + if role == "model-proxy" and values["WORKSTREAM_MODEL_PROXY_ENABLED"] == "true": + return (*keys, *MODEL_PROXY_SECRET_FILE_KEYS) return keys @@ -499,6 +522,14 @@ def validate_private_host_inputs(values: dict[str, str]) -> None: gid = _positive_id(values, "WORKSTREAM_RUNTIME_GID") _private_metadata(SCRATCH_ROOT, kind="directory", uid=uid, gid=gid, mode=0o700) _private_metadata(SOCKET_ROOT, kind="directory", uid=uid, gid=gid, mode=0o700) + model_proxy_enabled = values["WORKSTREAM_MODEL_PROXY_ENABLED"] == "true" + if model_proxy_enabled: + _private_metadata( + MODEL_SOCKET_ROOT, kind="directory", uid=uid, gid=gid, mode=0o700 + ) + _private_metadata( + MODEL_RELAY_ROOT, kind="directory", uid=uid, gid=gid, mode=0o700 + ) _private_metadata( GUIDE_SOCKET_ROOT, kind="directory", @@ -521,6 +552,22 @@ def validate_private_host_inputs(values: dict[str, str]) -> None: or guide_socket.st_nlink != 1 ): raise PreflightError("guide parser socket has unsafe retained state") + if model_proxy_enabled: + try: + model_socket = MODEL_SOCKET_PATH.lstat() + except FileNotFoundError: + model_socket = None + except OSError as exc: + raise PreflightError("model proxy socket identity is unavailable") from exc + if model_socket is not None and ( + stat.S_ISLNK(model_socket.st_mode) + or not stat.S_ISSOCK(model_socket.st_mode) + or model_socket.st_uid != uid + or model_socket.st_gid != gid + or stat.S_IMODE(model_socket.st_mode) != 0o600 + or model_socket.st_nlink != 1 + ): + raise PreflightError("model proxy socket has unsafe retained state") private_paths = [ Path(values["WORKSTREAM_CHECKER_CONFIG_FILE"]), *(Path(values[key]) for key in _secret_file_keys(values)), @@ -529,11 +576,14 @@ def validate_private_host_inputs(values: dict[str, str]) -> None: for path in private_paths: _private_metadata(path, kind="file", uid=uid, gid=gid, mode=0o400) try: - shared_roots = ( + shared_roots = [ SCRATCH_ROOT.resolve(strict=True), SOCKET_ROOT.resolve(strict=True), GUIDE_SOCKET_ROOT.resolve(strict=True), - ) + ] + if model_proxy_enabled: + shared_roots.append(MODEL_SOCKET_ROOT.resolve(strict=True)) + shared_roots.append(MODEL_RELAY_ROOT.resolve(strict=True)) resolved_paths = [path.resolve(strict=True) for path in private_paths] except (OSError, RuntimeError) as exc: raise PreflightError("private host input identity is unavailable") from exc @@ -589,8 +639,9 @@ def _run_json( def _compose_file_arguments(values: dict[str, str]) -> list[str]: arguments = ["--file", str(COMPOSE_PATH)] - if values["WORKSTREAM_PROJECT_AGENT_RUNTIME"] != "manager_authored": + if values["WORKSTREAM_MODEL_PROXY_ENABLED"] == "true": arguments.extend(("--file", str(INFERENCE_COMPOSE_PATH))) + arguments.extend(("--profile", "model-proxy")) return arguments @@ -647,11 +698,13 @@ def run_compose( def run_shutdown(environment_path: Path, values: dict[str, str]) -> int: """Stop producers, drain the worker, then stop its isolated dependencies.""" - phases = ( + phases = [ ["stop", "beat", "api"], ["stop", "--timeout", "7320", "worker"], - ["stop", "guide-document-parser", "checker-service"], - ) + ] + if values["WORKSTREAM_MODEL_PROXY_ENABLED"] == "true": + phases.append(["stop", "--timeout", "7320", "model-proxy"]) + phases.append(["stop", "guide-document-parser", "checker-service"]) for arguments in phases: try: completed = subprocess.run( @@ -790,14 +843,18 @@ def _validate_exact_bind_mounts( def validate_compose(rendered: dict[str, Any], values: dict[str, str]) -> None: """Check the rendered release process and privilege topology.""" services = rendered.get("services") - if not isinstance(services, dict) or set(services) != { + expected_services = { "api", "worker", "beat", "guide-document-parser", "checker-service", *PROOF_SERVICES, - }: + } + model_proxy_enabled = values["WORKSTREAM_MODEL_PROXY_ENABLED"] == "true" + if model_proxy_enabled: + expected_services.add("model-proxy") + if not isinstance(services, dict) or set(services) != expected_services: raise PreflightError( "release Compose services differ from the bounded topology" ) @@ -834,7 +891,16 @@ def validate_compose(rendered: dict[str, Any], values: dict[str, str]) -> None: runtime_identity = ( f"{values['WORKSTREAM_RUNTIME_UID']}:{values['WORKSTREAM_RUNTIME_GID']}" ) - for name in {"api", "worker", "beat", "checker-service", *PROOF_SERVICES}: + backend_runtime_services = { + "api", + "worker", + "beat", + "checker-service", + *PROOF_SERVICES, + } + if model_proxy_enabled: + backend_runtime_services.add("model-proxy") + for name in backend_runtime_services: if services[name]["user"] != runtime_identity: raise PreflightError( f"release service has the wrong runtime identity: {name}" @@ -852,6 +918,11 @@ def validate_compose(rendered: dict[str, Any], values: dict[str, str]) -> None: ) if any(services[name]["image"] != backend_image for name in PROOF_SERVICES): raise PreflightError("AWS proof jobs must use the exact backend image") + if ( + model_proxy_enabled + and services["model-proxy"]["image"] != values["WORKSTREAM_MODEL_PROXY_IMAGE"] + ): + raise PreflightError("model proxy must use the exact agents backend image") if any(services[name].get("pull_policy") != "never" for name in PROOF_SERVICES): raise PreflightError( "AWS proof jobs must use the cached release image without pulling" @@ -862,6 +933,7 @@ def validate_compose(rendered: dict[str, Any], values: dict[str, str]) -> None: "beat", "guide-document-parser", *PROOF_SERVICES, + *({"model-proxy"} if model_proxy_enabled else set()), ): if services[name].get("entrypoint") is not None: raise PreflightError(f"backend image entrypoint is overridden: {name}") @@ -892,10 +964,20 @@ def validate_compose(rendered: dict[str, Any], values: dict[str, str]) -> None: str(SCRATCH_ROOT): (str(SCRATCH_ROOT), True), str(SOCKET_ROOT): (str(SOCKET_ROOT), False), str(DOCKER_SOCKET): (str(DOCKER_SOCKET), False), - }, + } | ( + { + str(MODEL_SOCKET_ROOT): (str(MODEL_SOCKET_ROOT), True), + str(MODEL_RELAY_ROOT): (str(MODEL_RELAY_ROOT), False), + } if model_proxy_enabled else {} + ), "api": shared_backend_mounts | runtime_identity_mounts, "worker": shared_backend_mounts | {str(GUIDE_SOCKET_ROOT): (str(GUIDE_SOCKET_ROOT), True)} + | ( + {str(MODEL_SOCKET_ROOT): (str(MODEL_SOCKET_ROOT), True)} + if model_proxy_enabled + else {} + ) | runtime_identity_mounts, "beat": {}, "guide-document-parser": { @@ -932,6 +1014,12 @@ def validate_compose(rendered: dict[str, Any], values: dict[str, str]) -> None: }, "aws-proof-coordinate": {}, } + if model_proxy_enabled: + expected_mounts["model-proxy"] = { + str(SCRATCH_ROOT): (str(SCRATCH_ROOT), False), + str(MODEL_SOCKET_ROOT): (str(MODEL_SOCKET_ROOT), False), + **runtime_identity_mounts, + } for name, mounts in expected_mounts.items(): _validate_exact_bind_mounts(name, services[name], mounts) expected_secret_files = { @@ -1043,6 +1131,15 @@ def validate_compose(rendered: dict[str, Any], values: dict[str, str]) -> None: "guide-document-parser" ) != {"condition": "service_healthy", "required": True}: raise PreflightError("worker must wait for a healthy guide parser") + if model_proxy_enabled and worker_dependencies.get("model-proxy") != { + "condition": "service_healthy", + "required": True, + }: + raise PreflightError("worker must wait for a healthy model proxy") + if model_proxy_enabled and services["checker-service"].get("depends_on", {}).get( + "model-proxy" + ) != {"condition": "service_healthy", "required": True}: + raise PreflightError("checker service must wait for a healthy model proxy") worker = services["worker"] worker_command = worker.get("command") if ( @@ -1055,6 +1152,21 @@ def validate_compose(rendered: dict[str, Any], values: dict[str, str]) -> None: ) ): raise PreflightError("project guide worker delivery profile is invalid") + if model_proxy_enabled: + proxy = services["model-proxy"] + if ( + proxy.get("profiles") != ["model-proxy"] + or proxy.get("ports") + or proxy.get("command") + != ["python", "-m", "app.workers.checker_model_proxy"] + or proxy.get("stop_grace_period") not in {"7320s", "2h2m0s"} + or proxy.get("healthcheck", {}).get("test") + != [ + "CMD-SHELL", + "test -S /var/lib/workstream/model-runtime/model.sock", + ] + ): + raise PreflightError("model proxy delivery profile is invalid") for name in ("aws-proof-runtime", "aws-proof-readiness", "aws-proof-negative"): service = services[name] if service.get("profiles") != ["aws-proof"]: @@ -1101,7 +1213,10 @@ def validate_compose(rendered: dict[str, Any], values: dict[str, str]) -> None: ): raise PreflightError(f"{name} must receive only the database secret") environments: dict[str, dict[str, Any]] = {} - for role in ROLE_SECRET_FILE_KEYS: + active_roles = ("api", "worker", "beat") + ( + ("model-proxy",) if model_proxy_enabled else () + ) + for role in active_roles: environment = services[role].get("environment") if not isinstance(environment, dict): raise PreflightError(f"{role} release environment is unavailable") @@ -1148,6 +1263,7 @@ def validate_compose(rendered: dict[str, Any], values: dict[str, str]) -> None: not in { "WORKSTREAM_RELEASE_ROLE", "WORKSTREAM_GUIDE_DOCUMENT_PARSER_SOCKET", + "WORKSTREAM_CHECKER_MODEL_PROXY_SOCKET", } } ) @@ -1178,7 +1294,13 @@ def validate_compose(rendered: dict[str, Any], values: dict[str, str]) -> None: for key, value in exact.items(): if str(environment.get(key, "")).lower() != value: raise PreflightError(f"rendered release environment is unsafe: {key}") - for name in ("api", "worker", "beat", *PROOF_SERVICES): + for name in ( + "api", + "worker", + "beat", + *PROOF_SERVICES, + *(("model-proxy",) if model_proxy_enabled else ()), + ): service_environment = services[name].get("environment") if not isinstance(service_environment, dict): raise PreflightError(f"{name} release environment is unavailable") @@ -1193,16 +1315,30 @@ def validate_compose(rendered: dict[str, Any], values: dict[str, str]) -> None: GUIDE_SOCKET_PATH ): raise PreflightError("worker lacks the fixed guide parser socket") + if model_proxy_enabled and worker_environment.get( + "WORKSTREAM_CHECKER_MODEL_PROXY_SOCKET" + ) != str(MODEL_SOCKET_PATH): + raise PreflightError("worker lacks the fixed model proxy socket") + if model_proxy_enabled and environments["model-proxy"].get( + "WORKSTREAM_CHECKER_MODEL_PROXY_SOCKET" + ) != str(MODEL_SOCKET_PATH): + raise PreflightError("model proxy lacks its fixed socket") for name in ("api", "beat", "checker-service", *PROOF_SERVICES): service_environment = services[name].get("environment") or {} if not isinstance(service_environment, dict): raise PreflightError(f"{name} release environment is invalid") if "WORKSTREAM_GUIDE_DOCUMENT_PARSER_SOCKET" in service_environment: raise PreflightError(f"{name} must not receive the guide parser socket") - if "WORKSTREAM_PROJECT_AGENT_API_KEY_FILE" in service_environment: + if any(key in service_environment for key in MODEL_PROXY_SECRET_FILE_KEYS): raise PreflightError( - f"{name} must not receive the project agent credential selector" + f"{name} must not receive a model proxy credential selector" ) + if "WORKSTREAM_CHECKER_MODEL_PROXY_SOCKET" in service_environment: + raise PreflightError(f"{name} must not receive the model proxy socket") + if any(key in worker_environment for key in MODEL_PROXY_SECRET_FILE_KEYS): + raise PreflightError( + "worker must not receive a model proxy credential selector" + ) for name, service in services.items(): service_environment = service.get("environment") or {} if not isinstance(service_environment, dict): @@ -1249,6 +1385,16 @@ def validate_docker_runtime(config: dict[str, Any], values: dict[str, str]) -> N ) if not isinstance(runtimes, dict) or "runsc" not in runtimes: raise PreflightError("Docker does not report the required runsc runtime") + if values["WORKSTREAM_MODEL_PROXY_ENABLED"] == "true": + runtime = runtimes["runsc"] + arguments = runtime.get("runtimeArgs", []) if isinstance(runtime, dict) else [] + if not isinstance(arguments, list) or [ + argument for argument in arguments + if isinstance(argument, str) and argument.startswith("--host-uds") + ] != ["--host-uds=open"]: + raise PreflightError( + "model-enabled checker runsc requires exactly --host-uds=open" + ) for entry in config["cache"]: reference = f"{entry['repository']}@{entry['platform_manifest_digest']}" inspected = _run_json( diff --git a/deploy/hosted/release.env.example b/deploy/hosted/release.env.example index d1e928fea..05607669a 100644 --- a/deploy/hosted/release.env.example +++ b/deploy/hosted/release.env.example @@ -2,6 +2,8 @@ # an immutable repository@sha256 reference produced from the reviewed source. COMPOSE_PROJECT_NAME=workstream-release WORKSTREAM_BACKEND_IMAGE=registry.example/workstream/backend@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa +WORKSTREAM_MODEL_PROXY_IMAGE=registry.example/workstream/backend-agents@sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd +WORKSTREAM_MODEL_PROXY_ENABLED=true WORKSTREAM_CHECKER_SERVICE_IMAGE=registry.example/workstream/checker-service@sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb WORKSTREAM_RUNTIME_UID=10001 @@ -16,13 +18,17 @@ WORKSTREAM_DATABASE_URL_FILE=/etc/workstream/secrets/database_url WORKSTREAM_CELERY_BROKER_URL_FILE=/etc/workstream/secrets/celery_broker_url WORKSTREAM_API_RATE_LIMIT_KEY_SECRET_FILE=/etc/workstream/secrets/api_rate_limit_key WORKSTREAM_PAGINATION_CURSOR_HMAC_SECRET_FILE=/etc/workstream/secrets/pagination_cursor_hmac -# Required only for inference runtimes. Omit this line entirely when the runtime -# is manager_authored. -WORKSTREAM_PROJECT_AGENT_API_KEY_FILE=/etc/workstream/secrets/project_agent_api_key +# Required only when WORKSTREAM_MODEL_PROXY_ENABLED=true. They are independent +# of the setup SDK/provider because approved POST profiles may select OpenAI +# while setup selects OpenRouter. Omit both when the proxy is disabled. +WORKSTREAM_MODEL_PROXY_OPENAI_API_KEY_FILE=/etc/workstream/secrets/model_proxy_openai_api_key +WORKSTREAM_MODEL_PROXY_OPENROUTER_API_KEY_FILE=/etc/workstream/secrets/model_proxy_openrouter_api_key # The runtime, provider, model and bounded effective profile are public trusted -# configuration. The worker entrypoint maps the one provider credential file to -# the selected provider's native variable; no endpoint or key value belongs here. +# configuration. Only the dedicated trusted model-proxy entrypoint maps the two +# closed provider credential files to native variables. The proxy +# image is always built with the pinned `agents` dependency set; workers may use +# either mutually exclusive project-agent SDK image and never receive the key. WORKSTREAM_PROJECT_AGENT_RUNTIME=openai_agents_sdk WORKSTREAM_PROJECT_AGENT_MODEL_PROVIDER=openrouter WORKSTREAM_PROJECT_AGENT_MODEL=openai/gpt-5.6-terra diff --git a/docker-compose.agent-loop.yml b/docker-compose.agent-loop.yml index 9ea5da844..d68a1376e 100644 --- a/docker-compose.agent-loop.yml +++ b/docker-compose.agent-loop.yml @@ -10,6 +10,10 @@ # # The socket lives on a second named volume. Docker Desktop host-shared paths # refuse chmod on Unix sockets, and AF_UNIX paths must stay within 107 bytes. +# An optional singleton model-proxy worker uses its own private named volume. +# Invocation relay sockets use a fourth volume mounted in the checker at the +# volume's daemon-visible path so Docker can bind-mount an individual socket +# into an otherwise networkless checker image. x-agent-loop-backend: &agent-loop-backend environment: @@ -24,10 +28,37 @@ x-agent-loop-backend: &agent-loop-backend condition: service_completed_successfully services: - backend: *agent-loop-backend - worker: *agent-loop-backend + backend: + <<: *agent-loop-backend + worker: + <<: *agent-loop-backend + environment: + WORKSTREAM_ARTIFACT_SCRATCH_ROOT: /var/lib/docker/volumes/${COMPOSE_PROJECT_NAME}-checker-material/_data + WORKSTREAM_EXTERNAL_CHECKER_MATERIAL_ROOT: /var/lib/docker/volumes/${COMPOSE_PROJECT_NAME}-checker-material/_data + WORKSTREAM_EXTERNAL_CHECKER_SERVICE_SOCKET: /run/ws-checker/checker.sock + WORKSTREAM_CHECKER_MODEL_PROXY_SOCKET: /run/ws-model-proxy/model-proxy.sock + volumes: + - checker_material:/var/lib/docker/volumes/${COMPOSE_PROJECT_NAME}-checker-material/_data + - checker_socket:/run/ws-checker:ro + - checker_model_proxy:/run/ws-model-proxy:ro beat: *agent-loop-backend + model-proxy: + environment: + WORKSTREAM_CHECKER_MODEL_PROXY_SOCKET: /run/ws-model-proxy/model-proxy.sock + WORKSTREAM_ARTIFACT_SCRATCH_ROOT: /var/lib/docker/volumes/${COMPOSE_PROJECT_NAME}-checker-material/_data + volumes: + - checker_model_proxy:/run/ws-model-proxy + - checker_material:/var/lib/docker/volumes/${COMPOSE_PROJECT_NAME}-checker-material/_data + depends_on: + checker-volumes-init: + condition: service_completed_successfully + healthcheck: + test: [CMD, python, -c, "import socket; s=socket.socket(socket.AF_UNIX); s.settimeout(2); s.connect('/run/ws-model-proxy/model-proxy.sock'); s.close()"] + interval: 5s + timeout: 3s + retries: 12 + # Named-volume roots start root-owned 0755. The scratch manager and checker # service both require a private 0700 root owned by the runtime UID. checker-volumes-init: @@ -44,13 +75,19 @@ services: - -c - | set -eu - for directory in "$$MATERIAL_ROOT" /run/ws-checker; do + for directory in \ + "$$MATERIAL_ROOT" \ + /run/ws-checker \ + /run/ws-model-proxy \ + /var/lib/docker/volumes/${COMPOSE_PROJECT_NAME}-checker-relay/_data; do chown "$$RUNTIME_OWNER" "$$directory" chmod 0700 "$$directory" done volumes: - checker_material:/var/lib/docker/volumes/${COMPOSE_PROJECT_NAME}-checker-material/_data - checker_socket:/run/ws-checker + - checker_model_proxy:/run/ws-model-proxy + - checker_model_relay:/var/lib/docker/volumes/${COMPOSE_PROJECT_NAME}-checker-relay/_data # Loopback-only registry so the daemon records a platform-manifest digest. checker-registry: @@ -94,6 +131,8 @@ services: target: /var/lib/docker/volumes/${COMPOSE_PROJECT_NAME}-checker-material/_data read_only: true - checker_socket:/run/ws-checker + - checker_model_proxy:/run/ws-model-proxy:ro + - checker_model_relay:/var/lib/docker/volumes/${COMPOSE_PROJECT_NAME}-checker-relay/_data - /var/run/docker.sock:/var/run/docker.sock depends_on: checker-volumes-init: @@ -115,4 +154,7 @@ volumes: checker_material: name: ${COMPOSE_PROJECT_NAME}-checker-material checker_socket: + checker_model_proxy: + checker_model_relay: + name: ${COMPOSE_PROJECT_NAME}-checker-relay checker_registry: diff --git a/docker-compose.yml b/docker-compose.yml index a4711711b..b7e7a8a31 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -85,8 +85,6 @@ services: group_add: ["10001"] environment: <<: *backend-environment - OPENROUTER_API_KEY: ${OPENROUTER_API_KEY:-} - OPENAI_API_KEY: ${OPENAI_API_KEY:-} WORKSTREAM_GUIDE_DOCUMENT_PARSER_SOCKET: /var/lib/workstream/guide-runtime/guide.sock volumes: - ./backend:/workspace/backend @@ -122,6 +120,23 @@ services: retries: 12 start_period: 20s + model-proxy: + <<: *backend-runtime + profiles: ["model-proxy"] + environment: + <<: *backend-environment + OPENROUTER_API_KEY: ${OPENROUTER_API_KEY:-} + OPENAI_API_KEY: ${OPENAI_API_KEY:-} + command: [python, -m, app.workers.checker_model_proxy] + stop_grace_period: 125m + depends_on: + backend: + condition: service_healthy + redis: + condition: service_healthy + minio: + condition: service_healthy + beat: <<: *backend-runtime command: diff --git a/docker/backend/Dockerfile.release b/docker/backend/Dockerfile.release index 36c5a19ac..c9f5a9209 100644 --- a/docker/backend/Dockerfile.release +++ b/docker/backend/Dockerfile.release @@ -41,7 +41,8 @@ RUN test "${WORKSTREAM_RUNTIME_UID}" -ne 0 \ && useradd --uid "${WORKSTREAM_RUNTIME_UID}" --gid workstream \ --create-home --shell /usr/sbin/nologin workstream \ && install -d -m 0700 -o workstream -g workstream \ - /var/lib/workstream/scratch /var/lib/workstream/checker-runtime + /var/lib/workstream/scratch /var/lib/workstream/checker-runtime \ + /var/lib/workstream/model-runtime # The fixed guide parser owns this socket directory; workers only connect. RUN install -d -m 0750 -o 10002 -g 10001 /var/lib/workstream/guide-runtime diff --git a/docker/backend/release-entrypoint.sh b/docker/backend/release-entrypoint.sh index a9cc5fa28..e657004d5 100644 --- a/docker/backend/release-entrypoint.sh +++ b/docker/backend/release-entrypoint.sh @@ -21,7 +21,12 @@ runtime="${WORKSTREAM_PROJECT_AGENT_RUNTIME:-}" provider="${WORKSTREAM_PROJECT_AGENT_MODEL_PROVIDER:-}" model_api="${WORKSTREAM_PROJECT_AGENT_MODEL_API:-}" model="${WORKSTREAM_PROJECT_AGENT_MODEL:-}" -if [ "$role" != "parser" ]; then +if [ "$role" = "model-proxy" ]; then + if [ "$sdk_environment" != "agents" ]; then + echo "model proxy requires the pinned agents SDK environment" >&2 + exit 1 + fi +elif [ "$role" != "parser" ]; then case "$runtime:$sdk_environment" in openai_agents_sdk:agents|omnicore_agent:omnicore) ;; @@ -96,22 +101,11 @@ case "$role" in worker) load_required_secret WORKSTREAM_DATABASE_URL "${WORKSTREAM_DATABASE_URL_FILE:-}" load_required_secret WORKSTREAM_CELERY_BROKER_URL "${WORKSTREAM_CELERY_BROKER_URL_FILE:-}" - case "$runtime" in - manager_authored) - ;; - *) case "$provider" in - openrouter) - load_required_secret OPENROUTER_API_KEY "${WORKSTREAM_PROJECT_AGENT_API_KEY_FILE:-}" - ;; - openai) - load_required_secret OPENAI_API_KEY "${WORKSTREAM_PROJECT_AGENT_API_KEY_FILE:-}" - ;; - *) - echo "trusted project agent model provider is unavailable or unsupported" >&2 - exit 1 - ;; - esac ;; - esac + ;; + model-proxy) + load_required_secret WORKSTREAM_DATABASE_URL "${WORKSTREAM_DATABASE_URL_FILE:-}" + load_required_secret OPENAI_API_KEY "${WORKSTREAM_MODEL_PROXY_OPENAI_API_KEY_FILE:-}" + load_required_secret OPENROUTER_API_KEY "${WORKSTREAM_MODEL_PROXY_OPENROUTER_API_KEY_FILE:-}" ;; beat) load_required_secret WORKSTREAM_DATABASE_URL "${WORKSTREAM_DATABASE_URL_FILE:-}" @@ -134,7 +128,8 @@ unset WORKSTREAM_DATABASE_URL_FILE unset WORKSTREAM_CELERY_BROKER_URL_FILE unset WORKSTREAM_API_RATE_LIMIT_KEY_SECRET_FILE unset WORKSTREAM_PAGINATION_CURSOR_HMAC_SECRET_FILE -unset WORKSTREAM_PROJECT_AGENT_API_KEY_FILE +unset WORKSTREAM_MODEL_PROXY_OPENAI_API_KEY_FILE +unset WORKSTREAM_MODEL_PROXY_OPENROUTER_API_KEY_FILE unset OPENAI_API_KEY_FILE unset AWS_SHARED_CREDENTIALS_FILE unset AWS_CONFIG_FILE diff --git a/docs/architecture_checker_framework.md b/docs/architecture_checker_framework.md index bd8a0de41..c35bbdd95 100644 --- a/docs/architecture_checker_framework.md +++ b/docs/architecture_checker_framework.md @@ -398,11 +398,15 @@ atomically persists complete closed member results and a completion event; it does not activate automatic request delivery. ARCH-04B supplies the hidden ART input port: exact consumed Submission bytes, rebuilt manifest, async scoped file access and cleanup, followed by a fresh material-selection check. ARCH-04D2 supplies fresh fixed-service materialization authority before and after I/O. -ARCH-04B2 now supplies hidden typed output storage, byte-free recovery and -flush-only verified binding. The CHECKERS reservation reader now verifies the -exact current request/run/lease and returns zero slots; ART authority remains -deny-only. Controlled nonempty slots prove ART mechanics only. ARCH-04C supports -that exact empty output set and owns durable execution. ARCH-04D2 supplies input, +The existing ART output storage, byte-free recovery and flush-only verified +binding now retain private model-call request/response evidence. CHECKERS +reserves exactly those bounded slots against the stable paid attempt. Request +writes require current execution; response writes may complete an already-paid +effect after its original lease ends. Fresh current authority still gates image +access. The corresponding fixed ART writer/binding actions are active. This +private evidence is separate from the run's normalized member findings; ordinary +images do not acquire model-call authority. ARCH-04C owns durable execution. +ARCH-04D2 supplies input, execute and finalize authority; ARCH-04E/B8 supply the hidden routing operation and completion handler, and 04F2 supplies its remediation branch. Production request/completion registration is supplied by PILOT-04. A returned evaluation value is not a stored current @@ -487,12 +491,21 @@ coded work findings, positive and negative cases, runtime dependencies and infrastructure failures. File and directory inputs use canonical relative paths. Capacity, deadline, unavailable material or implementation, and invalid output remain infrastructure outcomes rather than contributor work findings. Model -evaluation requires an explicit model-service dependency; task or guide context +evaluation requires a `model_service` dependency provisioned by the credential- +isolated `model_proxy`; task or guide context and external prepared images or caches require explicit material dependencies. `prepared_material` remains a runtime gap with `material_unavailable` handling; -it is not received package content and its absence is not a work failure. The ordinary image runner remains -offline and receives only its trusted request contract, so a desired dependency -must not be presented as available runtime capability. +it is not received package content and its absence is not a work failure. The +ordinary image runner remains networkless. Model access is available only to a +registered POST image through its exact approved project model profile; a missing +image or profile remains an unbound capability suggestion that blocks activation. +The paid-call slot excludes retry generation and execution lease. Workstream +reserves the maximum configured charge before create, retains that reservation +on ambiguous delivery, and retrieves only a known provider ID during recovery. +The image owns prompt interpretation and verdict; the proxy cannot accept work +or spend a revision. Public Operator profile/budget controls and narrowly +authorized terminal-call recovery are described in +[model runtime operations](operations_checker_models.md). The trusted compiler resolves each supported project check to an immutable Operator-registered image, validates bounded typed configuration against that diff --git a/docs/architecture_data_model.md b/docs/architecture_data_model.md index f6679a119..858b8f8e3 100644 --- a/docs/architecture_data_model.md +++ b/docs/architecture_data_model.md @@ -1750,6 +1750,48 @@ request, run, lease, result, material and original execution receipt. Unprovable retained receipts cause an atomic upgrade refusal; queued work is preserved. Production uses real action-specific AUTH/PREP with fresh live identity checks. +## Shared model calls + +Setup agents and model-enabled external POST images share immutable project +profiles and one lifetime budget account. Setup snapshots its selected profile +into the compilation attempt; POST selects it through the locked policy +configuration. Paid effects retain distinct setup or checker ancestry. Setup +restart, loss of an execution lease, or checker retry does not itself authorize +another paid effect. + +| Table | Custody | +|---|---| +| `model_usage_profiles` | Immutable project/provider/model/bounds/pricing specification and Operator authorization receipt; image/configuration binding remains with approved POST policy | +| `model_usage_budget_configurations` | Immutable versions of the finite project lifetime ceiling | +| `model_usage_budget_accounts` | Current configuration and serialized lifetime reserved/settled integer USD nanodollars; configuration changes never reset spend | +| `checker_model_usage_envelopes` | Finite aggregate call/token/byte/tool bounds for the locked POST workload and profile | +| `setup_model_usage_envelopes` | Finite aggregate bounds across draft/review/correction for one compilation attempt, setup generation, runtime configuration and profile | +| `setup_model_usage_slots` | Stable setup envelope, phase and call index with the immutable first request hash | +| `checker_model_usage_slots` | Stable project, assignment, Submission/version, requirement, registry digest, configuration hash, exact profile and call-slot identity; excludes retry generation and execution lease | +| `model_usage_effects` | Exactly one strong setup-slot or checker-slot owner; immutable first request hash, dispatch intent, reservation, provider identity, private ART request/response references, usage and once-only terminal settlement | +| `checker_model_usage_recoveries` | Fresh current-run attachment with canonical execution/effect facts; SQL verifies the exact paid slot, running successor lease, fixed-service recovery receipt and resource digest, and the row cannot authorize a new provider create | +| `checker_model_invocation_grant_calls` | Each invocation call index binds the exact request hash and durable paid effect; a successor invocation may reuse that same effect without a new paid create | +| `checker_model_invocation_grants` | Hashed short-lived capability, exact execution and binding, PostgreSQL expiry and bounded indexed call attachments | + +An attempt progresses from `prepared` to `dispatching`, with uncertain delivery +retaining its full reservation. Authenticated output and complete usage settle +once as `completed` or `known_failed`; proven no-effect becomes `known_no_effect` +and releases the reservation. Only explicit Operator recovery from a terminal +no-effect or fully settled failure may create a child attempt. Unknown delivery +or incomplete usage never permits another create. Late completion may retain +evidence after the originating run ends; fresh current execution is still +required to release that output to an image. + +Migration `0062_model_usage` extends existing ART put/receipt custody +with `model_call_attempt_id` and exact private request/response slots. Transcript +bytes stay in ART, never these ledger rows or outbox payloads. Existing historical +run-bound ART rows remain retained; new model evidence uses the paid attempt. +Setup and POST request/response bindings use `setup_model_call` and +`checker_model_call`, respectively. Their private evidence stays separate from +normalized public checker findings. Setup uses provider storage disabled: an +observed provider ID alone cannot recover lost response bytes; unresolved effects +retain their reservation until authenticated evidence resolves them. See [model runtime operations](operations_checker_models.md). + ## CheckerResult A result is one ordered, selected structural member of its exact owning run. diff --git a/docs/engineering/repository_architecture_boundaries.md b/docs/engineering/repository_architecture_boundaries.md index 7e6f10be6..c0418a3a2 100644 --- a/docs/engineering/repository_architecture_boundaries.md +++ b/docs/engineering/repository_architecture_boundaries.md @@ -1,8 +1,8 @@ # Modular Monolith Architecture Boundaries -Workstream has nine business modules: `actors`, `authorization`, `projects`, -`tasks`, `artifacts`, `checkers`, `reviews`, `contributions`, and -`compensation`. It has three supporting modules: `audit`, `outbox`, and +Workstream has ten business modules: `actors`, `authorization`, `projects`, +`tasks`, `artifacts`, `checkers`, `model_usage`, `reviews`, `contributions`, +and `compensation`. It has three supporting modules: `audit`, `outbox`, and `api_controls`. The machine-readable canonical registry is `.ci/module-boundaries/registry.v1.json`. diff --git a/docs/operations_checker_models.md b/docs/operations_checker_models.md new file mode 100644 index 000000000..ebbdf9b66 --- /dev/null +++ b/docs/operations_checker_models.md @@ -0,0 +1,186 @@ +# Shared setup and POST model access + +Workstream pays for supported model calls through one project-lifetime account. +The same account serves guide setup inference and approved POST checker agents, +but each call keeps a strong workload-specific owner: + +- setup calls bind the exact compilation attempt, setup generation, runtime + configuration hash and phase/call index; +- POST calls bind the exact Submission, requirement, locked registry image and + configuration, current checker execution and stable paid slot. + +The model proxy owns credentials, request bounds, reservations, provider effects, +private evidence and settlement. It does not own setup tools, checker rubrics, +submitted-code execution, policy approval, acceptance or revision routing. + +## Provision a project + +A system Operator publishes immutable project model profiles and configures one +finite lifetime budget. These are separate authorized operations; approving a +guide or POST policy never grants budget authority. + +| Operation | Route | +| --- | --- | +| Publish an immutable profile | `POST /api/v1/operations/projects/{project_id}/model-profiles` | +| Configure the project lifetime ceiling | `POST /api/v1/operations/projects/{project_id}/model-budget-configurations` | +| Inspect one exact paid effect | `GET /api/v1/operations/projects/{project_id}/model-usage/effects/{effect_id}` | +| Authorize an eligible checker recovery child | `POST /api/v1/operations/projects/{project_id}/model-call-attempts/{parent_attempt_id}/recovery-children` | + +Mutations require their documented idempotency key and fresh system-Operator +authority. The exact-effect read uses `project.model_usage.read` under the +existing model-budget management permission. It conceals missing and foreign +effects and returns only bounded identities, usage, cost and ART custody facts; +it never returns provider IDs, credentials or transcript bytes. + +Amounts are integer USD nanodollars. US$10 is `10000000000`. A new explicitly +authorized budget version may raise the ceiling, but it never resets settled or +reserved spend. `externally_reconciled_prior_spend_nanodollars` records only +spend reconciled outside this ledger and cannot decrease. Historical setup spend +must not be guessed as zero. Until the Operator has reconciled the remaining +allowance, paid calls fail closed. + +Profiles are project-owned, not checker-image-owned. Each immutable version fixes +the provider/API/model snapshot, reasoning and verbosity, per-call and aggregate +call/token/byte/deadline limits, allowed tools and their aggregate limits, integer +pricing ceilings and pricing provenance. Multiple provider/model identities may +coexist for one project. + +Setup selects the newest published version matching the already configured exact +provider/API/model when a compilation attempt is created, then snapshots the +profile ID/version/digest into that attempt. A later publication cannot redirect +an existing attempt. POST policy configuration carries the same selector triple: + +```json +{ + "model_profile_id": "", + "model_profile_version": 1, + "model_profile_digest": "sha256:" +} +``` + +The POST owner separately validates the exact approved registry image and locked +configuration. A partial selector, another project's profile, a same-project +profile with a different provider/model, a PRE binding or a stale policy is +unavailable before spend. + +## Supported provider boundaries + +POST model sessions currently use the closed `openai_responses` / `responses.v1` +adapter. Setup preserves both native agent runtimes through a credential-free +local transport: + +- OpenAI Agents SDK uses the approved `openai` / `responses` request shape; +- OmniCoreAgent uses the approved `openrouter` / `chat_completions` shape. + +The setup proxy accepts only the exact selected model, two neutral document tools, +locked reasoning/verbosity, finite completion output and strict structured output. +Remote prompt references, provider routing/fallback controls, hidden retries, +unapproved service tiers and provider-side storage are rejected. OpenAI input is +counted with the provider's input-count endpoint before dispatch. OpenRouter uses +the exact non-alias model metadata endpoint before dispatch and requires current +context, completion, parameter and pricing metadata to fit the immutable profile; +unknown or unsupported non-token fees fail before spend. Metadata-to-dispatch +consistency remains an external provider assumption that must be requalified for +the selected account and model. + +Setup's fixed `read_document` and `search_document` capability retains its own +owner-side actual call/output counters. A model session opens only when those +runtime ceilings fit inside the selected profile's tool-call and tool-output +ceilings. The profile's aggregate paid-call/token/byte envelope spans the complete +setup attempt across draft, review and correction phases. + +## Runtime and credentials + +Run exactly one trusted model-proxy process for a private socket. Project images +receive only an invocation-local relay socket. A hosted setup worker receives the +global socket directory read-only; it does not own the socket and receives no +provider key. API and Beat receive neither socket nor provider credential. + +Hosted inference uses a dedicated digest-pinned `agents` backend image for the +proxy even when the setup worker uses the mutually exclusive `omnicore` image. +The proxy image contains the pinned OpenAI input-count/Responses dependencies and +the OpenRouter HTTP adapter. Its entrypoint validates the root-owned SDK marker +before reading the two closed proxy-only provider secrets. Proxy enablement is +independent of setup runtime selection, so `manager_authored` setup can coexist +with model-backed POST checks and OpenRouter setup can coexist with OpenAI POST. +See +[`deploy/hosted/README.md`](../deploy/hosted/README.md). + +The local agent-loop overlay uses +`WORKSTREAM_CHECKER_MODEL_PROXY_SOCKET=/run/ws-model-proxy/model-proxy.sock` and +the existing `model-proxy` profile. Configure the Rust checker service with the +same global socket and its private relay root. The image never sees that global +socket, a provider key, model coordinates or arbitrary network egress. See the +[external checker protocol](../external_checkers/README.md). + +Socket roots are canonical mode-`0700` directories and sockets are mode `0600` +under one non-root identity. Stop producers first, drain active executions, then drain the +proxy before removing any stale socket. Restarting a process does not clear a +reservation or authorize another provider create. + +## Paid-call lifecycle + +1. The workload owner proves current authority and exact locked lineage before + taking MODEL-USAGE locks. +2. MODEL-USAGE selects the immutable profile/account/run envelope and stable + phase/call slot, verifies the canonical request hash and retains request ART. +3. It reserves that dispatch's worst-case charge and conservative token/response + capacity against both the project lifetime account and immutable run envelope, + then commits dispatch intent before provider I/O. +4. The trusted adapter performs exactly one bounded provider dispatch with hidden + retries disabled. Provider acceptance, when known, is checkpointed with the + provider ID and response digest before response ART work. +5. Canonical response and usage bytes are stored, independently verified and + immutably bound through ART. Settlement replaces the reservation once using + the locked pricing version. Cached input is priced separately and reasoning + tokens are not charged twice. +6. A retry with the same stable owner and request reconstructs the exact retained + response. Request drift conflicts; lease renewal or checker retry does not mint + a new paid effect. + +Each setup phase may consume several finite call slots; POST profiles likewise +bound the complete image session. A conditional image path may leave an unused +capability untouched, but every started call must reach a replay-safe terminal or +remain held as uncertain. No profile, image, correction or retry creates a second +project budget. + +## Failure and recovery + +| Condition | Durable result | Safe next action | +| --- | --- | --- | +| Missing or exhausted funding | No provider dispatch | Operator configures reconciled funding; retry the same owner workflow | +| Input/metadata/profile refusal | Proven no provider effect | Correct authorized configuration; do not charge a revision | +| Delivery ambiguous without usable provider evidence | `uncertain`, full reservation retained | Inspect the exact effect; never create again without authenticated no-effect proof | +| Provider ID/response known, ART or callback interrupted | Exact effect and response identity retained | Recover and verify original ART/provider evidence, then complete without another create | +| Complete provider failure with authenticated usage | `known_failed`, usage settled | Checker-only authorized child may be prepared when eligibility is proven | +| Completion delivered but runtime/image reply lost | Completed effect, ART response and settled cost | Fresh authorized owner consumes the cached completion | + +Setup recovery never fabricates checker lineage. The setup handler reads retained +verified response ART and completes the original setup effect. Provider storage is +disabled: if delivery succeeds but the process is lost before response ART is +retained, a provider ID alone does not make that response retrievable. That effect, +like an ID-less genuinely ambiguous response, remains uncertain with its reservation +held until authenticated evidence can establish a safe terminal outcome. The exact +Operator read provides visibility but cannot assert no effect, release money or +create a replacement. + +For POST, budget exhaustion, provider outage, unavailable runtime and invalid +provider output remain infrastructure failure on the original Submission. They do +not prepare a revision, start a revision deadline or spend a revision round. +Eligible checker children still require the existing fresh Operator recovery +authorization and a current checker execution. + +## Verification boundaries + +Repository tests use controlled HTTP/provider transports; they perform no live +paid calls. PostgreSQL tests cover setup/checker concurrency on one account, +canonical lock waits, rollback, profile/budget versioning, replay and settlement. +MinIO tests cover setup request/response custody and crash recovery. Unix-socket, +Rust relay and networkless-container tests cover the restricted transport. +Release composition tests prove that only the proxy receives provider secrets and +that setup workers mount only the socket read-only. + +These checks do not establish the external provider account state, current live +pricing, model quality, deployed gVisor/AWS operation or the Terminal Systems +activation-to-contribution journey. The existing US$10 authorization is one +aggregate rehearsal ceiling, not an allowance per SDK, image, retry or call. diff --git a/docs/operations_roles_permissions.md b/docs/operations_roles_permissions.md index d483d58db..60b59eaeb 100644 --- a/docs/operations_roles_permissions.md +++ b/docs/operations_roles_permissions.md @@ -176,6 +176,12 @@ Recovery is a separate, reasoned, audited path. | Submission gate repair (planned; unavailable) | `operations.submission_gate.repair` | Operator | | Checker recovery-source discovery (active public read) | `operations.status.read` via `operations.checker_retry_source.list` | System Operator for one exact project | | Checker terminal-failure retry (active public operation) | `operations.checker.retry` | System Operator under exact terminal-failure guards | +| Immutable project model profile provisioning | `project.model_profile.manage` via `project.model_profile.provision` | System Operator for the exact project; approved POST policy separately binds the registered image | +| Lifetime project model budget configuration | `project.model_budget.manage` via `project.model_budget.configure` | System Operator; preserves all outstanding reservations and settled spend | +| Exact project model-effect inspection | `project.model_budget.manage` via `project.model_usage.read` | System Operator for one exact project/effect; redacted accounting and evidence identities only | +| Setup model preparation and dispatch (active internal operations) | `project.guide_compilation.model.prepare`, `project.guide_compilation.model.dispatch` | Exact fixed service `workstream.project.setup`; current compilation attempt and snapshotted runtime/profile | +| Setup model observation and completion (active internal operations) | `project.guide_compilation.model.observe`, `project.guide_compilation.model.complete` | Exact fixed service `workstream.project.setup`; retained paid effect and bounded evidence, with no new dispatch authority | +| Model-call recovery child authorization | `project.model_call.recovery_child.authorize` | System Operator for an exact proven no-effect or fully settled failed parent; uncertain delivery is ineligible | | Review lease force release | `review.lease.force_release` | Operator under WS-REV-001 | | Checker revision-context repair (active public operation) | `project.task.manage` via `project.task.revision.repair` | Covered Project Manager | | Checker revision expiry (active internal operation) | `task.revision.expire` | Exact fixed service `workstream.task.revision_expirer` | diff --git a/docs/roadmap_status.md b/docs/roadmap_status.md index 5a95474ab..141fefeb7 100644 --- a/docs/roadmap_status.md +++ b/docs/roadmap_status.md @@ -88,6 +88,20 @@ lifecycle is proven. See the ## Status Vocabulary +The shared model runtime adds immutable Operator-provisioned project profiles, +one finite lifetime project budget, private ART call evidence and recovery of +the same paid effect across setup-worker renewal and checker retries. Existing +OpenAI Agents SDK and OmniCoreAgent setup loops use the same account as approved +POST image sessions while retaining distinct setup-attempt and checker-execution +lineage. Images keep their own rubrics and receive only an invocation-local +socket through the generic Rust service; hosted setup workers connect read-only +to the trusted proxy and receive no provider key. This provides the model +capability needed by the real Terminal Systems POST proposal; it does not +establish that project's activation, model quality, current provider account, +gVisor qualification or live contribution journey. See +[model runtime operations](operations_checker_models.md) and its +[bounded change](../.commitrail/changes/checker-model-runtime.md). + The public automated-acceptance journey passed its bounded connected local drill from public guide inputs with recorded inference, local Flow-compatible auth and real PostgreSQL, MinIO, Redis, prefork Celery and Beat. Both policy outcomes, @@ -524,11 +538,12 @@ cannot be reused as post-submission review-gate evidence. See the | Lifecycle stage | Intended merged status | What is already proven | What remains before v0.1 | | --- | --- | --- | --- | +| Shared setup and project-image model use | **Budgeted runtime and public Operator controls** | One project-lifetime funded account serves exact setup-attempt and approved POST image lineage; immutable bounded profiles; finite multi-call/run envelopes and setup document-tool caps; private ART request/response evidence; stable paid-effect replay/recovery; once-only settlement; exact redacted Operator effect inspection; hosted composition gives provider credentials only to the trusted proxy | Complete full CI and independent reviews; qualify the external account/current pricing and each project's substantive rubric under intended gVisor/AWS isolation; then perform only the approved aggregate-capped live rehearsal. Repository proof is controlled/offline and not deployment or model-quality evidence | | Identity and actor resolution | **Live foundation** | Flow-token verification; canonical ActorProfile and ActorIdentityLink; human/service separation; lifecycle controls; canonical `/actors/me` self-read with duplicate `/auth/me` removed; suspension denies both self-read and self-update; CLI privileged exact-actor/profile and identity-link reads and explicit separate profile/link lifecycle writes remain distinct from self-service and backend guards | Final end-to-end operational and conformance proof | | Authorization kernel | **Live foundation** | Closed action/permission catalogues; deny-by-default evaluation; grants; exact-project, privacy-safe candidate read now consumed by the CLI; fixed services; rate controls; opaque transaction-bound PREP; atomic decision evidence; CLI scope-filtered administrative grant/catalogue reads and explicit keyed grant issue/revoke consume existing public APIs without local authority decisions | Activate only the remaining owner-proven TASK, checker, REV, and CON boundaries; remove obsolete authority after replacement paths are live | | System project catalogue | **Bounded public API and CLI read** | WS-CLI-001-46 adds `GET /api/v1/projects` and `workstream project list` for current system Project Manager, Operator, Finance or Audit `project.read` grants; each signed page exposes only ID, name and status. `me projects` retains exact-grant membership semantics. | TUI runtime, deployed CLI and hosted conformance remain separate | | Project Guide source custody | **Live foundation** | Guide creation declares documents and task examples; public document upload; immutable internal metadata snapshots; draft guide and review/revision policy writes with fresh-authorized exact replay of original receipts; exact run-scoped setup reads and assigned-task locked-original reads; S3-backed originals and isolated agent document inspection; exact document generation through separate manager approvals and public exact-authorized CP07 activation; Project Manager guide list, guide detail, activation readiness dry run and verified original read-back (PILOT-READ-3); the CLI exposes existing administrative active-chain and retained sufficiency/artifact-policy reads, keyed human sufficiency writes, and separate manual artifact-policy draft create/update with backend-owned policy decisions | Public original upload, task examples, Celery setup and separate approvals are connected locally with recorded inference; prove configured live inference and readers in the intended environment; manual drafts are not compiled activation proposals; deployed CLI remains separate | -| Unified Project Guide compilation | **Live setup and manager proposal operations** | Committed original-document readiness dispatches one immutable attempt through Celery; complete result and crash/recovery custody; distinct pre/post proposals; typed per-requirement classification and checker specifications; deterministic sufficiency and submission-artifact-policy projections; immutable authorized setup finalization; public exact manager review, pre-submit approval and manual correction dispatch; automatic deterministic post-policy derivation, public complete policy read, separate approval and shared correction custody. Neutral read/search tools support statically registered OpenAI Agents SDK and OmniCoreAgent adapters in mutually exclusive inference images, while provider-free `manager_authored` compiles a manager-uploaded rules document with no inference or provider resources ([runtime initiative](../.commitrail/initiatives/WS-AGENT-001/OVERVIEW.md), [manager-authored record](../.commitrail/changes/manager-authored-guide-runtime.md)). Capability gaps preserve a warning-ready review package but block activation; retained pre-specification results keep exact hashes and reads. Exact Omni SDK, mutually exclusive release-image, parser-isolation and real-MinIO proofs pass. Both inference adapters pass the same original-guide public API, durable Celery, real-parser and MinIO path; the Omni run stopped at a warning-ready manager draft with no bindings, approval or activation. The Terminal Systems handoff now has a project-owned image for its two PRE rules, with local rule and networkless-container proof. | Retain the exact external provider/account and selected-image AWS evidence; implement the eight Terminal Systems POST rules and their runtime dependencies; register the required images, obtain approved bindings and qualify the broader hosted Flow contribution journey. Original-guide qualification and local checker proof are not deployed operation or full guide-to-contribution proof. | +| Unified Project Guide compilation | **Live setup and manager proposal operations** | Committed original-document readiness dispatches one immutable attempt through Celery; complete result and crash/recovery custody; distinct pre/post proposals; typed per-requirement classification and checker specifications; deterministic sufficiency and submission-artifact-policy projections; immutable authorized setup finalization; public exact manager review, pre-submit approval and manual correction dispatch; automatic deterministic post-policy derivation, public complete policy read, separate approval and shared correction custody. Neutral read/search tools support statically registered OpenAI Agents SDK and OmniCoreAgent adapters in mutually exclusive inference images, while provider-free `manager_authored` compiles a manager-uploaded rules document with no inference or provider resources ([runtime initiative](../.commitrail/initiatives/WS-AGENT-001/OVERVIEW.md), [manager-authored record](../.commitrail/changes/manager-authored-guide-runtime.md)). Inference attempts snapshot an exact immutable model profile and use the shared project-funded proxy; fixed document-tool limits must fit that profile and provider credentials remain outside the setup process. Capability gaps preserve a warning-ready review package but block activation; retained pre-specification results keep exact hashes and reads. Exact Omni SDK, mutually exclusive release-image, parser-isolation and real-MinIO proofs pass. Both inference adapters pass the same original-guide public API, durable Celery, real-parser and MinIO path; the Omni run stopped at a warning-ready manager draft with no bindings, approval or activation. The Terminal Systems handoff now has a project-owned image for its two PRE rules, with local rule and networkless-container proof. | Retain the exact external provider/account and selected-image AWS evidence; reconcile remaining aggregate rehearsal allowance before paid setup; implement the eight Terminal Systems POST rules and their runtime dependencies; register the required images, obtain approved bindings and qualify the broader hosted Flow contribution journey. Original-guide qualification and local checker proof are not deployed operation or full guide-to-contribution proof. | | Contribution policy administration | **Public Finance policy workflow; binding administration internal** | Internal Finance Authority adapter-binding lifecycle; public ContributionPolicy discovery/read/create/update/publish/retire with exact Finance Authority and recoverable draft selectors, and a Finance list of every version (PILOT-READ-3); immutable operation and event history; internal exact selected-version validation; CP07 binding with live exact-project manager authority of the selected published version to the active guide generation | The bounded local public drill passed; policy publication alone does not create a contribution, and hosted operation remains unproven | | Task readiness and claim | **Foundation with grant-backed manager and contributor commands** | Task records, assignments and locked work context; guide-bound ContributionPolicyVersion locked before `READY` and copied to TaskAssignment; detached project/guide display; public project-scoped ready, management and operational queues with distinct current grant authority and signed bounded live pagination, with typed task status and manager filters by status, assignee, native import batch and update time (PILOT-READ-2); contributor cross-project own-assignment, attempt and summary reads under current Submitter grants (PILOT-READ-2); Project Manager project-wide attempt, claim, summary and import-batch reads (PILOT-READ-3); public contributor/management detail with exact project and assignment authority; separate current live work-context projections with exact receipt-selected review/revision/ContributionPolicy identities and no obsolete economic fields; explicit management/operational/audit locked-context projections using one historical resolver, with separate public manager, system-Operator and Audit Authority access; immutable contributor/management requirements using one historical translator, with public exact Contributor and Manager authority; the CLI exposes the three exact manager requirements/context reads separately from contributor commands (WS-CLI-001-27); bounded public Audit Authority lifecycle evidence with atomic project/task scoping and exact transition references; separate CLI audit commands consume those two existing Audit Authority GETs with explicit continuation; public manager create/screen/release use exact covered Project Manager authority, atomic audit and replay; claim/start/contributor context use exact-project Submitter grants; assigned context lists documents only and distinct `task.guide.read` verifies locked originals before delivery; separate manager context and system-Operator start; durable claim/start retry receipts with fresh authority and exact assignment checks; hidden exact-assignment invalidation with committed cause verification, delivery fencing, exact fixed-service authority and decision-bound immutable release evidence; atomic authority-loss publication and registered prefork delivery; PILOT-14 exposes both locked contribution types in ready rows and Contributor detail for active exact-project Submitters or Reviewers as unpaid or exact instrument/unit/quantity awards, without Finance binding facts; claim/start remain Submitter-only | The bounded local public durable-runtime journey passed; intended hosted operation remains unproven | | Operator task CLI | **Existing public task operations consumed by the CLI** | WS-CLI-001-36 adds separate status-only project pages, exact-project locked-reference reads and explicit reasoned start override with caller-owned manual replay. Backend Operator authority, assignment consistency, locked lineage and audit remain decisive; contributor start has no fallback to override. | Native public-API proof is bounded to existing fixtures; hosted/deployed operation, other Operator workflows and published binary remain separate | @@ -1311,7 +1326,8 @@ Delivered foundations (bounded local journey proven; no deployment claim) task/assignment/Submission lineage + public ZIP intake/admission-backed creation ARCH-03D exact approved historical guide through durable intake handoff ARCH-04B hidden verified Submission input - ARCH-04B2 hidden checker output store/recovery/binding (authority deny-only) + shared setup/POST model-call ART request/response custody with exact owner authority + immutable project model profiles + one lifetime account + stable multi-call paid-effect recovery ARCH-04C hidden durable execution/current results + atomic completion event shared dispatcher + exact-authorized hidden assignment invalidation ARCH-03C2 invalidation producer + first handler registration @@ -1457,6 +1473,13 @@ v0.1 is not ready until all of the following are true: Flow/AWS identities and the selected isolation engine with measured limits in the deployed environment; configuration rendering and local source-image builds are only prerequisites. +- Qualify model-enabled POST images with their approved profiles, current exact + pricing and the same finite lifetime project budget used by setup inference. + Reconcile any historical setup spend before enabling that budget, prove setup + and invocation sockets under the intended isolation, and recover interrupted + paid effects without another create. The Terminal Systems live rehearsal has + one US$10 aggregate cap across SDKs, images and retries; offline transport, + ART and ledger tests do not establish its full project journey. - Connect Smol preparation to the authorized upload request, replace production host ZIP/default processing with trusted guest intake, and prove public PRE, immutable admission, durable POST, recovery and both governed policy outcomes. @@ -1608,8 +1631,10 @@ and unapproved. This qualification required explicit PM corrections and establis one reviewed handoff, not automatic completeness for arbitrary guides. Ten required checks remain unbound suggestions in that immutable proposal. The project-owned PRE image now implements both PRE specifications, is locally qualified, published -by digest and registered in the local rehearsal, but remains unbound. The eight POST checks and their model, -execution and authenticated-material dependencies remain to be implemented. +by digest and registered in the local rehearsal, but remains unbound. The eight +POST images and their execution and authenticated-material dependencies remain +to be implemented. Model-enabled checks also require exact approved project +profiles before binding. Registration, corrected policy binding and approval must precede activation; no approved binding, activated guide or contribution outcome is established. diff --git a/docs/spec_authorization_service.md b/docs/spec_authorization_service.md index 97a7e5ab3..888f33466 100644 --- a/docs/spec_authorization_service.md +++ b/docs/spec_authorization_service.md @@ -311,7 +311,9 @@ registration, hidden ART behavior/resource composition, then dedicated AUTH evaluator integration and activation. ART never writes availability. AUTH-12, AUTH-14, and AUTH-15 are not alternate artifact activation paths. -These are 75 approved `PermissionId` values. `ActionId` values are a separate +The initial list above contains 75 approved `PermissionId` values. The current +closed catalogue contains 95 permissions, including the thirteen shared setup/POST +model-runtime permissions described below. `ActionId` values are a separate closed registry layer and are not included in that permission count. AUTH-05A's typed and PostgreSQL audit registry accepts the exact historical 49. The three approved Operator recovery identifiers, 16 artifact identifiers, @@ -687,13 +689,13 @@ remain planned and unavailable, and add no migration. | `artifact.guide_source.read` | `artifact.guide_source.read` | fixed guide-reader service | exact committed document version and fenced compilation attempt | `03` | | `artifact.submission_bundle.prepare` | `submission.create` | assigned contributor | exact task/admission context | `04C2` | | `artifact.submission.binding.create` | `artifact.binding.create` | fixed binding service | submission | `05` | -| `artifact.checker_output.binding.create` | `artifact.binding.create` | fixed binding service | checker run | `06B` | +| `artifact.checker_output.binding.create` | `artifact.binding.create` | fixed binding service | exact setup or POST paid model-call attempt, private request/response slot and verified content | `06B`, model runtime | | `artifact.verification.execute` | `artifact.verification.execute` | fixed verifier service | verification job | `02D` | | `artifact.pending_work.scan` | `artifact.pending_work.scan` | fixed scheduler service | system pending-work scope | `02D` | | `artifact.put_attempt.resolve` | `artifact.put_attempt.resolve` | fixed put-resolver service | put attempt | `02D` | | `artifact.pre_submit.checker_input.materialize` | `artifact.checker_input.materialize` | fixed materializer service | exact task, assignment, project/guide/snapshot and locked policy lineage plus plan/catalogue/archive/semantic-manifest identities and current process-local prepared generation; no scratch path/handle is serialized | `04B2/04B3` + `XINT-002-06A` | | `artifact.post_submit.checker_input.materialize` | `artifact.checker_input.materialize` | fixed materializer service | checker run and immutable bindings | `06A` | -| `artifact.checker_output.write` | `artifact.checker_output.write` | fixed checker-output service | checker run | `06B` | +| `artifact.checker_output.write` | `artifact.checker_output.write` | fixed checker-output service | exact setup or POST paid model-call attempt and private request/response slot; request requires current owner authority, late response requires retained effect custody | `06B`, model runtime | | `artifact.review_packet.materialize` | `artifact.review_packet.materialize` | fixed materializer service | exact active lease and Submission packet | `07A` | | `artifact.review_evidence.binding.create` | `artifact.binding.create` | fixed binding service | future REV-owned evidence slot; no approved v0.1 activation | future | @@ -714,7 +716,7 @@ closed: | `workstream.artifact.guide_reader` | `artifact.guide_source.read` | | `workstream.artifact.materializer` | `artifact.pre_submit.checker_input.materialize`, `artifact.post_submit.checker_input.materialize`, `artifact.review_packet.materialize` | | `workstream.artifact.checker_output` | `artifact.checker_output.write` | -| `workstream.project.setup` | `project.guide_compilation.request_automatic`, `project.guide_compilation.execute`, `project.guide_sufficiency.run`, `project.submission_artifact_policy.derive`, `project.post_submit_checker_policy.derive`, `project.setup_run.update` | +| `workstream.project.setup` | `project.guide_compilation.request_automatic`, `project.guide_compilation.execute`, `project.guide_compilation.model.prepare`, `project.guide_compilation.model.dispatch`, `project.guide_compilation.model.observe`, `project.guide_compilation.model.complete`, `project.guide_sufficiency.run`, `project.submission_artifact_policy.derive`, `project.post_submit_checker_policy.derive`, `project.setup_run.update` | | `workstream.review.preference_expiry` | `review.preference_expiry.run` | | `workstream.review.lease_expiry` | `review.lease_expiry.run` | | `workstream.review.authority_invalidation_reconciliation` | `review.reconcile.run` | @@ -726,7 +728,7 @@ closed: | `workstream.task.revision_expirer` | active: `task.revision.expire`, `task.revision.recover` | | `workstream.task.post_submit_router` | active: `task.post_submit.route` | | `workstream.checker.pre_submit` | active: `checker.pre_submit.execute`, `checker.pre_submit.finalize` | -| `workstream.checker.post_submit` | active: `checker.post_submit.execute`, `checker.post_submit.finalize` | +| `workstream.checker.post_submit` | active: `checker.post_submit.execute`, `checker.post_submit.finalize`, `checker.model_call.grant`, `checker.model_call.prepare`, `checker.model_call.dispatch`, `checker.model_call.recover`, `checker.model_call.observe`, `checker.model_call.complete` | | `workstream.compensation.adapter` | target-only; no action membership | The hidden 04B2 prepared resource first locks fixed-service authority using @@ -808,6 +810,16 @@ exact fixed-service authority and manifest. | `task.revision.recover` | `workstream.task.revision_expirer` | Exact retained routing manifest, assigned contributor, locked task context, request digest and optional preparation ID/digest pair; fresh fixed-service authority. Recovery retains the source and exact running/paused clock; exact already-scheduled work is a no-op | PILOT-08 implements bounded retained-source recovery of missing preparation or expiry intent; it may freeze unchanged or blocked context, never authorize an automatic rebase or reset the window | | `checker.post_submit.execute` | `workstream.checker.post_submit` | Immutable Submission/request/generation, locked compiled policy, attempt and admitted service; exact pre-I/O authority | Implemented by ARCH-04C/04D2 and invoked by the registered selected request handler | | `checker.post_submit.finalize` | `workstream.checker.post_submit` | Exact execution request/fence, accepted result digest, retained material and original execute receipt; fresh post-I/O authority and atomic evidence; current outputs are empty | Implemented by ARCH-04C/04D2 and invoked by the registered selected request handler | +| `project.guide_compilation.model.prepare` | `workstream.project.setup` | Exact compilation attempt, project/guide/snapshot, setup generation, runtime/profile identity, phase/call index and canonical request hash; fresh setup currentness before MODEL custody | Shared model runtime | +| `project.guide_compilation.model.dispatch` | `workstream.project.setup` | Prepared setup effect and verified request evidence; fresh attempt authority and atomic reservation in the shared lifetime project account before provider I/O | Shared model runtime | +| `project.guide_compilation.model.observe` | `workstream.project.setup` | Exact retained setup effect and provider observation digest; records ambiguous delivery without authorizing a new create | Shared model runtime | +| `project.guide_compilation.model.complete` | `workstream.project.setup` | Exact retained setup effect, terminal digest, verified response evidence and complete usage; settlement or proven no-effect release cannot authorize another dispatch | Shared model runtime | +| `checker.model_call.grant` | `workstream.checker.post_submit` | Exact current POST execution, locked project/image/profile, stable paid slot, invocation request digest and private token hash; PostgreSQL lease expiry and single consumption | Project model runtime | +| `checker.model_call.prepare` | `workstream.checker.post_submit` | Current POST execution and stable paid slot with immutable first request hash; AUTH then TASK then CHECKERS custody | Project model runtime | +| `checker.model_call.dispatch` | `workstream.checker.post_submit` | Current execution, prepared attempt and verified request evidence; atomic lifetime budget reservation and dispatch intent before provider I/O | Project model runtime | +| `checker.model_call.recover` | `workstream.checker.post_submit` | Current exact execution attaching to an existing paid effect; permits retrieval or cached attachment, never a new create | Project model runtime | +| `checker.model_call.observe` | `workstream.checker.post_submit` | Exact retained attempt, internal request identity and provider observation digest; may retain a late provider ID independently of the original lease | Project model runtime | +| `checker.model_call.complete` | `workstream.checker.post_submit` | Exact retained attempt and terminal digest, verified private response evidence and complete usage for settlement; a late completion cannot authorize stale image access | Project model runtime | | `task.post_submit.route` | `workstream.task.post_submit_router` | Committed completion event/claim, exact current CHECKER result/fence, immutable Submission, locked ReviewPolicy and TASK pre-review state. `allow_review` with true permits only TASK `review_pending`; `allow_review` with false binds exact accepted TASK effects for shared FinalAcceptance. `needs_revision` under either mode permits TASK remediation when revisions remain, or exact final-attempt exhaustion and release to ready; neither creates REV/CON effects. Never a human review decision or generic CON write | 04E2-B delivers canonical fixed-router PREP consumption and actual receipt custody for complete outcomes; ARCH-04F2 extends that same operation and receipt to exact checker remediation. SQL rejects orphan or incomplete outcomes and failure-to-acceptance substitution. Selected request and completion handlers are registered in production composition. False/pass effects require a live REV generation; the typed pre-effect non-live refusal rolls back and uses bounded OUTBOX retry. 04F3 adds exact-cause replacement under existing AUTH actions; 04F4 adds separate Operator retry authority through CHECKERS. | Each action maps to the identically named permission in this table and only @@ -816,6 +828,28 @@ cannot borrow another row. Existing ART materializer/output identities retain their separate permissions and allow evidence. No prepared handle crosses a commit, I/O, lease wait or message boundary. +### Project model administration + +Only a current system-scoped human Operator grant admits these public commands. +Project Manager approval of the image configuration is separate and cannot +provision a model profile, increase a budget or authorize another paid attempt. +Each command binds fresh authority to the exact project, actor, request and +immutable specification or parent receipt; idempotent replay rechecks authority. + +| ActionId | PermissionId | Exact target | +|---|---|---| +| `project.model_profile.provision` | `project.model_profile.manage` | Project and immutable provider/model/bounds/pricing profile; POST image binding is separately validated through the approved policy | +| `project.model_budget.configure` | `project.model_budget.manage` | Project and immutable budget configuration version; lifetime reservations and settled spend are never reset | +| `project.model_usage.read` | `project.model_budget.manage` | One exact paid effect in the project; redacted owner, budget, usage and ART evidence identities only | +| `project.model_call.recovery_child.authorize` | `project.model_call.recovery_child.authorize` | Exact current execution and terminal parent receipt; only proven no-effect or fully settled known failure permits a child | + +An uncertain delivery is ineligible for a child. Model/provider budget failures +are infrastructure outcomes, not contributor-fixable failures. Private evidence +is retained through ART's existing output writer and binding authorities. The +exact-effect read returns no transcript bytes, provider coordinate or credential +and adds no transcript permission. See [model runtime operations](operations_checker_models.md) +for public routes, effect recovery and verification boundaries. + Human task actions and existing permission mappings are enumerated in the [ARCH-03C manifest](../.commitrail/initiatives/WS-ARCH-001/planning/chunks/WS-ARCH-001-03C-auth-task-readiness.md#exact-surfaceaction-manifest). In particular, `task.start` uses existing `task.claim` entitlement plus exact diff --git a/external_checkers/README.md b/external_checkers/README.md index 5c6c10fb0..cf62a8af3 100644 --- a/external_checkers/README.md +++ b/external_checkers/README.md @@ -52,6 +52,95 @@ supported isolation and resource contract. Unsupported runtime needs must be resolved explicitly before approving that capability; they are not permission to add a named-project exception or expose the host Docker socket. +## Optional model socket + +An entitled post-submit invocation may receive one invocation-local Unix socket +at `/work/model.sock`. The image opens one connection and sends sequential +closed `workstream.model_session.v2` call objects as UTF-8 JSON, each prefixed +by a four-byte unsigned big-endian body length. A call supplies its exact +zero-based `call_index`, a non-empty `prompt` and a bounded unique +`required_receipt_handles` array. Each frame is at most 1 MiB. The reply echoes +the call index and is one closed session result object: + +- A completed result has `outcome: "completed"`, a non-empty string `output`, a + lowercase `sha256:` `receipt_digest` and a null `failure_code`. +- An unavailable result has `outcome: "unavailable"`, null `output` and + `receipt_digest`, and a bounded lowercase-identifier `failure_code`. + +Both results also contain the exact `protocol_version` and `session_exhausted`; +extra or missing fields are invalid. The locked model profile bounds calls, +prompt/response bytes, tokens, trusted receipt count/bytes, deadline and the +whole session budget. Receipt handles resolve only inside the current invocation; +the relay injects their ART-verified canonical bytes before spend and hashes +those retained facts rather than the transient handles. This image-facing +protocol is project-agnostic. The image needs no +Workstream project code, provider SDK, provider credential, model coordinate or +access to the global proxy endpoint. The service wraps the prompt with the +invocation's private grant and request digest. Setup and POST sessions share one +project-lifetime metered account while retaining distinct typed setup-attempt and +checker-invocation lineage. The checker container remains on `--network none`. + +The dedicated singleton model-proxy worker is optional for provider-free +deployments. Add `model-proxy` to the existing backend and agent-loop Compose +profiles to enable model-backed setup or POST checks. That profile +sets +`WORKSTREAM_CHECKER_MODEL_PROXY_SOCKET=/run/ws-model-proxy/model-proxy.sock` and +binds the listener. The setup task worker connects through a read-only mount of +that socket but never owns it or receives provider credentials; the ordinary API +and Beat processes do not mount it. An empty value outside the model-proxy and +authorized setup-worker processes leaves the listener disabled. The Rust service configuration must set both +`model_proxy_socket` to that same mounted path and `model_relay_root` to a +daemon-visible private relay directory. The local configuration generator's +`--enable-model-proxy` switch emits that pair, using +`/var/lib/docker/volumes/-checker-relay/_data` for the relay +root. Enabling only one side does not activate model calls. + +The proxy and relay roots must be canonical mode-`0700` directories owned by the +shared service UID and GID. The global proxy socket and each invocation socket +are mode `0600`. The singleton model-proxy worker has read-write access to the +proxy volume, the Rust service connects through its read-only volume mount and +creates invocation sockets in the relay volume, and a checker receives only its +individual socket file. A different service/sandbox identity is rejected +because this path does not rely on privileged ownership changes. + +Docker development proves only the ordinary runtime path. A gVisor `runsc` +deployment must separately enable `host-uds=open` before a mounted host Unix +socket can work. This repository does not claim that the model relay has been +qualified under gVisor. + +The Smol engine does not currently project host Unix sockets into its guest. +Its service configuration therefore rejects the model-proxy/relay pair, and a +model-entitled request sent to a Smol-only node returns +`implementation_unavailable` before VM admission. Model-enabled POST remains a +`runsc`/`gvisor` deployment capability until a separately reviewed guest-native +transport preserves the same invocation-local custody; this merge does not +claim such support. + +The opt-in +[`model_relay_docker.py`](workstream-checker-service/tests/model_relay_docker.py) +probe drives a real Docker-development checker from its invocation-local socket, +through the Rust relay, to a fake private global proxy. It records the created +checker before cleanup and verifies `--network none`, a read-only root filesystem, +read-only material and socket mounts, the closed proxy request and exact reply, +and removal of the checker and invocation directory. It uses only already-local +`alpine:3.20` and `registry:2` images, creates a small disposable checker layer, +and makes no provider call: + +```console +CARGO_TARGET_DIR=/dev/shm/workstream-checker-target \ + cargo build --locked -p workstream-checker-service +TMPDIR=/absolute/daemon-visible/private-directory python3 \ + workstream-checker-service/tests/model_relay_docker.py \ + --service /dev/shm/workstream-checker-target/debug/workstream-checker-service +``` + +Run those commands from `external_checkers/`. Set `TMPDIR` to a private directory +that resolves to the same files in both the command process and the Docker daemon; +a service-private `/tmp` or `/dev/shm` may not be shared. The probe never pulls images, +scans Docker state for cleanup, or qualifies the gVisor path. Its fake reply is +transport evidence only; it does not prove provider execution, budget settlement, +or a completed product model-evaluation journey. + ## From guide to an active project Use the real guide and task examples as setup input. Inspect the resulting diff --git a/external_checkers/harnessy_proof/publish_local.py b/external_checkers/harnessy_proof/publish_local.py index 1f3647b8c..4521a6a00 100644 --- a/external_checkers/harnessy_proof/publish_local.py +++ b/external_checkers/harnessy_proof/publish_local.py @@ -36,6 +36,7 @@ # The socket stays on a named volume: Docker Desktop host-shared paths refuse # chmod on Unix sockets, and AF_UNIX paths must stay within 107 bytes. SOCKET_PATH = "/run/ws-checker/checker.sock" +MODEL_PROXY_SOCKET = "/run/ws-model-proxy/model-proxy.sock" def material_root(compose_project: str) -> str: @@ -43,6 +44,11 @@ def material_root(compose_project: str) -> str: return f"/var/lib/docker/volumes/{compose_project}-checker-material/_data" +def model_relay_root(compose_project: str) -> str: + """Daemon-visible private root for invocation-local model sockets.""" + return f"/var/lib/docker/volumes/{compose_project}-checker-relay/_data" + + def _hash(value: object) -> str: """Canonical JSON digest for the string-only schema documents used here.""" encoded = json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":")) @@ -79,9 +85,14 @@ def registry_spec(phase: str, image_digest: str, capability_version: str) -> dic def service_config( - image: dict[str, object], *, compose_project: str, sandbox_uid: int, sandbox_gid: int + image: dict[str, object], + *, + compose_project: str, + sandbox_uid: int, + sandbox_gid: int, + enable_model_proxy: bool = False, ) -> dict[str, object]: - return { + config = { "socket_path": SOCKET_PATH, "material_root": material_root(compose_project), "docker_binary": "/usr/local/bin/docker", @@ -93,6 +104,20 @@ def service_config( "sandbox_gid": sandbox_gid, "cache": [image], } + if enable_model_proxy: + relay_root = model_relay_root(compose_project) + maximum_socket = f"{relay_root}/.model-{'0' * 16}/model.sock" + if len(maximum_socket.encode()) >= 108: + raise ValueError( + "compose project makes the model relay socket path too long" + ) + config.update( + { + "model_proxy_socket": MODEL_PROXY_SOCKET, + "model_relay_root": relay_root, + } + ) + return config def _docker(*arguments: str) -> str: @@ -146,6 +171,11 @@ def main(argv: list[str] | None = None) -> int: parser.add_argument("--sandbox-uid", type=int, required=True) parser.add_argument("--sandbox-gid", type=int, required=True) parser.add_argument("--out-dir", type=Path, required=True) + parser.add_argument( + "--enable-model-proxy", + action="store_true", + help="emit fixed local proxy and daemon-visible relay paths", + ) # The registry holds one immutable entry per phase, capability and version, # so a rebuilt image (new digest) needs a new version. parser.add_argument("--capability-version", default="v1") @@ -158,6 +188,7 @@ def main(argv: list[str] | None = None) -> int: compose_project=args.compose_project, sandbox_uid=args.sandbox_uid, sandbox_gid=args.sandbox_gid, + enable_model_proxy=args.enable_model_proxy, ), "registry-pre.json": registry_spec( "pre_submit", image["platform_manifest_digest"], args.capability_version diff --git a/external_checkers/workstream-checker-service/src/main.rs b/external_checkers/workstream-checker-service/src/main.rs index 7c12d1f3c..d3f491673 100644 --- a/external_checkers/workstream-checker-service/src/main.rs +++ b/external_checkers/workstream-checker-service/src/main.rs @@ -1,7 +1,13 @@ //! Trusted long-lived Unix-socket boundary for digest-pinned checker containers. +mod model_relay; +mod proxy_transport; mod smol; +use model_relay::{ + deserialize_required_model_grant, valid_model_grant, validate_proxy_socket, + validate_relay_root, InvocationRelay, ModelGrant, RequiredModelGrant, +}; use serde::{Deserialize, Serialize}; use serde_json::Value; use sha2::{Digest, Sha256}; @@ -41,6 +47,8 @@ struct ServiceConfig { #[serde(skip)] smol_admission: Option>, socket_path: PathBuf, + model_proxy_socket: Option, + model_relay_root: Option, material_root: PathBuf, #[serde(default)] docker_binary: PathBuf, @@ -76,6 +84,8 @@ struct ExecuteEnvelope { operation: String, request: Value, grant: GrantReference, + #[serde(deserialize_with = "deserialize_required_model_grant")] + model_grant: RequiredModelGrant, } #[derive(Debug, Deserialize)] @@ -612,6 +622,17 @@ fn validate_config(config: &ServiceConfig) -> Result<(), String> { { return Err("socket parent is not private".into()); } + match (&config.model_proxy_socket, &config.model_relay_root) { + (Some(model_proxy_socket), Some(model_relay_root)) if config.runtime != "smolvm" => { + validate_proxy_socket(model_proxy_socket)?; + validate_relay_root(model_relay_root)?; + if (config.sandbox_uid, config.sandbox_gid) != (effective_uid, effective_gid) { + return Err("model relay identity differs".into()); + } + } + (None, None) => {} + _ => return Err("model relay configuration incomplete".into()), + } if config.runtime == "smolvm" { if !config.docker_binary.as_os_str().is_empty() { return Err("unused Docker configuration".into()); @@ -699,6 +720,21 @@ fn handle_frame( } let request_bytes = serde_json::to_vec(&envelope.request).map_err(|_| "request invalid")?; let request = parse_request(&request_bytes).map_err(|_| "request contract invalid")?; + if let Some(model_grant) = envelope.model_grant.0.as_ref() { + if !valid_model_grant(model_grant, &request.request_digest, &request.phase) { + return Err("model grant invalid".into()); + } + if config.runtime == "smolvm" { + return encode_response( + infrastructure_result(&request, "implementation_unavailable") + .map_err(|_| "result construction failed")?, + None, + ); + } + if config.model_proxy_socket.is_none() || config.model_relay_root.is_none() { + return Err("model grant invalid".into()); + } + } let _reservation = if config.runtime == "smolvm" { match smol::reserve(config, &request) { Ok(reservation) => Some(reservation), @@ -746,6 +782,7 @@ fn handle_frame( cache, &request, &grant.workspace, + envelope.model_grant.0.as_ref(), stream, &mut budget, ) { @@ -1410,6 +1447,85 @@ fn execute_container( cache: &CacheEntry, request: &ValidatedRequest, workspace: &Path, + model_grant: Option<&ModelGrant>, + client: &UnixStream, + budget: &mut RequestExecutionBudget, +) -> Result { + container_owner(config) + .map_err(|_| ExecutionFailure::ordinary("implementation_unavailable", false))?; + inspect_workspace(workspace, config.sandbox_uid, config.sandbox_gid) + .map_err(|_| ExecutionFailure::ordinary("material_unavailable", false))?; + let relay = match model_grant { + Some(grant) => { + let Some(proxy_path) = config.model_proxy_socket.as_deref() else { + return Err(ExecutionFailure::ordinary( + "implementation_unavailable", + false, + )); + }; + let Some(relay_root) = config.model_relay_root.as_deref() else { + return Err(ExecutionFailure::ordinary( + "implementation_unavailable", + false, + )); + }; + Some( + InvocationRelay::start( + relay_root, + proxy_path, + grant, + &request.request_digest, + config.sandbox_uid, + config.sandbox_gid, + budget.deadline, + ) + .map_err(|failure| { + if failure.cleanup_unsettled { + ExecutionFailure::unsettled(false) + } else { + ExecutionFailure::ordinary("implementation_unavailable", false) + } + })?, + ) + } + None => None, + }; + let model_mount = relay.as_ref().map(InvocationRelay::mount_spec); + let result = execute_container_inner( + config, + cache, + request, + workspace, + model_mount.as_deref(), + client, + budget, + ); + let Some(relay) = relay else { + return result; + }; + let relay_result = relay.finish(); + let launched = match &result { + Ok(_) => true, + Err(failure) => failure.launched, + }; + if relay_result.cleanup_unsettled { + return Err(ExecutionFailure::unsettled(launched)); + } + if relay_result.failed { + return Err(ExecutionFailure::ordinary( + "implementation_unavailable", + launched, + )); + } + result +} + +fn execute_container_inner( + config: &ServiceConfig, + cache: &CacheEntry, + request: &ValidatedRequest, + workspace: &Path, + model_mount: Option<&str>, client: &UnixStream, budget: &mut RequestExecutionBudget, ) -> Result { @@ -1437,43 +1553,23 @@ fn execute_container( "type=bind,src={},dst=/work/input,readonly", workspace.display() ); - let create_arguments = [ - "create", - "--name", + let create_arguments = container_create_arguments( &name, - "--label", - "workstream.external-checker=true", - "--label", &attempt_label, - "--label", &owner_label, - "--runtime", &config.runtime, - "--network", - "none", - "--read-only", - "--user", &sandbox_user, - "--cap-drop", - "ALL", - "--security-opt", - "no-new-privileges", - "--pids-limit", &pids, - "--memory", &memory, - "--cpus", &cpus, - "--tmpfs", - "/tmp:rw,noexec,nosuid,nodev,size=16777216", - "--mount", &mount, - "-i", + model_mount, &image, - ]; + ); + let create_argument_refs: Vec<_> = create_arguments.iter().map(String::as_str).collect(); let created = match run_bounded_command_cancellable( &config.docker_binary, - &create_arguments, + &create_argument_refs, budget.deadline, budget.output.remaining, client, @@ -1539,6 +1635,60 @@ fn execute_container( result.map_err(|code| ExecutionFailure::ordinary(code, true)) } +#[allow(clippy::too_many_arguments)] +fn container_create_arguments( + name: &str, + attempt_label: &str, + owner_label: &str, + runtime: &str, + sandbox_user: &str, + pids: &str, + memory: &str, + cpus: &str, + material_mount: &str, + model_mount: Option<&str>, + image: &str, +) -> Vec { + let mut arguments = vec![ + "create".to_string(), + "--name".into(), + name.into(), + "--label".into(), + "workstream.external-checker=true".into(), + "--label".into(), + attempt_label.into(), + "--label".into(), + owner_label.into(), + "--runtime".into(), + runtime.into(), + "--network".into(), + "none".into(), + "--read-only".into(), + "--user".into(), + sandbox_user.into(), + "--cap-drop".into(), + "ALL".into(), + "--security-opt".into(), + "no-new-privileges".into(), + "--pids-limit".into(), + pids.into(), + "--memory".into(), + memory.into(), + "--cpus".into(), + cpus.into(), + "--tmpfs".into(), + "/tmp:rw,noexec,nosuid,nodev,size=16777216".into(), + "--mount".into(), + material_mount.into(), + ]; + if let Some(model_mount) = model_mount { + arguments.push("--mount".into()); + arguments.push(model_mount.into()); + } + arguments.extend(["-i".into(), image.into()]); + arguments +} + fn remove_container_work( config: &ServiceConfig, name: &str, @@ -2224,6 +2374,8 @@ mod tests { load_container_owner(&File::open(&socket_root).unwrap()).unwrap(), ), socket_path: socket_root.join("checker.sock"), + model_proxy_socket: None, + model_relay_root: None, material_root, docker_binary: docker, runtime: "runc".into(), @@ -2320,6 +2472,7 @@ mod tests { "grant_id": grant_id, "binding_digest": manifest.binding_digest, }, + "model_grant": null, }) } @@ -2780,6 +2933,7 @@ exit 2 "grant_id": "extract_0123456789abcdef0123456789abcdef", "binding_digest": format!("sha256:{}", "6".repeat(64)), }, + "model_grant": null, }); let (mut client, mut server) = UnixStream::pair().unwrap(); write_frame(&mut client, &serde_json::to_vec(&envelope).unwrap()).unwrap(); @@ -2812,6 +2966,126 @@ exit 2 assert!(serde_json::from_value::(configuration).is_err()); } + #[test] + fn smol_configuration_rejects_the_unprojected_host_model_relay() { + let (root, mut config) = test_config(); + config.runtime = "smolvm".into(); + config.isolation_mode = "smol-microvm".into(); + config.docker_binary = PathBuf::new(); + config.model_proxy_socket = Some(root.join("private/model-proxy.sock")); + config.model_relay_root = Some(root.join("private/model-relays")); + + assert_eq!( + validate_config(&config), + Err("model relay configuration incomplete".into()) + ); + fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn execute_wire_requires_an_exact_model_grant_member() { + let (root, config) = test_config(); + let envelope = pre_submit_envelope_with_grant(&config); + assert!(serde_json::from_value::(envelope.clone()).is_ok()); + + let mut missing = envelope.clone(); + missing.as_object_mut().unwrap().remove("model_grant"); + assert!(serde_json::from_value::(missing).is_err()); + + let mut extended = envelope; + extended["model_grant"] = serde_json::json!({ + "grant_id": format!("model_{}", "1".repeat(32)), + "binding_digest": extended["request"]["request_digest"], + "extra": true, + }); + assert!(serde_json::from_value::(extended).is_err()); + let workspace = config + .material_root + .join("workspaces/extract_0123456789abcdef0123456789abcdef/workspace"); + fs::set_permissions(workspace, fs::Permissions::from_mode(0o700)).unwrap(); + fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn model_grants_are_rejected_outside_the_bound_post_request() { + let (root, mut config) = test_config(); + config.model_proxy_socket = Some(PathBuf::from("/unused/private/proxy.sock")); + config.model_relay_root = Some(PathBuf::from("/unused/private/relay")); + let mut envelope = pre_submit_envelope_with_grant(&config); + envelope["model_grant"] = serde_json::json!({ + "grant_id": format!("model_{}", "1".repeat(32)), + "binding_digest": envelope["request"]["request_digest"], + }); + let (_client, server) = UnixStream::pair().unwrap(); + assert_eq!( + handle_frame(&config, &server, &serde_json::to_vec(&envelope).unwrap()), + Err("model grant invalid".into()) + ); + + let workspace = config + .material_root + .join("workspaces/extract_0123456789abcdef0123456789abcdef/workspace"); + fs::set_permissions(workspace, fs::Permissions::from_mode(0o700)).unwrap(); + fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn container_arguments_mount_only_the_invocation_model_socket() { + let material = "type=bind,src=/private/material,dst=/work/input,readonly"; + let invocation = + "type=bind,src=/private/runtime/.model-123/model.sock,dst=/work/model.sock,readonly"; + let arguments = container_create_arguments( + "candidate", + "workstream.external-checker-attempt=a", + "workstream.external-checker-owner=b", + "runc", + "65532:65532", + "16", + "16777216", + "1.000", + material, + Some(invocation), + "registry.example/checker@sha256:123", + ); + assert!(arguments + .windows(2) + .any(|pair| pair == ["--network", "none"])); + let mounts: Vec<_> = arguments + .windows(2) + .filter(|pair| pair[0] == "--mount") + .map(|pair| pair[1].as_str()) + .collect(); + assert_eq!(mounts, [material, invocation]); + assert!(!arguments.iter().any(|value| { + value.contains("proxy.sock") + || value.contains("model_0123456789abcdef0123456789abcdef") + || value.contains("provider") + || value.contains("api_key") + })); + + let without_model = container_create_arguments( + "candidate", + "workstream.external-checker-attempt=a", + "workstream.external-checker-owner=b", + "runc", + "65532:65532", + "16", + "16777216", + "1.000", + material, + None, + "registry.example/checker@sha256:123", + ); + assert_eq!( + without_model + .windows(2) + .filter(|pair| pair[0] == "--mount") + .map(|pair| pair[1].as_str()) + .collect::>(), + [material] + ); + } + #[test] fn checker_output_cannot_request_another_execution() { let (root, config) = test_config(); @@ -3155,6 +3429,7 @@ exit 2 &config.cache[0], &request, &workspace, + None, &client, &mut budget, ), @@ -3283,6 +3558,7 @@ exit 2 &config.cache[0], &request, &workspace, + None, &client, &mut budget, ), @@ -3318,6 +3594,7 @@ exit 2 &config.cache[0], &request, &workspace, + None, &client, &mut budget, ), diff --git a/external_checkers/workstream-checker-service/src/model_relay.rs b/external_checkers/workstream-checker-service/src/model_relay.rs new file mode 100644 index 000000000..b646df271 --- /dev/null +++ b/external_checkers/workstream-checker-service/src/model_relay.rs @@ -0,0 +1,1183 @@ +//! Invocation-local bounded session relay from a checker image to the trusted model proxy. + +use crate::proxy_transport::{connect_private, read_frame, write_frame}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use std::collections::{HashMap, HashSet}; +use std::fmt; +use std::fs::{self, DirBuilder, File}; +use std::io::{Read, Write}; +use std::os::unix::ffi::OsStrExt; +use std::os::unix::fs::{DirBuilderExt, FileTypeExt, MetadataExt, PermissionsExt}; +use std::os::unix::net::{UnixListener, UnixStream}; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex}; +use std::thread::{self, JoinHandle}; +use std::time::{Duration, Instant}; + +pub(crate) const CONTAINER_MODEL_SOCKET: &str = "/work/model.sock"; +const MODEL_PROXY_PROTOCOL: &str = "workstream.model_session.v2"; +const MODEL_FRAME_MAXIMUM: usize = 1024 * 1024; +const MODEL_MAXIMUM_RECEIPTS: usize = 1000; +const INVOCATION_DIRECTORY_PREFIX: &str = ".model-"; + +pub(crate) use crate::proxy_transport::validate_private_socket as validate_proxy_socket; + +#[derive(Clone, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub(crate) struct ModelGrant { + pub(crate) grant_id: String, + pub(crate) binding_digest: String, +} + +impl fmt::Debug for ModelGrant { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("ModelGrant()") + } +} + +/// The execute-envelope field uses an explicit deserializer so JSON `null` is +/// valid while an omitted `model_grant` member remains invalid. +#[derive(Clone, Deserialize)] +#[serde(transparent)] +pub(crate) struct RequiredModelGrant(pub(crate) Option); + +impl fmt::Debug for RequiredModelGrant { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self.0 { + Some(_) => formatter.write_str("RequiredModelGrant(Some())"), + None => formatter.write_str("RequiredModelGrant(None)"), + } + } +} + +pub(crate) fn deserialize_required_model_grant<'de, D>( + deserializer: D, +) -> Result +where + D: serde::Deserializer<'de>, +{ + Option::::deserialize(deserializer).map(RequiredModelGrant) +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct ImageModelRequest { + protocol_version: String, + operation: String, + call_index: u32, + prompt: String, + required_receipt_handles: Vec, +} + +impl fmt::Debug for ImageModelRequest { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("ImageModelRequest()") + } +} + +#[derive(Serialize)] +struct ProxyModelRequest<'a> { + protocol_version: &'static str, + grant_id: &'a str, + request_digest: &'a str, + call_index: u32, + prompt: &'a str, + resolved_receipts: &'a [ResolvedModelReceipt], +} + +impl fmt::Debug for ProxyModelRequest<'_> { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("ProxyModelRequest()") + } +} + +#[derive(Clone, Deserialize)] +#[serde(deny_unknown_fields)] +struct ProxyModelResponse { + protocol_version: String, + outcome: String, + #[serde(deserialize_with = "deserialize_required_optional_string")] + output: Option, + #[serde(deserialize_with = "deserialize_required_optional_string")] + receipt_digest: Option, + #[serde(deserialize_with = "deserialize_required_optional_string")] + failure_code: Option, + call_index: u32, + session_exhausted: bool, +} + +#[derive(Clone, Serialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub(crate) struct ResolvedModelReceipt { + pub(crate) receipt_sha256: String, + pub(crate) canonical_receipt_utf8: String, +} + +impl fmt::Debug for ResolvedModelReceipt { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("ResolvedModelReceipt()") + } +} + +#[derive(Debug, PartialEq, Eq)] +pub(crate) enum ModelReceiptResolutionFailure { + Invalid, + Unavailable, + LimitExceeded, +} + +pub(crate) trait ModelReceiptResolver: Send + Sync { + fn resolve( + &self, + request_digest: &str, + handles: &[String], + maximum_receipts: usize, + maximum_bytes: usize, + ) -> Result, ModelReceiptResolutionFailure>; +} + +struct RegisteredReceipt { + request_digest: String, + receipt: ResolvedModelReceipt, +} + +#[derive(Default)] +pub(crate) struct InvocationReceiptRegistry { + entries: Mutex>, +} + +impl InvocationReceiptRegistry { + pub(crate) fn register( + &self, + request_digest: &str, + canonical_receipt_utf8: &str, + receipt_sha256: &str, + ) -> Result { + if !valid_sha256(request_digest) + || !valid_sha256(receipt_sha256) + || canonical_receipt_utf8.is_empty() + || canonical_receipt_utf8.len() > MODEL_FRAME_MAXIMUM + || !canonical_json_receipt(canonical_receipt_utf8, receipt_sha256) + { + return Err(ModelReceiptResolutionFailure::Invalid); + } + let mut entries = self + .entries + .lock() + .map_err(|_| ModelReceiptResolutionFailure::Unavailable)?; + for _ in 0..8 { + let mut random = [0_u8; 16]; + File::open("/dev/urandom") + .and_then(|mut source| source.read_exact(&mut random)) + .map_err(|_| ModelReceiptResolutionFailure::Unavailable)?; + let handle = format!( + "trial_receipt_{}", + random + .iter() + .map(|byte| format!("{byte:02x}")) + .collect::() + ); + if !entries.contains_key(&handle) { + entries.insert( + handle.clone(), + RegisteredReceipt { + request_digest: request_digest.into(), + receipt: ResolvedModelReceipt { + receipt_sha256: receipt_sha256.into(), + canonical_receipt_utf8: canonical_receipt_utf8.into(), + }, + }, + ); + return Ok(handle); + } + } + Err(ModelReceiptResolutionFailure::Unavailable) + } +} + +impl ModelReceiptResolver for InvocationReceiptRegistry { + fn resolve( + &self, + request_digest: &str, + handles: &[String], + maximum_receipts: usize, + maximum_bytes: usize, + ) -> Result, ModelReceiptResolutionFailure> { + if !valid_sha256(request_digest) + || handles.len() > maximum_receipts + || maximum_receipts > MODEL_MAXIMUM_RECEIPTS + || maximum_bytes > MODEL_FRAME_MAXIMUM + { + return Err(ModelReceiptResolutionFailure::LimitExceeded); + } + let unique = handles.iter().collect::>(); + if unique.len() != handles.len() + || handles.iter().any(|handle| !valid_receipt_handle(handle)) + { + return Err(ModelReceiptResolutionFailure::Invalid); + } + let entries = self + .entries + .lock() + .map_err(|_| ModelReceiptResolutionFailure::Unavailable)?; + let mut total = 0_usize; + let mut receipts = Vec::with_capacity(handles.len()); + for handle in handles { + let entry = entries + .get(handle) + .filter(|entry| entry.request_digest == request_digest) + .ok_or(ModelReceiptResolutionFailure::Unavailable)?; + total = total + .checked_add(entry.receipt.canonical_receipt_utf8.len()) + .ok_or(ModelReceiptResolutionFailure::LimitExceeded)?; + if total > maximum_bytes { + return Err(ModelReceiptResolutionFailure::LimitExceeded); + } + receipts.push(entry.receipt.clone()); + } + Ok(receipts) + } +} + +impl fmt::Debug for ProxyModelResponse { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("ProxyModelResponse()") + } +} + +fn deserialize_required_optional_string<'de, D>(deserializer: D) -> Result, D::Error> +where + D: serde::Deserializer<'de>, +{ + Option::::deserialize(deserializer) +} + +#[derive(Debug, PartialEq, Eq)] +pub(crate) struct RelayFinish { + pub(crate) failed: bool, + pub(crate) cleanup_unsettled: bool, +} + +#[derive(Debug)] +pub(crate) struct RelayStartFailure { + pub(crate) cleanup_unsettled: bool, +} + +impl RelayStartFailure { + fn ordinary() -> Self { + Self { + cleanup_unsettled: false, + } + } +} + +#[derive(Debug)] +struct RelayPaths { + directory: PathBuf, + socket: PathBuf, + directory_device: u64, + directory_inode: u64, + socket_device: u64, + socket_inode: u64, + sandbox_uid: u32, + sandbox_gid: u32, +} + +pub(crate) struct InvocationRelay { + paths: RelayPaths, + cancelled: Arc, + worker: Option>>, +} + +impl InvocationRelay { + pub(crate) fn start( + relay_root: &Path, + proxy_path: &Path, + grant: &ModelGrant, + request_digest: &str, + sandbox_uid: u32, + sandbox_gid: u32, + deadline: Instant, + ) -> Result { + Self::start_with_resolver( + relay_root, + proxy_path, + grant, + request_digest, + Arc::new(InvocationReceiptRegistry::default()), + sandbox_uid, + sandbox_gid, + deadline, + ) + } + + #[allow(clippy::too_many_arguments)] + pub(crate) fn start_with_resolver( + relay_root: &Path, + proxy_path: &Path, + grant: &ModelGrant, + request_digest: &str, + receipt_resolver: Arc, + sandbox_uid: u32, + sandbox_gid: u32, + deadline: Instant, + ) -> Result { + validate_proxy_socket(proxy_path).map_err(|_| RelayStartFailure::ordinary())?; + validate_relay_root(relay_root).map_err(|_| RelayStartFailure::ordinary())?; + // The unprivileged service cannot chown after dropping every capability. + // Requiring the sandbox identity to match makes the newly bound socket + // inherit its final owner without a privileged operation. + // SAFETY: effective IDs are read-only process attributes. + let (effective_uid, effective_gid) = unsafe { (libc::geteuid(), libc::getegid()) }; + if (sandbox_uid, sandbox_gid) != (effective_uid, effective_gid) { + return Err(RelayStartFailure::ordinary()); + } + let directory = + create_invocation_directory(relay_root).map_err(|_| RelayStartFailure::ordinary())?; + let socket = directory.join("model.sock"); + let listener = match UnixListener::bind(&socket) { + Ok(listener) => listener, + Err(_) => { + return Err(RelayStartFailure { + cleanup_unsettled: fs::remove_dir(&directory).is_err(), + }); + } + }; + if listener.set_nonblocking(true).is_err() + || fs::set_permissions(&socket, fs::Permissions::from_mode(0o600)).is_err() + { + drop(listener); + return Err(remove_created_paths(&directory, &socket)); + } + let directory_metadata = match fs::symlink_metadata(&directory) { + Ok(metadata) => metadata, + Err(_) => { + drop(listener); + return Err(remove_created_paths(&directory, &socket)); + } + }; + let socket_metadata = match fs::symlink_metadata(&socket) { + Ok(metadata) => metadata, + Err(_) => { + drop(listener); + return Err(remove_created_paths(&directory, &socket)); + } + }; + if !private_process_directory(&directory_metadata) + || !sandbox_socket(&socket_metadata, sandbox_uid, sandbox_gid) + { + drop(listener); + return Err(remove_created_paths(&directory, &socket)); + } + let paths = RelayPaths { + directory, + socket, + directory_device: directory_metadata.dev(), + directory_inode: directory_metadata.ino(), + socket_device: socket_metadata.dev(), + socket_inode: socket_metadata.ino(), + sandbox_uid, + sandbox_gid, + }; + let cancelled = Arc::new(AtomicBool::new(false)); + let worker_cancelled = Arc::clone(&cancelled); + let proxy = proxy_path.to_owned(); + let grant = grant.clone(); + let digest = request_digest.to_owned(); + let worker = match thread::Builder::new() + .name("checker-model-relay".into()) + .spawn(move || { + relay_once( + listener, + &proxy, + &grant, + &digest, + receipt_resolver.as_ref(), + deadline, + &worker_cancelled, + ) + }) { + Ok(worker) => worker, + Err(_) => { + return Err(RelayStartFailure { + cleanup_unsettled: !cleanup_paths(&paths), + }); + } + }; + Ok(Self { + paths, + cancelled, + worker: Some(worker), + }) + } + + pub(crate) fn mount_spec(&self) -> String { + format!( + "type=bind,src={},dst={CONTAINER_MODEL_SOCKET},readonly", + self.paths.socket.display() + ) + } + + #[cfg(test)] + pub(crate) fn socket_path(&self) -> &Path { + &self.paths.socket + } + + pub(crate) fn finish(mut self) -> RelayFinish { + self.cancelled.store(true, Ordering::Release); + let failed = self + .worker + .take() + .is_some_and(|worker| !matches!(worker.join(), Ok(Ok(())))); + let cleanup_unsettled = !cleanup_paths(&self.paths); + RelayFinish { + failed, + cleanup_unsettled, + } + } +} + +fn remove_created_paths(directory: &Path, socket: &Path) -> RelayStartFailure { + let socket_removed = match fs::remove_file(socket) { + Ok(()) => true, + Err(error) => error.kind() == std::io::ErrorKind::NotFound, + }; + let directory_removed = match fs::remove_dir(directory) { + Ok(()) => true, + Err(error) => error.kind() == std::io::ErrorKind::NotFound, + }; + RelayStartFailure { + cleanup_unsettled: !socket_removed || !directory_removed, + } +} + +impl Drop for InvocationRelay { + fn drop(&mut self) { + self.cancelled.store(true, Ordering::Release); + if let Some(worker) = self.worker.take() { + let _ = worker.join(); + } + let _ = cleanup_paths(&self.paths); + } +} + +pub(crate) fn valid_model_grant(grant: &ModelGrant, request_digest: &str, phase: &str) -> bool { + phase == "post_submit" + && grant.binding_digest == request_digest + && grant.grant_id.len() == 38 + && grant.grant_id.starts_with("model_") + && grant.grant_id[6..] + .bytes() + .all(|item| item.is_ascii_hexdigit() && !item.is_ascii_uppercase()) +} + +pub(crate) fn validate_relay_root(path: &Path) -> Result<(), String> { + if !path.is_absolute() || path.as_os_str().as_bytes().contains(&b',') { + return Err("model relay root invalid".into()); + } + let canonical = fs::canonicalize(path).map_err(|_| "model relay root unavailable")?; + let metadata = fs::symlink_metadata(path).map_err(|_| "model relay root unavailable")?; + if canonical != path || !private_process_directory(&metadata) { + return Err("model relay root custody invalid".into()); + } + let maximum_socket_path = path + .join(format!("{INVOCATION_DIRECTORY_PREFIX}{}", "0".repeat(16))) + .join("model.sock"); + if maximum_socket_path.as_os_str().as_bytes().len() >= 108 { + return Err("model relay root path too long".into()); + } + Ok(()) +} + +fn create_invocation_directory(parent: &Path) -> Result { + for _ in 0..8 { + let mut random = [0_u8; 8]; + File::open("/dev/urandom") + .and_then(|mut source| source.read_exact(&mut random)) + .map_err(|_| "model relay randomness unavailable")?; + let name = random + .iter() + .fold(String::with_capacity(16), |mut value, byte| { + use std::fmt::Write; + write!(&mut value, "{byte:02x}").expect("writing to String cannot fail"); + value + }); + let path = parent.join(format!("{INVOCATION_DIRECTORY_PREFIX}{name}")); + let mut builder = DirBuilder::new(); + builder.mode(0o700); + match builder.create(&path) { + Ok(()) => return Ok(path), + Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => continue, + Err(_) => return Err("model relay directory unavailable".into()), + } + } + Err("model relay directory collision".into()) +} + +fn relay_once( + listener: UnixListener, + proxy_path: &Path, + grant: &ModelGrant, + request_digest: &str, + receipt_resolver: &dyn ModelReceiptResolver, + deadline: Instant, + cancelled: &AtomicBool, +) -> Result<(), String> { + let mut image = loop { + if Instant::now() >= deadline { + return Err("model relay deadline exceeded".into()); + } + match listener.accept() { + Ok((stream, _)) => break stream, + Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => { + if cancelled.load(Ordering::Acquire) { + return Ok(()); + } + thread::sleep(Duration::from_millis(2)); + } + Err(_) => return Err("model relay accept failed".into()), + } + }; + drop(listener); // one bounded session: no second connection can be accepted. + image + .set_nonblocking(true) + .map_err(|_| "model relay image socket invalid")?; + let mut next_call_index = 0_u32; + let mut completed_calls = 0_u32; + loop { + let request = match read_frame(&mut image, MODEL_FRAME_MAXIMUM, deadline, cancelled) { + Ok(request) => request, + Err(_) if cancelled.load(Ordering::Acquire) && completed_calls > 0 => return Ok(()), + Err(error) => return Err(error), + }; + let image_request: ImageModelRequest = + serde_json::from_slice(&request).map_err(|_| "model relay request invalid")?; + if image_request.protocol_version != MODEL_PROXY_PROTOCOL + || image_request.operation != "call" + || image_request.call_index != next_call_index + || image_request.call_index > 99 + || image_request.prompt.is_empty() + { + return Err("model relay request invalid".into()); + } + let resolved_receipts = receipt_resolver + .resolve( + request_digest, + &image_request.required_receipt_handles, + MODEL_MAXIMUM_RECEIPTS, + MODEL_FRAME_MAXIMUM, + ) + .map_err(|_| "model relay receipt unavailable")?; + let wrapped = serde_json::to_vec(&ProxyModelRequest { + protocol_version: MODEL_PROXY_PROTOCOL, + grant_id: &grant.grant_id, + request_digest, + call_index: image_request.call_index, + prompt: &image_request.prompt, + resolved_receipts: &resolved_receipts, + }) + .map_err(|_| "model relay request invalid")?; + if wrapped.is_empty() || wrapped.len() > MODEL_FRAME_MAXIMUM { + return Err("model relay request unbounded".into()); + } + let mut proxy = connect_private(proxy_path, deadline, cancelled)?; + write_frame( + &mut proxy, + &wrapped, + MODEL_FRAME_MAXIMUM, + deadline, + cancelled, + )?; + let response = read_frame(&mut proxy, MODEL_FRAME_MAXIMUM, deadline, cancelled)?; + let response_value: ProxyModelResponse = + serde_json::from_slice(&response).map_err(|_| "model relay response invalid")?; + if !valid_proxy_response(&response_value, image_request.call_index) { + return Err("model relay response invalid".into()); + } + write_frame( + &mut image, + &response, + MODEL_FRAME_MAXIMUM, + deadline, + cancelled, + )?; + completed_calls += 1; + if response_value.session_exhausted || response_value.outcome == "unavailable" { + return Ok(()); + } + next_call_index = next_call_index + .checked_add(1) + .ok_or_else(|| "model relay session exhausted".to_string())?; + } +} + +fn valid_proxy_response(response: &ProxyModelResponse, call_index: u32) -> bool { + if response.protocol_version != MODEL_PROXY_PROTOCOL || response.call_index != call_index { + return false; + } + match response.outcome.as_str() { + "completed" => { + response + .output + .as_ref() + .is_some_and(|value| !value.is_empty()) + && response.receipt_digest.as_deref().is_some_and(valid_sha256) + && response.failure_code.is_none() + } + "unavailable" => { + response.output.is_none() + && response.receipt_digest.is_none() + && response + .failure_code + .as_deref() + .is_some_and(valid_failure_code) + } + _ => false, + } +} + +fn valid_sha256(value: &str) -> bool { + value.len() == 71 + && value.starts_with("sha256:") + && value[7..] + .bytes() + .all(|item| item.is_ascii_hexdigit() && !item.is_ascii_uppercase()) +} + +fn valid_failure_code(value: &str) -> bool { + matches!( + value, + "model_request_invalid" + | "model_request_too_large" + | "model_receipt_unavailable" + | "model_tool_limit_exceeded" + | "model_session_exhausted" + | "model_session_deadline" + | "model_budget_unavailable" + | "model_call_unavailable" + | "model_call_operator_recovery_required" + | "model_provider_no_effect" + | "model_provider_delivery_uncertain" + | "model_provider_pending" + | "model_provider_recovery_unavailable" + | "model_provider_usage_unavailable" + | "model_provider_failed" + | "model_response_too_large" + | "model_transport_unavailable" + ) +} + +fn valid_receipt_handle(value: &str) -> bool { + value.len() == 46 + && value.starts_with("trial_receipt_") + && value[14..] + .bytes() + .all(|item| item.is_ascii_hexdigit() && !item.is_ascii_uppercase()) +} + +fn canonical_json_receipt(value: &str, expected_sha256: &str) -> bool { + let parsed: serde_json::Value = match serde_json::from_str(value) { + Ok(parsed) => parsed, + Err(_) => return false, + }; + if !matches!(serde_json::to_string(&parsed), Ok(encoded) if encoded == value) { + return false; + } + let mut digest = Sha256::new(); + digest.update(value.as_bytes()); + format!("sha256:{:x}", digest.finalize()) == expected_sha256 +} + +fn cleanup_paths(paths: &RelayPaths) -> bool { + let socket_ok = match fs::symlink_metadata(&paths.socket) { + Ok(metadata) + if sandbox_socket(&metadata, paths.sandbox_uid, paths.sandbox_gid) + && (metadata.dev(), metadata.ino()) + == (paths.socket_device, paths.socket_inode) => + { + fs::remove_file(&paths.socket).is_ok() + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => true, + _ => false, + }; + let directory_ok = match fs::symlink_metadata(&paths.directory) { + Ok(metadata) + if private_process_directory(&metadata) + && (metadata.dev(), metadata.ino()) + == (paths.directory_device, paths.directory_inode) => + { + fs::remove_dir(&paths.directory).is_ok() + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => true, + _ => false, + }; + socket_ok && directory_ok +} + +fn private_process_directory(metadata: &fs::Metadata) -> bool { + // SAFETY: effective IDs are read-only process attributes. + let (uid, gid) = unsafe { (libc::geteuid(), libc::getegid()) }; + metadata.file_type().is_dir() + && metadata.mode() & 0o777 == 0o700 + && metadata.uid() == uid + && metadata.gid() == gid +} + +fn sandbox_socket(metadata: &fs::Metadata, uid: u32, gid: u32) -> bool { + metadata.file_type().is_socket() + && metadata.mode() & 0o777 == 0o600 + && metadata.uid() == uid + && metadata.gid() == gid +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::atomic::AtomicU64; + use std::time::{SystemTime, UNIX_EPOCH}; + + static NEXT_ROOT: AtomicU64 = AtomicU64::new(0); + + fn private_root() -> PathBuf { + let unique = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + let root = std::env::temp_dir().join(format!( + "ws-model-relay-{}-{unique}-{}", + std::process::id(), + NEXT_ROOT.fetch_add(1, Ordering::Relaxed), + )); + fs::create_dir(&root).unwrap(); + fs::set_permissions(&root, fs::Permissions::from_mode(0o700)).unwrap(); + root + } + + fn frame(stream: &mut UnixStream, value: &[u8]) { + stream + .write_all(&(value.len() as u32).to_be_bytes()) + .unwrap(); + stream.write_all(value).unwrap(); + } + + fn receive(stream: &mut UnixStream) -> Vec { + stream.set_nonblocking(false).unwrap(); + let mut header = [0_u8; 4]; + stream.read_exact(&mut header).unwrap(); + let mut body = vec![0; u32::from_be_bytes(header) as usize]; + stream.read_exact(&mut body).unwrap(); + body + } + + #[test] + fn grant_is_exactly_post_submit_and_request_bound() { + let digest = format!("sha256:{}", "a".repeat(64)); + let grant = ModelGrant { + grant_id: format!("model_{}", "1".repeat(32)), + binding_digest: digest.clone(), + }; + assert!(valid_model_grant(&grant, &digest, "post_submit")); + assert!(!valid_model_grant(&grant, &digest, "pre_submit")); + assert!(!valid_model_grant( + &grant, + &format!("sha256:{}", "b".repeat(64)), + "post_submit" + )); + for invalid in [ + "model_0123456789abcdef0123456789abcde", + "model_0123456789abcdef0123456789abcdef0", + "model_0123456789abcdef0123456789abcdeG", + "other_0123456789abcdef0123456789abcdef", + ] { + let mut changed = grant.clone(); + changed.grant_id = invalid.into(); + assert!(!valid_model_grant(&changed, &digest, "post_submit")); + } + } + + #[test] + fn receipt_registry_is_request_bound_canonical_unique_and_bounded() { + let registry = InvocationReceiptRegistry::default(); + let request = format!("sha256:{}", "a".repeat(64)); + let foreign = format!("sha256:{}", "b".repeat(64)); + let canonical = r#"{"facts":{"exit_code":0},"schema":"trial.v1"}"#; + let mut digest = Sha256::new(); + digest.update(canonical.as_bytes()); + let receipt_sha = format!("sha256:{:x}", digest.finalize()); + let handle = registry + .register(&request, canonical, &receipt_sha) + .unwrap(); + assert!(valid_receipt_handle(&handle)); + assert_eq!( + registry.resolve(&request, std::slice::from_ref(&handle), 1, canonical.len()), + Ok(vec![ResolvedModelReceipt { + receipt_sha256: receipt_sha.clone(), + canonical_receipt_utf8: canonical.into(), + }]) + ); + assert_eq!( + registry.resolve(&foreign, std::slice::from_ref(&handle), 1, canonical.len()), + Err(ModelReceiptResolutionFailure::Unavailable) + ); + assert_eq!( + registry.resolve(&request, &[handle.clone(), handle], 2, canonical.len() * 2), + Err(ModelReceiptResolutionFailure::Invalid) + ); + assert_eq!(registry.resolve(&request, &[], 0, 0), Ok(Vec::new())); + assert_eq!( + registry.register(&request, "{\"z\":0,\"a\":1}", &receipt_sha), + Err(ModelReceiptResolutionFailure::Invalid) + ); + } + + #[test] + fn diagnostic_output_redacts_model_grants_prompts_and_results() { + let grant_id = format!("model_{}", "1".repeat(32)); + let digest = format!("sha256:{}", "a".repeat(64)); + let prompt = "private image prompt"; + let output = "private model output"; + let grant = ModelGrant { + grant_id: grant_id.clone(), + binding_digest: digest.clone(), + }; + let required = RequiredModelGrant(Some(grant.clone())); + let image = ImageModelRequest { + protocol_version: MODEL_PROXY_PROTOCOL.into(), + operation: "call".into(), + call_index: 0, + prompt: prompt.into(), + required_receipt_handles: vec![], + }; + let request = ProxyModelRequest { + protocol_version: MODEL_PROXY_PROTOCOL, + grant_id: &grant.grant_id, + request_digest: &digest, + call_index: 0, + prompt, + resolved_receipts: &[], + }; + let response = ProxyModelResponse { + protocol_version: MODEL_PROXY_PROTOCOL.into(), + outcome: "completed".into(), + output: Some(output.into()), + receipt_digest: Some(format!("sha256:{}", "b".repeat(64))), + failure_code: None, + call_index: 0, + session_exhausted: true, + }; + + for diagnostic in [ + format!("{grant:?}"), + format!("{required:?}"), + format!("{image:?}"), + format!("{request:?}"), + format!("{response:?}"), + ] { + assert!(!diagnostic.contains(&grant_id)); + assert!(!diagnostic.contains(&digest)); + assert!(!diagnostic.contains(prompt)); + assert!(!diagnostic.contains(output)); + } + assert_eq!( + format!("{:?}", RequiredModelGrant(None)), + "RequiredModelGrant(None)" + ); + } + + #[test] + fn proxy_response_has_a_closed_non_provider_contract() { + let completed = ProxyModelResponse { + protocol_version: MODEL_PROXY_PROTOCOL.into(), + outcome: "completed".into(), + output: Some("answer".into()), + receipt_digest: Some(format!("sha256:{}", "a".repeat(64))), + failure_code: None, + call_index: 0, + session_exhausted: true, + }; + assert!(valid_proxy_response(&completed, 0)); + let unavailable = ProxyModelResponse { + protocol_version: MODEL_PROXY_PROTOCOL.into(), + outcome: "unavailable".into(), + output: None, + receipt_digest: None, + failure_code: Some("model_budget_unavailable".into()), + call_index: 0, + session_exhausted: true, + }; + assert!(valid_proxy_response(&unavailable, 0)); + for invalid in [ + ProxyModelResponse { + failure_code: Some("provider_error: secret".into()), + ..unavailable.clone() + }, + ProxyModelResponse { + receipt_digest: None, + ..completed.clone() + }, + ] { + assert!(!valid_proxy_response(&invalid, 0)); + } + assert!(serde_json::from_slice::( + br#"{"protocol_version":"workstream.model_session.v2","outcome":"unavailable","output":null,"receipt_digest":null,"failure_code":"model_provider_failed","call_index":0,"session_exhausted":true,"provider_id":"secret"}"# + ) + .is_err()); + assert!(serde_json::from_slice::( + br#"{"protocol_version":"workstream.model_session.v2","outcome":"unavailable","output":null,"failure_code":"model_provider_failed","call_index":0,"session_exhausted":true}"# + ) + .is_err()); + } + + #[test] + fn relay_wraps_bounded_sequential_calls_and_cleans_invocation_socket() { + let root = private_root(); + let proxy_path = root.join("proxy.sock"); + let proxy = UnixListener::bind(&proxy_path).unwrap(); + fs::set_permissions(&proxy_path, fs::Permissions::from_mode(0o600)).unwrap(); + let digest = format!("sha256:{}", "a".repeat(64)); + let grant = ModelGrant { + grant_id: format!("model_{}", "1".repeat(32)), + binding_digest: digest.clone(), + }; + let uid = fs::metadata(&root).unwrap().uid(); + let gid = fs::metadata(&root).unwrap().gid(); + let relay = InvocationRelay::start( + &root, + &proxy_path, + &grant, + &digest, + uid, + gid, + Instant::now() + Duration::from_secs(2), + ) + .unwrap(); + let relay_path = relay.socket_path().to_owned(); + let mut image = UnixStream::connect(&relay_path).unwrap(); + frame(&mut image, br#"{"protocol_version":"workstream.model_session.v2","operation":"call","call_index":0,"prompt":"review this","required_receipt_handles":[]}"#); + let (mut backend, _) = proxy.accept().unwrap(); + let wrapped: serde_json::Value = serde_json::from_slice(&receive(&mut backend)).unwrap(); + assert_eq!(wrapped["protocol_version"], MODEL_PROXY_PROTOCOL); + assert_eq!(wrapped["grant_id"], grant.grant_id); + assert_eq!(wrapped["request_digest"], digest); + assert_eq!(wrapped["call_index"], 0); + assert_eq!(wrapped["prompt"], "review this"); + assert_eq!(wrapped["resolved_receipts"], serde_json::json!([])); + let response = format!( + r#"{{"protocol_version":"{MODEL_PROXY_PROTOCOL}","outcome":"completed","output":"bounded","receipt_digest":"sha256:{}","failure_code":null,"call_index":0,"session_exhausted":false}}"#, + "b".repeat(64) + ); + frame(&mut backend, response.as_bytes()); + assert_eq!(receive(&mut image), response.as_bytes()); + frame(&mut image, br#"{"protocol_version":"workstream.model_session.v2","operation":"call","call_index":1,"prompt":"assess this","required_receipt_handles":[]}"#); + let (mut backend, _) = proxy.accept().unwrap(); + let wrapped: serde_json::Value = serde_json::from_slice(&receive(&mut backend)).unwrap(); + assert_eq!(wrapped["call_index"], 1); + assert_eq!(wrapped["prompt"], "assess this"); + let response = format!( + r#"{{"protocol_version":"{MODEL_PROXY_PROTOCOL}","outcome":"completed","output":"complete","receipt_digest":"sha256:{}","failure_code":null,"call_index":1,"session_exhausted":true}}"#, + "c".repeat(64) + ); + frame(&mut backend, response.as_bytes()); + assert_eq!(receive(&mut image), response.as_bytes()); + assert!(UnixStream::connect(&relay_path).is_err()); + assert_eq!( + relay.finish(), + RelayFinish { + failed: false, + cleanup_unsettled: false + } + ); + assert!(!relay_path.exists()); + fs::remove_file(proxy_path).unwrap(); + fs::remove_dir(root).unwrap(); + } + + #[test] + fn distinct_invocations_and_frame_bounds_do_not_cross() { + let root = private_root(); + let proxy_path = root.join("proxy.sock"); + let _proxy = UnixListener::bind(&proxy_path).unwrap(); + fs::set_permissions(&proxy_path, fs::Permissions::from_mode(0o600)).unwrap(); + let uid = fs::metadata(&root).unwrap().uid(); + let gid = fs::metadata(&root).unwrap().gid(); + let digest = format!("sha256:{}", "a".repeat(64)); + let grant = ModelGrant { + grant_id: format!("model_{}", "1".repeat(32)), + binding_digest: digest.clone(), + }; + let mut first = InvocationRelay::start( + &root, + &proxy_path, + &grant, + &digest, + uid, + gid, + Instant::now() + Duration::from_secs(5), + ) + .unwrap(); + let second = InvocationRelay::start( + &root, + &proxy_path, + &grant, + &digest, + uid, + gid, + Instant::now() + Duration::from_secs(5), + ) + .unwrap(); + assert_ne!(first.socket_path(), second.socket_path()); + let mut attacker = UnixStream::connect(first.socket_path()).unwrap(); + attacker + .write_all(&((MODEL_FRAME_MAXIMUM + 1) as u32).to_be_bytes()) + .unwrap(); + // Wait for the relay to close its accepted peer before inspecting the + // worker result. Calling `finish` immediately would cancel a thread + // that had not necessarily accepted the queued connection yet. + attacker + .set_read_timeout(Some(Duration::from_secs(5))) + .unwrap(); + let mut closed = [0_u8; 1]; + assert_eq!(attacker.read(&mut closed).unwrap(), 0); + let result = first.worker.take().unwrap().join().unwrap(); + assert_eq!(result, Err("private proxy frame unbounded".into())); + assert!(!first.finish().cleanup_unsettled); + assert!(!second.finish().cleanup_unsettled); + fs::remove_file(proxy_path).unwrap(); + fs::remove_dir(root).unwrap(); + } + + #[test] + fn incomplete_exchange_obeys_deadline_and_cleanup() { + let root = private_root(); + let proxy_path = root.join("proxy.sock"); + let _proxy = UnixListener::bind(&proxy_path).unwrap(); + fs::set_permissions(&proxy_path, fs::Permissions::from_mode(0o600)).unwrap(); + let uid = fs::metadata(&root).unwrap().uid(); + let gid = fs::metadata(&root).unwrap().gid(); + let digest = format!("sha256:{}", "a".repeat(64)); + let grant = ModelGrant { + grant_id: format!("model_{}", "1".repeat(32)), + binding_digest: digest.clone(), + }; + let started = Instant::now(); + let relay = InvocationRelay::start( + &root, + &proxy_path, + &grant, + &digest, + uid, + gid, + Instant::now() + Duration::from_millis(50), + ) + .unwrap(); + let mut image = UnixStream::connect(relay.socket_path()).unwrap(); + image.write_all(&20_u32.to_be_bytes()).unwrap(); + assert!(relay.finish().failed); + assert!(started.elapsed() < Duration::from_millis(500)); + fs::remove_file(proxy_path).unwrap(); + fs::remove_dir(root).unwrap(); + } + + #[test] + fn relay_rejects_a_non_object_proxy_response() { + let root = private_root(); + let proxy_path = root.join("proxy.sock"); + let proxy = UnixListener::bind(&proxy_path).unwrap(); + fs::set_permissions(&proxy_path, fs::Permissions::from_mode(0o600)).unwrap(); + let owner = fs::metadata(&root).unwrap(); + let digest = format!("sha256:{}", "a".repeat(64)); + let grant = ModelGrant { + grant_id: format!("model_{}", "1".repeat(32)), + binding_digest: digest.clone(), + }; + let relay = InvocationRelay::start( + &root, + &proxy_path, + &grant, + &digest, + owner.uid(), + owner.gid(), + Instant::now() + Duration::from_secs(2), + ) + .unwrap(); + let mut image = UnixStream::connect(relay.socket_path()).unwrap(); + frame(&mut image, br#"{"protocol_version":"workstream.model_session.v2","operation":"call","call_index":0,"prompt":"review this","required_receipt_handles":[]}"#); + let (mut backend, _) = proxy.accept().unwrap(); + let _ = receive(&mut backend); + frame(&mut backend, br#"["not","a","contract"]"#); + assert!(relay.finish().failed); + fs::remove_file(proxy_path).unwrap(); + fs::remove_dir(root).unwrap(); + } + + #[test] + fn proxy_path_must_be_an_absolute_socket() { + let root = private_root(); + assert!(validate_relay_root(&root).is_ok()); + assert!(validate_proxy_socket(Path::new("relative.sock")).is_err()); + let regular = root.join("regular"); + fs::write(®ular, b"not a socket").unwrap(); + assert!(validate_proxy_socket(®ular).is_err()); + let socket = root.join("proxy.sock"); + let _listener = UnixListener::bind(&socket).unwrap(); + assert!(validate_proxy_socket(&socket).is_err()); + fs::set_permissions(&socket, fs::Permissions::from_mode(0o600)).unwrap(); + assert!(validate_proxy_socket(&socket).is_ok()); + let trusted = root.join("trusted"); + fs::create_dir(&trusted).unwrap(); + fs::set_permissions(&trusted, fs::Permissions::from_mode(0o700)).unwrap(); + let linked = root.join("linked"); + std::os::unix::fs::symlink(&trusted, &linked).unwrap(); + let linked_socket = trusted.join("proxy.sock"); + let _linked_listener = UnixListener::bind(&linked_socket).unwrap(); + fs::set_permissions(&linked_socket, fs::Permissions::from_mode(0o600)).unwrap(); + assert!(validate_proxy_socket(&linked.join("proxy.sock")).is_err()); + fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn relay_root_is_private_bounded_and_does_not_need_chown() { + let root = private_root(); + let proxy_path = root.join("proxy.sock"); + let _proxy = UnixListener::bind(&proxy_path).unwrap(); + fs::set_permissions(&proxy_path, fs::Permissions::from_mode(0o600)).unwrap(); + let owner = fs::metadata(&root).unwrap(); + let digest = format!("sha256:{}", "a".repeat(64)); + let grant = ModelGrant { + grant_id: format!("model_{}", "1".repeat(32)), + binding_digest: digest.clone(), + }; + let different_uid = owner.uid().wrapping_add(1); + assert!(InvocationRelay::start( + &root, + &proxy_path, + &grant, + &digest, + different_uid, + owner.gid(), + Instant::now() + Duration::from_secs(1), + ) + .is_err()); + assert_eq!( + fs::read_dir(&root) + .unwrap() + .filter_map(Result::ok) + .filter(|entry| entry.file_name().to_string_lossy().starts_with(".model-")) + .count(), + 0 + ); + + let comma_root = root.join("relay,invalid"); + fs::create_dir(&comma_root).unwrap(); + fs::set_permissions(&comma_root, fs::Permissions::from_mode(0o700)).unwrap(); + assert!(validate_relay_root(&comma_root).is_err()); + + let long_root = root.join("x".repeat(100)); + fs::create_dir(&long_root).unwrap(); + fs::set_permissions(&long_root, fs::Permissions::from_mode(0o700)).unwrap(); + assert!(validate_relay_root(&long_root).is_err()); + fs::remove_dir_all(root).unwrap(); + } +} diff --git a/external_checkers/workstream-checker-service/src/proxy_transport.rs b/external_checkers/workstream-checker-service/src/proxy_transport.rs new file mode 100644 index 000000000..12f8bf94d --- /dev/null +++ b/external_checkers/workstream-checker-service/src/proxy_transport.rs @@ -0,0 +1,203 @@ +//! Transport-only private Unix framing shared by trusted invocation relays. + +use std::fs; +use std::io::{Read, Write}; +use std::os::fd::FromRawFd; +use std::os::unix::ffi::OsStrExt; +use std::os::unix::fs::{FileTypeExt, MetadataExt}; +use std::os::unix::net::UnixStream; +use std::path::Path; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::time::{Duration, Instant}; + +pub(crate) fn validate_private_socket(path: &Path) -> Result<(), String> { + if !path.is_absolute() { + return Err("private proxy socket must be absolute".into()); + } + let parent = path + .parent() + .ok_or_else(|| "private proxy socket parent invalid".to_string())?; + let canonical_parent = + fs::canonicalize(parent).map_err(|_| "private proxy socket parent unavailable")?; + let parent_metadata = + fs::symlink_metadata(parent).map_err(|_| "private proxy socket parent unavailable")?; + // SAFETY: effective IDs are read-only process attributes. + let (uid, gid) = unsafe { (libc::geteuid(), libc::getegid()) }; + if canonical_parent != parent + || !parent_metadata.file_type().is_dir() + || parent_metadata.file_type().is_symlink() + || parent_metadata.mode() & 0o777 != 0o700 + || parent_metadata.uid() != uid + || parent_metadata.gid() != gid + { + return Err("private proxy socket parent custody invalid".into()); + } + let metadata = fs::symlink_metadata(path).map_err(|_| "private proxy socket unavailable")?; + if metadata.file_type().is_symlink() + || !metadata.file_type().is_socket() + || metadata.mode() & 0o777 != 0o600 + || metadata.uid() != uid + || metadata.gid() != gid + { + return Err("private proxy socket invalid".into()); + } + Ok(()) +} + +pub(crate) fn read_frame( + stream: &mut UnixStream, + maximum: usize, + deadline: Instant, + cancelled: &AtomicBool, +) -> Result, String> { + let mut header = [0_u8; 4]; + transfer(stream, &mut header, deadline, cancelled, |stream, bytes| { + stream.read(bytes) + })?; + let size = u32::from_be_bytes(header) as usize; + if size == 0 || size > maximum { + return Err("private proxy frame unbounded".into()); + } + let mut body = vec![0_u8; size]; + transfer(stream, &mut body, deadline, cancelled, |stream, bytes| { + stream.read(bytes) + })?; + Ok(body) +} + +pub(crate) fn write_frame( + stream: &mut UnixStream, + body: &[u8], + maximum: usize, + deadline: Instant, + cancelled: &AtomicBool, +) -> Result<(), String> { + if body.is_empty() || body.len() > maximum || body.len() > u32::MAX as usize { + return Err("private proxy frame unbounded".into()); + } + let mut header = (body.len() as u32).to_be_bytes(); + transfer(stream, &mut header, deadline, cancelled, |stream, bytes| { + stream.write(bytes) + })?; + let mut body = body.to_vec(); + transfer(stream, &mut body, deadline, cancelled, |stream, bytes| { + stream.write(bytes) + }) +} + +fn transfer( + stream: &mut UnixStream, + bytes: &mut [u8], + deadline: Instant, + cancelled: &AtomicBool, + mut operation: impl FnMut(&mut UnixStream, &mut [u8]) -> std::io::Result, +) -> Result<(), String> { + let mut offset = 0; + while offset < bytes.len() { + if cancelled.load(Ordering::Acquire) { + return Err("private proxy operation cancelled".into()); + } + if Instant::now() >= deadline { + return Err("private proxy operation deadline exceeded".into()); + } + match operation(stream, &mut bytes[offset..]) { + Ok(0) => return Err("private proxy frame incomplete".into()), + Ok(count) => offset += count, + Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => { + std::thread::sleep(Duration::from_millis(2)); + } + Err(_) => return Err("private proxy I/O failed".into()), + } + } + Ok(()) +} + +pub(crate) fn connect_private( + path: &Path, + deadline: Instant, + cancelled: &AtomicBool, +) -> Result { + let bytes = path.as_os_str().as_bytes(); + if bytes.is_empty() || bytes.len() >= 108 || bytes.contains(&0) { + return Err("private proxy socket path invalid".into()); + } + // SAFETY: socket returns a new descriptor or -1 and has no borrowed inputs. + let descriptor = unsafe { + libc::socket( + libc::AF_UNIX, + libc::SOCK_STREAM | libc::SOCK_NONBLOCK | libc::SOCK_CLOEXEC, + 0, + ) + }; + if descriptor < 0 { + return Err("private proxy connection unavailable".into()); + } + // SAFETY: zero is a valid initialized representation for sockaddr_un. + let mut address: libc::sockaddr_un = unsafe { std::mem::zeroed() }; + address.sun_family = libc::AF_UNIX as libc::sa_family_t; + for (target, source) in address.sun_path.iter_mut().zip(bytes) { + *target = *source as libc::c_char; + } + let length = (std::mem::size_of::() + bytes.len() + 1) as libc::socklen_t; + // SAFETY: descriptor is owned and address/length describe initialized storage. + let connected = unsafe { + libc::connect( + descriptor, + (&address as *const libc::sockaddr_un).cast(), + length, + ) + }; + if connected != 0 { + let error = std::io::Error::last_os_error(); + if !matches!( + error.raw_os_error(), + Some(code) if code == libc::EINPROGRESS || code == libc::EAGAIN + ) { + // SAFETY: descriptor is owned and not converted into a UnixStream. + unsafe { libc::close(descriptor) }; + return Err("private proxy connection unavailable".into()); + } + loop { + if cancelled.load(Ordering::Acquire) || Instant::now() >= deadline { + // SAFETY: descriptor is owned and not converted into a UnixStream. + unsafe { libc::close(descriptor) }; + return Err("private proxy connection deadline exceeded".into()); + } + let mut poll = libc::pollfd { + fd: descriptor, + events: libc::POLLOUT, + revents: 0, + }; + // SAFETY: poll points to one initialized pollfd for the supplied count. + let ready = unsafe { libc::poll(&mut poll, 1, 2) }; + if ready < 0 { + // SAFETY: descriptor is owned and not converted into a UnixStream. + unsafe { libc::close(descriptor) }; + return Err("private proxy connection unavailable".into()); + } + if ready == 0 { + continue; + } + let mut socket_error: libc::c_int = 0; + let mut size = std::mem::size_of_val(&socket_error) as libc::socklen_t; + // SAFETY: pointers refer to writable initialized storage with exact size. + let status = unsafe { + libc::getsockopt( + descriptor, + libc::SOL_SOCKET, + libc::SO_ERROR, + (&mut socket_error as *mut libc::c_int).cast(), + &mut size, + ) + }; + if status != 0 || socket_error != 0 { + // SAFETY: descriptor is owned and not converted into a UnixStream. + unsafe { libc::close(descriptor) }; + return Err("private proxy connection unavailable".into()); + } + break; + } + } + // SAFETY: descriptor is uniquely owned and successful connect completed above. + Ok(unsafe { UnixStream::from_raw_fd(descriptor) }) +} diff --git a/external_checkers/workstream-checker-service/tests/model_relay_docker.py b/external_checkers/workstream-checker-service/tests/model_relay_docker.py new file mode 100644 index 000000000..87686042a --- /dev/null +++ b/external_checkers/workstream-checker-service/tests/model_relay_docker.py @@ -0,0 +1,797 @@ +"""Opt-in Docker-dev proof for the invocation-local model relay. + +The probe uses an already-local base image and registry image, builds only a +small disposable checker layer, and never contacts a model provider. It starts +one fake private global proxy and drives the real Rust service through one +post-submit invocation. Every created container and image reference is recorded +and removed explicitly; the probe never scans or prunes Docker state. + +Usage: + TMPDIR=/absolute/daemon-visible/private-directory python3 model_relay_docker.py \ + --service /absolute/path/to/workstream-checker-service +""" + +from __future__ import annotations + +import argparse +import hashlib +import io +import json +import os +from pathlib import Path +import shutil +import socket +import struct +import subprocess +import tarfile +import tempfile +import threading +import time + + +PROTOCOL = "external_checker_service.v1" +GRANT_PROTOCOL = "workstream.external_checker_material_grant.v1" +MODEL_PROTOCOL = "workstream.model_session.v2" +MAX_FRAME = 1024 * 1024 +MODEL_GRANT_ID = "model_0123456789abcdef0123456789abcdef" +MATERIAL_GRANT_ID = "extract_0123456789abcdef0123456789abcdef" +PROMPT = "docker relay proof" +MODEL_REPLY = { + "protocol_version": MODEL_PROTOCOL, + "outcome": "completed", + "output": "docker-relay-ok", + "receipt_digest": "sha256:" + "e" * 64, + "failure_code": None, + "call_index": 0, + "session_exhausted": True, +} + + +def canonical(value: object) -> bytes: + return json.dumps( + value, + ensure_ascii=False, + allow_nan=False, + sort_keys=True, + separators=(",", ":"), + ).encode() + + +def digest(value: object) -> str: + return "sha256:" + hashlib.sha256(canonical(value)).hexdigest() + + +def run(docker: str, *arguments: str, timeout: int = 60) -> str: + return subprocess.check_output( + [docker, *arguments], text=True, stderr=subprocess.PIPE, timeout=timeout + ).strip() + + +def inspect_container(docker: str, identity: str) -> dict | None: + result = subprocess.run( + [docker, "container", "inspect", identity], + capture_output=True, + text=True, + timeout=30, + ) + if result.returncode: + run(docker, "info", "--format", "{{.ID}}") + if "No such" not in result.stderr: + raise RuntimeError(result.stderr) + return None + return json.loads(result.stdout)[0] + + +def read_exact(connection: socket.socket, length: int) -> bytes: + body = bytearray() + while len(body) < length: + part = connection.recv(length - len(body)) + if not part: + raise OSError("short frame") + body.extend(part) + return bytes(body) + + +def read_frame(connection: socket.socket, maximum: int = MAX_FRAME) -> bytes: + length = struct.unpack(">I", read_exact(connection, 4))[0] + if not 0 < length <= maximum: + raise RuntimeError("invalid frame length") + return read_exact(connection, length) + + +def write_frame(connection: socket.socket, body: bytes) -> None: + if not 0 < len(body) <= MAX_FRAME: + raise RuntimeError("invalid frame body") + connection.sendall(struct.pack(">I", len(body)) + body) + + +def extract_registry_binary(docker: str, image: str, root: Path) -> Path: + archive = root / "registry-image.tar" + subprocess.check_call( + [docker, "image", "save", "--output", str(archive), image], timeout=60 + ) + content: bytes | None = None + with tarfile.open(archive) as outer: + manifest_member = outer.extractfile("manifest.json") + if manifest_member is None: + raise RuntimeError("local registry image manifest is missing") + manifests = json.load(manifest_member) + selected = next( + ( + item + for item in manifests + if image in item.get("RepoTags", []) + or image.split(":", 1)[0] in item.get("RepoTags", []) + ), + manifests[0] if len(manifests) == 1 else None, + ) + if selected is None: + raise RuntimeError("local registry image is ambiguous") + for layer_name in selected["Layers"]: + layer_member = outer.extractfile(layer_name) + if layer_member is None: + raise RuntimeError("local registry image layer is missing") + with tarfile.open(fileobj=io.BytesIO(layer_member.read())) as layer: + for member in layer: + normalized = member.name.removeprefix("./").removeprefix("/") + if normalized == "bin/.wh.registry": + content = None + elif normalized == "bin/registry" and member.isfile(): + source = layer.extractfile(member) + if source is None: + raise RuntimeError("local registry binary is unreadable") + content = source.read() + archive.unlink() + if content is None: + raise RuntimeError("local registry binary is missing") + registry = root / "registry" + registry.write_bytes(content) + registry.chmod(0o500) + return registry + + +def start_registry(binary: Path, root: Path) -> tuple[subprocess.Popen, int]: + with socket.socket() as reservation: + reservation.bind(("127.0.0.1", 0)) + port = reservation.getsockname()[1] + storage = root / "registry-data" + storage.mkdir(mode=0o700) + config = root / "registry.yml" + config.write_text( + "version: 0.1\n" + "log:\n level: error\n" + f"storage:\n filesystem:\n rootdirectory: {storage}\n" + f"http:\n addr: 127.0.0.1:{port}\n", + encoding="utf-8", + ) + process = subprocess.Popen( + [str(binary), "serve", str(config)], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + deadline = time.monotonic() + 15 + while time.monotonic() < deadline: + if process.poll() is not None: + raise RuntimeError("disposable registry failed to start") + try: + with socket.create_connection(("127.0.0.1", port), timeout=0.5): + return process, port + except OSError: + time.sleep(0.05) + process.terminate() + try: + process.wait(timeout=5) + except subprocess.TimeoutExpired: + process.kill() + process.wait(timeout=5) + raise RuntimeError("disposable registry readiness timed out") + + +def wait_for_service(process: subprocess.Popen, address: Path) -> None: + deadline = time.monotonic() + 60 + last_error: Exception | None = None + while time.monotonic() < deadline: + if process.poll() is not None: + raise RuntimeError(process.stderr.read()) + try: + with socket.socket(socket.AF_UNIX) as connection: + connection.settimeout(15) + connection.connect(str(address)) + write_frame( + connection, + canonical({"protocol_version": PROTOCOL, "operation": "health"}), + ) + response = json.loads(read_frame(connection, 96 * 1024)) + if response.get("status") == "ready": + return + except (OSError, ValueError, RuntimeError) as error: + last_error = error + time.sleep(0.1) + raise RuntimeError(f"service readiness timed out: {last_error}") + + +def schema(schema_id: str) -> dict: + document = {"type": "object"} + return { + "schema_id": schema_id, + "schema_version": "v1", + "document": document, + "schema_sha256": digest(document), + } + + +def request_and_manifest(image_digest: str) -> tuple[dict, dict]: + def identifier(byte: str) -> str: + return f"{byte * 8}-0000-4000-8000-000000000000" + + def sha(byte: str) -> str: + return "sha256:" + byte * 64 + + semantic = digest( + {"schema_version": "workstream.submission_bundle_manifest.v1", "entries": []} + ) + registry = { + "capability_id": "probe.model_relay", + "capability_version": "v1", + "phase": "post_submit", + "image_digest": image_digest, + "configuration_schema": schema("probe.model_relay.configuration"), + "input_schema": schema("external_checker_post_submit_input"), + "output_schema": schema("external_checker_result"), + "resources": { + "cpu_millis": 500, + "memory_bytes": 64 * 1024 * 1024, + "deadline_ms": 30_000, + "maximum_output_bytes": 32_768, + }, + "registry_entry_id": identifier("f"), + "entry_digest": sha("0"), + "registration_operation_id": identifier("a"), + "registered_by_actor_profile_id": identifier("b"), + "authorization_decision_event_id": identifier("c"), + "created_at": "2026-10-23T00:00:00Z", + } + entry = dict(registry) + for field in ( + "registry_entry_id", + "entry_digest", + "registration_operation_id", + "registered_by_actor_profile_id", + "authorization_decision_event_id", + "created_at", + ): + entry.pop(field) + registry["entry_digest"] = digest(entry) + identity = { + "phase": "post_submit", + "project_id": identifier("1"), + "task_id": identifier("2"), + "assignment_id": identifier("3"), + "submission_id": identifier("4"), + "submission_version": 2, + "evaluation_request_id": identifier("5"), + "evaluation_request_digest": sha("5"), + "evaluation_generation": 3, + "attempt_id": identifier("6"), + "result_id": identifier("7"), + "lease_id": identifier("8"), + "lease_generation": 4, + "lease_expires_at": "2026-10-23T01:00:00+00:00", + } + material = { + "phase": "post_submit", + "role": "submission_archive", + "submission_id": identity["submission_id"], + "submission_version": identity["submission_version"], + "binding_id": identifier("9"), + "content_id": identifier("a"), + "replica_id": identifier("b"), + "content_sha256": sha("c"), + "byte_count": 0, + "media_type": "application/zip", + "evidence_id": identifier("c"), + "verification_receipt_id": identifier("d"), + "verification_job_id": identifier("e"), + "verification_generation": 5, + "namespace_fingerprint": sha("d"), + "semantic_manifest_sha256": semantic, + } + configuration: dict[str, object] = {} + checker_input: dict[str, object] = {} + request = { + "schema_version": "external_checker_request.v1", + "registry": registry, + "identity": identity, + "configuration": configuration, + "configuration_sha256": digest(configuration), + "input": checker_input, + "input_sha256": digest(checker_input), + "materials": [material], + } + request["request_digest"] = digest(request) + manifest = { + "protocol_version": GRANT_PROTOCOL, + "grant_id": MATERIAL_GRANT_ID, + "request_digest": request["request_digest"], + "phase": "post_submit", + "submission_id": identity["submission_id"], + "submission_version": identity["submission_version"], + "evaluation_request_id": identity["evaluation_request_id"], + "evaluation_request_digest": identity["evaluation_request_digest"], + "evaluation_generation": identity["evaluation_generation"], + "attempt_id": identity["attempt_id"], + "result_id": identity["result_id"], + "lease_id": identity["lease_id"], + "lease_generation": identity["lease_generation"], + "lease_expires_at": identity["lease_expires_at"], + "binding_id": material["binding_id"], + "content_id": material["content_id"], + "replica_id": material["replica_id"], + "content_sha256": material["content_sha256"], + "byte_count": material["byte_count"], + "evidence_id": material["evidence_id"], + "verification_receipt_id": material["verification_receipt_id"], + "verification_job_id": material["verification_job_id"], + "verification_generation": material["verification_generation"], + "namespace_fingerprint": material["namespace_fingerprint"], + "semantic_manifest_sha256": semantic, + "directories": [], + "files": [], + } + manifest["binding_digest"] = digest(manifest) + return request, manifest + + +def write_checker_sources(root: Path) -> tuple[Path, Path]: + client_source = root / "model-client.c" + client = root / "model-client" + client_source.write_text( + r"""#include +#include +#include +#include +#include +#include +#include +#include +#include + +static int transfer(int fd, void *buffer, size_t length, int writing) { + size_t offset = 0; + while (offset < length) { + ssize_t count = writing ? write(fd, (char *)buffer + offset, length - offset) + : read(fd, (char *)buffer + offset, length - offset); + if (count < 0 && errno == EINTR) continue; + if (count <= 0) return -1; + offset += (size_t)count; + } + return 0; +} + +int main(void) { + static const char body[] = "{\"protocol_version\":\"workstream.model_session.v2\",\"operation\":\"call\",\"call_index\":0,\"prompt\":\"docker relay proof\",\"required_receipt_handles\":[]}"; + struct sockaddr_un address = {0}; + int descriptor = socket(AF_UNIX, SOCK_STREAM, 0); + if (descriptor < 0) return 20; + address.sun_family = AF_UNIX; + if (strlen("/work/model.sock") >= sizeof(address.sun_path)) return 21; + strcpy(address.sun_path, "/work/model.sock"); + if (connect(descriptor, (struct sockaddr *)&address, sizeof(address)) != 0) return 22; + uint32_t length = htonl((uint32_t)(sizeof(body) - 1)); + if (transfer(descriptor, &length, sizeof(length), 1) || + transfer(descriptor, (void *)body, sizeof(body) - 1, 1)) return 23; + if (transfer(descriptor, &length, sizeof(length), 0)) return 24; + length = ntohl(length); + if (length == 0 || length > 1024 * 1024) return 25; + char *reply = malloc(length); + if (!reply || transfer(descriptor, reply, length, 0)) return 26; + if (write(STDOUT_FILENO, reply, length) != (ssize_t)length) return 27; + free(reply); + close(descriptor); + return 0; +} +""", + encoding="utf-8", + ) + subprocess.check_call( + ["cc", "-static", "-Os", "-s", str(client_source), "-o", str(client)], + timeout=60, + ) + client.chmod(0o555) + checker = root / "checker.sh" + expected_reply = canonical(MODEL_REPLY).decode() + checker.write_text( + f"""#!/bin/sh +set -eu +request="$(cat)" +case "$request" in + *'"model_grant"'*|*'"grant_id"'*|*'PROVIDER_SECRET_PROOF'*|*'model-proxy.sock'*) exit 40 ;; +esac +reply="$(/model-client)" +[ "$reply" = '{expected_reply}' ] || exit 41 +request_digest="$(printf %s "$request" | sed -n 's/.*"request_digest":"\\([^"]*\\)".*/\\1/p')" +registry_digest="$(printf %s "$request" | sed -n 's/.*"entry_digest":"\\([^"]*\\)".*/\\1/p')" +registry_id="$(printf %s "$request" | sed -n 's/.*"registry_entry_id":"\\([^"]*\\)".*/\\1/p')" +case "$request_digest:$registry_digest:$registry_id" in + sha256:????????????????????????????????????????????????????????????????:sha256:????????????????????????????????????????????????????????????????:?*) ;; + *) exit 42 ;; +esac +body="$(printf '{{\"findings\":[],\"infrastructure_failure_code\":null,\"outcome\":\"completed\",\"phase\":\"post_submit\",\"registry_entry_digest\":\"%s\",\"registry_entry_id\":\"%s\",\"request_digest\":\"%s\",\"schema_version\":\"external_checker_result.v1\",\"verdict\":\"passed\"}}' "$registry_digest" "$registry_id" "$request_digest")" +result_digest="$(printf %s "$body" | sha256sum | cut -d' ' -f1)" +printf '{{\"findings\":[],\"infrastructure_failure_code\":null,\"outcome\":\"completed\",\"phase\":\"post_submit\",\"registry_entry_digest\":\"%s\",\"registry_entry_id\":\"%s\",\"request_digest\":\"%s\",\"result_digest\":\"sha256:%s\",\"schema_version\":\"external_checker_result.v1\",\"verdict\":\"passed\"}}' "$registry_digest" "$registry_id" "$request_digest" "$result_digest" +""", + encoding="utf-8", + ) + checker.chmod(0o555) + return client, checker + + +def fake_proxy( + address: Path, expected_digest: str, result: dict, ready: threading.Event +) -> None: + try: + with socket.socket(socket.AF_UNIX) as listener: + listener.bind(str(address)) + address.chmod(0o600) + listener.listen(1) + ready.set() + listener.settimeout(45) + connection, _ = listener.accept() + with connection: + connection.settimeout(15) + request = json.loads(read_frame(connection)) + expected = { + "protocol_version": MODEL_PROTOCOL, + "grant_id": MODEL_GRANT_ID, + "request_digest": expected_digest, + "call_index": 0, + "prompt": PROMPT, + "resolved_receipts": [], + } + if request != expected: + raise RuntimeError("proxy received a different closed request") + result["request"] = request + write_frame(connection, canonical(MODEL_REPLY)) + except BaseException as error: + result["error"] = error + ready.set() + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--service", required=True, type=Path) + parser.add_argument("--base-image", default="alpine:3.20") + parser.add_argument("--registry-image", default="registry:2") + args = parser.parse_args() + service = args.service.resolve(strict=True) + docker = str(Path(shutil.which("docker") or "/usr/bin/docker").resolve(strict=True)) + uid, gid = os.geteuid(), os.getegid() + if uid == 0 or gid == 0: + raise RuntimeError( + "probe requires the same non-root UID/GID used by the service" + ) + + # These inspect calls must succeed locally. The probe never pulls either image. + run(docker, "image", "inspect", args.base_image) + run(docker, "image", "inspect", args.registry_image) + nonce = os.urandom(8).hex() + build_name = f"ws-model-relay-build-{nonce}" + local_tag = f"ws-model-relay-proof:{nonce}" + registry_tag: str | None = None + created_image_id: str | None = None + containers: list[str] = [] + container_names = (build_name,) + checker_id: str | None = None + inspection_path: Path | None = None + service_process: subprocess.Popen | None = None + registry_process: subprocess.Popen | None = None + + temporary_parent = os.environ.get("TMPDIR") + with tempfile.TemporaryDirectory( + prefix="ws-model-relay-", dir=temporary_parent + ) as temporary: + root = Path(temporary).resolve() + try: + client, checker = write_checker_sources(root) + build_id = run(docker, "create", "--name", build_name, args.base_image) + containers.append(build_id) + run(docker, "cp", str(client), f"{build_id}:/model-client") + run(docker, "cp", str(checker), f"{build_id}:/checker.sh") + created_image_id = run( + docker, + "commit", + "--change", + 'ENTRYPOINT ["/checker.sh"]', + "--change", + "CMD []", + build_id, + local_tag, + timeout=120, + ) + run(docker, "rm", build_id) + containers.remove(build_id) + + registry_binary = extract_registry_binary(docker, args.registry_image, root) + registry_process, port = start_registry(registry_binary, root) + repository = f"127.0.0.1:{port}/ws-model-relay-proof" + registry_tag = f"{repository}:{nonce}" + run(docker, "tag", local_tag, registry_tag) + run(docker, "push", registry_tag, timeout=120) + image = json.loads(run(docker, "image", "inspect", registry_tag))[0] + reference = next( + item + for item in image["RepoDigests"] + if item.startswith(repository + "@sha256:") + ) + platform_digest = reference.split("@", 1)[1] + + request, manifest = request_and_manifest(platform_digest) + socket_root = root / "service" + material_root = root / "material" + proxy_root = root / "proxy" + relay_root = root / "relay" + for directory in (socket_root, material_root, proxy_root, relay_root): + directory.mkdir(mode=0o700) + workspaces = material_root / "workspaces" + grant_root = workspaces / MATERIAL_GRANT_ID + workspace = grant_root / "workspace" + workspaces.mkdir(mode=0o700) + grant_root.mkdir(mode=0o700) + workspace.mkdir(mode=0o500) + manifest_path = grant_root / ".external-checker-grant.json" + manifest_path.write_bytes(canonical(manifest)) + manifest_path.chmod(0o400) + + proxy_address = proxy_root / "model-proxy.sock" + proxy_result: dict[str, object] = {} + proxy_ready = threading.Event() + proxy_thread = threading.Thread( + target=fake_proxy, + args=( + proxy_address, + request["request_digest"], + proxy_result, + proxy_ready, + ), + daemon=True, + ) + proxy_thread.start() + if not proxy_ready.wait(5) or "error" in proxy_result: + raise RuntimeError( + f"fake proxy startup failed: {proxy_result.get('error')}" + ) + + inspection_path = root / "container-inspection.json" + wrapper = root / "docker-wrapper.py" + wrapper.write_text( + "#!/usr/bin/python3\n" + "import json, pathlib, subprocess, sys\n" + f"docker={docker!r}\n" + f"proof=pathlib.Path({str(inspection_path)!r})\n" + "result=subprocess.run([docker,*sys.argv[1:]], stdin=sys.stdin.buffer, " + "stdout=subprocess.PIPE, stderr=subprocess.PIPE)\n" + "if result.returncode == 0 and len(sys.argv)>1 and sys.argv[1]=='create':\n" + " identity=result.stdout.decode().strip()\n" + " inspected=subprocess.check_output([docker,'container','inspect',identity])\n" + " proof.write_bytes(inspected)\n" + "sys.stdout.buffer.write(result.stdout)\n" + "sys.stderr.buffer.write(result.stderr)\n" + "raise SystemExit(result.returncode)\n", + encoding="utf-8", + ) + wrapper.chmod(0o700) + config = { + "socket_path": str(socket_root / "checker.sock"), + "model_proxy_socket": str(proxy_address), + "model_relay_root": str(relay_root), + "material_root": str(material_root), + "docker_binary": str(wrapper), + "runtime": "runc", + "isolation_mode": "docker-dev", + "operating_system": image["Os"], + "architecture": image["Architecture"], + "sandbox_uid": uid, + "sandbox_gid": gid, + "cache": [ + { + "repository": repository, + "platform_manifest_digest": platform_digest, + "platform_manifest_media_type": "application/vnd.docker.distribution.manifest.v2+json", + "platform_manifest_byte_count": 1, + "operating_system": image["Os"], + "architecture": image["Architecture"], + "config_image_id": image["Id"], + } + ], + } + config_path = root / "service.json" + config_path.write_bytes(canonical(config)) + environment = dict(os.environ) + provider_secret = "provider-secret-proof-" + nonce + environment["PROVIDER_SECRET_PROOF"] = provider_secret + service_process = subprocess.Popen( + [str(service), str(config_path)], + stdout=subprocess.DEVNULL, + stderr=subprocess.PIPE, + text=True, + env=environment, + ) + wait_for_service(service_process, socket_root / "checker.sock") + envelope = { + "protocol_version": PROTOCOL, + "operation": "execute", + "request": request, + "grant": { + "grant_id": MATERIAL_GRANT_ID, + "binding_digest": manifest["binding_digest"], + }, + "model_grant": { + "grant_id": MODEL_GRANT_ID, + "binding_digest": request["request_digest"], + }, + } + with socket.socket(socket.AF_UNIX) as connection: + connection.settimeout(45) + connection.connect(str(socket_root / "checker.sock")) + write_frame(connection, canonical(envelope)) + response = json.loads(read_frame(connection, 96 * 1024)) + + proxy_thread.join(timeout=5) + if proxy_thread.is_alive() or "error" in proxy_result: + raise RuntimeError( + f"fake proxy exchange failed: {proxy_result.get('error')}" + ) + if ( + response["result"]["outcome"] != "completed" + or response["result"]["verdict"] != "passed" + ): + raise RuntimeError(f"checker execution failed: {response['result']}") + if response["isolation"]["isolation_mode"] != "docker-dev": + raise RuntimeError("missing Docker-dev isolation receipt") + + inspected = json.loads(inspection_path.read_text())[0] + checker_id = inspected["Id"] + mounts = {item["Destination"]: item for item in inspected["Mounts"]} + material_mount = mounts["/work/input"] + model_mount = mounts["/work/model.sock"] + if inspected["HostConfig"]["NetworkMode"] != "none": + raise RuntimeError("checker network was not disabled") + if inspected["HostConfig"]["ReadonlyRootfs"] is not True: + raise RuntimeError("checker root filesystem was writable") + if material_mount["RW"] or material_mount["Source"] != str(workspace): + raise RuntimeError("checker material mount was not exact and read-only") + model_source = Path(model_mount["Source"]) + if model_mount["RW"] or model_source.name != "model.sock": + raise RuntimeError("checker model socket mount was not read-only") + if ( + model_source.parent.parent != relay_root + or not model_source.parent.name.startswith(".model-") + ): + raise RuntimeError( + "checker received a non-invocation-local model socket" + ) + serialized_inspection = canonical(inspected).decode() + for forbidden in (str(proxy_address), MODEL_GRANT_ID, provider_secret): + if forbidden in serialized_inspection: + raise RuntimeError( + "private proxy material reached the checker container" + ) + if inspect_container(docker, checker_id) is not None: + raise RuntimeError("checker container was not cleaned") + if any(relay_root.iterdir()): + raise RuntimeError("invocation relay path was not cleaned") + print( + json.dumps( + { + "status": "PASS", + "runtime": "docker-dev/runc", + "network_mode": "none", + "rootfs_read_only": True, + "material_read_only": True, + "invocation_socket_read_only": True, + "proxy_request_closed": proxy_result["request"] + == { + "protocol_version": MODEL_PROTOCOL, + "grant_id": MODEL_GRANT_ID, + "request_digest": request["request_digest"], + "call_index": 0, + "prompt": PROMPT, + "resolved_receipts": [], + }, + "reply": MODEL_REPLY["output"], + "checker_container_removed": True, + "relay_directory_empty": True, + "gvisor_qualified": False, + }, + sort_keys=True, + ) + ) + finally: + if service_process is not None: + if service_process.poll() is None: + service_process.terminate() + try: + service_process.wait(timeout=10) + except subprocess.TimeoutExpired: + service_process.kill() + service_process.wait(timeout=10) + service_process.stderr.close() + if registry_process is not None and registry_process.poll() is None: + registry_process.terminate() + try: + registry_process.wait(timeout=10) + except subprocess.TimeoutExpired: + registry_process.kill() + registry_process.wait(timeout=10) + if ( + checker_id is None + and inspection_path is not None + and inspection_path.exists() + ): + try: + checker_id = json.loads(inspection_path.read_text())[0]["Id"] + except (KeyError, IndexError, TypeError, ValueError): + checker_id = None + for identity in containers: + if inspect_container(docker, identity) is not None: + run(docker, "rm", "--force", identity) + for name in container_names: + if inspect_container(docker, name) is not None: + run(docker, "rm", "--force", name) + if ( + checker_id is not None + and inspect_container(docker, checker_id) is not None + ): + run(docker, "rm", "--force", checker_id) + for reference in (registry_tag, local_tag): + if reference is not None: + try: + subprocess.run( + [docker, "image", "rm", "--force", reference], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + timeout=30, + ) + except subprocess.TimeoutExpired: + pass + if created_image_id is not None: + try: + subprocess.run( + [docker, "image", "rm", "--force", created_image_id], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + timeout=30, + ) + except subprocess.TimeoutExpired: + pass + remaining = [ + identity + for identity in (*containers, *container_names) + if inspect_container(docker, identity) + ] + if ( + checker_id is not None + and inspect_container(docker, checker_id) is not None + ): + remaining.append(checker_id) + retained_images = [] + for reference in (registry_tag, local_tag, created_image_id): + if reference is None: + continue + result = subprocess.run( + [docker, "image", "inspect", reference], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + timeout=10, + ) + if result.returncode == 0: + retained_images.append(reference) + if remaining or retained_images: + raise RuntimeError( + "probe cleanup incomplete: " + f"containers={remaining}; images={retained_images}" + ) + + +if __name__ == "__main__": + main() diff --git a/mcp_server/contracts/admin_roles_list.json b/mcp_server/contracts/admin_roles_list.json index 8ea70e91b..18dde776b 100644 --- a/mcp_server/contracts/admin_roles_list.json +++ b/mcp_server/contracts/admin_roles_list.json @@ -1,5 +1,5 @@ { - "canonical_sha256": "17732f8c534e37651c1dfa36625e171f67aa482a6739570e17d3341b1cf44192", + "canonical_sha256": "e73bd0a8756d4d39a68cb132ab491691f160115a237b17d097efb7dfe877c2c4", "components": { "schemas": { "AdminRole": { @@ -156,6 +156,19 @@ "checker.pre_submit.finalize", "checker.post_submit.execute", "checker.post_submit.finalize", + "checker.model_call.prepare", + "checker.model_call.dispatch", + "checker.model_call.recover", + "checker.model_call.observe", + "checker.model_call.complete", + "checker.model_call.grant", + "project.guide_compilation.model.prepare", + "project.guide_compilation.model.dispatch", + "project.guide_compilation.model.observe", + "project.guide_compilation.model.complete", + "project.model_profile.manage", + "project.model_budget.manage", + "project.model_call.recovery_child.authorize", "outbox.dispatch", "actor.service.provision", "admin_role.read", @@ -430,7 +443,7 @@ "schema_version": 1, "source": { "action_id": "authorization.admin_role_definitions.read", - "backend_git_sha": "7326dc95e59f59fc9f6dee438857091e73c370aa", + "backend_git_sha": "3a1d2b53062c534d199f7d7087832b66d2c0b72a", "method": "GET", "openapi_version": "3.1.0", "path": "/api/v1/authorization/admin-role-definitions" diff --git a/mcp_server/contracts/authorization_context_get.json b/mcp_server/contracts/authorization_context_get.json index 38f175aac..17d13ddb5 100644 --- a/mcp_server/contracts/authorization_context_get.json +++ b/mcp_server/contracts/authorization_context_get.json @@ -1,9 +1,9 @@ { - "canonical_sha256": "068aa5a75c3e505eca21e1dc08b10e4fc5e6f02e43adce7765e9a7ae692c8d36", + "canonical_sha256": "60e4a118da27aff0fb92147162e5c5588e8e924673841e2a03893d29f68460e3", "components": { "schemas": { "ActionId": { - "type": "string", + "description": "Closed action identifiers reserved by approved owner chunks.", "enum": [ "task.submission.list", "submission.read", @@ -59,6 +59,20 @@ "checker.pre_submit.finalize", "checker.post_submit.execute", "checker.post_submit.finalize", + "checker.model_call.prepare", + "checker.model_call.dispatch", + "checker.model_call.recover", + "checker.model_call.observe", + "checker.model_call.complete", + "checker.model_call.grant", + "project.guide_compilation.model.prepare", + "project.guide_compilation.model.dispatch", + "project.guide_compilation.model.observe", + "project.guide_compilation.model.complete", + "project.model_profile.provision", + "project.model_budget.configure", + "project.model_usage.read", + "project.model_call.recovery_child.authorize", "outbox.dispatch", "actor.service.provision", "project.contributor_candidate.list", @@ -67,6 +81,7 @@ "project_role_grant.issue", "project_role_grant.revoke", "project.read", + "project.catalogue.list", "actor.authorization_context.read", "project.setup_run.read", "project.guide_sufficiency_report.list", @@ -167,7 +182,6 @@ "contribution.read_self", "contribution.read_project", "actor.project.list", - "project.catalogue.list", "actor.assignment.list", "actor.submission.list", "actor.work_summary.read", @@ -185,57 +199,57 @@ "contribution.policy.list" ], "title": "ActionId", - "description": "Closed action identifiers reserved by approved owner chunks." + "type": "string" }, "ActorAuthorizationContextResponse": { + "additionalProperties": false, + "description": "Self-only effective authority projected onto one canonical project.", "properties": { "actor_profile_id": { - "type": "string", - "format": "uuid", - "title": "Actor Profile Id" - }, - "status": { - "type": "string", - "enum": [ - "active", - "suspended", - "deactivated" - ], - "title": "Status" - }, - "project_id": { - "type": "string", "format": "uuid", - "title": "Project Id" + "title": "Actor Profile Id", + "type": "string" }, "admin_roles": { "items": { "type": "string" }, - "type": "array", - "title": "Admin Roles" + "title": "Admin Roles", + "type": "array" + }, + "effective_action_ids": { + "items": { + "$ref": "#/components/schemas/ActionId" + }, + "title": "Effective Action Ids", + "type": "array" + }, + "project_id": { + "format": "uuid", + "title": "Project Id", + "type": "string" }, "project_roles": { "items": { - "type": "string", "enum": [ "submitter", "reviewer" - ] + ], + "type": "string" }, - "type": "array", - "title": "Project Roles" + "title": "Project Roles", + "type": "array" }, - "effective_action_ids": { - "items": { - "$ref": "#/components/schemas/ActionId" - }, - "type": "array", - "title": "Effective Action Ids" + "status": { + "enum": [ + "active", + "suspended", + "deactivated" + ], + "title": "Status", + "type": "string" } }, - "additionalProperties": false, - "type": "object", "required": [ "actor_profile_id", "status", @@ -245,34 +259,34 @@ "effective_action_ids" ], "title": "ActorAuthorizationContextResponse", - "description": "Self-only effective authority projected onto one canonical project." + "type": "object" }, "ApiError": { + "additionalProperties": false, + "description": "Canonical machine-readable API error.", "properties": { "code": { - "type": "string", - "title": "Code" + "title": "Code", + "type": "string" }, - "message": { - "type": "string", - "title": "Message" + "correlation_id": { + "title": "Correlation Id", + "type": "string" }, "details": { "additionalProperties": true, - "type": "object", - "title": "Details" + "title": "Details", + "type": "object" }, - "correlation_id": { - "type": "string", - "title": "Correlation Id" + "message": { + "title": "Message", + "type": "string" }, "retryable": { - "type": "boolean", - "title": "Retryable" + "title": "Retryable", + "type": "boolean" } }, - "additionalProperties": false, - "type": "object", "required": [ "code", "message", @@ -281,13 +295,12 @@ "retryable" ], "title": "ApiError", - "description": "Canonical machine-readable API error." + "type": "object" }, "ApiErrorResponse": { + "additionalProperties": false, + "description": "Canonical error plus additive legacy compatibility fields.", "properties": { - "error": { - "$ref": "#/components/schemas/ApiError" - }, "detail": { "anyOf": [ {}, @@ -296,88 +309,79 @@ } ], "title": "Detail" + }, + "error": { + "$ref": "#/components/schemas/ApiError" } }, - "additionalProperties": false, - "type": "object", "required": [ "error" ], "title": "ApiErrorResponse", - "description": "Canonical error plus additive legacy compatibility fields." + "type": "object" }, "HTTPValidationError": { - "type": "object", - "required": [ - "detail", - "error" - ], + "additionalProperties": false, "properties": { "detail": { - "type": "array", "items": { "type": "object" - } + }, + "type": "array" }, "error": { "$ref": "#/components/schemas/ApiError" } }, - "additionalProperties": false + "required": [ + "detail", + "error" + ], + "type": "object" } } }, "operation": { - "tags": [ - "actors" - ], - "summary": "Read Current Actor Authorization Context", "description": "Return the caller's effective authority for one canonical project.", "operationId": "read_current_actor_authorization_context_api_v1_actors_me_authorization_context_get", - "security": [ - { - "HTTPBearer": [] - } - ], "parameters": [ { - "name": "project_id", "in": "query", + "name": "project_id", "required": true, "schema": { - "type": "string", - "minLength": 1, "maxLength": 100, + "minLength": 1, "pattern": "^[^\\x00]*$", - "title": "Project Id" + "title": "Project Id", + "type": "string" } }, { - "name": "X-Request-ID", "in": "header", + "name": "X-Request-ID", "required": false, "schema": { - "type": "string", "format": "uuid", + "maxLength": 36, "minLength": 36, - "maxLength": 36 + "type": "string" } }, { - "name": "X-Correlation-ID", "in": "header", + "name": "X-Correlation-ID", "required": false, "schema": { - "type": "string", "format": "uuid", + "maxLength": 36, "minLength": 36, - "maxLength": 36 + "type": "string" } } ], "responses": { "200": { - "description": "Successful Response", "content": { "application/json": { "schema": { @@ -385,185 +389,195 @@ } } }, + "description": "Successful Response", "headers": { - "X-Request-ID": { + "X-Correlation-ID": { "required": true, "schema": { - "type": "string", - "format": "uuid" + "format": "uuid", + "type": "string" } }, - "X-Correlation-ID": { + "X-Request-ID": { "required": true, "schema": { - "type": "string", - "format": "uuid" + "format": "uuid", + "type": "string" } } } }, "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + }, "description": "Invalid request.", "headers": { - "X-Request-ID": { + "X-Correlation-ID": { "required": true, "schema": { - "type": "string", - "format": "uuid" + "format": "uuid", + "type": "string" } }, - "X-Correlation-ID": { + "X-Request-ID": { "required": true, "schema": { - "type": "string", - "format": "uuid" + "format": "uuid", + "type": "string" } } - }, + } + }, + "401": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ApiErrorResponse" } } - } - }, - "500": { - "description": "Internal server error.", + }, + "description": "Authentication failed.", "headers": { - "X-Request-ID": { - "required": true, + "WWW-Authenticate": { "schema": { - "type": "string", - "format": "uuid" + "type": "string" } }, "X-Correlation-ID": { "required": true, "schema": { - "type": "string", - "format": "uuid" + "format": "uuid", + "type": "string" } - } - }, - "content": { - "application/json": { + }, + "X-Request-ID": { + "required": true, "schema": { - "$ref": "#/components/schemas/ApiErrorResponse" + "format": "uuid", + "type": "string" } } } }, - "422": { - "description": "Validation Error", + "403": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HTTPValidationError" + "$ref": "#/components/schemas/ApiErrorResponse" } } }, + "description": "Permission denied.", "headers": { - "X-Request-ID": { + "X-Correlation-ID": { "required": true, "schema": { - "type": "string", - "format": "uuid" + "format": "uuid", + "type": "string" } }, - "X-Correlation-ID": { + "X-Request-ID": { "required": true, "schema": { - "type": "string", - "format": "uuid" + "format": "uuid", + "type": "string" } } } }, - "401": { - "description": "Authentication failed.", - "headers": { - "X-Request-ID": { - "required": true, + "422": { + "content": { + "application/json": { "schema": { - "type": "string", - "format": "uuid" + "$ref": "#/components/schemas/HTTPValidationError" } - }, + } + }, + "description": "Validation Error", + "headers": { "X-Correlation-ID": { "required": true, "schema": { - "type": "string", - "format": "uuid" + "format": "uuid", + "type": "string" } }, - "WWW-Authenticate": { + "X-Request-ID": { + "required": true, "schema": { + "format": "uuid", "type": "string" } } - }, + } + }, + "500": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ApiErrorResponse" } } - } - }, - "403": { - "description": "Permission denied.", + }, + "description": "Internal server error.", "headers": { - "X-Request-ID": { + "X-Correlation-ID": { "required": true, "schema": { - "type": "string", - "format": "uuid" + "format": "uuid", + "type": "string" } }, - "X-Correlation-ID": { + "X-Request-ID": { "required": true, "schema": { - "type": "string", - "format": "uuid" + "format": "uuid", + "type": "string" } } - }, + } + }, + "503": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ApiErrorResponse" } } - } - }, - "503": { + }, "description": "Identity verification unavailable.", "headers": { - "X-Request-ID": { + "X-Correlation-ID": { "required": true, "schema": { - "type": "string", - "format": "uuid" + "format": "uuid", + "type": "string" } }, - "X-Correlation-ID": { + "X-Request-ID": { "required": true, "schema": { - "type": "string", - "format": "uuid" - } - } - }, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ApiErrorResponse" + "format": "uuid", + "type": "string" } } } } }, + "security": [ + { + "HTTPBearer": [] + } + ], + "summary": "Read Current Actor Authorization Context", + "tags": [ + "actors" + ], "x-workstream-action-id": "actor.authorization_context.read" }, "schema_version": 1, diff --git a/mcp_server/contracts/permissions_list.json b/mcp_server/contracts/permissions_list.json index 7dc5bd378..bfe560844 100644 --- a/mcp_server/contracts/permissions_list.json +++ b/mcp_server/contracts/permissions_list.json @@ -1,5 +1,5 @@ { - "canonical_sha256": "ce2d08c29f50493ccdb80798db1f65c9690dcd614304c8bdbef80a12c2268e37", + "canonical_sha256": "a72e8ae2d0139701a2cb3a2be6e3b0a9888c023e2ff21fbcbb97498e0db8a366", "components": { "schemas": { "ApiError": { @@ -87,7 +87,7 @@ "type": "array" }, "total": { - "const": 82, + "const": 95, "title": "Total", "type": "integer" } @@ -119,6 +119,19 @@ "checker.pre_submit.finalize", "checker.post_submit.execute", "checker.post_submit.finalize", + "checker.model_call.prepare", + "checker.model_call.dispatch", + "checker.model_call.recover", + "checker.model_call.observe", + "checker.model_call.complete", + "checker.model_call.grant", + "project.guide_compilation.model.prepare", + "project.guide_compilation.model.dispatch", + "project.guide_compilation.model.observe", + "project.guide_compilation.model.complete", + "project.model_profile.manage", + "project.model_budget.manage", + "project.model_call.recovery_child.authorize", "outbox.dispatch", "actor.service.provision", "admin_role.read", @@ -393,7 +406,7 @@ "schema_version": 1, "source": { "action_id": "authorization.permission_catalogue.read", - "backend_git_sha": "7326dc95e59f59fc9f6dee438857091e73c370aa", + "backend_git_sha": "3a1d2b53062c534d199f7d7087832b66d2c0b72a", "method": "GET", "openapi_version": "3.1.0", "path": "/api/v1/authorization/permissions" diff --git a/mcp_server/contracts/profile_get.json b/mcp_server/contracts/profile_get.json index f71a40db4..d5a089ce5 100644 --- a/mcp_server/contracts/profile_get.json +++ b/mcp_server/contracts/profile_get.json @@ -3,53 +3,27 @@ "components": { "schemas": { "ActorProfileSelfResponse": { + "additionalProperties": false, + "description": "Privacy-bounded canonical profile returned to its human owner.", "properties": { - "actor_profile_id": { - "type": "string", - "title": "Actor Profile Id" - }, "actor_kind": { - "type": "string", "const": "human", - "title": "Actor Kind" - }, - "status": { - "type": "string", - "enum": [ - "active", - "suspended", - "deactivated" - ], - "title": "Status" + "title": "Actor Kind", + "type": "string" }, - "domains": { - "items": { - "type": "string", - "const": "contributor" - }, - "type": "array", - "title": "Domains", - "default": [ - "contributor" - ] + "actor_profile_id": { + "title": "Actor Profile Id", + "type": "string" }, "admin_roles": { + "default": [], "items": { "type": "string" }, - "type": "array", "title": "Admin Roles", - "default": [] - }, - "project_role_grants": { - "items": { - "type": "string" - }, - "type": "array", - "title": "Project Role Grants", - "default": [] + "type": "array" }, - "display_name": { + "contact_email": { "anyOf": [ { "type": "string" @@ -58,9 +32,14 @@ "type": "null" } ], - "title": "Display Name" + "title": "Contact Email" }, - "contact_email": { + "created_at": { + "format": "date-time", + "title": "Created At", + "type": "string" + }, + "display_name": { "anyOf": [ { "type": "string" @@ -69,33 +48,54 @@ "type": "null" } ], - "title": "Contact Email" - }, - "created_at": { - "type": "string", - "format": "date-time", - "title": "Created At" + "title": "Display Name" }, - "updated_at": { - "type": "string", - "format": "date-time", - "title": "Updated At" + "domains": { + "default": [ + "contributor" + ], + "items": { + "const": "contributor", + "type": "string" + }, + "title": "Domains", + "type": "array" }, "last_seen_at": { "anyOf": [ { - "type": "string", - "format": "date-time" + "format": "date-time", + "type": "string" }, { "type": "null" } ], "title": "Last Seen At" + }, + "project_role_grants": { + "default": [], + "items": { + "type": "string" + }, + "title": "Project Role Grants", + "type": "array" + }, + "status": { + "enum": [ + "active", + "suspended", + "deactivated" + ], + "title": "Status", + "type": "string" + }, + "updated_at": { + "format": "date-time", + "title": "Updated At", + "type": "string" } }, - "additionalProperties": false, - "type": "object", "required": [ "actor_profile_id", "actor_kind", @@ -107,34 +107,34 @@ "last_seen_at" ], "title": "ActorProfileSelfResponse", - "description": "Privacy-bounded canonical profile returned to its human owner." + "type": "object" }, "ApiError": { + "additionalProperties": false, + "description": "Canonical machine-readable API error.", "properties": { "code": { - "type": "string", - "title": "Code" + "title": "Code", + "type": "string" }, - "message": { - "type": "string", - "title": "Message" + "correlation_id": { + "title": "Correlation Id", + "type": "string" }, "details": { "additionalProperties": true, - "type": "object", - "title": "Details" + "title": "Details", + "type": "object" }, - "correlation_id": { - "type": "string", - "title": "Correlation Id" + "message": { + "title": "Message", + "type": "string" }, "retryable": { - "type": "boolean", - "title": "Retryable" + "title": "Retryable", + "type": "boolean" } }, - "additionalProperties": false, - "type": "object", "required": [ "code", "message", @@ -143,13 +143,12 @@ "retryable" ], "title": "ApiError", - "description": "Canonical machine-readable API error." + "type": "object" }, "ApiErrorResponse": { + "additionalProperties": false, + "description": "Canonical error plus additive legacy compatibility fields.", "properties": { - "error": { - "$ref": "#/components/schemas/ApiError" - }, "detail": { "anyOf": [ {}, @@ -158,28 +157,48 @@ } ], "title": "Detail" + }, + "error": { + "$ref": "#/components/schemas/ApiError" } }, - "additionalProperties": false, - "type": "object", "required": [ "error" ], "title": "ApiErrorResponse", - "description": "Canonical error plus additive legacy compatibility fields." + "type": "object" } } }, "operation": { - "tags": [ - "actors" - ], - "summary": "Read Current Actor Profile", "description": "Return the caller's canonical Contributor-domain profile.", "operationId": "read_current_actor_profile_api_v1_actors_me_get", + "parameters": [ + { + "in": "header", + "name": "X-Request-ID", + "required": false, + "schema": { + "format": "uuid", + "maxLength": 36, + "minLength": 36, + "type": "string" + } + }, + { + "in": "header", + "name": "X-Correlation-ID", + "required": false, + "schema": { + "format": "uuid", + "maxLength": 36, + "minLength": 36, + "type": "string" + } + } + ], "responses": { "200": { - "description": "Successful Response", "content": { "application/json": { "schema": { @@ -187,156 +206,157 @@ } } }, + "description": "Successful Response", "headers": { - "X-Request-ID": { + "X-Correlation-ID": { "required": true, "schema": { - "type": "string", - "format": "uuid" + "format": "uuid", + "type": "string" } }, - "X-Correlation-ID": { + "X-Request-ID": { "required": true, "schema": { - "type": "string", - "format": "uuid" + "format": "uuid", + "type": "string" } } } }, "400": { - "description": "Invalid request.", - "headers": { - "X-Request-ID": { - "required": true, - "schema": { - "type": "string", - "format": "uuid" - } - }, - "X-Correlation-ID": { - "required": true, - "schema": { - "type": "string", - "format": "uuid" - } - } - }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ApiErrorResponse" } } - } - }, - "500": { - "description": "Internal server error.", + }, + "description": "Invalid request.", "headers": { - "X-Request-ID": { + "X-Correlation-ID": { "required": true, "schema": { - "type": "string", - "format": "uuid" + "format": "uuid", + "type": "string" } }, - "X-Correlation-ID": { + "X-Request-ID": { "required": true, "schema": { - "type": "string", - "format": "uuid" + "format": "uuid", + "type": "string" } } - }, + } + }, + "401": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ApiErrorResponse" } } - } - }, - "401": { + }, "description": "Authentication failed.", "headers": { - "X-Request-ID": { - "required": true, + "WWW-Authenticate": { "schema": { - "type": "string", - "format": "uuid" + "type": "string" } }, "X-Correlation-ID": { "required": true, "schema": { - "type": "string", - "format": "uuid" + "format": "uuid", + "type": "string" } }, - "WWW-Authenticate": { + "X-Request-ID": { + "required": true, "schema": { + "format": "uuid", "type": "string" } } - }, + } + }, + "403": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ApiErrorResponse" } } - } - }, - "403": { + }, "description": "Permission denied.", "headers": { - "X-Request-ID": { + "X-Correlation-ID": { "required": true, "schema": { - "type": "string", - "format": "uuid" + "format": "uuid", + "type": "string" } }, - "X-Correlation-ID": { + "X-Request-ID": { "required": true, "schema": { - "type": "string", - "format": "uuid" + "format": "uuid", + "type": "string" } } - }, + } + }, + "500": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ApiErrorResponse" } } - } - }, - "503": { - "description": "Identity verification unavailable.", + }, + "description": "Internal server error.", "headers": { - "X-Request-ID": { + "X-Correlation-ID": { "required": true, "schema": { - "type": "string", - "format": "uuid" + "format": "uuid", + "type": "string" } }, - "X-Correlation-ID": { + "X-Request-ID": { "required": true, "schema": { - "type": "string", - "format": "uuid" + "format": "uuid", + "type": "string" } } - }, + } + }, + "503": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ApiErrorResponse" } } + }, + "description": "Identity verification unavailable.", + "headers": { + "X-Correlation-ID": { + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + }, + "X-Request-ID": { + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + } } } }, @@ -345,31 +365,11 @@ "HTTPBearer": [] } ], - "x-workstream-action-id": "actor.profile.read_self", - "parameters": [ - { - "name": "X-Request-ID", - "in": "header", - "required": false, - "schema": { - "type": "string", - "format": "uuid", - "minLength": 36, - "maxLength": 36 - } - }, - { - "name": "X-Correlation-ID", - "in": "header", - "required": false, - "schema": { - "type": "string", - "format": "uuid", - "minLength": 36, - "maxLength": 36 - } - } - ] + "summary": "Read Current Actor Profile", + "tags": [ + "actors" + ], + "x-workstream-action-id": "actor.profile.read_self" }, "schema_version": 1, "source": { diff --git a/mcp_server/contracts/profile_update.json b/mcp_server/contracts/profile_update.json index c8d18e86e..740be1c6d 100644 --- a/mcp_server/contracts/profile_update.json +++ b/mcp_server/contracts/profile_update.json @@ -3,53 +3,27 @@ "components": { "schemas": { "ActorProfileSelfResponse": { + "additionalProperties": false, + "description": "Privacy-bounded canonical profile returned to its human owner.", "properties": { - "actor_profile_id": { - "type": "string", - "title": "Actor Profile Id" - }, "actor_kind": { - "type": "string", "const": "human", - "title": "Actor Kind" + "title": "Actor Kind", + "type": "string" }, - "status": { - "type": "string", - "enum": [ - "active", - "suspended", - "deactivated" - ], - "title": "Status" - }, - "domains": { - "items": { - "type": "string", - "const": "contributor" - }, - "type": "array", - "title": "Domains", - "default": [ - "contributor" - ] + "actor_profile_id": { + "title": "Actor Profile Id", + "type": "string" }, "admin_roles": { + "default": [], "items": { "type": "string" }, - "type": "array", "title": "Admin Roles", - "default": [] + "type": "array" }, - "project_role_grants": { - "items": { - "type": "string" - }, - "type": "array", - "title": "Project Role Grants", - "default": [] - }, - "display_name": { + "contact_email": { "anyOf": [ { "type": "string" @@ -58,9 +32,14 @@ "type": "null" } ], - "title": "Display Name" + "title": "Contact Email" }, - "contact_email": { + "created_at": { + "format": "date-time", + "title": "Created At", + "type": "string" + }, + "display_name": { "anyOf": [ { "type": "string" @@ -69,33 +48,54 @@ "type": "null" } ], - "title": "Contact Email" - }, - "created_at": { - "type": "string", - "format": "date-time", - "title": "Created At" + "title": "Display Name" }, - "updated_at": { - "type": "string", - "format": "date-time", - "title": "Updated At" + "domains": { + "default": [ + "contributor" + ], + "items": { + "const": "contributor", + "type": "string" + }, + "title": "Domains", + "type": "array" }, "last_seen_at": { "anyOf": [ { - "type": "string", - "format": "date-time" + "format": "date-time", + "type": "string" }, { "type": "null" } ], "title": "Last Seen At" + }, + "project_role_grants": { + "default": [], + "items": { + "type": "string" + }, + "title": "Project Role Grants", + "type": "array" + }, + "status": { + "enum": [ + "active", + "suspended", + "deactivated" + ], + "title": "Status", + "type": "string" + }, + "updated_at": { + "format": "date-time", + "title": "Updated At", + "type": "string" } }, - "additionalProperties": false, - "type": "object", "required": [ "actor_profile_id", "actor_kind", @@ -107,66 +107,66 @@ "last_seen_at" ], "title": "ActorProfileSelfResponse", - "description": "Privacy-bounded canonical profile returned to its human owner." + "type": "object" }, "ActorProfileUpdateRequest": { + "additionalProperties": false, + "description": "Human-owned display fields accepted by the canonical self API.", "properties": { - "display_name": { + "contact_email": { "anyOf": [ { - "type": "string", - "maxLength": 200 + "maxLength": 320, + "type": "string" }, { "type": "null" } ], - "title": "Display Name" + "title": "Contact Email" }, - "contact_email": { + "display_name": { "anyOf": [ { - "type": "string", - "maxLength": 320 + "maxLength": 200, + "type": "string" }, { "type": "null" } ], - "title": "Contact Email" + "title": "Display Name" } }, - "additionalProperties": false, - "type": "object", "title": "ActorProfileUpdateRequest", - "description": "Human-owned display fields accepted by the canonical self API." + "type": "object" }, "ApiError": { + "additionalProperties": false, + "description": "Canonical machine-readable API error.", "properties": { "code": { - "type": "string", - "title": "Code" + "title": "Code", + "type": "string" }, - "message": { - "type": "string", - "title": "Message" + "correlation_id": { + "title": "Correlation Id", + "type": "string" }, "details": { "additionalProperties": true, - "type": "object", - "title": "Details" + "title": "Details", + "type": "object" }, - "correlation_id": { - "type": "string", - "title": "Correlation Id" + "message": { + "title": "Message", + "type": "string" }, "retryable": { - "type": "boolean", - "title": "Retryable" + "title": "Retryable", + "type": "boolean" } }, - "additionalProperties": false, - "type": "object", "required": [ "code", "message", @@ -175,13 +175,12 @@ "retryable" ], "title": "ApiError", - "description": "Canonical machine-readable API error." + "type": "object" }, "ApiErrorResponse": { + "additionalProperties": false, + "description": "Canonical error plus additive legacy compatibility fields.", "properties": { - "error": { - "$ref": "#/components/schemas/ApiError" - }, "detail": { "anyOf": [ {}, @@ -190,44 +189,65 @@ } ], "title": "Detail" + }, + "error": { + "$ref": "#/components/schemas/ApiError" } }, - "additionalProperties": false, - "type": "object", "required": [ "error" ], "title": "ApiErrorResponse", - "description": "Canonical error plus additive legacy compatibility fields." + "type": "object" }, "HTTPValidationError": { - "type": "object", - "required": [ - "detail", - "error" - ], + "additionalProperties": false, "properties": { "detail": { - "type": "array", "items": { "type": "object" - } + }, + "type": "array" }, "error": { "$ref": "#/components/schemas/ApiError" } }, - "additionalProperties": false + "required": [ + "detail", + "error" + ], + "type": "object" } } }, "operation": { - "tags": [ - "actors" - ], - "summary": "Update Current Actor Profile", "description": "Update only the caller-owned canonical display fields.", "operationId": "update_current_actor_profile_api_v1_actors_me_patch", + "parameters": [ + { + "in": "header", + "name": "X-Request-ID", + "required": false, + "schema": { + "format": "uuid", + "maxLength": 36, + "minLength": 36, + "type": "string" + } + }, + { + "in": "header", + "name": "X-Correlation-ID", + "required": false, + "schema": { + "format": "uuid", + "maxLength": 36, + "minLength": 36, + "type": "string" + } + } + ], "requestBody": { "content": { "application/json": { @@ -240,7 +260,6 @@ }, "responses": { "200": { - "description": "Successful Response", "content": { "application/json": { "schema": { @@ -248,180 +267,181 @@ } } }, + "description": "Successful Response", "headers": { - "X-Request-ID": { + "X-Correlation-ID": { "required": true, "schema": { - "type": "string", - "format": "uuid" + "format": "uuid", + "type": "string" } }, - "X-Correlation-ID": { + "X-Request-ID": { "required": true, "schema": { - "type": "string", - "format": "uuid" + "format": "uuid", + "type": "string" } } } }, "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + }, "description": "Invalid request.", "headers": { - "X-Request-ID": { + "X-Correlation-ID": { "required": true, "schema": { - "type": "string", - "format": "uuid" + "format": "uuid", + "type": "string" } }, - "X-Correlation-ID": { + "X-Request-ID": { "required": true, "schema": { - "type": "string", - "format": "uuid" + "format": "uuid", + "type": "string" } } - }, + } + }, + "401": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ApiErrorResponse" } } - } - }, - "500": { - "description": "Internal server error.", + }, + "description": "Authentication failed.", "headers": { - "X-Request-ID": { - "required": true, + "WWW-Authenticate": { "schema": { - "type": "string", - "format": "uuid" + "type": "string" } }, "X-Correlation-ID": { "required": true, "schema": { - "type": "string", - "format": "uuid" + "format": "uuid", + "type": "string" } - } - }, - "content": { - "application/json": { + }, + "X-Request-ID": { + "required": true, "schema": { - "$ref": "#/components/schemas/ApiErrorResponse" + "format": "uuid", + "type": "string" } } } }, - "422": { - "description": "Validation Error", + "403": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HTTPValidationError" + "$ref": "#/components/schemas/ApiErrorResponse" } } }, + "description": "Permission denied.", "headers": { - "X-Request-ID": { + "X-Correlation-ID": { "required": true, "schema": { - "type": "string", - "format": "uuid" + "format": "uuid", + "type": "string" } }, - "X-Correlation-ID": { + "X-Request-ID": { "required": true, "schema": { - "type": "string", - "format": "uuid" + "format": "uuid", + "type": "string" } } } }, - "401": { - "description": "Authentication failed.", - "headers": { - "X-Request-ID": { - "required": true, + "422": { + "content": { + "application/json": { "schema": { - "type": "string", - "format": "uuid" + "$ref": "#/components/schemas/HTTPValidationError" } - }, + } + }, + "description": "Validation Error", + "headers": { "X-Correlation-ID": { "required": true, "schema": { - "type": "string", - "format": "uuid" + "format": "uuid", + "type": "string" } }, - "WWW-Authenticate": { + "X-Request-ID": { + "required": true, "schema": { + "format": "uuid", "type": "string" } } - }, + } + }, + "500": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ApiErrorResponse" } } - } - }, - "403": { - "description": "Permission denied.", + }, + "description": "Internal server error.", "headers": { - "X-Request-ID": { + "X-Correlation-ID": { "required": true, "schema": { - "type": "string", - "format": "uuid" + "format": "uuid", + "type": "string" } }, - "X-Correlation-ID": { + "X-Request-ID": { "required": true, "schema": { - "type": "string", - "format": "uuid" + "format": "uuid", + "type": "string" } } - }, + } + }, + "503": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ApiErrorResponse" } } - } - }, - "503": { + }, "description": "Identity verification unavailable.", "headers": { - "X-Request-ID": { + "X-Correlation-ID": { "required": true, "schema": { - "type": "string", - "format": "uuid" + "format": "uuid", + "type": "string" } }, - "X-Correlation-ID": { + "X-Request-ID": { "required": true, "schema": { - "type": "string", - "format": "uuid" - } - } - }, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ApiErrorResponse" + "format": "uuid", + "type": "string" } } } @@ -432,31 +452,11 @@ "HTTPBearer": [] } ], - "x-workstream-action-id": "actor.profile.update_self", - "parameters": [ - { - "name": "X-Request-ID", - "in": "header", - "required": false, - "schema": { - "type": "string", - "format": "uuid", - "minLength": 36, - "maxLength": 36 - } - }, - { - "name": "X-Correlation-ID", - "in": "header", - "required": false, - "schema": { - "type": "string", - "format": "uuid", - "minLength": 36, - "maxLength": 36 - } - } - ] + "summary": "Update Current Actor Profile", + "tags": [ + "actors" + ], + "x-workstream-action-id": "actor.profile.update_self" }, "schema_version": 1, "source": { diff --git a/mcp_server/tests/integration/test_access_reads.py b/mcp_server/tests/integration/test_access_reads.py index 8d89d44b6..df6da8101 100644 --- a/mcp_server/tests/integration/test_access_reads.py +++ b/mcp_server/tests/integration/test_access_reads.py @@ -202,10 +202,17 @@ async def _exercise_access_reads( if allowed and "items" in body: assert body["items"] and body["total"] >= len(body["items"]) if allowed and name == "workstream_permissions_list": - assert body["total"] == len(body["items"]) == 82 - assert {"task.revision.expire", "task.revision.recover"} <= { - item["permission_id"] for item in body["items"] - } + assert body["total"] == len(body["items"]) == 95 + assert { + "task.revision.expire", + "task.revision.recover", + "project.model_profile.manage", + "project.model_budget.manage", + "project.guide_compilation.model.prepare", + "project.guide_compilation.model.dispatch", + "project.guide_compilation.model.observe", + "project.guide_compilation.model.complete", + } <= {item["permission_id"] for item in body["items"]} if allowed and name == "workstream_actor_get": assert body["display_name"] == "Access read target" diff --git a/mcp_server/tests/test_access_reads.py b/mcp_server/tests/test_access_reads.py index 24415e98d..d05248aa2 100644 --- a/mcp_server/tests/test_access_reads.py +++ b/mcp_server/tests/test_access_reads.py @@ -52,7 +52,7 @@ def _arguments(name: str) -> dict[str, Any]: def _response(name: str) -> dict[str, Any]: # Representative entries use the backend's frozen catalogue totals. if name == "workstream_permissions_list": - return {"items": [{"permission_id": "actor.profile.read_any"}], "total": 82} + return {"items": [{"permission_id": "actor.profile.read_any"}], "total": 95} if name == "workstream_admin_roles_list": return { "items": [ @@ -219,7 +219,7 @@ def test_permission_catalogue_drift_still_fails_closed( {"permission_id": "task.revision.expire"}, {"permission_id": "task.revision.recover"}, ], - "total": 82, + "total": 95, } if malformation == "unknown_permission": payload["items"][0]["permission_id"] = "task.revision.unknown" diff --git a/pilots/terminal_systems/checkers/README.md b/pilots/terminal_systems/checkers/README.md index 8c7f71b49..1dff3fade 100644 --- a/pilots/terminal_systems/checkers/README.md +++ b/pilots/terminal_systems/checkers/README.md @@ -67,11 +67,13 @@ do not establish hosted gVisor isolation. The six model specifications and two execution specifications are not yet implemented. The user has resumed the minimal budgeted model runtime needed by -this project. It must preserve digest-pinned project images, exact locked -configuration and Workstream-owned lifecycle decisions. Credentials stay outside -the sandbox. Project-budget reservations, provider-call identity and uncertain -delivery recovery must prevent duplicate paid calls and distinguish infrastructure -failure from contributor failure. +this project. The generic runtime and its Operator provisioning/recovery controls +are described in [the model runtime runbook](../../../docs/operations_checker_models.md). +They preserve digest-pinned project images, exact locked configuration and +Workstream-owned lifecycle decisions. Credentials stay outside the sandbox. +The project still needs its review images, approved model profile selectors and +the authorized US$10 lifetime budget before live calls. Offline runtime proofs +do not establish this project's activation or live provider operation. Execution additionally needs a generic isolated build/trial capability and approved offline material. Author-evidence review needs authenticated trial diff --git a/scripts/test_hosted_release_config.py b/scripts/test_hosted_release_config.py index 434b09b16..0e4c9fff7 100644 --- a/scripts/test_hosted_release_config.py +++ b/scripts/test_hosted_release_config.py @@ -137,6 +137,7 @@ def test_valid_configuration_and_rendered_topology(self) -> None: "api", "worker", "beat", + "model-proxy", "guide-document-parser", "checker-service", *preflight.PROOF_SERVICES, @@ -151,6 +152,7 @@ def test_valid_configuration_and_rendered_topology(self) -> None: "api", "worker", "beat", + "model-proxy", "guide-document-parser", "checker-service", ): @@ -176,7 +178,13 @@ def test_valid_configuration_and_rendered_topology(self) -> None: key: self.environment[key] for key in preflight.PROJECT_AGENT_CONFIGURATION_KEYS } - for name in ("api", "worker", "beat", *preflight.PROOF_SERVICES): + for name in ( + "api", + "worker", + "beat", + "model-proxy", + *preflight.PROOF_SERVICES, + ): self.assertEqual( { key: services[name]["environment"][key] @@ -191,14 +199,25 @@ def test_valid_configuration_and_rendered_topology(self) -> None: self.assertIn("--pool=prefork", worker_command) health_commands = { name: " ".join(services[name]["healthcheck"]["test"]) - for name in ("api", "worker", "beat", "checker-service") + for name in ( + "api", + "worker", + "beat", + "model-proxy", + "checker-service", + ) } self.assertIn("/api/v1/health", health_commands["api"]) self.assertIn("/tmp/celery-worker.pid", health_commands["worker"]) self.assertIn("/tmp/celerybeat.pid", health_commands["beat"]) + self.assertIn( + "/var/lib/workstream/model-runtime/model.sock", + health_commands["model-proxy"], + ) self.assertIn(".Runtimes.runsc", health_commands["checker-service"]) self.assertEqual(services["api"]["stop_grace_period"], "30s") self.assertIn(services["worker"]["stop_grace_period"], {"7320s", "2h2m0s"}) + self.assertIn(services["model-proxy"]["stop_grace_period"], {"7320s", "2h2m0s"}) self.assertEqual(services["beat"]["stop_grace_period"], "30s") self.assertEqual(services["guide-document-parser"]["stop_grace_period"], "30s") self.assertEqual(services["api"]["ports"][0]["host_ip"], "127.0.0.1") @@ -273,7 +292,7 @@ def test_valid_configuration_and_rendered_topology(self) -> None: str(preflight.GUIDE_SOCKET_ROOT), preflight._service_mounts(services[name]), ) - for name in ("api", "worker"): + for name in ("api", "worker", "model-proxy"): mounts = preflight._service_mounts(services[name]) self.assertEqual( mounts[str(preflight.ROLES_ANYWHERE_CERTIFICATE_TARGET)]["source"], @@ -284,7 +303,25 @@ def test_valid_configuration_and_rendered_topology(self) -> None: ) beat_mounts = preflight._service_mounts(services["beat"]) self.assertEqual(beat_mounts, {}) + model_socket_root = str(preflight.MODEL_SOCKET_ROOT) + self.assertTrue(worker_mounts[model_socket_root]["read_only"]) + self.assertFalse( + preflight._service_mounts(services["model-proxy"])[model_socket_root].get( + "read_only", False + ) + ) + for name in ("api", "beat", *preflight.PROOF_SERVICES): + self.assertNotIn( + model_socket_root, + preflight._service_mounts(services[name]), + ) checker_mounts = preflight._service_mounts(services["checker-service"]) + self.assertTrue(checker_mounts[model_socket_root]["read_only"]) + relay_root = str(preflight.MODEL_RELAY_ROOT) + self.assertEqual(checker_mounts[relay_root]["source"], relay_root) + self.assertFalse(checker_mounts[relay_root].get("read_only", False)) + for name in ("api", "worker", "beat", "model-proxy", *preflight.PROOF_SERVICES): + self.assertNotIn(relay_root, preflight._service_mounts(services[name])) self.assertNotIn( str(preflight.ROLES_ANYWHERE_CERTIFICATE_TARGET), checker_mounts ) @@ -606,16 +643,20 @@ def test_shutdown_stops_producers_then_worker_then_parser_and_checker(self) -> N self.assertEqual(commands[0][-3:], ["stop", "beat", "api"]) self.assertEqual(commands[1][-4:], ["stop", "--timeout", "7320", "worker"]) self.assertEqual( - commands[2][-3:], + commands[2][-4:], + ["stop", "--timeout", "7320", "model-proxy"], + ) + self.assertEqual( + commands[3][-3:], ["stop", "guide-document-parser", "checker-service"], ) def test_shutdown_failure_keeps_later_dependencies_running(self) -> None: - for failed_phase in (0, 1): + for failed_phase in (0, 1, 2): with self.subTest(failed_phase=failed_phase): results = [ subprocess.CompletedProcess([], 17 if index == failed_phase else 0) - for index in range(3) + for index in range(4) ] with patch.object( preflight.subprocess, "run", side_effect=results @@ -908,8 +949,10 @@ def test_environment_accepts_only_registered_runtime_provider_tuples(self) -> No "WORKSTREAM_PROJECT_AGENT_MODEL_API": "none", "WORKSTREAM_PROJECT_AGENT_MODEL": "none", "WORKSTREAM_PROJECT_AGENT_RUNTIME_OPTIONS": "{}", + "WORKSTREAM_MODEL_PROXY_ENABLED": "false", } - manager.pop("WORKSTREAM_PROJECT_AGENT_API_KEY_FILE") + for key in preflight.MODEL_PROXY_SECRET_FILE_KEYS: + manager.pop(key) preflight.validate_environment(manager) for changed in ( omni | {"WORKSTREAM_PROJECT_AGENT_MODEL_PROVIDER": "openai"}, @@ -935,6 +978,9 @@ def test_manager_authored_render_has_no_provider_credential_surface(self) -> Non "WORKSTREAM_PROJECT_AGENT_MODEL_API=chat_completions": ( "WORKSTREAM_PROJECT_AGENT_MODEL_API=none" ), + "WORKSTREAM_MODEL_PROXY_ENABLED=true": ( + "WORKSTREAM_MODEL_PROXY_ENABLED=false" + ), } for old, new in replacements.items(): manager_text = manager_text.replace(old, new) @@ -942,7 +988,10 @@ def test_manager_authored_render_has_no_provider_credential_surface(self) -> Non "\n".join( line for line in manager_text.splitlines() - if not line.startswith("WORKSTREAM_PROJECT_AGENT_API_KEY_FILE=") + if not line.startswith("WORKSTREAM_MODEL_PROXY_OPENAI_API_KEY_FILE=") + and not line.startswith( + "WORKSTREAM_MODEL_PROXY_OPENROUTER_API_KEY_FILE=" + ) ) + "\n" ) @@ -954,22 +1003,88 @@ def test_manager_authored_render_has_no_provider_credential_surface(self) -> Non rendered = preflight.render_compose(manager_path, manager) preflight.validate_compose(rendered, manager) - self.assertNotIn("project_agent_api_key", rendered.get("secrets", {})) + self.assertNotIn("model_proxy_openai_api_key", rendered.get("secrets", {})) + self.assertNotIn("model_proxy_openrouter_api_key", rendered.get("secrets", {})) worker = rendered["services"]["worker"] - self.assertNotIn("WORKSTREAM_PROJECT_AGENT_API_KEY_FILE", worker["environment"]) + for key in preflight.MODEL_PROXY_SECRET_FILE_KEYS: + self.assertNotIn(key, worker["environment"]) self.assertNotIn( - "project_agent_api_key", + "model_proxy_openai_api_key", + {item["source"] for item in worker["secrets"]}, + ) + self.assertNotIn( + "model_proxy_openrouter_api_key", {item["source"] for item in worker["secrets"]}, ) selected = manager | { - "WORKSTREAM_PROJECT_AGENT_API_KEY_FILE": str(self.root / "provider") + "WORKSTREAM_MODEL_PROXY_OPENAI_API_KEY_FILE": str(self.root / "provider") } - with self.assertRaisesRegex( - preflight.PreflightError, "cannot select a provider credential" - ): + with self.assertRaisesRegex(preflight.PreflightError, "disabled model proxy"): preflight.validate_environment(selected) + def test_manager_setup_can_enable_post_proxy_with_both_provider_credentials( + self, + ) -> None: + manager_text = self.environment_path.read_text(encoding="utf-8") + for old, new in { + "WORKSTREAM_PROJECT_AGENT_RUNTIME=openai_agents_sdk": ( + "WORKSTREAM_PROJECT_AGENT_RUNTIME=manager_authored" + ), + "WORKSTREAM_PROJECT_AGENT_MODEL_PROVIDER=openrouter": ( + "WORKSTREAM_PROJECT_AGENT_MODEL_PROVIDER=none" + ), + "WORKSTREAM_PROJECT_AGENT_MODEL=openai/gpt-5.6-terra": ( + "WORKSTREAM_PROJECT_AGENT_MODEL=none" + ), + "WORKSTREAM_PROJECT_AGENT_MODEL_API=chat_completions": ( + "WORKSTREAM_PROJECT_AGENT_MODEL_API=none" + ), + }.items(): + manager_text = manager_text.replace(old, new) + manager_path = self.root / "manager-with-post-proxy.env" + manager_path.write_text(manager_text, encoding="utf-8") + manager = preflight.parse_environment(manager_path) + + preflight.validate_environment(manager) + rendered = preflight.render_compose(manager_path, manager) + preflight.validate_compose(rendered, manager) + + self.assertIn("model-proxy", rendered["services"]) + self.assertEqual( + {item["source"] for item in rendered["services"]["model-proxy"]["secrets"]}, + { + "database_url", + "model_proxy_openai_api_key", + "model_proxy_openrouter_api_key", + }, + ) + worker_environment = rendered["services"]["worker"]["environment"] + for key in preflight.MODEL_PROXY_SECRET_FILE_KEYS: + self.assertNotIn(key, worker_environment) + + def test_openrouter_setup_keeps_independent_openai_post_credential(self) -> None: + self.assertEqual( + self.environment["WORKSTREAM_PROJECT_AGENT_MODEL_PROVIDER"], "openrouter" + ) + rendered = preflight.render_compose(self.environment_path, self.environment) + preflight.validate_compose(rendered, self.environment) + + proxy = rendered["services"]["model-proxy"] + self.assertEqual( + { + item["source"] + for item in proxy["secrets"] + if item["source"] != "database_url" + }, + { + "model_proxy_openai_api_key", + "model_proxy_openrouter_api_key", + }, + ) + for key in preflight.MODEL_PROXY_SECRET_FILE_KEYS: + self.assertNotIn(key, rendered["services"]["worker"]["environment"]) + def test_checker_config_rejects_non_hosted_or_substituted_identity(self) -> None: cases = { "runc": ("runtime", "runc"), @@ -989,6 +1104,66 @@ def test_checker_config_rejects_non_hosted_or_substituted_identity(self) -> None with self.assertRaises(preflight.PreflightError): preflight.validate_checker_config(changed, self.environment) + def test_model_runtime_requires_usable_host_socket(self) -> None: + config = self.checker | {"cache": []} + for arguments in ([], ["--host-uds=none"], ["--host-uds=open", "--host-uds=none"]): + with self.subTest(arguments=arguments), patch.object( + preflight, "_run_json", return_value={"runsc": {"runtimeArgs": arguments}} + ): + with self.assertRaisesRegex(preflight.PreflightError, "host-uds"): + preflight.validate_docker_runtime(config, self.environment) + with patch.object( + preflight, "_run_json", + return_value={"runsc": {"runtimeArgs": ["--host-uds=open"]}}, + ): + preflight.validate_docker_runtime(config, self.environment) + with patch.object( + preflight, "_run_json", return_value={"runsc": {"runtimeArgs": []}} + ): + preflight.validate_docker_runtime( + config, self.environment | {"WORKSTREAM_MODEL_PROXY_ENABLED": "false"} + ) + + def test_checker_model_paths_require_enabled_exact_proxy_topology(self) -> None: + for key in ("model_proxy_socket", "model_relay_root"): + for replacement in (None, "/tmp/substituted"): + with self.subTest(key=key, replacement=replacement): + changed = copy.deepcopy(self.checker) + changed[key] = replacement + with self.assertRaises(preflight.PreflightError): + preflight.validate_checker_config(changed, self.environment) + disabled = self.environment | {"WORKSTREAM_MODEL_PROXY_ENABLED": "false"} + with self.assertRaises(preflight.PreflightError): + preflight.validate_checker_config(self.checker, disabled) + ordinary = copy.deepcopy(self.checker) + del ordinary["model_proxy_socket"] + del ordinary["model_relay_root"] + preflight.validate_checker_config(ordinary, disabled) + + def test_rendered_checker_model_relay_cannot_lose_custody(self) -> None: + rendered = preflight.render_compose(self.environment_path, self.environment) + for target, read_only in ( + (str(preflight.MODEL_SOCKET_ROOT), False), + (str(preflight.MODEL_RELAY_ROOT), True), + ): + for change in ("remove", "mode", "source"): + with self.subTest(target=target, change=change): + changed = copy.deepcopy(rendered) + mounts = changed["services"]["checker-service"]["volumes"] + selected = next(m for m in mounts if m["target"] == target) + if change == "remove": + mounts.remove(selected) + elif change == "mode": + selected["read_only"] = read_only + else: + selected["source"] = "/tmp/substituted" + with self.assertRaises(preflight.PreflightError): + preflight.validate_compose(changed, self.environment) + changed = copy.deepcopy(rendered) + del changed["services"]["checker-service"]["depends_on"]["model-proxy"] + with self.assertRaises(preflight.PreflightError): + preflight.validate_compose(changed, self.environment) + def test_rendered_topology_rejects_privilege_and_path_drift(self) -> None: rendered = preflight.render_compose(self.environment_path, self.environment) cases = [] @@ -1045,8 +1220,8 @@ def test_rendered_topology_rejects_privilege_and_path_drift(self) -> None: cases.append(("missing role secret selector", missing_role_secret)) unrelated_role_secret = copy.deepcopy(rendered) unrelated_role_secret["services"]["beat"]["environment"][ - "WORKSTREAM_PROJECT_AGENT_API_KEY_FILE" - ] = "/run/secrets/project_agent_api_key" + "WORKSTREAM_MODEL_PROXY_OPENAI_API_KEY_FILE" + ] = "/run/secrets/model_proxy_openai_api_key" cases.append(("unrelated role secret selector", unrelated_role_secret)) unrelated_role_mount = copy.deepcopy(rendered) unrelated_role_mount["services"]["beat"]["secrets"].append( @@ -1174,8 +1349,8 @@ def test_rendered_topology_rejects_privilege_and_path_drift(self) -> None: cases.append(("guide parser socket exposed to proof", proof_parser_socket)) proof_provider_selector = copy.deepcopy(rendered) proof_provider_selector["services"]["aws-proof-coordinate"]["environment"][ - "WORKSTREAM_PROJECT_AGENT_API_KEY_FILE" - ] = "/run/secrets/project_agent_api_key" + "WORKSTREAM_MODEL_PROXY_OPENAI_API_KEY_FILE" + ] = "/run/secrets/model_proxy_openai_api_key" cases.append(("provider selector exposed to proof", proof_provider_selector)) worker_provider_value = copy.deepcopy(rendered) worker_provider_value["services"]["worker"]["environment"][ @@ -1248,9 +1423,13 @@ def test_private_host_inputs_check_exact_socket_group_without_reading_secrets( ) -> None: scratch = self.root / "scratch" socket_root = self.root / "socket" + model_socket_root = self.root / "model-socket" + model_relay_root = self.root / "model-relays" guide_socket_root = self.root / "guide-socket" scratch.mkdir(mode=0o700) socket_root.mkdir(mode=0o700) + model_socket_root.mkdir(mode=0o700) + model_relay_root.mkdir(mode=0o700) guide_socket_root.mkdir(mode=0o750) host_uid = os.getuid() host_gid = os.getgid() @@ -1278,6 +1457,13 @@ def test_private_host_inputs_check_exact_socket_group_without_reading_secrets( with ( patch.object(preflight, "SCRATCH_ROOT", scratch), patch.object(preflight, "SOCKET_ROOT", socket_root), + patch.object(preflight, "MODEL_SOCKET_ROOT", model_socket_root), + patch.object(preflight, "MODEL_RELAY_ROOT", model_relay_root), + patch.object( + preflight, + "MODEL_SOCKET_PATH", + model_socket_root / "model.sock", + ), patch.object(preflight, "GUIDE_SOCKET_ROOT", guide_socket_root), patch.object( preflight, @@ -1289,6 +1475,10 @@ def test_private_host_inputs_check_exact_socket_group_without_reading_secrets( patch.object(preflight, "DOCKER_SOCKET", docker_socket), ): preflight.validate_private_host_inputs(environment) + model_relay_root.chmod(0o755) + with self.assertRaisesRegex(preflight.PreflightError, "wrong mode"): + preflight.validate_private_host_inputs(environment) + model_relay_root.chmod(0o700) guide_listener = socket.socket(socket.AF_UNIX) guide_listener.bind(str(guide_socket_root / "guide.sock")) (guide_socket_root / "guide.sock").chmod(0o660) @@ -1332,12 +1522,19 @@ def test_private_host_inputs_check_exact_socket_group_without_reading_secrets( scratch_secret.chmod(0o400) scratch_alias = self.root / "scratch-alias" scratch_alias.symlink_to(scratch, target_is_directory=True) - environment["WORKSTREAM_PROJECT_AGENT_API_KEY_FILE"] = str( + environment["WORKSTREAM_MODEL_PROXY_OPENROUTER_API_KEY_FILE"] = str( scratch_alias / scratch_secret.name ) with ( patch.object(preflight, "SCRATCH_ROOT", scratch), patch.object(preflight, "SOCKET_ROOT", socket_root), + patch.object(preflight, "MODEL_SOCKET_ROOT", model_socket_root), + patch.object(preflight, "MODEL_RELAY_ROOT", model_relay_root), + patch.object( + preflight, + "MODEL_SOCKET_PATH", + model_socket_root / "model.sock", + ), patch.object(preflight, "GUIDE_SOCKET_ROOT", guide_socket_root), patch.object( preflight, @@ -1352,7 +1549,9 @@ def test_private_host_inputs_check_exact_socket_group_without_reading_secrets( ), ): preflight.validate_private_host_inputs(environment) - environment["WORKSTREAM_PROJECT_AGENT_API_KEY_FILE"] = str(secret_paths[-1]) + environment["WORKSTREAM_MODEL_PROXY_OPENROUTER_API_KEY_FILE"] = str( + secret_paths[-1] + ) socket_identity = socket_root / "negative-key.pem" socket_identity.write_text("private", encoding="utf-8") socket_identity.chmod(0o400) @@ -1364,6 +1563,13 @@ def test_private_host_inputs_check_exact_socket_group_without_reading_secrets( with ( patch.object(preflight, "SCRATCH_ROOT", scratch), patch.object(preflight, "SOCKET_ROOT", socket_root), + patch.object(preflight, "MODEL_SOCKET_ROOT", model_socket_root), + patch.object(preflight, "MODEL_RELAY_ROOT", model_relay_root), + patch.object( + preflight, + "MODEL_SOCKET_PATH", + model_socket_root / "model.sock", + ), patch.object(preflight, "GUIDE_SOCKET_ROOT", guide_socket_root), patch.object( preflight, @@ -1389,6 +1595,13 @@ def test_private_host_inputs_check_exact_socket_group_without_reading_secrets( with ( patch.object(preflight, "SCRATCH_ROOT", scratch), patch.object(preflight, "SOCKET_ROOT", socket_root), + patch.object(preflight, "MODEL_SOCKET_ROOT", model_socket_root), + patch.object(preflight, "MODEL_RELAY_ROOT", model_relay_root), + patch.object( + preflight, + "MODEL_SOCKET_PATH", + model_socket_root / "model.sock", + ), patch.object(preflight, "GUIDE_SOCKET_ROOT", guide_socket_root), patch.object( preflight, @@ -1408,6 +1621,13 @@ def test_private_host_inputs_check_exact_socket_group_without_reading_secrets( with ( patch.object(preflight, "SCRATCH_ROOT", scratch), patch.object(preflight, "SOCKET_ROOT", socket_root), + patch.object(preflight, "MODEL_SOCKET_ROOT", model_socket_root), + patch.object(preflight, "MODEL_RELAY_ROOT", model_relay_root), + patch.object( + preflight, + "MODEL_SOCKET_PATH", + model_socket_root / "model.sock", + ), patch.object(preflight, "GUIDE_SOCKET_ROOT", guide_socket_root), patch.object( preflight, @@ -1433,6 +1653,13 @@ def test_private_host_inputs_check_exact_socket_group_without_reading_secrets( with ( patch.object(preflight, "SCRATCH_ROOT", scratch), patch.object(preflight, "SOCKET_ROOT", socket_root), + patch.object(preflight, "MODEL_SOCKET_ROOT", model_socket_root), + patch.object(preflight, "MODEL_RELAY_ROOT", model_relay_root), + patch.object( + preflight, + "MODEL_SOCKET_PATH", + model_socket_root / "model.sock", + ), patch.object(preflight, "GUIDE_SOCKET_ROOT", guide_socket_root), patch.object( preflight, @@ -1459,7 +1686,8 @@ class ReleaseSecretLoaderTests(unittest.TestCase): "WORKSTREAM_PAGINATION_CURSOR_HMAC_SECRET_FILE": ( "WORKSTREAM_PAGINATION_CURSOR_HMAC_SECRET" ), - "WORKSTREAM_PROJECT_AGENT_API_KEY_FILE": "OPENROUTER_API_KEY", + "WORKSTREAM_MODEL_PROXY_OPENAI_API_KEY_FILE": "OPENAI_API_KEY", + "WORKSTREAM_MODEL_PROXY_OPENROUTER_API_KEY_FILE": "OPENROUTER_API_KEY", } ROLE_FILES = { "api": ( @@ -1471,7 +1699,11 @@ class ReleaseSecretLoaderTests(unittest.TestCase): "worker": ( "WORKSTREAM_DATABASE_URL_FILE", "WORKSTREAM_CELERY_BROKER_URL_FILE", - "WORKSTREAM_PROJECT_AGENT_API_KEY_FILE", + ), + "model-proxy": ( + "WORKSTREAM_DATABASE_URL_FILE", + "WORKSTREAM_MODEL_PROXY_OPENAI_API_KEY_FILE", + "WORKSTREAM_MODEL_PROXY_OPENROUTER_API_KEY_FILE", ), "beat": ( "WORKSTREAM_DATABASE_URL_FILE", @@ -1500,7 +1732,10 @@ def _run_loader( "WORKSTREAM_CELERY_BROKER_URL_FILE": "broker", "WORKSTREAM_API_RATE_LIMIT_KEY_SECRET_FILE": "rate", "WORKSTREAM_PAGINATION_CURSOR_HMAC_SECRET_FILE": "cursor", - "WORKSTREAM_PROJECT_AGENT_API_KEY_FILE": "project-agent", + "WORKSTREAM_MODEL_PROXY_OPENAI_API_KEY_FILE": "model-proxy-openai", + "WORKSTREAM_MODEL_PROXY_OPENROUTER_API_KEY_FILE": ( + "model-proxy-openrouter" + ), } for variable, name in file_names.items(): path = root / name @@ -1509,8 +1744,6 @@ def _run_loader( path.write_text(content, encoding="utf-8") path.chmod(0o400) environment[variable] = str(path) - if runtime == "manager_authored": - environment.pop("WORKSTREAM_PROJECT_AGENT_API_KEY_FILE", None) for secret_name in { *self.FILE_TO_SECRET.values(), "OPENAI_API_KEY", @@ -1552,11 +1785,6 @@ def _run_loader( ) environment["EXPECT_PARSER_SELECTOR_SCRUB"] = "1" if role == "parser" else "0" expected = {self.FILE_TO_SECRET[key] for key in self.ROLE_FILES.get(role, ())} - if role == "worker" and runtime == "manager_authored": - expected.remove("OPENROUTER_API_KEY") - if role == "worker" and provider == "openai": - expected.remove("OPENROUTER_API_KEY") - expected.add("OPENAI_API_KEY") environment["EXPECTED_SECRET_NAMES"] = ",".join(sorted(expected)) environment["EXPECTED_SECRET_SHA256"] = hashlib.sha256( content.encode() @@ -1644,11 +1872,16 @@ def test_loader_loads_only_role_secrets_and_rejects_missing_or_unknown_role( self.assertIn( "required secret file is unavailable", missing.stderr ) - openai = self._run_loader(root, provider="openai") + openai = self._run_loader(root, role="model-proxy", provider="openai") self.assertEqual(openai.returncode, 0, openai.stderr) - unsupported = self._run_loader(root, provider="unknown") - self.assertNotEqual(unsupported.returncode, 0) - self.assertIn("model provider", unsupported.stderr) + setup_provider_independent = self._run_loader( + root, role="model-proxy", provider="unknown" + ) + self.assertEqual( + setup_provider_independent.returncode, + 0, + setup_provider_independent.stderr, + ) unknown = self._run_loader(root, role="unknown") self.assertNotEqual(unknown.returncode, 0) self.assertEqual(unknown.stdout, "") @@ -1663,6 +1896,21 @@ def test_loader_binds_runtime_to_sdk_marker_before_provider_secret(self) -> None sdk_environment="omnicore", ) self.assertEqual(omni.returncode, 0, omni.stderr) + omni_proxy = self._run_loader( + root, + role="model-proxy", + runtime="omnicore_agent", + sdk_environment="agents", + ) + self.assertEqual(omni_proxy.returncode, 0, omni_proxy.stderr) + manager_proxy = self._run_loader( + root, + role="model-proxy", + runtime="manager_authored", + provider="none", + sdk_environment="agents", + ) + self.assertEqual(manager_proxy.returncode, 0, manager_proxy.stderr) for sdk_environment in ("agents", "omnicore"): with self.subTest( runtime="manager_authored", sdk_environment=sdk_environment diff --git a/scripts/test_local_guide_runtime.py b/scripts/test_local_guide_runtime.py index ccd1996d8..3df91a846 100644 --- a/scripts/test_local_guide_runtime.py +++ b/scripts/test_local_guide_runtime.py @@ -1,14 +1,14 @@ """Local guide parsing must retain the same isolation boundary as deployment.""" -from contextlib import chdir import json import os -from pathlib import Path import subprocess import sys import tempfile -from types import ModuleType import unittest +from contextlib import chdir +from pathlib import Path +from types import ModuleType from unittest.mock import patch import yaml @@ -81,15 +81,23 @@ def test_parser_has_no_secrets_network_or_source_mount(self): self.assertIn("size=64m", parser["tmpfs"][0]) self.assertIn("--healthcheck", parser["healthcheck"]["test"]) - def test_only_worker_receives_provider_keys_and_readonly_parser_socket(self): - for name in ("backend", "beat", "guide-document-parser"): + def test_only_model_proxy_receives_provider_keys(self): + for name in ("backend", "worker", "beat", "guide-document-parser"): service = self.services[name] self.assertFalse( {"OPENAI_API_KEY", "OPENROUTER_API_KEY"} & set(service.get("environment", {})), name, ) - if name != "guide-document-parser": + if name in {"backend", "beat"}: self.assertFalse(any("guide_parser_socket:" in mount for mount in service["volumes"])) + proxy = self.services["model-proxy"] + self.assertEqual( + {"OPENAI_API_KEY", "OPENROUTER_API_KEY"} + & set(proxy.get("environment", {})), + {"OPENAI_API_KEY", "OPENROUTER_API_KEY"}, + ) + + def test_worker_receives_readonly_parser_socket(self): worker = self.services["worker"] self.assertIn("10001", worker["group_add"]) self.assertIn("guide_parser_socket:/var/lib/workstream/guide-runtime:ro", worker["volumes"])